support split unit for product order

This commit is contained in:
Timi
2026-09-09 10:50:53 +08:00
parent 07205f869b
commit f91fb530a4
10 changed files with 384 additions and 81 deletions
+109 -17
View File
@@ -48,22 +48,29 @@
</div>
</div>
<div class="input">
<t-button
size="extra-small"
variant="outline"
:content="getFulfillmentUnit(item)?.name || '选择实际单位'"
@click.stop="openUnitPicker(item)"
/>
<t-input
v-model.trim="quantityMap[item.id || '']"
class="quantity"
type="number"
borderless
align="right"
:disabled="!getFulfillmentUnit(item)"
placeholder="请输入履约数量"
>
<template #suffix-icon>
<span class="unit" v-text="item.unitSnapshot" />
<span class="unit" v-text="getFulfillmentUnit(item)?.name || ''" />
</template>
</t-input>
<t-button
size="extra-small"
variant="outline"
:disabled="getAvailableQuantity(item) === '0'"
:disabled="getAvailableBaseQuantity(item) === '0'"
@click.stop="fillAllQuantity(item)"
>全部履约</t-button>
</div>
@@ -100,24 +107,29 @@
<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, createPackageRequestNo, getPackageQuantityText, normalizePackageQuantity } from "@/utils/package";
import { comparePackageQuantity, convertPackageQuantity, createPackageRequestNo, getPackageQuantityText, normalizePackageQuantity, toPackageBaseQuantity } from "@/utils/package";
import { getErrorMessage } from "@/utils/error";
import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission";
import { gaoRoleCodes, permissionCodes } from "@/utils/permission";
import { uploadPhotoList } from "@/utils/photo";
import { getProductPhotoURL } from "@/utils/product";
@@ -128,6 +140,8 @@ 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);
@@ -135,12 +149,19 @@ 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));
const canOperateStock = computed(() => userStore.hasPermissions(permissionCodes.productStock.create));
const canFulfillByPayment = computed(() => order.value?.paymentStatus === "PAID" || (order.value?.paymentStatus === "PARTIALLY_PAID" && canOperateStock.value));
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 ? getSellableUnitList(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 || "");
@@ -151,23 +172,86 @@ async function loadData(): Promise<void> {
}
try {
order.value = await SalesOrderAPI.detail(salesOrderId);
await loadFulfillmentSkuList();
} catch (error) {
Message.error(getErrorMessage(error, "履约来源加载失败"));
}
}
function getAvailableQuantity(item: GaoSalesOrderItem): string {
return getPackageQuantityText(item.availableQuantity);
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 = getSellableUnitList(item).find(unit => unit.id === item.skuUnitId);
const defaultUnit = originalUnit || getSellableUnitList(item)[0];
if (defaultUnit?.id) {
fulfillmentUnitMap[item.id] = defaultUnit.id;
}
}
}
function getAvailableBaseQuantity(item: GaoSalesOrderItem): string {
return getPackageQuantityText(item.availableBaseQuantity);
}
function getItemDescription(item: GaoSalesOrderItem): string {
return `剩余 ${getAvailableQuantity(item)}`;
return `可履约 ${getAvailableBaseQuantity(item)} ${getBaseUnitName(item)}`;
}
function getBaseUnitName(item: GaoSalesOrderItem): string {
return item.skuId ? fulfillmentSkuMap[item.skuId]?.unitList?.find(unit => unit.isBase)?.name || "基础单位" : "基础单位";
}
function fillAllQuantity(item: GaoSalesOrderItem): void {
if (item.id) {
quantityMap[item.id] = getAvailableQuantity(item);
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 getSellableUnitList(item: GaoSalesOrderItem): ProductSkuUnit[] {
return (item.skuId ? fulfillmentSkuMap[item.skuId]?.unitList : [])?.filter(unit => unit.allowSale !== false && !!unit.id) || [];
}
function getFulfillmentUnit(item: GaoSalesOrderItem): ProductSkuUnit | undefined {
return getSellableUnitList(item).find(unit => unit.id === fulfillmentUnitMap[item.id || ""]);
}
function openUnitPicker(item: GaoSalesOrderItem): void {
if (!item.id) {
return;
}
if (!getSellableUnitList(item).length) {
Message.warning("当前 SKU 没有可用于履约的销售单位");
return;
}
unitPickerSourceItemId.value = item.id;
unitPickerDraft.value = [fulfillmentUnitMap[item.id] || getSellableUnitList(item)[0]?.id || ""];
unitPickerVisible.value = true;
}
function confirmUnitPicker(value: Array<string | number>): void {
const skuUnitId = String(value[0] || "");
if (unitPickerSourceItemId.value && skuUnitId) {
fulfillmentUnitMap[unitPickerSourceItemId.value] = skuUnitId;
}
unitPickerVisible.value = false;
}
function confirmTime(value: string | number): void {
@@ -212,11 +296,17 @@ function validate(): boolean {
}
for (const item of selected) {
const quantity = normalizePackageQuantity(quantityMap[item.id || ""]);
if (!quantity) {
const unit = getFulfillmentUnit(item);
if (!quantity || !unit) {
Message.warning("履约数量必须是正数,最多三位小数");
return false;
}
if (0 < comparePackageQuantity(quantity, item.availableQuantity ?? "0")) {
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;
}
@@ -231,9 +321,10 @@ async function save(): Promise<void> {
try {
const itemList: GaoOrderFulfillmentItemRequest[] = sourceItems.value
.filter(item => !!item.id && quantityMap[item.id])
.map(item => ({
sourceOrderItemId: item.id!,
quantity: normalizePackageQuantity(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!,
@@ -333,6 +424,7 @@ onUnmounted(() => navBarStore.clearRight());
.input {
gap: 1rem;
display: flex;
margin-top: .5rem;
.unit {
font-size: .875rem;