feat(procurement): show customer sample fulfillment and charges

This commit is contained in:
2026-10-10 02:19:39 +08:00
parent c0b151bc15
commit d7f8365ecc
13 changed files with 759 additions and 187 deletions
+2 -1
View File
@@ -54,7 +54,8 @@
| 统一订单展示 | `src/modules/order-management/` | `src/modules/first-leg/` 与对应后端订单契约 | | 统一订单展示 | `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/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/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/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 | | 海外仓贴标、仓储转运、货物废弃 | `src/modules/overseas-warehouse/routes.ts`、`views/WarehouseServiceView.vue`、`api/warehouseServices.ts` | 三个路由均为 `active`;退货预报另见本节独立入口;贴标申请先选国外中转仓和有库存历史入仓单,换标类型为只换 SKU、只换箱唛、SKU+箱唛。涉及箱唛换标时,整张贴标申请只选择一个 FBA 目的仓,全部箱级明细统一提交该目的仓,模板和明细不再逐行选择目的仓。箱唛换标明细一行固定代表一箱,箱数不可编辑,提交也固定为 1。货物明细外层始终提供“SKU 关联”“箱号关联”“SKU 混箱换标”三套模板的下载、上传,不随换标类型切换;上传只进入待导入关系区,客户可勾选部分或全选后导入本次明细,只有进入明细的库存才随申请提交和冻结。明细也支持手动新增编辑,组合换标以仓库唛头连接旧/新 SKU 和新 FBA 箱唛关系。涉及箱唛换标时必须选择卡车、自提或快递;快递可选择打印面单并上传 PDF/图片。PDA 逐箱换标、上传照片并由仓库复核后,客户详情展示完工照片和“换标完成确认”;客户确认前不得继续出库,确认后卡车进入预排车,自提/快递进入换标出库待创建。历史库存位置不能解释成箱唛与具体托盘快照,客户页面不暴露托盘、库位和内部库存 ID |
+1
View File
@@ -42,6 +42,7 @@ declare module 'vue' {
ElRadio: typeof import('element-plus/es')['ElRadio'] ElRadio: typeof import('element-plus/es')['ElRadio']
ElRadioButton: typeof import('element-plus/es')['ElRadioButton'] ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup'] ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElResult: typeof import('element-plus/es')['ElResult']
ElSelect: typeof import('element-plus/es')['ElSelect'] ElSelect: typeof import('element-plus/es')['ElSelect']
ElSkeleton: typeof import('element-plus/es')['ElSkeleton'] ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
ElSwitch: typeof import('element-plus/es')['ElSwitch'] ElSwitch: typeof import('element-plus/es')['ElSwitch']
+1 -56
View File
@@ -8,7 +8,7 @@ export interface PageResult<T> {
pages: number pages: number
} }
export type CrossBorderBusinessKind = 'procurement' | 'trucking' | 'looseboard' export type CrossBorderBusinessKind = 'trucking' | 'looseboard'
export interface BusinessOrderQuery { export interface BusinessOrderQuery {
page: number page: number
@@ -16,8 +16,6 @@ export interface BusinessOrderQuery {
orderNo?: string orderNo?: string
status?: string status?: string
routeKeyword?: string routeKeyword?: string
productName?: string
purchaseCategory?: string
transportType?: string transportType?: string
transportMode?: string transportMode?: string
requestedFrom?: string requestedFrom?: string
@@ -26,54 +24,6 @@ export interface BusinessOrderQuery {
createdTo?: string 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 { export interface TransportSummary {
fleetArrangement: import('../fleetArrangement').FleetArrangement | null fleetArrangement: import('../fleetArrangement').FleetArrangement | null
orderNo: string orderNo: string
@@ -132,7 +82,6 @@ export interface TransportUpdate extends Omit<TransportCreate, 'idempotencyKey'>
} }
const endpointByKind: Record<CrossBorderBusinessKind, string> = { const endpointByKind: Record<CrossBorderBusinessKind, string> = {
procurement: '/merchant/v1/last-leg/overseas-procurements',
trucking: '/merchant/v1/last-leg/us-truck-orders', trucking: '/merchant/v1/last-leg/us-truck-orders',
looseboard: '/merchant/v1/last-leg/local-looseboard-orders', looseboard: '/merchant/v1/last-leg/local-looseboard-orders',
} }
@@ -149,10 +98,6 @@ export function getBusinessOrder<T>(kind: CrossBorderBusinessKind, orderNo: stri
return apiRequest<T>({ method: 'GET', url: `${endpointByKind[kind]}/${encodeURIComponent(orderNo)}` }) return apiRequest<T>({ method: 'GET', url: `${endpointByKind[kind]}/${encodeURIComponent(orderNo)}` })
} }
export function createProcurement(payload: ProcurementCreate) {
return apiRequest<ProcurementDetail>({ method: 'POST', url: endpointByKind.procurement, data: payload })
}
export function createTransport(kind: 'trucking' | 'looseboard', payload: TransportCreate) { export function createTransport(kind: 'trucking' | 'looseboard', payload: TransportCreate) {
return apiRequest<TransportDetail>({ method: 'POST', url: endpointByKind[kind], data: payload }) return apiRequest<TransportDetail>({ method: 'POST', url: endpointByKind[kind], data: payload })
} }
+108
View File
@@ -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<PageResult<ProcurementSummary>>({ 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<ProcurementDetail>({ method: 'GET', url: `${endpoint}/${encodeURIComponent(orderNo)}` })
}
export function createProcurement(data: ProcurementCreate) {
return apiRequest<ProcurementDetail>({ method: 'POST', url: endpoint, data })
}
@@ -7,6 +7,8 @@ import ts from 'typescript'
const api = readFileSync(new URL('./api/businessOrders.ts', import.meta.url), 'utf8') const api = readFileSync(new URL('./api/businessOrders.ts', import.meta.url), 'utf8')
const routes = readFileSync(new URL('./routes.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 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', () => { 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(')) 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.match(routes, new RegExp(`path: '${path}'[\\s\\S]*?release: 'active'[\\s\\S]*?permission: '${permission}'`))
} }
assert.doesNotMatch(view, /PrototypeFeatureView|mock|customerId|tenantId/i) 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:domestic-trucking:create/)
assert.match(view, /merchant:cross-border:pallet-shipping:create/) assert.match(view, /merchant:cross-border:pallet-shipping:create/)
assert.match(view, /v-if="canCreate"[^>]*type="primary"/) assert.match(view, /v-if="canCreate"[^>]*type="primary"/)
}) })
test('uses only merchant overseas endpoints and keeps retry idempotency stable', () => { 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, /us-truck-orders/)
assert.match(api, /local-looseboard-orders/) assert.match(api, /local-looseboard-orders/)
assert.doesNotMatch(api, /\/platform\/|\/internal\/|customerId|tenantId/) 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, /<DataTable[^>]*:row-actions="getRowActions"/) assert.match(view, /<DataTable[^>]*:row-actions="getRowActions"/)
assert.match(view, /key: 'actions', label: '操作', width: 90, fixed: 'left'/) assert.match(view, /key: 'actions', label: '操作', width: 90, fixed: 'left'/)
assert.match(view, /class="business-table-card"/) assert.match(view, /class="business-table-card"/)
assert.match(view, /PENDING_OUTBOUND: '待出库'/) assert.doesNotMatch(procurementView, /inventoryQuantity|outboundQuantity|reviewRemark/)
assert.match(view, /PARTIALLY_OUTBOUND: '部分出库'/) assert.match(procurementView, /procurementStatuses/)
assert.match(view, /OUTBOUNDED: '全部出库'/) assert.match(procurementView, /ProcurementDetailPanel/)
assert.match(view, /\['入库量', read\(summary, 'inboundQuantity'\)\]/)
assert.match(view, /\['审核备注', current\.reviewRemark\]/)
}) })
test('opens every dashboard cross-border action on a dedicated protected create page', () => { 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"/) 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, /@media \(max-width: 767px\)/)
assert.match(view, /queryFields/) assert.match(view, /queryFields/)
assert.match(view, /\.detail-grid \{ grid-template-columns: 1fr; \}/) assert.match(view, /\.detail-grid \{ grid-template-columns: 1fr; \}/)
@@ -7,13 +7,10 @@ import QueryToolbar, { type QueryField, type QueryModel } from '@/components/com
import type { TableAction } from '@/components/common/tableActions' import type { TableAction } from '@/components/common/tableActions'
import { useAuthStore } from '@/modules/auth/public' import { useAuthStore } from '@/modules/auth/public'
import { import {
createProcurement,
createTransport, createTransport,
getBusinessOrder, getBusinessOrder,
listBusinessOrders, listBusinessOrders,
type CrossBorderBusinessKind, type CrossBorderBusinessKind,
type ProcurementDetail,
type ProcurementSummary,
type TransportDetail, type TransportDetail,
type TransportSummary, type TransportSummary,
} from '../api/businessOrders' } from '../api/businessOrders'
@@ -26,23 +23,17 @@ const router = useRouter()
const route = useRoute() const route = useRoute()
const detailOrderNo = computed(() => String(route.params.orderNo || '')) const detailOrderNo = computed(() => String(route.params.orderNo || ''))
const canCreate = computed(() => auth.hasPermission(({ const canCreate = computed(() => auth.hasPermission(({
procurement: 'merchant:cross-border:procurement:create',
trucking: 'merchant:cross-border:domestic-trucking:create', trucking: 'merchant:cross-border:domestic-trucking:create',
looseboard: 'merchant:cross-border:pallet-shipping:create', looseboard: 'merchant:cross-border:pallet-shipping:create',
} as Record<CrossBorderBusinessKind, string>)[props.kind])) } as Record<CrossBorderBusinessKind, string>)[props.kind]))
type BusinessRow = ProcurementSummary | TransportSummary type BusinessRow = TransportSummary
type BusinessDetail = ProcurementDetail | TransportDetail type BusinessDetail = TransportDetail
type PageMode = 'list' | 'create' | 'detail' type PageMode = 'list' | 'create' | 'detail'
interface ColumnDefinition { key: string; label: string; width?: number } interface ColumnDefinition { key: string; label: string; width?: number }
interface BusinessForm { interface BusinessForm {
purchaseCategory: string
productName: string
productSpecification: string
productUrl: string
purchaseQuantity: number
transportMode: string transportMode: string
palletRequired: string palletRequired: string
transportType: string transportType: string
@@ -60,28 +51,10 @@ interface BusinessForm {
contactName: string contactName: string
contactInfo: string contactInfo: string
requestedDate: string requestedDate: string
receivingAddress: string
remark: string remark: string
} }
const configuration = computed(() => ({ 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: { trucking: {
title: '美国本土卡', title: '美国本土卡',
description: '查询并管理美国本土卡订单', description: '查询并管理美国本土卡订单',
@@ -117,23 +90,19 @@ const submitKey = ref('')
const error = ref('') const error = ref('')
const rows = ref<BusinessRow[]>([]) const rows = ref<BusinessRow[]>([])
const detail = ref<BusinessDetail | null>(null) const detail = ref<BusinessDetail | null>(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<FormInstance>() const formRef = ref<FormInstance>()
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 total = ref(0)
const queryModel = computed<QueryModel>({ const queryModel = computed<QueryModel>({
get: () => ({ get: () => ({
orderNo: query.orderNo, orderNo: query.orderNo,
purchaseCategory: query.purchaseCategory,
productName: query.productName,
routeKeyword: query.routeKeyword, routeKeyword: query.routeKeyword,
status: query.status, status: query.status,
}), }),
set: (value) => { set: (value) => {
query.orderNo = String(value.orderNo || '') query.orderNo = String(value.orderNo || '')
query.purchaseCategory = String(value.purchaseCategory || '')
query.productName = String(value.productName || '')
query.routeKeyword = String(value.routeKeyword || '') query.routeKeyword = String(value.routeKeyword || '')
query.status = String(value.status || '') query.status = String(value.status || '')
}, },
@@ -141,12 +110,7 @@ const queryModel = computed<QueryModel>({
const queryFields = computed<QueryField[]>(() => [ const queryFields = computed<QueryField[]>(() => [
{ key: 'orderNo', label: '订单号', type: 'input', placeholder: '请输入订单号' }, { key: 'orderNo', label: '订单号', type: 'input', placeholder: '请输入订单号' },
...(props.kind === 'procurement' { key: 'routeKeyword', label: '运输路线', type: 'input', placeholder: '请输入起运地或目的地' },
? [
{ 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: 'status', key: 'status',
label: '状态', label: '状态',
@@ -156,11 +120,8 @@ const queryFields = computed<QueryField[]>(() => [
{ label: '待审核', value: 'PENDING_REVIEW' }, { label: '待审核', value: 'PENDING_REVIEW' },
{ label: '已通过', value: 'APPROVED' }, { label: '已通过', value: 'APPROVED' },
{ label: '已拒绝', value: 'REJECTED' }, { label: '已拒绝', value: 'REJECTED' },
{ label: '待出库', value: 'PENDING_OUTBOUND' },
{ label: '部分出库', value: 'PARTIALLY_OUTBOUND' },
{ label: '全部出库', value: 'OUTBOUNDED' },
{ label: '待接单', value: 'PENDING_ACCEPTANCE' }, { 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: 'PROCESSING' },
{ label: '运输中', value: 'IN_TRANSIT' }, { label: '运输中', value: 'IN_TRANSIT' },
{ label: '已完成', value: 'COMPLETED' }, { label: '已完成', value: 'COMPLETED' },
@@ -171,9 +132,6 @@ const queryFields = computed<QueryField[]>(() => [
const form = reactive<BusinessForm>(emptyForm()) const form = reactive<BusinessForm>(emptyForm())
const rules: FormRules<BusinessForm> = { const rules: FormRules<BusinessForm> = {
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' }], origin: [{ required: true, message: '请输入起运地', trigger: 'blur' }],
destination: [{ required: true, message: '请输入目的地', trigger: 'blur' }], destination: [{ required: true, message: '请输入目的地', trigger: 'blur' }],
palletCount: [{ required: true, type: 'number', min: 1, message: '托盘数至少为 1', trigger: 'change' }], palletCount: [{ required: true, type: 'number', min: 1, message: '托盘数至少为 1', trigger: 'change' }],
@@ -185,21 +143,6 @@ const rules: FormRules<BusinessForm> = {
const detailFields = computed(() => { const detailFields = computed(() => {
if (!detail.value) return [] if (!detail.value) return []
const summary = detail.value.summary 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 const current = detail.value as TransportDetail
return [ return [
['运输方式', transportModeLabel(read(summary, 'transportMode'))], ['运输方式', transportModeLabel(read(summary, 'transportMode'))],
@@ -228,18 +171,16 @@ const detailStatus = computed(() => detail.value ? statusLabel(detail.value.summ
function emptyForm(): BusinessForm { function emptyForm(): BusinessForm {
return { return {
purchaseCategory: '', productName: '', productSpecification: '', productUrl: '', purchaseQuantity: 1,
transportMode: 'SHARED_TRUCK', palletRequired: 'NO', transportType: 'PORT_PICKUP', origin: '', originAddress: '', transportMode: 'SHARED_TRUCK', palletRequired: 'NO', transportType: 'PORT_PICKUP', origin: '', originAddress: '',
destination: '', destinationAddress: '', containerType: DEFAULT_CONTAINER_TYPE, palletCount: 1, packageCount: 0, destination: '', destinationAddress: '', containerType: DEFAULT_CONTAINER_TYPE, palletCount: 1, packageCount: 0,
totalWeightKg: 0, totalVolumeCbm: 0, currency: 'USD', contactName: '', contactInfo: '', requestedDate: '', totalWeightKg: 0, totalVolumeCbm: 0, currency: 'USD', contactName: '', contactInfo: '', requestedDate: '',
receivingAddress: '', remark: '', remark: '',
} }
} }
function resetForm() { Object.assign(form, emptyForm()); submitKey.value = ''; formRef.value?.clearValidate() } 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 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 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 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 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) } 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, string>)[String(value)] || text(value) } function transportModeLabel(value: unknown) { return ({ SHARED_TRUCK: '拼车', PRIORITY_CONSOLIDATION: '优先拼载' } as Record<string, string>)[String(value)] || text(value) }
function statusLabel(value: unknown) { function statusLabel(value: unknown) {
if (poolOrderStatusLabels[String(value)]) return poolOrderStatusLabels[String(value)] 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, string>)[String(value)] || text(value) return ({ PENDING_REVIEW: '待审核', APPROVED: '已通过', REJECTED: '已拒绝', PENDING_ACCEPTANCE: '待接单', PROCESSING: '处理中', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消' } as Record<string, string>)[String(value)] || text(value)
} }
function statusTone(value: unknown) { 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, 'success' | 'warning' | 'danger' | 'info' | 'primary'>)[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, 'success' | 'warning' | 'danger' | 'info' | 'primary'>)[String(value)] || 'info'
} }
function displayCell(candidate: unknown, key: string) { function displayCell(candidate: unknown, key: string) {
const row = candidate as BusinessRow const row = candidate as BusinessRow
@@ -260,7 +201,7 @@ function displayCell(candidate: unknown, key: string) {
if (key === 'transportMode') return transportModeLabel(value) if (key === 'transportMode') return transportModeLabel(value)
if (key === 'palletRequired') return yesNo(value) if (key === 'palletRequired') return yesNo(value)
if (key === 'createdAt') return dateTime(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) return text(value)
} }
function idempotencyKey() { return globalThis.crypto?.randomUUID?.() || `client-${Date.now()}-${Math.random().toString(16).slice(2)}` } function idempotencyKey() { return globalThis.crypto?.randomUUID?.() || `client-${Date.now()}-${Math.random().toString(16).slice(2)}` }
@@ -272,12 +213,9 @@ async function load() {
try { try {
const params = { const params = {
page: query.page, size: query.size, orderNo: query.orderNo || undefined, status: query.status || undefined, page: query.page, size: query.size, orderNo: query.orderNo || undefined, status: query.status || undefined,
routeKeyword: query.routeKeyword || undefined, productName: query.productName || undefined, routeKeyword: query.routeKeyword || undefined,
purchaseCategory: query.purchaseCategory || undefined,
} }
const result = props.kind === 'procurement' const result = await listBusinessOrders<TransportSummary>(props.kind, params)
? await listBusinessOrders<ProcurementSummary>('procurement', params)
: await listBusinessOrders<TransportSummary>(props.kind, params)
rows.value = result.records rows.value = result.records
total.value = result.total total.value = result.total
} catch (cause) { } catch (cause) {
@@ -289,7 +227,7 @@ async function load() {
function resetQuery() { function resetQuery() {
const reloadDirectly = query.page === 1 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() if (reloadDirectly) void load()
} }
@@ -303,16 +241,14 @@ function getRowActions(candidate: object): TableAction[] {
} }
function detailFieldClass(label: unknown) { function detailFieldClass(label: unknown) {
return ['收货地址', '商品链接', '国内运单', '海外运单', '审核备注', '备注', '起运地址', '目的地址'].includes(String(label)) ? 'is-wide' : '' return ['备注', '起运地址', '目的地址'].includes(String(label)) ? 'is-wide' : ''
} }
const listRouteName = computed(() => ({ const listRouteName = computed(() => ({
procurement: 'cross-border-procurement',
trucking: 'cross-border-domestic-trucking', trucking: 'cross-border-domestic-trucking',
looseboard: 'cross-border-pallet-shipping', looseboard: 'cross-border-pallet-shipping',
} as Record<CrossBorderBusinessKind, string>)[props.kind]) } as Record<CrossBorderBusinessKind, string>)[props.kind])
const detailRouteName = computed(() => ({ const detailRouteName = computed(() => ({
procurement: 'cross-border-procurement-detail',
trucking: 'cross-border-domestic-trucking-detail', trucking: 'cross-border-domestic-trucking-detail',
looseboard: 'cross-border-pallet-shipping-detail', looseboard: 'cross-border-pallet-shipping-detail',
} as Record<CrossBorderBusinessKind, string>)[props.kind]) } as Record<CrossBorderBusinessKind, string>)[props.kind])
@@ -334,9 +270,7 @@ async function loadDetail() {
loading.value = true loading.value = true
error.value = '' error.value = ''
try { try {
detail.value = props.kind === 'procurement' detail.value = await getBusinessOrder<TransportDetail>(props.kind, detailOrderNo.value)
? await getBusinessOrder<ProcurementDetail>('procurement', detailOrderNo.value)
: await getBusinessOrder<TransportDetail>(props.kind, detailOrderNo.value)
mode.value = 'detail' mode.value = 'detail'
} catch (cause) { detail.value = null; error.value = errorMessage(cause); ElMessage.error(error.value) } } catch (cause) { detail.value = null; error.value = errorMessage(cause); ElMessage.error(error.value) }
finally { loading.value = false } finally { loading.value = false }
@@ -348,31 +282,19 @@ async function submit() {
const stableSubmitKey = submitKey.value || idempotencyKey() const stableSubmitKey = submitKey.value || idempotencyKey()
submitKey.value = stableSubmitKey submitKey.value = stableSubmitKey
try { try {
if (props.kind === 'procurement') { const base = {
await createProcurement({ transportType: form.transportType, origin: form.origin, originAddress: form.originAddress || undefined,
customerName: auth.user?.tenantName || '', destination: form.destination, destinationAddress: form.destinationAddress || undefined,
purchaseCategory: form.purchaseCategory, productName: form.productName, containerType: form.containerType || undefined, packageCount: form.packageCount,
productSpecification: form.productSpecification || undefined, productUrl: form.productUrl || undefined, totalWeightKg: form.totalWeightKg, totalVolumeCbm: form.totalVolumeCbm,
purchaseQuantity: form.purchaseQuantity, budgetUnitPrice: form.budgetAmount, budgetAmount: form.budgetAmount, currency: form.currency || undefined,
currency: form.currency || undefined, receivingAddress: form.receivingAddress || undefined, contactName: form.contactName || undefined, contactInfo: form.contactInfo || undefined,
contactName: form.contactName, contactPhone: form.contactInfo, remark: form.remark || undefined, requestedDate: form.requestedDate || undefined, remark: form.remark || undefined,
idempotencyKey: stableSubmitKey, 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 })
} }
await createTransport(props.kind, props.kind === 'trucking'
? { ...base, palletCount: form.palletCount }
: { ...base, transportMode: form.transportMode, palletRequired: form.palletRequired })
ElMessage.success('订单提交成功') ElMessage.success('订单提交成功')
resetForm() resetForm()
if (props.createPage) await router.push({ name: listRouteName.value }) if (props.createPage) await router.push({ name: listRouteName.value })
@@ -442,15 +364,6 @@ onMounted(() => { if (props.detailPage) void loadDetail(); else if (!props.creat
<el-card v-else shadow="never" class="form-card"> <el-card v-else shadow="never" class="form-card">
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px"> <el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
<template v-if="kind === 'procurement'">
<el-form-item label="采购品类" prop="purchaseCategory"><el-input v-model.trim="form.purchaseCategory" maxlength="128" placeholder="请输入采购品类" /></el-form-item>
<el-form-item label="商品名称" prop="productName"><el-input v-model.trim="form.productName" maxlength="255" placeholder="请输入商品名称" /></el-form-item>
<el-form-item label="商品规格"><el-input v-model.trim="form.productSpecification" maxlength="500" placeholder="请输入规格、型号等信息" /></el-form-item>
<el-form-item label="商品链接"><el-input v-model.trim="form.productUrl" maxlength="2000" placeholder="请输入商品链接" /></el-form-item>
<el-form-item label="采购数量" prop="purchaseQuantity"><el-input-number v-model="form.purchaseQuantity" :min="1" placeholder="请输入数量" /></el-form-item>
<el-form-item label="收货地址"><el-input v-model.trim="form.receivingAddress" maxlength="1000" placeholder="请输入收货地址" /></el-form-item>
</template>
<template v-else>
<el-form-item v-if="kind === 'looseboard'" label="运输方式"><el-radio-group v-model="form.transportMode"><el-radio-button value="SHARED_TRUCK">拼车</el-radio-button><el-radio-button value="PRIORITY_CONSOLIDATION">优先拼载</el-radio-button></el-radio-group></el-form-item> <el-form-item v-if="kind === 'looseboard'" label="运输方式"><el-radio-group v-model="form.transportMode"><el-radio-button value="SHARED_TRUCK">拼车</el-radio-button><el-radio-button value="PRIORITY_CONSOLIDATION">优先拼载</el-radio-button></el-radio-group></el-form-item>
<el-form-item v-if="kind === 'looseboard'" label="是否打盘"><el-radio-group v-model="form.palletRequired"><el-radio-button value="NO">否</el-radio-button><el-radio-button value="YES">是</el-radio-button></el-radio-group></el-form-item> <el-form-item v-if="kind === 'looseboard'" label="是否打盘"><el-radio-group v-model="form.palletRequired"><el-radio-button value="NO">否</el-radio-button><el-radio-button value="YES">是</el-radio-button></el-radio-group></el-form-item>
<el-form-item label="运输类型"><el-select v-model="form.transportType"><el-option label="港口提柜" value="PORT_PICKUP" /><el-option label="仓到仓" value="WAREHOUSE_TO_WAREHOUSE" /><el-option label="仓到门" value="WAREHOUSE_TO_DOOR" /></el-select></el-form-item> <el-form-item label="运输类型"><el-select v-model="form.transportType"><el-option label="港口提柜" value="PORT_PICKUP" /><el-option label="仓到仓" value="WAREHOUSE_TO_WAREHOUSE" /><el-option label="仓到门" value="WAREHOUSE_TO_DOOR" /></el-select></el-form-item>
@@ -464,8 +377,7 @@ onMounted(() => { if (props.detailPage) void loadDetail(); else if (!props.creat
<el-form-item label="总重量(kg)"><el-input-number v-model="form.totalWeightKg" :min="0" :precision="2" placeholder="请输入总重量" /></el-form-item> <el-form-item label="总重量(kg)"><el-input-number v-model="form.totalWeightKg" :min="0" :precision="2" placeholder="请输入总重量" /></el-form-item>
<el-form-item label="总体积(m³)"><el-input-number v-model="form.totalVolumeCbm" :min="0" :precision="3" placeholder="请输入总体积" /></el-form-item> <el-form-item label="总体积(m³)"><el-input-number v-model="form.totalVolumeCbm" :min="0" :precision="3" placeholder="请输入总体积" /></el-form-item>
<el-form-item label="要求时间" :prop="kind === 'trucking' ? 'requestedDate' : undefined"><el-date-picker v-model="form.requestedDate" type="date" value-format="YYYY-MM-DD" /></el-form-item> <el-form-item label="要求时间" :prop="kind === 'trucking' ? 'requestedDate' : undefined"><el-date-picker v-model="form.requestedDate" type="date" value-format="YYYY-MM-DD" /></el-form-item>
</template> <el-form-item class="budget-form-item" label="预算金额"><el-input-number v-model="form.budgetAmount" :min="0" :precision="2" placeholder="请输入预算金额" /><el-select v-model="form.currency" class="currency"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /></el-select></el-form-item>
<el-form-item class="budget-form-item" :label="kind === 'procurement' ? '预算单价' : '预算金额'"><el-input-number v-model="form.budgetAmount" :min="0" :precision="2" placeholder="请输入预算金额" /><el-select v-model="form.currency" class="currency"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /></el-select></el-form-item>
<el-form-item label="联系人" prop="contactName"><el-input v-model.trim="form.contactName" maxlength="128" placeholder="请输入联系人" /></el-form-item> <el-form-item label="联系人" prop="contactName"><el-input v-model.trim="form.contactName" maxlength="128" placeholder="请输入联系人" /></el-form-item>
<el-form-item label="联系方式" prop="contactInfo"><el-input v-model.trim="form.contactInfo" maxlength="128" placeholder="请输入电话、微信或邮箱" /></el-form-item> <el-form-item label="联系方式" prop="contactInfo"><el-input v-model.trim="form.contactInfo" maxlength="128" placeholder="请输入电话、微信或邮箱" /></el-form-item>
<el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="4" maxlength="1000" show-word-limit placeholder="请输入其他要求" /></el-form-item> <el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="4" maxlength="1000" show-word-limit placeholder="请输入其他要求" /></el-form-item>
@@ -0,0 +1,106 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { ProcurementDetail, ProcurementFee } from '../api/procurement'
import { dateTime, feePurposes, feeStatus, feeTone, feeTotals, measurement, money, productHref, text } from '../procurementPresentation'
const props = defineProps<{ detail: ProcurementDetail }>()
const totals = computed(() => feeTotals(props.detail.fees))
const progress = computed(() => props.detail.progress.filter(step => step.key !== 'closed_at' || step.completedAt))
const ended = computed(() => ['REJECTED', 'CANCELLED', 'EXCEPTION_CLOSED', 'SHIPPED_TO_CUSTOMER'].includes(props.detail.order.status))
const hasPending = computed(() => props.detail.fees.some(f => f.walletStatus?.endsWith('_PENDING') || f.walletStatus?.endsWith('_FAILED')))
</script>
<template>
<section class="procurement-section" aria-label="采购履约进度">
<h3>履约进度</h3>
<ol class="procurement-progress">
<li v-for="step in progress" :key="step.key" :class="{ 'is-complete': step.completedAt }">
<strong>{{ step.title }}</strong><span>{{ step.completedAt ? dateTime(step.completedAt) : ended ? '未执行' : '待处理' }}</span>
</li>
</ol>
<p v-if="detail.purchaseNeedsAttention" class="procurement-note">购买资料正在核对,客服将协助处理。已发生的收货与费用结果仍保留在本单。</p>
</section>
<section class="procurement-section" aria-label="商品与收件信息">
<h3>商品与收件信息</h3>
<dl class="procurement-grid">
<div><dt>采购品类</dt><dd>{{ detail.order.purchaseCategory }}</dd></div>
<div><dt>商品名称</dt><dd>{{ detail.order.productName }}</dd></div>
<div><dt>商品规格</dt><dd>{{ text(detail.order.productSpecification) }}</dd></div>
<div><dt>采购数量</dt><dd>{{ detail.order.purchaseQuantity }} 件</dd></div>
<div><dt>下单预算单价</dt><dd>{{ money(detail.order.budgetUnitPrice, detail.order.currency) }}</dd></div>
<div><dt>货款预算总上限</dt><dd>{{ money(detail.budget.totalAmount, detail.budget.currency) }}</dd></div>
<div><dt>预算核验时间</dt><dd>{{ dateTime(detail.budget.authorizedAt) }}</dd></div>
<div><dt>预计到海外仓</dt><dd>{{ dateTime(detail.order.estimatedArrivalAt) }}</dd></div>
<div><dt>收件人</dt><dd>{{ detail.order.contactName }}</dd></div>
<div><dt>联系电话</dt><dd>{{ detail.order.contactPhone }}</dd></div>
<div class="wide"><dt>下单收货地址</dt><dd>{{ detail.order.receivingAddress }}</dd></div>
<div class="wide"><dt>实际海外仓</dt><dd>{{ text(detail.order.actualWarehouseName) }}<span v-if="detail.order.actualWarehouseCode">({{ detail.order.actualWarehouseCode }})</span></dd></div>
<div class="wide"><dt>商品链接</dt><dd><a v-if="productHref(detail.order.productUrl)" :href="productHref(detail.order.productUrl)" target="_blank" rel="noopener noreferrer">{{ detail.order.productUrl }}</a><span v-else>{{ text(detail.order.productUrl) }}</span></dd></div>
</dl>
</section>
<section class="procurement-section" aria-label="已确认海外装箱清单">
<h3>已确认海外装箱清单</h3>
<el-table :data="detail.boxes" border stripe empty-text="尚无已确认的装箱清单" row-key="cartonNo">
<el-table-column prop="cartonNo" label="箱号" min-width="170" />
<el-table-column label="箱内商品" min-width="200"><template #default>{{ detail.order.productName }}</template></el-table-column>
<el-table-column label="规格" min-width="180"><template #default>{{ text(detail.order.productSpecification) }}</template></el-table-column>
<el-table-column prop="quantity" label="数量(件)" width="120" />
<el-table-column label="外箱长 × 宽 × 高(cm)" min-width="240"><template #default="{ row }">{{ measurement(row.lengthCm, 2) }} × {{ measurement(row.widthCm, 2) }} × {{ measurement(row.heightCm, 2) }}</template></el-table-column>
<el-table-column label="封箱毛重(kg)" width="170"><template #default="{ row }">{{ measurement(row.grossWeightKg, 3) }}</template></el-table-column>
</el-table>
</section>
<section class="procurement-section" aria-label="两段实际运输">
<h3>两段实际运输</h3>
<el-table :data="detail.shipments" border stripe empty-text="尚无实际交寄记录">
<el-table-column label="运输段" width="150"><template #default="{ row }">{{ row.leg === 'OVERSEAS' ? '国际段 · 海外交寄' : '国内段 · 寄客户' }}</template></el-table-column>
<el-table-column prop="shipmentNo" label="交寄编号" min-width="270" />
<el-table-column prop="cartonNo" label="原箱号" min-width="150" />
<el-table-column prop="quantity" label="实际件数" width="110" />
<el-table-column prop="carrier" label="承运商" min-width="140" />
<el-table-column prop="trackingNo" label="运单号" min-width="230" />
<el-table-column label="实际交寄时间" width="185"><template #default="{ row }">{{ dateTime(row.shippedAt) }}</template></el-table-column>
</el-table>
<p class="procurement-note">这里显示已实际交给承运方的记录;国内寄客户后,本单履约完成。</p>
</section>
<section class="procurement-section" aria-label="采购费用与资金结果">
<h3>费用与资金结果</h3>
<dl class="procurement-grid money-grid">
<div><dt>累计成功扣款</dt><dd>{{ money(totals?.paid) }}</dd></div>
<div><dt>累计成功退款</dt><dd>{{ money(totals?.refunded) }}</dd></div>
<div><dt>净实扣</dt><dd>{{ money(totals?.net) }}</dd></div>
<div><dt>预估国际运费占款</dt><dd>{{ money(totals?.estimatedHold) }}</dd></div>
</dl>
<p class="procurement-note">预算上限仅包含样品货款。预估国际运费只冻结、不直接扣款,最终以实际费用结算;表内保留原费用、追加及退款记录。</p>
<el-table :data="detail.fees" border stripe empty-text="尚无已核定费用" row-key="feeNo">
<el-table-column prop="feeNo" label="费用编号" min-width="270" />
<el-table-column label="费用用途" min-width="145"><template #default="{ row }">{{ feePurposes[row.purpose] || '待核定' }}</template></el-table-column>
<el-table-column prop="name" label="费用名称" min-width="145" />
<el-table-column label="原币金额" min-width="130"><template #default="{ row }">{{ money(row.originalAmount, row.originalCurrency) }}</template></el-table-column>
<el-table-column label="汇率 / 月份" min-width="150"><template #default="{ row }">{{ measurement(row.exchangeRate, 6) }}<br /><small>{{ text(row.exchangeRateMonth) }}</small></template></el-table-column>
<el-table-column label="人民币金额" min-width="130"><template #default="{ row }">{{ money(row.amount) }}</template></el-table-column>
<el-table-column label="处理结果" min-width="190"><template #default="{ row }"><el-tag :type="feeTone(row as ProcurementFee)" size="small">{{ feeStatus(row as ProcurementFee) }}</el-tag><small v-if="row.purpose !== 'INTERNATIONAL_ESTIMATE' && row.walletStatus === 'SETTLED' && !row.sourceLinked" class="fee-sync">费用结果同步中</small></template></el-table-column>
<el-table-column prop="parentFeeNo" label="关联原费用 / 预估" min-width="270"><template #default="{ row }">{{ text(row.parentFeeNo) }}</template></el-table-column>
</el-table>
<p v-if="hasPending" class="procurement-note">部分资金处理尚未完成,可点击页面“刷新”查看最新结果;如需协助请联系客户服务。</p>
</section>
<section v-if="detail.exceptionSummaries.length" class="procurement-section" aria-label="客服处理说明">
<h3>客服处理说明</h3><ul class="procurement-summaries"><li v-for="(summary, index) in detail.exceptionSummaries" :key="index">{{ summary }}</li></ul>
</section>
</template>
<style scoped>
.procurement-section { margin-top: 18px; min-width: 0; }
h3 { margin: 0 0 10px; padding-bottom: 7px; border-bottom: 1px solid var(--border); color: var(--navy); font-size: 14px; }
.procurement-progress { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0; padding: 0; margin: 0; list-style: none; border-top: 1px solid var(--border); border-left: 1px solid var(--border); }
.procurement-progress li { display: grid; gap: 7px; padding: 12px; border-bottom: 1px solid var(--border); border-right: 1px solid var(--border); color: var(--muted); }
.procurement-progress strong { font-size: 13px; font-weight: 600; }.procurement-progress span { font-size: 12px; }.procurement-progress .is-complete strong { color: var(--navy); }
.procurement-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 0; border-left: 1px solid var(--border); border-top: 1px solid var(--border); }
.procurement-grid > div { display: grid; grid-template-columns: 110px minmax(0, 1fr); align-items: center; gap: 8px; padding: 9px 12px; border-right: 1px solid var(--border); border-bottom: 1px solid var(--border); min-width: 0; }
.procurement-grid dt { font-size: 12px; color: var(--muted); }.procurement-grid dd { margin: 0; font-size: 13px; line-height: 1.6; color: var(--navy); overflow-wrap: anywhere; }
.procurement-grid .wide { grid-column: span 2; }.money-grid > div { grid-template-columns: minmax(0, 1fr); }.money-grid dd { font-size: 16px; font-weight: 600; }
a { color: var(--client-action); } .procurement-note { margin: 10px 0; color: var(--muted); font-size: 12px; line-height: 1.7; }
.fee-sync { display: block; padding-top: 5px; color: var(--muted); }.procurement-summaries { margin: 0; padding-left: 22px; line-height: 1.8; color: var(--navy); font-size: 13px; white-space: pre-wrap; }
@media (max-width: 767px) {
.procurement-progress, .procurement-grid { grid-template-columns: 1fr; }.procurement-grid .wide { grid-column: auto; }
}
</style>
@@ -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() }
})
@@ -0,0 +1,123 @@
import type { Amount, ProcurementCreate, ProcurementFee } from './api/procurement'
export const procurementStatuses: Record<string, string> = {
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<string, string> = {
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<string, string> = { FROZEN: '已冻结,未扣款', ZERO: '零预估已确认', SETTLED: '原预估已结清', RELEASED: '预估已释放' }
if (fee.walletStatus && label[fee.walletStatus]) return label[fee.walletStatus]
}
const labels: Record<string, string> = {
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 }
}
@@ -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<string, unknown>
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<Crypto, 'getRandomValues'> & Partial<Pick<Crypto, 'randomUUID'>> = 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('')}`
}
@@ -1,6 +1,187 @@
<script setup lang="ts"> <script setup lang="ts">
import BusinessOrdersView from '../components/BusinessOrdersView.vue' import { computed, onBeforeUnmount, reactive, ref, shallowRef, watch } from 'vue'
defineProps<{ createPage?: boolean; detailPage?: boolean }>() import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ApiError } from '@/api/types'
import { accessTokenIdentity } from '@/api/session'
import { useAuthStore } from '@/modules/auth/public'
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue'
import type { TableAction } from '@/components/common/tableActions'
import ProcurementDetailPanel from '../components/ProcurementDetailPanel.vue'
import { createProcurement, getProcurement, listProcurements, type ProcurementCreate, type ProcurementDetail, type ProcurementSummary } from '../api/procurement'
import { buildProcurementCreate, dateTime, draftBudgetTotal, emptyProcurementDraft, money, procurementStatuses, statusLabel, statusTone } from '../procurementPresentation'
import { definitelyRejected, procurementRequestKey, restoreProcurementSubmission } from '../procurementSubmission'
const props = withDefaults(defineProps<{ createPage?: boolean; detailPage?: boolean }>(), { createPage: false, detailPage: false })
const auth = useAuthStore(), route = useRoute(), router = useRouter()
const mode = computed(() => props.detailPage ? 'detail' : props.createPage ? 'create' : 'list')
const orderNo = computed(() => typeof route.params.orderNo === 'string' ? route.params.orderNo : '')
const canCreate = computed(() => auth.hasPermission('merchant:cross-border:procurement:create'))
const canList = computed(() => auth.hasPermission('merchant:cross-border:procurement:list'))
const identity = computed(() => auth.token ? accessTokenIdentity(auth.token) : null)
const storageKey = computed(() => identity.value ? `cc-procurement-submit:${encodeURIComponent(identity.value)}` : null)
const companyName = computed(() => auth.profile.tenantName || (auth.user?.tenantName && !/^企业租户 \d+$/.test(auth.user.tenantName) ? auth.user.tenantName : ''))
const form = reactive(emptyProcurementDraft())
const pending = shallowRef<ProcurementCreate | null>(null)
const recoveryError = ref(''), submitError = ref(''), submittedOrder = ref('')
const submitting = ref(false), loading = ref(false), error = ref('')
const detail = shallowRef<ProcurementDetail | null>(null)
const rows = ref<ProcurementSummary[]>([]), total = ref(0)
const query = reactive({ page: 1, size: 20, orderNo: '', purchaseCategory: '', productName: '', status: '' })
let requestSequence = 0, active = true, inFlight = ''
const queryModel = computed<QueryModel>({
get: () => ({ orderNo: query.orderNo, purchaseCategory: query.purchaseCategory, productName: query.productName, status: query.status }),
set: value => { for (const key of ['orderNo', 'purchaseCategory', 'productName', 'status'] as const) query[key] = String(value[key] || '') },
})
const queryFields: QueryField[] = [
{ key: 'orderNo', label: '采购单号', type: 'input' }, { key: 'purchaseCategory', label: '采购品类', type: 'input' },
{ key: 'productName', label: '商品名称', type: 'input' },
{ key: 'status', label: '履约进度', type: 'select', options: Object.entries(procurementStatuses).map(([value, label]) => ({ value, label })) },
]
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' },
{ prop: 'orderNo', label: '采购单号', minWidth: 250, slotName: 'orderNo' },
{ prop: 'purchaseCategory', label: '采购品类', width: 120 }, { prop: 'productName', label: '商品名称', minWidth: 200 },
{ prop: 'productSpecification', label: '商品规格', minWidth: 160 }, { prop: 'purchaseQuantity', label: '采购数量(件)', width: 135 },
{ prop: 'budgetUnitPrice', label: '预算单价', width: 155, slotName: 'budget' },
{ prop: 'status', label: '履约进度', width: 180, slotName: 'status' }, { prop: 'createdAt', label: '下单时间', width: 185, slotName: 'createdAt' },
]
function rowActions(row: object): TableAction[] { return [{ key: 'detail', label: '详情', run: () => openDetail(row as ProcurementSummary) }] }
function openDetail(row: ProcurementSummary) { if (row.orderNo) void router.push({ name: 'cross-border-procurement-detail', params: { orderNo: row.orderNo } }) }
function returnToList() { if (canList.value) void router.push({ name: 'cross-border-procurement' }) }
function message(cause: unknown) {
if (cause instanceof ApiError && ((cause.status || 0) >= 500 || cause.code.startsWith('B'))) return '服务暂不可用,请稍后重试'
return cause instanceof Error ? cause.message : '请求失败,请稍后重试'
}
async function loadList() {
const params = { page: query.page, size: query.size, orderNo: query.orderNo || undefined, productName: query.productName || undefined, purchaseCategory: query.purchaseCategory || undefined, status: query.status || undefined }
const key = JSON.stringify([identity.value, params])
if (inFlight === key) return
const sequence = ++requestSequence; inFlight = key; loading.value = true; error.value = ''
try { const result = await listProcurements(params); if (sequence === requestSequence) { rows.value = result.records; total.value = result.total } }
catch (cause) { if (sequence === requestSequence) { rows.value = []; total.value = 0; error.value = message(cause) } }
finally { if (sequence === requestSequence) { loading.value = false; inFlight = '' } }
}
async function loadDetail() {
if (!orderNo.value) { error.value = '采购单号缺失'; return }
const key = JSON.stringify([identity.value, orderNo.value])
if (inFlight === key) return
const sequence = ++requestSequence; inFlight = key; loading.value = true; error.value = ''; detail.value = null
try { const result = await getProcurement(orderNo.value); if (sequence === requestSequence) detail.value = result }
catch (cause) { if (sequence === requestSequence) error.value = message(cause) }
finally { if (sequence === requestSequence) { loading.value = false; inFlight = '' } }
}
function search() { if (query.page === 1) void loadList(); else query.page = 1 }
function resetQuery() { const reload = query.page === 1; Object.assign(query, { page: 1, orderNo: '', purchaseCategory: '', productName: '', status: '' }); if (reload) void loadList() }
function populate(payload: ProcurementCreate) {
Object.assign(form, { purchaseCategory: payload.purchaseCategory, productName: payload.productName, productSpecification: payload.productSpecification || '', productUrl: payload.productUrl || '',
purchaseQuantity: String(payload.purchaseQuantity), budgetUnitPrice: String(payload.budgetUnitPrice), currency: payload.currency, receivingAddress: payload.receivingAddress,
contactName: payload.contactName, contactPhone: payload.contactPhone, remark: payload.remark || '' })
}
function restorePending() {
pending.value = null; recoveryError.value = ''; submitError.value = ''; submittedOrder.value = ''; Object.assign(form, emptyProcurementDraft())
if (!storageKey.value) return
try { const saved = sessionStorage.getItem(storageKey.value); if (saved) { pending.value = Object.freeze(restoreProcurementSubmission(saved)); populate(pending.value) } }
catch { recoveryError.value = '上次提交记录暂无法恢复,请先在订单列表或通过客服核对提交结果,避免重复下单。' }
}
async function submit() {
if (submitting.value || !canCreate.value || recoveryError.value || !storageKey.value) return
submitError.value = ''; const owner = identity.value, key = storageKey.value; const resuming = Boolean(pending.value)
let started = false
try {
if (!pending.value) {
const payload = buildProcurementCreate(form, companyName.value, procurementRequestKey())
try { sessionStorage.setItem(key, JSON.stringify(payload)) } catch { throw new Error('浏览器暂无法保留提交信息,请允许会话存储后重试') }
pending.value = Object.freeze(payload)
}
submitting.value = true; started = true
const result = await createProcurement(pending.value)
sessionStorage.removeItem(key)
if (!active || owner !== identity.value) return
pending.value = null; submittedOrder.value = result.order.orderNo; ElMessage.success('采购订单已提交')
if (canList.value) await router.push({ name: 'cross-border-procurement-detail', params: { orderNo: result.order.orderNo } })
} catch (cause) {
if (!active || owner !== identity.value) return
if (!resuming && cause instanceof ApiError && definitelyRejected(cause)) { sessionStorage.removeItem(key); pending.value = null }
submitError.value = started && pending.value ? `${message(cause)}。提交结果尚未确认,请再次确认上次提交结果。` : message(cause)
} finally { submitting.value = false }
}
watch(() => [mode.value, orderNo.value, query.page, query.size, identity.value], (values, previous) => {
if (values[4] !== previous?.[4]) { rows.value = []; total.value = 0 }
++requestSequence; inFlight = ''; loading.value = false; error.value = ''; detail.value = null
if (mode.value === 'create') restorePending()
else if (mode.value === 'detail') void loadDetail()
else void loadList()
}, { immediate: true })
onBeforeUnmount(() => { active = false; ++requestSequence })
</script> </script>
<template><BusinessOrdersView kind="procurement" :create-page="createPage" :detail-page="detailPage" /></template> <template>
<section class="procurement-page client-ui" :aria-busy="loading || submitting">
<template v-if="mode === 'list'">
<QueryToolbar v-model="queryModel" layout="grid" ariaLabel="采购订单查询条件" :fields="queryFields" :search-on-reset="false" @search="search" @reset="resetQuery" />
<div class="procurement-table-card">
<DataTable v-model:page="query.page" v-model:page-size="query.size" :data="rows" :columns="columns" :row-actions="rowActions" :loading="loading" :total="total" :page-sizes="[10, 20, 50, 100]" row-key="orderNo" empty-text="暂无采购订单">
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="router.push({ name: 'cross-border-procurement-create' })">新增下单</el-button></template>
<template v-if="error" #error><div class="procurement-error" role="alert"><span>{{ error }}</span><el-button :disabled="loading" @click="loadList">重新加载</el-button></div></template>
<template #orderNo="{ row }"><button class="procurement-link" type="button" @click="openDetail(row as ProcurementSummary)">{{ (row as ProcurementSummary).orderNo || '—' }}</button></template>
<template #budget="{ row }">{{ money((row as ProcurementSummary).budgetUnitPrice, (row as ProcurementSummary).currency) }}</template>
<template #status="{ row }"><el-tag :type="statusTone((row as ProcurementSummary).status)" size="small">{{ statusLabel((row as ProcurementSummary).status) }}</el-tag></template>
<template #createdAt="{ row }">{{ dateTime((row as ProcurementSummary).createdAt) }}</template>
</DataTable>
</div>
</template>
<section v-else-if="mode === 'detail'" v-loading="loading" class="procurement-detail" aria-label="采购订单详情">
<header class="procurement-heading"><div><el-button link type="primary" @click="returnToList">← 返回列表</el-button><h2>{{ detail?.order.orderNo || '采购订单详情' }}</h2></div><div><el-tag v-if="detail" :type="statusTone(detail.order.status)">{{ statusLabel(detail.order.status) }}</el-tag><el-button :disabled="loading" @click="loadDetail">刷新</el-button></div></header>
<div v-if="error" class="procurement-error" role="alert"><span>{{ error }}</span><el-button :disabled="loading" @click="loadDetail">重新加载</el-button></div>
<ProcurementDetailPanel v-else-if="detail" :detail="detail" /><el-skeleton v-else :rows="8" animated />
</section>
<section v-else class="procurement-create" aria-label="采购下单">
<header class="procurement-heading"><div><h2>海外采购下单</h2><p>一单一个商品及规格;预算仅授权样品货款,运费及关税另行核定。</p></div><el-button v-if="canList" @click="returnToList">返回列表</el-button></header>
<el-result v-if="!canCreate" icon="warning" title="没有采购下单权限" sub-title="请联系企业管理员配置权限" />
<el-result v-else-if="submittedOrder" icon="success" title="采购订单已提交" :sub-title="submittedOrder"><template #extra><el-button @click="restorePending">新建另一单</el-button></template></el-result>
<template v-else>
<el-alert v-if="recoveryError" :title="recoveryError" type="error" :closable="false" />
<el-alert v-else-if="pending" title="已保留本次提交内容。请再次确认上次提交结果,确认前不修改订单信息。" type="warning" :closable="false" />
<el-alert v-if="submitError" class="submit-error" :title="submitError" type="error" :closable="false" />
<el-form :model="form" label-position="top" :disabled="submitting || Boolean(pending) || Boolean(recoveryError)" @submit.prevent="submit">
<h3>商品与预算</h3><div class="procurement-form-grid">
<el-form-item label="采购品类" required><el-input v-model.trim="form.purchaseCategory" maxlength="128" placeholder="请输入采购品类" /></el-form-item>
<el-form-item label="商品名称" required><el-input v-model.trim="form.productName" maxlength="255" placeholder="请输入商品名称" /></el-form-item>
<el-form-item label="商品规格"><el-input v-model.trim="form.productSpecification" maxlength="500" placeholder="规格、型号" /></el-form-item>
<el-form-item label="采购数量(件)" required><el-input v-model.trim="form.purchaseQuantity" inputmode="numeric" maxlength="18" placeholder="正整数" /></el-form-item>
<el-form-item label="预算单价" required><el-input v-model.trim="form.budgetUnitPrice" inputmode="decimal" maxlength="20" placeholder="请明确填写;免费样品填 0" /></el-form-item>
<el-form-item label="预算币种" required><el-select v-model="form.currency"><el-option label="美元 USD" value="USD" /><el-option label="人民币 CNY" value="CNY" /></el-select></el-form-item>
<el-form-item class="full" label="商品链接"><el-input v-model.trim="form.productUrl" maxlength="2000" placeholder="https://…" /></el-form-item>
</div><p class="procurement-hint">货款预算总上限:<strong>{{ money(draftBudgetTotal(form), form.currency) }}</strong>。零预算仅授权免费样品;实际货款按供应商实付核定。</p>
<h3>最终国内收件资料</h3><div class="procurement-form-grid">
<el-form-item label="收件人" required><el-input v-model.trim="form.contactName" maxlength="128" placeholder="实际收件人姓名" /></el-form-item>
<el-form-item label="联系电话" required><el-input v-model.trim="form.contactPhone" maxlength="64" type="tel" placeholder="快递可联系的电话" /></el-form-item>
<el-form-item label="下单企业"><el-input :model-value="companyName" disabled /></el-form-item>
<el-form-item class="full" label="完整国内地址" required><el-input v-model.trim="form.receivingAddress" maxlength="1000" placeholder="省、市、区县、街道及详细门牌" /></el-form-item>
<el-form-item class="full" label="采购要求"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="其他采购要求(选填)" /></el-form-item>
</div>
</el-form>
<footer class="procurement-form-actions"><el-button v-if="canList" :disabled="submitting" @click="returnToList">返回列表</el-button><el-button type="primary" :loading="submitting" :disabled="Boolean(recoveryError) || (!pending && !companyName) || !identity" @click="submit">{{ pending ? '确认上次提交结果' : '提交采购订单' }}</el-button></footer>
</template>
</section>
</section>
</template>
<style scoped>
.procurement-page { display: grid; gap: var(--client-section-gap); min-width: 0; }
.procurement-table-card, .procurement-detail, .procurement-create { min-width: 0; border: 1px solid var(--client-border); border-radius: var(--client-radius); background: #fff; }
.procurement-table-card { overflow: hidden; }.procurement-detail, .procurement-create { padding: 16px 20px 24px; }
.procurement-heading { display: flex; justify-content: space-between; align-items: center; gap: 20px; min-height: 54px; border-bottom: 1px solid var(--border); padding-bottom: 12px; }
.procurement-heading > div { display: flex; align-items: center; gap: 14px; min-width: 0; }.procurement-heading h2 { margin: 0; color: var(--navy); font-size: 18px; overflow-wrap: anywhere; }
.procurement-create .procurement-heading > div { display: block; }.procurement-heading p { margin: 7px 0 0; font-size: 13px; color: var(--muted); }
.procurement-link { padding: 0; border: 0; background: transparent; color: var(--client-action); font: inherit; cursor: pointer; text-align: left; }.procurement-link:hover { text-decoration: underline; }
.procurement-error { min-height: 160px; display: flex; justify-content: center; align-items: center; gap: 16px; color: #b42318; }.procurement-error span { max-width: 900px; overflow-wrap: anywhere; }
.procurement-form-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0 24px; }.procurement-form-grid .full { grid-column: 1 / -1; }.procurement-form-grid :deep(.el-select) { width: 100%; }
.procurement-create h3 { margin: 22px 0 16px; color: var(--navy); font-size: 14px; }.procurement-hint { margin: 0 0 20px; line-height: 1.8; font-size: 13px; color: var(--muted); }.procurement-hint strong { color: var(--navy); }
.procurement-form-actions { display: flex; justify-content: flex-end; gap: 12px; padding-top: 16px; border-top: 1px solid var(--border); }.submit-error { margin: 12px 0; }
@media (max-width: 767px) {
.procurement-form-grid { grid-template-columns: 1fr; }.procurement-heading, .procurement-heading > div { flex-wrap: wrap; }
}
</style>
@@ -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', () => { 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, /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', () => { test('keeps overseas warehouse writes behind operation-specific button permissions', () => {
@@ -6,7 +6,9 @@ const view = readFileSync(new URL('./WarehouseServiceView.vue', import.meta.url)
const api = readFileSync(new URL('../api/warehouseServices.ts', import.meta.url), 'utf8') const api = readFileSync(new URL('../api/warehouseServices.ts', import.meta.url), 'utf8')
test('贴标总文件与关联模板保持左右分组,退货来源使用专用明细', () => { test('贴标总文件与关联模板保持左右分组,退货来源使用专用明细', () => {
assert.match(view, /class="warehouse-file-groups" :class="\{ 'warehouse-file-groups--single': isReturnRelabelSource \}"/) assert.match(view, /class="warehouse-file-groups(?:\s[^"]*)?"/)
assert.match(view, /\.warehouse-file-groups--compact\s*\{[^}]*grid-template-columns:\s*minmax\(0, 1fr\) minmax\(0, 1fr\)/)
assert.match(view, /v-if="isReturnRelabelSource" class="warehouse-table-scroll"/)
assert.match(view, /warehouse-service-page--return-relabel/) assert.match(view, /warehouse-service-page--return-relabel/)
assert.match(view, /const sourceValue = `RETURN_ORDER:\$\{requestedReturnNo\}`/) assert.match(view, /const sourceValue = `RETURN_ORDER:\$\{requestedReturnNo\}`/)
}) })
@@ -136,7 +138,7 @@ test('仓储转运需要换标时上传总文件并挂到关联换标单', () =>
}) })
test('仓储转运换标总文件与明细模板在桌面端左右排列', () => { test('仓储转运换标总文件与明细模板在桌面端左右排列', () => {
assert.match(view, /class="warehouse-transfer-file-groups"/) assert.match(view, /class="warehouse-transfer-file-groups(?:\s[^"]*)?"/)
assert.match(view, /\.warehouse-transfer-file-groups\{display:grid;grid-template-columns:minmax\(0,1\.15fr\) minmax\(360px,\.85fr\)/) assert.match(view, /\.warehouse-transfer-file-groups\{display:grid;grid-template-columns:minmax\(0,1\.15fr\) minmax\(360px,\.85fr\)/)
assert.match(view, /\.warehouse-transfer-total-files,\.warehouse-transfer-template-group\{height:100%;margin:0\}/) assert.match(view, /\.warehouse-transfer-total-files,\.warehouse-transfer-template-group\{height:100%;margin:0\}/)
assert.match(view, /@media\(max-width:1180px\)\{\.warehouse-transfer-file-groups\{grid-template-columns:minmax\(0,1fr\)\}\}/) assert.match(view, /@media\(max-width:1180px\)\{\.warehouse-transfer-file-groups\{grid-template-columns:minmax\(0,1fr\)\}\}/)