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
+155 -13
View File
@@ -80,7 +80,7 @@
<t-form class="items" label-width="2.5rem">
<div class="title">
<strong>购买权益明细</strong>
<span v-if="!canPriceOverride">当前账号无定制权限,明细不可修改</span>
<span v-if="!canPriceOverride">当前账号无定制权限,仅可等值转换单位</span>
</div>
<t-form-item
v-for="(item, index) in packagePurchaseItemList"
@@ -102,7 +102,15 @@
/>
</template>
<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
:model-value="item.totalQuantity"
type="number"
@@ -113,9 +121,18 @@
@update:model-value="updateItemQuantity(index, $event)"
>
<template #suffix-icon>
<span class="unit" v-text="getItemUnit(item)" />
<span class="unit" v-text="getItemUnit(item)?.name || ''" />
</template>
</t-input>
<t-button
v-if="canPriceOverride"
class="remove"
size="extra-small"
theme="danger"
variant="text"
content="移除"
@click="removePurchaseItem(index)"
/>
</div>
</t-form-item>
<div v-if="!packagePurchaseItemList.length" class="center-tips" v-text="'当前套餐暂无权益明细'" />
@@ -185,6 +202,9 @@
<div v-else class="center-tips" v-text="'暂无可购买套餐'" />
</div>
</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">
<div class="selected-customer-popup bg-white">
<div class="popup-header">
@@ -221,6 +241,7 @@ import { Time } from "timi-web";
import { useUserStore } from "timi-web-api";
import CustomerAPI from "@/api/CustomerAPI";
import PackageAPI from "@/api/PackageAPI";
import ProductAPI from "@/api/ProductAPI";
import CodeScanner from "@/components/CodeScanner.vue";
import CustomerList from "@/components/CustomerList.vue";
import CustomerProfileCard from "@/components/CustomerProfileCard.vue";
@@ -232,8 +253,9 @@ import { useSettingStore } from "@/store/settingStore";
import type { Customer } from "@/types/Customer";
import type { PhotoItem } from "@/types/Photo";
import type { GaoPackageConfig, PackageAttachment, PackagePurchaseItem } from "@/types/Package";
import type { ProductSku, ProductSkuUnit } from "@/types/Product";
import { getErrorMessage } from "@/utils/error";
import { formatPackageAmount } from "@/utils/package";
import { addPackageQuantity, convertPackageQuantity, formatPackageAmount, normalizePackageQuantity } from "@/utils/package";
import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission";
import { uploadPhotoList } from "@/utils/photo";
@@ -256,6 +278,10 @@ const isCustomerPackageCreate = computed(() => route.name === "CustomerPackageCr
const config = ref<GaoPackageConfig>();
const configList = ref<GaoPackageConfig[]>([]);
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
});
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 {
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) {
return "产品权益";
}
@@ -416,16 +447,78 @@ function getItemLabel(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 {
const configItem = config.value?.itemList?.find(configItem => configItem.skuId === item.skuId && configItem.skuUnitId === item.skuUnitId);
return getProductPhotoURL(configItem?.sku || {});
return getProductPhotoURL(getItemSku(item) || {});
}
function getItemUnit(item: PackagePurchaseItem): string {
return config.value?.itemList?.find(configItem => configItem.skuId === item.skuId && configItem.skuUnitId === item.skuUnitId)?.unitSnapshot || "";
function getItemSku(item: PackagePurchaseItem): ProductSku | undefined {
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 {
@@ -433,13 +526,33 @@ function updateItemQuantity(index: number, value: string | number): void {
...item,
totalQuantity: String(value)
} : 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> {
if (isSaving.value) {
return;
}
if (!validatePurchaseForm()) {
if (!validatePurchaseForm() || !validateItemDecimalQuantity()) {
return;
}
@@ -473,6 +586,7 @@ async function loadPageData(): Promise<void> {
}
config.value = await PackageAPI.configDetail(configId.value);
applyConfig(config.value);
await loadConfigSkuList(config.value);
}
async function selectConfig(item: GaoPackageConfig): Promise<void> {
@@ -480,9 +594,21 @@ async function selectConfig(item: GaoPackageConfig): Promise<void> {
return;
}
config.value = await PackageAPI.configDetail(item.id);
await loadConfigSkuList(config.value);
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 () => {
navBarStore.clearRight();
if (userStore.hasPermissions(permissionCodes.customerPackage.create)) {
@@ -608,11 +734,27 @@ watch(config, value => {
display: none;
}
.content {
.content {
display: flex;
height: 2rem;
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 {
min-width: 0;