add ProductStockRecordDetail.vue

This commit is contained in:
Timi
2026-09-03 22:01:24 +08:00
parent 7a47e3a9f8
commit c593e404f6
7 changed files with 401 additions and 67 deletions
+24 -2
View File
@@ -1,5 +1,17 @@
import { axios } from "timi-web-api"; import { axios } from "timi-web-api";
import type { Product, ProductCategory, ProductCategoryPage, ProductPage, ProductPageResult, ProductStock, ProductStockChangeRequest, ProductStockPage, StockRecord, StockRecordPage } from "@/types/Product"; import type {
Product,
ProductCategory,
ProductCategoryPage,
ProductPage,
ProductPageResult,
ProductStock,
ProductStockChangeRequest,
ProductStockManualRevokeRequest,
ProductStockPage,
StockRecord,
StockRecordPage
} from "@/types/Product";
import { getCurrentStoreId, withCurrentStoreId } from "@/utils/store"; import { getCurrentStoreId, withCurrentStoreId } from "@/utils/store";
const PRODUCT_URI = "/gao/product"; const PRODUCT_URI = "/gao/product";
@@ -71,6 +83,10 @@ async function manualOut(request: ProductStockChangeRequest): Promise<ProductSto
return await axios.post(`${STOCK_URI}/manual-out`, withCurrentStoreId(request)); return await axios.post(`${STOCK_URI}/manual-out`, withCurrentStoreId(request));
} }
async function manualRevoke(request: ProductStockManualRevokeRequest): Promise<ProductStock> {
return await axios.post(`${STOCK_URI}/manual-revoke`, request);
}
async function stocktakeGain(request: ProductStockChangeRequest): Promise<ProductStock> { async function stocktakeGain(request: ProductStockChangeRequest): Promise<ProductStock> {
return await axios.post(`${STOCK_URI}/stocktake-gain`, withCurrentStoreId(request)); return await axios.post(`${STOCK_URI}/stocktake-gain`, withCurrentStoreId(request));
} }
@@ -83,6 +99,10 @@ async function recordList(page: StockRecordPage): Promise<ProductPageResult<Stoc
return await axios.post(`${STOCK_URI}/record/list`, withCurrentStoreId(page)); return await axios.post(`${STOCK_URI}/record/list`, withCurrentStoreId(page));
} }
async function recordDetail(id: string): Promise<StockRecord> {
return await axios.post(`${STOCK_URI}/record/detail`, undefined, { params: { id } });
}
export default { export default {
categoryList, categoryList,
categoryCreate, categoryCreate,
@@ -100,7 +120,9 @@ export default {
initialIn, initialIn,
manualIn, manualIn,
manualOut, manualOut,
manualRevoke,
stocktakeGain, stocktakeGain,
stocktakeLoss, stocktakeLoss,
recordList recordList,
recordDetail
}; };
@@ -0,0 +1,307 @@
<template>
<div v-can="permissionCodes.productStock.read" class="product-stock-record-detail">
<t-loading v-if="isLoading" class="loading" size="32px" />
<template v-else-if="record && product">
<product-profile :product="product" v-entrance-transition>
<template #tags>
<t-tag
size="small"
variant="light-outline"
:theme="getOperationTheme(record.onHandDelta)"
:content="getStockOperationText(record.operationType)"
/>
</template>
</product-profile>
<t-cell-group title="库存流水" theme="card" v-entrance-transition="70">
<t-cell title="操作类型" :note="getStockOperationText(record.operationType)" />
<t-cell title="业务数量" :note="getQuantityText(record)" />
<t-cell title="基础单位数量" :note="getProductQuantityText(record.baseQuantity)" />
<t-cell title="实际库存变动">
<template #note>
<span :class="getDeltaClass(record.onHandDelta)" v-text="getDeltaText(record.onHandDelta)" />
</template>
</t-cell>
<t-cell title="预占库存变动">
<template #note>
<span :class="getDeltaClass(record.reservedDelta)" v-text="getDeltaText(record.reservedDelta)" />
</template>
</t-cell>
<t-cell title="实际库存">
<template #note>
<span class="balance">
<span v-text="getProductQuantityText(record.onHandBefore)" />
<t-icon name="arrow-right" />
<span :class="getDeltaClass(record.onHandDelta)" v-text="getProductQuantityText(record.onHandAfter)" />
<span class="unit" v-text="getProductBaseUnitName(product) || '基础单位'" />
</span>
</template>
</t-cell>
<t-cell title="预占库存">
<template #note>
<span class="balance">
<span v-text="getProductQuantityText(record.reservedBefore)" />
<t-icon name="arrow-right" />
<span :class="getDeltaClass(record.reservedDelta)" v-text="getProductQuantityText(record.reservedAfter)" />
<span class="unit" v-text="getProductBaseUnitName(product) || '基础单位'" />
</span>
</template>
</t-cell>
<t-cell title="业务类型" :note="getBizTypeText(record.bizType)" />
<t-cell title="发生时间">
<template #note>
<pass-time-label :timestamp="record.occurredAt" underline />
</template>
</t-cell>
<t-cell title="操作人" :note="record.operator?.name || record.operatorUserId || '未知操作人'" />
<t-cell v-if="record.sourceRecordId" title="原库存流水" :note="record.sourceRecordId" arrow @click="openRelatedRecord(record.sourceRecordId)" />
<t-cell v-if="record.revokeRecordId" title="冲正流水" :note="record.revokeRecordId" arrow @click="openRelatedRecord(record.revokeRecordId)" />
<t-cell v-if="record.remark" title="备注" :note="record.remark" />
<t-cell>
<template #title>
<span></span>
</template>
<template #description>
<photo-scroller
:model-value="photoURLList"
:photo-items="photoItemList"
:show-add="false"
:show-remove="false"
:show-viewer-actions="false"
title="操作照片"
/>
</template>
</t-cell>
</t-cell-group>
<t-button
v-if="canManualRevoke"
class="cell-button"
theme="danger"
@click="openManualRevoke"
v-entrance-transition="140"
content="手工流水冲正"
/>
</template>
<t-empty v-else :description="isLoading ? '正在加载库存流水' : '库存流水不存在'" />
<bottom-popup
v-model="revokeVisible"
title="手工流水冲正"
left-button-text="取消"
:right-button-text="isOperating ? '提交中' : '提交'"
@left-button-click="revokeVisible = false"
@right-button-click="submitManualRevoke"
>
<div class="revoke-popup">
<p class="tip">将新增一条反向库存流水,原流水不会被修改或删除。</p>
<t-form>
<t-form-item label="冲正备注" required-mark>
<t-textarea v-model="revokeRemark" borderless autosize maxlength="500" placeholder="请输入冲正备注" />
</t-form-item>
</t-form>
<package-photo-field v-model="revokePhotoItems" required title="冲正凭证照片" />
</div>
</bottom-popup>
</div>
</template>
<script setup lang="ts">
import { BottomPopup } from "timi-tdesign-mobile";
import { PassTimeLabel } from "timi-web";
import { useUserStore, type Attachment } from "timi-web-api";
import ProductAPI from "@/api/ProductAPI";
import PhotoScroller from "@/components/PhotoScroller.vue";
import ProductProfile from "@/pages/product/component/ProductProfile.vue";
import { useNavBarStore } from "@/store/navBarStore";
import type { PhotoItem } from "@/types/Photo";
import type { Product, StockBizType, StockRecord } from "@/types/Product";
import { getAttachmentReadURL } from "@/utils/attachment";
import { getErrorMessage } from "@/utils/error";
import { createProductRequestNo, getProductBaseUnitName, getProductQuantityText, getStockOperationText } from "@/utils/product";
import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission";
defineOptions({ name: "ProductStockRecordDetail" });
const route = useRoute();
const navBarStore = useNavBarStore();
const router = useRouter();
const userStore = useUserStore();
const record = ref<StockRecord>();
const product = ref<Product>();
const isLoading = ref(false);
const isOperating = ref(false);
const revokeVisible = ref(false);
const revokeRemark = ref("");
const revokeRequestNo = ref("");
const revokePhotoItems = ref<PhotoItem[]>([]);
const canManualRevoke = computed(() => {
const operationType = record.value?.operationType;
return (operationType === "MANUAL_IN" || operationType === "MANUAL_OUT")
&& !record.value?.sourceRecordId
&& !record.value?.revokeRecordId
&& userStore.hasPermissions(permissionCodes.productStock.manualRevoke);
});
const photoItemList = computed<PhotoItem[]>(() => (record.value?.attachmentList || [])
.filter(item => !!item.id)
.map(item => ({
attachment: item,
attachType: item.attachType || "PHOTO",
previewUrl: getAttachmentReadURL(item.id || ""),
url: getAttachmentReadURL(item.id || "", 128)
})));
const photoURLList = computed(() => photoItemList.value.map(item => item.url));
function getOperationTheme(value?: number | string): "success" | "danger" | "default" {
const amount = Number(value || 0);
return 0 < amount ? "success" : amount < 0 ? "danger" : "default";
}
function getDeltaText(value?: number | string): string {
const amount = Number(value || 0);
const text = getProductQuantityText(String(value || "0").replace(/^-/, ""));
return `${0 < amount ? "+" : amount < 0 ? "-" : ""}${text}`;
}
function getDeltaClass(value?: number | string): string {
const amount = Number(value || 0);
return 0 < amount ? "positive" : amount < 0 ? "negative" : "gray";
}
function getQuantityText(item: StockRecord): string {
return `${getProductQuantityText(item.quantity)} ${item.unitSnapshot || "业务单位"}`;
}
function getBizTypeText(type?: StockBizType): string {
return ({ PRODUCT_STOCK: "产品库存", ORDER_FULFILLMENT: "订单履约" } as Record<string, string>)[type || ""] || "未知";
}
function buildAttachments(): Partial<Attachment>[] {
return revokePhotoItems.value.flatMap(item => item.tempFileId ? [{ tempFileId: item.tempFileId, attachType: "PHOTO" }] : []);
}
function openManualRevoke(): void {
if (!canManualRevoke.value) {
return;
}
revokeRemark.value = "";
revokeRequestNo.value = "";
revokePhotoItems.value = [];
revokeVisible.value = true;
}
async function openRelatedRecord(id?: string): Promise<void> {
if (id) {
await router.push({ name: "ProductStockRecordDetail", params: { id } });
}
}
async function submitManualRevoke(): Promise<void> {
if (!canManualRevoke.value || !record.value?.id || isOperating.value) {
return;
}
if (!revokeRemark.value.trim()) {
Message.warning("冲正备注不能为空");
return;
}
if (!revokePhotoItems.value.some(item => !!item.tempFileId)) {
Message.warning("库存流水冲正必须上传至少一张照片");
return;
}
isOperating.value = true;
try {
if (!revokeRequestNo.value) {
revokeRequestNo.value = createProductRequestNo();
}
await ProductAPI.manualRevoke({
requestNo: revokeRequestNo.value,
sourceRecordId: record.value.id,
remark: revokeRemark.value.trim(),
attachmentList: buildAttachments()
});
revokeVisible.value = false;
Message.success("手工库存流水已冲正");
await loadData();
} catch (error) {
Message.error(getErrorMessage(error, "库存流水冲正失败,重试会复用同一请求号"));
} finally {
isOperating.value = false;
}
}
async function loadData(): Promise<void> {
const id = String(route.params.id || "");
if (!id) {
return;
}
isLoading.value = true;
try {
const result = await ProductAPI.recordDetail(id);
record.value = result;
if (result.productId) {
product.value = await ProductAPI.detail(result.productId);
}
} catch (error) {
Message.error(getErrorMessage(error, "库存流水加载失败"));
} finally {
isLoading.value = false;
}
}
onMounted(async () => {
navBarStore.clearRight();
await loadData();
});
watch(() => String(route.params.id || ""), async (id, previousId) => {
if (id && id !== previousId) {
await loadData();
}
});
onUnmounted(() => navBarStore.clearRight());
</script>
<style scoped lang="less">
.product-stock-record-detail {
gap: 1rem;
display: flex;
padding: calc(var(--app-nav-height) + 1rem) 0 2rem;
flex-direction: column;
.loading {
margin: 3rem auto;
}
.positive {
color: var(--td-success-color);
}
.negative {
color: var(--td-error-color);
}
.balance {
gap: .25rem;
display: inline-flex;
align-items: center;
.arrow,
.unit {
color: var(--td-text-color-secondary);
}
}
.actions {
padding: 0 1rem;
}
}
.revoke-popup {
padding: .5rem 1rem calc(1rem + env(safe-area-inset-bottom, 0));
.tip {
margin: 0 0 .75rem;
color: var(--td-text-color-secondary);
font-size: .875rem;
}
}
</style>
+31 -43
View File
@@ -10,10 +10,6 @@
placeholder="搜索产品名称或编码" placeholder="搜索产品名称或编码"
/> />
</div> </div>
<t-cell-group class="summary" theme="card">
<t-cell title="查询范围" :note="scopeText" />
<t-cell title="记录数量" :note="`共 ${total} 条`" />
</t-cell-group>
<t-pull-down-refresh <t-pull-down-refresh
v-model="isRefreshing" v-model="isRefreshing"
:refresh-timeout="30000" :refresh-timeout="30000"
@@ -25,7 +21,10 @@
v-for="(record, index) in recordList" v-for="(record, index) in recordList"
:key="record.id || `${record.occurredAt}-${record.requestNo}-${index}`" :key="record.id || `${record.occurredAt}-${record.requestNo}-${index}`"
class="record" class="record"
:arrow="!!record.id"
:hover="!!record.id"
v-entrance-transition="index * 30" v-entrance-transition="index * 30"
@click="toDetail(record)"
> >
<template #leftIcon> <template #leftIcon>
<span class="icon" :class="getDeltaClass(record.onHandDelta)"> <span class="icon" :class="getDeltaClass(record.onHandDelta)">
@@ -35,29 +34,31 @@
<template #title> <template #title>
<div class="title"> <div class="title">
<strong v-text="getStockOperationText(record.operationType)" /> <strong v-text="getStockOperationText(record.operationType)" />
<span :class="getDeltaClass(record.onHandDelta)" v-text="getDeltaText(record.onHandDelta)" />
</div> </div>
</template> </template>
<template #note> <template #note>
<span v-text="record.occurredAt ? Time.toDateTime(record.occurredAt) : '时间未知'" /> <span
class="delta"
:class="getDeltaClass(record.onHandDelta)"
v-text="getDeltaText(record.onHandDelta)"
/>
</template> </template>
<template #description> <template #description>
<div class="description"> <div class="description">
<span v-text="getQuantityText(record)" /> <span v-text="record.productNameSnapshot || '未命名产品'" />
<span v-text="getBalanceText(record)" /> <pass-time-label :timestamp="record.occurredAt" underline />
<span v-if="record.remark" class="gray" v-text="record.remark" />
</div> </div>
</template> </template>
</t-cell> </t-cell>
</t-cell-group> </t-cell-group>
<div v-else class="empty" v-text="emptyText" /> <div v-else class="center-tips" v-text="emptyText" />
<div v-if="recordList.length" class="center-tips" v-text="loadStateText" /> <div v-if="recordList.length" class="center-tips" v-text="loadStateText" />
</t-pull-down-refresh> </t-pull-down-refresh>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Time } from "timi-web"; import { PassTimeLabel } from "timi-web";
import ProductAPI from "@/api/ProductAPI"; import ProductAPI from "@/api/ProductAPI";
import { useNavBarStore } from "@/store/navBarStore"; import { useNavBarStore } from "@/store/navBarStore";
import type { StockOperationType, StockRecord } from "@/types/Product"; import type { StockOperationType, StockRecord } from "@/types/Product";
@@ -70,6 +71,7 @@ defineOptions({ name: "ProductStockRecordList" });
const pageSize = 15; const pageSize = 15;
const route = useRoute(); const route = useRoute();
const router = useRouter();
const navBarStore = useNavBarStore(); const navBarStore = useNavBarStore();
const recordList = ref<StockRecord[]>([]); const recordList = ref<StockRecord[]>([]);
const pageIndex = ref(0); const pageIndex = ref(0);
@@ -81,12 +83,6 @@ const productId = computed(() => String(route.query.productId || ""));
const keyword = ref(getQueryText("keyword")); const keyword = ref(getQueryText("keyword"));
let keywordTimer: number | undefined; let keywordTimer: number | undefined;
let requestToken = 0; let requestToken = 0;
const scopeText = computed(() => {
const scope = productId.value
? `指定产品 · ${recordList.value[0]?.productNameSnapshot || "当前产品"}`
: "全部产品";
return keyword.value.trim() ? `${scope} · 搜索:${keyword.value.trim()}` : scope;
});
const emptyText = computed(() => { const emptyText = computed(() => {
if (loading.value) { if (loading.value) {
return "正在加载库存流水"; return "正在加载库存流水";
@@ -102,12 +98,12 @@ function getQueryText(key: string): string {
function getOperationIcon(type?: StockOperationType): string { function getOperationIcon(type?: StockOperationType): string {
return ({ return ({
INITIAL_IN: "download", INITIAL_IN: "import",
MANUAL_IN: "add-circle", MANUAL_IN: "import",
MANUAL_OUT: "remove-circle", MANUAL_OUT: "export",
ORDER_RESERVE: "lock-on", ORDER_RESERVE: "lock-on",
ORDER_RELEASE: "unlock", ORDER_RELEASE: "lock-off",
ORDER_OUT: "remove-circle", ORDER_OUT: "task",
ORDER_REVOKE: "rollback", ORDER_REVOKE: "rollback",
STOCKTAKE_GAIN: "arrow-up", STOCKTAKE_GAIN: "arrow-up",
STOCKTAKE_LOSS: "arrow-down" STOCKTAKE_LOSS: "arrow-down"
@@ -116,8 +112,8 @@ function getOperationIcon(type?: StockOperationType): string {
function getDeltaText(value?: number | string): string { function getDeltaText(value?: number | string): string {
const amount = Number(value || 0); const amount = Number(value || 0);
const text = String(value || "0"); const text = String(value || "0").replace(/^-/, "");
return `${0 < amount ? "+" : ""}${amount < 0 ? getProductQuantityText(text.slice(1)) : getProductQuantityText(text)}`; return `${0 < amount ? "+" : amount < 0 ? "-" : ""}${getProductQuantityText(text)}`;
} }
function getDeltaClass(value?: number | string): string { function getDeltaClass(value?: number | string): string {
@@ -125,13 +121,10 @@ function getDeltaClass(value?: number | string): string {
return 0 < amount ? "positive" : amount < 0 ? "negative" : "gray"; return 0 < amount ? "positive" : amount < 0 ? "negative" : "gray";
} }
function getQuantityText(record: StockRecord): string { async function toDetail(record: StockRecord): Promise<void> {
return `${record.productNameSnapshot || "未命名产品"} · ${getProductQuantityText(record.quantity)} ${record.unitSnapshot || "业务单位"} · 基础单位 ${getProductQuantityText(record.baseQuantity)}`; if (record.id) {
} await router.push({ name: "ProductStockRecordDetail", params: { id: record.id } });
}
function getBalanceText(record: StockRecord): string {
const operator = record.operator?.name || record.operatorUserId || "未知操作人";
return `库存 ${getProductQuantityText(record.onHandAfter)} · 预占 ${getProductQuantityText(record.reservedAfter)} · ${operator}`;
} }
async function loadMore(force = false): Promise<void> { async function loadMore(force = false): Promise<void> {
@@ -230,23 +223,19 @@ onUnmounted(() => {
} }
} }
.summary {
margin: 0;
}
.list { .list {
margin: 0; margin: 0;
}
.record { .record {
.icon { .icon {
width: 2.25rem; width: 2.25rem;
height: 2.25rem; height: 2.25rem;
display: inline-flex; display: inline-flex;
background: var(--td-bg-color-secondarycontainer);
align-items: center; align-items: center;
border-radius: 50%; border-radius: 50%;
justify-content: center; justify-content: center;
background: var(--td-bg-color-secondarycontainer);
} }
.title { .title {
@@ -255,11 +244,16 @@ onUnmounted(() => {
align-items: center; align-items: center;
} }
.delta {
font-size: 1.25rem;
}
.description { .description {
gap: .125rem; gap: .125rem;
display: grid; display: grid;
} }
} }
}
.positive { .positive {
color: var(--td-success-color); color: var(--td-success-color);
@@ -268,11 +262,5 @@ onUnmounted(() => {
.negative { .negative {
color: var(--td-error-color); color: var(--td-error-color);
} }
.empty {
color: var(--td-text-color-secondary);
padding: 2rem 1rem;
text-align: center;
}
} }
</style> </style>
+7
View File
@@ -6,6 +6,7 @@ import ProductEdit from "@/pages/product/ProductEdit.vue";
import ProductList from "@/pages/product/ProductList.vue"; import ProductList from "@/pages/product/ProductList.vue";
import ProductStockDetail from "@/pages/product/ProductStockDetail.vue"; import ProductStockDetail from "@/pages/product/ProductStockDetail.vue";
import ProductStockList from "@/pages/product/ProductStockList.vue"; import ProductStockList from "@/pages/product/ProductStockList.vue";
import ProductStockRecordDetail from "@/pages/product/ProductStockRecordDetail.vue";
import ProductStockRecordList from "@/pages/product/ProductStockRecordList.vue"; import ProductStockRecordList from "@/pages/product/ProductStockRecordList.vue";
function pageMeta(title: string, depth = 3, background = "#F5F5F8"): RouteRecordRaw["meta"] { function pageMeta(title: string, depth = 3, background = "#F5F5F8"): RouteRecordRaw["meta"] {
@@ -69,6 +70,12 @@ const productRoutes: RouteRecordRaw[] = [
component: ProductStockRecordList, component: ProductStockRecordList,
meta: pageMeta("库存流水") meta: pageMeta("库存流水")
}, },
{
path: "/management/product-stock-records/:id",
name: "ProductStockRecordDetail",
component: ProductStockRecordDetail,
meta: pageMeta("库存流水详情", 4)
},
{ {
path: "/management/product-stocks/:productId", path: "/management/product-stocks/:productId",
name: "ProductStockDetail", name: "ProductStockDetail",
+8
View File
@@ -108,6 +108,7 @@ export type StockRecord = {
bizType?: StockBizType; bizType?: StockBizType;
bizId?: string; bizId?: string;
sourceRecordId?: string; sourceRecordId?: string;
revokeRecordId?: string;
requestNo?: string; requestNo?: string;
operatorUserId?: string; operatorUserId?: string;
occurredAt?: number; occurredAt?: number;
@@ -139,6 +140,13 @@ export type ProductStockChangeRequest = {
attachmentList: Partial<Attachment>[]; attachmentList: Partial<Attachment>[];
}; };
export type ProductStockManualRevokeRequest = {
requestNo: string;
sourceRecordId: string;
remark: string;
attachmentList: Partial<Attachment>[];
};
export type ProductPageResult<T> = PageResult<T> & { export type ProductPageResult<T> = PageResult<T> & {
pages?: number; pages?: number;
}; };
+2 -1
View File
@@ -57,7 +57,8 @@ export const permissionCodes = {
}, },
productStock: { productStock: {
create: "GAO:PRODUCT_STOCK:CREATE", create: "GAO:PRODUCT_STOCK:CREATE",
read: "GAO:PRODUCT_STOCK:READ" read: "GAO:PRODUCT_STOCK:READ",
manualRevoke: "GAO:PRODUCT_STOCK:MANUAL_REVOKE"
}, },
packageConfig: { packageConfig: {
create: "GAO:PACKAGE_CONFIG:CREATE", create: "GAO:PACKAGE_CONFIG:CREATE",
+1
View File
@@ -68,6 +68,7 @@ export function getStockOperationText(type?: StockOperationType): string {
INITIAL_IN: "期初入库", INITIAL_IN: "期初入库",
MANUAL_IN: "手工入库", MANUAL_IN: "手工入库",
MANUAL_OUT: "手工出库", MANUAL_OUT: "手工出库",
MANUAL_REVOKE: "手工流水冲正",
ORDER_RESERVE: "履约预占", ORDER_RESERVE: "履约预占",
ORDER_RELEASE: "履约释放预占", ORDER_RELEASE: "履约释放预占",
ORDER_OUT: "履约出库", ORDER_OUT: "履约出库",