add product-stock-preview for OrderFulfillmentDetail.vue

This commit is contained in:
Timi
2026-09-19 15:42:51 +08:00
parent e998b0c06e
commit 77642bcd94
2 changed files with 149 additions and 28 deletions
+124 -16
View File
@@ -31,19 +31,8 @@
v-for="item in fulfillment.itemList || []"
:key="item.id"
:title="item.productNameSnapshot || '未命名产品'"
:note="getFulfillmentQuantityText(item)"
>
<template #description>
<div class="item-description">
<t-tag v-if="isGiftedFulfillmentItem(item)" size="small" theme="success" content="赠送" />
<product-sku-snapshot-tags
:product-name="item.productNameSnapshot"
:snapshot="item.skuSpecSnapshot"
:fallback="item.specSummarySnapshot || item.skuNameSnapshot || '默认 SKU'"
:unit="item.unitSnapshot"
/>
<span v-text="getFulfillmentQuantityText(item)" />
</div>
</template>
<template #leftIcon>
<t-image
v-if="getProductPhotoURL(item)"
@@ -58,9 +47,29 @@
v-text="(item.productNameSnapshot || '产').slice(0, 1)"
/>
</template>
<template #description>
<div class="item-description">
<t-tag v-if="isGiftedFulfillmentItem(item)" size="small" theme="success" content="赠送" />
<product-sku-snapshot-tags
:product-name="item.productNameSnapshot"
:snapshot="item.skuSpecSnapshot"
:fallback="item.specSummarySnapshot || item.skuNameSnapshot || '默认 SKU'"
:unit="item.unitSnapshot"
/>
</div>
</template>
</t-cell>
<t-cell v-if="!fulfillment.itemList?.length" title="暂无履约明细" />
</t-cell-group>
<section
v-if="stockPreviewVisible && (stockPreviewLoading || stockPreviewList.length)"
class="stock-preview"
v-entrance-transition="105"
>
<div class="title">库存预览</div>
<t-loading v-if="stockPreviewLoading" size="1.5rem" text="加载中" />
<product-stock-preview v-else-if="stockPreviewList.length" :items="stockPreviewList" />
</section>
<section
v-if="(!isOrderCancelled && isWaitingStock && (canVerify || canCreate)) || (!isOrderCancelled && isPending && (canVerify || canCreate)) || (isVerified && canRevoke)"
class="actions"
@@ -140,15 +149,17 @@ import { BottomPopup } from "timi-tdesign-mobile";
import { PassTimeLabel } from "timi-web";
import { useUserStore } from "timi-web-api";
import FulfillmentAPI from "@/api/FulfillmentAPI";
import ProductAPI from "@/api/ProductAPI";
import SalesOrderAPI from "@/api/SalesOrderAPI";
import MediaSelector from "@/components/mediaSelector/MediaSelector.vue";
import ProductSkuSnapshotTags from "@/components/ProductSkuSnapshotTags.vue";
import ProductStockPreview from "@/components/ProductStockPreview.vue";
import { useNavBarStore } from "@/store/navBarStore";
import type { OrderFulfillment, OrderFulfillmentItem } from "@/types/Fulfillment";
import type { GaoSalesOrderItemType, SalesOrder } from "@/types/SalesOrder";
import type { PhotoItem } from "@/types/Photo";
import type { Attachment } from "timi-web-api";
import { createPackageRequestNo, getPackageQuantityText } from "@/utils/package";
import { addPackageQuantity, createPackageRequestNo, getPackageQuantityText } from "@/utils/package";
import { getAttachmentReadURL } from "@/utils/attachment";
import { getErrorMessage } from "@/utils/error";
import { getProductPhotoURL } from "@/utils/product";
@@ -156,6 +167,8 @@ import { getFulfillmentStatusText, getSalesOrderItemTypeText, getStatusTheme } f
import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission";
import { uploadPhotoList } from "@/utils/photo";
import type { ProductStockPreviewItem } from "@/utils/productStock";
import { buildProductStockPreview, mergeProductStockRequirements } from "@/utils/productStock";
type ReasonAction = "reject" | "cancel";
defineOptions({ name: "OrderFulfillmentDetail" });
@@ -175,6 +188,8 @@ const revokeReason = ref("");
const returnedToStore = ref(true);
const revokeRequestNo = ref("");
const photoItems = ref<PhotoItem[]>([]);
const stockPreviewList = ref<ProductStockPreviewItem[]>([]);
const stockPreviewLoading = ref(false);
const photoUrlList = computed(() => photoItems.value.map(item => item.url));
const fulfillmentPhotoItems = computed<PhotoItem[]>(() => (fulfillment.value?.attachmentList || [])
.filter(item => !!item.id)
@@ -192,19 +207,92 @@ const isOrderCancelled = computed(() => order.value?.status === "CANCELLED");
const canVerify = computed(() => userStore.hasPermissions(permissionCodes.orderFulfillment.verify));
const canCreate = computed(() => userStore.hasPermissions(permissionCodes.orderFulfillment.create));
const canRevoke = computed(() => userStore.hasPermissions(permissionCodes.orderFulfillment.revoke));
const stockPreviewVisible = computed(() => (isWaitingStock.value || isPending.value)
&& userStore.hasPermissions(permissionCodes.product.read)
&& userStore.hasPermissions(permissionCodes.productStock.read));
const sourceItemType = computed<GaoSalesOrderItemType | undefined>(() => {
const sourceOrderItemId = fulfillment.value?.itemList?.[0]?.sourceOrderItemId;
return order.value?.itemList?.find(item => item.id === sourceOrderItemId)?.itemType;
});
function getFulfillmentQuantityText(item: OrderFulfillmentItem): string {
return `履约数量 ${getPackageQuantityText(item.quantity)} ${item.unitSnapshot || "单位"}`;
return `${getPackageQuantityText(item.quantity)} ${item.unitSnapshot || "单位"}`;
}
function isGiftedFulfillmentItem(item: OrderFulfillmentItem): boolean {
return Boolean(order.value?.itemList?.find(orderItem => orderItem.id === item.sourceOrderItemId)?.gifted);
}
const stockEnabledCache = new Map<string, Promise<boolean | null>>();
function loadStockEnabled(skuId: string): Promise<boolean | null> {
const cached = stockEnabledCache.get(skuId);
if (cached) {
return cached;
}
const request = (async () => {
try {
const sku = await ProductAPI.skuDetail(skuId);
return sku.stockEnabled;
} catch {
return null;
}
})();
stockEnabledCache.set(skuId, request);
return request;
}
async function loadStockPreview(target: OrderFulfillment): Promise<void> {
stockPreviewList.value = [];
if (!stockPreviewVisible.value) {
return;
}
stockPreviewLoading.value = true;
try {
const requirementList = mergeProductStockRequirements((await Promise.all((target.itemList || []).map(async item => {
if (!item.skuId || item.baseQuantity === undefined || item.baseQuantity === null) {
return undefined;
}
const stockEnabled = await loadStockEnabled(item.skuId);
if (stockEnabled === null) {
throw new Error(`${item.productNameSnapshot || "产品"} 的库存信息加载失败`);
}
if (!stockEnabled) {
return undefined;
}
return {
skuId: item.skuId,
baseQuantity: getPackageQuantityText(item.baseQuantity),
productName: item.productNameSnapshot || "未命名产品",
unitName: "基础单位",
stockEnabled
};
}))).filter((item): item is NonNullable<typeof item> => !!item));
if (!requirementList.length) {
return;
}
const stockList = await ProductAPI.stockBatchDetail({ skuIdList: requirementList.map(item => item.skuId) });
const ownReservationMap = new Map(requirementList.map(item => [item.skuId, item.baseQuantity]));
const previewStockList = target.status === "PENDING"
? stockList.map(stock => {
const ownReservation = ownReservationMap.get(stock.skuId || "");
if (!ownReservation) {
return stock;
}
return {
...stock,
availableQuantity: addPackageQuantity(stock.availableQuantity ?? "0", ownReservation) || stock.availableQuantity
};
})
: stockList;
stockPreviewList.value = buildProductStockPreview(previewStockList, requirementList);
} catch (error) {
Message.error(getErrorMessage(error, "库存预览加载失败"));
} finally {
stockPreviewLoading.value = false;
}
}
async function loadData(): Promise<void> {
const id = String(route.params.id || "");
if (!id) return;
@@ -215,6 +303,7 @@ async function loadData(): Promise<void> {
order.value = result.salesOrderId && userStore.hasPermissions(permissionCodes.salesOrder.read)
? await SalesOrderAPI.detail(result.salesOrderId)
: undefined;
await loadStockPreview(result);
}
catch (error) { Message.error(getErrorMessage(error, "履约单加载失败")); }
finally { loading.value = false; }
@@ -339,6 +428,25 @@ onUnmounted(() => navBarStore.clearRight());
border-radius: 1.25rem;
}
.stock-preview {
margin: 0 1rem;
padding: 1rem .5rem .75rem;
background: var(--td-bg-color-container, #fff);
box-shadow: var(--td-shadow-1);
border-radius: 1.25rem;
.title {
padding: 0 .5rem .5rem;
font-size: .875rem;
font-weight: 600;
}
:deep(.t-loading) {
justify-content: center;
padding: 1rem 0;
}
}
.items {
.item {
@@ -352,8 +460,8 @@ onUnmounted(() => navBarStore.clearRight());
}
.avatar {
width: 3.5rem;
height: 3.5rem;
width: 3rem;
height: 3rem;
}
}
}