1200 lines
36 KiB
Vue
1200 lines
36 KiB
Vue
<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>
|