support split unit for product order
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
v-for="item in fulfillment.itemList || []"
|
||||
:key="item.id"
|
||||
:title="item.productNameSnapshot || '未命名产品'"
|
||||
:note="`待履约 ${item.reservedBefore || 0} → ${item.reservedAfter || 0}`"
|
||||
:note="`待履约(基础单位)${item.reservedBaseBefore || 0} → ${item.reservedBaseAfter || 0}`"
|
||||
>
|
||||
<template #description>
|
||||
<div class="item-description">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -71,6 +71,16 @@ export function usePackagePurchase(options: UsePackagePurchaseOptions) {
|
||||
if (!packageRequestNo.value) {
|
||||
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({
|
||||
requestNo: packageRequestNo.value,
|
||||
packageConfigId: config.id,
|
||||
@@ -78,13 +88,7 @@ export function usePackagePurchase(options: UsePackagePurchaseOptions) {
|
||||
saleAmount,
|
||||
purchasedAt: options.buildRegisteredAt(),
|
||||
attachmentList: options.buildAttachments(),
|
||||
itemList: packagePurchaseItemList.value.length ? packagePurchaseItemList.value.map((item, index) => ({
|
||||
skuId: item.skuId,
|
||||
skuUnitId: item.skuUnitId,
|
||||
totalQuantity: normalizePackageQuantity(item.totalQuantity)!,
|
||||
sort: item.sort ?? index,
|
||||
remark: item.remark ?? null
|
||||
})) : undefined,
|
||||
itemList,
|
||||
remark: options.note.value.trim() || null
|
||||
});
|
||||
}
|
||||
@@ -110,18 +114,28 @@ export function usePackagePurchase(options: UsePackagePurchaseOptions) {
|
||||
Message.warning("成交价必须是合法的元金额,最多两位小数");
|
||||
return false;
|
||||
}
|
||||
const keySet = new Set<string>();
|
||||
let hasPositiveQuantity = false;
|
||||
for (const item of packagePurchaseItemList.value) {
|
||||
const quantity = normalizePackageQuantity(item.totalQuantity);
|
||||
if (!item.skuId || !item.skuUnitId || !quantity || quantity === "0") {
|
||||
Message.warning("SKU 单位和正数量不能为空");
|
||||
if (!item.skuId || !item.skuUnitId || !quantity) {
|
||||
Message.warning("SKU 单位和数量不能为空,数量最多三位小数");
|
||||
return false;
|
||||
}
|
||||
const configItem = options.config.value.itemList?.find(source => source.skuId === item.skuId && source.skuUnitId === item.skuUnitId);
|
||||
if (configItem?.sku && !configItem.sku.allowDecimalQuantity && quantity.includes(".")) {
|
||||
Message.warning("该 SKU 不允许小数数量");
|
||||
return false;
|
||||
if (quantity !== "0") {
|
||||
hasPositiveQuantity = true;
|
||||
const key = `${item.skuId}\u0000${item.skuUnitId}`;
|
||||
if (keySet.has(key)) {
|
||||
Message.warning("最终套餐权益不能重复相同 SKU 和单位");
|
||||
return false;
|
||||
}
|
||||
keySet.add(key);
|
||||
}
|
||||
}
|
||||
if (!hasPositiveQuantity) {
|
||||
Message.warning("套餐至少需要保留一项正数量权益");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -129,23 +129,23 @@
|
||||
<div class="content">
|
||||
<div class="metric">
|
||||
<strong v-text="getPackageQuantityText(item.quantity)" />
|
||||
<span>总数</span>
|
||||
<span>订单数量</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<strong :class="getAvailableQuantityClass(item)" v-text="getPackageQuantityText(item.availableQuantity)" />
|
||||
<span>可履约</span>
|
||||
<strong :class="getAvailableQuantityClass(item)" v-text="getPackageQuantityText(item.availableBaseQuantity)" />
|
||||
<span>可履约(基础)</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<strong v-text="getPackageQuantityText(item.pendingQuantity)" />
|
||||
<span>待核销</span>
|
||||
<strong v-text="getPackageQuantityText(item.pendingBaseQuantity)" />
|
||||
<span>待核销(基础)</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<strong :class="getFulfilledQuantityClass(item)" v-text="getPackageQuantityText(item.fulfilledQuantity)" />
|
||||
<span>已履约</span>
|
||||
<strong :class="getFulfilledQuantityClass(item)" v-text="getPackageQuantityText(item.fulfilledBaseQuantity)" />
|
||||
<span>已履约(基础)</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<strong :class="getRemainingQuantityClass(item)" v-text="getPackageQuantityText(item.remainingQuantity)" />
|
||||
<span>剩余</span>
|
||||
<strong :class="getRemainingQuantityClass(item)" v-text="getPackageQuantityText(item.remainingBaseQuantity)" />
|
||||
<span>剩余(基础)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -242,7 +242,7 @@ import {
|
||||
getStatusTheme
|
||||
} from "@/utils/salesOrder";
|
||||
import { Message } from "@/utils/message";
|
||||
import { permissionCodes } from "@/utils/permission";
|
||||
import { gaoRoleCodes, permissionCodes } from "@/utils/permission";
|
||||
import { uploadPhotoList } from "@/utils/photo";
|
||||
|
||||
defineOptions({ name: "SalesOrderDetail" });
|
||||
@@ -261,12 +261,11 @@ const canReadFund = computed(() => userStore.hasPermissions(permissionCodes.fund
|
||||
const canReadMyReceive = computed(() => userStore.hasPermissions(permissionCodes.fundReceive.create));
|
||||
const canUpdate = computed(() => userStore.hasPermissions(permissionCodes.salesOrder.update));
|
||||
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 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 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));
|
||||
|
||||
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" {
|
||||
return Number(item.availableQuantity || 0) === 0 ? "green" : "orange";
|
||||
return Number(item.availableBaseQuantity || 0) === 0 ? "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" {
|
||||
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" } {
|
||||
const fulfilledQuantity = Number(item.fulfilledQuantity || 0);
|
||||
const remainingQuantity = Number(item.remainingQuantity || 0);
|
||||
const fulfilledQuantity = Number(item.fulfilledBaseQuantity || 0);
|
||||
const remainingQuantity = Number(item.remainingBaseQuantity || 0);
|
||||
if (0 < fulfilledQuantity && remainingQuantity === 0) {
|
||||
return { text: "已完成", theme: "success" };
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
:order="item"
|
||||
:can-receive="canReceive"
|
||||
:can-fulfill="canCreateFulfillment"
|
||||
:can-operate-stock="canOperateStock"
|
||||
:can-store-manager="canStoreManager"
|
||||
v-entrance-transition="index * 30"
|
||||
@click="toDetail(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 { getErrorMessage } from "@/utils/error";
|
||||
import { Message } from "@/utils/message";
|
||||
import { permissionCodes } from "@/utils/permission";
|
||||
import { gaoRoleCodes, permissionCodes } from "@/utils/permission";
|
||||
|
||||
defineOptions({ name: "SalesOrderList" });
|
||||
const route = useRoute();
|
||||
@@ -84,7 +84,7 @@ const canCreate = computed(() => userStore.hasPermissions(permissionCodes.salesO
|
||||
const canCreatePackageOrder = computed(() => userStore.hasPermissions(permissionCodes.customerPackage.create));
|
||||
const canReceive = computed(() => userStore.hasPermissions(permissionCodes.fundReceive.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(() => [
|
||||
...(canCreate.value ? [{
|
||||
key: "create",
|
||||
|
||||
@@ -100,7 +100,7 @@ const props = defineProps<{
|
||||
order: SalesOrder;
|
||||
canReceive: boolean;
|
||||
canFulfill: boolean;
|
||||
canOperateStock: boolean;
|
||||
canStoreManager: boolean;
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
click: [];
|
||||
@@ -113,10 +113,10 @@ const avatarText = computed(() => customerName.value.slice(0, 1) || "客");
|
||||
const photoURL = computed(() => getCustomerPhotoURL(props.order.customer, 64));
|
||||
const orderNoText = computed(() => `订单号:${props.order.orderNo || props.order.id || "未知"}`);
|
||||
const totalProductQuantity = computed(() => sumProductQuantity("quantity"));
|
||||
const fulfilledProductQuantity = computed(() => sumProductQuantity("fulfilledQuantity"));
|
||||
const productSummaryText = computed(() => `共 ${itemList.value.length} 项 · 总数量 ${totalProductQuantity.value} · 已履约 ${fulfilledProductQuantity.value}`);
|
||||
const fulfilledProductQuantity = computed(() => sumProductQuantity("fulfilledBaseQuantity"));
|
||||
const productSummaryText = computed(() => `共 ${itemList.value.length} 项 · 订单数量 ${totalProductQuantity.value} · 已履约(基础)${fulfilledProductQuantity.value}`);
|
||||
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");
|
||||
|
||||
function getPaymentStatusTheme(status?: GaoSalesPaymentStatus): "success" | "warning" | "danger" | "default" {
|
||||
@@ -132,7 +132,7 @@ function getPaymentStatusTheme(status?: GaoSalesPaymentStatus): "success" | "war
|
||||
return "default";
|
||||
}
|
||||
|
||||
function sumProductQuantity(field: "quantity" | "fulfilledQuantity"): string {
|
||||
function sumProductQuantity(field: "quantity" | "fulfilledBaseQuantity"): string {
|
||||
let total = 0;
|
||||
for (const item of itemList.value) {
|
||||
const quantity = Number(item[field] || 0);
|
||||
|
||||
@@ -12,10 +12,10 @@ export type OrderFulfillmentItem = {
|
||||
sourceOrderItemId?: string;
|
||||
quantity?: number | string;
|
||||
baseQuantity?: number | string;
|
||||
reservedBefore?: number | string;
|
||||
reservedAfter?: number | string;
|
||||
fulfilledBefore?: number | string;
|
||||
fulfilledAfter?: number | string;
|
||||
reservedBaseBefore?: number | string;
|
||||
reservedBaseAfter?: number | string;
|
||||
fulfilledBaseBefore?: number | string;
|
||||
fulfilledBaseAfter?: number | string;
|
||||
productNameSnapshot?: string;
|
||||
productIdSnapshot?: string;
|
||||
skuCodeSnapshot?: string;
|
||||
@@ -70,6 +70,7 @@ export type OrderFulfillmentCreateRequest = {
|
||||
|
||||
export type GaoOrderFulfillmentItemRequest = {
|
||||
sourceOrderItemId: string;
|
||||
skuUnitId: string;
|
||||
quantity: number | string;
|
||||
};
|
||||
|
||||
|
||||
@@ -27,13 +27,14 @@ export type GaoSalesOrderItem = {
|
||||
unitSnapshot?: string;
|
||||
baseQuantityPerUnitSnapshot?: number | string;
|
||||
quantity?: number | string;
|
||||
reservedQuantity?: number | string;
|
||||
fulfilledQuantity?: number | string;
|
||||
baseQuantity?: number | string;
|
||||
reservedBaseQuantity?: number | string;
|
||||
fulfilledBaseQuantity?: number | string;
|
||||
unitPrice?: number | null;
|
||||
lineAmount?: number | null;
|
||||
pendingQuantity?: number | string;
|
||||
availableQuantity?: number | string;
|
||||
remainingQuantity?: number | string;
|
||||
pendingBaseQuantity?: number | string;
|
||||
availableBaseQuantity?: number | string;
|
||||
remainingBaseQuantity?: number | string;
|
||||
sort?: number;
|
||||
remark?: string | null;
|
||||
} & Model;
|
||||
|
||||
+57
-3
@@ -59,6 +59,44 @@ export function comparePackageQuantity(left: number | string, right: number | st
|
||||
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 {
|
||||
if (value === undefined || value === null || value === "") {
|
||||
return "0";
|
||||
@@ -79,13 +117,29 @@ export function getFundStatusText(status?: FundRecordStatus): string {
|
||||
}
|
||||
|
||||
function toQuantityParts(value: number | string): { value: bigint; scale: number } | undefined {
|
||||
const normalized = normalizePackageQuantity(value);
|
||||
if (!normalized) {
|
||||
return toDecimalParts(value, 3);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
const [integerText, fractionText = ""] = normalized.split(".");
|
||||
const [integerText, fractionText = ""] = text.split(".");
|
||||
return {
|
||||
value: BigInt(`${integerText}${fractionText}`),
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user