Files
gao-customer-h5/src/pages/salesOrder/SalesOrderCreate.vue
T
2026-09-07 23:11:32 +08:00

650 lines
20 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.salesOrder.create" class="sales-order-create">
<t-form class="form" label-align="left" label-width="5.25rem" v-entrance-transition>
<t-form-item label="客户" required-mark>
<t-input
:model-value="customerDisplayText"
class="customer"
readonly
borderless
placeholder="选择或扫码客户"
@click="customerSelectorVisible = true"
>
<template #suffix-icon>
<div class="actions">
<t-button
theme="primary"
variant="outline"
size="extra-small"
@click.stop="customerSelectorVisible = 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="customer"
class="customer-profile"
:customer="customer"
v-entrance-transition
/>
<t-form-item label-width="0">
<div class="product-part">
<div class="header">
<strong>购买产品</strong>
<span v-text="`${items.length} 项`" />
</div>
<div class="tips">同一 SKU 单位不能重复</div>
<div class="items">
<div v-for="(item, index) in items" :key="item.clientKey" class="item">
<div class="header">
<span class="no" v-text="String(index + 1).padStart(2, '0')" />
<strong v-text="getItemTitle(item)" />
<t-button theme="danger" variant="text" size="extra-small" @click="removeItem(index)">删除</t-button>
</div>
<product-selector
:model-value="item.skuId ? { skuId: item.skuId, skuUnitId: item.skuUnitId || '' } : undefined"
:selected-product="getProduct(item)"
label="产品"
placeholder="请选择产品"
required-mark
@update:model-value="applyProduct(index, $event)"
/>
<t-form-item v-if="item.skuId" label="SKU 单位" content-align="right">
<span v-text="getUnit(item)?.name || '未选择单位'" />
</t-form-item>
<t-form-item :label="canPriceOverride ? '成交单价' : '单价'">
<t-input
v-model.trim="item.unitPriceText"
type="number"
align="right"
borderless
:readonly="!canPriceOverride"
:placeholder="getItemUnitPricePlaceholder(item)"
/>
<template #help>
<span
v-if="item.skuId && item.unitPriceText"
class="price-tips gray"
v-text="getItemUnitPricePlaceholder(item)"
/>
</template>
</t-form-item>
<t-form-item label="数量" content-align="right">
<t-stepper
v-model="item.quantity"
size="large"
:max="maxQuantity"
:min="getQuantityStep(item)"
:step="getQuantityStep(item)"
:integer="!getSku(item)?.allowDecimalQuantity"
theme="outline"
/>
</t-form-item>
<t-form-item label="产品备注">
<t-input v-model="item.remark" borderless placeholder="选填" />
</t-form-item>
<div class="price">
<strong class="coin red">¥</strong>
<strong class="value red" v-text="getItemSubtotalText(item)" />
</div>
</div>
</div>
<t-button
class="add"
type="button"
theme="light"
variant="dashed"
block
@click="addItem"
>
<template #icon>
<t-icon name="add" size="1.125rem" />
</template>
<span>继续添加产品</span>
</t-button>
</div>
</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="openTimePicker"
/>
<t-button
v-if="purchasedAtText"
theme="primary"
variant="text"
size="extra-small"
content="此刻"
@click="resetPurchasedAt"
/>
</div>
</t-form-item>
<t-form-item label="订单备注">
<t-textarea v-model="remark" class="textarea" borderless autosize maxlength="500" placeholder="记录本单需要注意的内容" />
</t-form-item>
<div class="photo-part">
<photo-scroller :model-value="photoUrlList" v-model:photo-items="photoItems" :upload="uploadPhotoList" :edit-mode="true" title="订单照片(选填)" />
</div>
</t-form>
<div class="submit-wrap glass-white bg-white">
<div class="total">
<span>订单总价</span>
<div class="price">
<strong class="red coin">¥</strong>
<strong class="red value" v-text="totalAmountText" />
</div>
</div>
<t-button
class="submit"
theme="danger"
:loading="isSaving"
:disabled="isSaving"
@click="save"
>提交订单</t-button>
</div>
<customer-selector v-model="customerIdList" v-model:visible="customerSelectorVisible" selection-mode="single" @change="loadCustomer" />
<t-popup v-model="pickerVisible" placement="bottom">
<t-date-time-picker v-if="pickerVisible" v-model="draftTime" format="YYYY-MM-DD HH:mm" :mode="['date', 'minute']" title="下单时间" @cancel="pickerVisible = false" @confirm="confirmTime" />
</t-popup>
</div>
</template>
<script setup lang="ts">
import { type Attachment, useUserStore } from "timi-web-api";
import { Time } from "timi-web";
import CustomerAPI from "@/api/CustomerAPI";
import ProductAPI from "@/api/ProductAPI";
import SalesOrderAPI from "@/api/SalesOrderAPI";
import CodeScanner from "@/components/CodeScanner.vue";
import CustomerProfileCard from "@/components/CustomerProfileCard.vue";
import CustomerSelector from "@/components/CustomerSelector.vue";
import PhotoScroller from "@/components/PhotoScroller.vue";
import type { Customer } from "@/types/Customer";
import type { Product, ProductSku, ProductSkuUnit } from "@/types/Product";
import type { PhotoItem } from "@/types/Photo";
import type { DirectProductOrderItemRequest } from "@/types/SalesOrder";
import { createPackageRequestNo, normalizePackageQuantity, parsePackageAmount } from "@/utils/package";
import { formatProductAmount, getProductQuantityStep } from "@/utils/product";
import { getErrorMessage } from "@/utils/error";
import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission";
import { uploadPhotoList } from "@/utils/photo";
import { useNavBarStore } from "@/store/navBarStore";
import { useSettingStore } from "@/store/settingStore";
type FormItem = Partial<DirectProductOrderItemRequest> & { clientKey: string; productName: string; unitPriceText: string };
defineOptions({ name: "SalesOrderCreate" });
const router = useRouter();
const navBarStore = useNavBarStore();
const settingStore = useSettingStore();
const userStore = useUserStore();
const canPriceOverride = computed(() => userStore.hasPermissions(permissionCodes.salesOrder.priceOverride));
const customerIdList = ref<string[]>([]);
const customer = ref<Customer>();
const customerSelectorVisible = ref(false);
const items = ref<FormItem[]>([]);
const remark = ref("");
const purchasedAtText = ref("");
const draftTime = ref("");
const pickerVisible = ref(false);
const isSaving = ref(false);
const photoItems = ref<PhotoItem[]>([]);
const photoUrlList = computed(() => photoItems.value.map(item => item.url));
const maxQuantity = Number.MAX_SAFE_INTEGER;
const productCache = new Map<string, Product>();
const skuCache = new Map<string, ProductSku>();
const requestNo = ref("");
const customerDisplayText = computed(() => customer.value?.name || customer.value?.code || "选择客户");
const customerCode = ref("");
const scanCode = ref("");
const scannerVisible = ref(false);
const scanStatus = ref("点击扫码开始识别。");
let latestDecodedText = "";
function getSku(item: FormItem): ProductSku | undefined {
return skuCache.get(item.skuId || "");
}
function getQuantityStep(item: FormItem): number {
return getProductQuantityStep(getSku(item));
}
function getProduct(item: FormItem): Product | undefined {
const sku = getSku(item);
return productCache.get(sku?.productId || "");
}
function getUnit(item: FormItem): ProductSkuUnit | undefined {
return getSku(item)?.unitList?.find(unit => unit.id === item.skuUnitId);
}
function getItemTitle(item: FormItem): string {
const product = getProduct(item);
const sku = getSku(item);
return sku?.specSummary || sku?.name || product?.name || item.productName || "未选择产品";
}
const totalAmount = computed(() => {
let amount = 0;
for (const item of items.value) {
const itemAmount = calculateItemAmount(item);
if (itemAmount === undefined) {
return undefined;
}
amount += itemAmount;
}
return amount;
});
const totalAmountText = computed(() => totalAmount.value === undefined ? "0.00" : formatProductAmount(totalAmount.value));
function createItem(): FormItem {
return {
clientKey: window.crypto.randomUUID(),
quantity: "1",
productName: "",
unitPriceText: "",
remark: ""
};
}
function addItem(): void {
items.value.push(createItem());
}
function removeItem(index: number): void {
if (1 < items.value.length) {
items.value.splice(index, 1);
} else {
Message.warning("订单至少需要一项产品");
}
}
async function applyProduct(index: number, value?: { skuId: string; skuUnitId: string }): Promise<void> {
if (!value) return;
const sku = skuCache.get(value.skuId) || await ProductAPI.skuDetail(value.skuId);
skuCache.set(value.skuId, sku);
const product = productCache.get(sku.productId || "") || await ProductAPI.detail(sku.productId || "");
productCache.set(sku.productId || "", product);
const item = items.value[index];
const selectionChanged = item.skuId !== value.skuId || item.skuUnitId !== value.skuUnitId;
item.skuId = value.skuId;
item.skuUnitId = value.skuUnitId;
item.productName = product.name;
if (selectionChanged) {
item.unitPriceText = "";
}
}
function getItemUnitPrice(item: FormItem): number | undefined {
if (canPriceOverride.value && item.unitPriceText.trim()) {
return parsePackageAmount(item.unitPriceText);
}
const salePrice = getUnit(item)?.salePrice;
return salePrice === null ? undefined : salePrice;
}
function getItemUnitPricePlaceholder(item: FormItem): string {
const salePrice = getUnit(item)?.salePrice;
return salePrice === undefined || salePrice === null ? "产品定价" : `产品定价:${formatProductAmount(salePrice)}`;
}
function calculateItemAmount(item: FormItem): number | undefined {
const quantity = normalizePackageQuantity(item.quantity ?? "");
const unitPrice = getItemUnitPrice(item);
if (!quantity || quantity === "0" || unitPrice === undefined) {
return undefined;
}
const [integerPart, fractionPart = ""] = quantity.split(".");
const quantityScale = 10n ** BigInt(fractionPart.length);
const quantityValue = BigInt(`${integerPart}${fractionPart}`);
const amount = BigInt(unitPrice) * quantityValue;
if (amount % quantityScale !== 0n) {
return undefined;
}
const result = Number(amount / quantityScale);
return Number.isSafeInteger(result) ? result : undefined;
}
function getItemSubtotalText(item: FormItem): string {
const amount = calculateItemAmount(item);
return amount === undefined ? "0.00" : formatProductAmount(amount);
}
async function loadCustomer(value: string | string[]): Promise<void> {
const id = Array.isArray(value) ? value[0] : value;
customer.value = id ? await CustomerAPI.detail(id) : undefined;
customerCode.value = customer.value?.code || "";
scanCode.value = customerCode.value;
}
function toggleScanner(): 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;
scanCode.value = normalizedText;
await settingStore.playScanSuccessSound();
await applyScanCode();
}
async function applyScanCode(): Promise<void> {
customerCode.value = scanCode.value.trim();
scanStatus.value = `已识别 ${customerCode.value},正在加载客户档案。`;
if (!customerCode.value) {
return;
}
const result = await CustomerAPI.findByCode(customerCode.value);
if (!result?.id) {
customer.value = undefined;
customerIdList.value = [];
scanStatus.value = `客户 ${customerCode.value} 不存在,请先建档。`;
return;
}
customer.value = result;
customerIdList.value = [result.id];
scanStatus.value = `已识别 ${result.code || customerCode.value},已自动选中 ${result.name || "未命名客户"}。`;
scannerVisible.value = false;
}
function handleScanStatusChange(value: string): void {
scanStatus.value = value;
}
function openTimePicker(): void { draftTime.value = purchasedAtText.value || Time.formatDateTime(new Date()); pickerVisible.value = true; }
function confirmTime(value?: string | number): void { purchasedAtText.value = String(value || draftTime.value); pickerVisible.value = false; }
function resetPurchasedAt(): void { purchasedAtText.value = ""; draftTime.value = ""; }
function resolveTimestamp(value: string): number | undefined {
if (!value) return 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 validate(): boolean {
if (!customer.value?.id) { Message.warning("请选择客户"); return false; }
if (!items.value.length) { Message.warning("请至少添加一项产品"); return false; }
const keys = new Set<string>();
for (const item of items.value) {
const quantity = normalizePackageQuantity(item.quantity ?? "");
const key = `${item.skuId}\u0000${item.skuUnitId}`;
if (!item.skuId || !item.skuUnitId || !quantity || quantity === "0") { Message.warning("SKU 单位和正数量不能为空"); return false; }
if (getSku(item) && !getSku(item)!.allowDecimalQuantity && quantity.includes(".")) { Message.warning("该 SKU 不允许小数数量"); return false; }
if (keys.has(key)) { Message.warning("产品单位不能重复"); return false; }
keys.add(key);
if (canPriceOverride.value && item.unitPriceText && parsePackageAmount(item.unitPriceText) === undefined) { Message.warning("成交单价必须是合法金额"); return false; }
}
return true;
}
async function save(): Promise<void> {
if (isSaving.value || !validate()) return;
if (!requestNo.value) requestNo.value = createPackageRequestNo();
isSaving.value = true;
try {
const itemList = items.value.map((item, index) => ({
skuId: item.skuId!,
skuUnitId: item.skuUnitId!,
quantity: normalizePackageQuantity(item.quantity ?? "")!,
unitPrice: canPriceOverride.value && item.unitPriceText ? parsePackageAmount(item.unitPriceText) : undefined,
sort: index,
remark: item.remark?.trim() || null
}));
const result = await SalesOrderAPI.createDirectProduct({
requestNo: requestNo.value,
customerId: customer.value!.id!,
purchasedAt: resolveTimestamp(purchasedAtText.value),
itemList,
attachmentList: buildAttachments(),
remark: remark.value.trim() || null
});
Message.success("销售订单已创建");
await router.replace(`/management/sales-orders/${result.id}`);
} catch (error) {
const message = getErrorMessage(error, "销售订单创建失败,重试会复用同一请求号");
Message.error(message);
}
finally { isSaving.value = false; }
}
onMounted(() => {
addItem();
navBarStore.clearRight();
});
onUnmounted(() => navBarStore.clearRight());
</script>
<style scoped lang="less">
.sales-order-create {
gap: 1rem;
display: flex;
padding: calc(var(--app-nav-height) + 1rem) 0 calc(6.5rem + env(safe-area-inset-bottom, 0));
flex-direction: column;
.form {
margin: 0 1rem;
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;
}
.customer-profile {
margin: 1rem;
}
.time-actions {
gap: .5rem;
display: flex;
align-items: center;
justify-content: flex-end;
}
.product-part {
> .header {
display: flex;
align-items: center;
justify-content: space-between;
strong {
font-size: .9375rem;
}
span {
color: var(--td-brand-color);
font-size: .75rem;
}
}
.tips {
margin-top: .25rem;
color: var(--td-text-color-secondary);
font-size: .75rem;
line-height: 1.5;
}
.items {
gap: .75rem;
display: flex;
margin-top: .75rem;
flex-direction: column;
.item {
border: 1px dashed var(--td-border-level-1-color);
padding: .5rem;
border-radius: .875rem;
.header {
gap: .625rem;
display: flex;
align-items: center;
.no {
width: 1.75rem;
height: 1.75rem;
color: var(--td-brand-color);
flex: none;
display: flex;
align-items: center;
justify-content: center;
background: var(--td-brand-color-1);
border-radius: .625rem;
font-size: .6875rem;
font-weight: 700;
}
strong {
flex: 1;
min-width: 0;
overflow: hidden;
color: var(--td-text-color-primary);
font-size: .875rem;
white-space: nowrap;
text-overflow: ellipsis;
}
}
.price-tips {
flex: 1;
text-align: right;
}
.unit-list {
gap: .5rem;
width: 100%;
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
}
.unit-empty {
color: var(--td-text-color-secondary);
font-size: .75rem;
}
}
}
.price {
display: flex;
align-items: baseline;
justify-content: end;
.value {
font-size: 1.5rem;
}
}
.add {
--td-button-border-radius: 1rem;
gap: .375rem;
color: var(--td-brand-color);
margin-top: .75rem;
}
}
.textarea {
width: 100%;
}
.photo-part {
padding: 1rem;
}
}
.submit-wrap {
gap: 1rem;
left: 2rem;
right: 2rem;
border: 1px solid rgba(0, 0, 0, .05);
bottom: calc(env(safe-area-inset-bottom, 0) + 1rem);
z-index: 12;
display: flex;
padding: .5rem 1rem calc(.5rem + env(safe-area-inset-bottom, 0)) 1rem;
position: fixed;
align-items: center;
border-radius: 1.75rem;
backdrop-filter: blur(10px);
justify-content: space-between;
.total {
flex: 1;
display: flex;
flex-direction: column;
span {
color: var(--td-text-color-secondary);
font-size: .6875rem;
}
.price {
display: flex;
align-items: baseline;
.value {
font-size: 1.5rem;
}
}
}
.submit {
--td-button-border-radius: 1rem;
}
}
}
:global(html.theme-dark) {
.sales-order-create {
.form {
background: rgba(15, 23, 42, .88);
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
.order-item {
background: rgba(15, 23, 42, .72);
border-color: rgba(148, 163, 184, .16);
}
:deep(.textarea .t-textarea__inner) {
color: #e2e8f0;
background: rgba(15, 23, 42, .72);
border-color: rgba(148, 163, 184, .16);
}
}
.submit-bar {
box-shadow: 0 -.25rem 1rem rgba(0, 0, 0, .2);
background: rgba(15, 23, 42, .92);
}
}
}
</style>