Files
gao-customer-h5/src/pages/fulfillment/OrderFulfillmentDetail.vue
T
2026-09-09 14:23:35 +08:00

361 lines
13 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" />
</t-cell-group>
<div class="photos" v-entrance-transition="35">
<photo-scroller
:model-value="fulfillmentPhotoURLList"
:photo-items="fulfillmentPhotoItems"
:show-add="false"
:show-remove="false"
:show-viewer-actions="false"
title="履约照片"
/>
</div>
<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"
:title="item.productNameSnapshot || '未命名产品'"
>
<template #description>
<div class="item-description">
<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)"
:src="getProductPhotoURL(item)"
:alt="item.productNameSnapshot || '产品图片'"
fit="cover"
class="avatar"
/>
<div
v-else
class="avatar fallback"
v-text="(item.productNameSnapshot || '产').slice(0, 1)"
/>
</template>
</t-cell>
<t-cell v-if="!fulfillment.itemList?.length" title="暂无履约明细" />
</t-cell-group>
<section
v-if="(!isOrderCancelled && isWaitingStock && canVerify) || (!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 && isPending && canCreate"
theme="default"
content="撤销履约"
@click="openReason('cancel')"
/>
<t-button
v-if="isVerified && canRevoke"
theme="danger"
content="履约冲正"
@click="openRevoke"
/>
</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>
<bottom-popup
v-model="revokeVisible"
title="履约冲正"
left-button-text="取消"
right-button-text="提交"
@left-button-click="revokeVisible = false"
@right-button-click="submitRevoke"
>
<t-form class="revoke-form">
<t-form-item label="冲正原因" required-mark>
<t-textarea
v-model="revokeReason"
borderless
autosize
/>
</t-form-item>
<t-form-item label="库存退回门店" content-align="right">
<t-switch v-model="returnedToStore" />
</t-form-item>
<t-form-item label-width="0">
<photo-scroller
:model-value="photoUrlList"
v-model:photo-items="photoItems"
:upload="uploadPhotoList"
:edit-mode="true"
required
title="冲正凭证照片"
/>
</t-form-item>
</t-form>
</bottom-popup>
</div>
</template>
<script setup lang="ts">
import { BottomPopup } from "timi-tdesign-mobile";
import { PassTimeLabel } from "timi-web";
import { useUserStore } from "timi-web-api";
import FulfillmentAPI from "@/api/FulfillmentAPI";
import SalesOrderAPI from "@/api/SalesOrderAPI";
import PhotoScroller from "@/components/PhotoScroller.vue";
import ProductSkuSnapshotTags from "@/components/ProductSkuSnapshotTags.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 { 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 { uploadPhotoList } from "@/utils/photo";
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 revokeReason = ref("");
const returnedToStore = ref(true);
const revokeRequestNo = ref("");
const photoItems = ref<PhotoItem[]>([]);
const photoUrlList = computed(() => photoItems.value.map(item => item.url));
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 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 || "单位"}`;
}
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;
}
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; }
}
function openRevoke(): void { revokeReason.value = ""; returnedToStore.value = true; revokeRequestNo.value = ""; photoItems.value = []; revokeVisible.value = true; }
function buildAttachments(): Partial<Attachment>[] {
return photoItems.value.flatMap(item => item.tempFileId ? [{ tempFileId: item.tempFileId, attachType: "PHOTO" }] : []);
}
async function submitRevoke(): Promise<void> {
if (!fulfillment.value?.id || !revokeReason.value || operating.value) return;
if (fulfillment.value.stockRecordList?.length && !buildAttachments().length) { Message.warning("实物履约冲正必须上传退回照片"); return; }
if (!revokeRequestNo.value) revokeRequestNo.value = createPackageRequestNo();
operating.value = true;
try {
fulfillment.value = await FulfillmentAPI.revoke({
fulfillmentId: fulfillment.value.id,
requestNo: revokeRequestNo.value,
reason: revokeReason.value.trim(),
returnedToStore: returnedToStore.value,
attachmentList: buildAttachments()
});
revokeVisible.value = false;
Message.success("履约单已冲正");
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;
.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; }
}
.photos {
margin: 0 1rem;
padding: 1rem;
background: var(--td-bg-color-container, #fff);
box-shadow: var(--td-shadow-1);
border-radius: 1.25rem;
}
.items {
.item {
padding: 10px;
.item-description {
gap: .375rem;
display: flex;
flex-wrap: wrap;
align-items: center;
}
.avatar {
width: 3.5rem;
height: 3.5rem;
}
}
}
}
.revoke-form {
margin: 0;
}
</style>