diff --git a/AGENTS.md b/AGENTS.md index 1cbfbe0..2e51930 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,7 +54,8 @@ | 统一订单展示 | `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`、`components/BusinessOrdersView.vue`、`api/businessOrders.ts` | 三个路由均为 `active`,客户范围只取 Token;采购创建的 `customerName` 只取当前认证会话企业名称,不允许自由输入或冒用其他客户。Backend `lastleg-service/` 的 `procurement/`、`trucking/`、`looseboard/` 客户接口 | +| 海外采购 | `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`、`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/components.d.ts b/src/components.d.ts index bcd7883..acad601 100644 --- a/src/components.d.ts +++ b/src/components.d.ts @@ -42,6 +42,7 @@ declare module 'vue' { ElRadio: typeof import('element-plus/es')['ElRadio'] ElRadioButton: typeof import('element-plus/es')['ElRadioButton'] ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup'] + ElResult: typeof import('element-plus/es')['ElResult'] ElSelect: typeof import('element-plus/es')['ElSelect'] ElSkeleton: typeof import('element-plus/es')['ElSkeleton'] ElSwitch: typeof import('element-plus/es')['ElSwitch'] diff --git a/src/modules/cross-border/api/businessOrders.ts b/src/modules/cross-border/api/businessOrders.ts index 5bb7ee3..ae51b30 100644 --- a/src/modules/cross-border/api/businessOrders.ts +++ b/src/modules/cross-border/api/businessOrders.ts @@ -8,7 +8,7 @@ export interface PageResult { pages: number } -export type CrossBorderBusinessKind = 'procurement' | 'trucking' | 'looseboard' +export type CrossBorderBusinessKind = 'trucking' | 'looseboard' export interface BusinessOrderQuery { page: number @@ -16,8 +16,6 @@ export interface BusinessOrderQuery { orderNo?: string status?: string routeKeyword?: string - productName?: string - purchaseCategory?: string transportType?: string transportMode?: string requestedFrom?: string @@ -26,54 +24,6 @@ export interface BusinessOrderQuery { createdTo?: string } -export interface ProcurementSummary { - orderNo: string - purchaseCategory: string - productName: string - productSpecification: string | null - purchaseQuantity: number - budgetUnitPrice: number | null - currency: string | null - inboundQuantity: number - outboundQuantity: number - inventoryQuantity: number - status: string - version: number - createdAt: string - updatedAt: string -} - -export interface ProcurementDetail { - summary: ProcurementSummary - productUrl: string | null - receivingAddress: string | null - contactName: string - contactPhone: string - domesticCarrier: string | null - domesticTrackingNo: string | null - overseasCarrier: string | null - overseasTrackingNo: string | null - estimatedArrivalAt: string | null - remark: string | null - reviewRemark: string | null -} - -export interface ProcurementCreate { - customerName: string - purchaseCategory: string - productName: string - productSpecification?: string - productUrl?: string - purchaseQuantity: number - budgetUnitPrice?: number - currency?: string - receivingAddress?: string - contactName: string - contactPhone: string - remark?: string - idempotencyKey: string -} - export interface TransportSummary { fleetArrangement: import('../fleetArrangement').FleetArrangement | null orderNo: string @@ -132,7 +82,6 @@ export interface TransportUpdate extends Omit } const endpointByKind: Record = { - procurement: '/merchant/v1/last-leg/overseas-procurements', trucking: '/merchant/v1/last-leg/us-truck-orders', looseboard: '/merchant/v1/last-leg/local-looseboard-orders', } @@ -149,10 +98,6 @@ export function getBusinessOrder(kind: CrossBorderBusinessKind, orderNo: stri return apiRequest({ method: 'GET', url: `${endpointByKind[kind]}/${encodeURIComponent(orderNo)}` }) } -export function createProcurement(payload: ProcurementCreate) { - return apiRequest({ method: 'POST', url: endpointByKind.procurement, data: payload }) -} - export function createTransport(kind: 'trucking' | 'looseboard', payload: TransportCreate) { return apiRequest({ method: 'POST', url: endpointByKind[kind], data: payload }) } diff --git a/src/modules/cross-border/api/procurement.ts b/src/modules/cross-border/api/procurement.ts new file mode 100644 index 0000000..6b1b6ca --- /dev/null +++ b/src/modules/cross-border/api/procurement.ts @@ -0,0 +1,108 @@ +import { apiRequest } from '@/api/http' +import type { PageResult } from './businessOrders' + +export type Amount = number | string +export interface ProcurementSummary { + orderNo: string + purchaseCategory: string + productName: string + productSpecification: string | null + purchaseQuantity: number + budgetUnitPrice: Amount | null + currency: string | null + status: string + version: number + createdAt: string + updatedAt: string +} +export interface ProcurementOrder extends ProcurementSummary { + productUrl: string | null + receivingAddress: string + contactName: string + contactPhone: string + estimatedArrivalAt: string | null + plannedWarehouseName: string | null + actualWarehouseCode: string | null + actualWarehouseName: string | null +} +export interface ProcurementFee { + feeNo: string + purpose: string + name: string + version: number | null + active: boolean + determination: string + amount: Amount + currency: string + originalAmount: Amount | null + originalCurrency: string | null + exchangeRate: Amount | null + exchangeRateMonth: string | null + businessDate: string | null + parentFeeNo: string | null + walletStatus: string | null + sourceLinked: boolean +} +export interface ProcurementBox { + cartonNo: string + quantity: number + lengthCm: Amount + widthCm: Amount + heightCm: Amount + grossWeightKg: Amount +} +export interface ProcurementShipment { + shipmentNo: string + leg: 'OVERSEAS' | 'DOMESTIC' + shippedAt: string + cartonNo: string + quantity: number + carrier: string + trackingNo: string +} +export interface ProcurementDetail { + order: ProcurementOrder + budget: { totalAmount: Amount | null; currency: string; authorizedAt: string | null } + purchased: boolean + purchaseConfirmed: boolean + purchaseNeedsAttention: boolean + progress: { key: string; title: string; completedAt: string | null }[] + boxes: ProcurementBox[] + shipments: ProcurementShipment[] + fees: ProcurementFee[] + exceptionSummaries: string[] +} +export interface ProcurementCreate { + customerName: string + purchaseCategory: string + productName: string + productSpecification?: string + productUrl?: string + purchaseQuantity: number + budgetUnitPrice: number + currency: 'CNY' | 'USD' + receivingAddress: string + contactName: string + contactPhone: string + remark?: string + idempotencyKey: string +} +export interface ProcurementQuery { + page: number + size: number + orderNo?: string + productName?: string + purchaseCategory?: string + status?: string +} +const endpoint = '/merchant/v1/last-leg/overseas-procurements' +export async function listProcurements(params: ProcurementQuery) { + const result = await apiRequest>({ method: 'GET', url: endpoint, params }) + return { ...result, page: Number(result.page), size: Number(result.size), total: Number(result.total), pages: Number(result.pages) } +} +export function getProcurement(orderNo: string) { + return apiRequest({ method: 'GET', url: `${endpoint}/${encodeURIComponent(orderNo)}` }) +} +export function createProcurement(data: ProcurementCreate) { + return apiRequest({ method: 'POST', url: endpoint, data }) +} diff --git a/src/modules/cross-border/businessOrders.test.mjs b/src/modules/cross-border/businessOrders.test.mjs index 01d11f0..38830ef 100644 --- a/src/modules/cross-border/businessOrders.test.mjs +++ b/src/modules/cross-border/businessOrders.test.mjs @@ -7,6 +7,8 @@ import ts from 'typescript' const api = readFileSync(new URL('./api/businessOrders.ts', import.meta.url), 'utf8') const routes = readFileSync(new URL('./routes.ts', import.meta.url), 'utf8') const view = readFileSync(new URL('./components/BusinessOrdersView.vue', import.meta.url), 'utf8') +const procurementApi = readFileSync(new URL('./api/procurement.ts', import.meta.url), 'utf8') +const procurementView = readFileSync(new URL('./views/ProcurementView.vue', import.meta.url), 'utf8') test('renders unknown pallet service and container type as dashes while preserving explicit values', () => { const helpers = view.slice(view.indexOf('function read('), view.indexOf('function idempotencyKey(')) @@ -42,14 +44,15 @@ test('activates all three customer cross-border pages with frozen permissions', assert.match(routes, new RegExp(`path: '${path}'[\\s\\S]*?release: 'active'[\\s\\S]*?permission: '${permission}'`)) } assert.doesNotMatch(view, /PrototypeFeatureView|mock|customerId|tenantId/i) - assert.match(view, /merchant:cross-border:procurement:create/) + assert.match(procurementView, /merchant:cross-border:procurement:create/) assert.match(view, /merchant:cross-border:domestic-trucking:create/) assert.match(view, /merchant:cross-border:pallet-shipping:create/) assert.match(view, /v-if="canCreate"[^>]*type="primary"/) }) test('uses only merchant overseas endpoints and keeps retry idempotency stable', () => { - assert.match(api, /overseas-procurements/) + assert.match(procurementApi, /overseas-procurements/) + assert.doesNotMatch(api, /overseas-procurements|ProcurementSummary|ProcurementDetail/) assert.match(api, /us-truck-orders/) assert.match(api, /local-looseboard-orders/) assert.doesNotMatch(api, /\/platform\/|\/internal\/|customerId|tenantId/) @@ -69,11 +72,9 @@ test('covers list detail create loading error empty pagination and actions-first assert.match(view, /]*:row-actions="getRowActions"/) assert.match(view, /key: 'actions', label: '操作', width: 90, fixed: 'left'/) assert.match(view, /class="business-table-card"/) - assert.match(view, /PENDING_OUTBOUND: '待出库'/) - assert.match(view, /PARTIALLY_OUTBOUND: '部分出库'/) - assert.match(view, /OUTBOUNDED: '全部出库'/) - assert.match(view, /\['入库量', read\(summary, 'inboundQuantity'\)\]/) - assert.match(view, /\['审核备注', current\.reviewRemark\]/) + assert.doesNotMatch(procurementView, /inventoryQuantity|outboundQuantity|reviewRemark/) + assert.match(procurementView, /procurementStatuses/) + assert.match(procurementView, /ProcurementDetailPanel/) }) test('opens every dashboard cross-border action on a dedicated protected create page', () => { @@ -100,7 +101,7 @@ test('opens all cross-border details on dedicated protected routes', () => { assert.match(view, /class="business-detail-error"/) }) -test('keeps procurement filters, details and forms readable at PDA width', () => { +test('preserves the existing transport responsive rules without adding a narrow-screen procurement acceptance gate', () => { assert.match(view, /@media \(max-width: 767px\)/) assert.match(view, /queryFields/) assert.match(view, /\.detail-grid \{ grid-template-columns: 1fr; \}/) diff --git a/src/modules/cross-border/components/BusinessOrdersView.vue b/src/modules/cross-border/components/BusinessOrdersView.vue index 6ddbf6e..4793615 100644 --- a/src/modules/cross-border/components/BusinessOrdersView.vue +++ b/src/modules/cross-border/components/BusinessOrdersView.vue @@ -7,13 +7,10 @@ import QueryToolbar, { type QueryField, type QueryModel } from '@/components/com import type { TableAction } from '@/components/common/tableActions' import { useAuthStore } from '@/modules/auth/public' import { - createProcurement, createTransport, getBusinessOrder, listBusinessOrders, type CrossBorderBusinessKind, - type ProcurementDetail, - type ProcurementSummary, type TransportDetail, type TransportSummary, } from '../api/businessOrders' @@ -26,23 +23,17 @@ const router = useRouter() const route = useRoute() const detailOrderNo = computed(() => String(route.params.orderNo || '')) const canCreate = computed(() => auth.hasPermission(({ - procurement: 'merchant:cross-border:procurement:create', trucking: 'merchant:cross-border:domestic-trucking:create', looseboard: 'merchant:cross-border:pallet-shipping:create', } as Record)[props.kind])) -type BusinessRow = ProcurementSummary | TransportSummary -type BusinessDetail = ProcurementDetail | TransportDetail +type BusinessRow = TransportSummary +type BusinessDetail = TransportDetail type PageMode = 'list' | 'create' | 'detail' interface ColumnDefinition { key: string; label: string; width?: number } interface BusinessForm { - purchaseCategory: string - productName: string - productSpecification: string - productUrl: string - purchaseQuantity: number transportMode: string palletRequired: string transportType: string @@ -60,28 +51,10 @@ interface BusinessForm { contactName: string contactInfo: string requestedDate: string - receivingAddress: string remark: string } const configuration = computed(() => ({ - procurement: { - title: '海外采购', - description: '查询并管理海外采购订单', - columns: [ - { key: 'orderNo', label: '订单号', width: 180 }, - { key: 'purchaseCategory', label: '采购品类', width: 130 }, - { key: 'productName', label: '商品名称', width: 160 }, - { key: 'productSpecification', label: '商品规格', width: 160 }, - { key: 'purchaseQuantity', label: '采购数量', width: 100 }, - { key: 'budgetUnitPrice', label: '预算单价', width: 110 }, - { key: 'inboundQuantity', label: '入库量', width: 90 }, - { key: 'outboundQuantity', label: '出库量', width: 90 }, - { key: 'inventoryQuantity', label: '库存量', width: 90 }, - { key: 'status', label: '状态', width: 120 }, - { key: 'createdAt', label: '创建时间', width: 170 }, - ] satisfies ColumnDefinition[], - }, trucking: { title: '美国本土卡', description: '查询并管理美国本土卡订单', @@ -117,23 +90,19 @@ const submitKey = ref('') const error = ref('') const rows = ref([]) const detail = ref(null) -const fleetArrangement = computed(() => detail.value && props.kind !== 'procurement' ? (detail.value as TransportDetail).summary.fleetArrangement : null) +const fleetArrangement = computed(() => detail.value ? (detail.value as TransportDetail).summary.fleetArrangement : null) const formRef = ref() -const query = reactive({ page: 1, size: 20, orderNo: '', routeKeyword: '', status: '', productName: '', purchaseCategory: '' }) +const query = reactive({ page: 1, size: 20, orderNo: '', routeKeyword: '', status: '' }) const total = ref(0) const queryModel = computed({ get: () => ({ orderNo: query.orderNo, - purchaseCategory: query.purchaseCategory, - productName: query.productName, routeKeyword: query.routeKeyword, status: query.status, }), set: (value) => { query.orderNo = String(value.orderNo || '') - query.purchaseCategory = String(value.purchaseCategory || '') - query.productName = String(value.productName || '') query.routeKeyword = String(value.routeKeyword || '') query.status = String(value.status || '') }, @@ -141,12 +110,7 @@ const queryModel = computed({ const queryFields = computed(() => [ { key: 'orderNo', label: '订单号', type: 'input', placeholder: '请输入订单号' }, - ...(props.kind === 'procurement' - ? [ - { key: 'purchaseCategory', label: '采购品类', type: 'input', placeholder: '请输入采购品类' } as QueryField, - { key: 'productName', label: '商品名称', type: 'input', placeholder: '请输入商品名称' } as QueryField, - ] - : [{ key: 'routeKeyword', label: '运输路线', type: 'input', placeholder: '请输入起运地或目的地' } as QueryField]), + { key: 'routeKeyword', label: '运输路线', type: 'input', placeholder: '请输入起运地或目的地' }, { key: 'status', label: '状态', @@ -156,11 +120,8 @@ const queryFields = computed(() => [ { label: '待审核', value: 'PENDING_REVIEW' }, { label: '已通过', value: 'APPROVED' }, { label: '已拒绝', value: 'REJECTED' }, - { label: '待出库', value: 'PENDING_OUTBOUND' }, - { label: '部分出库', value: 'PARTIALLY_OUTBOUND' }, - { label: '全部出库', value: 'OUTBOUNDED' }, { label: '待接单', value: 'PENDING_ACCEPTANCE' }, - ...(props.kind === 'procurement' ? [] : Object.entries(poolOrderStatusLabels).filter(([value]) => !['IN_TRANSIT', 'COMPLETED', 'CANCELLED'].includes(value)).map(([value, label]) => ({ value, label }))), + ...Object.entries(poolOrderStatusLabels).filter(([value]) => !['IN_TRANSIT', 'COMPLETED', 'CANCELLED'].includes(value)).map(([value, label]) => ({ value, label })), { label: '处理中', value: 'PROCESSING' }, { label: '运输中', value: 'IN_TRANSIT' }, { label: '已完成', value: 'COMPLETED' }, @@ -171,9 +132,6 @@ const queryFields = computed(() => [ const form = reactive(emptyForm()) const rules: FormRules = { - purchaseCategory: [{ required: true, message: '请输入采购品类', trigger: 'blur' }], - productName: [{ required: true, message: '请输入商品名称', trigger: 'blur' }], - purchaseQuantity: [{ required: true, type: 'number', min: 1, message: '采购数量至少为 1', trigger: 'change' }], origin: [{ required: true, message: '请输入起运地', trigger: 'blur' }], destination: [{ required: true, message: '请输入目的地', trigger: 'blur' }], palletCount: [{ required: true, type: 'number', min: 1, message: '托盘数至少为 1', trigger: 'change' }], @@ -185,21 +143,6 @@ const rules: FormRules = { const detailFields = computed(() => { if (!detail.value) return [] const summary = detail.value.summary - if (props.kind === 'procurement') { - const current = detail.value as ProcurementDetail - return [ - ['采购品类', read(summary, 'purchaseCategory')], - ['商品名称', read(summary, 'productName')], ['商品规格', read(summary, 'productSpecification')], - ['采购数量', read(summary, 'purchaseQuantity')], ['预算单价', money(read(summary, 'budgetUnitPrice'), read(summary, 'currency'))], - ['入库量', read(summary, 'inboundQuantity')], ['出库量', read(summary, 'outboundQuantity')], - ['库存量', read(summary, 'inventoryQuantity')], ['预计到达时间', dateTime(current.estimatedArrivalAt)], - ['联系人', current.contactName], ['联系电话', current.contactPhone], - ['收货地址', current.receivingAddress], ['商品链接', current.productUrl], - ['国内运单', joinText(current.domesticCarrier, current.domesticTrackingNo)], - ['海外运单', joinText(current.overseasCarrier, current.overseasTrackingNo)], - ['审核备注', current.reviewRemark], ['备注', current.remark], - ] - } const current = detail.value as TransportDetail return [ ['运输方式', transportModeLabel(read(summary, 'transportMode'))], @@ -228,18 +171,16 @@ const detailStatus = computed(() => detail.value ? statusLabel(detail.value.summ function emptyForm(): BusinessForm { return { - purchaseCategory: '', productName: '', productSpecification: '', productUrl: '', purchaseQuantity: 1, transportMode: 'SHARED_TRUCK', palletRequired: 'NO', transportType: 'PORT_PICKUP', origin: '', originAddress: '', destination: '', destinationAddress: '', containerType: DEFAULT_CONTAINER_TYPE, palletCount: 1, packageCount: 0, totalWeightKg: 0, totalVolumeCbm: 0, currency: 'USD', contactName: '', contactInfo: '', requestedDate: '', - receivingAddress: '', remark: '', + remark: '', } } function resetForm() { Object.assign(form, emptyForm()); submitKey.value = ''; formRef.value?.clearValidate() } function read(source: object, key: string) { return Reflect.get(source, key) as unknown } function text(value: unknown) { return value === null || value === undefined || value === '' ? '—' : String(value) } -function joinText(...values: unknown[]) { const result = values.filter(Boolean).join(' / '); return result || '—' } function money(amount: unknown, currency: unknown) { return amount === null || amount === undefined ? '—' : `${text(currency)} ${Number(amount).toFixed(2)}` } function dateTime(value: unknown) { return value ? new Date(String(value)).toLocaleString('zh-CN', { hour12: false }) : '—' } function yesNo(value: unknown) { return value === 'YES' ? '是' : value === 'NO' ? '否' : text(value) } @@ -247,10 +188,10 @@ function transportTypeLabel(value: unknown) { return ({ PORT_PICKUP: '港口提 function transportModeLabel(value: unknown) { return ({ SHARED_TRUCK: '拼车', PRIORITY_CONSOLIDATION: '优先拼载' } as Record)[String(value)] || text(value) } function statusLabel(value: unknown) { if (poolOrderStatusLabels[String(value)]) return poolOrderStatusLabels[String(value)] - return ({ PENDING_REVIEW: '待审核', APPROVED: '已通过', REJECTED: '已拒绝', PENDING_OUTBOUND: '待出库', PARTIALLY_OUTBOUND: '部分出库', OUTBOUNDED: '全部出库', PENDING_ACCEPTANCE: '待接单', PROCESSING: '处理中', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消' } as Record)[String(value)] || text(value) + return ({ PENDING_REVIEW: '待审核', APPROVED: '已通过', REJECTED: '已拒绝', PENDING_ACCEPTANCE: '待接单', PROCESSING: '处理中', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消' } as Record)[String(value)] || text(value) } function statusTone(value: unknown) { - return ({ APPROVED: 'success', COMPLETED: 'success', OUTBOUNDED: 'success', REJECTED: 'danger', CANCELLED: 'info', PENDING_REVIEW: 'warning', PENDING_ACCEPTANCE: 'warning', PROCESSING: 'primary', IN_TRANSIT: 'primary' } as Record)[String(value)] || 'info' + return ({ APPROVED: 'success', COMPLETED: 'success', REJECTED: 'danger', CANCELLED: 'info', PENDING_REVIEW: 'warning', PENDING_ACCEPTANCE: 'warning', PROCESSING: 'primary', IN_TRANSIT: 'primary' } as Record)[String(value)] || 'info' } function displayCell(candidate: unknown, key: string) { const row = candidate as BusinessRow @@ -260,7 +201,7 @@ function displayCell(candidate: unknown, key: string) { if (key === 'transportMode') return transportModeLabel(value) if (key === 'palletRequired') return yesNo(value) if (key === 'createdAt') return dateTime(value) - if (key === 'budgetUnitPrice' || key === 'budgetAmount') return money(value, read(row, 'currency')) + if (key === 'budgetAmount') return money(value, read(row, 'currency')) return text(value) } function idempotencyKey() { return globalThis.crypto?.randomUUID?.() || `client-${Date.now()}-${Math.random().toString(16).slice(2)}` } @@ -272,12 +213,9 @@ async function load() { try { const params = { page: query.page, size: query.size, orderNo: query.orderNo || undefined, status: query.status || undefined, - routeKeyword: query.routeKeyword || undefined, productName: query.productName || undefined, - purchaseCategory: query.purchaseCategory || undefined, + routeKeyword: query.routeKeyword || undefined, } - const result = props.kind === 'procurement' - ? await listBusinessOrders('procurement', params) - : await listBusinessOrders(props.kind, params) + const result = await listBusinessOrders(props.kind, params) rows.value = result.records total.value = result.total } catch (cause) { @@ -289,7 +227,7 @@ async function load() { function resetQuery() { const reloadDirectly = query.page === 1 - Object.assign(query, { page: 1, orderNo: '', routeKeyword: '', status: '', productName: '', purchaseCategory: '' }) + Object.assign(query, { page: 1, orderNo: '', routeKeyword: '', status: '' }) if (reloadDirectly) void load() } @@ -303,16 +241,14 @@ function getRowActions(candidate: object): TableAction[] { } function detailFieldClass(label: unknown) { - return ['收货地址', '商品链接', '国内运单', '海外运单', '审核备注', '备注', '起运地址', '目的地址'].includes(String(label)) ? 'is-wide' : '' + return ['备注', '起运地址', '目的地址'].includes(String(label)) ? 'is-wide' : '' } const listRouteName = computed(() => ({ - procurement: 'cross-border-procurement', trucking: 'cross-border-domestic-trucking', looseboard: 'cross-border-pallet-shipping', } as Record)[props.kind]) const detailRouteName = computed(() => ({ - procurement: 'cross-border-procurement-detail', trucking: 'cross-border-domestic-trucking-detail', looseboard: 'cross-border-pallet-shipping-detail', } as Record)[props.kind]) @@ -334,9 +270,7 @@ async function loadDetail() { loading.value = true error.value = '' try { - detail.value = props.kind === 'procurement' - ? await getBusinessOrder('procurement', detailOrderNo.value) - : await getBusinessOrder(props.kind, detailOrderNo.value) + detail.value = await getBusinessOrder(props.kind, detailOrderNo.value) mode.value = 'detail' } catch (cause) { detail.value = null; error.value = errorMessage(cause); ElMessage.error(error.value) } finally { loading.value = false } @@ -348,31 +282,19 @@ async function submit() { const stableSubmitKey = submitKey.value || idempotencyKey() submitKey.value = stableSubmitKey try { - if (props.kind === 'procurement') { - await createProcurement({ - customerName: auth.user?.tenantName || '', - purchaseCategory: form.purchaseCategory, productName: form.productName, - productSpecification: form.productSpecification || undefined, productUrl: form.productUrl || undefined, - purchaseQuantity: form.purchaseQuantity, budgetUnitPrice: form.budgetAmount, - currency: form.currency || undefined, receivingAddress: form.receivingAddress || undefined, - contactName: form.contactName, contactPhone: form.contactInfo, remark: form.remark || undefined, - idempotencyKey: stableSubmitKey, - }) - } else { - const base = { - transportType: form.transportType, origin: form.origin, originAddress: form.originAddress || undefined, - destination: form.destination, destinationAddress: form.destinationAddress || undefined, - containerType: form.containerType || undefined, packageCount: form.packageCount, - totalWeightKg: form.totalWeightKg, totalVolumeCbm: form.totalVolumeCbm, - budgetAmount: form.budgetAmount, currency: form.currency || undefined, - contactName: form.contactName || undefined, contactInfo: form.contactInfo || undefined, - requestedDate: form.requestedDate || undefined, remark: form.remark || undefined, - idempotencyKey: stableSubmitKey, - } - await createTransport(props.kind, props.kind === 'trucking' - ? { ...base, palletCount: form.palletCount } - : { ...base, transportMode: form.transportMode, palletRequired: form.palletRequired }) + const base = { + transportType: form.transportType, origin: form.origin, originAddress: form.originAddress || undefined, + destination: form.destination, destinationAddress: form.destinationAddress || undefined, + containerType: form.containerType || undefined, packageCount: form.packageCount, + totalWeightKg: form.totalWeightKg, totalVolumeCbm: form.totalVolumeCbm, + budgetAmount: form.budgetAmount, currency: form.currency || undefined, + contactName: form.contactName || undefined, contactInfo: form.contactInfo || undefined, + requestedDate: form.requestedDate || undefined, remark: form.remark || undefined, + idempotencyKey: stableSubmitKey, } + await createTransport(props.kind, props.kind === 'trucking' + ? { ...base, palletCount: form.palletCount } + : { ...base, transportMode: form.transportMode, palletRequired: form.palletRequired }) ElMessage.success('订单提交成功') resetForm() if (props.createPage) await router.push({ name: listRouteName.value }) @@ -442,15 +364,6 @@ onMounted(() => { if (props.detailPage) void loadDetail(); else if (!props.creat - - - + diff --git a/src/modules/cross-border/components/ProcurementDetailPanel.vue b/src/modules/cross-border/components/ProcurementDetailPanel.vue new file mode 100644 index 0000000..4324b63 --- /dev/null +++ b/src/modules/cross-border/components/ProcurementDetailPanel.vue @@ -0,0 +1,106 @@ + + + + + diff --git a/src/modules/cross-border/procurement.test.mjs b/src/modules/cross-border/procurement.test.mjs new file mode 100644 index 0000000..8e8e0eb --- /dev/null +++ b/src/modules/cross-border/procurement.test.mjs @@ -0,0 +1,165 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { readFileSync } from 'node:fs' +import { runInNewContext } from 'node:vm' +import ts from 'typescript' +import * as vue from 'vue' +import { parse, compileScript } from 'vue/compiler-sfc' + +let nextKey = 0 +const crypto = { randomUUID: () => `test-key-${++nextKey}` } +function moduleFrom(source, require = () => { throw new Error('unexpected import') }) { + const context = { exports: {}, require, URL, Error, crypto } + const code = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText + runInNewContext(code, context) + return context.exports +} +const presentation = moduleFrom(readFileSync(new URL('./procurementPresentation.ts', import.meta.url), 'utf8')) +const submission = moduleFrom(readFileSync(new URL('./procurementSubmission.ts', import.meta.url), 'utf8'), name => { + if (name === './procurementPresentation') return presentation + throw new Error(`unexpected import ${name}`) +}) +const normalized = value => JSON.parse(JSON.stringify(value)) +const draft = () => ({ ...presentation.emptyProcurementDraft(), purchaseCategory: 'Sample', productName: 'Test product', productSpecification: 'A', + purchaseQuantity: '3', budgetUnitPrice: '0.29', receivingAddress: '测试省测试市测试街 1 号', contactName: '测试收件人', contactPhone: '13800000000' }) +const fee = (purpose, walletStatus, amount) => ({ purpose, walletStatus, amount, currency: 'CNY' }) + +test('budget distinguishes unknown from zero, preserves cents, and requires a real telephone/address', () => { + assert.equal(presentation.draftBudgetTotal(draft()), '0.87') + const valid = presentation.buildProcurementCreate(draft(), 'Authenticated Company', 'same-key') + assert.equal(valid.customerName, 'Authenticated Company') + assert.equal(valid.budgetUnitPrice, 0.29) + for (const value of ['', '-1', '1.001', '90071992547409.91']) assert.throws(() => presentation.buildProcurementCreate({ ...draft(), budgetUnitPrice: value }, 'Company', 'key'), /预算/) + assert.equal(presentation.buildProcurementCreate({ ...draft(), budgetUnitPrice: '0' }, 'Company', 'key').budgetUnitPrice, 0) + for (const patch of [{ contactPhone: 'wechat-name' }, { contactPhone: 'a@test.invalid' }, { receivingAddress: '' }, { purchaseQuantity: '1.5' }]) { + assert.throws(() => presentation.buildProcurementCreate({ ...draft(), ...patch }, 'Company', 'key')) + } + assert.equal(presentation.measurement(20, 2), '20.00') + assert.equal(presentation.measurement('5.125', 3), '5.125') + assert.equal(presentation.measurement('1.234', 2), '待核对') + assert.equal(presentation.money(null), '—') + assert.equal(presentation.money(0), 'CNY 0.00') + assert.equal(presentation.productHref('javascript:alert(1)'), undefined) + assert.equal(presentation.productHref('https://example.com/item'), 'https://example.com/item') +}) + +test('successful net debit excludes the original estimate and counts only successful refunds and differences', () => { + const result = presentation.feeTotals([fee('INTERNATIONAL_ESTIMATE', 'SETTLED', '100'), fee('INTERNATIONAL_FREIGHT', 'SETTLED', '100'), + fee('ADJUSTMENT', 'SETTLED', '20'), fee('ADJUSTMENT', 'SETTLED', '10'), fee('REFUND', 'REFUNDED', '20'), fee('REFUND', 'REFUND_PENDING', '5')]) + assert.deepEqual(normalized(result), { paid: '130.00', refunded: '20.00', net: '110.00', estimatedHold: '0.00' }) + assert.equal(presentation.feeTotals([fee('INTERNATIONAL_ESTIMATE', 'RELEASE_PENDING', '100')]).estimatedHold, '100.00') + assert.equal(presentation.feeTotals([fee('DUTY', 'SETTLED', 'unknown')]), null) + assert.equal(presentation.feeStatus(fee('INTERNATIONAL_ESTIMATE', 'FROZEN', 100)), '已冻结,未扣款') + assert.equal(presentation.feeStatus(fee('INTERNATIONAL_ESTIMATE', 'SETTLED', 100)), '原预估已结清') + assert.equal(presentation.feeStatus(fee('DUTY', null, 100)), '待核定') +}) + +test('request recovery preserves content and identity while malformed recovery cannot create a replacement', () => { + const original = presentation.buildProcurementCreate(draft(), 'Company', 'fixed-key') + assert.deepEqual(normalized(submission.restoreProcurementSubmission(JSON.stringify(original))), normalized(original)) + assert.throws(() => submission.restoreProcurementSubmission('{}')) + assert.throws(() => submission.restoreProcurementSubmission(JSON.stringify({ ...original, currency: 'EUR' }))) + assert.equal(submission.definitelyRejected({ code: 'TIMEOUT' }), false) + assert.equal(submission.definitelyRejected({ status: 500 }), false) + assert.equal(submission.definitelyRejected({ status: 409 }), false) + assert.equal(submission.definitelyRejected({ status: 400 }), true) + assert.equal(submission.procurementRequestKey({ getRandomValues: target => { target.set([1, 2, 3, 4]); return target } }), 'proc-00000001000000020000000300000004') +}) + +class ApiError extends Error { constructor(message, code, status) { super(message); this.code = code; this.status = status } } +const flush = async () => { await vue.nextTick(); await new Promise(resolve => setImmediate(resolve)); await vue.nextTick() } +function viewHarness({ createPage = false, detailPage = false, api = {}, storage = new Map() } = {}) { + const props = vue.reactive({ createPage, detailPage }) + 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 { 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 === '@/api/types') return { ApiError } + if (name === '@/api/session') return { accessTokenIdentity: token => token } + if (name === '@/modules/auth/public') return { useAuthStore: () => auth } + if (name === '../api/procurement') return { ...defaults, ...api } + if (name === '../procurementPresentation') return presentation + if (name === '../procurementSubmission') return submission + if (name.endsWith('.vue')) return { default: {} } + throw new Error(`unexpected view import ${name}`) + }, + } + runInNewContext(ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText, context) + const state = context.exports.default.setup(props, { expose: () => {} }) + return { state, props, route, auth, pushes, storage, dispose: () => dispose.forEach(callback => callback()) } +} + +test('detail has one in-flight request and an older response cannot overwrite another order', async () => { + const calls = [], pending = new Map() + const harness = viewHarness({ detailPage: true, api: { getProcurement: orderNo => { calls.push(orderNo); return new Promise(resolve => pending.set(orderNo, resolve)) } } }) + try { + assert.deepEqual(calls, ['PO-1']) + await harness.state.loadDetail(); assert.equal(calls.length, 1) + harness.route.params.orderNo = 'PO-2'; await flush(); assert.deepEqual(calls, ['PO-1', 'PO-2']) + pending.get('PO-1')({ order: { orderNo: 'PO-1' } }); await flush(); assert.equal(harness.state.detail.value, null) + pending.get('PO-2')({ order: { orderNo: 'PO-2' } }); await flush(); assert.equal(harness.state.detail.value.order.orderNo, 'PO-2') + assert.equal(harness.state.loading.value, false) + } finally { harness.dispose() } +}) + +test('timeout retries the exact original body after reload and does not accept changed form data', async () => { + const storage = new Map(), calls = [] + const first = viewHarness({ createPage: true, storage, api: { createProcurement: async payload => { calls.push(normalized(payload)); throw new ApiError('timeout', 'TIMEOUT') } } }) + try { + Object.assign(first.state.form, draft()); await first.state.submit(); assert.equal(calls.length, 1); assert.ok(first.state.pending.value) + first.state.form.productName = 'changed'; await first.state.submit(); assert.deepEqual(calls[1], calls[0]) + } finally { first.dispose() } + const recovered = viewHarness({ createPage: true, storage, api: { createProcurement: async payload => { calls.push(normalized(payload)); return { order: { orderNo: 'PO-SAME' } } } } }) + try { + assert.equal(recovered.state.form.productName, 'Test product'); await recovered.state.submit(); assert.deepEqual(calls[2], calls[0]) + assert.equal(storage.size, 0); assert.equal(recovered.pushes[0].params.orderNo, 'PO-SAME') + } finally { recovered.dispose() } +}) + +test('submission double-click sends once and definitive validation rejection permits correction', async () => { + let reject, calls = 0 + const harness = viewHarness({ createPage: true, api: { createProcurement: () => { calls++; return new Promise((_, failure) => { reject = failure }) } } }) + try { + Object.assign(harness.state.form, draft()); const first = harness.state.submit(); await harness.state.submit(); assert.equal(calls, 1) + reject(new ApiError('invalid', 'A0422')); await first + assert.equal(harness.state.pending.value, null); assert.equal(harness.storage.size, 0) + } finally { harness.dispose() } +}) + +test('pagination batches one request and switching account clears old rows before the new response', async () => { + const calls = []; let resolveSecond + const harness = viewHarness({ api: { listProcurements: async params => { + calls.push(normalized(params)); if (calls.length < 3) return { records: [{ orderNo: 'PO-A' }], total: 100 } + return new Promise(resolve => { resolveSecond = resolve }) + } } }) + try { + await flush(); assert.equal(calls.length, 1) + harness.state.query.page = 2; harness.state.query.size = 50; await flush(); assert.equal(calls.length, 2) + harness.auth.token = 'identity-B'; await vue.nextTick(); assert.equal(harness.state.rows.value.length, 0); assert.equal(harness.state.total.value, 0) + resolveSecond({ records: [{ orderNo: 'PO-B' }], total: 1 }); await flush(); assert.equal(harness.state.rows.value[0].orderNo, 'PO-B') + assert.ok(calls.every(params => !('customerId' in params) && !('tenantId' in params))) + } finally { harness.dispose() } +}) + + +test('replay rejection cannot discard an earlier unknown creation outcome', async () => { + const calls = []; let attempt = 0 + const harness = viewHarness({ createPage: true, api: { createProcurement: async payload => { + calls.push(normalized(payload)); attempt++ + if (attempt === 1) throw new ApiError('timeout', 'TIMEOUT') + throw new ApiError('permission revoked', 'A0403') + } } }) + try { + Object.assign(harness.state.form, draft()); await harness.state.submit() + const saved = [...harness.storage.values()][0]; await harness.state.submit() + assert.equal([...harness.storage.values()][0], saved); assert.ok(harness.state.pending.value) + assert.deepEqual(calls[1], calls[0]) + } finally { harness.dispose() } +}) diff --git a/src/modules/cross-border/procurementPresentation.ts b/src/modules/cross-border/procurementPresentation.ts new file mode 100644 index 0000000..0e1e08c --- /dev/null +++ b/src/modules/cross-border/procurementPresentation.ts @@ -0,0 +1,123 @@ +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: '已寄客户', + REJECTED: '已拒绝', CANCELLED: '已取消', EXCEPTION_CLOSED: '异常关闭', +} +export const feePurposes: Record = { + SAMPLE_PAYMENT: '样品货款', INTERNATIONAL_ESTIMATE: '预估国际运费', INTERNATIONAL_FREIGHT: '实际国际运费', + DUTY: '关税', DOMESTIC_FREIGHT: '国内运费', OTHER: '其他费用', ADJUSTMENT: '追加费用', REFUND: '退款', +} +export function statusLabel(status: string | null | undefined) { return status ? procurementStatuses[status] || '待核对进度' : '—' } +export function statusTone(status: string | null | undefined): 'success' | 'warning' | 'danger' | 'info' | 'primary' { + if (status === 'SHIPPED_TO_CUSTOMER') return 'success' + if (status === 'REJECTED' || status === 'EXCEPTION_CLOSED') return 'danger' + if (status === 'CANCELLED') return 'info' + if (status === 'INTERNATIONAL_TRANSIT') return 'primary' + return 'warning' +} +function cents(value: Amount | null | undefined): bigint | null { + if (value === null || value === undefined) return null + const match = /^(-?)(\d+)(?:\.(\d{1,2}))?$/.exec(String(value).trim()) + if (!match) return null + const result = BigInt(match[2]) * 100n + BigInt((match[3] || '').padEnd(2, '0')) + return match[1] ? -result : result +} +function decimal(value: bigint) { + const absolute = value < 0n ? -value : value + return `${value < 0n ? '-' : ''}${absolute / 100n}.${String(absolute % 100n).padStart(2, '0')}` +} +export function money(value: Amount | null | undefined, currency: string | null | undefined = 'CNY') { + const amount = cents(value) + return amount === null ? '—' : `${currency || '—'} ${decimal(amount)}` +} +export function dateTime(value: string | null | undefined) { return value ? value.replace('T', ' ').replace(/\.\d+$/, '') : '—' } +export function measurement(value: Amount | null | undefined, scale: number) { + if (value === null || value === undefined) return '—' + const parts = /^(\d+)(?:\.(\d+))?$/.exec(String(value)) + if (!parts || (parts[2] || '').length > scale) return '待核对' + return `${parts[1]}.${(parts[2] || '').padEnd(scale, '0')}` +} +export function text(value: unknown) { return value === null || value === undefined || value === '' ? '—' : String(value) } +export function productHref(value: string | null | undefined) { + if (!value) return 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.purpose === 'INTERNATIONAL_ESTIMATE') { + const label: Record = { FROZEN: '已冻结,未扣款', ZERO: '零预估已确认', SETTLED: '原预估已结清', RELEASED: '预估已释放' } + if (fee.walletStatus && label[fee.walletStatus]) return label[fee.walletStatus] + } + const labels: Record = { + FREEZE_PENDING: '冻结处理中', FREEZE_FAILED: '冻结未成功', FROZEN: '等待扣款', SETTLE_PENDING: '扣款处理中', SETTLE_ACTUAL_PENDING: '实际费用结算中', + SETTLE_FAILED: '扣款未成功', SETTLE_ACTUAL_FAILED: '实际费用未结清', SETTLED: '已扣款', RELEASE_PENDING: '释放处理中', RELEASE_FAILED: '释放未成功', + RELEASED: '已释放冻结', REFUND_PENDING: '退款处理中', REFUND_FAILED: '退款未成功', REFUNDED: '已退款', ZERO: '已确认零元', NOT_APPLICABLE: '不适用', TERMINATED: '已终止', + } + return fee.walletStatus ? labels[fee.walletStatus] || '待核定' : '待核定' +} +export function feeTone(fee: ProcurementFee): 'success' | 'warning' | 'danger' | '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' + return 'warning' +} +export function feeTotals(fees: ProcurementFee[]) { + let paid = 0n, refunded = 0n, estimatedHold = 0n + for (const fee of fees) { + const estimate = fee.purpose === 'INTERNATIONAL_ESTIMATE' + const include = (!estimate && fee.walletStatus === 'SETTLED') || fee.walletStatus === 'REFUNDED' + || (estimate && ['FROZEN', 'RELEASE_PENDING', 'RELEASE_FAILED'].includes(fee.walletStatus || '')) + if (!include) continue + const amount = cents(fee.amount) + if (amount === null || amount < 0n || fee.currency !== 'CNY') return null + if (estimate) estimatedHold += amount + else if (fee.walletStatus === 'REFUNDED') refunded += amount + else paid += amount + } + return { paid: decimal(paid), refunded: decimal(refunded), net: decimal(paid - refunded), estimatedHold: decimal(estimatedHold) } +} +export interface ProcurementDraft { + purchaseCategory: string + productName: string + productSpecification: string + productUrl: string + purchaseQuantity: string + budgetUnitPrice: string + currency: 'CNY' | 'USD' + receivingAddress: string + contactName: string + contactPhone: string + remark: string +} +export function emptyProcurementDraft(): ProcurementDraft { + return { purchaseCategory: '', productName: '', productSpecification: '', productUrl: '', purchaseQuantity: '1', budgetUnitPrice: '', currency: 'USD', receivingAddress: '', contactName: '', contactPhone: '', remark: '' } +} +export function draftBudgetTotal(draft: ProcurementDraft) { + const price = cents(draft.budgetUnitPrice) + if (price === null || price < 0n || !/^\d+$/.test(draft.purchaseQuantity)) return null + return decimal(price * BigInt(draft.purchaseQuantity)) +} +export function buildProcurementCreate(draft: ProcurementDraft, customerName: string, key: string): ProcurementCreate { + for (const [field, label, max] of [ + ['purchaseCategory', '采购品类', 128], ['productName', '商品名称', 255], ['receivingAddress', '完整国内收货地址', 1000], + ['contactName', '最终收件人', 128], ['contactPhone', '联系电话', 64], + ] as const) { + if (!draft[field].trim() || draft[field].trim().length > max) throw new Error(`请填写有效的${label}`) + } + if (!customerName.trim()) throw new Error('当前企业信息尚未加载,请刷新登录信息后重试') + if (!/^\d+$/.test(draft.purchaseQuantity) || !Number.isSafeInteger(Number(draft.purchaseQuantity)) || Number(draft.purchaseQuantity) <= 0) throw new Error('采购数量必须为正整数') + const price = cents(draft.budgetUnitPrice) + if (price === null || price < 0n) throw new Error('请明确填写非负预算单价,最多两位小数;免费样品填写 0') + if (price > BigInt(Number.MAX_SAFE_INTEGER) || cents(Number(decimal(price))) !== price) throw new Error('预算金额过大,无法准确表示,请核对金额') + if (!['CNY', 'USD'].includes(draft.currency)) throw new Error('预算币种只支持 CNY 或 USD') + if (!/^[+0-9][0-9 ()-]{5,63}$/.test(draft.contactPhone.trim())) throw new Error('请填写快递可联系的电话号码,微信或邮箱不能代替电话') + if (draft.productUrl && (!productHref(draft.productUrl.trim()) || draft.productUrl.trim().length > 2000)) throw new Error('商品链接须为有效的 http 或 https 地址') + if (draft.productSpecification.length > 500 || draft.remark.length > 1000) throw new Error('商品规格或备注超过长度限制') + return { customerName: customerName.trim(), purchaseCategory: draft.purchaseCategory.trim(), productName: draft.productName.trim(), + productSpecification: draft.productSpecification.trim() || undefined, productUrl: draft.productUrl.trim() || undefined, + purchaseQuantity: Number(draft.purchaseQuantity), budgetUnitPrice: Number(decimal(price)), currency: draft.currency, + receivingAddress: draft.receivingAddress.trim(), contactName: draft.contactName.trim(), contactPhone: draft.contactPhone.trim(), + remark: draft.remark.trim() || undefined, idempotencyKey: key } +} diff --git a/src/modules/cross-border/procurementSubmission.ts b/src/modules/cross-border/procurementSubmission.ts new file mode 100644 index 0000000..3c4e9c4 --- /dev/null +++ b/src/modules/cross-border/procurementSubmission.ts @@ -0,0 +1,24 @@ +import type { ProcurementCreate } from './api/procurement' +import { buildProcurementCreate, emptyProcurementDraft } from './procurementPresentation' + +/** Restore the exact request; an unknown result cannot become a new order. */ +export function restoreProcurementSubmission(raw: string): ProcurementCreate { + const saved: unknown = JSON.parse(raw) + if (!saved || typeof saved !== 'object' || Array.isArray(saved)) throw new Error('提交记录格式错误') + const value = saved as Record + for (const key of ['customerName', 'purchaseCategory', 'productName', 'currency', 'receivingAddress', 'contactName', 'contactPhone', 'idempotencyKey']) if (typeof value[key] !== 'string') throw new Error('提交记录不完整') + if (typeof value.purchaseQuantity !== 'number' || typeof value.budgetUnitPrice !== 'number' || !String(value.idempotencyKey).trim() || String(value.idempotencyKey).length > 255) throw new Error('提交记录不完整') + for (const key of ['productSpecification', 'productUrl', 'remark']) if (value[key] !== undefined && typeof value[key] !== 'string') throw new Error('提交记录不完整') + if (value.currency !== 'CNY' && value.currency !== 'USD') throw new Error('提交记录币种无效') + return buildProcurementCreate({ ...emptyProcurementDraft(), purchaseCategory: String(value.purchaseCategory), productName: String(value.productName), + productSpecification: String(value.productSpecification || ''), productUrl: String(value.productUrl || ''), purchaseQuantity: String(value.purchaseQuantity), + budgetUnitPrice: String(value.budgetUnitPrice), currency: value.currency, receivingAddress: String(value.receivingAddress), contactName: String(value.contactName), + contactPhone: String(value.contactPhone), remark: String(value.remark || '') }, String(value.customerName), String(value.idempotencyKey)) +} +export function definitelyRejected(error: { code?: string; status?: number }) { + return ['A0400', 'A0401', 'A0403', 'A0422'].includes(error.code || '') || [400, 401, 403, 422].includes(error.status || 0) +} +export function procurementRequestKey(provider: Pick & Partial> = globalThis.crypto) { + if (provider.randomUUID) return provider.randomUUID() + return `proc-${Array.from(provider.getRandomValues(new Uint32Array(4)), value => value.toString(16).padStart(8, '0')).join('')}` +} diff --git a/src/modules/cross-border/views/ProcurementView.vue b/src/modules/cross-border/views/ProcurementView.vue index 72080f6..50b99f4 100644 --- a/src/modules/cross-border/views/ProcurementView.vue +++ b/src/modules/cross-border/views/ProcurementView.vue @@ -1,6 +1,187 @@ - + + + diff --git a/src/modules/overseas-warehouse/permissions.test.mjs b/src/modules/overseas-warehouse/permissions.test.mjs index 649278a..d055d90 100644 --- a/src/modules/overseas-warehouse/permissions.test.mjs +++ b/src/modules/overseas-warehouse/permissions.test.mjs @@ -42,7 +42,10 @@ test('opens warehouse service and return receipt details as dedicated protected test('returns to the labeling list after a labeling application is submitted', () => { assert.match(services, /ElMessage\.success\(`\$\{config\.value\.title\}提交成功`\)[\s\S]*?router\.push\(\{ name: serviceListRouteName\(props\.service\) \}\)/) - assert.match(services, /uploadReturnRelabelFile\([\s\S]*?router\.push\(\{ name: serviceListRouteName\(props\.service\) \}\)/) + // Return-carton sources use the unified relabel application and its file endpoint. + assert.match(services, /returnCartonId: item\.returnCartonId/) + assert.match(services, /uploadRelabelFile\(created\.application\.relabelNo,[\s\S]*?router\.push\(\{ name: serviceListRouteName\(props\.service\) \}\)/) + assert.match(services, /申请已创建,附件待补传/) }) test('keeps overseas warehouse writes behind operation-specific button permissions', () => { diff --git a/src/modules/overseas-warehouse/views/warehouseTransferForm.test.mjs b/src/modules/overseas-warehouse/views/warehouseTransferForm.test.mjs index 94a9f3b..b66bbd7 100644 --- a/src/modules/overseas-warehouse/views/warehouseTransferForm.test.mjs +++ b/src/modules/overseas-warehouse/views/warehouseTransferForm.test.mjs @@ -6,7 +6,9 @@ const view = readFileSync(new URL('./WarehouseServiceView.vue', import.meta.url) const api = readFileSync(new URL('../api/warehouseServices.ts', import.meta.url), 'utf8') test('贴标总文件与关联模板保持左右分组,退货来源使用专用明细', () => { - assert.match(view, /class="warehouse-file-groups" :class="\{ 'warehouse-file-groups--single': isReturnRelabelSource \}"/) + assert.match(view, /class="warehouse-file-groups(?:\s[^"]*)?"/) + assert.match(view, /\.warehouse-file-groups--compact\s*\{[^}]*grid-template-columns:\s*minmax\(0, 1fr\) minmax\(0, 1fr\)/) + assert.match(view, /v-if="isReturnRelabelSource" class="warehouse-table-scroll"/) assert.match(view, /warehouse-service-page--return-relabel/) assert.match(view, /const sourceValue = `RETURN_ORDER:\$\{requestedReturnNo\}`/) }) @@ -136,7 +138,7 @@ test('仓储转运需要换标时上传总文件并挂到关联换标单', () => }) test('仓储转运换标总文件与明细模板在桌面端左右排列', () => { - assert.match(view, /class="warehouse-transfer-file-groups"/) + assert.match(view, /class="warehouse-transfer-file-groups(?:\s[^"]*)?"/) assert.match(view, /\.warehouse-transfer-file-groups\{display:grid;grid-template-columns:minmax\(0,1\.15fr\) minmax\(360px,\.85fr\)/) assert.match(view, /\.warehouse-transfer-total-files,\.warehouse-transfer-template-group\{height:100%;margin:0\}/) assert.match(view, /@media\(max-width:1180px\)\{\.warehouse-transfer-file-groups\{grid-template-columns:minmax\(0,1fr\)\}\}/)