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/components/ClientSidebarMenu.vue b/src/components/ClientSidebarMenu.vue index 4c66e7f..dde7db9 100644 --- a/src/components/ClientSidebarMenu.vue +++ b/src/components/ClientSidebarMenu.vue @@ -1,5 +1,5 @@ @@ -54,6 +58,7 @@ const groupId = computed(() => `client-nav-group-${props.node.key}`) v-for="child in node.children" :key="child.key" :node="child" + :badges="badges" :active-path="activePath" :opened-keys="openedKeys" :active-parent-keys="activeParentKeys" @@ -78,6 +83,14 @@ const groupId = computed(() => `client-nav-group-${props.node.key}`) aria-hidden="true" /> {{ node.title }} + + {{ badge?.error ? '!' : badge!.count! > 99 ? '99+' : badge?.count }} + + + 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/first-leg/api/customs.ts b/src/modules/first-leg/api/customs.ts index 6b2d957..afe413a 100644 --- a/src/modules/first-leg/api/customs.ts +++ b/src/modules/first-leg/api/customs.ts @@ -283,3 +283,11 @@ export function merchantCustomsErrorMessage(error: unknown, fallback: string) { export function isMerchantCustomsUnavailable(error: unknown) { return error instanceof ApiError && (error.status === 404 || error.code === 'A0404') } + + +export async function getMerchantCustomsPendingCount() { + const result = await apiRequest<{ pendingCount: string | number }>({ method: 'GET', url: '/merchant/v1/customs/pending-summary' }) + const count = Number(result.pendingCount) + if (result.pendingCount == null || result.pendingCount === '' || !Number.isSafeInteger(count) || count < 0) throw new Error('报关待办数量返回不完整') + return count +} diff --git a/src/modules/first-leg/api/unifiedOrders.ts b/src/modules/first-leg/api/unifiedOrders.ts index fdde238..e7fd13b 100644 --- a/src/modules/first-leg/api/unifiedOrders.ts +++ b/src/modules/first-leg/api/unifiedOrders.ts @@ -177,6 +177,11 @@ export interface MerchantUnifiedOrderQuery { const unifiedOrderBaseUrl = '/merchant/v1/orders' +export type OrderQueue = 'all' | 'fee' | 'customs' | 'draft' | 'loaded' | 'declared' | 'cleared' | 'scheduled' | 'delivering' | 'delivered' +export type OrderProgressStage = 'none' | 'loaded' | 'declared' | 'cleared' | 'scheduled' | 'delivering' | 'delivered' +export interface MerchantOrderProgress { orderId: string | number; stage: OrderProgressStage; partial: boolean } +export type OrderQueueCounts = Record + export interface MerchantOrderWorkbenchQuery { cargoKeyword?: string receivingWarehouseId?: string @@ -186,14 +191,15 @@ export interface MerchantOrderWorkbenchQuery { mainStatus?: string createdFrom?: string createdTo?: string - queue?: 'all' | 'fee' | 'customs' | 'draft' + queue?: OrderQueue page?: number size?: number } export interface MerchantOrderWorkbench { page: PageResult - queues: { all: number; fee: number; customs: number; draft: number } + queues: OrderQueueCounts + progress: MerchantOrderProgress[] channelNames: string[] } diff --git a/src/modules/first-leg/components/CustomsDocumentsDialog.test.mjs b/src/modules/first-leg/components/CustomsDocumentsDialog.test.mjs index 9c4860d..ddac9f8 100644 --- a/src/modules/first-leg/components/CustomsDocumentsDialog.test.mjs +++ b/src/modules/first-leg/components/CustomsDocumentsDialog.test.mjs @@ -10,7 +10,7 @@ function harness(t) { const code = ts.transpileModule(source + '\nexport { loadDetail, detail, clearanceResults, loading, errorMessage, selectedInboundIds, clearanceError, clearanceLoading, retryClearanceResults, close }', { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText const props = vue.reactive({ modelValue: false, orderId: 'A', canEdit: true }), requests = [], clearances = [], updates = [], view = {} const unmounts = [] - const dependencies = { vue: { ...vue, onBeforeUnmount: fn => unmounts.push(fn) }, 'element-plus': { ElMessage: {} }, '@/modules/first-leg/api/customs': { + const dependencies = { '../store/customsPending': { useCustomsPendingStore: () => ({ refresh: async () => {} }) }, vue: { ...vue, onBeforeUnmount: fn => unmounts.push(fn) }, 'element-plus': { ElMessage: {} }, '@/modules/first-leg/api/customs': { getMerchantCustomsForOrder: id => { const pending = deferred(); requests.push({ id, ...pending }); return pending.promise }, getMerchantClearanceResults: id => { const pending = deferred(); clearances.push({ id, ...pending }); return pending.promise }, merchantCustomsErrorMessage: error => error.message, diff --git a/src/modules/first-leg/components/CustomsDocumentsDialog.vue b/src/modules/first-leg/components/CustomsDocumentsDialog.vue index 2f3ae9b..0e101dc 100644 --- a/src/modules/first-leg/components/CustomsDocumentsDialog.vue +++ b/src/modules/first-leg/components/CustomsDocumentsDialog.vue @@ -22,6 +22,9 @@ import { type MerchantClearanceResult, } from '@/modules/first-leg/api/customs' +import { useCustomsPendingStore } from '../store/customsPending' + +const customsPending = useCustomsPendingStore() const props = defineProps<{ modelValue: boolean orderId: string @@ -104,6 +107,7 @@ async function retryClearanceResults() { async function loadDetail() { if (disposed || !props.modelValue || !props.orderId) return + void customsPending.refresh(true) const request = ++detailRequest resetClearanceResults() const orderId = props.orderId diff --git a/src/modules/first-leg/public.ts b/src/modules/first-leg/public.ts index e0350de..4fa94e7 100644 --- a/src/modules/first-leg/public.ts +++ b/src/modules/first-leg/public.ts @@ -5,3 +5,4 @@ export { getMerchantWarehouses, type MerchantWarehouse } from './api/firstLeg' export { getFirstLegStatusLabel, getFirstLegStatusTone } from './utils/firstLegOrder' export { default as InboundCodeAssociationDialog } from './components/InboundCodeAssociationDialog.vue' export { default as FirstLegOrdersView } from './views/FirstLegOrdersView.vue' +export { useCustomsPendingStore } from './store/customsPending' diff --git a/src/modules/first-leg/store/customsPending.test.mjs b/src/modules/first-leg/store/customsPending.test.mjs new file mode 100644 index 0000000..5e5862f --- /dev/null +++ b/src/modules/first-leg/store/customsPending.test.mjs @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { readFileSync } from 'node:fs' +import ts from 'typescript' +import * as vue from 'vue' +import * as pinia from 'pinia' +const source = readFileSync(new URL('./customsPending.ts', import.meta.url), 'utf8') +const code = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText +function deferred() { let resolve, reject; const promise = new Promise((a,b) => { resolve=a; reject=b }); return { promise, resolve, reject } } +function harness(t, read) { + const context=pinia.createPinia();pinia.setActivePinia(context);t.after(()=>pinia.disposePinia(context)) + const deps={vue,pinia,'../api/customs':{getMerchantCustomsPendingCount:read}}, exports={} + new Function('require','exports',code)(id=>{assert.ok(id in deps,id);return deps[id]},exports) + return exports.useCustomsPendingStore() +} +test('does not request without a permitted identity; preserves counts while refreshing and accepts zero',async t=>{ + const pending=deferred();let calls=0 + const store=harness(t,()=>++calls===1?Promise.resolve(2):pending.promise) + await store.refresh();assert.equal(calls,0) + store.setScope('customer-a');await store.refresh();assert.equal(store.count,2) + const load=store.refresh();await store.refresh();assert.equal(calls,2);assert.equal(store.count,2) + pending.resolve(0);await load;assert.equal(store.count,0);assert.equal(store.loading,false) +}) +test('logout, permission removal and account switches discard old in-flight responses',async t=>{ + const pending=deferred();const store=harness(t,()=>pending.promise) + store.setScope('a');const load=store.refresh();store.setScope('') + pending.resolve(9);await load;assert.equal(store.count,null);assert.equal(store.error,'') + store.setScope('b');assert.equal(store.count,null) +}) +test('successful mutation can force a newer refresh without being overwritten by an older query',async t=>{ + const a=deferred(),b=deferred();let calls=0;const store=harness(t,()=>++calls===1?a.promise:b.promise) + store.setScope('a');const first=store.refresh();const second=store.refresh(true) + b.resolve(1);await second;a.resolve(2);await first;assert.equal(store.count,1) +}) +test('failure is distinguishable from zero and retry restores authoritative count',async t=>{ + let fail=true;const store=harness(t,()=>fail?Promise.reject(new Error('offline')):Promise.resolve(3)) + store.setScope('a');await store.refresh();assert.equal(store.count,null);assert.notEqual(store.error,'') + fail=false;await store.refresh();assert.equal(store.count,3);assert.equal(store.error,'') +}) diff --git a/src/modules/first-leg/store/customsPending.ts b/src/modules/first-leg/store/customsPending.ts new file mode 100644 index 0000000..940b9eb --- /dev/null +++ b/src/modules/first-leg/store/customsPending.ts @@ -0,0 +1,40 @@ +import { ref } from 'vue' +import { defineStore } from 'pinia' +import { getMerchantCustomsPendingCount } from '../api/customs' + +export const useCustomsPendingStore = defineStore('client-customs-pending', () => { + const count = ref(null) + const loading = ref(false) + const error = ref('') + let scope = '' + let requestVersion = 0 + + function setScope(value: string) { + if (scope === value) return + scope = value + requestVersion++ + count.value = null + error.value = '' + loading.value = false + } + + async function refresh(force = false) { + if (!scope || (loading.value && !force)) return + const request = ++requestVersion + loading.value = true + try { + const result = await getMerchantCustomsPendingCount() + if (request !== requestVersion) return + count.value = result + error.value = '' + } catch { + if (request !== requestVersion) return + count.value = null + error.value = '报关待办数量加载失败,请进入报关资料刷新' + } finally { + if (request === requestVersion) loading.value = false + } + } + + return { count, loading, error, setScope, refresh } +}) diff --git a/src/modules/first-leg/store/firstLegOrders.test.mjs b/src/modules/first-leg/store/firstLegOrders.test.mjs index 34c3cd7..8a369db 100644 --- a/src/modules/first-leg/store/firstLegOrders.test.mjs +++ b/src/modules/first-leg/store/firstLegOrders.test.mjs @@ -236,5 +236,5 @@ test('keeps failed order loads distinct from empty results', () => { assert.match(storeSource, /const loadError = ref\(''\)/) assert.match(storeSource, /orderResult\.reason instanceof Error/) assert.match(storeSource, /warehouseLoadError/) - assert.match(storeSource, /return \{ orders, orderDetails, total, queueCounts, queryChannelNames, warehouses, loading, loadedFromApi, loadError, warehouseLoadError/) + assert.match(storeSource, /return \{ orders, orderDetails, total, queueCounts, orderProgress, queryChannelNames, warehouses, loading, loadedFromApi, loadError, warehouseLoadError/) }) diff --git a/src/modules/first-leg/store/firstLegOrders.ts b/src/modules/first-leg/store/firstLegOrders.ts index 64932d0..ef3d09d 100644 --- a/src/modules/first-leg/store/firstLegOrders.ts +++ b/src/modules/first-leg/store/firstLegOrders.ts @@ -22,6 +22,8 @@ import { getMerchantOrderWorkbench, type MerchantUnifiedOrderDetail, type MerchantOrderWorkbenchQuery, + type MerchantOrderProgress, + type OrderQueueCounts, type MerchantUnifiedOrderRow, } from '@/modules/first-leg/api/unifiedOrders' import type { MerchantTenantStatus } from '@/modules/tenant-management/public' @@ -396,7 +398,8 @@ export const useFirstLegOrdersStore = defineStore('client-first-leg-orders', () const orders = ref([]) const orderDetails = ref>({}) const total = ref(0) - const queueCounts = ref({ all: 0, fee: 0, customs: 0, draft: 0 }) + const queueCounts = ref({ all: 0, fee: 0, customs: 0, draft: 0, loaded: 0, declared: 0, cleared: 0, scheduled: 0, delivering: 0, delivered: 0 }) + const orderProgress = ref>({}) const queryChannelNames = ref([]) let listRequest = 0 const pendingSubmissions = new Map() @@ -451,7 +454,8 @@ export const useFirstLegOrdersStore = defineStore('client-first-leg-orders', () loadError.value = '' orders.value = [] total.value = 0 - queueCounts.value = { all: 0, fee: 0, customs: 0, draft: 0 } + // Keep the last confirmed category counts while refreshing; replace them only after validation. + orderProgress.value = {} const [orderResult, warehouseResult] = await Promise.allSettled([ getMerchantOrderWorkbench(query), loadWarehouseType('DOMESTIC'), ]) @@ -466,18 +470,26 @@ export const useFirstLegOrdersStore = defineStore('client-first-leg-orders', () || Number(result.page.size) !== size || Number(result.page.pages) !== pages || result.page.records.length !== Math.min(size, Math.max(0, count - (expectedPage - 1) * size)) || new Set(result.page.records.map(row => String(row.orderId))).size !== result.page.records.length - || ['all', 'fee', 'customs', 'draft'].some(key => { const value = result.queues[key as keyof typeof result.queues]; return !Number.isSafeInteger(Number(value)) || Number(value) < 0 }) + || ['all', 'fee', 'customs', 'draft', 'loaded', 'declared', 'cleared', 'scheduled', 'delivering', 'delivered'].some(key => { const value = result.queues[key as keyof typeof result.queues]; return !Number.isSafeInteger(Number(value)) || Number(value) < 0 }) || Number(result.queues[query.queue ?? 'all']) !== count + || !Array.isArray(result.progress) || result.progress.length !== result.page.records.length + || new Set(result.progress.map(p => String(p.orderId))).size !== result.progress.length + || result.progress.some(p => !result.page.records.some(row => String(row.orderId) === String(p.orderId)) + || !['none', 'loaded', 'declared', 'cleared', 'scheduled', 'delivering', 'delivered'].includes(p.stage) || typeof p.partial !== 'boolean') || !Array.isArray(result.channelNames)) { loadError.value = '订单分页数据不完整,请重新加载' } else { orders.value = result.page.records.map(item => mapUnifiedOrderRow(item, warehouses.value)) total.value = count - queueCounts.value = { all: Number(result.queues.all), fee: Number(result.queues.fee), customs: Number(result.queues.customs), draft: Number(result.queues.draft) } + queueCounts.value = Object.fromEntries(Object.entries(result.queues).map(([key, value]) => [key, Number(value)])) as OrderQueueCounts + orderProgress.value = Object.fromEntries(result.progress.map(p => [String(p.orderId), p])) queryChannelNames.value = result.channelNames } } catch { loadError.value = '订单分页数据不完整,请重新加载' } } else loadError.value = orderResult.reason instanceof Error ? orderResult.reason.message : '订单数据加载失败' + if (loadError.value) { + queueCounts.value = { all: 0, fee: 0, customs: 0, draft: 0, loaded: 0, declared: 0, cleared: 0, scheduled: 0, delivering: 0, delivered: 0 } + } loading.value = false return orders.value.length } @@ -629,5 +641,5 @@ export const useFirstLegOrdersStore = defineStore('client-first-leg-orders', () return loadOrderAggregate(id) } - return { orders, orderDetails, total, queueCounts, queryChannelNames, warehouses, loading, loadedFromApi, loadError, warehouseLoadError, getById, load, loadDetail, loadDraftDetail, loadWarehouseDetail, loadWarehouseType, beginDraftEdit, saveDraft, prepareCancellation, cancelOrder, saveDraftBatch, submitSaved, hasPendingSubmission, isSubmissionRetry, beginNewSubmissionEdit, saveDraftForSubmission } + return { orders, orderDetails, total, queueCounts, orderProgress, queryChannelNames, warehouses, loading, loadedFromApi, loadError, warehouseLoadError, getById, load, loadDetail, loadDraftDetail, loadWarehouseDetail, loadWarehouseType, beginDraftEdit, saveDraft, prepareCancellation, cancelOrder, saveDraftBatch, submitSaved, hasPendingSubmission, isSubmissionRetry, beginNewSubmissionEdit, saveDraftForSubmission } }) diff --git a/src/modules/first-leg/store/firstLegWorkbench.test.mjs b/src/modules/first-leg/store/firstLegWorkbench.test.mjs index 0e35218..3a7ecc0 100644 --- a/src/modules/first-leg/store/firstLegWorkbench.test.mjs +++ b/src/modules/first-leg/store/firstLegWorkbench.test.mjs @@ -11,7 +11,8 @@ const row = (id, mainStatus = 'LOADED') => ({ orderId: String(id), orderNo: `FL- function result(page = 1, size = 10, total = 215) { return { page: { page, size, total, pages: Math.ceil(total / size), records: Array.from({ length: Math.min(size, Math.max(0, total - (page - 1) * size)) }, (_, i) => row((page - 1) * size + i + 1)) }, - queues: { all: total, fee: 2, customs: 1, draft: 3 }, channelNames: ['AIR', 'SEA'], + queues: { all: total, fee: 2, customs: 1, draft: 3, loaded: 0, declared: 0, cleared: 0, scheduled: 0, delivering: 0, delivered: 0 }, channelNames: ['AIR', 'SEA'], + progress: Array.from({ length: Math.min(size, Math.max(0, total - (page - 1) * size)) }, (_, i) => ({ orderId: String((page - 1) * size + i + 1), stage: 'loaded', partial: false })), } } function deferred() { let resolve, reject; const promise = new Promise((a, b) => { resolve = a; reject = b }); return { promise, resolve, reject } } @@ -44,7 +45,7 @@ test('loads beyond 200 with server totals and forwards every filter using one re assert.equal(h.store.orders[0].id, '201') assert.equal(h.store.total, 215) assert.deepEqual(h.calls, [query]) - assert.deepEqual(h.store.queueCounts, { all: 215, fee: 2, customs: 1, draft: 3 }) + assert.deepEqual(h.store.queueCounts, { all: 215, fee: 2, customs: 1, draft: 3, loaded: 0, declared: 0, cleared: 0, scheduled: 0, delivering: 0, delivered: 0 }) assert.deepEqual(h.store.queryChannelNames, ['AIR', 'SEA']) await h.store.load({ page: 22, size: 10 }) assert.deepEqual(h.store.orders.map(item => item.id), ['211', '212', '213', '214', '215']) @@ -109,9 +110,61 @@ test('draft and cancelled rows retain authoritative versions; off-page detail ca test('queue total is checked against the selected queue rather than all orders', async t => { const response = result(1, 10, 2) - response.queues = { all: 215, fee: 2, customs: 1, draft: 3 } + response.queues = { all: 215, fee: 2, customs: 1, draft: 3, loaded: 0, declared: 0, cleared: 0, scheduled: 0, delivering: 0, delivered: 0 } const h = harness(t, () => response) await h.store.load({ queue: 'fee' }) assert.equal(h.store.total, 2); assert.equal(h.store.queueCounts.all, 215) assert.equal(h.store.loadError, '') }) + + +test('retains server progress and rejects missing or foreign progress without inferring milestones', async t => { + let response = result() + response.progress[0] = { orderId: '1', stage: 'scheduled', partial: true } + const h = harness(t, () => response) + await h.store.load() + assert.deepEqual(h.store.orderProgress['1'], response.progress[0]) + response = result(); response.progress[0].orderId = 'foreign' + await h.store.load() + assert.match(h.store.loadError, /分页数据不完整/) + assert.deepEqual(h.store.orderProgress, {}) + response = result(); delete response.queues.cleared + await h.store.load(); assert.match(h.store.loadError, /分页数据不完整/) +}) + + +test('category counts remain visible throughout refresh and update together after the response is validated', async t => { + const pending = deferred() + let refreshing = false + const h = harness(t, () => refreshing ? pending.promise : result()) + await h.store.load() + const counts = h.store.queueCounts + refreshing = true + const request = h.store.load({ queue: 'fee' }) + assert.equal(h.store.loading, true) + assert.equal(h.store.queueCounts, counts) + assert.equal(h.store.queueCounts.fee, 2) + const updated = result(1, 10, 3) + updated.queues = { ...updated.queues, all: 216, fee: 3 } + pending.resolve(updated) + await request + assert.equal(h.store.queueCounts.all, 216) + assert.equal(h.store.queueCounts.fee, 3) + assert.equal(h.store.loading, false) +}) + +test('rapid category switches preserve counts and ignore an older response while the newest request is pending', async t => { + const older = deferred(), latest = deferred() + const h = harness(t, q => q.queue === 'fee' ? older.promise : q.queue === 'customs' ? latest.promise : result()) + await h.store.load() + const oldRequest = h.store.load({ queue: 'fee' }) + const newRequest = h.store.load({ queue: 'customs' }) + const oldResult = result(1, 10, 2); oldResult.queues.all = 999 + older.resolve(oldResult); await oldRequest + assert.equal(h.store.queueCounts.all, 215) + assert.equal(h.store.loading, true) + const newResult = result(1, 10, 1); newResult.queues.all = 216 + latest.resolve(newResult); await newRequest + assert.equal(h.store.queueCounts.all, 216) + assert.equal(h.store.total, 1) +}) diff --git a/src/modules/first-leg/utils/orderProgress.test.mjs b/src/modules/first-leg/utils/orderProgress.test.mjs new file mode 100644 index 0000000..efc8626 --- /dev/null +++ b/src/modules/first-leg/utils/orderProgress.test.mjs @@ -0,0 +1,15 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { readFileSync } from 'node:fs' +import ts from 'typescript' +const source = readFileSync(new URL('./orderProgress.ts', import.meta.url), 'utf8') +const code = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText +const exports = {} +new Function('exports', code)(exports) +test('prototype queues preserve order and distinguish partial progress from completion', () => { + assert.deepEqual(exports.ORDER_QUEUES.slice(0, 7).map(q => q.key), ['all','fee','loaded','declared','cleared','scheduled','delivering']) + assert.equal(exports.orderProgressLabel({ stage: 'scheduled', partial: true }), '部分已排车') + assert.equal(exports.orderProgressLabel({ stage: 'cleared', partial: false }), '已清关') + assert.equal(exports.orderProgressLabel({ stage: 'none', partial: false }), '—') + assert.equal(exports.orderProgressLabel(), '—') +}) diff --git a/src/modules/first-leg/utils/orderProgress.ts b/src/modules/first-leg/utils/orderProgress.ts new file mode 100644 index 0000000..fe3ce02 --- /dev/null +++ b/src/modules/first-leg/utils/orderProgress.ts @@ -0,0 +1,20 @@ +import type { MerchantOrderProgress, OrderQueue } from '../api/unifiedOrders' + +export const ORDER_QUEUES: ReadonlyArray<{ key: OrderQueue; label: string; todo?: boolean }> = [ + { key: 'all', label: '全部' }, + { key: 'fee', label: '确认计费重', todo: true }, + { key: 'loaded', label: '装柜完成' }, + { key: 'declared', label: '已报关' }, + { key: 'cleared', label: '已清关' }, + { key: 'scheduled', label: '已排车' }, + { key: 'delivering', label: '派送中' }, + { key: 'delivered', label: '派送完成' }, + { key: 'customs', label: '上传资料', todo: true }, + { key: 'draft', label: '草稿' }, +] + +export function orderProgressLabel(progress?: MerchantOrderProgress) { + if (!progress || progress.stage === 'none') return '—' + const label = ORDER_QUEUES.find(q => q.key === progress.stage)?.label ?? '—' + return `${progress.partial ? '部分' : ''}${label}` +} diff --git a/src/modules/first-leg/views/CustomsDocumentsView.test.mjs b/src/modules/first-leg/views/CustomsDocumentsView.test.mjs index 3390943..2ea9f67 100644 --- a/src/modules/first-leg/views/CustomsDocumentsView.test.mjs +++ b/src/modules/first-leg/views/CustomsDocumentsView.test.mjs @@ -46,6 +46,7 @@ function harness(t, orders, getDetail = async id => detail(id)) { vue: { ...vue, onMounted() {} }, '../utils/customsDocumentsReader': reader, '../api/customs': customsApi, + '../store/customsPending': { useCustomsPendingStore: () => ({ refresh: async () => {} }) }, })) t.after(() => scope.stop()) return { view, store, calls, pageCalls } diff --git a/src/modules/first-leg/views/CustomsDocumentsView.vue b/src/modules/first-leg/views/CustomsDocumentsView.vue index 69e5368..1542db8 100644 --- a/src/modules/first-leg/views/CustomsDocumentsView.vue +++ b/src/modules/first-leg/views/CustomsDocumentsView.vue @@ -8,6 +8,9 @@ import { MERCHANT_CUSTOMS_STATUS_LABELS, type MerchantCustomsContainer } from '. import { readCustomsDocuments, type CustomsDocumentRow } from '../utils/customsDocumentsReader' import CustomsDocumentsDialog from '../components/CustomsDocumentsDialog.vue' +import { useCustomsPendingStore } from '../store/customsPending' + +const customsPending = useCustomsPendingStore() const loading = ref(false) const errorMessage = ref('') const queryModel = ref({ orderNo: '', inboundCode: '', containerNo: '', status: '' }) @@ -68,6 +71,7 @@ function statusType(detail: MerchantCustomsContainer) { } async function load() { if (loading.value) return + void customsPending.refresh(true) loading.value = true; errorMessage.value = '' try { const { records: loadedRecords, failedCount } = await readCustomsDocuments() diff --git a/src/modules/first-leg/views/FirstLegOrdersView.test.mjs b/src/modules/first-leg/views/FirstLegOrdersView.test.mjs index 4b8c255..4d498ff 100644 --- a/src/modules/first-leg/views/FirstLegOrdersView.test.mjs +++ b/src/modules/first-leg/views/FirstLegOrdersView.test.mjs @@ -176,7 +176,7 @@ test('exports every visible order field with normalized dates', () => { test('matches the prototype with selectable dense order-list columns', () => { assert.match(source, /aria-label="设置订单列表显示列"/) assert.match(source, /显示字段筛选/) - assert.match(source, /const defaultVisibleColumnKeys = \['status', 'channel', 'transportMode', 'pickupMethod', 'receivingWarehouse', 'createdAt'\]/) + assert.match(source, /const defaultVisibleColumnKeys = \['status', 'progress', 'channel', 'transportMode', 'pickupMethod', 'receivingWarehouse', 'createdAt'\]/) assert.match(source, /key: 'inboundCode', label: '入仓码', width: 230, fixed: 'left'/) assert.match(source, / { test('keeps the first viewport compact and moves create into the table toolbar', () => { const tableToolbarSource = source.slice(source.indexOf('#toolbar-actions'), source.indexOf('class="order-eligibility is-pending"')) assert.match(source, /#toolbar-actions/) - assert.match(source, /class="order-queue-tabs"[\s\S]*确认计费重[\s\S]*上传资料[\s\S]*草稿/) + assert.match(source, /class="order-queue-tabs"[\s\S]*v-for="queue in ORDER_QUEUES"[\s\S]*orderQueueCounts\[queue.key\]/) assert.match(source, /activeOrderQueue\.value === 'draft'[\s\S]*order\.status === 'DRAFT'/) assert.doesNotMatch(source, /共 \{\{ filteredOrders\.length \}\} 条/) assert.match(source, /#toolbar-actions[\s\S]*order:first-leg:create[\s\S]*创建订单[\s\S]*class="order-eligibility is-pending"/) diff --git a/src/modules/first-leg/views/FirstLegOrdersView.vue b/src/modules/first-leg/views/FirstLegOrdersView.vue index 8fba950..f56abf9 100644 --- a/src/modules/first-leg/views/FirstLegOrdersView.vue +++ b/src/modules/first-leg/views/FirstLegOrdersView.vue @@ -59,6 +59,8 @@ import { summarizeFirstLegCargo, validateFirstLegOrderDraft, } from '@/modules/first-leg/utils/firstLegOrder' +import { ORDER_QUEUES, orderProgressLabel } from '../utils/orderProgress' +import type { OrderQueue } from '../api/unifiedOrders' import { validateProductImageFile } from '@/modules/first-leg/utils/productImage' import { createPriceEstimateBreakdown } from '@/modules/first-leg/utils/priceEstimate' @@ -168,6 +170,7 @@ const fixedOrderColumns: DataTableColumn[] = [ ] const selectableOrderColumns: DataTableColumn[] = [ { key: 'status', label: '当前订单状态', width: 126, align: 'center', headerAlign: 'center', slotName: 'status' }, + { key: 'progress', label: '履约进度', width: 142, align: 'center', headerAlign: 'center', slotName: 'progress' }, { key: 'channel', label: '渠道', width: 150, align: 'left', headerAlign: 'left', showOverflowTooltip: true, slotName: 'channel' }, { key: 'transportMode', label: '运输方式', width: 104, align: 'center', headerAlign: 'center', slotName: 'transportMode' }, { key: 'pickupMethod', label: '交货方式', width: 112, align: 'center', headerAlign: 'center', slotName: 'pickupMethod' }, @@ -198,7 +201,7 @@ const selectableOrderColumns: DataTableColumn[] = [ { key: 'updatedAt', label: '更新时间', width: 164, align: 'center', headerAlign: 'center', slotName: 'updatedAt' }, ] const allSelectableColumnKeys = selectableOrderColumns.map((column) => String(column.key)) -const defaultVisibleColumnKeys = ['status', 'channel', 'transportMode', 'pickupMethod', 'receivingWarehouse', 'createdAt'] +const defaultVisibleColumnKeys = ['status', 'progress', 'channel', 'transportMode', 'pickupMethod', 'receivingWarehouse', 'createdAt'] const visibleColumnKeys = ref([...defaultVisibleColumnKeys]) const orderedColumnKeys = ref([...allSelectableColumnKeys]) const draggedColumnKey = ref('') @@ -243,7 +246,6 @@ const selectedOrders = ref([]) const selectedOrderCount = computed(() => selectedOrders.value.length) const selectedDraftOrders = computed(() => selectedOrders.value.filter((order) => order.status === 'DRAFT')) const orderTableVersion = ref(0) -type OrderQueue = 'all' | 'fee' | 'customs' | 'draft' const activeOrderQueue = ref('all') const editorOpen = ref(false) const orderFormRef = ref() @@ -505,6 +507,7 @@ const orderQueueCounts = computed(() => store.queueCounts) const pagedOrders = computed(() => store.orders) async function selectOrderQueue(queue: OrderQueue) { + if (activeOrderQueue.value === queue && !store.loadError) return activeOrderQueue.value = queue tablePage.value = 1 await loadOrderPage() @@ -638,6 +641,7 @@ const emptyDescription = computed(() => { if (activeOrderQueue.value === 'customs') return '暂无需要上传报关资料的订单' if (activeOrderQueue.value === 'fee') return '暂无需要确认计费重的订单' if (activeOrderQueue.value === 'draft') return '暂无草稿订单' + if (activeOrderQueue.value !== 'all') return `暂无${ORDER_QUEUES.find(q => q.key === activeOrderQueue.value)?.label ?? ''}的订单` return '还没有订单' }) @@ -1812,6 +1816,7 @@ function exportOrderInformation() { displayListValue(order.destinationWarehouseName), destinationTypeLabels[order.destinationType], getFirstLegStatusLabel(order.status), + orderProgressLabel(store.orderProgress[order.id]), formatListMetric(orderPackageCount(order)), displayListValue(order.expressCompanyName), displayListValue(order.trackingNumber), @@ -1830,7 +1835,7 @@ function exportOrderInformation() { formatExportDateTime(order.createdAt), ]) downloadCsv('订单信息.csv', [[ - '入仓码', '渠道', '运输方式', '交货方式', '预约提货时间', '目的仓', '目的仓类型', '当前订单状态', '箱数', '快递公司', '快递单号', 'SKUS', + '入仓码', '渠道', '运输方式', '交货方式', '预约提货时间', '目的仓', '目的仓类型', '当前订单状态', '履约进度', '箱数', '快递公司', '快递单号', 'SKUS', '总毛重', '总净重', '总体积', '总数量', '计费单价', '计费体积', '计费重量', '备注', '货件编号', '跟踪编号', '国内仓入库时间', '创建时间', ], ...rows]) @@ -2106,10 +2111,12 @@ async function searchOrders(model: QueryModel) { @@ -2185,6 +2192,7 @@ async function searchOrders(model: QueryModel) { + @@ -2570,7 +2578,12 @@ async function searchOrders(model: QueryModel) { .supporting-data-error span { overflow-wrap: anywhere; color: var(--muted); font-size: 11px; line-height: 1.55; } .order-row-actions, .editor-actions { display: flex; justify-content: flex-end; gap: 8px; } .order-table-card { padding: 0; overflow: hidden; border: 1px solid var(--client-border); border-radius: var(--client-radius); box-shadow: none; } -.order-queue-tabs { display: flex; align-items: center; gap: 4px; } +.prototype-order-table :deep(.data-table__toolbar) { flex-wrap: nowrap; } +.prototype-order-table :deep(.data-table__toolbar-heading) { min-width: 0; flex: 1; flex-wrap: nowrap; } +.prototype-order-table :deep(.data-table__toolbar-actions) { flex-shrink: 0; } +.order-queue-tabs { display: flex; min-width: 0; align-items: center; gap: 4px; overflow-x: auto; padding: 7px 0 10px; scrollbar-width: thin; } +.order-queue-tabs button { flex-shrink: 0; white-space: nowrap; } +.queue-stage-count { margin-left: 3px; } .order-queue-tabs button { position: relative; min-height: 34px; padding: 0 12px; border: 1px solid transparent; border-radius: 7px; background: transparent; color: #667085; cursor: pointer; font: inherit; font-weight: 600; } .order-queue-tabs button:hover { color: var(--client-action); } .order-queue-tabs button.active { border-color: #cfe0ff; background: #f3f7ff; color: var(--client-action); } diff --git a/src/modules/first-leg/views/FirstLegWorkbenchPagination.test.mjs b/src/modules/first-leg/views/FirstLegWorkbenchPagination.test.mjs index dbeb38d..a0b780a 100644 --- a/src/modules/first-leg/views/FirstLegWorkbenchPagination.test.mjs +++ b/src/modules/first-leg/views/FirstLegWorkbenchPagination.test.mjs @@ -49,3 +49,13 @@ test('old same-page response cannot clamp a newer query or clear its current sel assert.deepEqual(h.selectedOrders.value, ['new-selection']) assert.equal(h.orderTableVersion.value, 1) }) + + +test('clicking the current category does not reload or reset its page, but an error can be retried', async () => { + const h = harness(); h.tablePage.value = 3 + await h.selectOrderQueue('all') + assert.equal(h.calls.length, 0); assert.equal(h.tablePage.value, 3) + h.store.loadError = 'failed' + await h.selectOrderQueue('all') + assert.equal(h.calls.length, 1); assert.equal(h.tablePage.value, 1) +}) diff --git a/src/modules/notifications/notifications.test.mjs b/src/modules/notifications/notifications.test.mjs index 1c6adaf..462fc14 100644 --- a/src/modules/notifications/notifications.test.mjs +++ b/src/modules/notifications/notifications.test.mjs @@ -36,7 +36,7 @@ test('keeps customer message actions first and covers the requested local states assert.match(source, /notification-state--error/) assert.match(source, /暂无消息/) assert.doesNotMatch(source, /清空已读|删除消息/) - assert.match(source, /Promise\.all\(\[load\(\), notifications\.refreshUnreadCount\(\)\]\)/) + assert.match(source, /Promise\.all\(\[load\(\), notifications\.refreshUnreadCount\(true\)\]\)/) assert.match(source, /auth\.hasPermission\('merchant:notification:read'\)/) assert.match(source, /auth\.hasPermission\('merchant:notification:read-all'\)/) assert.match(source, /v-if="canMarkAllRead"/) diff --git a/src/modules/notifications/store/notifications.test.mjs b/src/modules/notifications/store/notifications.test.mjs new file mode 100644 index 0000000..f41f6df --- /dev/null +++ b/src/modules/notifications/store/notifications.test.mjs @@ -0,0 +1,49 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import ts from 'typescript' +import { computed, ref } from 'vue' + +function setup() { + const requests = [] + const source = readFileSync(new URL('./notifications.ts', import.meta.url), 'utf8').replace(/^import [^\r\n]*\r?\n/gm, '').replace('export const', 'const') + const code = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText + const store = new Function('computed', 'ref', 'defineStore', 'getMyUnreadNotificationCount', code + '\nreturn useNotificationsStore()')(computed, ref, (_id, factory) => factory, () => new Promise((resolve, reject) => requests.push({ resolve, reject }))) + return { store, requests } +} + +test('shell and page concurrent reads share one pending request', async () => { + const { store, requests } = setup() + const a = store.refreshUnreadCount(), b = store.refreshUnreadCount() + assert.equal(requests.length, 1) + requests[0].resolve(5) + await Promise.all([a, b]) + assert.equal(store.unreadCount.value, 5) + assert.equal(store.loading.value, false) +}) + +test('post-mutation refresh reads again after a pending pre-mutation read', async () => { + const { store, requests } = setup() + const a = store.refreshUnreadCount(), b = store.refreshUnreadCount(true) + assert.equal(requests.length, 1) + requests[0].resolve(5) + await a + await Promise.resolve() + assert.equal(requests.length, 2) + requests[1].resolve(0) + await b + assert.equal(store.unreadCount.value, 0) +}) + +test('failed request releases the pending guard for retry', async () => { + const { store, requests } = setup() + const a = store.refreshUnreadCount() + requests[0].reject(new Error('network')) + await a + const b = store.refreshUnreadCount() + assert.equal(requests.length, 2) + requests[1].resolve(2) + await b + assert.equal(store.unreadCount.value, 2) + assert.equal(store.errorMessage.value, '') +}) diff --git a/src/modules/notifications/store/notifications.ts b/src/modules/notifications/store/notifications.ts index ebe0f92..d9514e0 100644 --- a/src/modules/notifications/store/notifications.ts +++ b/src/modules/notifications/store/notifications.ts @@ -9,7 +9,19 @@ export const useNotificationsStore = defineStore('client-notifications', () => { const hasUnread = computed(() => (unreadCount.value ?? 0) > 0) - async function refreshUnreadCount() { + let pending: Promise | null = null + + async function refreshUnreadCount(afterMutation = false): Promise { + if (pending) { + if (!afterMutation) return pending + await pending + return refreshUnreadCount() + } + pending = loadUnreadCount().finally(() => { pending = null }) + return pending + } + + async function loadUnreadCount() { loading.value = true errorMessage.value = '' try { diff --git a/src/modules/notifications/views/NotificationDetailView.vue b/src/modules/notifications/views/NotificationDetailView.vue index 9be7bcc..4593170 100644 --- a/src/modules/notifications/views/NotificationDetailView.vue +++ b/src/modules/notifications/views/NotificationDetailView.vue @@ -10,7 +10,7 @@ import type { NotificationDetail } from '../contracts' import { notificationCategoryLabel, notificationSentAt } from '../presentation' const route = useRoute(); const router = useRouter(); const auth = useAuthStore(); const notifications = useNotificationsStore(); const detail = ref(null); const loading = ref(false); const errorMessage = ref(''); const readErrorMessage = ref('') const canMarkRead = computed(() => auth.hasPermission('merchant:notification:read')) -async function markReadIfAllowed(){if(!detail.value||detail.value.status!=='UNREAD'||!canMarkRead.value)return;try{await markMyNotificationRead(detail.value.id);detail.value={...detail.value,status:'READ'};await notifications.refreshUnreadCount()}catch(error){readErrorMessage.value=error instanceof ApiError?error.message:'消息已加载,但自动标记已读失败'}} +async function markReadIfAllowed(){if(!detail.value||detail.value.status!=='UNREAD'||!canMarkRead.value)return;try{await markMyNotificationRead(detail.value.id);detail.value={...detail.value,status:'READ'};await notifications.refreshUnreadCount(true)}catch(error){readErrorMessage.value=error instanceof ApiError?error.message:'消息已加载,但自动标记已读失败'}} async function load(){loading.value=true;errorMessage.value='';readErrorMessage.value='';try{detail.value=await getMyNotification(String(route.params.notificationId));await markReadIfAllowed()}catch(error){detail.value=null;errorMessage.value=error instanceof ApiError?error.message:'消息加载失败,请稍后重试'}finally{loading.value=false}} onMounted(()=>{void load()}) diff --git a/src/modules/notifications/views/NotificationsView.vue b/src/modules/notifications/views/NotificationsView.vue index 9f934b1..f128956 100644 --- a/src/modules/notifications/views/NotificationsView.vue +++ b/src/modules/notifications/views/NotificationsView.vue @@ -78,7 +78,7 @@ async function markRead(row: NotificationSummary) { if (!canMarkRead.value) return try { await markMyNotificationRead(row.id) - await Promise.all([load(), notifications.refreshUnreadCount()]) + await Promise.all([load(), notifications.refreshUnreadCount(true)]) } catch (error) { errorMessage.value = messageOf(error) } @@ -89,7 +89,7 @@ async function markAllRead() { markingAll.value = true try { await markAllMyNotificationsRead() - await Promise.all([load(), notifications.refreshUnreadCount()]) + await Promise.all([load(), notifications.refreshUnreadCount(true)]) } catch (error) { errorMessage.value = messageOf(error) } finally { diff --git a/src/modules/overseas-warehouse/api/returnOrders.ts b/src/modules/overseas-warehouse/api/returnOrders.ts index e6cda49..be70b82 100644 --- a/src/modules/overseas-warehouse/api/returnOrders.ts +++ b/src/modules/overseas-warehouse/api/returnOrders.ts @@ -16,10 +16,11 @@ export interface ReturnReceipt { labels?: Array<{ cartonMark: string; printableHtml: string }> labelRepairRequired?: boolean } -export interface ReturnPallet { palletCode: string; unitType: 'PALLET' | 'BOX'; unitCode: string; skuTypes: number; totalQuantity: number; totalBoxCount: number; items: Array<{ skuCode: string; productNameZh?: string; quantity: number; boxCount: number }> } +export interface ReturnPallet { palletCode: string; unitType: 'PALLET' | 'BOX'; unitCode: string; skuTypes: number; totalQuantity: number; totalBoxCount: number; length?: number; width?: number; height?: number; weight?: number; volumeCbm?: number; items: Array<{ skuCode: string; productNameZh?: string; quantity: number; boxCount: number }> } export interface ReturnPhoto { fileId: number; fileName: string; contentType: string; sizeBytes: number } export interface ReturnPackingDetail { sourceCartonMark: string; targetCartonMark: string; returnNo: string; skuCode: string; quantity: number; createdAt: string } -export interface ReturnOrderDetail { order: ReturnOrderSummary; remark?: string; items: ReturnOrderItem[]; receipts: ReturnReceipt[]; pallets: ReturnPallet[]; photos: ReturnPhoto[]; packingDetails?: ReturnPackingDetail[] } +export interface ReturnReceiptCarton { receiptNo: string; palletCode?: string; cartonMark: string; countStatus: 'PENDING' | 'CONFIRMED' | 'UNRECORDED'; countedAt?: string; items: ReturnReceipt['items'] } +export interface ReturnOrderDetail { order: ReturnOrderSummary; remark?: string; items: ReturnOrderItem[]; receipts: ReturnReceipt[]; pallets: ReturnPallet[]; photos: ReturnPhoto[]; packingDetails?: ReturnPackingDetail[]; cartons?: ReturnReceiptCarton[] } export interface ReturnOrderCreate { overseasWarehouseId: string; warehouseContactName: string; warehouseContactPhone: string; customerReferenceNo?: string; remark?: string; needRelabel: boolean; idempotencyKey: string items: Array<{ skuCode: string; productNameZh?: string; expectedQuantity: number }> diff --git a/src/modules/overseas-warehouse/api/warehouseServices.ts b/src/modules/overseas-warehouse/api/warehouseServices.ts index c030e92..16c3bb7 100644 --- a/src/modules/overseas-warehouse/api/warehouseServices.ts +++ b/src/modules/overseas-warehouse/api/warehouseServices.ts @@ -33,7 +33,7 @@ export interface RelabelInventoryCandidate { destinationFbaWarehouseCode?: string; destinationFbaWarehouseName?: string; skuCode?: string; productNameZh?: string availablePackages: number | string; version: number positions: Array<{ positionId: string; palletNo?: string; batchNo?: string; warehouseLocationCode?: string; availablePackages: number }> - sourceType?: 'INBOUND' | 'RETURN_CARTON'; returnCartonId?: string; cartonMark?: string + sourceType?: 'INBOUND' | 'RETURN_CARTON' | 'RETURN_PARCEL'; returnCartonId?: string; cartonMark?: string skuItems?: Array<{ skuCode: string; newSkuCode?: string; quantity: number; remark?: string }>; relabelledPackages?: number } 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/utils/returnReceiptDisplay.test.mjs b/src/modules/overseas-warehouse/utils/returnReceiptDisplay.test.mjs new file mode 100644 index 0000000..3643b8e --- /dev/null +++ b/src/modules/overseas-warehouse/utils/returnReceiptDisplay.test.mjs @@ -0,0 +1,22 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' +import ts from 'typescript' + +const source = readFileSync(new URL('./returnReceiptDisplay.ts', import.meta.url), 'utf8') +const compiled = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText +const exports = {} +new Function('exports', compiled)(exports) + +test('volume rounds upward to exactly three decimal places without increasing exact thousandths', () => { + for (const [input, expected] of [[0.186624, '0.187'], [0.15, '0.150'], [1.001, '1.001'], [1.00100001, '1.002'], [0.0000001, '0.001'], [0, '0.000'], [null, '—'], [NaN, '—']]) { + assert.equal(exports.formatReturnVolume(input), expected) + } +}) + +test('parcel waybills appear as tracking numbers while generated parcel codes remain physical codes', () => { + assert.equal(exports.receiptTrackingNo({ unitType: 'PARCEL', unitCode: 'sf561626' }), 'sf561626') + assert.equal(exports.receiptTrackingNo({ unitType: 'PARCEL', unitCode: 'PK0123456789ABCDEF' }), '') + assert.equal(exports.receiptTrackingNo({ unitType: 'PARCEL', unitCode: 'PK0123456789ABCDEF', trackingNo: ' SF123 ' }), 'SF123') + assert.equal(exports.receiptTrackingNo({ unitType: 'PALLET', unitCode: 'PLT123' }), '') +}) diff --git a/src/modules/overseas-warehouse/utils/returnReceiptDisplay.ts b/src/modules/overseas-warehouse/utils/returnReceiptDisplay.ts new file mode 100644 index 0000000..57e1087 --- /dev/null +++ b/src/modules/overseas-warehouse/utils/returnReceiptDisplay.ts @@ -0,0 +1,21 @@ +import type { ReturnReceipt } from '../api/returnOrders' + +export function receiptTrackingNo(receipt: Pick | Record) { + const trackingNo = typeof receipt.trackingNo === 'string' ? receipt.trackingNo.trim() : '' + if (trackingNo) return trackingNo + const code = typeof receipt.unitCode === 'string' ? receipt.unitCode.trim() : '' + return receipt.unitType === 'PARCEL' && code && !/^PK[0-9A-F]{16}$/.test(code) ? code : '' +} + +export function formatReturnVolume(value?: number | null) { + if (value == null || !Number.isFinite(value) || value < 0) return '—' + // Shift the decimal in text to avoid floating-point over-rounding exact thousandths. + const [coefficient, exponent = '0'] = String(value).toLowerCase().split('e') + const [integer, fraction = ''] = coefficient!.split('.') + const digits = integer! + fraction + const cutoff = integer!.length + Number(exponent) + 3 + const scaled = cutoff <= 0 ? '0' : digits.slice(0, cutoff).padEnd(cutoff, '0') + const remainder = cutoff < 0 ? digits : digits.slice(Math.max(0, cutoff)) + const rounded = BigInt(scaled) + (/[1-9]/.test(remainder) ? 1n : 0n) + return `${rounded / 1000n}.${String(rounded % 1000n).padStart(3, '0')}` +} diff --git a/src/modules/overseas-warehouse/views/ReturnInventoryView.vue b/src/modules/overseas-warehouse/views/ReturnInventoryView.vue index d506bce..b76a16f 100644 --- a/src/modules/overseas-warehouse/views/ReturnInventoryView.vue +++ b/src/modules/overseas-warehouse/views/ReturnInventoryView.vue @@ -37,7 +37,7 @@ const warehouses = ref([]) const warehouseError = ref('') const warehousesLoading = ref(false) function formatInventoryTime(value?: string) { return value ? value.replace('T', ' ').slice(0, 19) : '—' } -function cartonStatusLabel(value: string) { return ({ AVAILABLE: '可用', MERGED: '已合箱', OUTBOUNDED: '已出库' } as Record)[value] || '—' } +function cartonStatusLabel(value: string) { return ({ PENDING_COUNT: '箱内待清点', AVAILABLE: '可用', MERGED: '已合箱', OUTBOUNDED: '已出库' } as Record)[value] || '—' } function warehouseLabel(warehouseId: string) { const warehouse = warehouses.value.find(item => String(item.id) === String(warehouseId)) return warehouse ? `${warehouse.warehouseName} · ${warehouse.warehouseCode}` : '—' @@ -81,7 +81,7 @@ const queryFields = computed(() => { if (activeTab.value === 'cartons') fields.push( { key: 'cartonMark', label: '箱号 / 箱唛', type: 'input', placeholder: '请输入箱号或箱唛' }, { key: 'returnNo', label: '退货单号', type: 'input', placeholder: '请输入退货单号' }, - { key: 'status', label: '箱状态', type: 'select', options: [{ label: '可用', value: 'AVAILABLE' }, { label: '已合箱', value: 'MERGED' }] }, + { key: 'status', label: '箱状态', type: 'select', options: [{ label: '箱内待清点', value: 'PENDING_COUNT' }, { label: '可用', value: 'AVAILABLE' }, { label: '已合箱', value: 'MERGED' }] }, ) return fields }) diff --git a/src/modules/overseas-warehouse/views/ReturnOrdersView.test.mjs b/src/modules/overseas-warehouse/views/ReturnOrdersView.test.mjs index 651ddc1..90e1872 100644 --- a/src/modules/overseas-warehouse/views/ReturnOrdersView.test.mjs +++ b/src/modules/overseas-warehouse/views/ReturnOrdersView.test.mjs @@ -5,6 +5,45 @@ import test from 'node:test' const view = readFileSync(new URL('./ReturnOrdersView.vue', import.meta.url), 'utf8') const api = readFileSync(new URL('../api/returnOrders.ts', import.meta.url), 'utf8') +function submitHarness(failure) { + const source = view.slice(view.indexOf('async function submit()'), view.indexOf('\nfunction statusType(')) + const state = { requests: [], loads: 0, routes: [], errors: [], successes: [] } + const form = { overseasWarehouseId: 'WH-1', warehouseContactName: 'Contact', warehouseContactPhone: '123', customerReferenceNo: 'REF-NEW', remark: 'edited', needRelabel: true, items: [{ skuCode: 'SKU-NEW', productNameZh: 'Product', expectedQuantity: 25 }] } + const editingReturnNo = { value: 'RT-TEST' }, showCreate = { value: true }, creating = { value: false } + const submit = new Function('form', 'duplicateSkuIds', 'creating', 'editingReturnNo', 'showCreate', 'props', 'ElMessage', 'updateReturnOrder', 'createReturnOrder', 'createIdempotencyKey', 'resetForm', 'router', 'load', `${source}; return submit`)( + form, { value: new Set() }, creating, editingReturnNo, showCreate, { createPage: false, detailPage: false }, + { warning() {}, success(message) { state.successes.push(message) }, error(message) { state.errors.push(message) } }, + async (returnNo, payload) => { state.requests.push({ returnNo, payload }); if (failure) throw failure }, + async () => assert.fail('Editing must use update'), () => 'edit-test', () => {}, + { async push(route) { state.routes.push(route) } }, async () => { state.loads++ }, + ) + return { submit, state, editingReturnNo, showCreate, creating } +} + +test('编辑退货保存提交修改后的字段,并重新读取当前列表', async () => { + const { submit, state, editingReturnNo, showCreate, creating } = submitHarness() + await submit() + assert.equal(state.requests[0].returnNo, 'RT-TEST') + assert.equal(state.requests[0].payload.customerReferenceNo, 'REF-NEW') + assert.equal(state.requests[0].payload.items[0].expectedQuantity, 25) + assert.equal(state.loads, 1) + assert.deepEqual(state.routes, []) + assert.equal(showCreate.value, false) + assert.equal(editingReturnNo.value, '') + assert.equal(creating.value, false) +}) + +test('编辑退货保存失败显示接口错误并保留编辑内容供重试', async () => { + const { submit, state, editingReturnNo, showCreate, creating } = submitHarness(new Error('只有待审核退货预报可以修改')) + await submit() + assert.deepEqual(state.errors, ['只有待审核退货预报可以修改']) + assert.deepEqual(state.successes, []) + assert.equal(state.loads, 0) + assert.equal(showCreate.value, true) + assert.equal(editingReturnNo.value, 'RT-TEST') + assert.equal(creating.value, false) +}) + test('退货签收照片通过客户鉴权下载接口加载并释放对象 URL', () => { assert.match(api, /\/return-orders\/\$\{encodeURIComponent\(returnNo\)\}\/photos\/files\/\$\{encodeURIComponent\(String\(fileId\)\)\}\/download/) assert.match(api, /apiBlobRequest/) @@ -81,7 +120,7 @@ const labelHtml = mark => `${labelStyle} function printHarness() { const source = view.slice(view.indexOf('async function printReturnLabel('), view.indexOf('\nfunction openDetail(')) - .replace('receipt: ReturnReceipt', 'receipt') + .replace('receipt: ReturnReceipt | ReturnReceipt[]', 'receipt') const calls = { opened: 0, printed: 0, html: '', warnings: [], errors: [], fetched: [] } const popup = { document: { open() {}, write(html) { calls.html = html }, close() {} }, @@ -119,7 +158,7 @@ test('退货多箱打印保留统一模板尺寸和每箱条码,并按箱分 assert.equal(calls.printed, 1) assert.deepEqual(calls.fetched, []) assert.deepEqual(calls.errors, []) - assert.match(view, /打印全部箱唛(\{\{ receipt\.labels\?\.length \|\| receipt\.boxCount \|\| 1 \}\} 箱)/) + assert.match(view, /打印全部箱唛(\{\{ printableBoxCount \}\} 箱)/) }) test('缺少内嵌模板时读取鉴权箱唛模板,待修复箱记录禁止打印', async () => { @@ -136,6 +175,22 @@ test('缺少内嵌模板时读取鉴权箱唛模板,待修复箱记录禁止 assert.deepEqual(calls.errors, []) }) +test('两托各两箱合并为一次四页打印,每箱条码保留且模板样式不重复', async () => { + const { print, calls } = printHarness() + const receipts = [1, 2].map(pallet => ({ + cartonMark: `PALLET-${pallet}`, boxCount: 2, + labels: [1, 2].map(box => ({ cartonMark: `SKU-${pallet}#2-${box}`, printableHtml: labelHtml(`SKU-${pallet}#2-${box}`) })), + printableHtml: `batch-${pallet}`, + })) + await print(receipts) + assert.equal(calls.opened, 1) + assert.equal(calls.printed, 1) + assert.equal((calls.html.match(/
/g) || []).length, 4) + assert.equal(calls.html.split(labelStyle).length - 1, 1) + for (const receipt of receipts) for (const label of receipt.labels) assert.ok(calls.html.includes(`data-barcode="${label.cartonMark}"`)) + assert.deepEqual(calls.errors, []) +}) + test('待审核退货可编辑,完成入库后从独立新增页发起贴标', () => { assert.match(view, /row\.status === 'PENDING_REVIEW'.*key: 'edit'/) assert.match(view, /if \(editingReturnNo\.value\) await updateReturnOrder\(editingReturnNo\.value, payload\)/) diff --git a/src/modules/overseas-warehouse/views/ReturnOrdersView.vue b/src/modules/overseas-warehouse/views/ReturnOrdersView.vue index 0d7535a..770fb7e 100644 --- a/src/modules/overseas-warehouse/views/ReturnOrdersView.vue +++ b/src/modules/overseas-warehouse/views/ReturnOrdersView.vue @@ -11,6 +11,7 @@ import { getMerchantWarehouses, type MerchantWarehouse } from '@/modules/first-l import { createIdempotencyKey } from '@/utils/idempotencyKey' import { createReturnOrder, downloadReturnPhoto, getReturnOrder, listReturnOrders, updateReturnOrder, type ReturnOrderDetail, type ReturnOrderSummary, type ReturnReceipt } from '../api/returnOrders' import { getReturnInventoryLabel } from '../api/returnInventory' +import { formatReturnVolume, receiptTrackingNo } from '../utils/returnReceiptDisplay' const props = withDefaults(defineProps<{ createPage?: boolean; detailPage?: boolean; returnNo?: string }>(), { createPage: false, detailPage: false, returnNo: '' }) const router = useRouter() @@ -24,6 +25,9 @@ const creating = ref(false) const showCreate = ref(false) const editingReturnNo = ref('') const selected = ref() +const showCartons = ref(false) +const printableReceipts = computed(() => selected.value?.receipts.filter(item => item.unitType !== 'PARCEL' && (item.cartonMark || item.labels?.length)) || []) +const printableBoxCount = computed(() => printableReceipts.value.reduce((total, receipt) => total + (receipt.labels?.length || receipt.boxCount || 1), 0)) const photoUrls = reactive>({}) const rows = ref([]) const total = ref(0) @@ -64,7 +68,7 @@ const receiptColumns: DataTableColumn[] = [ { prop: 'receiptBatchNo', label: '批次号', minWidth: 145 }, { prop: 'receiptNo', label: '签收单号', minWidth: 145 }, { key: 'unitType', label: '实物类型', slotName: 'unitType', width: 90 }, - { prop: 'unitCode', label: '实物编码', minWidth: 160 }, + { key: 'unitCode', label: '实物编码', slotName: 'unitCode', minWidth: 160 }, { key: 'trackingNo', label: '快递单号', slotName: 'trackingNo', minWidth: 140 }, { key: 'startedBy', label: '开始方式', slotName: 'startedBy', width: 100 }, { prop: 'receivedQuantity', label: '实收', width: 80 }, @@ -175,18 +179,21 @@ function previewPhoto(fileId: string | number) { const url = photoUrls[String(fileId)] if (url) window.open(url, '_blank', 'noopener') } -async function printReturnLabel(receipt: ReturnReceipt) { - if (receipt.labelRepairRequired) { ElMessage.warning('签收箱数与当前箱记录不一致,请联系仓库核实并修复后打印'); return } - if (!receipt.cartonMark) return +async function printReturnLabel(receipt: ReturnReceipt | ReturnReceipt[]) { + const receipts = Array.isArray(receipt) ? receipt : [receipt] + if (receipts.some(item => item.labelRepairRequired)) { ElMessage.warning('签收箱数与当前箱记录不一致,请联系仓库核实并修复后打印'); return } + if (!receipts.length) return const popup = window.open('', '_blank') if (!popup) { ElMessage.warning('请允许浏览器打开打印窗口'); return } popup.opener = null try { - const labels = receipt.labels?.length - ? receipt.labels - : [{ cartonMark: receipt.cartonMark, printableHtml: receipt.printableHtml || (await getReturnInventoryLabel(receipt.cartonMark)).printableHtml }] + const groups = await Promise.all(receipts.map(async item => item.labels?.length + ? await Promise.all(item.labels.map(async label => ({ ...label, printableHtml: label.printableHtml || (await getReturnInventoryLabel(label.cartonMark)).printableHtml }))) + : item.cartonMark ? [{ cartonMark: item.cartonMark, printableHtml: item.printableHtml || (await getReturnInventoryLabel(item.cartonMark)).printableHtml }] : [])) + const labels = [...new Map(groups.flat().map(label => [label.cartonMark, label])).values()] + if (!labels.length) { popup.close(); ElMessage.warning('暂无可打印的箱唛'); return } const printStyles = [...new Set(labels.flatMap(label => label.printableHtml.match(/]*>[\s\S]*?<\/style>/gi) || []))].join('') - const printableHtml = receipt.printableHtml || `退货箱唛${printStyles}${labels.map((label) => { + const printableHtml = (receipts.length === 1 ? receipts[0]?.printableHtml : undefined) || `退货箱唛${printStyles}${labels.map((label) => { const html = label.printableHtml || '' const bodyStart = html.indexOf('', bodyStart) + 1 @@ -221,7 +228,12 @@ async function submit() { const payload = { overseasWarehouseId: form.overseasWarehouseId, warehouseContactName: form.warehouseContactName.trim(), warehouseContactPhone: form.warehouseContactPhone.trim(), customerReferenceNo: form.customerReferenceNo || undefined, remark: form.remark || undefined, needRelabel: form.needRelabel, idempotencyKey: createIdempotencyKey(), items: items.map(item => ({ ...item, productNameZh: item.productNameZh || undefined })) } if (editingReturnNo.value) await updateReturnOrder(editingReturnNo.value, payload) else await createReturnOrder(payload) - ElMessage.success(editingReturnNo.value ? '退货预报已更新' : '退货预报已创建'); showCreate.value = false; editingReturnNo.value = ''; resetForm(); await router.push({ name: 'overseas-return-relabel' }) + const wasEditing = Boolean(editingReturnNo.value) + ElMessage.success(wasEditing ? '退货预报已更新' : '退货预报已创建'); showCreate.value = false; editingReturnNo.value = ''; resetForm() + if (props.createPage || props.detailPage) await router.push({ name: 'overseas-return-relabel' }) + else await load() + } catch (error) { + ElMessage.error(error instanceof Error ? error.message : '保存退货预报失败') } finally { creating.value = false } } function statusType(status: string, needRelabel = false) { @@ -367,11 +379,19 @@ onBeforeUnmount(clearPhotoUrls)
-
-

实物明细({{ selected.pallets?.length || 0 }} 个托盘/箱)打印全部箱唛({{ receipt.labels?.length || receipt.boxCount || 1 }} 箱)

+
+

包裹签收明细

+
+
包裹 {{ receipt.unitCode || receipt.trackingNo || receipt.receiptNo }}{{ receipt.receivedQuantity }} 件
+
{{ item.skuCode }}{{ item.productNameZh || '中文品名未填写' }}{{ item.quantity }} 件
+
+
+
+

实物明细({{ selected.pallets?.length || 0 }} 个托盘/箱)打印全部箱唛({{ printableBoxCount }} 箱)

- + +
长 {{ pallet.length ?? '—' }} cm · 宽 {{ pallet.width ?? '—' }} cm · 高 {{ pallet.height ?? '—' }} cm · 重量 {{ pallet.weight ?? '—' }} kg
@@ -382,6 +402,27 @@ onBeforeUnmount(clearPhotoUrls)
+
+

签收箱号明细({{ selected.cartons?.length || 0 }} 箱)查看箱号明细

+ +
+ + + +
签收单 {{ box.receiptNo }}
+ + + + + +
{{ box.countStatus === 'PENDING' ? '箱内数量待仓库清点确认' : '未留存原始箱内明细,不以当前库存代替签收数量' }}
+
+
+ +
+ +
+

退货打托照片({{ selected.photos?.length || 0 }} 张)

@@ -397,14 +438,12 @@ onBeforeUnmount(clearPhotoUrls)

签收批次

- + + -
- 包裹 {{ receipt.unitCode || receipt.trackingNo || receipt.receiptNo }} SKU 明细 - {{ item.skuCode }} × {{ item.quantity }} -
+
@@ -461,11 +500,21 @@ onBeforeUnmount(clearPhotoUrls) .return-detail-section :deep(.el-table .cell) { padding:0 8px; line-height:18px; } .return-detail-section :deep(.el-table .el-tag) { font-size:12px; } .return-pallet-list { padding:0 14px 10px; } +.return-cartons-dialog-body { max-height:65vh; overflow-y:auto; } .return-pallet-summary { margin-left:12px; color:#7c899c; font-size:12px; font-weight:400; } +.return-pallet-measurements { margin-bottom:8px; color:#7c899c; font-size:12px; } .return-photo-grid { display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:12px; padding:0 16px 16px; } .return-photo { min-width:0; overflow:hidden; padding:0; border:1px solid #dfe6ef; border-radius:6px; color:#526176; background:#fff; cursor:pointer; } .return-photo img,.return-photo>span { display:flex; width:100%; height:120px; align-items:center; justify-content:center; object-fit:cover; background:#f4f7fb; } .return-photo small { display:block; overflow:hidden; padding:7px; text-overflow:ellipsis; white-space:nowrap; } +.return-parcel-items { margin:10px 16px 16px; overflow:hidden; border:1px solid #dfe6ef; border-radius:8px; background:#fbfcfe; } +.return-parcel-items__head { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:10px 14px; color:#26364f; background:#f3f7fc; border-bottom:1px solid #e7edf5; } +.return-parcel-items__head span { color:#1764c0; font-size:12px; font-weight:600; } +.return-parcel-items__list { display:grid; grid-template-columns:repeat(auto-fit,minmax(280px,1fr)); gap:8px; padding:10px; } +.return-parcel-item { display:grid; grid-template-columns:minmax(110px,1fr) minmax(100px,1.4fr) auto; align-items:center; gap:10px; min-height:38px; padding:7px 10px; border:1px solid #edf1f5; border-radius:6px; background:#fff; } +.return-parcel-item__sku { color:#2c3c55; font-weight:600; } +.return-parcel-item__name { overflow:hidden; color:#8490a1; font-size:12px; text-overflow:ellipsis; white-space:nowrap; } +.return-parcel-item strong { color:#1764c0; white-space:nowrap; } @media (max-width: 760px) { .return-create-form { grid-template-columns:1fr; } } @media (max-width: 1100px) { .return-detail-grid { grid-template-columns:repeat(2,minmax(0,1fr)); } } @media (max-width: 900px) { .return-photo-grid { grid-template-columns:repeat(3,minmax(0,1fr)); } } diff --git a/src/modules/overseas-warehouse/views/WarehouseServiceView.vue b/src/modules/overseas-warehouse/views/WarehouseServiceView.vue index de8ff08..d108168 100644 --- a/src/modules/overseas-warehouse/views/WarehouseServiceView.vue +++ b/src/modules/overseas-warehouse/views/WarehouseServiceView.vue @@ -26,16 +26,14 @@ import { } from '@/modules/first-leg/api/firstLeg' import { loadAllWarehouseRecords } from '@/modules/first-leg/utils/warehousePagination' import { createIdempotencyKey } from '@/utils/idempotencyKey' -import { createReturnRelabel, uploadReturnRelabelFile } from '../api/returnOrders' type ServiceType = 'labeling' | 'transfer' | 'disposal' | 'relabel' type PageMode = 'list' | 'create' | 'edit' | 'detail' type LabelingMode = 'carton' | 'sku' | 'carton_and_sku' type RelabelTemplateType = 'carton' | 'sku' | 'mixed' -interface CargoRow { id: number; included?: boolean; sku: string; name: string; quantity: number; availableQuantity?: number; sourceOrderId?: string; sourceType?: 'DROPSHIP' | 'RETURN_CARTON'; sourceInventoryBalanceId?: string; sourceInventoryPositionId?: string; sourceInboundOrderId?: string; returnCartonId?: string; sourceInboundOrderNo?: string; sourceInboundCode?: string; sourceWarehouseName?: string; palletNo?: string; warehouseLocationCode?: string; fnsku: string; shipmentNo: string; newSkuCode: string; shippingMark: string; customerCartonMarks: string; cartonMark: string; fbaCartonMark: string; destinationWarehouseId: string; remark: string } +interface CargoRow { id: number; included?: boolean; sku: string; name: string; quantity: number; availableQuantity?: number; sourceOrderId?: string; sourceType?: 'DROPSHIP' | 'RETURN_CARTON' | 'RETURN_PARCEL'; sourceInventoryBalanceId?: string; sourceInventoryPositionId?: string; sourceInboundOrderId?: string; returnCartonId?: string; sourceInboundOrderNo?: string; sourceInboundCode?: string; sourceWarehouseName?: string; palletNo?: string; warehouseLocationCode?: string; fnsku: string; shipmentNo: string; newSkuCode: string; shippingMark: string; customerCartonMarks: string; cartonMark: string; fbaCartonMark: string; destinationWarehouseId: string; remark: string } interface SkuRelabelRow { id: number; sourceRowId: number | null; cartonMark: string; originalSku: string; newSku: string; quantity: number; fbaCartonMark: string; destinationWarehouseId: string; remark: string } interface ImportedRelabelBinding extends SkuRelabelRow { templateType: RelabelTemplateType; selected: boolean; shipmentNo: string } -interface ReturnRelabelSkuRow { skuCode: string; productNameZh: string; availableQuantity: number; selected: boolean; quantity: number } interface ServiceConfig { title: string; createTitle: string; numberLabel: string; emptyText: string; columns: string[] } interface TransferCarton { cartonMark: string; customerCartonMark?: string; cartonIndex: number; totalCartons: number; status: string } @@ -189,16 +187,23 @@ const selectedDiscardOrderId = ref('') const discardCandidatesLoading = ref(false) const relabelCandidates = ref([]) const selectedRelabelInboundOrderId = ref('') +const orderInfoCollapsed = ref(false) +const availableBoxesCollapsed = ref(false) +const availableSkusCollapsed = ref(false) const relabelCandidatesLoading = ref(false) const transferInventoryCandidates = ref([]) const selectedTransferInventory = ref('') // Snowflake IDs exceed JavaScript's safe integer range. Keep them as strings // all the way to JSON so the backend receives the exact source order ID. const selectedTransferSourceOrderIds = ref([]) -const returnRelabelSkuRows = ref([]) const isReturnRelabelSource = computed(() => props.service === 'labeling' && selectedRelabelInboundOrderId.value.startsWith('RETURN_ORDER:')) const selectedReturnOrderNo = computed(() => isReturnRelabelSource.value ? selectedRelabelInboundOrderId.value.slice('RETURN_ORDER:'.length) : '') -const requiresBothTotalFiles = computed(() => (props.service === 'labeling' || (props.service === 'transfer' && form.relabel)) && !isReturnRelabelSource.value && labelingMode.value === 'carton_and_sku') +const hideReturnSkuCartonMark = computed(() => isReturnRelabelSource.value && labelingMode.value === 'sku') +const selectedReturnCandidates = computed(() => relabelCandidates.value.filter(item => Boolean(item.returnCartonId) && item.inboundOrderNo === selectedReturnOrderNo.value)) +const returnBoxCandidates = computed(() => selectedReturnCandidates.value.filter(item => item.sourceType === 'RETURN_CARTON')) +const returnLooseCandidates = computed(() => selectedReturnCandidates.value.filter(item => item.sourceType === 'RETURN_PARCEL')) +const returnSkuOnly = computed(() => isReturnRelabelSource.value && returnLooseCandidates.value.length > 0 && returnBoxCandidates.value.length === 0) +const requiresBothTotalFiles = computed(() => (props.service === 'labeling' || (props.service === 'transfer' && form.relabel)) && labelingMode.value === 'carton_and_sku') function inventoryQuantity(value: number | string | null | undefined) { const quantity = Number(value) return Number.isFinite(quantity) && quantity > 0 ? quantity : 0 @@ -207,7 +212,7 @@ const relabelInboundOptions = computed(() => { const grouped = new Map() for (const candidate of relabelCandidates.value) { const returnOrderNo = candidate.inboundOrderNo || String(candidate.returnCartonId || '') - const value = candidate.sourceType === 'RETURN_CARTON' ? `RETURN_ORDER:${returnOrderNo}` : String(candidate.inboundOrderId) + const value = candidate.returnCartonId ? `RETURN_ORDER:${returnOrderNo}` : String(candidate.inboundOrderId) const current = grouped.get(value) if (current) { current.availablePackages += inventoryQuantity(candidate.availablePackages) @@ -215,11 +220,11 @@ const relabelInboundOptions = computed(() => { } else grouped.set(value, { value, - inboundOrderNo: candidate.sourceType === 'RETURN_CARTON' ? `退货单 ${candidate.inboundOrderNo || '—'}` : (candidate.inboundOrderNo || '—'), - inboundCode: candidate.sourceType === 'RETURN_CARTON' ? '已签收退货库存' : (candidate.inboundCode || '—'), + inboundOrderNo: candidate.returnCartonId ? `退货单 ${candidate.inboundOrderNo || '—'}` : (candidate.inboundOrderNo || '—'), + inboundCode: candidate.returnCartonId ? '已签收退货库存' : (candidate.inboundCode || '—'), availablePackages: inventoryQuantity(candidate.availablePackages), relabelledPackages: inventoryQuantity(candidate.relabelledPackages), - quantityUnit: candidate.sourceType === 'RETURN_CARTON' ? '件' : '箱', + quantityUnit: candidate.returnCartonId ? '件' : '箱', }) } return [...grouped.values()] @@ -263,7 +268,10 @@ watch(() => cargoRows.value.map((row) => row.sku), (skus) => { if (sourceSku) row.sku = sourceSku }) }) -watch(labelingMode, (value) => { if (value !== 'carton' && !skuRelabelRows.value.length) addSkuRelabelRow() }) +watch([returnSkuOnly, labelingMode], ([skuOnly, value]) => { + if (skuOnly && value !== 'sku') { labelingMode.value = 'sku'; return } + if (value !== 'carton' && !skuRelabelRows.value.length) addSkuRelabelRow() +}) watch(() => form.destination, (value) => { if (props.service !== 'labeling') return for (const row of cargoRows.value) row.destinationWarehouseId = value @@ -293,6 +301,9 @@ onMounted(() => { onBeforeUnmount(() => { clearCompletionPhotos(); clearMeasurementPhotos() }) function resetFilters() { Object.assign(filters, { number: '', inboundNo: '', warehouse: '', destination: '', status: '', start: '', end: '', reviewStart: '', reviewEnd: '', completedStart: '', completedEnd: '', measuredStart: '', measuredEnd: '', outboundStart: '', outboundEnd: '' }) } function resetForm() { + orderInfoCollapsed.value = false + availableBoxesCollapsed.value = false + availableSkusCollapsed.value = false Object.assign(form, { customerNo: '', inboundNo: '', sourceWarehouse: '', warehouse: '', destination: '', privateDestinationAddress: '', privateDestinationPostalCode: '', privateDestinationContact: '', privateDestinationPhone: '', address: '', contact: '', phone: '', pickupContact: '', pickupPhone: '', relabelContact: '', relabelPhone: '', deliveryMethod: '', relabelDeliveryMethod: '', trackingNo: '', reason: '', requirement: '', relabel: false, remark: '', labelDeadline: '', estimatedArrivalAt: '', estimatedShipAt: '', fbaShipmentNo: '', volumeCbm: 0.01 }) cargoRows.value = [] transferRelabelRows.value = [] @@ -490,7 +501,7 @@ function clearTransferRelabelRows() { function addSkuRelabelRow() { const source = cargoRows.value[Math.min(skuRelabelRows.value.length, Math.max(0, cargoRows.value.length - 1))] skuRelabelRows.value.push({ id: ++skuRelabelSequence, sourceRowId: source?.id || null, - cartonMark: '', originalSku: source?.sku || '', newSku: '', quantity: 1, + cartonMark: hideReturnSkuCartonMark.value ? source?.cartonMark || '' : '', originalSku: source?.sku || '', newSku: '', quantity: 1, fbaCartonMark: '', destinationWarehouseId: form.destination, remark: '' }) } function removeSkuRelabelRow(id: number) { skuRelabelRows.value = skuRelabelRows.value.filter((row) => row.id !== id) } @@ -532,6 +543,16 @@ function chooseCreateTotalFile(scope: 'CARTON_TOTAL' | 'SKU_TOTAL') { function buildSkuRelabelItems(): RelabelCreate['items'] { const groups = new Map() for (const row of skuRelabelRows.value) { + if (hideReturnSkuCartonMark.value) { + const source = cargoRows.value.find(item => item.id === row.sourceRowId) + const candidate = selectedReturnCandidates.value.find(item => String(item.returnCartonId) === source?.returnCartonId) + if (candidate?.skuItems?.some(item => item.skuCode === row.originalSku.trim())) row.cartonMark = source?.cartonMark || '' + else { + const matches = selectedReturnCandidates.value.filter(item => item.skuItems?.some(sku => sku.skuCode === row.originalSku.trim())) + if (matches.length !== 1) throw new Error(`请从上方库存明细选择 SKU ${row.originalSku} 的来源库存`) + row.cartonMark = matches[0].cartonMark || '' + } + } const cartonMark = row.cartonMark.trim().toUpperCase() if (!cartonMark) throw new Error('请填写每个箱子的我方箱唛') const rows = groups.get(cartonMark) || [] @@ -546,7 +567,20 @@ function buildSkuRelabelItems(): RelabelCreate['items'] { throw new Error('填写的箱唛数量超过当前入仓单可用库存箱数') } return [...groups.entries()].map(([_, rows], groupIndex) => { - const source = cargoRows.value.find((item) => item.id === rows[0].sourceRowId) || anchors[groupIndex] + const source = isReturnRelabelSource.value + ? cargoRows.value.find(item => item.cartonMark === rows[0].cartonMark.trim()) + : cargoRows.value.find((item) => item.id === rows[0].sourceRowId) || anchors[groupIndex] + if (isReturnRelabelSource.value && !source) throw new Error('请选择该退货单实际可用的来源箱号') + if (isReturnRelabelSource.value) { + const candidate = selectedReturnCandidates.value.find(item => String(item.returnCartonId) === source?.returnCartonId) + if (candidate?.sourceType === 'RETURN_PARCEL' && labelingMode.value !== 'sku') throw new Error('散件库存只能贴 SKU,请先合箱后申请箱唛或混贴') + const requested = new Map() + for (const row of rows) { + const quantity = (requested.get(row.originalSku) || 0) + row.quantity + requested.set(row.originalSku, quantity) + if (quantity > Number(candidate?.skuItems?.find(item => item.skuCode === row.originalSku)?.quantity || 0)) throw new Error(`SKU ${row.originalSku} 超过来源箱可用数量`) + } + } if (!source?.returnCartonId && (!source?.sourceInventoryBalanceId || !source.sourceInventoryPositionId || !source.sourceInboundOrderId)) { throw new Error('来源库存已变化,请重新选择有库存的历史入仓单') } @@ -949,48 +983,18 @@ function listRowActions(candidate: object): TableAction[] { if (canConfirmRelabel.value && props.service === 'labeling' && item.__status === 'CUSTOMER_CONFIRM_PENDING' && !item.__confirmedAt) actions.push({ key: 'confirm-relabel', label: '确认换标', run: () => confirmRelabelFromList(item['0']) }) return actions } -async function submitReturnRelabel() { - const items = returnRelabelSkuRows.value.filter(item => item.selected) - .map(item => ({ skuCode: item.skuCode, quantity: Number(item.quantity) })) - if (!selectedReturnOrderNo.value || !items.length) { ElMessage.warning('请至少选择一个需要贴标的 SKU'); return } - if (items.some(item => item.quantity < 1 || item.quantity > (returnRelabelSkuRows.value.find(row => row.skuCode === item.skuCode)?.availableQuantity || 0))) { - ElMessage.warning('本次贴标数量必须大于 0,且不能超过该 SKU 的库存总数'); return - } - const destination = fbaWarehouses.value.find(warehouse => warehouse.warehouseCode === form.destination) - if (!destination) { ElMessage.warning('请选择 FBA 目的仓'); return } - const selectedFile = cartonTotalFile.value || skuTotalFile.value - const fileScope = cartonTotalFile.value ? 'CARTON_TOTAL' as const : 'SKU_TOTAL' as const - if (!selectedFile) { ElMessage.warning('请上传箱唛总文件或 SKU 总文件(二选一)'); return } - try { await ElMessageBox.confirm(`确认提交退货单 ${selectedReturnOrderNo.value} 的贴标申请吗?`, '提交确认', { confirmButtonText: '提交', cancelButtonText: '取消' }) } catch { return } - saving.value = true - try { - const created = await createReturnRelabel(selectedReturnOrderNo.value, { - items, - fileScope, - destinationFbaWarehouseId: String(destination.id), - destinationFbaWarehouseCode: destination.warehouseCode, - cartonRemark: form.remark.trim() || undefined, - idempotencyKey: createIdempotencyKey(), - }) - await uploadReturnRelabelFile(created.relabelNo, fileScope, selectedFile) - ElMessage.success(`贴标申请 ${created.relabelNo} 已提交,等待审核`) - await router.push({ name: 'overseas-labeling' }) - } catch (error) { ElMessage.error(error instanceof Error ? error.message : '退货贴标申请提交失败,请重试') } - finally { saving.value = false } -} watch([page, pageSize], ([nextPage, nextSize], [previousPage, previousSize]) => { if (nextSize !== previousSize && nextPage !== 1) { page.value = 1; return } void load() }) async function submit(action: 'draft' | 'submit' = 'submit') { - if (isReturnRelabelSource.value) { await submitReturnRelabel(); return } const primaryWarehouse = props.service === 'relabel' ? form.sourceWarehouse : form.warehouse const relabelRows = labelingMode.value === 'carton' ? cartonDetailRows.value : skuRelabelRows.value const invalidCargo = props.service === 'disposal' ? !selectedDiscardOrderId.value || cargoRows.value.every((item) => item.quantity < 1) || cargoRows.value.some((item) => item.quantity < 0 || item.quantity > Number(item.availableQuantity || 0)) : (props.service === 'labeling' || props.service === 'relabel') && (relabelRows.length === 0 || (props.service === 'labeling' && labelingMode.value !== 'carton' - ? skuRelabelRows.value.some((item) => !item.cartonMark || !item.originalSku || !item.newSku || item.quantity < 1 + ? skuRelabelRows.value.some((item) => (!hideReturnSkuCartonMark.value && !item.cartonMark) || !item.originalSku || !item.newSku || item.quantity < 1 || (labelingMode.value === 'carton_and_sku' && !item.fbaCartonMark)) : cargoRows.value.some((item) => (props.service !== 'labeling' && !item.sku) || !item.name || (props.service !== 'labeling' && item.quantity < 1)))) @@ -1019,7 +1023,7 @@ async function submit(action: 'draft' | 'submit' = 'submit') { if (props.service === 'labeling' && labelingMode.value === 'carton' && cartonDetailRows.value.some((item) => !item.shipmentNo || !item.cartonMark || !item.fbaCartonMark)) { ElMessage.warning('请上传关联面单,确保每箱的我方箱唛和 FBA 箱唛完整'); return } - if (props.service === 'labeling' && labelingMode.value === 'carton' && cartonDetailRows.value.some((item) => !item.sourceInventoryPositionId)) { + if (props.service === 'labeling' && labelingMode.value === 'carton' && cartonDetailRows.value.some((item) => !item.returnCartonId && !item.sourceInventoryPositionId)) { ElMessage.warning('请选择有库存的历史入仓单'); return } if (props.service === 'disposal' && !form.reason) { @@ -1061,6 +1065,8 @@ async function submit(action: 'draft' | 'submit' = 'submit') { const items = labelingMode.value === 'carton' ? cartonDetailRows.value.map((item) => ({ sourceInventoryBalanceId: item.sourceInventoryBalanceId, sourceInventoryPositionId: item.sourceInventoryPositionId, sourceInboundOrderId: item.sourceInboundOrderId, returnCartonId: item.returnCartonId, skuCode: item.sku, productNameZh: item.name, totalPackageCount: 1, notifiedPackageCount: 1, shipmentNo: item.shipmentNo, trackingCode: form.trackingNo || undefined, newSkuCode: undefined, labelRequirement: form.requirement || undefined, cartonMark: item.cartonMark, fbaCartonMark: item.fbaCartonMark, destinationFbaWarehouseCode: form.destination, operationType: 'CARTON_LABEL' as const, skuItems: [], remark: item.remark || undefined })) : buildSkuRelabelItems() + const selectedOperationType = labelingMode.value === 'carton' ? 'CARTON_LABEL' : labelingMode.value === 'sku' ? 'SKU_LABEL' : 'CARTON_AND_SKU' + if (items.some(item => item.operationType !== selectedOperationType)) throw new Error('一张申请只能选择一种换标类型,请核对绑定明细') const created = await createRelabel({ customerOrderNo: sourceInboundOrders || '贴标申请', operationWarehouseId: form.warehouse, labelDeadline: form.labelDeadline, contactName: form.contact, contactPhone: form.phone, remark: form.remark || undefined, idempotencyKey, items, deliveryMethod: labelingMode.value === 'sku' ? undefined : form.deliveryMethod as 'TRUCK' | 'EXPRESS' | 'TRUCK_PICKUP' | 'EXPRESS_PICKUP', printShippingLabel: labelingMode.value !== 'sku' && (form.deliveryMethod === 'EXPRESS' || form.deliveryMethod === 'EXPRESS_PICKUP') && form.relabel }) @@ -1399,34 +1405,17 @@ function selectRelabelInventory(inboundOrderId: string) { const rows: CargoRow[] = [] const selectedCandidates = relabelCandidates.value.filter((item) => { const returnOrderNo = item.inboundOrderNo || String(item.returnCartonId || '') - return (item.sourceType === 'RETURN_CARTON' ? `RETURN_ORDER:${returnOrderNo}` : String(item.inboundOrderId)) === inboundOrderId + return (item.returnCartonId ? `RETURN_ORDER:${returnOrderNo}` : String(item.inboundOrderId)) === inboundOrderId }) - returnRelabelSkuRows.value = [] if (inboundOrderId.startsWith('RETURN_ORDER:')) { - const grouped = new Map() - for (const candidate of selectedCandidates) { - const skuItems = candidate.skuItems?.length - ? candidate.skuItems - : candidate.skuCode ? [{ skuCode: candidate.skuCode, quantity: inventoryQuantity(candidate.availablePackages) }] : [] - for (const item of skuItems) { - const skuCode = item.skuCode.trim() - if (!skuCode) continue - const key = skuCode.toUpperCase() - const current = grouped.get(key) - const quantity = inventoryQuantity(item.quantity) - if (current) current.availableQuantity += quantity - else grouped.set(key, { - skuCode, - productNameZh: candidate.skuCode?.trim().toUpperCase() === key ? (candidate.productNameZh || '') : '', - availableQuantity: quantity, - selected: false, - // 可贴库存和本次申请数量是两个概念;重复换标时默认不应自动带入上次/首个箱子的数量。 - quantity: 0, - }) - } - } - returnRelabelSkuRows.value = [...grouped.values()] labelingMode.value = 'sku' + for (const candidate of selectedCandidates) rows.push({ + id: ++cargoSequence, sku: candidate.skuItems?.[0]?.skuCode || candidate.skuCode || '', + name: candidate.productNameZh || '退货库存', included: false, quantity: 1, availableQuantity: 1, + returnCartonId: String(candidate.returnCartonId), sourceInboundOrderNo: candidate.inboundOrderNo, + fnsku: '', shipmentNo: candidate.inboundOrderNo || '', newSkuCode: '', shippingMark: '', customerCartonMarks: '', + cartonMark: candidate.cartonMark || '', fbaCartonMark: '', destinationWarehouseId: '', remark: '', + }) } else for (const candidate of selectedCandidates) { for (const position of candidate.positions) rows.push(...Array.from({ length: Math.min(inventoryQuantity(position.availablePackages), 500) }, () => ({ id: ++cargoSequence, sku: candidate.skuCode || '', name: candidate.productNameZh || '库存货物', @@ -1440,7 +1429,7 @@ function selectRelabelInventory(inboundOrderId: string) { }))) } cargoRows.value = rows.slice(0, 500) - if (cargoRows.value.length) cargoRows.value[0].included = true + if (cargoRows.value.length && !inboundOrderId.startsWith('RETURN_ORDER:')) cargoRows.value[0].included = true skuRelabelRows.value = [] importedRelabelBindings.value = [] shippingLabelFile.value = null @@ -1448,6 +1437,22 @@ function selectRelabelInventory(inboundOrderId: string) { skuTotalFile.value = null if (!isReturnRelabelSource.value && labelingMode.value !== 'carton') addSkuRelabelRow() } +function addReturnSkuBinding(source: RelabelInventoryCandidate, skuCode: string) { + const sku = source.skuItems?.find(item => item.skuCode === skuCode) + if (!sku) return + const row = skuRelabelRows.value.find(item => item.cartonMark === source.cartonMark && item.originalSku === skuCode) + if (row) return + const sourceRow = cargoRows.value.find(item => item.returnCartonId === String(source.returnCartonId)) + skuRelabelRows.value.push({ id: ++skuRelabelSequence, sourceRowId: sourceRow?.id || null, cartonMark: source.cartonMark || '', originalSku: skuCode, newSku: '', quantity: Math.max(1, Number(sku.quantity)), fbaCartonMark: '', destinationWarehouseId: '', remark: '' }) +} +function addReturnBoxBinding(source: RelabelInventoryCandidate) { + if (labelingMode.value !== 'carton') { + for (const sku of source.skuItems || []) addReturnSkuBinding(source, sku.skuCode) + return + } + if (cartonDetailRows.value.some(item => item.cartonMark === source.cartonMark)) return + cargoRows.value.push({ id: ++cargoSequence, included: true, sku: source.skuItems?.map(item => item.skuCode).join(',') || '', name: source.productNameZh || '退货箱', shipmentNo: selectedReturnOrderNo.value, cartonMark: source.cartonMark || '', fbaCartonMark: '', remark: '', sourceInventoryBalanceId: undefined, sourceInventoryPositionId: undefined, sourceInboundOrderId: undefined, returnCartonId: String(source.returnCartonId), quantity: 1, fnsku: '', newSkuCode: '', shippingMark: '', customerCartonMarks: '', destinationWarehouseId: '' }) +}