From dd64f443cf93356c33571bf23d5072a58579f764 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Tue, 27 Oct 2026 17:39:28 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/business-index.md | 2 +- src/modules/cross-border/AGENTS.md | 2 +- src/modules/cross-border/api/procurement.ts | 41 ++++- .../cross-border/businessOrders.test.mjs | 6 +- .../components/BusinessOrdersView.vue | 26 +-- .../components/ProcurementDetailPanel.vue | 148 +++++++++--------- .../components/TransportDemandForm.vue | 20 +-- src/modules/cross-border/procurement.test.mjs | 60 ++++++- .../cross-border/procurementPresentation.ts | 17 +- .../cross-border/transportDemandForm.ts | 34 ++-- .../cross-border/views/ProcurementView.vue | 62 ++++++-- .../utils/transferCartonPrinting.ts | 3 +- 12 files changed, 277 insertions(+), 144 deletions(-) diff --git a/docs/business-index.md b/docs/business-index.md index 44898eb..321959a 100644 --- a/docs/business-index.md +++ b/docs/business-index.md @@ -23,7 +23,7 @@ | 统一订单展示 | `src/modules/order-management/` | `src/modules/first-leg/` 与对应后端订单契约 | | 客户财务账单、入仓码联查、收款、未收余额与客户确认 | `src/modules/finance/`;确认与明细入口 `views/FinanceBillsView.vue`、`api/finance.ts`,入仓码通过 `src/modules/first-leg/public.ts` 复用 `InboundCodeAssociationDialog` | Backend `finance/`;账单行只显示服务端 `inboundCode` 并按登录客户查询业务关系,缺失显示“—”,不得回退内部 ID。收款方式由服务端枚举映射中文。出账或已出账调整后为 `PENDING_CONFIRMATION`,客户确认提交登录成员、`expectedVersion` 和幂等键 | | 客户资金账户、授信与充值申请 | `src/modules/finance/views/FinanceAccountView.vue`、`api/account.ts` | 显示当前企业 CNY 实际余额、冻结金额、授信额度、授信有效期、已用授信、冻结授信、总可用余额和不可变流水;数据加载前显示“—”,不得先伪装为零。授信到期日当天仍有效,次日已用授信并入实际余额,负数实际余额会阻止提交新订单。充值只展示服务端启用的平台统一静态收款码,客户上传 JPG/PNG 付款截图后进入待审核。客户范围只取登录态,无收款码时禁止提交,不模拟支付成功 | -| 海外采购 | `src/modules/cross-border/routes.ts`、`views/ProcurementView.vue`、`components/ProcurementDetailPanel.vue`、`api/procurement.ts` | 独立列表、新建及详情路由;客户范围只取 Token,创建的 `customerName` 只取认证会话企业名称。详情消费独立客户投影,展示履约、已确认箱明细、实际运单和费用结果,不开放内部文件或平台操作。Backend `lastleg-service/procurement/` | +| 海外采购 | `src/modules/cross-border/routes.ts`、`views/ProcurementView.vue`、`components/ProcurementDetailPanel.vue`、`api/procurement.ts` | 独立列表、新建及详情路由;客户范围只取 Token,创建的 `customerName` 只取认证会话企业名称。七状态简化流程展示分批收货、分批出库与费用,待客户确认时提供当前客户确认动作;不开放内部文件或平台操作。Backend `lastleg-service/procurement/`;规则见 `baiyue-firstleg/lastleg-service/docs/procurement-workflow.md`(工作区路径) | | 美国本土卡与美本土散板 | `src/modules/cross-border/routes.ts`、`components/BusinessOrdersView.vue`、`api/businessOrders.ts` | 两个运输业务共用列表/表单组件,客户范围只取 Token;不消费采购字段或状态。Backend `lastleg-service/` 的 `trucking/`、`looseboard/` 客户接口 | | 海外仓入库订单 | `src/modules/order-management/routes.ts`、`views/InboundOrdersView.vue`、`api/inboundPlans.ts` | `orders/inbound` 为只读列表/详情,权限 `merchant:warehouse:inbound:list`;不展示或提交内部 ID | | 海外仓贴标、仓储转运、货物废弃 | `src/modules/overseas-warehouse/routes.ts`、`views/WarehouseServiceView.vue`、`api/warehouseServices.ts` | 三个路由均为 `active`;退货预报另见本节独立入口;贴标申请先选国外中转仓和有库存历史入仓单,换标类型为只换 SKU、只换箱唛、SKU+箱唛。涉及箱唛换标时,整张贴标申请只选择一个 FBA 目的仓,全部箱级明细统一提交该目的仓,模板和明细不再逐行选择目的仓。箱唛换标明细一行固定代表一箱,箱数不可编辑,提交也固定为 1。货物明细外层始终提供“SKU 关联”“箱号关联”“SKU 混箱换标”三套模板的下载、上传,不随换标类型切换;上传只进入待导入关系区,客户可勾选部分或全选后导入本次明细,只有进入明细的库存才随申请提交和冻结。明细也支持手动新增编辑,组合换标以仓库唛头连接旧/新 SKU 和新 FBA 箱唛关系。涉及箱唛换标时必须选择卡车、自提或快递;快递可选择打印面单并上传 PDF/图片。PDA 逐箱换标、上传照片并由仓库复核后,客户详情展示完工照片和“换标完成确认”;客户确认前不得继续出库,确认后卡车进入预排车,自提/快递进入换标出库待创建。历史库存位置不能解释成箱唛与具体托盘快照,客户页面不暴露托盘、库位和内部库存 ID | diff --git a/src/modules/cross-border/AGENTS.md b/src/modules/cross-border/AGENTS.md index 0a296a9..34d673d 100644 --- a/src/modules/cross-border/AGENTS.md +++ b/src/modules/cross-border/AGENTS.md @@ -21,7 +21,7 @@ - 三条线都是客户自助下单,提交后由平台审核;客户侧不提供审核、改价或内部作业动作。 - 客户与仓库范围只取服务端当前租户上下文,不从前端参数推断;跨客户查询一律拒绝。 - 提交需要幂等键、提交中状态与可恢复失败反馈;失败保留已填内容。 -- 海外采购的购买、装箱、面单、交寄与国内核费是一条独立链路,改节点前先读 `docs/海外仓业务/采购订单样品履约流程变更需求.md`(工作区路径)。 +- 海外采购按 `baiyue-firstleg/lastleg-service/docs/procurement-workflow.md`(工作区路径)的七状态流程执行;详情展示分批收货、出库与费用,待客户确认时由当前客户确认,不恢复旧装箱与两段核费节点。 - 页面承载方式按已确认原型:列表 + 独立建单/详情路由,不改为弹框或抽屉。 ## 验证 diff --git a/src/modules/cross-border/api/procurement.ts b/src/modules/cross-border/api/procurement.ts index 29db3c9..38883d9 100644 --- a/src/modules/cross-border/api/procurement.ts +++ b/src/modules/cross-border/api/procurement.ts @@ -1,4 +1,4 @@ -import { apiRequest } from '@/api/http' +import { apiBlobRequest, apiRequest } from '@/api/http' import type { PageResult } from './businessOrders' export type Amount = number | string @@ -97,6 +97,35 @@ export interface ProcurementQuery { purchaseCategory?: string status?: string } +export interface ProcurementWorkflowFile { fileId: string; fileName: string; contentType: string } +export interface ProcurementReceipt { + id: string + quantity: number + fileIds: string[] + receivedAt: string +} +export interface ProcurementOutbound { + id: string + outboundNo: string + quantity: number + trackingNo: string + status: string + fileIds: string[] + createdAt: string + completedAt: string | null +} +export interface ProcurementWorkflow { + orderNo: string + version: number + status: string + receivedQuantity: number + stockQuantity: number + reservedQuantity: number + availableQuantity: number + files: ProcurementWorkflowFile[] + receipts: ProcurementReceipt[] + outbounds: ProcurementOutbound[] +} const endpoint = '/merchant/v1/last-leg/overseas-procurements' export async function listProcurements(params: ProcurementQuery) { const result = await apiRequest>({ method: 'GET', url: endpoint, params }) @@ -108,3 +137,13 @@ export function getProcurement(orderNo: string) { export function createProcurement(data: ProcurementCreate) { return apiRequest({ method: 'POST', url: endpoint, data }) } +export function getProcurementWorkflow(orderNo: string) { + return apiRequest({ method: 'GET', url: `${endpoint}/${encodeURIComponent(orderNo)}/workflow` }) +} +export function confirmProcurement(orderNo: string, data: { version: number; idempotencyKey: string }) { + return apiRequest({ method: 'POST', url: `${endpoint}/${encodeURIComponent(orderNo)}/confirm`, data }) +} + +export function getProcurementFile(orderNo: string, fileId: string) { + return apiBlobRequest({ method: 'GET', url: `${endpoint}/${encodeURIComponent(orderNo)}/files/${encodeURIComponent(fileId)}/content` }) +} diff --git a/src/modules/cross-border/businessOrders.test.mjs b/src/modules/cross-border/businessOrders.test.mjs index 6279cc0..7e1c079 100644 --- a/src/modules/cross-border/businessOrders.test.mjs +++ b/src/modules/cross-border/businessOrders.test.mjs @@ -150,7 +150,7 @@ test('truck requires both contacts vehicle and minute times without inheriting l truck.form.pickupAt = '2026-10-20T09:30:00' let failure truck.rules.value.deliveryAt[1].validator({}, '2026-10-20T09:29:00', error => { failure = error }) - assert.equal(failure.message, '送货时间不能早于取货时间') + assert.equal(failure.message, 'C点送达时间不能早于B点取货时间') }) test('truck retry submits the complete new contract with identical local times and idempotency key', async () => { @@ -176,8 +176,8 @@ test('truck details render saved minute times and unknown pool demand without ti current.detail.value = { summary: { vehicleType: null, pickupAt: '2026-10-20T09:30:00', deliveryAt: null }, originAddress: null, destinationAddress: null, remark: null } const fields = new Map(current.detailFields.value) assert.equal(fields.get('车辆类型'), '—') - assert.equal(fields.get('取货时间'), '2026-10-20 09:30') - assert.equal(fields.get('送货时间'), '—') + assert.equal(fields.get('B点取货时间'), '2026-10-20 09:30') + assert.equal(fields.get('C点送达时间'), '—') assert.equal(fields.has('柜型'), false) assert.equal(fields.has('托盘数'), false) }) diff --git a/src/modules/cross-border/components/BusinessOrdersView.vue b/src/modules/cross-border/components/BusinessOrdersView.vue index f55b7b5..e612d97 100644 --- a/src/modules/cross-border/components/BusinessOrdersView.vue +++ b/src/modules/cross-border/components/BusinessOrdersView.vue @@ -38,7 +38,7 @@ type BusinessForm = DemandFormModel const configuration = computed(() => ({ trucking: { - title: '美国本土卡', + title: '美国本土卡车', description: '查询并管理美国本土卡订单', columns: transportColumns(false), }, @@ -50,16 +50,20 @@ const configuration = computed(() => ({ })[props.kind]) function transportColumns(looseboard: boolean): ColumnDefinition[] { + const originLabel = looseboard ? '起运地' : 'B点取货地' + const destinationLabel = looseboard ? '目的地' : 'C点送达地' + const pickupLabel = looseboard ? '取货时间' : 'B点取货时间' + const deliveryLabel = looseboard ? '送货时间' : 'C点送达时间' return [ { key: 'orderNo', label: '订单编号', width: 180 }, ...(!looseboard ? [{ key: 'vehicleType', label: '车辆类型', width: 110 }] : []), - { key: 'origin', label: '起运地', width: 150 }, - { key: 'destination', label: '目的地', width: 150 }, + { key: 'origin', label: originLabel, width: 150 }, + { key: 'destination', label: destinationLabel, width: 150 }, ...(looseboard ? [{ key: 'productName', label: '商品名称', width: 150 }, { key: 'estimatedPalletCount', label: '预计托数', width: 100 }] : []), { key: 'totalWeightKg', label: '总重量(kg)', width: 120 }, { key: 'budgetAmount', label: '预算总价', width: 110 }, ...([ - { key: 'pickupAt', label: '取货时间', width: 165 }, { key: 'deliveryAt', label: '送货时间', width: 165 }, + { key: 'pickupAt', label: pickupLabel, width: 165 }, { key: 'deliveryAt', label: deliveryLabel, width: 165 }, ]), { key: 'status', label: '状态', width: 130 }, { key: 'fleetArrangement', label: '承运安排', width: 190 }, @@ -93,7 +97,7 @@ const queryModel = computed({ const queryFields = computed(() => [ { key: 'orderNo', label: '订单号', type: 'input', placeholder: '请输入订单号' }, - { key: 'routeKeyword', label: '运输路线', type: 'input', placeholder: '请输入起运地或目的地' }, + { key: 'routeKeyword', label: '运输路线', type: 'input', placeholder: props.kind === 'trucking' ? '请输入 B 点或 C 点' : '请输入起运地或目的地' }, { key: 'status', label: '状态', @@ -123,13 +127,13 @@ const detailFields = computed(() => { ...(props.kind === 'looseboard' ? [ ['商品名称', summary.productName], ['预计托数', summary.estimatedPalletCount], ] : [['车辆类型', vehicleTypeLabel(summary.vehicleType)]]), - ['起运地', read(summary, 'origin')], ['起运地址', current.originAddress], - ['目的地', read(summary, 'destination')], ['目的地址', current.destinationAddress], + [props.kind === 'trucking' ? 'B点取货地' : '起运地', read(summary, 'origin')], [props.kind === 'trucking' ? 'B点取货地址' : '起运地址', current.originAddress], + [props.kind === 'trucking' ? 'C点送达地' : '目的地', read(summary, 'destination')], [props.kind === 'trucking' ? 'C点送达地址' : '目的地址', current.destinationAddress], ['件数', read(summary, 'packageCount')], ['总重量(kg)', read(summary, 'totalWeightKg')], ['总体积(m³)', read(summary, 'totalVolumeCbm')], ['预算总价', money(read(summary, 'budgetAmount'), read(summary, 'currency'))], - ['起运地联系人', summary.originContactName], ['起运地联系电话', summary.originContactPhone], - ['目的地联系人', summary.destinationContactName], ['目的地联系电话', summary.destinationContactPhone], - ['取货时间', localMinute(summary.pickupAt)], ['送货时间', localMinute(summary.deliveryAt)], ['备注', current.remark], + [props.kind === 'trucking' ? 'B点联系人' : '起运地联系人', summary.originContactName], [props.kind === 'trucking' ? 'B点联系电话' : '起运地联系电话', summary.originContactPhone], + [props.kind === 'trucking' ? 'C点联系人' : '目的地联系人', summary.destinationContactName], [props.kind === 'trucking' ? 'C点联系电话' : '目的地联系电话', summary.destinationContactPhone], + [props.kind === 'trucking' ? 'B点取货时间' : '取货时间', localMinute(summary.pickupAt)], [props.kind === 'trucking' ? 'C点送达时间' : '送货时间', localMinute(summary.deliveryAt)], ['备注', current.remark], ] }) @@ -212,7 +216,7 @@ function getRowActions(candidate: object): TableAction[] { } function detailFieldClass(label: unknown) { - return ['备注', '起运地址', '目的地址'].includes(String(label)) ? 'is-wide' : '' + return ['备注', props.kind === 'trucking' ? 'B点取货地址' : '起运地址', props.kind === 'trucking' ? 'C点送达地址' : '目的地址'].includes(String(label)) ? 'is-wide' : '' } const listRouteName = computed(() => ({ diff --git a/src/modules/cross-border/components/ProcurementDetailPanel.vue b/src/modules/cross-border/components/ProcurementDetailPanel.vue index 1f8cd42..ccc7d9c 100644 --- a/src/modules/cross-border/components/ProcurementDetailPanel.vue +++ b/src/modules/cross-border/components/ProcurementDetailPanel.vue @@ -1,107 +1,103 @@ diff --git a/src/modules/cross-border/components/TransportDemandForm.vue b/src/modules/cross-border/components/TransportDemandForm.vue index 460c928..cc02418 100644 --- a/src/modules/cross-border/components/TransportDemandForm.vue +++ b/src/modules/cross-border/components/TransportDemandForm.vue @@ -29,14 +29,14 @@ defineExpose({ - - - - - - - - + + + + + + + +
@@ -58,8 +58,8 @@ defineExpose({
- - + +
diff --git a/src/modules/cross-border/procurement.test.mjs b/src/modules/cross-border/procurement.test.mjs index d369664..c9c865a 100644 --- a/src/modules/cross-border/procurement.test.mjs +++ b/src/modules/cross-border/procurement.test.mjs @@ -65,14 +65,14 @@ function viewHarness({ createPage = false, detailPage = false, api = {}, storage const route = vue.reactive({ params: { orderNo: detailPage ? 'PO-1' : '' } }) const auth = vue.reactive({ token: 'identity-A', user: { tenantName: 'Company' }, profile: { tenantName: 'Company' }, hasPermission: () => true }) const pushes = [], dispose = [] - const defaults = { listProcurements: async () => ({ records: [], total: 0 }), getProcurement: async orderNo => ({ order: { orderNo } }), createProcurement: async () => ({ order: { orderNo: 'PO-CREATED' } }) } + const defaults = { getProcurementWorkflow: async orderNo => ({ orderNo, version: 1, status: 'WAIT_CUSTOMER_CONFIRM', receipts: [], outbounds: [] }), confirmProcurement: async () => ({ orderNo: 'PO-1', version: 2, status: 'CONFIRMED' }), listProcurements: async () => ({ records: [], total: 0 }), getProcurement: async orderNo => ({ order: { orderNo } }), createProcurement: async () => ({ order: { orderNo: 'PO-CREATED' } }) } const { descriptor } = parse(readFileSync(new URL('./views/ProcurementView.vue', import.meta.url), 'utf8')) const source = compileScript(descriptor, { id: 'procurement-test' }).content const context = { exports: {}, Error, crypto, sessionStorage: { getItem: key => storage.get(key) || null, setItem: (key, value) => storage.set(key, value), removeItem: key => storage.delete(key) }, require(name) { if (name === 'vue') return { ...vue, onBeforeUnmount: callback => dispose.push(callback), watch: (...args) => { const stop = vue.watch(...args); dispose.push(stop); return stop } } if (name === 'vue-router') return { useRoute: () => route, useRouter: () => ({ push: async target => pushes.push(target) }) } - if (name === 'element-plus') return { ElMessage: { success: () => {} } } + if (name === 'element-plus') return { ElMessage: { success: () => {} }, ElMessageBox: { confirm: async () => {} } } if (name === '@/api/types') return { ApiError } if (name === '@/api/session') return { accessTokenIdentity: token => token } if (name === '@/modules/auth/public') return { useAuthStore: () => auth } @@ -160,12 +160,60 @@ test('domestic purchase creates an export order and displays the actual origin a const payload = presentation.buildProcurementCreate({ ...draft(), procurementDirection: 'DOMESTIC_TO_OVERSEAS', receivingAddress: 'US, CA, Los Angeles, test destination' }, 'Authenticated Company', 'export-key') assert.equal(payload.procurementDirection, 'DOMESTIC_TO_OVERSEAS') assert.equal(payload.receivingAddress, 'US, CA, Los Angeles, test destination') - assert.equal(presentation.statusLabel('WAIT_OVERSEAS_RECEIPT', payload.procurementDirection), '待国内到仓') - assert.equal(presentation.statusLabel('DOMESTIC_RECEIVING', payload.procurementDirection), '海外收货中') - assert.equal(presentation.statusLabel('WAIT_CHARGES', payload.procurementDirection), '待海外核费') - assert.equal(presentation.statusLabel('WAIT_OVERSEAS_RECEIPT'), '待海外到仓') + assert.equal(presentation.statusLabel('RECEIVING', payload.procurementDirection), '收货中') + assert.equal(presentation.statusLabel('DELIVERING', payload.procurementDirection), '派送中') + assert.equal(presentation.statusLabel('WAIT_CUSTOMER_CONFIRM', payload.procurementDirection), '待客户确认') + assert.equal(presentation.statusLabel('COMPLETED'), '签收完成') assert.throws(() => presentation.buildProcurementCreate({ ...draft(), procurementDirection: 'INVALID' }, 'Company', 'key'), /采购方向/) const source = readFileSync(new URL('./views/ProcurementView.vue', import.meta.url), 'utf8') assert.match(source, /v-model="form.procurementDirection"/) assert.match(source, /regionText\('最终国内收件资料', form.procurementDirection\)/) }) + + +test('customer confirms with current workflow version and double-click only sends once', async () => { + let resolve, calls = [] + const harness = viewHarness({ detailPage: true, api: { confirmProcurement: (orderNo, payload) => { + calls.push({ orderNo, ...payload }); return new Promise(done => { resolve = done }) + } } }) + try { + await flush() + const first = harness.state.confirmReceived(); await flush(); await harness.state.confirmReceived() + assert.equal(calls.length, 1); assert.equal(calls[0].version, 1); assert.equal(calls[0].orderNo, 'PO-1') + assert.ok(calls[0].idempotencyKey) + resolve({ orderNo: 'PO-1', version: 2, status: 'CONFIRMED' }); await first + assert.equal(harness.state.workflow.value.status, 'CONFIRMED'); assert.equal(harness.state.canConfirm.value, false) + } finally { harness.dispose() } +}) + +test('failed customer confirmation preserves receipt context for recovery', async () => { + const harness = viewHarness({ detailPage: true, api: { confirmProcurement: async () => { throw new ApiError('版本已更新,请刷新', 'A0409') } } }) + try { + await flush(); await harness.state.confirmReceived() + assert.equal(harness.state.workflow.value.status, 'WAIT_CUSTOMER_CONFIRM') + assert.equal(harness.state.confirming.value, false); assert.equal(harness.state.confirmError.value, '版本已更新,请刷新') + } finally { harness.dispose() } +}) + + +test('unknown create and confirmation results reuse idempotency keys for unchanged retries', async () => { + const creates = [], confirms = [] + const create = viewHarness({ createPage: true, api: { createProcurement: async payload => { creates.push(payload); throw new ApiError('timeout', 'TIMEOUT') } } }) + const confirm = viewHarness({ detailPage: true, api: { confirmProcurement: async (_, payload) => { confirms.push(payload); throw new ApiError('timeout', 'TIMEOUT') } } }) + try { + Object.assign(create.state.form, draft()); await create.state.submit(); await create.state.submit() + assert.equal(creates[0].idempotencyKey, creates[1].idempotencyKey) + await flush(); await confirm.state.confirmReceived(); await confirm.state.confirmReceived() + assert.equal(confirms[0].idempotencyKey, confirms[1].idempotencyKey) + } finally { create.dispose(); confirm.dispose() } +}) + + +test('bill-settled manual fees show review results without counting a balance debit', () => { + const bill = determination => ({ ...fee('OTHER', 'NOT_REQUIRED', '100'), determination }) + assert.equal(presentation.feeStatus(bill('PENDING')), '账单结算 · 待审核') + assert.equal(presentation.feeStatus(bill('APPROVED')), '账单结算 · 已确认') + assert.equal(presentation.feeStatus(bill('REJECTED')), '账单结算 · 已拒绝') + assert.equal(presentation.feeTone(bill('REJECTED')), 'danger') + assert.equal(presentation.feeTotals([bill('PENDING'), bill('APPROVED'), bill('REJECTED')]).paid, '0.00') +}) diff --git a/src/modules/cross-border/procurementPresentation.ts b/src/modules/cross-border/procurementPresentation.ts index 57f7282..5290def 100644 --- a/src/modules/cross-border/procurementPresentation.ts +++ b/src/modules/cross-border/procurementPresentation.ts @@ -1,9 +1,8 @@ import type { Amount, ProcurementCreate, ProcurementFee } from './api/procurement' export const procurementStatuses: Record = { - PENDING_REVIEW: '待审核', WAIT_PURCHASE: '待购买', WAIT_OVERSEAS_RECEIPT: '待海外到仓', OVERSEAS_RECEIVING: '海外收货中', - WAIT_PACKING: '待装箱', WAIT_LABELS: '待面单及预估处理', WAIT_OVERSEAS_SHIPMENT: '待海外交寄', INTERNATIONAL_TRANSIT: '国际运输中', - DOMESTIC_RECEIVING: '国内收货中', WAIT_CHARGES: '待国内核费', WAIT_CUSTOMER_SHIPMENT: '待寄客户', SHIPPED_TO_CUSTOMER: '已寄客户', + PENDING_REVIEW: '待审核', RECEIVING: '收货中', WAIT_CUSTOMER_CONFIRM: '待客户确认', CONFIRMED: '已确认', + IN_TRANSIT: '运输中', DELIVERING: '派送中', COMPLETED: '签收完成', REJECTED: '已拒绝', CANCELLED: '已取消', EXCEPTION_CLOSED: '异常关闭', } export const feePurposes: Record = { @@ -12,10 +11,10 @@ export const feePurposes: Record = { } export function statusLabel(status: string | null | undefined, direction?: string | null) { return regionText(status ? procurementStatuses[status] || '待核对进度' : '—', direction) } export function statusTone(status: string | null | undefined): 'success' | 'warning' | 'danger' | 'info' | 'primary' { - if (status === 'SHIPPED_TO_CUSTOMER') return 'success' + if (status === 'COMPLETED' || status === 'CONFIRMED') return 'success' if (status === 'REJECTED' || status === 'EXCEPTION_CLOSED') return 'danger' if (status === 'CANCELLED') return 'info' - if (status === 'INTERNATIONAL_TRANSIT') return 'primary' + if (status === 'IN_TRANSIT' || status === 'DELIVERING') return 'primary' return 'warning' } function cents(value: Amount | null | undefined): bigint | null { @@ -46,6 +45,12 @@ export function productHref(value: string | null | undefined) { try { const url = new URL(value); return ['http:', 'https:'].includes(url.protocol) ? url.href : undefined } catch { return undefined } } export function feeStatus(fee: ProcurementFee) { + if (fee.walletStatus === 'NOT_REQUIRED') { + if (fee.determination === 'PENDING') return '账单结算 · 待审核' + if (fee.determination === 'APPROVED') return '账单结算 · 已确认' + if (fee.determination === 'REJECTED') return '账单结算 · 已拒绝' + return '账单结算' + } if (fee.purpose === 'INTERNATIONAL_ESTIMATE') { const label: Record = { FROZEN: '已冻结,未扣款', ZERO: '零预估已确认', SETTLED: '原预估已结清', RELEASED: '预估已释放' } if (fee.walletStatus && label[fee.walletStatus]) return label[fee.walletStatus] @@ -58,6 +63,8 @@ export function feeStatus(fee: ProcurementFee) { return fee.walletStatus ? labels[fee.walletStatus] || '待核定' : '待核定' } export function feeTone(fee: ProcurementFee): 'success' | 'warning' | 'danger' | 'info' { + if (fee.determination === 'REJECTED') return 'danger' + if (fee.walletStatus === 'NOT_REQUIRED' && fee.determination === 'APPROVED') return 'info' if (fee.walletStatus?.endsWith('_FAILED')) return 'danger' if (['SETTLED', 'REFUNDED', 'ZERO', 'NOT_APPLICABLE', 'RELEASED'].includes(fee.walletStatus || '')) return 'success' if (fee.walletStatus === 'TERMINATED') return 'info' diff --git a/src/modules/cross-border/transportDemandForm.ts b/src/modules/cross-border/transportDemandForm.ts index 91d5f31..b235b6a 100644 --- a/src/modules/cross-border/transportDemandForm.ts +++ b/src/modules/cross-border/transportDemandForm.ts @@ -32,10 +32,14 @@ export function createTransportDemandForm(): TransportDemandForm { } } -const requiredText = { - origin: '起运地', destination: '目的地', originContactName: '起运地联系人', - originContactPhone: '起运地联系电话', destinationContactName: '目的地联系人', destinationContactPhone: '目的地联系电话', -} as const +const requiredText = (kind: TransportDemandKind) => ({ + origin: kind === 'truck' ? 'B点取货地' : '起运地', + destination: kind === 'truck' ? 'C点送达地' : '目的地', + originContactName: kind === 'truck' ? 'B点联系人' : '起运地联系人', + originContactPhone: kind === 'truck' ? 'B点联系电话' : '起运地联系电话', + destinationContactName: kind === 'truck' ? 'C点联系人' : '目的地联系人', + destinationContactPhone: kind === 'truck' ? 'C点联系电话' : '目的地联系电话', +} as const) export function localMinuteInput(value: unknown) { return typeof value === 'string' && value ? `${value.slice(0, 16)}:00` : '' @@ -43,8 +47,9 @@ export function localMinuteInput(value: unknown) { export function transportDemandRules(kind: TransportDemandKind, form: TransportDemandForm): FormRules { const rules: FormRules = {} - for (const [field, label] of Object.entries(requiredText)) { - rules[field as keyof typeof requiredText] = [{ required: true, whitespace: true, message: `请输入${label}`, trigger: 'blur' }] + const labels = requiredText(kind) + for (const [field, label] of Object.entries(labels)) { + rules[field as keyof typeof labels] = [{ required: true, whitespace: true, message: `请输入${label}`, trigger: 'blur' }] } if (kind === 'truck') rules.vehicleType = [{ required: true, message: '请选择车辆类型', trigger: 'change' }] for (const [field, label] of [['packageCount', '件数'], ['estimatedPalletCount', '预计托数'], ['totalWeightKg', '总重量'], ['totalVolumeCbm', '总体积']] as const) { @@ -52,23 +57,26 @@ export function transportDemandRules(kind: TransportDemandKind, form: TransportD rules[field] = [{ required: true, type: 'number', min: 0, message: `${label}须为不小于 0 的数字`, trigger: 'change' }] } rules.budgetAmount = [{ type: 'number', min: 0, message: '预算总价不能小于 0', trigger: 'change' }] - rules.pickupAt = [{ required: true, message: '请选择取货时间', trigger: 'change' }] - rules.deliveryAt = [{ required: true, message: '请选择送货时间', trigger: 'change' }, { - validator: (_rule, value, callback) => callback(value && form.pickupAt && localMinuteInput(value) < localMinuteInput(form.pickupAt) ? new Error('送货时间不能早于取货时间') : undefined), trigger: 'change', + const pickupLabel = kind === 'truck' ? 'B点取货时间' : '取货时间', deliveryLabel = kind === 'truck' ? 'C点送达时间' : '送货时间' + rules.pickupAt = [{ required: true, message: `请选择${pickupLabel}`, trigger: 'change' }] + rules.deliveryAt = [{ required: true, message: `请选择${deliveryLabel}`, trigger: 'change' }, { + validator: (_rule, value, callback) => callback(value && form.pickupAt && localMinuteInput(value) < localMinuteInput(form.pickupAt) ? new Error(`${deliveryLabel}不能早于${pickupLabel}`) : undefined), trigger: 'change', }] return rules } export function transportDemandError(kind: TransportDemandKind, form: TransportDemandForm): string | undefined { if (kind === 'truck' && !['PALLET_15', 'PALLET_30'].includes(form.vehicleType)) return '请选择车辆类型' - for (const [field, label] of Object.entries(requiredText)) { - if (!String(form[field as keyof typeof requiredText] || '').trim()) return `请输入${label}` + const labels = requiredText(kind) + for (const [field, label] of Object.entries(labels)) { + if (!String(form[field as keyof typeof labels] || '').trim()) return `请输入${label}` } - for (const [field, label] of [['pickupAt', '取货时间'], ['deliveryAt', '送货时间']] as const) { + const pickupLabel = kind === 'truck' ? 'B点取货时间' : '取货时间', deliveryLabel = kind === 'truck' ? 'C点送达时间' : '送货时间' + for (const [field, label] of [['pickupAt', pickupLabel], ['deliveryAt', deliveryLabel]] as const) { if (!form[field]) return `请选择${label}` if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?$/.test(form[field])) return `请选择有效的${label}` } - if (localMinuteInput(form.deliveryAt) < localMinuteInput(form.pickupAt)) return '送货时间不能早于取货时间' + if (localMinuteInput(form.deliveryAt) < localMinuteInput(form.pickupAt)) return `${deliveryLabel}不能早于${pickupLabel}` for (const [field, label] of [['packageCount', '件数'], ['totalWeightKg', '总重量'], ['totalVolumeCbm', '总体积'], ...(kind === 'looseboard' ? [['estimatedPalletCount', '预计托数'] as const] : [])] as const) { if (!Number.isFinite(form[field]) || form[field] < 0) return `${label}须为不小于 0 的数字` } diff --git a/src/modules/cross-border/views/ProcurementView.vue b/src/modules/cross-border/views/ProcurementView.vue index 66695ed..0d010ae 100644 --- a/src/modules/cross-border/views/ProcurementView.vue +++ b/src/modules/cross-border/views/ProcurementView.vue @@ -1,7 +1,7 @@