425 lines
15 KiB
Vue
425 lines
15 KiB
Vue
<template>
|
|
<div v-can="permissionCodes.orderFulfillment.read" class="fulfillment-detail">
|
|
<template v-if="fulfillment">
|
|
<t-cell-group title="履约单信息" theme="card" v-entrance-transition>
|
|
<t-cell title="销售订单" arrow @click="toOrder" />
|
|
<t-cell title="订单项类型" :note="getSalesOrderItemTypeText(sourceItemType)" />
|
|
<t-cell title="状态"><template #note><t-tag :content="getFulfillmentStatusText(fulfillment.status)" variant="light-outline" :theme="getStatusTheme(fulfillment.status)" /></template></t-cell>
|
|
<t-cell title="履约时间">
|
|
<template #note>
|
|
<pass-time-label :timestamp="fulfillment.deliveredAt" underline />
|
|
</template>
|
|
</t-cell>
|
|
<t-cell title="提交人" :note="fulfillment.operator?.name || fulfillment.operatorUserId || '未知'" />
|
|
<t-cell title="处理人" :note="fulfillment.verifier?.name || fulfillment.verifierUserId || '未处理'" />
|
|
<t-cell v-if="fulfillment.reason" title="原因" :note="fulfillment.reason" />
|
|
<t-cell v-if="fulfillment.remark" title="备注" :note="fulfillment.remark" />
|
|
<div class="photo-wrap bg-white">
|
|
<media-selector
|
|
:model-value="fulfillmentPhotoURLList"
|
|
:photo-items="fulfillmentPhotoItems"
|
|
:show-add="false"
|
|
:show-remove="false"
|
|
:show-viewer-actions="false"
|
|
title="履约照片"
|
|
/>
|
|
</div>
|
|
</t-cell-group>
|
|
<t-cell-group class="items" title="履约明细" theme="card" v-entrance-transition="70">
|
|
<t-cell
|
|
class="item"
|
|
v-for="item in fulfillment.itemList || []"
|
|
:key="item.id"
|
|
>
|
|
<template #leftIcon>
|
|
<t-image
|
|
v-if="getProductPhotoURL(item)"
|
|
:src="getProductPhotoURL(item)"
|
|
:alt="item.productNameSnapshot || '产品图片'"
|
|
fit="cover"
|
|
class="avatar"
|
|
/>
|
|
<div
|
|
v-else
|
|
class="avatar fallback"
|
|
v-text="(item.productNameSnapshot || '产').slice(0, 1)"
|
|
/>
|
|
</template>
|
|
<template #title>
|
|
<div class="title">
|
|
<span class="clip-text" v-text="item.productNameSnapshot || '未命名产品'" />
|
|
<t-tag v-if="isGiftedFulfillmentItem(item)" size="small" theme="success" content="赠送" />
|
|
<product-sku-tags
|
|
class="tags"
|
|
:product-name="item.productNameSnapshot"
|
|
:snapshot="item.skuSpecSnapshot"
|
|
:fallback="item.specSummarySnapshot || item.skuNameSnapshot || '默认 SKU'"
|
|
/>
|
|
</div>
|
|
</template>
|
|
<template #note>
|
|
<span class="keep-text" v-text="getFulfillmentQuantityText(item)" />
|
|
</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"
|
|
v-entrance-transition="140"
|
|
>
|
|
<t-button
|
|
v-if="!isOrderCancelled && isWaitingStock && canVerify"
|
|
theme="primary"
|
|
content="准备库存"
|
|
@click="prepareStock"
|
|
/>
|
|
<div v-if="!isOrderCancelled && isPending && canVerify" class="pair">
|
|
<t-button theme="primary" content="核销履约" @click="verify" />
|
|
<t-button theme="danger" content="拒绝履约" @click="openReason('reject')" />
|
|
</div>
|
|
<t-button
|
|
v-if="!isOrderCancelled && (isWaitingStock || isPending) && canCreate"
|
|
theme="default"
|
|
content="撤销履约"
|
|
@click="openReason('cancel')"
|
|
/>
|
|
<t-button
|
|
v-if="isVerified && canRevoke"
|
|
theme="danger"
|
|
content="履约冲正"
|
|
@click="revokeVisible = true"
|
|
/>
|
|
</section>
|
|
</template>
|
|
<t-empty v-else :description="loading ? '正在加载履约单' : '履约单不存在'" />
|
|
<t-dialog
|
|
v-model:visible="reasonVisible"
|
|
:title="reasonAction === 'reject' ? '拒绝履约' : '撤销履约'"
|
|
cancel-btn="取消"
|
|
:confirm-btn="reasonAction === 'reject' ? '拒绝' : '撤销'"
|
|
@cancel="reasonVisible = false"
|
|
@confirm="submitReason"
|
|
>
|
|
<t-textarea v-model.trim="reason" borderless autosize placeholder="请输入原因" />
|
|
</t-dialog>
|
|
<order-fulfillment-revoke
|
|
v-if="fulfillment"
|
|
v-model:visible="revokeVisible"
|
|
:fulfillment="fulfillment"
|
|
@success="loadData"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
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 ProductSkuTags from "@/components/ProductSkuTags.vue";
|
|
import ProductStockPreview from "@/components/ProductStockPreview.vue";
|
|
import OrderFulfillmentRevoke from "@/pages/fulfillment/components/OrderFulfillmentRevoke.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 { addPackageQuantity, getPackageQuantityText } from "@/utils/package";
|
|
import { getAttachmentReadURL } from "@/utils/attachment";
|
|
import { getErrorMessage } from "@/utils/error";
|
|
import { getProductPhotoURL } from "@/utils/product";
|
|
import { getFulfillmentStatusText, getSalesOrderItemTypeText, getStatusTheme } from "@/utils/salesOrder";
|
|
import { Message } from "@/utils/message";
|
|
import { permissionCodes } from "@/utils/permission";
|
|
import type { ProductStockPreviewItem } from "@/utils/productStock";
|
|
import { buildProductStockPreview, mergeProductStockRequirements } from "@/utils/productStock";
|
|
|
|
type ReasonAction = "reject" | "cancel";
|
|
defineOptions({ name: "OrderFulfillmentDetail" });
|
|
const route = useRoute();
|
|
const router = useRouter();
|
|
const navBarStore = useNavBarStore();
|
|
const userStore = useUserStore();
|
|
const fulfillment = ref<OrderFulfillment>();
|
|
const order = ref<SalesOrder>();
|
|
const loading = ref(false);
|
|
const operating = ref(false);
|
|
const reasonVisible = ref(false);
|
|
const reasonAction = ref<ReasonAction>("reject");
|
|
const reason = ref("");
|
|
const revokeVisible = ref(false);
|
|
const stockPreviewList = ref<ProductStockPreviewItem[]>([]);
|
|
const stockPreviewLoading = ref(false);
|
|
const fulfillmentPhotoItems = computed<PhotoItem[]>(() => (fulfillment.value?.attachmentList || [])
|
|
.filter(item => !!item.id)
|
|
.map(item => ({
|
|
attachment: item,
|
|
attachType: item.attachType || "PHOTO",
|
|
previewUrl: getAttachmentReadURL(item.id || ""),
|
|
url: getAttachmentReadURL(item.id || "", 90)
|
|
})));
|
|
const fulfillmentPhotoURLList = computed(() => fulfillmentPhotoItems.value.map(item => item.url));
|
|
const isWaitingStock = computed(() => fulfillment.value?.status === "WAITING_STOCK");
|
|
const isPending = computed(() => fulfillment.value?.status === "PENDING");
|
|
const isVerified = computed(() => fulfillment.value?.status === "VERIFIED");
|
|
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 || "单位"}`;
|
|
}
|
|
|
|
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;
|
|
loading.value = true;
|
|
try {
|
|
const result = await FulfillmentAPI.detail(id);
|
|
fulfillment.value = result;
|
|
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; }
|
|
}
|
|
async function toOrder(): Promise<void> {
|
|
if (fulfillment.value?.salesOrderId) {
|
|
await router.push({
|
|
path: `/management/sales-orders/${fulfillment.value.salesOrderId}`,
|
|
query: { transitionDepth: String(getCurrentRouteDepth() + 1) }
|
|
});
|
|
}
|
|
}
|
|
function getCurrentRouteDepth(): number {
|
|
const transitionDepth = Array.isArray(route.query.transitionDepth)
|
|
? route.query.transitionDepth[0]
|
|
: route.query.transitionDepth;
|
|
const depth = Number(transitionDepth);
|
|
if (transitionDepth && Number.isInteger(depth) && 0 <= depth && depth <= 20) {
|
|
return depth;
|
|
}
|
|
return Number(route.meta.depth ?? 0);
|
|
}
|
|
async function prepareStock(): Promise<void> {
|
|
if (!fulfillment.value?.id || operating.value) return;
|
|
if (isOrderCancelled.value) { Message.warning("已取消订单不能准备库存"); return; }
|
|
operating.value = true;
|
|
try {
|
|
fulfillment.value = await FulfillmentAPI.prepareStock({ fulfillmentId: fulfillment.value.id, reason: "库存已备齐" });
|
|
Message.success("库存准备已提交");
|
|
await loadData();
|
|
} catch (error) { Message.error(getErrorMessage(error, "库存准备失败")); }
|
|
finally { operating.value = false; }
|
|
}
|
|
async function verify(): Promise<void> {
|
|
if (!fulfillment.value?.id || operating.value) return;
|
|
if (isOrderCancelled.value) { Message.warning("已取消订单不能核销履约"); return; }
|
|
operating.value = true;
|
|
try {
|
|
fulfillment.value = await FulfillmentAPI.verify({ fulfillmentId: fulfillment.value.id, reason: "" });
|
|
Message.success("履约已核销");
|
|
await loadData();
|
|
} catch (error) { Message.error(getErrorMessage(error, "履约核销失败")); }
|
|
finally { operating.value = false; }
|
|
}
|
|
function openReason(action: ReasonAction): void { reasonAction.value = action; reason.value = ""; reasonVisible.value = true; }
|
|
async function submitReason(): Promise<void> {
|
|
if (!fulfillment.value?.id || !reason.value || operating.value) return;
|
|
operating.value = true;
|
|
try {
|
|
const payload = { fulfillmentId: fulfillment.value.id, reason: reason.value.trim() };
|
|
fulfillment.value = reasonAction.value === "reject"
|
|
? await FulfillmentAPI.reject(payload)
|
|
: await FulfillmentAPI.cancel(payload);
|
|
reasonVisible.value = false;
|
|
Message.success(reasonAction.value === "reject" ? "履约已拒绝" : "履约已撤销");
|
|
await loadData();
|
|
} catch (error) { Message.error(getErrorMessage(error, "履约状态操作失败")); }
|
|
finally { operating.value = false; }
|
|
}
|
|
onMounted(loadData);
|
|
onUnmounted(() => navBarStore.clearRight());
|
|
</script>
|
|
|
|
<style scoped lang="less">
|
|
.fulfillment-detail {
|
|
gap: 1rem;
|
|
padding: calc(var(--app-nav-height) + 1rem) 0 2rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
|
|
.photo-wrap {
|
|
padding: .75rem 1rem;
|
|
}
|
|
|
|
.items {
|
|
|
|
.item {
|
|
padding: 10px;
|
|
|
|
:deep(.t-cell__title) {
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
|
|
.avatar {
|
|
width: 2rem;
|
|
height: 2rem;
|
|
border-radius: .5rem !important;
|
|
}
|
|
|
|
.title {
|
|
gap: .375rem;
|
|
display: flex;
|
|
min-width: 0;
|
|
flex-wrap: nowrap;
|
|
align-items: center;
|
|
|
|
.tags {
|
|
flex-wrap: nowrap;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
.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;
|
|
}
|
|
}
|
|
|
|
.actions {
|
|
--td-button-border-radius: 1rem;
|
|
|
|
gap: .5rem;
|
|
padding: 0 1rem;
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
|
|
.pair {
|
|
gap: .5rem;
|
|
width: 100%;
|
|
display: flex;
|
|
|
|
.t-button {
|
|
width: calc(50% - .25rem);
|
|
margin: 0;
|
|
flex: 1 1 0;
|
|
}
|
|
}
|
|
|
|
.t-button { flex: 1 1 auto; }
|
|
}
|
|
}
|
|
|
|
</style>
|