Files
gao-customer-h5/src/pages/salesOrder/SalesOrderCreate.vue
T
2026-09-06 01:10:22 +08:00

647 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">同一产品单位不能重复,数量最多三位小数</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="item.productName || '未选择产品'" />
<t-button theme="danger" variant="text" size="extra-small" @click="removeItem(index)">删除</t-button>
</div>
<product-selector
:model-value="item.productId ? {productId: item.productId, productUnitId: item.productUnitId || ''} : undefined"
label="产品"
placeholder="请选择产品"
selection-mode="product"
required-mark
@update:model-value="applyProduct(index, $event)"
/>
<t-form-item v-if="item.productId" label="产品单位" required-mark>
<div v-if="getSaleableUnits(item).length" class="unit-list">
<t-button
v-for="unit in getSaleableUnits(item)"
:key="unit.id"
:theme="item.productUnitId === unit.id ? 'primary' : 'default'"
:variant="item.productUnitId === unit.id ? 'base' : 'outline'"
size="extra-small"
:content="unit.name"
@click="selectProductUnit(index, unit)"
/>
</div>
<span v-else class="unit-empty" v-text="'暂无可售单位'" />
</t-form-item>
<t-form-item v-if="canPriceOverride" label="成交单价">
<t-input
v-model.trim="item.unitPriceText"
type="number"
align="right"
borderless
:placeholder="getItemUnitPricePlaceholder(item)"
/>
</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="!getProduct(item)?.allowDecimalQuantity"
theme="outline"
/>
</t-form-item>
<t-form-item label="产品备注">
<t-input v-model.trim="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.trim="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, ProductUnit } 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 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 getProduct(item: FormItem): Product | undefined {
return productCache.get(item.productId || "");
}
function getQuantityStep(item: FormItem): number {
return getProductQuantityStep(getProduct(item));
}
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?: { productId: string; productUnitId: string }): Promise<void> {
if (!value) return;
const product = productCache.get(value.productId) || await ProductAPI.detail(value.productId);
productCache.set(value.productId, product);
const item = items.value[index];
const saleableUnits = (product.unitList || []).filter(unit => unit.allowSale !== false && unit.id);
const productUnitId = saleableUnits.length < 2 ? saleableUnits[0]?.id || "" : value.productUnitId;
const selectionChanged = item.productId !== value.productId || item.productUnitId !== productUnitId;
item.productId = value.productId;
item.productUnitId = productUnitId;
item.productName = product.name;
if (selectionChanged) {
item.unitPriceText = "";
}
}
function getSaleableUnits(item: FormItem): ProductUnit[] {
return (productCache.get(item.productId || "")?.unitList || []).filter(unit => unit.allowSale !== false && unit.id);
}
function selectProductUnit(index: number, unit: ProductUnit): void {
const item = items.value[index];
const product = productCache.get(item.productId || "");
if (!product?.id || !unit.id) {
return;
}
if (item.productUnitId !== unit.id) {
item.unitPriceText = "";
}
item.productUnitId = unit.id;
item.productName = product.name;
}
function getItemUnitPrice(item: FormItem): number | undefined {
if (canPriceOverride.value && item.unitPriceText.trim()) {
return parsePackageAmount(item.unitPriceText);
}
const salePrice = productCache.get(item.productId || "")?.unitList?.find(unit => unit.id === item.productUnitId)?.salePrice;
return salePrice === null ? undefined : salePrice;
}
function getItemUnitPricePlaceholder(item: FormItem): string {
const salePrice = productCache.get(item.productId || "")?.unitList?.find(unit => unit.id === item.productUnitId)?.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.productId}\u0000${item.productUnitId}`;
if (!item.productId || !item.productUnitId || !quantity || quantity === "0") { Message.warning("产品单位和正数量不能为空"); return false; }
if (getProduct(item) && !getProduct(item)!.allowDecimalQuantity && quantity.includes(".")) { Message.warning("该产品不允许小数数量"); 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) => ({
productId: item.productId!,
productUnitId: item.productUnitId!,
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;
}
}
.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>