Files
gao-customer-h5/src/pages/fulfillment/OrderFulfillmentCreate.vue
T
2026-09-30 11:51:41 +08:00

700 lines
22 KiB
Vue

<template>
<div v-can="permissionCodes.orderFulfillment.create" class="fulfillment-create">
<t-cell-group title="履约来源" theme="card">
<t-cell title="销售订单" :note="order?.orderNo || order?.id || '未找到来源订单'" />
<t-cell title="客户" :note="order?.customer?.name || order?.customerId || '未选择'" />
<t-cell title="来源类型" :note="isPackage ? '套餐权益' : '直接产品'" />
</t-cell-group>
<t-form v-if="sourceItems.length" class="items form">
<t-form-item v-for="item in sourceItems" :key="item.id" class="item" label-width="0">
<div class="content">
<div class="photo">
<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)"
/>
</div>
<div class="main">
<div class="title">
<div class="heading">
<div class="name-gift">
<strong v-text="item.productNameSnapshot || '未命名产品'"/>
<t-tag
v-if="item.gifted"
size="small"
theme="primary"
content="赠送"
/>
</div>
<span class="can-fulfill" v-text="getItemDescription(item)" />
</div>
</div>
<div class="input">
<div
v-if="item.skuCodeSnapshot || item.skuSpecSnapshot?.length || item.skuNameSnapshot || item.specSummarySnapshot || item.unitSnapshot"
class="sku-tags"
>
<product-sku-tags
:product-name="item.productNameSnapshot"
:snapshot="item.skuSpecSnapshot"
:fallback="item.specSummarySnapshot || item.skuNameSnapshot"
/>
</div>
<t-input
v-model.trim="quantityMap[item.id || '']"
class="quantity"
type="number"
borderless
align="right"
:disabled="!getFulfillmentUnit(item)"
placeholder="请输入履约数量"
@change="refreshStockPreview"
>
<template #suffix-icon>
<t-button
size="extra-small"
variant="outline"
:content="getFulfillmentUnit(item)?.name || '选择实际单位'"
@click.stop="openUnitPicker(item)"
/>
</template>
</t-input>
<t-button
size="extra-small"
variant="outline"
:disabled="getAvailableBaseQuantity(item) === '0' || (directVerify && isStockChecking)"
@click.stop="fillAllQuantity(item)"
>全部履约</t-button>
</div>
</div>
</div>
</t-form-item>
</t-form>
<div v-else class="empty" v-text="'暂无可履约明细,请返回订单详情检查'" />
<t-form class="form" label-width="7rem">
<t-form-item label="履约时间" content-align="right">
<div class="time-actions">
<t-button
theme="primary"
variant="outline"
size="extra-small"
:content="deliveredAtText || '此刻'"
@click="openDeliveredAtPicker"
/>
<t-button
v-if="deliveredAtText"
theme="primary"
variant="text"
size="extra-small"
content="此刻"
@click="resetDeliveredAt"
/>
</div>
</t-form-item>
<t-form-item v-if="canDirectVerify" label="直接核销履约" content-align="right">
<t-switch v-model="directVerify" size="small" :disabled="isSaving || isStockChecking" />
</t-form-item>
<t-form-item v-if="directVerify || stockPreviewList.length" label-width="0">
<div class="stock-tips">
<span v-if="directVerify" class="tips red">直接核销会立即扣减实物库存</span>
<product-stock-preview v-if="stockPreviewList.length" :items="stockPreviewList" />
</div>
</t-form-item>
<t-form-item label="备注">
<t-textarea v-model="remark" borderless autosize maxlength="500" />
</t-form-item>
<t-form-item label-width="0">
<media-selector
v-if="order"
:model-value="photoUrlList"
v-model:photo-items="photoItems"
:upload="uploadPhotoList"
:edit-mode="true"
required
title="履约凭证照片"
/>
</t-form-item>
</t-form>
<t-popup v-model="pickerVisible" placement="bottom">
<t-date-time-picker v-model="draftTime" format="YYYY-MM-DD HH:mm" :mode="['date', 'minute']" title="履约时间" @cancel="pickerVisible = false" @confirm="confirmTime" />
</t-popup>
<t-popup v-model="unitPickerVisible" placement="bottom">
<t-picker v-model="unitPickerDraft" :columns="unitPickerOptions" title="选择实际履约单位" @cancel="unitPickerVisible = false" @confirm="confirmUnitPicker" />
</t-popup>
</div>
</template>
<script setup lang="ts">
import SalesOrderAPI from "@/api/SalesOrderAPI";
import FulfillmentAPI from "@/api/FulfillmentAPI";
import ProductAPI from "@/api/ProductAPI";
import { Time } from "timi-web";
import MediaSelector from "@/components/mediaSelector/MediaSelector.vue";
import ProductSkuTags from "@/components/ProductSkuTags.vue";
import ProductStockPreview from "@/components/ProductStockPreview.vue";
import { useNavBarStore } from "@/store/navBarStore";
import type { Attachment } from "timi-web-api";
import { useUserStore } from "timi-web-api";
import type { GaoOrderFulfillmentItemRequest } from "@/types/Fulfillment";
import type { GaoSalesOrderItem, SalesOrder } from "@/types/SalesOrder";
import type { ProductSku, ProductSkuUnit } from "@/types/Product";
import type { PhotoItem } from "@/types/Photo";
import {
comparePackageQuantity,
convertPackageQuantity,
createPackageRequestNo,
getPackageQuantityText,
normalizePackageQuantity,
toPackageBaseQuantity
} from "@/utils/package";
import { getErrorMessage } from "@/utils/error";
import { emitListDataChanged, listDataEventKeys } from "@/utils/listDataEvents";
import { Message } from "@/utils/message";
import { gaoRoleCodes, permissionCodes } from "@/utils/permission";
import type { ProductStockPreviewItem } from "@/utils/productStock";
import {
buildProductStockPreview,
getProductStockShortageMessage,
mergeProductStockRequirements
} from "@/utils/productStock";
import { uploadPhotoList } from "@/utils/photo";
import { getProductPhotoURL } from "@/utils/product";
defineOptions({ name: "OrderFulfillmentCreate" });
const route = useRoute();
const router = useRouter();
const navBarStore = useNavBarStore();
const userStore = useUserStore();
const order = ref<SalesOrder>();
const quantityMap = reactive<Record<string, string>>({});
const fulfillmentSkuMap = reactive<Record<string, ProductSku>>({});
const fulfillmentUnitMap = reactive<Record<string, string>>({});
const deliveredAtText = ref("");
const draftTime = ref("");
const pickerVisible = ref(false);
const remark = ref("");
const requestNo = ref("");
const isSaving = ref(false);
const photoItems = ref<PhotoItem[]>([]);
const unitPickerVisible = ref(false);
const unitPickerSourceItemId = ref("");
const unitPickerDraft = ref<Array<string | number>>([]);
const directVerify = ref(false);
const isStockChecking = ref(false);
const stockPreviewList = ref<ProductStockPreviewItem[]>([]);
let stockCheckToken = 0;
const photoUrlList = computed(() => photoItems.value.map(item => item.url));
const isPackage = computed(() => order.value?.orderType === "PACKAGE");
const expectedItemType = computed(() => isPackage.value ? "PACKAGE_ENTITLEMENT" : "DIRECT_PRODUCT");
const sourceItems = computed<GaoSalesOrderItem[]>(() => (order.value?.itemList || []).filter(item => item.itemType === expectedItemType.value
&& 0 < comparePackageQuantity(item.availableBaseQuantity ?? "0", "0")));
const unitPickerOptions = computed(() => {
const item = sourceItems.value.find(value => value.id === unitPickerSourceItemId.value);
return item ? getFulfillmentUnitList(item).map(unit => ({ label: unit.name, value: unit.id || "" })) : [];
});
const canFulfillByPayment = computed(() => order.value?.paymentStatus === "PAID" || userStore.hasRoles(gaoRoleCodes.storeManager));
const canDirectVerify = computed(() => userStore.hasRoles(gaoRoleCodes.storeManager)
&& userStore.hasPermissions(permissionCodes.orderFulfillment.create)
&& userStore.hasPermissions(permissionCodes.orderFulfillment.verify)
&& userStore.hasPermissions(permissionCodes.productStock.read));
watch(directVerify, async value => {
stockCheckToken += 1;
const token = stockCheckToken;
if (!value) {
isStockChecking.value = false;
return;
}
isStockChecking.value = true;
try {
if (!await validateDirectStock() && token === stockCheckToken && directVerify.value) {
directVerify.value = false;
}
} finally {
if (token === stockCheckToken) {
isStockChecking.value = false;
}
}
});
async function loadData(): Promise<void> {
const salesOrderId = String(route.query.salesOrderId || "");
if (!salesOrderId) {
Message.warning("请从订单详情进入履约创建");
await router.replace("/management/order-fulfillments");
return;
}
try {
order.value = await SalesOrderAPI.detail(salesOrderId);
await loadFulfillmentSkuList();
} catch (error) {
Message.error(getErrorMessage(error, "履约来源加载失败"));
}
}
async function loadFulfillmentSkuList(): Promise<void> {
const skuIdList = [...new Set(sourceItems.value.map(item => item.skuId).filter((skuId): skuId is string => !!skuId))];
await Promise.all(skuIdList.map(async skuId => {
try {
fulfillmentSkuMap[skuId] = await ProductAPI.skuDetail(skuId);
} catch {
// 提交时仍由后端校验实际履约单位,单个 SKU 加载失败不影响其他明细
}
}));
for (const item of sourceItems.value) {
if (!item.id || fulfillmentUnitMap[item.id]) {
continue;
}
const originalUnit = getFulfillmentUnitList(item).find(unit => unit.id === item.skuUnitId);
const defaultUnit = getFulfillmentUnitForAvailableQuantity(item, originalUnit || getFulfillmentUnitList(item)[0]);
if (defaultUnit?.id) {
fulfillmentUnitMap[item.id] = defaultUnit.id;
}
}
}
function getAvailableBaseQuantity(item: GaoSalesOrderItem): string {
return getPackageQuantityText(item.availableBaseQuantity);
}
function getAvailableQuantity(item: GaoSalesOrderItem): string {
const unit = getFulfillmentUnit(item);
const quantity = convertPackageQuantity(
getAvailableBaseQuantity(item),
"1",
unit?.baseQuantityPerUnit ?? "1"
);
return getPackageQuantityText(quantity ?? item.availableBaseQuantity);
}
function getItemDescription(item: GaoSalesOrderItem): string {
return `可履约 ${getAvailableQuantity(item)} ${getFulfillmentUnit(item)?.name || getBaseUnitName(item)}`;
}
function getBaseUnitName(item: GaoSalesOrderItem): string {
return getBaseUnit(item)?.name || "基础单位";
}
function getBaseUnit(item: GaoSalesOrderItem): ProductSkuUnit | undefined {
return getFulfillmentUnitList(item).find(unit => unit.isBase);
}
function shouldDowngradeUnit(item: GaoSalesOrderItem, unit?: ProductSkuUnit): boolean {
const sku = item.skuId ? fulfillmentSkuMap[item.skuId] : undefined;
if (!unit || unit.isBase || sku?.allowDecimalQuantity !== false) {
return false;
}
return Number(getAvailableBaseQuantity(item)) < Number(unit.baseQuantityPerUnit);
}
function getFulfillmentUnitForAvailableQuantity(item: GaoSalesOrderItem, unit?: ProductSkuUnit): ProductSkuUnit | undefined {
if (!shouldDowngradeUnit(item, unit)) {
return unit;
}
return getBaseUnit(item) || unit;
}
async function fillAllQuantity(item: GaoSalesOrderItem): Promise<void> {
const unit = getFulfillmentUnit(item);
if (!item.id || !unit) {
Message.warning("请先选择实际履约单位");
return;
}
const quantity = convertPackageQuantity(getAvailableBaseQuantity(item), "1", unit.baseQuantityPerUnit);
if (!quantity) {
Message.warning("当前单位不能精确履约全部剩余权益,请填写可精确换算的数量");
return;
}
quantityMap[item.id] = quantity;
await refreshStockPreview();
}
function getFulfillmentUnitList(item: GaoSalesOrderItem): ProductSkuUnit[] {
const unitList = (item.skuId ? fulfillmentSkuMap[item.skuId]?.unitList : [])?.filter(unit => !!unit.id) || [];
if (!item.skuUnitId) {
return unitList;
}
const originalUnit: ProductSkuUnit = {
...(unitList.find(unit => unit.id === item.skuUnitId) || {}),
id: item.skuUnitId,
skuId: item.skuId,
name: item.unitSnapshot || "成交单位",
baseQuantityPerUnit: item.baseQuantityPerUnitSnapshot ?? "1",
allowPurchase: false,
allowSale: false
};
return [originalUnit, ...unitList.filter(unit => unit.id !== item.skuUnitId)];
}
function getFulfillmentUnit(item: GaoSalesOrderItem): ProductSkuUnit | undefined {
return getFulfillmentUnitList(item).find(unit => unit.id === fulfillmentUnitMap[item.id || ""]);
}
function setFulfillmentUnit(item: GaoSalesOrderItem, unit: ProductSkuUnit): void {
if (!item.id) {
return;
}
const currentUnit = getFulfillmentUnit(item);
const currentQuantity = quantityMap[item.id];
if (currentQuantity && currentUnit?.id !== unit.id) {
quantityMap[item.id] = convertPackageQuantity(
currentQuantity,
currentUnit?.baseQuantityPerUnit ?? "1",
unit.baseQuantityPerUnit
) || "";
}
fulfillmentUnitMap[item.id] = unit.id!;
}
function openUnitPicker(item: GaoSalesOrderItem): void {
if (!item.id) {
return;
}
if (!getFulfillmentUnitList(item).length) {
Message.warning("当前 SKU 没有可用于履约的单位");
return;
}
unitPickerSourceItemId.value = item.id;
unitPickerDraft.value = [fulfillmentUnitMap[item.id] || getFulfillmentUnitList(item)[0]?.id || ""];
unitPickerVisible.value = true;
}
async function confirmUnitPicker(value: Array<string | number>): Promise<void> {
const skuUnitId = String(value[0] || "");
const item = sourceItems.value.find(sourceItem => sourceItem.id === unitPickerSourceItemId.value);
const selectedUnit = item ? getFulfillmentUnitList(item).find(unit => unit.id === skuUnitId) : undefined;
const fulfillmentUnit = item ? getFulfillmentUnitForAvailableQuantity(item, selectedUnit) : selectedUnit;
if (item?.id && fulfillmentUnit?.id) {
setFulfillmentUnit(item, fulfillmentUnit);
}
unitPickerVisible.value = false;
await refreshStockPreview();
}
function confirmTime(value: string | number): void {
deliveredAtText.value = String(value);
pickerVisible.value = false;
}
function openDeliveredAtPicker(): void {
draftTime.value = deliveredAtText.value || Time.formatDateTime(new Date());
pickerVisible.value = true;
}
function resetDeliveredAt(): void {
deliveredAtText.value = "";
draftTime.value = "";
}
function resolveTimestamp(value: string): number | undefined {
if (!value) return undefined;
const timestamp = new Date(value.replace(" ", "T")).getTime();
return Number.isFinite(timestamp) ? timestamp : undefined;
}
function buildAttachments(): Partial<Attachment>[] {
return photoItems.value.flatMap(item => item.tempFileId ? [{
tempFileId: item.tempFileId,
attachType: "PHOTO"
}] : []);
}
async function validateDirectStock(showShortageMessage = true): Promise<boolean> {
stockPreviewList.value = [];
const selected = sourceItems.value.filter(item => !!item.id && quantityMap[item.id]);
const skuMap = new Map<string, ProductSku>();
for (const item of selected) {
if (!item.skuId) {
continue;
}
let sku = fulfillmentSkuMap[item.skuId];
if (!sku) {
try {
sku = await ProductAPI.skuDetail(item.skuId);
} catch {
// 统一在下方提示,避免部分 SKU 查询失败时静默跳过库存校验
}
}
if (!sku) {
Message.warning(`${item.productNameSnapshot || "产品"} 的库存信息加载失败,请刷新后重试`);
return false;
}
fulfillmentSkuMap[item.skuId] = sku;
skuMap.set(item.skuId, sku);
}
const requirementList = mergeProductStockRequirements(selected.flatMap(item => {
const sku = item.skuId ? skuMap.get(item.skuId) : undefined;
const unit = getFulfillmentUnit(item);
const quantity = normalizePackageQuantity(quantityMap[item.id || ""] || "");
const baseQuantity = quantity && unit ? toPackageBaseQuantity(quantity, unit.baseQuantityPerUnit) : undefined;
if (!item.skuId || !sku?.stockEnabled || !baseQuantity) {
return [];
}
return [{ skuId: item.skuId, baseQuantity, productName: item.productNameSnapshot || "未命名产品", unitName: "基础单位", stockEnabled: sku.stockEnabled }];
}));
if (!requirementList.length) {
return true;
}
try {
const stockList = await ProductAPI.stockBatchDetail({ skuIdList: requirementList.map(item => item.skuId) });
stockPreviewList.value = buildProductStockPreview(stockList, requirementList);
const shortageMessage = getProductStockShortageMessage(stockList, requirementList);
if (shortageMessage) {
if (showShortageMessage) {
Message.warning(`${shortageMessage},不能直接核销履约`);
}
return false;
}
return true;
} catch (error) {
stockPreviewList.value = [];
Message.error(getErrorMessage(error, "库存查询失败,暂不能直接核销履约"));
return false;
}
}
async function previewDirectStock(force = false): Promise<void> {
if ((!directVerify.value && !force) || isStockChecking.value) {
return;
}
stockCheckToken += 1;
const token = stockCheckToken;
const showShortageMessage = directVerify.value;
isStockChecking.value = true;
try {
if (!await validateDirectStock(showShortageMessage) && token === stockCheckToken && directVerify.value) {
directVerify.value = false;
}
} finally {
if (token === stockCheckToken) {
isStockChecking.value = false;
}
}
}
async function refreshStockPreview(): Promise<void> {
if (!directVerify.value && !stockPreviewList.value.length) {
return;
}
await previewDirectStock(!directVerify.value);
}
function validate(): boolean {
if (!order.value?.id) {
Message.warning("请从订单详情进入履约创建");
return false;
}
if (order.value.status !== "ACTIVE") {
Message.warning("当前订单不是进行中状态,不能创建履约");
return false;
}
if (!canFulfillByPayment.value) {
Message.warning("订单尚未满足履约收款条件");
return false;
}
if (!buildAttachments().length) {
Message.warning("履约必须上传至少一张照片");
return false;
}
const selected = sourceItems.value.filter(item => !!item.id && quantityMap[item.id]);
if (!selected.length) {
Message.warning("请填写至少一项履约数量");
return false;
}
for (const item of selected) {
const quantity = normalizePackageQuantity(quantityMap[item.id || ""]);
const unit = getFulfillmentUnit(item);
if (!quantity || !unit) {
Message.warning("履约数量必须是正数,最多三位小数");
return false;
}
const baseQuantity = toPackageBaseQuantity(quantity, unit.baseQuantityPerUnit);
if (!baseQuantity) {
Message.warning("履约数量换算基础单位后最多只能保留三位小数");
return false;
}
if (0 < comparePackageQuantity(baseQuantity, item.availableBaseQuantity ?? "0")) {
Message.warning(`${item.productNameSnapshot || "产品"} 的可履约数量不足`);
return false;
}
}
return true;
}
async function save(): Promise<void> {
if (isSaving.value) return;
if (isStockChecking.value) {
Message.warning("库存正在预检,请稍候再提交");
return;
}
if (!validate()) return;
if (!requestNo.value) requestNo.value = createPackageRequestNo();
isSaving.value = true;
try {
const itemList: GaoOrderFulfillmentItemRequest[] = sourceItems.value
.filter(item => !!item.id && quantityMap[item.id])
.map(item => ({
sourceOrderItemId: item.id!,
skuUnitId: fulfillmentUnitMap[item.id!]!,
quantity: normalizePackageQuantity(quantityMap[item.id || ""])!
}));
const request = {
salesOrderId: order.value!.id!,
requestNo: requestNo.value,
deliveredAt: resolveTimestamp(deliveredAtText.value),
itemList,
attachmentList: buildAttachments(),
remark: remark.value.trim() || null
};
if (directVerify.value && !await validateDirectStock(true)) {
directVerify.value = false;
return;
}
const result = directVerify.value
? await FulfillmentAPI.createAndVerify(request)
: await FulfillmentAPI.create(request);
emitListDataChanged(listDataEventKeys.salesOrderChanged, request.salesOrderId);
emitListDataChanged(listDataEventKeys.productStockChanged);
Message.success(directVerify.value ? "履约单创建并核销成功" : "履约单已提交");
await router.replace(`/management/order-fulfillments/${result.id}`);
} catch (error) {
Message.error(getErrorMessage(error, "履约单创建失败,重试会复用同一请求号"));
} finally {
isSaving.value = false;
}
}
onMounted(async () => {
navBarStore.setRightText("提交", "var(--td-brand-color)", save);
await loadData();
});
onUnmounted(() => navBarStore.clearRight());
</script>
<style scoped lang="less">
.fulfillment-create {
gap: 1rem;
padding: calc(var(--app-nav-height) + 1rem) 0 2rem;
display: flex;
flex-direction: column;
.items {
margin: 0 1rem;
background: var(--td-bg-color-container);
box-shadow: var(--td-shadow-1);
border-radius: 1rem;
overflow: hidden;
.item {
padding: 10px;
min-width: 0;
:deep(.t-form__controls),
:deep(.t-form__controls-content) {
min-width: 0;
}
.content {
gap: .5rem;
width: 100%;
min-width: 0;
flex: 1 1 auto;
display: flex;
.photo {
gap: .5rem;
display: flex;
.avatar {
width: 3.5rem;
height: 3.5rem;
}
}
.main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: space-between;
.title {
gap: .375rem;
min-width: 0;
display: flex;
flex-direction: column;
.heading {
gap: .5rem;
display: flex;
min-width: 0;
align-items: baseline;
justify-content: space-between;
.name-gift {
gap: .5rem;
flex: 1 1 0;
min-width: 0;
display: flex;
overflow: hidden;
align-items: center;
justify-content: start;
strong {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
.can-fulfill {
color: var(--td-text-color-secondary);
font-size: .75rem;
white-space: nowrap;
}
}
}
.input {
gap: .5rem;
display: flex;
margin-top: .5rem;
.sku-tags {
gap: .375rem;
display: flex;
flex-wrap: wrap;
align-items: center;
}
.unit {
font-size: .875rem;
}
}
}
}
}
}
.time-actions {
gap: .5rem;
display: flex;
align-items: center;
justify-content: flex-end;
}
.tips {
font-size: .875rem;
}
}
</style>