auto complete order

This commit is contained in:
Timi
2026-09-13 01:25:37 +08:00
parent 8d9d0c4591
commit 6cce293683
9 changed files with 317 additions and 28 deletions
+1 -2
View File
@@ -292,7 +292,6 @@ function selectUnit(sku: ProductSku, skuUnitId: string): void {
}
.sku-card {
margin-top: 1.25rem;
padding: .75rem;
background: var(--td-bg-color-secondarycontainer);
border-radius: .875rem;
@@ -349,7 +348,7 @@ function selectUnit(sku: ProductSku, skuUnitId: string): void {
padding-bottom: env(safe-area-inset-bottom, 0);
.collapse {
margin-bottom: 1rem;
margin: 0;
}
.product-header {
+1 -1
View File
@@ -11,7 +11,7 @@
</t-cell>
<t-cell title="识别成功自动登记">
<template #left-icon>
<t-icon name="arrow-right-up" />
<t-icon name="task-checked" />
</template>
<template #right-icon>
<t-switch v-model="autoRegisterEnabled" />
+163 -18
View File
@@ -65,7 +65,7 @@
<div class="description" v-text="config.description" />
</div>
</t-form-item>
<t-form-item label="成交价(元)" label-width="6rem" required-mark>
<t-form-item label="成交价" label-width="6rem" required-mark>
<t-input
v-model.trim="saleAmountText"
type="number"
@@ -170,6 +170,41 @@
/>
</div>
</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"
/>
</t-form-item>
<t-form-item label-width="0">
<div class="red">全部履约会同步启用全额收款;请先准备全部产品库存,任意一步失败,整张订单都会回滚。</div>
</t-form-item>
<t-form-item v-if="receiveFullPayment && parsedSaleAmount" label-width="0">
<photo-scroller
: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">
<photo-scroller
: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"
@@ -195,26 +230,41 @@
:enable-scan="false"
@change="handleCustomerChange"
/>
<t-popup v-model="configSelectorVisible" placement="bottom">
<div class="config-selector bg-white">
<div class="popup-header">
<t-button variant="text" size="small" @click="configSelectorVisible = false">关闭</t-button>
<h3>选择套餐</h3>
<span />
</div>
<bottom-popup
v-model="configSelectorVisible"
title="选择套餐"
height="70vh"
right-button-text=""
@left-button-click="configSelectorVisible = false"
>
<div class="config-selector">
<t-cell-group v-if="configList.length">
<t-cell
v-for="item in configList"
:key="item.id"
class="config-option"
:title="item.name || '未命名套餐'"
:note="`标准价:${formatPackageAmount(item.standardAmount)}`"
:note="formatPackageAmount(item.standardAmount)"
arrow
@click="selectConfig(item)"
/>
>
<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>
</t-popup>
</bottom-popup>
<t-popup v-model="unitPickerVisible" placement="bottom">
<t-picker v-model="unitPickerDraft" :columns="unitPickerOptions" title="调整权益单位" @cancel="unitPickerVisible = false" @confirm="confirmItemUnitPicker" />
</t-popup>
@@ -252,6 +302,8 @@
<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";
@@ -265,14 +317,16 @@ 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, PackagePurchaseItem } from "@/types/Package";
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,
normalizePackageQuantity
getPackageQuantityText,
normalizePackageQuantity,
parsePackageAmount
} from "@/utils/package";
import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission";
@@ -423,15 +477,22 @@ function parseDateTime(value: string): Date {
const note = ref("");
const isSaving = ref(false);
const photoItems = ref<PhotoItem[]>([]);
const paymentPhotoItems = ref<PhotoItem[]>([]);
const fulfillmentPhotoItems = ref<PhotoItem[]>([]);
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));
function buildAttachments(): PackageAttachment[] {
return photoItems.value.flatMap(item => item.tempFileId ? [{
function buildAttachments(source: PhotoItem[] = photoItems.value): PackageAttachment[] {
return source.flatMap(item => item.tempFileId ? [{
tempFileId: item.tempFileId,
attachType: item.attachType || "PHOTO"
}] : []);
}
const receiveFullPayment = ref(false);
const fulfillAll = ref(false);
const {
isSaleable,
canPriceOverride,
@@ -446,9 +507,32 @@ const {
config,
selectedCustomerList,
note,
receiveFullPayment,
fulfillAll,
hasPermission: permissionCode => userStore.hasPermissions(permissionCode),
buildRegisteredAt: buildPurchasedAt,
buildAttachments
buildAttachments,
buildPaymentAttachments: () => buildAttachments(paymentPhotoItems.value),
buildFulfillmentAttachments: () => buildAttachments(fulfillmentPhotoItems.value)
});
const parsedSaleAmount = computed(() => parsePackageAmount(saleAmountText.value));
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)
&& (parsedSaleAmount.value === 0 || canReceiveImmediately.value));
watch(fulfillAll, value => {
if (value && parsedSaleAmount.value !== 0) {
receiveFullPayment.value = true;
}
});
watch(receiveFullPayment, value => {
if (!value && parsedSaleAmount.value !== 0) {
fulfillAll.value = false;
}
});
const unitPickerOptions = computed(() => {
@@ -456,6 +540,12 @@ const unitPickerOptions = computed(() => {
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;
@@ -647,6 +737,28 @@ async function submitRecord(): Promise<void> {
if (!validatePurchaseForm() || !validateItemDecimalQuantity()) {
return;
}
if (receiveFullPayment.value || fulfillAll.value) {
const dialog = DialogPlugin.confirm({
title: "确认直通完成交易",
content: fulfillAll.value
? "将一次性购买套餐、核销全额收款,并完成全部履约和库存扣减。提交后如需更正,只能走撤销或冲正流程。"
: "将一次性购买套餐并核销全额收款。提交后如需更正,只能走资金冲正流程。",
cancelBtn: "取消",
confirmBtn: "确认并提交",
onCancel() {
dialog.destroy();
},
async onConfirm() {
dialog.destroy();
await purchasePackage();
}
});
return;
}
await purchasePackage();
}
async function purchasePackage(): Promise<void> {
isSaving.value = true;
try {
@@ -656,7 +768,7 @@ async function submitRecord(): Promise<void> {
return;
}
await settingStore.playRegisterSuccessSound();
Message.success("套餐购买已提交");
Message.success(fulfillAll.value ? "套餐、收款及履约已完成" : receiveFullPayment.value ? "套餐及全额收款已完成" : "套餐购买已提交");
await router.replace({
name: "SalesOrderDetail",
params: { id: result.salesOrderId },
@@ -749,6 +861,25 @@ watch(config, value => {
width: 100%;
}
.straight-through {
width: 100%;
padding: .75rem;
background: var(--td-bg-color-secondarycontainer);
border-radius: .875rem;
.title {
font-size: .9375rem;
font-weight: 600;
}
.tips {
color: var(--td-text-color-secondary);
margin: .5rem .5rem 0;
font-size: .75rem;
line-height: 1.5;
}
}
.customer-profile {
margin: 1rem;
}
@@ -881,7 +1012,21 @@ watch(config, value => {
border-radius: inherit;
}
.config-selector,
.config-selector {
height: 100%;
overflow: auto;
.config-option {
.products {
gap: .35rem;
width: 100%;
display: flex;
flex-wrap: wrap;
margin-top: .5rem;
}
}
}
.selected-customer-popup {
height: 70vh;
overflow: auto;
+37 -1
View File
@@ -10,9 +10,13 @@ interface UsePackagePurchaseOptions {
config: Ref<GaoPackageConfig | undefined>;
selectedCustomerList: Ref<Customer[]>;
note: Ref<string>;
receiveFullPayment: Ref<boolean>;
fulfillAll: Ref<boolean>;
hasPermission: (permissionCode: string) => boolean;
buildRegisteredAt: () => number;
buildAttachments: () => PackageAttachment[];
buildPaymentAttachments: () => PackageAttachment[];
buildFulfillmentAttachments: () => PackageAttachment[];
}
/** 套餐配置直接购买流程,购买请求只依赖套餐配置 ID,不再关联购买事件。 */
@@ -91,12 +95,19 @@ export function usePackagePurchase(options: UsePackagePurchaseOptions) {
saleAmount,
purchasedAt: options.buildRegisteredAt(),
attachmentList: options.buildAttachments(),
straightThrough: options.receiveFullPayment.value || options.fulfillAll.value ? {
receiveFullPayment: options.receiveFullPayment.value,
paymentAttachmentList: options.buildPaymentAttachments(),
fulfillAll: options.fulfillAll.value,
fulfillmentAttachmentList: options.buildFulfillmentAttachments()
} : undefined,
itemList,
remark: options.note.value.trim() || null
});
}
function validatePurchaseForm(): boolean {
const saleAmount = parsePackageAmount(saleAmountText.value);
if (!options.hasPermission(permissionCodes.customerPackage.create)) {
Message.warning("当前账号没有套餐购买权限");
return false;
@@ -113,7 +124,7 @@ export function usePackagePurchase(options: UsePackagePurchaseOptions) {
Message.warning("套餐当前不可购买,请检查套餐状态和有效期");
return false;
}
if (parsePackageAmount(saleAmountText.value) === undefined) {
if (saleAmount === undefined) {
Message.warning("成交价必须是合法的元金额,最多两位小数");
return false;
}
@@ -139,6 +150,31 @@ export function usePackagePurchase(options: UsePackagePurchaseOptions) {
Message.warning("套餐至少需要保留一项正数量权益");
return false;
}
const canReceiveImmediately = options.hasPermission(permissionCodes.fundReceive.create)
&& options.hasPermission(permissionCodes.fundReceive.verify);
const canFulfillImmediately = options.hasPermission(permissionCodes.orderFulfillment.create)
&& options.hasPermission(permissionCodes.orderFulfillment.verify)
&& (saleAmount === 0 || canReceiveImmediately);
if (options.receiveFullPayment.value && !canReceiveImmediately) {
Message.warning("当前账号没有收款登记和核销权限");
return false;
}
if (options.fulfillAll.value && !canFulfillImmediately) {
Message.warning("当前账号没有交易直通所需权限");
return false;
}
if (options.fulfillAll.value && saleAmount !== 0 && !options.receiveFullPayment.value) {
Message.warning("全部履约直通必须同时直接全额收款");
return false;
}
if (options.receiveFullPayment.value && saleAmount !== 0 && !options.buildPaymentAttachments().length) {
Message.warning("直接全额收款必须上传收款凭证照片");
return false;
}
if (options.fulfillAll.value && !options.buildFulfillmentAttachments().length) {
Message.warning("全部履约直通必须上传履约凭证照片");
return false;
}
return true;
}
+97 -3
View File
@@ -136,6 +136,41 @@
/>
</div>
</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"
/>
</t-form-item>
<t-form-item label-width="0">
<div class="red">全部履约会同步启用全额收款;请先准备全部产品库存,任意一步失败,整张订单都会回滚。</div>
</t-form-item>
<t-form-item v-if="receiveFullPayment && totalAmount" label-width="0">
<photo-scroller
: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">
<photo-scroller
: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="remark" class="textarea" borderless autosize maxlength="500" placeholder="记录本单需要注意的内容" />
</t-form-item>
@@ -169,6 +204,7 @@
<script setup lang="ts">
import { type Attachment, useUserStore } from "timi-web-api";
import { Time } from "timi-web";
import { DialogPlugin } from "tdesign-mobile-vue";
import CustomerAPI from "@/api/CustomerAPI";
import ProductAPI from "@/api/ProductAPI";
import SalesOrderAPI from "@/api/SalesOrderAPI";
@@ -206,7 +242,11 @@ const draftTime = ref("");
const pickerVisible = ref(false);
const isSaving = ref(false);
const photoItems = ref<PhotoItem[]>([]);
const paymentPhotoItems = ref<PhotoItem[]>([]);
const fulfillmentPhotoItems = ref<PhotoItem[]>([]);
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 maxQuantity = Number.MAX_SAFE_INTEGER;
const productCache = new Map<string, Product>();
const skuCache = new Map<string, ProductSku>();
@@ -218,6 +258,28 @@ const scannerVisible = ref(false);
const scanStatus = ref("点击扫码开始识别。");
let latestDecodedText = "";
// ---------- 交易直通 ----------
const receiveFullPayment = ref(false);
const fulfillAll = ref(false);
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)
&& (totalAmount.value === 0 || canReceiveImmediately.value));
watch(fulfillAll, value => {
if (value && totalAmount.value !== 0) {
receiveFullPayment.value = true;
}
});
watch(receiveFullPayment, value => {
if (!value && totalAmount.value !== 0) {
fulfillAll.value = false;
}
});
function getSku(item: FormItem): ProductSku | undefined {
return skuCache.get(item.skuId || "");
}
@@ -376,8 +438,8 @@ function resolveTimestamp(value: string): number | undefined {
const timestamp = new Date(value.replace(" ", "T")).getTime();
return Number.isFinite(timestamp) ? timestamp : undefined;
}
function buildAttachments(): Partial<Attachment>[] {
return photoItems.value.flatMap(item => item.tempFileId ? [{ tempFileId: item.tempFileId, attachType: "PHOTO" }] : []);
function buildAttachments(source: PhotoItem[] = photoItems.value): Partial<Attachment>[] {
return source.flatMap(item => item.tempFileId ? [{ tempFileId: item.tempFileId, attachType: "PHOTO" }] : []);
}
function validate(): boolean {
if (!customer.value?.id) { Message.warning("请选择客户"); return false; }
@@ -392,10 +454,36 @@ function validate(): boolean {
keys.add(key);
if (canPriceOverride.value && item.unitPriceText && parsePackageAmount(item.unitPriceText) === undefined) { Message.warning("成交单价必须是合法金额"); return false; }
}
if (receiveFullPayment.value && !canReceiveImmediately.value) { Message.warning("当前账号没有收款登记和核销权限"); return false; }
if (fulfillAll.value && !canFulfillImmediately.value) { Message.warning("当前账号没有交易直通所需权限"); return false; }
if (fulfillAll.value && totalAmount.value !== 0 && !receiveFullPayment.value) { Message.warning("全部履约直通必须同时直接全额收款"); return false; }
if (receiveFullPayment.value && totalAmount.value && !buildAttachments(paymentPhotoItems.value).length) { Message.warning("直接全额收款必须上传收款凭证照片"); return false; }
if (fulfillAll.value && !buildAttachments(fulfillmentPhotoItems.value).length) { Message.warning("全部履约直通必须上传履约凭证照片"); return false; }
return true;
}
async function save(): Promise<void> {
if (isSaving.value || !validate()) return;
if (receiveFullPayment.value || fulfillAll.value) {
const dialog = DialogPlugin.confirm({
title: "确认直通完成交易",
content: fulfillAll.value
? "将一次性创建订单、核销全额收款,并完成全部履约和库存扣减。提交后如需更正,只能走撤销或冲正流程。"
: "将一次性创建订单并核销全额收款。提交后如需更正,只能走资金冲正流程。",
cancelBtn: "取消",
confirmBtn: "确认并提交",
onCancel() {
dialog.destroy();
},
async onConfirm() {
dialog.destroy();
await submitOrder();
}
});
return;
}
await submitOrder();
}
async function submitOrder(): Promise<void> {
if (!requestNo.value) requestNo.value = createPackageRequestNo();
isSaving.value = true;
try {
@@ -413,9 +501,15 @@ async function save(): Promise<void> {
purchasedAt: resolveTimestamp(purchasedAtText.value),
itemList,
attachmentList: buildAttachments(),
straightThrough: receiveFullPayment.value || fulfillAll.value ? {
receiveFullPayment: receiveFullPayment.value,
paymentAttachmentList: buildAttachments(paymentPhotoItems.value),
fulfillAll: fulfillAll.value,
fulfillmentAttachmentList: buildAttachments(fulfillmentPhotoItems.value)
} : undefined,
remark: remark.value.trim() || null
});
Message.success("销售订单已创建");
Message.success(fulfillAll.value ? "订单、收款及履约已完成" : receiveFullPayment.value ? "订单及全额收款已完成" : "销售订单已创建");
await router.replace(`/management/sales-orders/${result.id}`);
} catch (error) {
const message = getErrorMessage(error, "销售订单创建失败,重试会复用同一请求号");
+3 -1
View File
@@ -5,7 +5,7 @@
<h1 v-text="appName" />
<p v-text="appDescription" />
</section>
<t-cell-group title="协议" theme="card" v-entrance-transition="70">
<t-cell-group theme="card" v-entrance-transition="70">
<t-cell
v-for="item in agreementItemList"
:key="item.code"
@@ -21,6 +21,8 @@
</t-cell-group>
<footer class="footer" v-entrance-transition="140">
<p>技术支持:夜雨</p>
<p>联系技术:imyeyu@qq.com</p>
<p>短信服务支持:广州易保网络科技有限公司</p>
<p>Copyright © 2026 imyeyu.com</p>
</footer>
</div>
+1 -1
View File
@@ -62,7 +62,7 @@ const entryGroupList: HomeEntryGroup[] = [
{
path: "/management/customers/create?redirect=/customers",
permission: permissionCodes.customer.create,
icon: "edit-1",
icon: "user-add",
title: "录入"
},
{
+2 -1
View File
@@ -2,7 +2,7 @@ import type { Attachment, Model, PageResult, User } from "timi-web-api";
import type { Customer } from "@/types/Customer";
import type { OrderFulfillment } from "@/types/Fulfillment";
import type { ProductSku } from "@/types/Product";
import type { GaoSalesOrderItem, GaoSalesOrderStatus, SalesOrder } from "@/types/SalesOrder";
import type { GaoSalesOrderItem, GaoSalesOrderStatus, SalesOrder, SalesOrderStraightThroughRequest } from "@/types/SalesOrder";
export type PackageConfigStatus = "DRAFT" | "ACTIVE" | "INACTIVE";
@@ -79,6 +79,7 @@ export type PackagePurchaseRequest = {
purchasedAt?: number;
itemList?: PackagePurchaseItem[];
attachmentList?: PackageAttachment[];
straightThrough?: SalesOrderStraightThroughRequest;
remark?: string | null;
};
+12
View File
@@ -98,6 +98,17 @@ export type DirectProductOrderItemRequest = {
remark?: string | null;
};
export type SalesOrderStraightThroughRequest = {
receiveFullPayment?: boolean;
paymentOccurredAt?: number;
paymentAttachmentList?: Partial<Attachment>[];
paymentRemark?: string | null;
fulfillAll?: boolean;
deliveredAt?: number;
fulfillmentAttachmentList?: Partial<Attachment>[];
fulfillmentRemark?: string | null;
};
export type DirectProductOrderCreateRequest = {
requestNo: string;
storeId?: string;
@@ -105,6 +116,7 @@ export type DirectProductOrderCreateRequest = {
purchasedAt?: number;
itemList: DirectProductOrderItemRequest[];
attachmentList?: Partial<Attachment>[];
straightThrough?: SalesOrderStraightThroughRequest;
remark?: string | null;
};