Files
gao-customer-h5/src/pages/package/PackagePurchase.vue
T
2026-09-21 22:54:30 +08:00

1200 lines
36 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div v-can="permissionCodes.customerPackage.create" class="package-purchase">
<t-form class="form" label-align="left" v-entrance-transition>
<t-form-item label="客户" required-mark>
<t-input
:model-value="customerDisplayText"
class="customer"
readonly
borderless
placeholder="选择或扫码客户"
@click="openSelectedCustomerList()"
>
<template #suffix-icon>
<div class="actions">
<t-button
theme="primary"
variant="outline"
size="extra-small"
@click.stop="selectorVisible = true"
>选择</t-button>
<t-button
theme="primary"
size="extra-small"
@click.stop="toggleScanner"
>
<span v-text="scannerVisible ? '关闭扫码' : '扫码'" />
</t-button>
</div>
</template>
</t-input>
</t-form-item>
<div v-if="scannerVisible" class="code-scanner-wrapper">
<code-scanner
visible
:status-text="scanStatus"
active-text="正在识别客户编码,请将条码或二维码移入取景框。"
@decode="handleDecodedText"
@status-change="handleScanStatusChange"
/>
</div>
<customer-profile-card
v-if="selectedCustomer"
class="customer-profile"
:customer="selectedCustomer"
v-entrance-transition
/>
<t-form-item
v-if="isCustomerPackageCreate"
label="套餐"
required-mark
content-align="right"
arrow
@click="configSelectorVisible = true"
>选择套餐</t-form-item>
<div v-if="config && !isSaleable" class="saleable-tip red">套餐当前不可购买,请检查套餐状态和有效期</div>
<t-form-item v-if="config" label-width="0">
<div class="config-summary">
<div class="header">
<div class="name text-bold clip-text" v-text="config.name || '未命名套餐'" />
<div class="price">
<span class="label gray">标准价:</span>
<span class="value red text-bold" v-text="formatPackageAmount(config.standardAmount)" />
</div>
</div>
<div class="description" v-text="config.description" />
</div>
</t-form-item>
<t-form-item label="成交价" label-width="6rem" required-mark>
<t-input
v-model.trim="saleAmountText"
type="number"
align="right"
borderless
:readonly="!canPriceOverride"
placeholder="请输入成交价"
/>
</t-form-item>
<t-form-item v-if="config" label="购买权益明细" label-align="top">
<t-form class="items" label-width="2.5rem">
<span v-if="!canPriceOverride">当前账号无定制权限,仅可等值转换单位</span>
<t-form-item
v-for="(item, index) in packagePurchaseItemList"
:key="`${index}-${item.skuId}-${item.skuUnitId}-${item.gifted}`"
class="item"
>
<template #label>
<t-image
v-if="getItemPhotoURL(item)"
:src="getItemPhotoURL(item)"
:alt="getItemName(item)"
fit="cover"
class="avatar"
/>
<div
v-else
class="avatar fallback"
v-text="getItemName(item).slice(0, 1)"
/>
</template>
<div class="content">
<div class="main">
<span class="name" v-text="getItemLabel(item)" />
<t-tag v-if="item.gifted" size="small" theme="success" content="赠" />
<t-input
class="input"
:model-value="item.totalQuantity"
type="number"
:readonly="!canEditItem(item)"
placeholder="总权益数量"
borderless
align="right"
@update:model-value="updateItemQuantity(index, $event)"
>
</t-input>
<div class="actions">
<t-button
size="extra-small"
variant="outline"
:content="getItemUnit(item)?.name || '选择单位'"
@click="openItemUnitPicker(index)"
/>
<t-button
v-if="canRemoveItem(item)"
class="remove"
size="extra-small"
theme="danger"
variant="text"
content="移除"
@click="removePurchaseItem(index)"
/>
</div>
</div>
<t-input
v-if="item.gifted"
class="gift-remark"
:model-value="item.remark || ''"
align="right"
borderless
placeholder="赠送说明(选填)"
@update:model-value="updateItemRemark(index, $event)"
/>
</div>
</t-form-item>
<product-selector
v-if="canGiftCreate"
:model-value="undefined"
label=""
placeholder="添加可售赠送产品"
@update:model-value="addGiftProduct"
/>
<div v-if="!packagePurchaseItemList.length" class="center-tips" v-text="'当前套餐暂无权益明细'" />
</t-form>
</t-form-item>
<t-form-item label="购买时间" content-align="right">
<div class="time-actions">
<t-button
theme="primary"
variant="outline"
size="extra-small"
:content="purchasedAtText || '此刻'"
@click="openPurchasedAtPicker"
/>
<t-button
v-if="purchasedAtText"
theme="primary"
variant="text"
size="extra-small"
content="此刻"
@click="resetPurchasedAt"
/>
</div>
</t-form-item>
<t-form-item
v-if="canReceiveImmediately"
class="payment-item"
:class="{ disabled: receiveFullPayment }"
:label="receiveFullPayment ? '收款和核销金额' : '已部分收款和核销'"
label-width="9rem"
content-align="right"
>
<t-input
:model-value="receiveFullPayment ? formatPackageAmount(parsedSaleAmount || 0) : paymentAmountText"
type="number"
align="right"
borderless
:disabled="receiveFullPayment"
:placeholder="paymentAmountPlaceholder"
@update:model-value="paymentAmountText = String($event)"
/>
</t-form-item>
<t-form-item label="已全额收款和核销" content-align="right">
<t-switch
v-model="receiveFullPayment"
size="small"
:disabled="!canReceiveImmediately"
/>
</t-form-item>
<t-form-item label="已全部产品履约和核销" content-align="right">
<t-switch
v-model="fulfillAll"
size="small"
:disabled="!canFulfillImmediately || isStockChecking"
/>
</t-form-item>
<t-form-item label-width="0">
<div class="stock-tips">
<div class="tips red">全部履约会同步启用全额收款;请先准备充足的库存,任意一步失败都会下单失败。</div>
<product-stock-preview v-if="stockPreviewList.length" :items="stockPreviewList" />
</div>
</t-form-item>
<t-form-item v-if="(paymentVerified && parsedPaymentAmount) || (receiveFullPayment && parsedSaleAmount)" label-width="0">
<media-selector
:model-value="paymentPhotoUrlList"
v-model:photo-items="paymentPhotoItems"
:upload="uploadPhotoList"
:edit-mode="true"
title="收款凭证照片(必要)"
/>
</t-form-item>
<t-form-item v-if="fulfillAll" label-width="0">
<media-selector
:model-value="fulfillmentPhotoUrlList"
v-model:photo-items="fulfillmentPhotoItems"
:upload="uploadPhotoList"
:edit-mode="true"
title="履约凭证照片(必要)"
/>
</t-form-item>
<t-form-item label="购买备注">
<t-textarea
v-model.trim="note"
class="textarea"
:autosize="{ minRows: 3, maxRows: 5 }"
placeholder="购买说明"
/>
</t-form-item>
<t-form-item label-width="0">
<media-selector
:model-value="photoUrlList"
v-model:photo-items="photoItems"
:upload="uploadPhotoList"
:edit-mode="true"
title="订单照片"
/>
</t-form-item>
</t-form>
<customer-selector
v-model="selectedCustomerId"
v-model:visible="selectorVisible"
selection-mode="single"
:enable-scan="false"
@change="handleCustomerChange"
/>
<bottom-popup
v-model="configSelectorVisible"
title="选择套餐"
height="70vh"
right-button-text=""
@left-button-click="configSelectorVisible = false"
>
<div class="config-selector fix-cell-title-clip empty-cell-title-margin">
<t-cell-group v-if="configList.length">
<t-cell
v-for="item in configList"
:key="item.id"
class="config-option"
arrow
@click="selectConfig(item)"
>
<template #title>
<div class="title">
<div class="clip-text" v-text="item.name || '未命名套餐'" />
<div class="price keep-text">
<span class="label gray">标准价:</span>
<span class="red" v-text="formatPackageAmount(item.standardAmount)" />
</div>
</div>
</template>
<template #description>
<div class="products">
<t-tag
v-for="productItem in item.itemList || []"
:key="productItem.id || `${productItem.skuId}-${productItem.skuUnitId}`"
:content="getConfigProductText(productItem)"
theme="primary"
variant="light"
/>
<t-tag v-if="!item.itemList?.length" content="暂无产品权益" />
</div>
</template>
</t-cell>
</t-cell-group>
<div v-else class="center-tips" v-text="'暂无可购买套餐'" />
</div>
</bottom-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">
<t-button variant="text" size="small" @click="selectedCustomerListVisible = false">关闭</t-button>
<h3>已选客户</h3>
<t-button theme="primary" variant="text" size="small" @click="openCustomerSelector">选择</t-button>
</div>
<customer-list
class="list"
:customer-list="selectedCustomerList"
removable
empty-text="暂未选择客户"
@remove="removeSelectedCustomer"
/>
</div>
</t-popup>
</div>
<t-popup v-model="purchasedAtPickerVisible" placement="bottom">
<t-date-time-picker
v-if="purchasedAtPickerVisible"
v-model="purchasedAtDraftText"
:start="minimumDateTimeText"
:mode="['date', 'minute']"
format="YYYY-MM-DD HH:mm"
title="购买时间"
@cancel="purchasedAtPickerVisible = false"
@confirm="confirmPurchasedAt"
/>
</t-popup>
</template>
<script setup lang="ts">
import { Time } from "timi-web";
import { useUserStore } from "timi-web-api";
import { BottomPopup } from "timi-tdesign-mobile";
import { DialogPlugin } from "tdesign-mobile-vue";
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";
import CustomerSelector from "@/components/CustomerSelector.vue";
import MediaSelector from "@/components/mediaSelector/MediaSelector.vue";
import ProductStockPreview from "@/components/ProductStockPreview.vue";
import { usePackagePurchase } from "@/pages/package/usePackagePurchase";
import { useNavBarStore } from "@/store/navBarStore";
import { useSettingStore } from "@/store/settingStore";
import type { Customer } from "@/types/Customer";
import type { PhotoItem } from "@/types/Photo";
import type { GaoPackageConfig, PackageAttachment, PackageConfigItem, PackagePurchaseItem } from "@/types/Package";
import type { Product, ProductSku, ProductSkuUnit } from "@/types/Product";
import { getErrorMessage } from "@/utils/error";
import {
addPackageQuantity,
convertPackageQuantity,
formatPackageAmount,
getPackageQuantityText,
normalizePackageQuantity,
parsePackageAmount,
toPackageBaseQuantity
} from "@/utils/package";
import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission";
import { buildProductStockPreview, getProductStockShortageMessage, mergeProductStockRequirements } from "@/utils/productStock";
import type { ProductStockPreviewItem } from "@/utils/productStock";
import { uploadPhotoList } from "@/utils/photo";
import { getProductPhotoURL, getProductSkuSnapshotText } from "@/utils/product";
defineOptions({
name: "PackagePurchase"
});
const route = useRoute();
const router = useRouter();
const navBarStore = useNavBarStore();
const settingStore = useSettingStore();
const userStore = useUserStore();
// ---------- 套餐配置 ----------
const configId = computed(() => String(route.params.id || ""));
const isCustomerPackageCreate = computed(() => route.name === "CustomerPackageCreate");
const config = ref<GaoPackageConfig>();
const configList = ref<GaoPackageConfig[]>([]);
const configSelectorVisible = ref(false);
const skuMap = reactive<Record<string, ProductSku>>({});
const giftProductMap = reactive<Record<string, Product>>({});
const unitPickerVisible = ref(false);
const unitPickerIndex = ref(-1);
const unitPickerDraft = ref<Array<string | number>>([]);
// ---------- 客户 ----------
const customerCode = ref("");
const selectorVisible = ref(false);
const selectedCustomerListVisible = ref(false);
const selectedCustomerId = ref("");
const selectedCustomerList = ref<Customer[]>([]);
const selectedCustomer = computed(() => selectedCustomerList.value[0]);
const customerDisplayText = computed(() => selectedCustomer.value?.name || selectedCustomer.value?.code || customerCode.value);
async function handleCustomerChange(value: string | string[]): Promise<void> {
selectedCustomerId.value = Array.isArray(value) ? value[0] || "" : value;
resetRequestNo();
await loadSelectedCustomer();
}
async function loadSelectedCustomer(): Promise<void> {
if (!selectedCustomerId.value) {
selectedCustomerList.value = [];
return;
}
const customer = await CustomerAPI.detail(selectedCustomerId.value);
selectedCustomerList.value = customer?.id ? [customer] : [];
}
function openSelectedCustomerList(): void {
selectedCustomerListVisible.value = true;
}
async function openCustomerSelector(): Promise<void> {
selectedCustomerListVisible.value = false;
await nextTick();
selectorVisible.value = true;
}
async function removeSelectedCustomer(): Promise<void> {
selectedCustomerId.value = "";
selectedCustomerList.value = [];
}
// ---------- 扫码 ----------
const scannerVisible = ref(false);
const scanStatus = ref("点击扫码开始识别。");
let latestDecodedText = "";
async function toggleScanner(): Promise<void> {
scannerVisible.value = !scannerVisible.value;
if (scannerVisible.value) {
latestDecodedText = "";
}
}
async function handleDecodedText(rawText: string): Promise<void> {
const normalizedText = rawText.trim();
if (!normalizedText || normalizedText === latestDecodedText) {
return;
}
latestDecodedText = normalizedText;
await settingStore.playScanSuccessSound();
try {
const customer = await CustomerAPI.findByCode(normalizedText);
if (!customer?.id) {
scanStatus.value = `客户 ${normalizedText} 不存在,请先建档。`;
return;
}
selectedCustomerId.value = customer.id;
await loadSelectedCustomer();
resetRequestNo();
scanStatus.value = `已识别 ${customer.code || normalizedText},已选中 ${customer.name || "未命名客户"}。`;
scannerVisible.value = false;
} catch {
scanStatus.value = "客户查询失败,请重试。";
}
}
function handleScanStatusChange(value: string): void {
scanStatus.value = value;
}
// ---------- 购买时间 ----------
const minimumDateTimeText = "1970-01-01 00:00";
const purchasedAtText = ref("");
const purchasedAtDraftText = ref("");
const purchasedAtPickerVisible = ref(false);
function openPurchasedAtPicker(): void {
purchasedAtDraftText.value = purchasedAtText.value || Time.formatDateTime(new Date());
purchasedAtPickerVisible.value = true;
}
function confirmPurchasedAt(value?: string | number): void {
purchasedAtText.value = String(value || purchasedAtDraftText.value);
purchasedAtPickerVisible.value = false;
}
function resetPurchasedAt(): void {
purchasedAtText.value = "";
purchasedAtDraftText.value = "";
}
function buildPurchasedAt(): number {
return Time.toTimestamp(parseDateTime(purchasedAtText.value));
}
function parseDateTime(value: string): Date {
if (!value) {
return new Date();
}
const [dateText, timeText = "00:00"] = value.split(" ");
const [year, month, day] = dateText.split("-").map(Number);
const [hour, minute] = timeText.split(":").map(Number);
return new Date(year, month - 1, day, hour || 0, minute || 0, 0, 0);
}
// ---------- 购买 ----------
const note = ref("");
const isSaving = ref(false);
const photoItems = ref<PhotoItem[]>([]);
const paymentPhotoItems = ref<PhotoItem[]>([]);
const fulfillmentPhotoItems = ref<PhotoItem[]>([]);
const paymentAmountText = ref("0");
const photoUrlList = computed(() => photoItems.value.map(item => item.url));
const paymentPhotoUrlList = computed(() => paymentPhotoItems.value.map(item => item.url));
const fulfillmentPhotoUrlList = computed(() => fulfillmentPhotoItems.value.map(item => item.url));
const receiveFullPayment = ref(false);
const fulfillAll = ref(false);
const isStockChecking = ref(false);
const stockPreviewList = ref<ProductStockPreviewItem[]>([]);
let stockCheckToken = 0;
const parsedPaymentAmount = computed(() => parsePackageAmount(paymentAmountText.value));
const paymentVerified = computed(() => !receiveFullPayment.value
&& 0 < paymentAmountText.value.trim().length
&& parsedPaymentAmount.value !== 0);
function buildAttachments(source: PhotoItem[] = photoItems.value): PackageAttachment[] {
return source.flatMap(item => item.tempFileId ? [{
tempFileId: item.tempFileId,
attachType: item.attachType || "PHOTO"
}] : []);
}
const {
isSaleable,
canPriceOverride,
canGiftCreate,
saleAmountText,
packagePurchaseItemList,
applyConfig,
resetRequestNo,
submitPurchase,
validatePurchaseForm
} = usePackagePurchase({
config,
selectedCustomerList,
note,
paymentVerified,
paymentAmount: parsedPaymentAmount,
receiveFullPayment,
fulfillAll,
hasPermission: permissionCode => userStore.hasPermissions(permissionCode),
buildRegisteredAt: buildPurchasedAt,
buildAttachments,
buildPaymentAttachments: () => buildAttachments(paymentPhotoItems.value),
buildFulfillmentAttachments: () => buildAttachments(fulfillmentPhotoItems.value)
});
const parsedSaleAmount = computed(() => parsePackageAmount(saleAmountText.value));
const paymentAmountPlaceholder = computed(() => `订单总金额:${formatPackageAmount(parsedSaleAmount.value || 0)}`);
const canReceiveImmediately = computed(() => userStore.hasPermissions(permissionCodes.fundReceive.create)
&& userStore.hasPermissions(permissionCodes.fundReceive.verify));
const canFulfillImmediately = computed(() => userStore.hasPermissions(permissionCodes.orderFulfillment.create)
&& userStore.hasPermissions(permissionCodes.orderFulfillment.verify)
&& userStore.hasPermissions(permissionCodes.productStock.read)
&& (parsedSaleAmount.value === 0 || canReceiveImmediately.value));
watch(fulfillAll, async value => {
stockCheckToken += 1;
const token = stockCheckToken;
if (!value) {
isStockChecking.value = false;
return;
}
isStockChecking.value = true;
try {
if (!await validateDirectStock() && token === stockCheckToken && fulfillAll.value) {
fulfillAll.value = false;
return;
}
if (token === stockCheckToken && fulfillAll.value && parsedSaleAmount.value !== 0) {
receiveFullPayment.value = true;
}
} finally {
if (token === stockCheckToken) {
isStockChecking.value = false;
}
}
});
watch(receiveFullPayment, value => {
if (!value && parsedSaleAmount.value !== 0 && (!paymentVerified.value || parsedPaymentAmount.value !== parsedSaleAmount.value)) {
fulfillAll.value = false;
}
});
const unitPickerOptions = computed(() => {
const item = packagePurchaseItemList.value[unitPickerIndex.value];
return item ? getSelectableUnitList(item).map(unit => ({ label: unit.name, value: unit.id || "" })) : [];
});
function getConfigProductText(item: PackageConfigItem): string {
const quantity = getPackageQuantityText(item.defaultQuantity);
const skuText = getProductSkuSnapshotText(item.productNameSnapshot, item.skuNameSnapshot, item.specSummarySnapshot);
return `${item.productNameSnapshot || "未命名产品"}${skuText ? `(${skuText})` : " "}${quantity}${item.unitSnapshot ? ` ${item.unitSnapshot}` : ""}`;
}
function getItemLabel(item: PackagePurchaseItem): string {
const configItem = config.value?.itemList?.find(configItem => configItem.skuId === item.skuId);
const product = item.gifted ? giftProductMap[item.skuId] : undefined;
const productName = item.gifted
? product?.name || item.productNameSnapshot || "未命名产品"
: configItem?.productNameSnapshot || "未命名产品";
const skuName = item.gifted ? item.skuNameSnapshot || getItemSku(item)?.name : configItem?.skuNameSnapshot;
const specSummary = item.gifted ? item.specSummarySnapshot || getItemSku(item)?.specSummary : configItem?.specSummarySnapshot;
const skuText = getProductSkuSnapshotText(productName, skuName, specSummary);
return skuText ? `${productName} · ${skuText}` : productName;
}
function getItemName(item: PackagePurchaseItem): string {
return item.gifted ? giftProductMap[item.skuId]?.name || item.productNameSnapshot || "未命名产品"
: config.value?.itemList?.find(configItem => configItem.skuId === item.skuId)?.productNameSnapshot || "未命名产品";
}
function getItemPhotoURL(item: PackagePurchaseItem): string {
return getProductPhotoURL(getItemSku(item) || {});
}
function getItemSku(item: PackagePurchaseItem): ProductSku | undefined {
return skuMap[item.skuId] || config.value?.itemList?.find(configItem => configItem.skuId === item.skuId)?.sku;
}
function syncConfigSkuMap(value?: GaoPackageConfig): void {
for (const item of value?.itemList || []) {
if (item.skuId && item.sku) {
skuMap[item.skuId] = item.sku;
}
}
}
function getItemUnit(item: PackagePurchaseItem): ProductSkuUnit | undefined {
return getItemSku(item)?.unitList?.find(unit => unit.id === item.skuUnitId);
}
function getSelectableUnitList(item: PackagePurchaseItem): ProductSkuUnit[] {
if (item.gifted) {
return (getItemSku(item)?.unitList || []).filter(unit => unit.allowSale !== false && !!unit.id);
}
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 || !canEditItem(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 || !canEditItem(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 && Boolean(candidate.gifted) === Boolean(item.gifted));
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;
}
async function addGiftProduct(value?: { skuId: string; skuUnitId: string }): Promise<void> {
if (!value || !canGiftCreate.value) {
return;
}
try {
const sku = skuMap[value.skuId] || await ProductAPI.skuDetail(value.skuId);
skuMap[value.skuId] = sku;
const productId = sku.productId || "";
const product = productId ? giftProductMap[value.skuId] || await ProductAPI.detail(productId) : undefined;
if (!product) {
Message.warning("赠送产品信息加载失败,请重新选择");
return;
}
giftProductMap[value.skuId] = product;
const existingIndex = packagePurchaseItemList.value.findIndex(item => item.gifted
&& item.skuId === value.skuId && item.skuUnitId === value.skuUnitId);
if (0 <= existingIndex) {
const existingItem = packagePurchaseItemList.value[existingIndex];
const totalQuantity = addPackageQuantity(existingItem.totalQuantity, "1");
if (!totalQuantity) {
Message.warning("赠送数量合并后不能精确保留三位小数");
return;
}
packagePurchaseItemList.value[existingIndex] = { ...existingItem, totalQuantity };
} else {
const maxSort = Math.max(-1, ...packagePurchaseItemList.value.map(item => item.sort ?? -1));
packagePurchaseItemList.value.push({
skuId: value.skuId,
skuUnitId: value.skuUnitId,
totalQuantity: "1",
gifted: true,
sort: maxSort + 1,
remark: null,
productNameSnapshot: product.name,
skuNameSnapshot: sku.name,
specSummarySnapshot: sku.specSummary
});
}
resetRequestNo();
} catch (error) {
Message.error(getErrorMessage(error, "赠送产品加载失败"));
}
}
function canEditItem(item: PackagePurchaseItem): boolean {
return Boolean(item.gifted) ? canGiftCreate.value : canPriceOverride.value;
}
function canRemoveItem(item: PackagePurchaseItem): boolean {
return canEditItem(item);
}
function updateItemQuantity(index: number, value: string | number): void {
const item = packagePurchaseItemList.value[index];
if (!item || !canEditItem(item)) {
return;
}
packagePurchaseItemList.value = packagePurchaseItemList.value.map((item, itemIndex) => itemIndex === index ? {
...item,
totalQuantity: String(value)
} : item);
resetRequestNo();
}
function updateItemRemark(index: number, value: string | number): void {
const item = packagePurchaseItemList.value[index];
if (!item?.gifted || !canGiftCreate.value) {
return;
}
packagePurchaseItemList.value[index] = { ...item, remark: String(value) || null };
resetRequestNo();
}
function removePurchaseItem(index: number): void {
const item = packagePurchaseItemList.value[index];
if (!item || !canRemoveItem(item)) {
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 validateDirectStock(): Promise<boolean> {
stockPreviewList.value = [];
const selected = packagePurchaseItemList.value.filter(item => {
const quantity = normalizePackageQuantity(item.totalQuantity);
return !!quantity && quantity !== "0";
});
const skuIdList = [...new Set(selected.map(item => item.skuId).filter(Boolean))];
await Promise.all(skuIdList.map(async skuId => {
if (skuMap[skuId]) {
return;
}
try {
skuMap[skuId] = await ProductAPI.skuDetail(skuId);
} catch {
// 统一在下方提示,避免部分 SKU 查询失败时静默跳过库存校验
}
}));
for (const item of selected) {
if (!skuMap[item.skuId]) {
Message.warning(`${getItemName(item)} 的库存信息加载失败,请刷新后重试`);
return false;
}
if (!getItemUnit(item)) {
Message.warning(`${getItemName(item)} 的权益单位不存在,请刷新后重试`);
return false;
}
}
const requirementList = mergeProductStockRequirements(selected.flatMap(item => {
const sku = skuMap[item.skuId];
const unit = getItemUnit(item);
const quantity = normalizePackageQuantity(item.totalQuantity);
const baseQuantity = quantity && unit ? toPackageBaseQuantity(quantity, unit.baseQuantityPerUnit) : undefined;
if (!sku?.stockEnabled || !baseQuantity) {
return [];
}
return [{ skuId: item.skuId, baseQuantity, productName: getItemName(item), unitName: "基础单位", stockEnabled: sku.stockEnabled }];
}));
if (!requirementList.length) {
return true;
}
try {
const stockList = await ProductAPI.stockBatchDetail({ skuIdList: requirementList.map(item => item.skuId) });
stockPreviewList.value = buildProductStockPreview(stockList, requirementList);
const shortageMessage = getProductStockShortageMessage(stockList, requirementList);
if (shortageMessage) {
Message.warning(`${shortageMessage},不能直接履约`);
return false;
}
return true;
} catch (error) {
stockPreviewList.value = [];
Message.error(getErrorMessage(error, "库存查询失败,暂不能直接履约"));
return false;
}
}
async function submitRecord(): Promise<void> {
if (isSaving.value) {
return;
}
if (isStockChecking.value) {
Message.warning("库存正在预检,请稍候再提交");
return;
}
if (!validatePurchaseForm() || !validateItemDecimalQuantity()) {
return;
}
if (paymentVerified.value || receiveFullPayment.value || fulfillAll.value) {
const dialog = DialogPlugin.confirm({
title: "确认直通完成交易",
content: fulfillAll.value
? "将一次性购买套餐、完成收款核销,并完成全部履约和库存扣减。提交后如需更正,只能走撤销或冲正流程。"
: paymentVerified.value
? "将一次性购买套餐并登记已核销收款。提交后如需更正,只能走资金冲正流程。"
: "将一次性购买套餐并核销全额收款。提交后如需更正,只能走资金冲正流程。",
cancelBtn: "取消",
confirmBtn: "确认并提交",
onCancel() {
dialog.destroy();
},
async onConfirm() {
dialog.destroy();
await purchasePackage();
}
});
return;
}
await purchasePackage();
}
async function purchasePackage(): Promise<void> {
isSaving.value = true;
try {
if (fulfillAll.value && !await validateDirectStock()) return;
const result = await submitPurchase();
if (!result?.salesOrderId) {
Message.error("套餐购买成功但未返回完整订单关联,请联系管理员");
return;
}
await settingStore.playRegisterSuccessSound();
Message.success(fulfillAll.value ? "套餐、收款及履约已完成" : paymentVerified.value ? "套餐及已核销收款已完成" : receiveFullPayment.value ? "套餐及全额收款已完成" : "套餐购买已提交");
await router.replace({
name: "SalesOrderDetail",
params: { id: result.salesOrderId },
query: { transitionDepth: String(Number(route.meta.depth ?? 0) + 1) }
});
} catch (error) {
Message.error(getErrorMessage(error, "套餐购买提交失败"));
} finally {
isSaving.value = false;
}
}
// ---------- 页面数据 ----------
async function loadPageData(): Promise<void> {
if (!configId.value) {
configList.value = await PackageAPI.configListValid();
return;
}
config.value = await PackageAPI.configDetail(configId.value);
syncConfigSkuMap(config.value);
applyConfig(config.value);
}
async function selectConfig(item: GaoPackageConfig): Promise<void> {
if (!item.id) {
return;
}
config.value = await PackageAPI.configDetail(item.id);
syncConfigSkuMap(config.value);
applyConfig(config.value);
configSelectorVisible.value = false;
}
onMounted(async () => {
navBarStore.clearRight();
if (userStore.hasPermissions(permissionCodes.customerPackage.create)) {
navBarStore.setRightText("提交", "var(--td-brand-color)", submitRecord);
}
try {
await loadPageData();
} catch (error) {
Message.error(getErrorMessage(error, "套餐配置加载失败"));
}
});
onUnmounted(() => navBarStore.clearRight());
watch(config, value => {
if (value) {
applyConfig(value);
}
});
</script>
<style scoped lang="less">
.package-purchase {
gap: 1rem;
display: flex;
padding: calc(var(--app-nav-height) + 1rem) 0 1rem 0;
flex-direction: column;
.form {
padding: .5rem 0;
background: rgba(255, 255, 255, .92);
box-shadow: var(--td-shadow-1);
border-radius: 1.4rem;
.customer .actions {
gap: .5rem;
display: flex;
}
.code-scanner-wrapper,
.photo-part {
padding: 1rem;
}
.textarea {
width: 100%;
}
.tips {
font-size: .875rem;
}
.customer-profile {
margin: 1rem;
}
.config-summary {
gap: .25rem;
width: 100%;
padding: .5rem .75rem;
display: flex;
background: var(--td-error-color-1);
border-radius: .75rem;
flex-direction: column;
.header {
display: flex;
justify-content: space-between;
.name {
flex: 1;
}
.price {
flex: none;
display: flex;
align-items: baseline;
.label {
font-size: .75rem;
}
.value {
font-size: 1.75rem;
line-height: 1;
}
}
}
.description {
width: 100%;
font-size: .75rem;
min-width: 0;
max-width: 100%;
word-break: break-word;
white-space: normal;
overflow-wrap: anywhere;
}
}
.items {
gap: .5rem;
flex: 1;
margin: 0;
display: grid;
.title {
font-size: .8125rem;
margin-bottom: .5rem;
}
.item {
padding: 6px;
box-shadow: .25rem .25rem 1rem rgba(0, 0, 0, .08);
border-radius: 1rem;
.avatar {
width: 2rem;
height: 2rem;
border-radius: .5rem !important;
}
&::after {
display: none;
}
.content {
gap: .25rem;
display: flex;
flex-direction: column;
.main {
gap: .25rem;
display: flex;
align-items: center;
.name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.input {
flex: 1;
min-width: 0;
margin-top: .2rem;
}
.actions {
gap: .25rem;
margin: .1rem 0 0 .75rem;
display: flex;
}
.gift-remark {
padding: 0;
}
}
}
}
}
.payment-item.disabled {
color: var(--td-text-color-disabled);
:deep(.t-form__label) {
color: var(--td-text-color-disabled);
}
}
}
.time-actions {
gap: .5rem;
display: flex;
align-items: center;
justify-content: flex-end;
}
.file-input {
display: none;
}
}
.config-selector {
height: 100%;
overflow: auto;
.config-option {
.title {
gap: .5rem;
width: 100%;
min-width: 0;
display: flex;
align-items: center;
justify-content: space-between;
.price {
flex: none;
display: flex;
.label {
font-size: .75rem;
}
}
}
.products {
gap: .35rem;
width: 100%;
display: flex;
flex-wrap: wrap;
margin-top: .5rem;
}
}
}
.selected-customer-popup {
height: 70vh;
overflow: auto;
padding-top: 1rem;
border-radius: 1.25rem 1.25rem 0 0;
.popup-header {
padding: 0 1rem;
display: flex;
align-items: center;
justify-content: space-between;
h3 {
margin: 0;
}
}
.list {
margin-top: 1rem;
}
}
:global(html.theme-dark) {
.package-purchase {
:deep(.form-item .t-form__label) {
color: #94a3b8;
}
.form {
background: rgba(15, 23, 42, .88);
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
}
:deep(.textarea .t-textarea__inner) {
color: #e2e8f0;
background: rgba(15, 23, 42, .72);
border-color: rgba(148, 163, 184, .16);
}
.saleable-tip {
color: #fbbf24;
}
}
}
</style>