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

537 lines
17 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
class="clip-text"
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
v-if="item.skuCodeSnapshot || item.skuSpecSnapshot?.length || item.skuNameSnapshot || item.specSummarySnapshot || item.unitSnapshot"
class="sku-tags"
>
<t-tag
v-if="item.skuCodeSnapshot"
size="small"
theme="primary"
variant="light"
:content="item.skuCodeSnapshot"
/>
<product-sku-snapshot-tags
:product-name="item.productNameSnapshot"
:snapshot="item.skuSpecSnapshot"
:fallback="item.specSummarySnapshot || item.skuNameSnapshot"
:unit="item.unitSnapshot"
/>
</div>
</div>
<div class="input">
<t-input
v-model.trim="quantityMap[item.id || '']"
class="quantity"
type="number"
borderless
align="right"
:disabled="!getFulfillmentUnit(item)"
placeholder="请输入履约数量"
>
<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'"
@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-align="left" label-width="6rem">
<t-form-item label="履约时间" content-align="right">
<t-button
theme="primary"
variant="outline"
size="extra-small"
:content="deliveredAtText || '此刻'"
@click="pickerVisible = true"
/>
</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">
<photo-scroller
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 PhotoScroller from "@/components/PhotoScroller.vue";
import ProductSkuSnapshotTags from "@/components/ProductSkuSnapshotTags.vue";
import { useNavBarStore } from "@/store/navBarStore";
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 type { Attachment } from "timi-web-api";
import { comparePackageQuantity, convertPackageQuantity, createPackageRequestNo, getPackageQuantityText, normalizePackageQuantity, toPackageBaseQuantity } from "@/utils/package";
import { getErrorMessage } from "@/utils/error";
import { Message } from "@/utils/message";
import { gaoRoleCodes, permissionCodes } from "@/utils/permission";
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 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));
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;
}
function fillAllQuantity(item: GaoSalesOrderItem): 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;
}
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;
}
function confirmUnitPicker(value: Array<string | number>): 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;
}
function confirmTime(value: string | number): void {
deliveredAtText.value = String(value);
pickerVisible.value = false;
}
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"
}] : []);
}
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 || !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 result = await FulfillmentAPI.create({
salesOrderId: order.value!.id!,
requestNo: requestNo.value,
deliveredAt: resolveTimestamp(deliveredAtText.value),
itemList,
attachmentList: buildAttachments(),
remark: remark.value.trim() || null
});
Message.success("履约单已提交");
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;
strong {
flex: 1 1 0;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
.can-fulfill {
color: var(--td-text-color-secondary);
font-size: .75rem;
white-space: nowrap;
}
}
.sku-tags {
gap: .375rem;
display: flex;
flex-wrap: wrap;
align-items: center;
}
}
.input {
gap: .5rem;
display: flex;
margin-top: .5rem;
.unit {
font-size: .875rem;
}
}
}
}
}
}
.empty {
color: var(--td-text-color-secondary);
padding: 2rem 1rem;
text-align: center;
}
}
</style>