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> </div>
<div class="input"> <div class="input">
<t-button
size="extra-small"
variant="outline"
:content="getFulfillmentUnit(item)?.name || '选择实际单位'"
@click.stop="openUnitPicker(item)"
/>
<t-input <t-input
v-model.trim="quantityMap[item.id || '']" v-model.trim="quantityMap[item.id || '']"
class="quantity" class="quantity"
type="number" type="number"
borderless borderless
align="right" align="right"
:disabled="!getFulfillmentUnit(item)"
placeholder="请输入履约数量" placeholder="请输入履约数量"
> >
<template #suffix-icon> <template #suffix-icon>
<span class="unit" v-text="item.unitSnapshot" /> <span class="unit" v-text="getFulfillmentUnit(item)?.name || ''" />
</template> </template>
</t-input> </t-input>
<t-button <t-button
size="extra-small" size="extra-small"
variant="outline" variant="outline"
:disabled="getAvailableQuantity(item) === '0'" :disabled="getAvailableBaseQuantity(item) === '0'"
@click.stop="fillAllQuantity(item)" @click.stop="fillAllQuantity(item)"
>全部履约</t-button> >全部履约</t-button>
</div> </div>
@@ -100,24 +107,29 @@
<t-popup v-model="pickerVisible" placement="bottom"> <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-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>
<t-popup v-model="unitPickerVisible" placement="bottom">
<t-picker v-model="unitPickerDraft" :columns="unitPickerOptions" title="选择实际履约单位" @cancel="unitPickerVisible = false" @confirm="confirmUnitPicker" />
</t-popup>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import SalesOrderAPI from "@/api/SalesOrderAPI"; import SalesOrderAPI from "@/api/SalesOrderAPI";
import FulfillmentAPI from "@/api/FulfillmentAPI"; import FulfillmentAPI from "@/api/FulfillmentAPI";
import ProductAPI from "@/api/ProductAPI";
import PhotoScroller from "@/components/PhotoScroller.vue"; import PhotoScroller from "@/components/PhotoScroller.vue";
import ProductSkuSnapshotTags from "@/components/ProductSkuSnapshotTags.vue"; import ProductSkuSnapshotTags from "@/components/ProductSkuSnapshotTags.vue";
import { useNavBarStore } from "@/store/navBarStore"; import { useNavBarStore } from "@/store/navBarStore";
import { useUserStore } from "timi-web-api"; import { useUserStore } from "timi-web-api";
import type { GaoOrderFulfillmentItemRequest } from "@/types/Fulfillment"; import type { GaoOrderFulfillmentItemRequest } from "@/types/Fulfillment";
import type { GaoSalesOrderItem, SalesOrder } from "@/types/SalesOrder"; import type { GaoSalesOrderItem, SalesOrder } from "@/types/SalesOrder";
import type { ProductSku, ProductSkuUnit } from "@/types/Product";
import type { PhotoItem } from "@/types/Photo"; import type { PhotoItem } from "@/types/Photo";
import type { Attachment } from "timi-web-api"; 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 { getErrorMessage } from "@/utils/error";
import { Message } from "@/utils/message"; import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission"; import { gaoRoleCodes, permissionCodes } from "@/utils/permission";
import { uploadPhotoList } from "@/utils/photo"; import { uploadPhotoList } from "@/utils/photo";
import { getProductPhotoURL } from "@/utils/product"; import { getProductPhotoURL } from "@/utils/product";
@@ -128,6 +140,8 @@ const navBarStore = useNavBarStore();
const userStore = useUserStore(); const userStore = useUserStore();
const order = ref<SalesOrder>(); const order = ref<SalesOrder>();
const quantityMap = reactive<Record<string, string>>({}); const quantityMap = reactive<Record<string, string>>({});
const fulfillmentSkuMap = reactive<Record<string, ProductSku>>({});
const fulfillmentUnitMap = reactive<Record<string, string>>({});
const deliveredAtText = ref(""); const deliveredAtText = ref("");
const draftTime = ref(""); const draftTime = ref("");
const pickerVisible = ref(false); const pickerVisible = ref(false);
@@ -135,12 +149,19 @@ const remark = ref("");
const requestNo = ref(""); const requestNo = ref("");
const isSaving = ref(false); const isSaving = ref(false);
const photoItems = ref<PhotoItem[]>([]); 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 photoUrlList = computed(() => photoItems.value.map(item => item.url));
const isPackage = computed(() => order.value?.orderType === "PACKAGE"); const isPackage = computed(() => order.value?.orderType === "PACKAGE");
const expectedItemType = computed(() => isPackage.value ? "PACKAGE_ENTITLEMENT" : "DIRECT_PRODUCT"); const expectedItemType = computed(() => isPackage.value ? "PACKAGE_ENTITLEMENT" : "DIRECT_PRODUCT");
const sourceItems = computed<GaoSalesOrderItem[]>(() => (order.value?.itemList || []).filter(item => item.itemType === expectedItemType.value)); const sourceItems = computed<GaoSalesOrderItem[]>(() => (order.value?.itemList || []).filter(item => item.itemType === expectedItemType.value
const canOperateStock = computed(() => userStore.hasPermissions(permissionCodes.productStock.create)); && 0 < comparePackageQuantity(item.availableBaseQuantity ?? "0", "0")));
const canFulfillByPayment = computed(() => order.value?.paymentStatus === "PAID" || (order.value?.paymentStatus === "PARTIALLY_PAID" && canOperateStock.value)); 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> { async function loadData(): Promise<void> {
const salesOrderId = String(route.query.salesOrderId || ""); const salesOrderId = String(route.query.salesOrderId || "");
@@ -151,23 +172,86 @@ async function loadData(): Promise<void> {
} }
try { try {
order.value = await SalesOrderAPI.detail(salesOrderId); order.value = await SalesOrderAPI.detail(salesOrderId);
await loadFulfillmentSkuList();
} catch (error) { } catch (error) {
Message.error(getErrorMessage(error, "履约来源加载失败")); Message.error(getErrorMessage(error, "履约来源加载失败"));
} }
} }
function getAvailableQuantity(item: GaoSalesOrderItem): string { async function loadFulfillmentSkuList(): Promise<void> {
return getPackageQuantityText(item.availableQuantity); 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 { 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 { function fillAllQuantity(item: GaoSalesOrderItem): void {
if (item.id) { const unit = getFulfillmentUnit(item);
quantityMap[item.id] = getAvailableQuantity(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 { function confirmTime(value: string | number): void {
@@ -212,11 +296,17 @@ function validate(): boolean {
} }
for (const item of selected) { for (const item of selected) {
const quantity = normalizePackageQuantity(quantityMap[item.id || ""]); const quantity = normalizePackageQuantity(quantityMap[item.id || ""]);
if (!quantity) { const unit = getFulfillmentUnit(item);
if (!quantity || !unit) {
Message.warning("履约数量必须是正数,最多三位小数"); Message.warning("履约数量必须是正数,最多三位小数");
return false; 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 || "产品"} 的可履约数量不足`); Message.warning(`${item.productNameSnapshot || "产品"} 的可履约数量不足`);
return false; return false;
} }
@@ -231,9 +321,10 @@ async function save(): Promise<void> {
try { try {
const itemList: GaoOrderFulfillmentItemRequest[] = sourceItems.value const itemList: GaoOrderFulfillmentItemRequest[] = sourceItems.value
.filter(item => !!item.id && quantityMap[item.id]) .filter(item => !!item.id && quantityMap[item.id])
.map(item => ({ .map(item => ({
sourceOrderItemId: item.id!, sourceOrderItemId: item.id!,
quantity: normalizePackageQuantity(quantityMap[item.id || ""])! skuUnitId: fulfillmentUnitMap[item.id!]!,
quantity: normalizePackageQuantity(quantityMap[item.id || ""])!
})); }));
const result = await FulfillmentAPI.create({ const result = await FulfillmentAPI.create({
salesOrderId: order.value!.id!, salesOrderId: order.value!.id!,
@@ -333,6 +424,7 @@ onUnmounted(() => navBarStore.clearRight());
.input { .input {
gap: 1rem; gap: 1rem;
display: flex; display: flex;
margin-top: .5rem;
.unit { .unit {
font-size: .875rem; font-size: .875rem;
@@ -31,7 +31,7 @@
v-for="item in fulfillment.itemList || []" v-for="item in fulfillment.itemList || []"
:key="item.id" :key="item.id"
:title="item.productNameSnapshot || '未命名产品'" :title="item.productNameSnapshot || '未命名产品'"
:note="`待履约 ${item.reservedBefore || 0} → ${item.reservedAfter || 0}`" :note="`待履约(基础单位)${item.reservedBaseBefore || 0} → ${item.reservedBaseAfter || 0}`"
> >
<template #description> <template #description>
<div class="item-description"> <div class="item-description">
+155 -13
View File
@@ -80,7 +80,7 @@
<t-form class="items" label-width="2.5rem"> <t-form class="items" label-width="2.5rem">
<div class="title"> <div class="title">
<strong>购买权益明细</strong> <strong>购买权益明细</strong>
<span v-if="!canPriceOverride">当前账号无定制权限,明细不可修改</span> <span v-if="!canPriceOverride">当前账号无定制权限,仅可等值转换单位</span>
</div> </div>
<t-form-item <t-form-item
v-for="(item, index) in packagePurchaseItemList" v-for="(item, index) in packagePurchaseItemList"
@@ -102,7 +102,15 @@
/> />
</template> </template>
<div class="content"> <div class="content">
<span class="name" v-text="getItemLabel(item)" /> <div class="item-title">
<span class="name" v-text="getItemLabel(item)" />
<t-button
size="extra-small"
variant="outline"
:content="getItemUnit(item)?.name || '选择单位'"
@click="openItemUnitPicker(index)"
/>
</div>
<t-input <t-input
:model-value="item.totalQuantity" :model-value="item.totalQuantity"
type="number" type="number"
@@ -113,9 +121,18 @@
@update:model-value="updateItemQuantity(index, $event)" @update:model-value="updateItemQuantity(index, $event)"
> >
<template #suffix-icon> <template #suffix-icon>
<span class="unit" v-text="getItemUnit(item)" /> <span class="unit" v-text="getItemUnit(item)?.name || ''" />
</template> </template>
</t-input> </t-input>
<t-button
v-if="canPriceOverride"
class="remove"
size="extra-small"
theme="danger"
variant="text"
content="移除"
@click="removePurchaseItem(index)"
/>
</div> </div>
</t-form-item> </t-form-item>
<div v-if="!packagePurchaseItemList.length" class="center-tips" v-text="'当前套餐暂无权益明细'" /> <div v-if="!packagePurchaseItemList.length" class="center-tips" v-text="'当前套餐暂无权益明细'" />
@@ -185,6 +202,9 @@
<div v-else class="center-tips" v-text="'暂无可购买套餐'" /> <div v-else class="center-tips" v-text="'暂无可购买套餐'" />
</div> </div>
</t-popup> </t-popup>
<t-popup v-model="unitPickerVisible" placement="bottom">
<t-picker v-model="unitPickerDraft" :columns="unitPickerOptions" title="调整权益单位" @cancel="unitPickerVisible = false" @confirm="confirmItemUnitPicker" />
</t-popup>
<t-popup v-model="selectedCustomerListVisible" placement="bottom"> <t-popup v-model="selectedCustomerListVisible" placement="bottom">
<div class="selected-customer-popup bg-white"> <div class="selected-customer-popup bg-white">
<div class="popup-header"> <div class="popup-header">
@@ -221,6 +241,7 @@ import { Time } from "timi-web";
import { useUserStore } from "timi-web-api"; import { useUserStore } from "timi-web-api";
import CustomerAPI from "@/api/CustomerAPI"; import CustomerAPI from "@/api/CustomerAPI";
import PackageAPI from "@/api/PackageAPI"; import PackageAPI from "@/api/PackageAPI";
import ProductAPI from "@/api/ProductAPI";
import CodeScanner from "@/components/CodeScanner.vue"; import CodeScanner from "@/components/CodeScanner.vue";
import CustomerList from "@/components/CustomerList.vue"; import CustomerList from "@/components/CustomerList.vue";
import CustomerProfileCard from "@/components/CustomerProfileCard.vue"; import CustomerProfileCard from "@/components/CustomerProfileCard.vue";
@@ -232,8 +253,9 @@ import { useSettingStore } from "@/store/settingStore";
import type { Customer } from "@/types/Customer"; import type { Customer } from "@/types/Customer";
import type { PhotoItem } from "@/types/Photo"; import type { PhotoItem } from "@/types/Photo";
import type { GaoPackageConfig, PackageAttachment, PackagePurchaseItem } from "@/types/Package"; import type { GaoPackageConfig, PackageAttachment, PackagePurchaseItem } from "@/types/Package";
import type { ProductSku, ProductSkuUnit } from "@/types/Product";
import { getErrorMessage } from "@/utils/error"; import { getErrorMessage } from "@/utils/error";
import { formatPackageAmount } from "@/utils/package"; import { addPackageQuantity, convertPackageQuantity, formatPackageAmount, normalizePackageQuantity } from "@/utils/package";
import { Message } from "@/utils/message"; import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission"; import { permissionCodes } from "@/utils/permission";
import { uploadPhotoList } from "@/utils/photo"; import { uploadPhotoList } from "@/utils/photo";
@@ -256,6 +278,10 @@ const isCustomerPackageCreate = computed(() => route.name === "CustomerPackageCr
const config = ref<GaoPackageConfig>(); const config = ref<GaoPackageConfig>();
const configList = ref<GaoPackageConfig[]>([]); const configList = ref<GaoPackageConfig[]>([]);
const configSelectorVisible = ref(false); const configSelectorVisible = ref(false);
const skuMap = reactive<Record<string, ProductSku>>({});
const unitPickerVisible = ref(false);
const unitPickerIndex = ref(-1);
const unitPickerDraft = ref<Array<string | number>>([]);
// ---------- 客户 ---------- // ---------- 客户 ----------
@@ -405,8 +431,13 @@ const {
buildAttachments buildAttachments
}); });
const unitPickerOptions = computed(() => {
const item = packagePurchaseItemList.value[unitPickerIndex.value];
return item ? getSelectableUnitList(item).map(unit => ({ label: unit.name, value: unit.id || "" })) : [];
});
function getItemLabel(item: PackagePurchaseItem): string { function getItemLabel(item: PackagePurchaseItem): string {
const configItem = config.value?.itemList?.find(configItem => configItem.skuId === item.skuId && configItem.skuUnitId === item.skuUnitId); const configItem = config.value?.itemList?.find(configItem => configItem.skuId === item.skuId);
if (!configItem) { if (!configItem) {
return "产品权益"; return "产品权益";
} }
@@ -416,16 +447,78 @@ function getItemLabel(item: PackagePurchaseItem): string {
} }
function getItemName(item: PackagePurchaseItem): string { function getItemName(item: PackagePurchaseItem): string {
return config.value?.itemList?.find(configItem => configItem.skuId === item.skuId && configItem.skuUnitId === item.skuUnitId)?.productNameSnapshot || "未命名产品"; return config.value?.itemList?.find(configItem => configItem.skuId === item.skuId)?.productNameSnapshot || "未命名产品";
} }
function getItemPhotoURL(item: PackagePurchaseItem): string { function getItemPhotoURL(item: PackagePurchaseItem): string {
const configItem = config.value?.itemList?.find(configItem => configItem.skuId === item.skuId && configItem.skuUnitId === item.skuUnitId); return getProductPhotoURL(getItemSku(item) || {});
return getProductPhotoURL(configItem?.sku || {});
} }
function getItemUnit(item: PackagePurchaseItem): string { function getItemSku(item: PackagePurchaseItem): ProductSku | undefined {
return config.value?.itemList?.find(configItem => configItem.skuId === item.skuId && configItem.skuUnitId === item.skuUnitId)?.unitSnapshot || ""; return skuMap[item.skuId] || config.value?.itemList?.find(configItem => configItem.skuId === item.skuId)?.sku;
}
function getItemUnit(item: PackagePurchaseItem): ProductSkuUnit | undefined {
return getItemSku(item)?.unitList?.find(unit => unit.id === item.skuUnitId);
}
function getSelectableUnitList(item: PackagePurchaseItem): ProductSkuUnit[] {
const maxBaseQuantityPerUnit = Math.max(...(config.value?.itemList || [])
.filter(configItem => configItem.skuId === item.skuId)
.map(configItem => Number(configItem.baseQuantityPerUnitSnapshot || 0)));
return (getItemSku(item)?.unitList || []).filter(unit => unit.allowSale !== false && !!unit.id
&& Number(unit.baseQuantityPerUnit) <= maxBaseQuantityPerUnit);
}
function openItemUnitPicker(index: number): void {
const item = packagePurchaseItemList.value[index];
if (!item || !getSelectableUnitList(item).length) {
Message.warning("当前 SKU 没有可用于套餐权益的销售单位");
return;
}
unitPickerIndex.value = index;
unitPickerDraft.value = [item.skuUnitId];
unitPickerVisible.value = true;
}
function confirmItemUnitPicker(value: Array<string | number>): void {
const index = unitPickerIndex.value;
const item = packagePurchaseItemList.value[index];
const skuUnitId = String(value[0] || "");
const currentUnit = item && getItemUnit(item);
const targetUnit = item && getSelectableUnitList(item).find(unit => unit.id === skuUnitId);
if (!item || !currentUnit || !targetUnit || !skuUnitId || skuUnitId === item.skuUnitId) {
unitPickerVisible.value = false;
return;
}
const convertedQuantity = convertPackageQuantity(item.totalQuantity, currentUnit.baseQuantityPerUnit, targetUnit.baseQuantityPerUnit);
if (!convertedQuantity) {
Message.warning("调整单位后数量不能精确保留三位小数");
return;
}
const duplicateIndex = packagePurchaseItemList.value.findIndex((candidate, candidateIndex) => candidateIndex !== index
&& candidate.skuId === item.skuId && candidate.skuUnitId === skuUnitId);
if (duplicateIndex < 0) {
packagePurchaseItemList.value[index] = { ...item, skuUnitId, totalQuantity: convertedQuantity };
} else {
const duplicateItem = packagePurchaseItemList.value[duplicateIndex];
const totalQuantity = addPackageQuantity(duplicateItem.totalQuantity, convertedQuantity);
if (!totalQuantity) {
Message.warning("合并同单位权益后数量不能精确保留三位小数");
return;
}
packagePurchaseItemList.value = packagePurchaseItemList.value.flatMap((candidate, candidateIndex) => {
if (candidateIndex === index) {
return [];
}
if (candidateIndex === duplicateIndex) {
return [{ ...candidate, totalQuantity, sort: Math.min(candidate.sort ?? candidateIndex, item.sort ?? index) }];
}
return [candidate];
});
}
resetRequestNo();
unitPickerVisible.value = false;
} }
function updateItemQuantity(index: number, value: string | number): void { function updateItemQuantity(index: number, value: string | number): void {
@@ -433,13 +526,33 @@ function updateItemQuantity(index: number, value: string | number): void {
...item, ...item,
totalQuantity: String(value) totalQuantity: String(value)
} : item); } : item);
resetRequestNo();
}
function removePurchaseItem(index: number): void {
if (!canPriceOverride.value) {
return;
}
packagePurchaseItemList.value = packagePurchaseItemList.value.filter((_, itemIndex) => itemIndex !== index);
resetRequestNo();
}
function validateItemDecimalQuantity(): boolean {
for (const item of packagePurchaseItemList.value) {
const quantity = normalizePackageQuantity(item.totalQuantity);
if (quantity && quantity !== "0" && quantity.includes(".") && getItemSku(item)?.allowDecimalQuantity === false) {
Message.warning("该 SKU 不允许小数数量");
return false;
}
}
return true;
} }
async function submitRecord(): Promise<void> { async function submitRecord(): Promise<void> {
if (isSaving.value) { if (isSaving.value) {
return; return;
} }
if (!validatePurchaseForm()) { if (!validatePurchaseForm() || !validateItemDecimalQuantity()) {
return; return;
} }
@@ -473,6 +586,7 @@ async function loadPageData(): Promise<void> {
} }
config.value = await PackageAPI.configDetail(configId.value); config.value = await PackageAPI.configDetail(configId.value);
applyConfig(config.value); applyConfig(config.value);
await loadConfigSkuList(config.value);
} }
async function selectConfig(item: GaoPackageConfig): Promise<void> { async function selectConfig(item: GaoPackageConfig): Promise<void> {
@@ -480,9 +594,21 @@ async function selectConfig(item: GaoPackageConfig): Promise<void> {
return; return;
} }
config.value = await PackageAPI.configDetail(item.id); config.value = await PackageAPI.configDetail(item.id);
await loadConfigSkuList(config.value);
configSelectorVisible.value = false; configSelectorVisible.value = false;
} }
async function loadConfigSkuList(value?: GaoPackageConfig): Promise<void> {
const skuIdList = [...new Set((value?.itemList || []).map(item => item.skuId).filter(Boolean))];
await Promise.all(skuIdList.map(async skuId => {
try {
skuMap[skuId] = await ProductAPI.skuDetail(skuId);
} catch {
// 后端会校验最终权益单位,单个 SKU 的单位加载失败不影响套餐查看
}
}));
}
onMounted(async () => { onMounted(async () => {
navBarStore.clearRight(); navBarStore.clearRight();
if (userStore.hasPermissions(permissionCodes.customerPackage.create)) { if (userStore.hasPermissions(permissionCodes.customerPackage.create)) {
@@ -608,11 +734,27 @@ watch(config, value => {
display: none; display: none;
} }
.content { .content {
display: flex; display: flex;
height: 2rem; height: 2rem;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
.item-title {
gap: .375rem;
min-width: 0;
display: flex;
align-items: center;
}
.t-input {
min-width: 0;
flex: 1;
}
.remove {
flex: none;
}
.name { .name {
min-width: 0; min-width: 0;
+27 -13
View File
@@ -71,6 +71,16 @@ export function usePackagePurchase(options: UsePackagePurchaseOptions) {
if (!packageRequestNo.value) { if (!packageRequestNo.value) {
packageRequestNo.value = createPackageRequestNo(); packageRequestNo.value = createPackageRequestNo();
} }
const itemList = packagePurchaseItemList.value.flatMap((item, index) => {
const totalQuantity = normalizePackageQuantity(item.totalQuantity);
return totalQuantity && totalQuantity !== "0" ? [{
skuId: item.skuId,
skuUnitId: item.skuUnitId,
totalQuantity,
sort: item.sort ?? index,
remark: item.remark ?? null
}] : [];
});
return await PackageAPI.purchase({ return await PackageAPI.purchase({
requestNo: packageRequestNo.value, requestNo: packageRequestNo.value,
packageConfigId: config.id, packageConfigId: config.id,
@@ -78,13 +88,7 @@ export function usePackagePurchase(options: UsePackagePurchaseOptions) {
saleAmount, saleAmount,
purchasedAt: options.buildRegisteredAt(), purchasedAt: options.buildRegisteredAt(),
attachmentList: options.buildAttachments(), attachmentList: options.buildAttachments(),
itemList: packagePurchaseItemList.value.length ? packagePurchaseItemList.value.map((item, index) => ({ itemList,
skuId: item.skuId,
skuUnitId: item.skuUnitId,
totalQuantity: normalizePackageQuantity(item.totalQuantity)!,
sort: item.sort ?? index,
remark: item.remark ?? null
})) : undefined,
remark: options.note.value.trim() || null remark: options.note.value.trim() || null
}); });
} }
@@ -110,18 +114,28 @@ export function usePackagePurchase(options: UsePackagePurchaseOptions) {
Message.warning("成交价必须是合法的元金额,最多两位小数"); Message.warning("成交价必须是合法的元金额,最多两位小数");
return false; return false;
} }
const keySet = new Set<string>();
let hasPositiveQuantity = false;
for (const item of packagePurchaseItemList.value) { for (const item of packagePurchaseItemList.value) {
const quantity = normalizePackageQuantity(item.totalQuantity); const quantity = normalizePackageQuantity(item.totalQuantity);
if (!item.skuId || !item.skuUnitId || !quantity || quantity === "0") { if (!item.skuId || !item.skuUnitId || !quantity) {
Message.warning("SKU 单位和正数量不能为空"); Message.warning("SKU 单位和数量不能为空,数量最多三位小数");
return false; return false;
} }
const configItem = options.config.value.itemList?.find(source => source.skuId === item.skuId && source.skuUnitId === item.skuUnitId); if (quantity !== "0") {
if (configItem?.sku && !configItem.sku.allowDecimalQuantity && quantity.includes(".")) { hasPositiveQuantity = true;
Message.warning("该 SKU 不允许小数数量"); const key = `${item.skuId}\u0000${item.skuUnitId}`;
return false; if (keySet.has(key)) {
Message.warning("最终套餐权益不能重复相同 SKU 和单位");
return false;
}
keySet.add(key);
} }
} }
if (!hasPositiveQuantity) {
Message.warning("套餐至少需要保留一项正数量权益");
return false;
}
return true; return true;
} }
+16 -17
View File
@@ -129,23 +129,23 @@
<div class="content"> <div class="content">
<div class="metric"> <div class="metric">
<strong v-text="getPackageQuantityText(item.quantity)" /> <strong v-text="getPackageQuantityText(item.quantity)" />
<span>总数</span> <span>订单数量</span>
</div> </div>
<div class="metric"> <div class="metric">
<strong :class="getAvailableQuantityClass(item)" v-text="getPackageQuantityText(item.availableQuantity)" /> <strong :class="getAvailableQuantityClass(item)" v-text="getPackageQuantityText(item.availableBaseQuantity)" />
<span>可履约</span> <span>可履约(基础)</span>
</div> </div>
<div class="metric"> <div class="metric">
<strong v-text="getPackageQuantityText(item.pendingQuantity)" /> <strong v-text="getPackageQuantityText(item.pendingBaseQuantity)" />
<span>待核销</span> <span>待核销(基础)</span>
</div> </div>
<div class="metric"> <div class="metric">
<strong :class="getFulfilledQuantityClass(item)" v-text="getPackageQuantityText(item.fulfilledQuantity)" /> <strong :class="getFulfilledQuantityClass(item)" v-text="getPackageQuantityText(item.fulfilledBaseQuantity)" />
<span>已履约</span> <span>已履约(基础)</span>
</div> </div>
<div class="metric"> <div class="metric">
<strong :class="getRemainingQuantityClass(item)" v-text="getPackageQuantityText(item.remainingQuantity)" /> <strong :class="getRemainingQuantityClass(item)" v-text="getPackageQuantityText(item.remainingBaseQuantity)" />
<span>剩余</span> <span>剩余(基础)</span>
</div> </div>
</div> </div>
</div> </div>
@@ -242,7 +242,7 @@ import {
getStatusTheme getStatusTheme
} from "@/utils/salesOrder"; } from "@/utils/salesOrder";
import { Message } from "@/utils/message"; import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission"; import { gaoRoleCodes, permissionCodes } from "@/utils/permission";
import { uploadPhotoList } from "@/utils/photo"; import { uploadPhotoList } from "@/utils/photo";
defineOptions({ name: "SalesOrderDetail" }); defineOptions({ name: "SalesOrderDetail" });
@@ -261,12 +261,11 @@ const canReadFund = computed(() => userStore.hasPermissions(permissionCodes.fund
const canReadMyReceive = computed(() => userStore.hasPermissions(permissionCodes.fundReceive.create)); const canReadMyReceive = computed(() => userStore.hasPermissions(permissionCodes.fundReceive.create));
const canUpdate = computed(() => userStore.hasPermissions(permissionCodes.salesOrder.update)); const canUpdate = computed(() => userStore.hasPermissions(permissionCodes.salesOrder.update));
const canCreateFulfillment = computed(() => userStore.hasPermissions(permissionCodes.orderFulfillment.create)); const canCreateFulfillment = computed(() => userStore.hasPermissions(permissionCodes.orderFulfillment.create));
const canOperateStock = computed(() => userStore.hasPermissions(permissionCodes.productStock.create));
const canReceive = computed(() => !!order.value && order.value.status !== "CANCELLED" && order.value.paymentStatus !== "PAID" && userStore.hasPermissions(permissionCodes.fundReceive.create)); const canReceive = computed(() => !!order.value && order.value.status !== "CANCELLED" && order.value.paymentStatus !== "PAID" && userStore.hasPermissions(permissionCodes.fundReceive.create));
const canRefund = computed(() => !!order.value && order.value.status === "CANCELLED" && order.value.refundStatus === "PENDING" && userStore.hasPermissions(permissionCodes.fund.refund)); const canRefund = computed(() => !!order.value && order.value.status === "CANCELLED" && order.value.refundStatus === "PENDING" && userStore.hasPermissions(permissionCodes.fund.refund));
const canConfirmNoRefund = computed(() => !!order.value && order.value.status === "CANCELLED" && order.value.refundStatus === "PENDING" && userStore.hasPermissions(permissionCodes.fund.refund)); const canConfirmNoRefund = computed(() => !!order.value && order.value.status === "CANCELLED" && order.value.refundStatus === "PENDING" && userStore.hasPermissions(permissionCodes.fund.refund));
const canCancel = computed(() => !!order.value && order.value.status === "ACTIVE" && !hasPendingFulfillment.value && userStore.hasPermissions(permissionCodes.salesOrder.cancel)); const canCancel = computed(() => !!order.value && order.value.status === "ACTIVE" && !hasPendingFulfillment.value && userStore.hasPermissions(permissionCodes.salesOrder.cancel));
const canFulfillByPayment = computed(() => order.value?.paymentStatus === "PAID" || (order.value?.paymentStatus === "PARTIALLY_PAID" && canOperateStock.value)); const canFulfillByPayment = computed(() => order.value?.paymentStatus === "PAID" || userStore.hasRoles(gaoRoleCodes.storeManager));
const photoURLList = computed(() => photoItems.value.map(item => item.url)); const photoURLList = computed(() => photoItems.value.map(item => item.url));
function toPhotoItems(attachmentList?: readonly Partial<Attachment>[]): PhotoItem[] { function toPhotoItems(attachmentList?: readonly Partial<Attachment>[]): PhotoItem[] {
@@ -312,20 +311,20 @@ function getRemainingAmountClass(amount?: number | null): "green" | "red" | "gra
} }
function getAvailableQuantityClass(item: GaoSalesOrderItem): "green" | "orange" { function getAvailableQuantityClass(item: GaoSalesOrderItem): "green" | "orange" {
return Number(item.availableQuantity || 0) === 0 ? "green" : "orange"; return Number(item.availableBaseQuantity || 0) === 0 ? "green" : "orange";
} }
function getFulfilledQuantityClass(item: GaoSalesOrderItem): "green" | "orange" { function getFulfilledQuantityClass(item: GaoSalesOrderItem): "green" | "orange" {
return Number(item.remainingQuantity || 0) === 0 ? "green" : "orange"; return Number(item.remainingBaseQuantity || 0) === 0 ? "green" : "orange";
} }
function getRemainingQuantityClass(item: GaoSalesOrderItem): "green" | "orange" { function getRemainingQuantityClass(item: GaoSalesOrderItem): "green" | "orange" {
return Number(item.remainingQuantity || 0) === 0 ? "green" : "orange"; return Number(item.remainingBaseQuantity || 0) === 0 ? "green" : "orange";
} }
function getItemFulfillmentStatus(item: GaoSalesOrderItem): { text: string; theme: "success" | "warning" | "danger" } { function getItemFulfillmentStatus(item: GaoSalesOrderItem): { text: string; theme: "success" | "warning" | "danger" } {
const fulfilledQuantity = Number(item.fulfilledQuantity || 0); const fulfilledQuantity = Number(item.fulfilledBaseQuantity || 0);
const remainingQuantity = Number(item.remainingQuantity || 0); const remainingQuantity = Number(item.remainingBaseQuantity || 0);
if (0 < fulfilledQuantity && remainingQuantity === 0) { if (0 < fulfilledQuantity && remainingQuantity === 0) {
return { text: "已完成", theme: "success" }; return { text: "已完成", theme: "success" };
} }
+3 -3
View File
@@ -48,7 +48,7 @@
:order="item" :order="item"
:can-receive="canReceive" :can-receive="canReceive"
:can-fulfill="canCreateFulfillment" :can-fulfill="canCreateFulfillment"
:can-operate-stock="canOperateStock" :can-store-manager="canStoreManager"
v-entrance-transition="index * 30" v-entrance-transition="index * 30"
@click="toDetail(item)" @click="toDetail(item)"
@receive="toPayment(item)" @receive="toPayment(item)"
@@ -73,7 +73,7 @@ import { useNavBarStore } from "@/store/navBarStore";
import type { GaoSalesOrderStatus, GaoSalesOrderType, GaoSalesPaymentStatus, GaoSalesRefundStatus, SalesOrder, SalesOrderPage } from "@/types/SalesOrder"; import type { GaoSalesOrderStatus, GaoSalesOrderType, GaoSalesPaymentStatus, GaoSalesRefundStatus, SalesOrder, SalesOrderPage } from "@/types/SalesOrder";
import { getErrorMessage } from "@/utils/error"; import { getErrorMessage } from "@/utils/error";
import { Message } from "@/utils/message"; import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission"; import { gaoRoleCodes, permissionCodes } from "@/utils/permission";
defineOptions({ name: "SalesOrderList" }); defineOptions({ name: "SalesOrderList" });
const route = useRoute(); const route = useRoute();
@@ -84,7 +84,7 @@ const canCreate = computed(() => userStore.hasPermissions(permissionCodes.salesO
const canCreatePackageOrder = computed(() => userStore.hasPermissions(permissionCodes.customerPackage.create)); const canCreatePackageOrder = computed(() => userStore.hasPermissions(permissionCodes.customerPackage.create));
const canReceive = computed(() => userStore.hasPermissions(permissionCodes.fundReceive.create)); const canReceive = computed(() => userStore.hasPermissions(permissionCodes.fundReceive.create));
const canCreateFulfillment = computed(() => userStore.hasPermissions(permissionCodes.orderFulfillment.create)); const canCreateFulfillment = computed(() => userStore.hasPermissions(permissionCodes.orderFulfillment.create));
const canOperateStock = computed(() => userStore.hasPermissions(permissionCodes.productStock.create)); const canStoreManager = computed(() => userStore.hasRoles(gaoRoleCodes.storeManager));
const actions = computed(() => [ const actions = computed(() => [
...(canCreate.value ? [{ ...(canCreate.value ? [{
key: "create", key: "create",
@@ -100,7 +100,7 @@ const props = defineProps<{
order: SalesOrder; order: SalesOrder;
canReceive: boolean; canReceive: boolean;
canFulfill: boolean; canFulfill: boolean;
canOperateStock: boolean; canStoreManager: boolean;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
click: []; click: [];
@@ -113,10 +113,10 @@ const avatarText = computed(() => customerName.value.slice(0, 1) || "客");
const photoURL = computed(() => getCustomerPhotoURL(props.order.customer, 64)); const photoURL = computed(() => getCustomerPhotoURL(props.order.customer, 64));
const orderNoText = computed(() => `订单号:${props.order.orderNo || props.order.id || "未知"}`); const orderNoText = computed(() => `订单号:${props.order.orderNo || props.order.id || "未知"}`);
const totalProductQuantity = computed(() => sumProductQuantity("quantity")); const totalProductQuantity = computed(() => sumProductQuantity("quantity"));
const fulfilledProductQuantity = computed(() => sumProductQuantity("fulfilledQuantity")); const fulfilledProductQuantity = computed(() => sumProductQuantity("fulfilledBaseQuantity"));
const productSummaryText = computed(() => `共 ${itemList.value.length} 项 · 总数量 ${totalProductQuantity.value} · 已履约 ${fulfilledProductQuantity.value}`); const productSummaryText = computed(() => `共 ${itemList.value.length} 项 · 订单数量 ${totalProductQuantity.value} · 已履约(基础)${fulfilledProductQuantity.value}`);
const showReceiveAction = computed(() => props.canReceive && props.order.status !== "CANCELLED" && props.order.paymentStatus !== "PAID"); const showReceiveAction = computed(() => props.canReceive && props.order.status !== "CANCELLED" && props.order.paymentStatus !== "PAID");
const canFulfillByPayment = computed(() => props.order.paymentStatus === "PAID" || (props.order.paymentStatus === "PARTIALLY_PAID" && props.canOperateStock)); const canFulfillByPayment = computed(() => props.order.paymentStatus === "PAID" || props.canStoreManager);
const showFulfillAction = computed(() => props.canFulfill && canFulfillByPayment.value && props.order.status === "ACTIVE"); const showFulfillAction = computed(() => props.canFulfill && canFulfillByPayment.value && props.order.status === "ACTIVE");
function getPaymentStatusTheme(status?: GaoSalesPaymentStatus): "success" | "warning" | "danger" | "default" { function getPaymentStatusTheme(status?: GaoSalesPaymentStatus): "success" | "warning" | "danger" | "default" {
@@ -132,7 +132,7 @@ function getPaymentStatusTheme(status?: GaoSalesPaymentStatus): "success" | "war
return "default"; return "default";
} }
function sumProductQuantity(field: "quantity" | "fulfilledQuantity"): string { function sumProductQuantity(field: "quantity" | "fulfilledBaseQuantity"): string {
let total = 0; let total = 0;
for (const item of itemList.value) { for (const item of itemList.value) {
const quantity = Number(item[field] || 0); const quantity = Number(item[field] || 0);
+5 -4
View File
@@ -12,10 +12,10 @@ export type OrderFulfillmentItem = {
sourceOrderItemId?: string; sourceOrderItemId?: string;
quantity?: number | string; quantity?: number | string;
baseQuantity?: number | string; baseQuantity?: number | string;
reservedBefore?: number | string; reservedBaseBefore?: number | string;
reservedAfter?: number | string; reservedBaseAfter?: number | string;
fulfilledBefore?: number | string; fulfilledBaseBefore?: number | string;
fulfilledAfter?: number | string; fulfilledBaseAfter?: number | string;
productNameSnapshot?: string; productNameSnapshot?: string;
productIdSnapshot?: string; productIdSnapshot?: string;
skuCodeSnapshot?: string; skuCodeSnapshot?: string;
@@ -70,6 +70,7 @@ export type OrderFulfillmentCreateRequest = {
export type GaoOrderFulfillmentItemRequest = { export type GaoOrderFulfillmentItemRequest = {
sourceOrderItemId: string; sourceOrderItemId: string;
skuUnitId: string;
quantity: number | string; quantity: number | string;
}; };
+6 -5
View File
@@ -27,13 +27,14 @@ export type GaoSalesOrderItem = {
unitSnapshot?: string; unitSnapshot?: string;
baseQuantityPerUnitSnapshot?: number | string; baseQuantityPerUnitSnapshot?: number | string;
quantity?: number | string; quantity?: number | string;
reservedQuantity?: number | string; baseQuantity?: number | string;
fulfilledQuantity?: number | string; reservedBaseQuantity?: number | string;
fulfilledBaseQuantity?: number | string;
unitPrice?: number | null; unitPrice?: number | null;
lineAmount?: number | null; lineAmount?: number | null;
pendingQuantity?: number | string; pendingBaseQuantity?: number | string;
availableQuantity?: number | string; availableBaseQuantity?: number | string;
remainingQuantity?: number | string; remainingBaseQuantity?: number | string;
sort?: number; sort?: number;
remark?: string | null; remark?: string | null;
} & Model; } & Model;
+57 -3
View File
@@ -59,6 +59,44 @@ export function comparePackageQuantity(left: number | string, right: number | st
return leftValue < rightValue ? -1 : rightValue < leftValue ? 1 : 0; return leftValue < rightValue ? -1 : rightValue < leftValue ? 1 : 0;
} }
/** 将当前单位数量精确换算为最多三位小数的基础单位数量。 */
export function toPackageBaseQuantity(quantity: number | string, baseQuantityPerUnit: number | string): string | undefined {
const quantityParts = toDecimalParts(quantity, 3);
const unitParts = toDecimalParts(baseQuantityPerUnit, 6);
if (!quantityParts || !unitParts) {
return undefined;
}
return formatDecimal(quantityParts.value * unitParts.value, quantityParts.scale + unitParts.scale, 3);
}
/** 将数量从一个单位精确换算到另一个单位,结果最多保留三位小数。 */
export function convertPackageQuantity(quantity: number | string, fromBaseQuantityPerUnit: number | string, toBaseQuantityPerUnit: number | string): string | undefined {
const quantityParts = toDecimalParts(quantity, 3);
const fromParts = toDecimalParts(fromBaseQuantityPerUnit, 6);
const toParts = toDecimalParts(toBaseQuantityPerUnit, 6);
if (!quantityParts || !fromParts || !toParts || toParts.value === 0n) {
return undefined;
}
const numerator = quantityParts.value * fromParts.value * 10n ** BigInt(toParts.scale + 3);
const denominator = 10n ** BigInt(quantityParts.scale + fromParts.scale) * toParts.value;
if (numerator % denominator !== 0n) {
return undefined;
}
return formatDecimal(numerator / denominator, 3, 3);
}
/** 精确相加两个最多三位小数的数量。 */
export function addPackageQuantity(left: number | string, right: number | string): string | undefined {
const leftParts = toQuantityParts(left);
const rightParts = toQuantityParts(right);
if (!leftParts || !rightParts) {
return undefined;
}
const scale = Math.max(leftParts.scale, rightParts.scale);
const value = leftParts.value * 10n ** BigInt(scale - leftParts.scale) + rightParts.value * 10n ** BigInt(scale - rightParts.scale);
return formatDecimal(value, scale, 3);
}
export function getPackageQuantityText(value?: number | string | null): string { export function getPackageQuantityText(value?: number | string | null): string {
if (value === undefined || value === null || value === "") { if (value === undefined || value === null || value === "") {
return "0"; return "0";
@@ -79,13 +117,29 @@ export function getFundStatusText(status?: FundRecordStatus): string {
} }
function toQuantityParts(value: number | string): { value: bigint; scale: number } | undefined { function toQuantityParts(value: number | string): { value: bigint; scale: number } | undefined {
const normalized = normalizePackageQuantity(value); return toDecimalParts(value, 3);
if (!normalized) { }
function toDecimalParts(value: number | string, maxScale: number): { value: bigint; scale: number } | undefined {
const text = String(value).trim();
if (!new RegExp(`^\\d+(?:\\.\\d{1,${maxScale}})?$`).test(text)) {
return undefined; return undefined;
} }
const [integerText, fractionText = ""] = normalized.split("."); const [integerText, fractionText = ""] = text.split(".");
return { return {
value: BigInt(`${integerText}${fractionText}`), value: BigInt(`${integerText}${fractionText}`),
scale: fractionText.length scale: fractionText.length
}; };
} }
function formatDecimal(value: bigint, scale: number, maxScale: number): string | undefined {
while (maxScale < scale && value % 10n === 0n) {
value /= 10n;
scale--;
}
if (maxScale < scale) {
return undefined;
}
const text = value.toString().padStart(scale + 1, "0");
return normalizePackageQuantity(scale ? `${text.slice(0, -scale)}.${text.slice(-scale)}` : text);
}