Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba6599646f | ||
|
|
513bbe2bb0 | ||
|
|
d910b24755 | ||
|
|
666be8a80d | ||
|
|
33db45f1c6 | ||
|
|
dcdaf1438c | ||
|
|
17580fb0a2 | ||
|
|
34650b06df | ||
|
|
04a06668a1 | ||
|
|
f1436f2889 | ||
|
|
82dcc5a14d | ||
|
|
9a24e0c83d | ||
|
|
07590dd4d3 | ||
|
|
88c9ff2cb5 | ||
|
|
f7dd39c7ed | ||
|
|
7e68d978dd | ||
|
|
4b5113a690 | ||
|
|
e0726444cc | ||
|
|
fbaa48337d | ||
|
|
af3cacd2db | ||
|
|
a674ec2435 | ||
|
|
8c064cfb0d | ||
|
|
d1e9002bef | ||
|
|
ded625f2a1 | ||
|
|
3d3ca053a6 | ||
|
|
3d73f1c82d | ||
|
|
bb674eb3a0 | ||
|
|
c79b5492c6 | ||
|
|
9b58c12012 | ||
|
|
d89830fe41 | ||
|
|
4d7b6eb6dc | ||
|
|
5b320a3293 | ||
|
|
63e169b051 |
@@ -43,6 +43,8 @@ const visibleNavigation = computed(() => filterNavigationByPermissions(clientNav
|
|||||||
|
|
||||||
const activeParentKeys = computed(() => new Set(getNavigationParentKeys(visibleNavigation.value, props.activePath)))
|
const activeParentKeys = computed(() => new Set(getNavigationParentKeys(visibleNavigation.value, props.activePath)))
|
||||||
const openedKeys = ref<ReadonlySet<string>>(new Set(activeParentKeys.value))
|
const openedKeys = ref<ReadonlySet<string>>(new Set(activeParentKeys.value))
|
||||||
|
// Expand the order group by default so the three customer order shortcuts are visible.
|
||||||
|
openedKeys.value = new Set([...openedKeys.value, 'order-center'])
|
||||||
|
|
||||||
function handleToggle(key: string) {
|
function handleToggle(key: string) {
|
||||||
const next = new Set(openedKeys.value)
|
const next = new Set(openedKeys.value)
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# 本土卡车字段调整核对
|
||||||
|
|
||||||
|
依据:用户提供的下单页截图 `codex-clipboard-c458c2e1-e9a8-4726-a8cd-4581e5e7102f.png` 及本轮字段、必填和时分确认。当前需求规则见 `docs/海外仓业务/README.md`“美国本土卡独立下单”。
|
||||||
|
|
||||||
|
| 页面 | 操作 | 依据及承载 | 当前调整 | 验收 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 美国本土卡 | 新增 | 用户截图:独立页面、单列表单、取消和提交按钮 | 保留容器、按钮;按确认删除字段,新增车型及两端联系人和时间 | 自动验证与人工视觉验收分开;待人工截图确认 |
|
||||||
|
| 美国本土卡 | 列表 | 现有 `BusinessOrdersView.vue` 的独立列表与左侧操作列 | 对应列展示车型和取送时间 | 待人工截图确认 |
|
||||||
|
| 美国本土卡 | 详情 | 现有独立详情路由及键值网格 | 展示保存的两端联系信息、车型和取送时间;加宽长字段标签 | 待人工截图确认 |
|
||||||
|
|
||||||
|
本节记录本土卡车调整;散板最新范围见下节。无新增审核、删除或附件操作。
|
||||||
|
|
||||||
|
|
||||||
|
## 本土散板简化需求合同
|
||||||
|
|
||||||
|
当前字段规则见 `docs/海外仓业务/README.md`“本土散板简化下单”;依据用户提供的 Admin 简化原型及本轮同步消费者确认,不改变客户认证范围。
|
||||||
|
|
||||||
|
| 页面 | 操作 | 承载与范围 | 验收 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 美本土散板 | 新增 | 保持现有独立路由和表单容器;同步商品、预计托数、四项联系信息及必填的当地取送时间;删除运输方式、柜型和下单安排项 | 自动测试与人工视觉分开;待人工截图确认 |
|
||||||
|
| 美本土散板 | 列表、详情 | 保持现有独立路由;展示同一保存值和真实承运安排,不访问运力池 | 待人工截图确认 |
|
||||||
|
|
||||||
|
|
||||||
|
## Admin / Client 统一运输下单表单(2026-10-19)
|
||||||
|
|
||||||
|
依据:当前对话明确要求“最多 Admin 添加客户选项,Client 从登录客户下单”,覆盖此前两端独立排版结论。保留应用壳层、正式路由及权限;业务表单统一为同一字段清单、排列、控件、默认值、校验及提交转换。
|
||||||
|
|
||||||
|
| 页面 | 操作 | 承载与现有依据 | 本轮统一结论 | 验收 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 本土卡车 | Client 新增、Admin 新增/编辑 | 既有独立页面;原客户端截图及当前统一确认 | 运输信息双列,车辆必选;两端联系人/电话及详细地址左右对应;货物件重体;费用币种与两个分钟时间;备注;底部取消/提交。Admin 新增前置客户选择 | 自动合同及行为测试;待人工 1920×1080、100% 截图 |
|
||||||
|
| 本土散板 | Client 新增、Admin 新增/编辑 | 既有独立页面;当前统一确认 | 与卡车共用表单;保留商品名、预计托数和件重体,移除车型;其余相同。Admin 新增前置客户选择 | 同上 |
|
||||||
|
| 两类业务 | 列表、详情、承运安排 | 既有独立路由及只读承运摘要 | 本轮不改变承载;Client 不新增编辑路由 | 既有行为回归 |
|
||||||
|
|
||||||
|
统一表单组件和转换逻辑各自位于应用内部,运行时不跨应用引用。两端业务组件源码一致性另作定向检查;下单数据不允许混入客户标识,Admin 在 API 参数显式选择客户,Client 由认证会话确定客户。
|
||||||
@@ -17,7 +17,9 @@ export interface BusinessOrderQuery {
|
|||||||
status?: string
|
status?: string
|
||||||
routeKeyword?: string
|
routeKeyword?: string
|
||||||
transportType?: string
|
transportType?: string
|
||||||
transportMode?: string
|
vehicleType?: 'PALLET_15' | 'PALLET_30'
|
||||||
|
pickupFrom?: string
|
||||||
|
pickupTo?: string
|
||||||
requestedFrom?: string
|
requestedFrom?: string
|
||||||
requestedTo?: string
|
requestedTo?: string
|
||||||
createdFrom?: string
|
createdFrom?: string
|
||||||
@@ -27,21 +29,25 @@ export interface BusinessOrderQuery {
|
|||||||
export interface TransportSummary {
|
export interface TransportSummary {
|
||||||
fleetArrangement: import('../fleetArrangement').FleetArrangement | null
|
fleetArrangement: import('../fleetArrangement').FleetArrangement | null
|
||||||
orderNo: string
|
orderNo: string
|
||||||
transportType: string
|
transportType?: string
|
||||||
transportMode?: string
|
|
||||||
palletRequired?: string | null
|
palletRequired?: string | null
|
||||||
origin: string
|
origin: string
|
||||||
destination: string
|
destination: string
|
||||||
containerType: string | null
|
|
||||||
palletCount?: number
|
palletCount?: number
|
||||||
packageCount: number
|
packageCount: number
|
||||||
totalWeightKg: number
|
totalWeightKg: number
|
||||||
totalVolumeCbm: number
|
totalVolumeCbm: number
|
||||||
budgetAmount: number | null
|
budgetAmount: number | null
|
||||||
currency: string | null
|
currency: string | null
|
||||||
contactName: string | null
|
productName?: string | null
|
||||||
contactInfo: string | null
|
estimatedPalletCount?: number | null
|
||||||
requestedDate: string | null
|
vehicleType?: 'PALLET_15' | 'PALLET_30' | null
|
||||||
|
originContactName?: string | null
|
||||||
|
originContactPhone?: string | null
|
||||||
|
destinationContactName?: string | null
|
||||||
|
destinationContactPhone?: string | null
|
||||||
|
pickupAt?: string | null
|
||||||
|
deliveryAt?: string | null
|
||||||
status: string
|
status: string
|
||||||
version: number
|
version: number
|
||||||
createdAt: string
|
createdAt: string
|
||||||
@@ -56,23 +62,44 @@ export interface TransportDetail {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface TransportCreate {
|
export interface TransportCreate {
|
||||||
transportType: string
|
productName?: string
|
||||||
transportMode?: string
|
estimatedPalletCount: number
|
||||||
palletRequired?: string
|
|
||||||
origin: string
|
origin: string
|
||||||
originAddress?: string
|
originAddress?: string
|
||||||
destination: string
|
destination: string
|
||||||
destinationAddress?: string
|
destinationAddress?: string
|
||||||
containerType?: string
|
|
||||||
palletCount?: number
|
|
||||||
packageCount: number
|
packageCount: number
|
||||||
totalWeightKg: number
|
totalWeightKg: number
|
||||||
totalVolumeCbm: number
|
totalVolumeCbm: number
|
||||||
budgetAmount?: number
|
budgetAmount?: number
|
||||||
currency?: string
|
currency?: string
|
||||||
contactName?: string
|
originContactName: string
|
||||||
contactInfo?: string
|
originContactPhone: string
|
||||||
requestedDate?: string
|
destinationContactName: string
|
||||||
|
destinationContactPhone: string
|
||||||
|
pickupAt: string
|
||||||
|
deliveryAt: string
|
||||||
|
remark?: string
|
||||||
|
idempotencyKey: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TruckCreate {
|
||||||
|
vehicleType: 'PALLET_15' | 'PALLET_30'
|
||||||
|
origin: string
|
||||||
|
originAddress?: string
|
||||||
|
destination: string
|
||||||
|
destinationAddress?: string
|
||||||
|
originContactName: string
|
||||||
|
originContactPhone: string
|
||||||
|
destinationContactName: string
|
||||||
|
destinationContactPhone: string
|
||||||
|
pickupAt: string
|
||||||
|
deliveryAt: string
|
||||||
|
packageCount: number
|
||||||
|
totalWeightKg: number
|
||||||
|
totalVolumeCbm: number
|
||||||
|
budgetAmount?: number
|
||||||
|
currency?: string
|
||||||
remark?: string
|
remark?: string
|
||||||
idempotencyKey: string
|
idempotencyKey: string
|
||||||
}
|
}
|
||||||
@@ -81,6 +108,10 @@ export interface TransportUpdate extends Omit<TransportCreate, 'idempotencyKey'>
|
|||||||
version: number
|
version: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TruckUpdate extends Omit<TruckCreate, 'idempotencyKey'> {
|
||||||
|
version: number
|
||||||
|
}
|
||||||
|
|
||||||
const endpointByKind: Record<CrossBorderBusinessKind, string> = {
|
const endpointByKind: Record<CrossBorderBusinessKind, string> = {
|
||||||
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',
|
||||||
@@ -98,11 +129,15 @@ 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 createTransport(kind: 'trucking' | 'looseboard', payload: TransportCreate) {
|
export function createTransport(kind: 'trucking', payload: TruckCreate): Promise<TransportDetail>
|
||||||
|
export function createTransport(kind: 'looseboard', payload: TransportCreate): Promise<TransportDetail>
|
||||||
|
export function createTransport(kind: CrossBorderBusinessKind, payload: TransportCreate | TruckCreate) {
|
||||||
return apiRequest<TransportDetail>({ method: 'POST', url: endpointByKind[kind], data: payload })
|
return apiRequest<TransportDetail>({ method: 'POST', url: endpointByKind[kind], data: payload })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateTransport(kind: 'trucking' | 'looseboard', orderNo: string, payload: TransportUpdate) {
|
export function updateTransport(kind: 'trucking', orderNo: string, payload: TruckUpdate): Promise<TransportDetail>
|
||||||
|
export function updateTransport(kind: 'looseboard', orderNo: string, payload: TransportUpdate): Promise<TransportDetail>
|
||||||
|
export function updateTransport(kind: CrossBorderBusinessKind, orderNo: string, payload: TransportUpdate | TruckUpdate) {
|
||||||
return apiRequest<TransportDetail>({
|
return apiRequest<TransportDetail>({
|
||||||
method: kind === 'trucking' ? 'PATCH' : 'PUT',
|
method: kind === 'trucking' ? 'PATCH' : 'PUT',
|
||||||
url: `${endpointByKind[kind]}/${encodeURIComponent(orderNo)}`,
|
url: `${endpointByKind[kind]}/${encodeURIComponent(orderNo)}`,
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ 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 demandComponent = readFileSync(new URL('./components/TransportDemandForm.vue', import.meta.url), 'utf8')
|
||||||
|
const demandLogic = ts.transpileModule(readFileSync(new URL('./transportDemandForm.ts', import.meta.url), 'utf8'), { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext } }).outputText.replace(/^export /gm, '')
|
||||||
|
const demandHelpers = runInNewContext(`${demandLogic}\n({ createTransportDemandForm, transportDemandRules, transportDemandError, buildTransportDemand })`)
|
||||||
const procurementApi = readFileSync(new URL('./api/procurement.ts', 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')
|
const procurementView = readFileSync(new URL('./views/ProcurementView.vue', import.meta.url), 'utf8')
|
||||||
|
|
||||||
@@ -106,7 +109,7 @@ test('preserves the existing transport responsive rules without adding a narrow-
|
|||||||
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; \}/)
|
||||||
assert.match(view, /\.form-card :deep\(\.el-form-item\) \{ display: block; \}/)
|
assert.match(view, /\.form-card :deep\(\.el-form-item\) \{ display: block; \}/)
|
||||||
assert.match(view, /\.budget-form-item :deep\(\.el-form-item__content\) \{ display: flex; flex-wrap: nowrap; \}/)
|
assert.match(demandComponent, /\.transport-demand-budget \{ display: grid; width: 100%/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('uses the shared dense order-list and detail visual language', () => {
|
test('uses the shared dense order-list and detail visual language', () => {
|
||||||
@@ -115,3 +118,93 @@ test('uses the shared dense order-list and detail visual language', () => {
|
|||||||
assert.match(view, /\.detail-grid \{[^}]*grid-template-columns: repeat\(4, minmax\(0, 1fr\)\)/)
|
assert.match(view, /\.detail-grid \{[^}]*grid-template-columns: repeat\(4, minmax\(0, 1fr\)\)/)
|
||||||
assert.match(view, /\.detail-grid div \{[^}]*min-height: 34px;[^}]*grid-template-columns: 76px minmax\(0, 1fr\)/)
|
assert.match(view, /\.detail-grid div \{[^}]*min-height: 34px;[^}]*grid-template-columns: 76px minmax\(0, 1fr\)/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function transportHarness(kind = 'trucking', failFirst = false) {
|
||||||
|
const calls = []
|
||||||
|
const script = view.match(/<script setup lang="ts">([\s\S]*?)<\/script>/)[1]
|
||||||
|
const compiled = ts.transpileModule(script, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext } }).outputText
|
||||||
|
.replace(/^import .*?;$/gm, '').replace(/^export \{\};$/gm, '')
|
||||||
|
const harness = runInNewContext(`${compiled}\n({ form, formRef, submit, detail, detailFields })`, {
|
||||||
|
defineProps: () => ({ kind, createPage: true, detailPage: false }),
|
||||||
|
withDefaults: props => props,
|
||||||
|
computed: getter => ({ get value() { return typeof getter === 'function' ? getter() : getter.get() } }),
|
||||||
|
reactive: value => value, ref: value => ({ value }), onMounted: () => {}, watch: () => {},
|
||||||
|
useAuthStore: () => ({ hasPermission: () => true }), useRouter: () => ({ push: async () => {} }), useRoute: () => ({ params: {} }),
|
||||||
|
...demandHelpers, poolOrderStatusLabels: {},
|
||||||
|
ElMessage: { success: () => {}, error: () => {} },
|
||||||
|
createTransport: async (business, payload) => { calls.push({ business, payload: { ...payload } }); if (failFirst && calls.length === 1) throw new Error('retry'); },
|
||||||
|
})
|
||||||
|
harness.formRef.value = { validate: async () => true, clearValidate: () => {} }
|
||||||
|
return { ...harness, calls, rules: { get value() { return demandHelpers.transportDemandRules(kind === 'trucking' ? 'truck' : 'looseboard', harness.form) } } }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('truck requires both contacts vehicle and minute times without inheriting looseboard fields', () => {
|
||||||
|
const truck = transportHarness()
|
||||||
|
for (const key of ['vehicleType', 'originContactName', 'originContactPhone', 'destinationContactName', 'destinationContactPhone', 'pickupAt', 'deliveryAt']) assert.equal(truck.rules.value[key][0].required, true)
|
||||||
|
for (const key of ['palletCount', 'contactName', 'contactInfo', 'requestedDate']) assert.equal(truck.rules.value[key], undefined)
|
||||||
|
const loose = transportHarness('looseboard')
|
||||||
|
assert.equal(loose.rules.value.contactName, undefined)
|
||||||
|
assert.equal(loose.rules.value.contactInfo, undefined)
|
||||||
|
assert.equal(loose.rules.value.transportMode, undefined)
|
||||||
|
for (const key of ['originContactName', 'originContactPhone', 'destinationContactName', 'destinationContactPhone', 'pickupAt', 'deliveryAt']) assert.equal(loose.rules.value[key][0].required, true)
|
||||||
|
truck.form.pickupAt = '2026-10-20T09:30:00'
|
||||||
|
let failure
|
||||||
|
truck.rules.value.deliveryAt[1].validator({}, '2026-10-20T09:29:00', error => { failure = error })
|
||||||
|
assert.equal(failure.message, '送货时间不能早于取货时间')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('truck retry submits the complete new contract with identical local times and idempotency key', async () => {
|
||||||
|
const current = transportHarness('trucking', true)
|
||||||
|
Object.assign(current.form, { vehicleType: 'PALLET_30', origin: 'Los Angeles', destination: 'Ontario', originContactName: 'A', originContactPhone: '+1-555-0100', destinationContactName: 'B', destinationContactPhone: '+1-555-0101', pickupAt: '2026-10-20T09:30:00', deliveryAt: '2026-10-20T15:45:00' })
|
||||||
|
await current.submit()
|
||||||
|
assert.equal(current.form.destinationContactName, 'B')
|
||||||
|
await current.submit()
|
||||||
|
assert.equal(current.calls.length, 2)
|
||||||
|
const first = current.calls[0].payload
|
||||||
|
const second = current.calls[1].payload
|
||||||
|
assert.equal(first.idempotencyKey, second.idempotencyKey)
|
||||||
|
assert.equal(second.vehicleType, 'PALLET_30')
|
||||||
|
assert.equal(second.originContactPhone, '+1-555-0100')
|
||||||
|
assert.equal(second.destinationContactName, 'B')
|
||||||
|
assert.equal(second.pickupAt, '2026-10-20T09:30:00')
|
||||||
|
assert.equal(second.deliveryAt, '2026-10-20T15:45:00')
|
||||||
|
for (const field of ['transportType', 'containerType', 'palletCount', 'contactName', 'contactInfo', 'requestedDate']) assert.equal(Object.hasOwn(second, field), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('truck details render saved minute times and unknown pool demand without timezone conversion', () => {
|
||||||
|
const current = transportHarness()
|
||||||
|
current.detail.value = { summary: { vehicleType: null, pickupAt: '2026-10-20T09:30:00', deliveryAt: null }, originAddress: null, destinationAddress: null, remark: null }
|
||||||
|
const fields = new Map(current.detailFields.value)
|
||||||
|
assert.equal(fields.get('车辆类型'), '—')
|
||||||
|
assert.equal(fields.get('取货时间'), '2026-10-20 09:30')
|
||||||
|
assert.equal(fields.get('送货时间'), '—')
|
||||||
|
assert.equal(fields.has('柜型'), false)
|
||||||
|
assert.equal(fields.has('托盘数'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
test('looseboard saves split contacts and both local minute times without obsolete fields', async () => {
|
||||||
|
const current = transportHarness('looseboard', true)
|
||||||
|
Object.assign(current.form, { originContactName: 'A', originContactPhone: '1', destinationContactName: 'B', destinationContactPhone: '2', productName: '家居用品', estimatedPalletCount: 3, packageCount: 8, origin: 'LA', destination: 'ONT', pickupAt: '2026-10-20T09:00:00', deliveryAt: '2026-10-20T15:30:00' })
|
||||||
|
await current.submit(); await current.submit()
|
||||||
|
const payload = current.calls[1].payload
|
||||||
|
assert.equal(payload.estimatedPalletCount, 3)
|
||||||
|
assert.equal(payload.packageCount, 8)
|
||||||
|
assert.equal(payload.productName, '家居用品')
|
||||||
|
assert.equal(payload.pickupAt, '2026-10-20T09:00:00')
|
||||||
|
assert.equal(payload.originContactName, 'A')
|
||||||
|
assert.equal(payload.destinationContactPhone, '2')
|
||||||
|
assert.equal(payload.deliveryAt, '2026-10-20T15:30:00')
|
||||||
|
assert.equal(payload.idempotencyKey, current.calls[0].payload.idempotencyKey)
|
||||||
|
for (const field of ['palletRequired', 'transportType', 'transportMode', 'containerType', 'contactName', 'contactInfo', 'requestedDate']) assert.equal(Object.hasOwn(payload, field), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('looseboard requires both times and rejects backwards schedule', () => {
|
||||||
|
const current = transportHarness('looseboard')
|
||||||
|
assert.equal(current.rules.value.pickupAt[0].required, true)
|
||||||
|
assert.equal(current.rules.value.deliveryAt[0].required, true)
|
||||||
|
let failure
|
||||||
|
current.form.pickupAt = '2026-10-20T10:30:00'
|
||||||
|
current.rules.value.deliveryAt[1].validator({}, '2026-10-20T10:29:00', error => { failure = error })
|
||||||
|
assert.ok(failure)
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
import { ElMessage, type FormInstance } from 'element-plus'
|
||||||
|
import TransportDemandForm from './TransportDemandForm.vue'
|
||||||
|
import { createTransportDemandForm, transportDemandError, buildTransportDemand, type TransportDemandForm as DemandFormModel } from '../transportDemandForm'
|
||||||
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
|
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
|
||||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue'
|
import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue'
|
||||||
import type { TableAction } from '@/components/common/tableActions'
|
import type { TableAction } from '@/components/common/tableActions'
|
||||||
@@ -14,7 +16,6 @@ import {
|
|||||||
type TransportDetail,
|
type TransportDetail,
|
||||||
type TransportSummary,
|
type TransportSummary,
|
||||||
} from '../api/businessOrders'
|
} from '../api/businessOrders'
|
||||||
import { CONTAINER_SPECIFICATIONS, DEFAULT_CONTAINER_TYPE } from '../containerSpecifications'
|
|
||||||
import { poolOrderStatusLabels } from '../fleetArrangement'
|
import { poolOrderStatusLabels } from '../fleetArrangement'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{ kind: CrossBorderBusinessKind; createPage?: boolean; detailPage?: boolean }>(), { createPage: false, detailPage: false })
|
const props = withDefaults(defineProps<{ kind: CrossBorderBusinessKind; createPage?: boolean; detailPage?: boolean }>(), { createPage: false, detailPage: false })
|
||||||
@@ -33,26 +34,7 @@ type PageMode = 'list' | 'create' | 'detail'
|
|||||||
|
|
||||||
interface ColumnDefinition { key: string; label: string; width?: number }
|
interface ColumnDefinition { key: string; label: string; width?: number }
|
||||||
|
|
||||||
interface BusinessForm {
|
type BusinessForm = DemandFormModel
|
||||||
transportMode: string
|
|
||||||
palletRequired: string
|
|
||||||
transportType: string
|
|
||||||
origin: string
|
|
||||||
originAddress: string
|
|
||||||
destination: string
|
|
||||||
destinationAddress: string
|
|
||||||
containerType: string
|
|
||||||
palletCount: number
|
|
||||||
packageCount: number
|
|
||||||
totalWeightKg: number
|
|
||||||
totalVolumeCbm: number
|
|
||||||
budgetAmount?: number
|
|
||||||
currency: string
|
|
||||||
contactName: string
|
|
||||||
contactInfo: string
|
|
||||||
requestedDate: string
|
|
||||||
remark: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const configuration = computed(() => ({
|
const configuration = computed(() => ({
|
||||||
trucking: {
|
trucking: {
|
||||||
@@ -70,14 +52,15 @@ const configuration = computed(() => ({
|
|||||||
function transportColumns(looseboard: boolean): ColumnDefinition[] {
|
function transportColumns(looseboard: boolean): ColumnDefinition[] {
|
||||||
return [
|
return [
|
||||||
{ key: 'orderNo', label: '订单编号', width: 180 },
|
{ key: 'orderNo', label: '订单编号', width: 180 },
|
||||||
...(looseboard ? [{ key: 'transportMode', label: '运输方式', width: 120 }, { key: 'palletRequired', label: '是否打盘', width: 100 }] : []),
|
...(!looseboard ? [{ key: 'vehicleType', label: '车辆类型', width: 110 }] : []),
|
||||||
{ key: 'transportType', label: '运输类型', width: 130 },
|
|
||||||
{ key: 'origin', label: '起运地', width: 150 },
|
{ key: 'origin', label: '起运地', width: 150 },
|
||||||
{ key: 'destination', label: '目的地', width: 150 },
|
{ key: 'destination', label: '目的地', width: 150 },
|
||||||
{ key: 'containerType', label: '柜型', width: 100 },
|
...(looseboard ? [{ key: 'productName', label: '商品名称', width: 150 }, { key: 'estimatedPalletCount', label: '预计托数', width: 100 }] : []),
|
||||||
{ key: 'totalWeightKg', label: '总重量(kg)', width: 120 },
|
{ key: 'totalWeightKg', label: '总重量(kg)', width: 120 },
|
||||||
{ key: 'budgetAmount', label: '预算总价', width: 110 },
|
{ key: 'budgetAmount', label: '预算总价', width: 110 },
|
||||||
{ key: 'requestedDate', label: '要求时间', width: 120 },
|
...([
|
||||||
|
{ key: 'pickupAt', label: '取货时间', width: 165 }, { key: 'deliveryAt', label: '送货时间', width: 165 },
|
||||||
|
]),
|
||||||
{ key: 'status', label: '状态', width: 130 },
|
{ key: 'status', label: '状态', width: 130 },
|
||||||
{ key: 'fleetArrangement', label: '承运安排', width: 190 },
|
{ key: 'fleetArrangement', label: '承运安排', width: 190 },
|
||||||
]
|
]
|
||||||
@@ -131,29 +114,22 @@ const queryFields = computed<QueryField[]>(() => [
|
|||||||
])
|
])
|
||||||
|
|
||||||
const form = reactive<BusinessForm>(emptyForm())
|
const form = reactive<BusinessForm>(emptyForm())
|
||||||
const rules: FormRules<BusinessForm> = {
|
|
||||||
origin: [{ required: true, message: '请输入起运地', trigger: 'blur' }],
|
|
||||||
destination: [{ required: true, message: '请输入目的地', trigger: 'blur' }],
|
|
||||||
palletCount: [{ required: true, type: 'number', min: 1, message: '托盘数至少为 1', trigger: 'change' }],
|
|
||||||
contactName: [{ required: true, message: '请输入联系人', trigger: 'blur' }],
|
|
||||||
contactInfo: [{ required: true, message: '请输入联系电话或联系方式', trigger: 'blur' }],
|
|
||||||
requestedDate: [{ required: true, message: '请选择要求时间', trigger: 'change' }],
|
|
||||||
}
|
|
||||||
|
|
||||||
const detailFields = computed(() => {
|
const detailFields = computed(() => {
|
||||||
if (!detail.value) return []
|
if (!detail.value) return []
|
||||||
const summary = detail.value.summary
|
const summary = detail.value.summary
|
||||||
const current = detail.value as TransportDetail
|
const current = detail.value as TransportDetail
|
||||||
return [
|
return [
|
||||||
['运输方式', transportModeLabel(read(summary, 'transportMode'))],
|
...(props.kind === 'looseboard' ? [
|
||||||
['是否打盘', yesNo(read(summary, 'palletRequired'))], ['运输类型', transportTypeLabel(read(summary, 'transportType'))],
|
['商品名称', summary.productName], ['预计托数', summary.estimatedPalletCount],
|
||||||
|
] : [['车辆类型', vehicleTypeLabel(summary.vehicleType)]]),
|
||||||
['起运地', read(summary, 'origin')], ['起运地址', current.originAddress],
|
['起运地', read(summary, 'origin')], ['起运地址', current.originAddress],
|
||||||
['目的地', read(summary, 'destination')], ['目的地址', current.destinationAddress],
|
['目的地', read(summary, 'destination')], ['目的地址', current.destinationAddress],
|
||||||
['柜型', read(summary, 'containerType')], ['托盘数', read(summary, 'palletCount')],
|
|
||||||
['件数', read(summary, 'packageCount')], ['总重量(kg)', read(summary, 'totalWeightKg')],
|
['件数', read(summary, 'packageCount')], ['总重量(kg)', read(summary, 'totalWeightKg')],
|
||||||
['总体积(m³)', read(summary, 'totalVolumeCbm')], ['预算总价', money(read(summary, 'budgetAmount'), read(summary, 'currency'))],
|
['总体积(m³)', read(summary, 'totalVolumeCbm')], ['预算总价', money(read(summary, 'budgetAmount'), read(summary, 'currency'))],
|
||||||
['联系人', read(summary, 'contactName')], ['联系方式', read(summary, 'contactInfo')],
|
['起运地联系人', summary.originContactName], ['起运地联系电话', summary.originContactPhone],
|
||||||
['要求时间', read(summary, 'requestedDate')], ['备注', current.remark],
|
['目的地联系人', summary.destinationContactName], ['目的地联系电话', summary.destinationContactPhone],
|
||||||
|
['取货时间', localMinute(summary.pickupAt)], ['送货时间', localMinute(summary.deliveryAt)], ['备注', current.remark],
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -169,14 +145,7 @@ const tableColumns = computed<DataTableColumn[]>(() => [
|
|||||||
|
|
||||||
const detailStatus = computed(() => detail.value ? statusLabel(detail.value.summary.status) : '—')
|
const detailStatus = computed(() => detail.value ? statusLabel(detail.value.summary.status) : '—')
|
||||||
|
|
||||||
function emptyForm(): BusinessForm {
|
function emptyForm(): BusinessForm { return createTransportDemandForm() }
|
||||||
return {
|
|
||||||
transportMode: 'SHARED_TRUCK', palletRequired: 'NO', transportType: 'PORT_PICKUP', origin: '', originAddress: '',
|
|
||||||
destination: '', destinationAddress: '', containerType: DEFAULT_CONTAINER_TYPE, palletCount: 1, packageCount: 0,
|
|
||||||
totalWeightKg: 0, totalVolumeCbm: 0, currency: 'USD', contactName: '', contactInfo: '', requestedDate: '',
|
|
||||||
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 }
|
||||||
@@ -184,8 +153,9 @@ function text(value: unknown) { return value === null || value === undefined ||
|
|||||||
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) }
|
||||||
|
function vehicleTypeLabel(value: unknown) { return ({ PALLET_15: '15拖', PALLET_30: '30拖' } as Record<string, string>)[String(value)] || text(value) }
|
||||||
|
function localMinute(value: unknown) { return value ? String(value).replace('T', ' ').slice(0, 16) : '—' }
|
||||||
function transportTypeLabel(value: unknown) { return ({ PORT_PICKUP: '港口提柜', WAREHOUSE_TO_WAREHOUSE: '仓到仓', WAREHOUSE_TO_DOOR: '仓到门' } as Record<string, string>)[String(value)] || text(value) }
|
function transportTypeLabel(value: unknown) { return ({ PORT_PICKUP: '港口提柜', WAREHOUSE_TO_WAREHOUSE: '仓到仓', WAREHOUSE_TO_DOOR: '仓到门' } 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_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)
|
||||||
@@ -197,8 +167,9 @@ function displayCell(candidate: unknown, key: string) {
|
|||||||
const row = candidate as BusinessRow
|
const row = candidate as BusinessRow
|
||||||
const value = read(row, key)
|
const value = read(row, key)
|
||||||
if (key === 'status') return statusLabel(value)
|
if (key === 'status') return statusLabel(value)
|
||||||
|
if (key === 'vehicleType') return vehicleTypeLabel(value)
|
||||||
|
if (key === 'pickupAt' || key === 'deliveryAt') return localMinute(value)
|
||||||
if (key === 'transportType') return transportTypeLabel(value)
|
if (key === 'transportType') return transportTypeLabel(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 === 'budgetAmount') return money(value, read(row, 'currency'))
|
if (key === 'budgetAmount') return money(value, read(row, 'currency'))
|
||||||
@@ -277,24 +248,15 @@ async function loadDetail() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
|
const failure = transportDemandError(props.kind === 'trucking' ? 'truck' : 'looseboard', form)
|
||||||
|
if (failure) { ElMessage.error(failure); return }
|
||||||
if (!await formRef.value?.validate().catch(() => false)) return
|
if (!await formRef.value?.validate().catch(() => false)) return
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
const stableSubmitKey = submitKey.value || idempotencyKey()
|
const stableSubmitKey = submitKey.value || idempotencyKey()
|
||||||
submitKey.value = stableSubmitKey
|
submitKey.value = stableSubmitKey
|
||||||
try {
|
try {
|
||||||
const base = {
|
if (props.kind === 'trucking') await createTransport('trucking', { ...buildTransportDemand('truck', form), idempotencyKey: stableSubmitKey })
|
||||||
transportType: form.transportType, origin: form.origin, originAddress: form.originAddress || undefined,
|
else await createTransport('looseboard', { ...buildTransportDemand('looseboard', form), idempotencyKey: stableSubmitKey })
|
||||||
destination: form.destination, destinationAddress: form.destinationAddress || undefined,
|
|
||||||
containerType: form.containerType || undefined, packageCount: form.packageCount,
|
|
||||||
totalWeightKg: form.totalWeightKg, totalVolumeCbm: form.totalVolumeCbm,
|
|
||||||
budgetAmount: form.budgetAmount, currency: form.currency || undefined,
|
|
||||||
contactName: form.contactName || undefined, contactInfo: form.contactInfo || undefined,
|
|
||||||
requestedDate: form.requestedDate || undefined, remark: form.remark || undefined,
|
|
||||||
idempotencyKey: stableSubmitKey,
|
|
||||||
}
|
|
||||||
await createTransport(props.kind, props.kind === 'trucking'
|
|
||||||
? { ...base, palletCount: form.palletCount }
|
|
||||||
: { ...base, transportMode: form.transportMode, palletRequired: form.palletRequired })
|
|
||||||
ElMessage.success('订单提交成功')
|
ElMessage.success('订单提交成功')
|
||||||
resetForm()
|
resetForm()
|
||||||
if (props.createPage) await router.push({ name: listRouteName.value })
|
if (props.createPage) await router.push({ name: listRouteName.value })
|
||||||
@@ -309,7 +271,7 @@ onMounted(() => { if (props.detailPage) void loadDetail(); else if (!props.creat
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section v-loading="loading" class="business-orders client-ui" :aria-busy="loading">
|
<section v-loading="loading" class="business-orders client-ui" :aria-busy="loading">
|
||||||
<header v-if="mode === 'create'" class="page-head">
|
<header v-if="mode === 'create'" class="page-head" :class="{ 'looseboard-page-head': props.kind === 'looseboard' }">
|
||||||
<div>
|
<div>
|
||||||
<h2>{{ configuration.title }}下单</h2>
|
<h2>{{ configuration.title }}下单</h2>
|
||||||
<p>请填写订单信息,带 * 项为必填项</p>
|
<p>请填写订单信息,带 * 项为必填项</p>
|
||||||
@@ -338,7 +300,7 @@ onMounted(() => { if (props.detailPage) void loadDetail(); else if (!props.creat
|
|||||||
<div v-if="error" class="business-detail-error" role="alert"><strong>{{ error }}</strong><el-button size="small" @click="loadDetail">重新加载</el-button></div>
|
<div v-if="error" class="business-detail-error" role="alert"><strong>{{ error }}</strong><el-button size="small" @click="loadDetail">重新加载</el-button></div>
|
||||||
<section v-else class="business-detail-section">
|
<section v-else class="business-detail-section">
|
||||||
<h3>订单信息</h3>
|
<h3>订单信息</h3>
|
||||||
<dl class="detail-grid">
|
<dl class="detail-grid" :class="{ 'trucking-detail-grid': kind === 'trucking' }">
|
||||||
<div v-for="field in detailFields" :key="String(field[0])" :class="detailFieldClass(field[0])"><dt>{{ field[0] }}</dt><dd>{{ text(field[1]) }}</dd></div>
|
<div v-for="field in detailFields" :key="String(field[0])" :class="detailFieldClass(field[0])"><dt>{{ field[0] }}</dt><dd>{{ text(field[1]) }}</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
</section>
|
</section>
|
||||||
@@ -363,26 +325,7 @@ onMounted(() => { if (props.detailPage) void loadDetail(); else if (!props.creat
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<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">
|
<TransportDemandForm ref="formRef" :model="form" :kind="kind === 'trucking' ? 'truck' : 'looseboard'" :submitting="submitting" :can-submit="canCreate" @submit="submit" @cancel="returnToList" />
|
||||||
<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 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="起运地" prop="origin"><el-input v-model.trim="form.origin" maxlength="255" placeholder="请输入起运地" /></el-form-item>
|
|
||||||
<el-form-item label="起运地址"><el-input v-model.trim="form.originAddress" maxlength="500" placeholder="请输入详细起运地址" /></el-form-item>
|
|
||||||
<el-form-item label="目的地" prop="destination"><el-input v-model.trim="form.destination" maxlength="255" placeholder="请输入目的地" /></el-form-item>
|
|
||||||
<el-form-item label="目的地址"><el-input v-model.trim="form.destinationAddress" maxlength="500" placeholder="请输入详细目的地址" /></el-form-item>
|
|
||||||
<el-form-item label="柜型"><el-select v-model="form.containerType"><el-option v-for="item in CONTAINER_SPECIFICATIONS" :key="item.type" :label="item.type" :value="item.type" /></el-select></el-form-item>
|
|
||||||
<el-form-item v-if="kind === 'trucking'" label="托盘数" prop="palletCount"><el-input-number v-model="form.palletCount" :min="1" placeholder="请输入托盘数" /></el-form-item>
|
|
||||||
<el-form-item label="件数"><el-input-number v-model="form.packageCount" :min="0" 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="要求时间" :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 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 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="备注"><el-input v-model="form.remark" type="textarea" :rows="4" maxlength="1000" show-word-limit placeholder="请输入其他要求" /></el-form-item>
|
|
||||||
<el-form-item><el-button @click="returnToList">取消</el-button><el-button v-if="canCreate" type="primary" :loading="submitting" @click="submit">提交订单</el-button></el-form-item>
|
|
||||||
</el-form>
|
|
||||||
</el-card>
|
</el-card>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
@@ -390,6 +333,7 @@ onMounted(() => { if (props.detailPage) void loadDetail(); else if (!props.creat
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.business-orders { display: grid; gap: var(--client-section-gap); }
|
.business-orders { display: grid; gap: var(--client-section-gap); }
|
||||||
.page-head { display: flex; align-items: center; justify-content: space-between; gap: 24px; }
|
.page-head { display: flex; align-items: center; justify-content: space-between; gap: 24px; }
|
||||||
|
.looseboard-page-head { max-width: 1180px; padding-right: 30px; box-sizing: border-box; }
|
||||||
.page-head h2 { margin: 0; color: #111827; font-size: 26px; }
|
.page-head h2 { margin: 0; color: #111827; font-size: 26px; }
|
||||||
.page-head p { margin: 7px 0 0; color: #64748b; font-size: 14px; }
|
.page-head p { margin: 7px 0 0; color: #64748b; font-size: 14px; }
|
||||||
.business-table-card { overflow: hidden; border: 1px solid var(--client-border); border-radius: var(--client-radius); background: #fff; }
|
.business-table-card { overflow: hidden; border: 1px solid var(--client-border); border-radius: var(--client-radius); background: #fff; }
|
||||||
@@ -407,14 +351,13 @@ onMounted(() => { if (props.detailPage) void loadDetail(); else if (!props.creat
|
|||||||
.business-detail-section h3 { margin: 0; padding: 0 0 5px; border-bottom: 1px solid #eaf0f7; color: var(--navy); font-size: 13px; }
|
.business-detail-section h3 { margin: 0; padding: 0 0 5px; border-bottom: 1px solid #eaf0f7; color: var(--navy); font-size: 13px; }
|
||||||
.detail-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 0; border-top: 1px solid var(--border); border-left: 1px solid var(--border); }
|
.detail-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 0; border-top: 1px solid var(--border); border-left: 1px solid var(--border); }
|
||||||
.detail-grid div { display: grid; min-width: 0; min-height: 34px; grid-template-columns: 76px minmax(0, 1fr); align-items: center; gap: 7px; padding: 3px 8px; border-right: 1px solid var(--border); border-bottom: 1px solid var(--border); }
|
.detail-grid div { display: grid; min-width: 0; min-height: 34px; grid-template-columns: 76px minmax(0, 1fr); align-items: center; gap: 7px; padding: 3px 8px; border-right: 1px solid var(--border); border-bottom: 1px solid var(--border); }
|
||||||
|
.trucking-detail-grid div { grid-template-columns: 112px minmax(0, 1fr); }
|
||||||
.detail-grid div.is-wide { grid-column: span 2; }
|
.detail-grid div.is-wide { grid-column: span 2; }
|
||||||
.detail-grid dt { color: var(--muted); font-size: 10px; white-space: nowrap; }
|
.detail-grid dt { color: var(--muted); font-size: 10px; white-space: nowrap; }
|
||||||
.arrangement-grid div { grid-template-columns: 124px minmax(0, 1fr); }
|
.arrangement-grid div { grid-template-columns: 124px minmax(0, 1fr); }
|
||||||
.arrangement-grid dt { white-space: normal; overflow-wrap: anywhere; }
|
.arrangement-grid dt { white-space: normal; overflow-wrap: anywhere; }
|
||||||
.detail-grid dd { min-width: 0; margin: 0; overflow-wrap: anywhere; color: var(--navy); font-size: 12px; font-weight: 600; line-height: 1.4; }
|
.detail-grid dd { min-width: 0; margin: 0; overflow-wrap: anywhere; color: var(--navy); font-size: 12px; font-weight: 600; line-height: 1.4; }
|
||||||
.form-card :deep(.el-form) { max-width: 860px; }
|
.form-card :deep(.el-card__body) { padding: 0; }
|
||||||
.form-card :deep(.el-input), .form-card :deep(.el-textarea), .form-card :deep(.el-select) { width: 100%; }
|
|
||||||
.form-card .currency { width: 110px; margin-left: 12px; }
|
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
.business-orders { gap: 14px; }
|
.business-orders { gap: 14px; }
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import type { FormInstance } from 'element-plus'
|
||||||
|
import { transportDemandRules, type TransportDemandForm, type TransportDemandKind } from '../transportDemandForm'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
model: TransportDemandForm
|
||||||
|
kind: TransportDemandKind
|
||||||
|
disabled?: boolean
|
||||||
|
submitting?: boolean
|
||||||
|
canSubmit?: boolean
|
||||||
|
editing?: boolean
|
||||||
|
}>(), { disabled: false, submitting: false, canSubmit: true, editing: false })
|
||||||
|
const emit = defineEmits<{ submit: []; cancel: [] }>()
|
||||||
|
const formRef = ref<FormInstance>()
|
||||||
|
const rules = computed(() => transportDemandRules(props.kind, props.model))
|
||||||
|
defineExpose({
|
||||||
|
validate: () => formRef.value?.validate() ?? Promise.resolve(false),
|
||||||
|
clearValidate: (fields?: string | string[]) => formRef.value?.clearValidate(fields),
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-form ref="formRef" class="transport-demand-form" :model="model" :rules="rules" label-position="top" :disabled="disabled || submitting">
|
||||||
|
<slot name="customer" />
|
||||||
|
<section class="transport-demand-section">
|
||||||
|
<h3>运输信息</h3>
|
||||||
|
<div class="transport-demand-grid">
|
||||||
|
<el-form-item v-if="kind === 'truck'" class="transport-demand-wide" label="车辆类型" prop="vehicleType">
|
||||||
|
<el-select v-model="model.vehicleType" placeholder="请选择车辆类型"><el-option label="15拖" value="PALLET_15" /><el-option label="30拖" value="PALLET_30" /></el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="起运地" prop="origin"><el-input v-model.trim="model.origin" maxlength="255" placeholder="城市 / 港口 / 州" /></el-form-item>
|
||||||
|
<el-form-item label="目的地" prop="destination"><el-input v-model.trim="model.destination" maxlength="255" placeholder="城市 / 州" /></el-form-item>
|
||||||
|
<el-form-item label="起运地联系人" prop="originContactName"><el-input v-model.trim="model.originContactName" maxlength="128" placeholder="请输入起运地联系人" /></el-form-item>
|
||||||
|
<el-form-item label="目的地联系人" prop="destinationContactName"><el-input v-model.trim="model.destinationContactName" maxlength="128" placeholder="请输入目的地联系人" /></el-form-item>
|
||||||
|
<el-form-item label="起运地联系电话" prop="originContactPhone"><el-input v-model.trim="model.originContactPhone" type="tel" maxlength="128" placeholder="请输入起运地联系电话" /></el-form-item>
|
||||||
|
<el-form-item label="目的地联系电话" prop="destinationContactPhone"><el-input v-model.trim="model.destinationContactPhone" type="tel" maxlength="128" placeholder="请输入目的地联系电话" /></el-form-item>
|
||||||
|
<el-form-item label="起运详细地址"><el-input v-model.trim="model.originAddress" maxlength="500" placeholder="街道 / 门牌等补充信息(选填)" /></el-form-item>
|
||||||
|
<el-form-item label="目的详细地址"><el-input v-model.trim="model.destinationAddress" maxlength="500" placeholder="街道 / 门牌等补充信息(选填)" /></el-form-item>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="transport-demand-section">
|
||||||
|
<h3>货物信息</h3>
|
||||||
|
<div class="transport-demand-grid" :class="kind === 'looseboard' ? 'transport-demand-cargo-four' : 'transport-demand-cargo-three'">
|
||||||
|
<el-form-item v-if="kind === 'looseboard'" label="商品名称"><el-input v-model.trim="model.productName" maxlength="255" placeholder="请输入商品名称(选填)" /></el-form-item>
|
||||||
|
<el-form-item v-if="kind === 'looseboard'" label="预计托数" prop="estimatedPalletCount"><el-input-number v-model="model.estimatedPalletCount" :min="0" :precision="0" controls-position="right" placeholder="请输入预计托数" /></el-form-item>
|
||||||
|
<el-form-item v-if="kind === 'truck'" label="件数" prop="packageCount"><el-input-number v-model="model.packageCount" :min="0" :precision="0" controls-position="right" placeholder="请输入件数" /></el-form-item>
|
||||||
|
<el-form-item label="总重量 (kg)" prop="totalWeightKg"><el-input-number v-model="model.totalWeightKg" :min="0" :precision="3" controls-position="right" placeholder="请输入总重量" /></el-form-item>
|
||||||
|
<el-form-item label="总体积 (m³)" prop="totalVolumeCbm"><el-input-number v-model="model.totalVolumeCbm" :min="0" :precision="6" controls-position="right" placeholder="请输入总体积" /></el-form-item>
|
||||||
|
<el-form-item v-if="kind === 'looseboard'" label="件数" prop="packageCount"><el-input-number v-model="model.packageCount" :min="0" :precision="0" controls-position="right" placeholder="请输入件数" /></el-form-item>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="transport-demand-section">
|
||||||
|
<h3>费用与时效</h3>
|
||||||
|
<div class="transport-demand-grid">
|
||||||
|
<el-form-item label="预算总价" prop="budgetAmount"><div class="transport-demand-budget">
|
||||||
|
<el-input-number v-model="model.budgetAmount" :min="0" :precision="2" controls-position="right" placeholder="请输入预算总价(选填)" />
|
||||||
|
<el-select v-model="model.currency" aria-label="预算币种" placeholder="请选择预算币种"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /></el-select>
|
||||||
|
</div></el-form-item>
|
||||||
|
<div />
|
||||||
|
<el-form-item label="取货时间" prop="pickupAt"><el-date-picker v-model="model.pickupAt" type="datetime" format="YYYY-MM-DD HH:mm" time-format="HH:mm" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择取货日期和时间" @change="formRef?.validateField('deliveryAt').catch(() => undefined)" /></el-form-item>
|
||||||
|
<el-form-item label="送货时间" prop="deliveryAt"><el-date-picker v-model="model.deliveryAt" type="datetime" format="YYYY-MM-DD HH:mm" time-format="HH:mm" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择送货日期和时间" /></el-form-item>
|
||||||
|
<el-form-item class="transport-demand-wide" label="备注"><el-input v-model="model.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="请输入其他要求(选填)" /></el-form-item>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<footer class="transport-demand-actions">
|
||||||
|
<el-button :disabled="submitting" @click="emit('cancel')">取消</el-button>
|
||||||
|
<el-button v-if="canSubmit" type="primary" :loading="submitting" :disabled="disabled" @click="emit('submit')">{{ editing ? '保存修改' : '提交订单' }}</el-button>
|
||||||
|
</footer>
|
||||||
|
</el-form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.transport-demand-form { --el-component-size: 36px; width: 100%; max-width: 1180px; margin: 0; background: #fff; }
|
||||||
|
.transport-demand-section { padding: 20px 24px 0; border-bottom: 1px solid #e7ebf1; }
|
||||||
|
.transport-demand-section h3 { margin: 0 0 16px; color: #27364d; font-size: 14px; font-weight: 600; }
|
||||||
|
.transport-demand-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 24px; }
|
||||||
|
.transport-demand-cargo-three { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||||
|
.transport-demand-cargo-four { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||||
|
.transport-demand-wide { grid-column: 1 / -1; }
|
||||||
|
.transport-demand-form :deep(.el-form-item) { min-width: 0; margin-bottom: 18px; }
|
||||||
|
.transport-demand-form :deep(.el-form-item__label) { height: auto; padding-bottom: 6px; font-size: 13px; line-height: 20px; }
|
||||||
|
.transport-demand-form :deep(.el-input), .transport-demand-form :deep(.el-input-number), .transport-demand-form :deep(.el-select), .transport-demand-form :deep(.el-date-editor) { width: 100%; }
|
||||||
|
.transport-demand-budget { display: grid; width: 100%; grid-template-columns: minmax(0, 1fr) 100px; gap: 10px; }
|
||||||
|
.transport-demand-actions { display: flex; justify-content: flex-end; gap: 10px; padding: 18px 24px; }
|
||||||
|
.transport-demand-actions :deep(.el-button + .el-button) { margin-left: 0; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { runInNewContext } from 'node:vm'
|
||||||
|
import test from 'node:test'
|
||||||
|
import ts from 'typescript'
|
||||||
|
|
||||||
|
const source = readFileSync(new URL('./transportDemandForm.ts', import.meta.url), 'utf8')
|
||||||
|
const code = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext } }).outputText.replace(/^export /gm, '')
|
||||||
|
const { createTransportDemandForm, transportDemandError, buildTransportDemand } = runInNewContext(`${code}\n({ createTransportDemandForm, transportDemandError, buildTransportDemand })`)
|
||||||
|
const valid = () => ({ ...createTransportDemandForm(), vehicleType: 'PALLET_15', origin: ' LA ', destination: ' ONT ', originContactName: ' 发货人 ', originContactPhone: ' +1-555-0100 ', destinationContactName: ' 收货人 ', destinationContactPhone: ' +1-555-0101 ', pickupAt: '2026-10-20T09:30:45', deliveryAt: '2026-10-20T15:45:12' })
|
||||||
|
|
||||||
|
test('initial form leaves contacts and times blank, zeroes cargo and distinguishes blank budget from zero', () => {
|
||||||
|
const form = createTransportDemandForm()
|
||||||
|
for (const key of ['originContactName', 'originContactPhone', 'destinationContactName', 'destinationContactPhone', 'pickupAt', 'deliveryAt']) assert.equal(form[key], '')
|
||||||
|
for (const key of ['estimatedPalletCount', 'packageCount', 'totalWeightKg', 'totalVolumeCbm']) assert.equal(form[key], 0)
|
||||||
|
assert.equal(form.budgetAmount, undefined)
|
||||||
|
assert.equal(form.currency, 'USD')
|
||||||
|
assert.equal(buildTransportDemand('truck', valid()).budgetAmount, undefined)
|
||||||
|
assert.equal(buildTransportDemand('truck', { ...valid(), budgetAmount: 0 }).budgetAmount, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const kind of ['truck', 'looseboard']) {
|
||||||
|
test(`${kind} requires both route contacts and both service times before building a payload`, () => {
|
||||||
|
for (const field of ['origin', 'destination', 'originContactName', 'originContactPhone', 'destinationContactName', 'destinationContactPhone', 'pickupAt', 'deliveryAt']) {
|
||||||
|
const form = { ...valid(), [field]: '' }
|
||||||
|
assert.ok(transportDemandError(kind, form), field)
|
||||||
|
assert.throws(() => buildTransportDemand(kind, form), undefined, field)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
test(`${kind} rejects negative cargo, fractional counts, invalid budgets and reversed times`, () => {
|
||||||
|
for (const field of ['packageCount', 'totalWeightKg', 'totalVolumeCbm', 'budgetAmount']) {
|
||||||
|
assert.ok(transportDemandError(kind, { ...valid(), [field]: -1 }), field)
|
||||||
|
assert.ok(transportDemandError(kind, { ...valid(), [field]: Number.NaN }), field)
|
||||||
|
}
|
||||||
|
assert.ok(transportDemandError(kind, { ...valid(), packageCount: 1.5 }))
|
||||||
|
assert.ok(transportDemandError(kind, { ...valid(), deliveryAt: '2026-10-20T09:29:00' }))
|
||||||
|
assert.ok(transportDemandError(kind, { ...valid(), currency: 'EUR' }))
|
||||||
|
})
|
||||||
|
test(`${kind} sends minute wall-clock values, trims contacts, and excludes customer and old fields`, () => {
|
||||||
|
const payload = buildTransportDemand(kind, { ...valid(), customerId: 'forged', tenantId: 'forged', transportMode: 'OLD', containerType: '40HQ' })
|
||||||
|
assert.equal(payload.pickupAt, '2026-10-20T09:30:00')
|
||||||
|
assert.equal(payload.deliveryAt, '2026-10-20T15:45:00')
|
||||||
|
assert.equal(payload.origin, 'LA')
|
||||||
|
assert.equal(payload.originContactName, '发货人')
|
||||||
|
assert.equal(payload.destinationContactPhone, '+1-555-0101')
|
||||||
|
for (const key of ['customerId', 'tenantId', 'transportMode', 'transportType', 'containerType', 'contactName', 'contactInfo', 'requestedDate']) assert.equal(Object.hasOwn(payload, key), false, key)
|
||||||
|
assert.equal(transportDemandError(kind, { ...valid(), deliveryAt: '2026-10-20T09:30:01' }), undefined)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test('truck accepts only the two vehicle types and does not submit looseboard cargo forecasts', () => {
|
||||||
|
assert.ok(transportDemandError('truck', { ...valid(), vehicleType: 'PALLET_99' }))
|
||||||
|
const truck = buildTransportDemand('truck', { ...valid(), productName: 'cargo', estimatedPalletCount: 3 })
|
||||||
|
assert.equal(Object.hasOwn(truck, 'productName'), false)
|
||||||
|
assert.equal(Object.hasOwn(truck, 'estimatedPalletCount'), false)
|
||||||
|
const loose = buildTransportDemand('looseboard', { ...valid(), vehicleType: '', productName: ' cargo ', estimatedPalletCount: 3, packageCount: 8 })
|
||||||
|
assert.equal(Object.hasOwn(loose, 'vehicleType'), false)
|
||||||
|
assert.equal(loose.productName, 'cargo')
|
||||||
|
assert.equal(loose.estimatedPalletCount, 3)
|
||||||
|
assert.equal(loose.packageCount, 8)
|
||||||
|
})
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import type { FormRules } from 'element-plus'
|
||||||
|
|
||||||
|
export type TransportDemandKind = 'truck' | 'looseboard'
|
||||||
|
export interface TransportDemandForm {
|
||||||
|
vehicleType: '' | 'PALLET_15' | 'PALLET_30'
|
||||||
|
origin: string
|
||||||
|
destination: string
|
||||||
|
originAddress: string
|
||||||
|
destinationAddress: string
|
||||||
|
originContactName: string
|
||||||
|
originContactPhone: string
|
||||||
|
destinationContactName: string
|
||||||
|
destinationContactPhone: string
|
||||||
|
productName: string
|
||||||
|
estimatedPalletCount: number
|
||||||
|
packageCount: number
|
||||||
|
totalWeightKg: number
|
||||||
|
totalVolumeCbm: number
|
||||||
|
budgetAmount: number | undefined
|
||||||
|
currency: 'USD' | 'CNY'
|
||||||
|
pickupAt: string
|
||||||
|
deliveryAt: string
|
||||||
|
remark: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTransportDemandForm(): TransportDemandForm {
|
||||||
|
return {
|
||||||
|
vehicleType: '', origin: '', destination: '', originAddress: '', destinationAddress: '',
|
||||||
|
originContactName: '', originContactPhone: '', destinationContactName: '', destinationContactPhone: '',
|
||||||
|
productName: '', estimatedPalletCount: 0, packageCount: 0, totalWeightKg: 0, totalVolumeCbm: 0,
|
||||||
|
budgetAmount: undefined, currency: 'USD', pickupAt: '', deliveryAt: '', remark: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const requiredText = {
|
||||||
|
origin: '起运地', destination: '目的地', originContactName: '起运地联系人',
|
||||||
|
originContactPhone: '起运地联系电话', destinationContactName: '目的地联系人', destinationContactPhone: '目的地联系电话',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export function localMinuteInput(value: unknown) {
|
||||||
|
return typeof value === 'string' && value ? `${value.slice(0, 16)}:00` : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function transportDemandRules(kind: TransportDemandKind, form: TransportDemandForm): FormRules<TransportDemandForm> {
|
||||||
|
const rules: FormRules<TransportDemandForm> = {}
|
||||||
|
for (const [field, label] of Object.entries(requiredText)) {
|
||||||
|
rules[field as keyof typeof requiredText] = [{ required: true, whitespace: true, message: `请输入${label}`, trigger: 'blur' }]
|
||||||
|
}
|
||||||
|
if (kind === 'truck') rules.vehicleType = [{ required: true, message: '请选择车辆类型', trigger: 'change' }]
|
||||||
|
for (const [field, label] of [['packageCount', '件数'], ['estimatedPalletCount', '预计托数'], ['totalWeightKg', '总重量'], ['totalVolumeCbm', '总体积']] as const) {
|
||||||
|
if (field === 'estimatedPalletCount' && kind !== 'looseboard') continue
|
||||||
|
rules[field] = [{ required: true, type: 'number', min: 0, message: `${label}须为不小于 0 的数字`, trigger: 'change' }]
|
||||||
|
}
|
||||||
|
rules.budgetAmount = [{ type: 'number', min: 0, message: '预算总价不能小于 0', trigger: 'change' }]
|
||||||
|
rules.pickupAt = [{ required: true, message: '请选择取货时间', trigger: 'change' }]
|
||||||
|
rules.deliveryAt = [{ required: true, message: '请选择送货时间', trigger: 'change' }, {
|
||||||
|
validator: (_rule, value, callback) => callback(value && form.pickupAt && localMinuteInput(value) < localMinuteInput(form.pickupAt) ? new Error('送货时间不能早于取货时间') : undefined), trigger: 'change',
|
||||||
|
}]
|
||||||
|
return rules
|
||||||
|
}
|
||||||
|
|
||||||
|
export function transportDemandError(kind: TransportDemandKind, form: TransportDemandForm): string | undefined {
|
||||||
|
if (kind === 'truck' && !['PALLET_15', 'PALLET_30'].includes(form.vehicleType)) return '请选择车辆类型'
|
||||||
|
for (const [field, label] of Object.entries(requiredText)) {
|
||||||
|
if (!String(form[field as keyof typeof requiredText] || '').trim()) return `请输入${label}`
|
||||||
|
}
|
||||||
|
for (const [field, label] of [['pickupAt', '取货时间'], ['deliveryAt', '送货时间']] as const) {
|
||||||
|
if (!form[field]) return `请选择${label}`
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?$/.test(form[field])) return `请选择有效的${label}`
|
||||||
|
}
|
||||||
|
if (localMinuteInput(form.deliveryAt) < localMinuteInput(form.pickupAt)) return '送货时间不能早于取货时间'
|
||||||
|
for (const [field, label] of [['packageCount', '件数'], ['totalWeightKg', '总重量'], ['totalVolumeCbm', '总体积'], ...(kind === 'looseboard' ? [['estimatedPalletCount', '预计托数'] as const] : [])] as const) {
|
||||||
|
if (!Number.isFinite(form[field]) || form[field] < 0) return `${label}须为不小于 0 的数字`
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(form.packageCount) || kind === 'looseboard' && !Number.isInteger(form.estimatedPalletCount)) return '件数和预计托数须为整数'
|
||||||
|
if (form.budgetAmount != null && (!Number.isFinite(form.budgetAmount) || form.budgetAmount < 0)) return '预算总价不能小于 0'
|
||||||
|
if (!['USD', 'CNY'].includes(form.currency)) return '请选择预算币种'
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function commonPayload(form: TransportDemandForm) {
|
||||||
|
return {
|
||||||
|
origin: form.origin.trim(), destination: form.destination.trim(),
|
||||||
|
originAddress: form.originAddress.trim() || undefined, destinationAddress: form.destinationAddress.trim() || undefined,
|
||||||
|
originContactName: form.originContactName.trim(), originContactPhone: form.originContactPhone.trim(),
|
||||||
|
destinationContactName: form.destinationContactName.trim(), destinationContactPhone: form.destinationContactPhone.trim(),
|
||||||
|
packageCount: form.packageCount, totalWeightKg: form.totalWeightKg, totalVolumeCbm: form.totalVolumeCbm,
|
||||||
|
budgetAmount: form.budgetAmount ?? undefined, currency: form.currency,
|
||||||
|
pickupAt: localMinuteInput(form.pickupAt), deliveryAt: localMinuteInput(form.deliveryAt), remark: form.remark.trim() || undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type CommonPayload = ReturnType<typeof commonPayload>
|
||||||
|
type TruckPayload = CommonPayload & { vehicleType: 'PALLET_15' | 'PALLET_30' }
|
||||||
|
type LooseboardPayload = CommonPayload & { productName: string | undefined; estimatedPalletCount: number }
|
||||||
|
|
||||||
|
export function buildTransportDemand(kind: 'truck', form: TransportDemandForm): TruckPayload
|
||||||
|
export function buildTransportDemand(kind: 'looseboard', form: TransportDemandForm): LooseboardPayload
|
||||||
|
export function buildTransportDemand(kind: TransportDemandKind, form: TransportDemandForm) {
|
||||||
|
const failure = transportDemandError(kind, form)
|
||||||
|
if (failure) throw new Error(failure)
|
||||||
|
const common = commonPayload(form)
|
||||||
|
return kind === 'truck'
|
||||||
|
? { ...common, vehicleType: form.vehicleType as 'PALLET_15' | 'PALLET_30' }
|
||||||
|
: { ...common, productName: form.productName.trim() || undefined, estimatedPalletCount: form.estimatedPalletCount }
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# 头程下单交货方式调整
|
||||||
|
|
||||||
|
依据:当前对话 2026-10-19 用户确认“卡车自送排第一,我司提货排第二,快递自提排第三;快递自提增加非必填快递单号”。该确认优先于历史原型对应字段。
|
||||||
|
|
||||||
|
| 页面 | 操作 | 承载方式 | 布局 | 当前实现 | 结论 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 头程下单 | 新增 | 现有独立页面 | 保持现有国内交货区布局 | 快递自送、我司提货、卡车自送;无快递单号输入 | 按确认顺序排列;第三项改为快递自提,下方条件显示选填快递单号 |
|
||||||
|
| 头程订单 | 编辑草稿 | 现有居中弹框 | 复用新增表单 | 无快递单号输入 | 同步新增规则,回显已保存单号 |
|
||||||
|
| 头程订单 | 详情 | 现有独立页面 | 保持现有信息区 | 已有快递单号只读展示 | 复用当前展示;说明客户可在快递自提草稿填写单号 |
|
||||||
|
|
||||||
|
自动检查与人工视觉验收分别记录;1920×1080、100% 缩放的页面效果待人工截图确认。
|
||||||
@@ -139,6 +139,7 @@ export interface FirstLegOrderSaveRequest {
|
|||||||
privateDestinationAddressId?: number | string
|
privateDestinationAddressId?: number | string
|
||||||
cargoes: FirstLegOrderCargoPayload[]
|
cargoes: FirstLegOrderCargoPayload[]
|
||||||
pickupMethod: ApiPickupMethod
|
pickupMethod: ApiPickupMethod
|
||||||
|
expressTrackingNo?: string
|
||||||
receivingWarehouseId: number | string
|
receivingWarehouseId: number | string
|
||||||
receivingWarehouseName: string
|
receivingWarehouseName: string
|
||||||
receivingWarehouseAddress?: string | null
|
receivingWarehouseAddress?: string | null
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ export interface MerchantTransportFragment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface MerchantUnifiedOrderDetail {
|
export interface MerchantUnifiedOrderDetail {
|
||||||
|
progress: MerchantOrderProgress
|
||||||
transportFragments: MerchantTransportFragment[]
|
transportFragments: MerchantTransportFragment[]
|
||||||
summary: MerchantUnifiedOrderRow
|
summary: MerchantUnifiedOrderRow
|
||||||
order: MerchantFirstLegOrderDetail
|
order: MerchantFirstLegOrderDetail
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { readFileSync } from 'node:fs'
|
|||||||
import ts from 'typescript'
|
import ts from 'typescript'
|
||||||
import * as vue from 'vue'
|
import * as vue from 'vue'
|
||||||
import * as pinia from 'pinia'
|
import * as pinia from 'pinia'
|
||||||
|
import { requireOrderProgress } from '../utils/orderProgress.ts'
|
||||||
|
|
||||||
function compile(path, dependencies) {
|
function compile(path, dependencies) {
|
||||||
const source = readFileSync(new URL(path, import.meta.url), 'utf8')
|
const source = readFileSync(new URL(path, import.meta.url), 'utf8')
|
||||||
@@ -31,10 +32,11 @@ function harness(t) {
|
|||||||
vue, pinia,
|
vue, pinia,
|
||||||
'@/modules/first-leg/api/firstLeg': api,
|
'@/modules/first-leg/api/firstLeg': api,
|
||||||
'@/modules/first-leg/api/unifiedOrders': {
|
'@/modules/first-leg/api/unifiedOrders': {
|
||||||
getMerchantUnifiedOrder: async () => ({ summary: { orderId: 'one', mainStatus: null }, order: { order: { id: 'one', status: behavior.status, version: behavior.version, receivingWarehouseId: 'warehouse', receivingWarehouseAddress: behavior.receivingWarehouseAddress } }, timeline: [] }),
|
getMerchantUnifiedOrder: async () => ({ progress: { orderId: 'one', stage: 'none', partial: false }, summary: { orderId: 'one', mainStatus: null }, order: { order: { id: 'one', status: behavior.status, version: behavior.version, receivingWarehouseId: 'warehouse', receivingWarehouseAddress: behavior.receivingWarehouseAddress } }, timeline: [] }),
|
||||||
},
|
},
|
||||||
'@/modules/first-leg/utils/firstLegOrder': { canClientEditFirstLegOrder: status => status === 'DRAFT', getFirstLegStatusLabel: value => value },
|
'@/modules/first-leg/utils/firstLegOrder': { canClientEditFirstLegOrder: status => status === 'DRAFT', getFirstLegStatusLabel: value => value },
|
||||||
'@/modules/first-leg/utils/warehousePagination': {},
|
'@/modules/first-leg/utils/warehousePagination': {},
|
||||||
|
'@/modules/first-leg/utils/orderProgress': { requireOrderProgress },
|
||||||
})
|
})
|
||||||
const store = module.useFirstLegOrdersStore()
|
const store = module.useFirstLegOrdersStore()
|
||||||
store.warehouses = [{ id: 'warehouse', name: '国内仓', warehouseType: 'DOMESTIC' }]
|
store.warehouses = [{ id: 'warehouse', name: '国内仓', warehouseType: 'DOMESTIC' }]
|
||||||
|
|||||||
@@ -8,6 +8,47 @@ const storeSource = readFileSync(new URL('./firstLegOrders.ts', import.meta.url)
|
|||||||
const apiSource = readFileSync(new URL('../api/firstLeg.ts', import.meta.url), 'utf8')
|
const apiSource = readFileSync(new URL('../api/firstLeg.ts', import.meta.url), 'utf8')
|
||||||
const unifiedApiSource = readFileSync(new URL('../api/unifiedOrders.ts', import.meta.url), 'utf8')
|
const unifiedApiSource = readFileSync(new URL('../api/unifiedOrders.ts', import.meta.url), 'utf8')
|
||||||
|
|
||||||
|
test('saves optional express tracking, trims it, and omits it for other pickup methods', () => {
|
||||||
|
const functions = storeSource.slice(storeSource.indexOf('function apiTransport('), storeSource.indexOf('function prepareDraftForSave('))
|
||||||
|
const javascript = ts.transpileModule(functions, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText
|
||||||
|
const save = runInNewContext(`${javascript}; savePayload`)
|
||||||
|
const draft = { pickupMethod: 'SELF_EXPRESS', trackingNumber: ' SF123456789 ', destinationCountryCode: 'US', destinationType: 'OVERSEAS', items: [] }
|
||||||
|
const warehouse = { id: '1', name: '收货仓' }
|
||||||
|
assert.equal(save(draft, warehouse, '客户').expressTrackingNo, 'SF123456789')
|
||||||
|
for (const trackingNumber of ['', ' ']) {
|
||||||
|
assert.equal(save({ ...draft, trackingNumber }, warehouse, '客户').expressTrackingNo, undefined)
|
||||||
|
}
|
||||||
|
for (const pickupMethod of ['SELF_TRUCK', 'PICKUP']) {
|
||||||
|
assert.equal(save({ ...draft, pickupMethod }, warehouse, '客户').expressTrackingNo, undefined)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('detail fetches authoritative progress on direct entry and refresh, and clears stale progress on failure', async () => {
|
||||||
|
const functionSource = storeSource.slice(storeSource.indexOf(' async function loadDetail('), storeSource.indexOf(' async function loadWarehouseDetail('))
|
||||||
|
const progressSource = readFileSync(new URL('../utils/orderProgress.ts', import.meta.url), 'utf8')
|
||||||
|
const validationSource = progressSource.slice(progressSource.indexOf('export function requireOrderProgress(')).replace('export ', '')
|
||||||
|
const javascript = ts.transpileModule(`${validationSource}\n${functionSource}`, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText
|
||||||
|
const id = '2110663764278312961'
|
||||||
|
const orderProgress = { value: {} }
|
||||||
|
let calls = 0
|
||||||
|
let response = { progress: { orderId: id, stage: 'scheduled', partial: true } }
|
||||||
|
const loadDetail = runInNewContext(`${javascript}; loadDetail`, {
|
||||||
|
orderProgress, warehouses: { value: [] },
|
||||||
|
getMerchantUnifiedOrder: async requested => { assert.equal(requested, id); calls++; return response },
|
||||||
|
mapUnifiedOrderDetail: detail => ({ id, detail }), upsertOrder: order => order,
|
||||||
|
})
|
||||||
|
await loadDetail(id)
|
||||||
|
assert.equal(orderProgress.value[id].stage, 'scheduled')
|
||||||
|
assert.equal(orderProgress.value[id].partial, true)
|
||||||
|
response = { progress: { orderId: id, stage: 'delivering', partial: false } }
|
||||||
|
await loadDetail(id)
|
||||||
|
assert.equal(calls, 2)
|
||||||
|
assert.equal(orderProgress.value[id].stage, 'delivering')
|
||||||
|
response = { progress: { orderId: 'another-order', stage: 'delivered', partial: false } }
|
||||||
|
await assert.rejects(loadDetail(id), /订单履约进度不完整/)
|
||||||
|
assert.equal(orderProgress.value[id], undefined)
|
||||||
|
})
|
||||||
|
|
||||||
test('renders estimated sailing from the current API without substituting actual departure', () => {
|
test('renders estimated sailing from the current API without substituting actual departure', () => {
|
||||||
const mapper = storeSource.slice(storeSource.indexOf('function businessSummary('), storeSource.indexOf('\nconst unifiedActionLabels'))
|
const mapper = storeSource.slice(storeSource.indexOf('function businessSummary('), storeSource.indexOf('\nconst unifiedActionLabels'))
|
||||||
const javascript = ts.transpileModule(mapper, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText
|
const javascript = ts.transpileModule(mapper, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import type { FirstLegPartySnapshot } from '@/modules/first-leg/data/firstLegPar
|
|||||||
import type { FirstLegOrder, FirstLegOrderDraft, FirstLegOrderTimelineItem } from '@/modules/first-leg/types/firstLegOrder'
|
import type { FirstLegOrder, FirstLegOrderDraft, FirstLegOrderTimelineItem } from '@/modules/first-leg/types/firstLegOrder'
|
||||||
import { canClientEditFirstLegOrder, getFirstLegStatusLabel } from '@/modules/first-leg/utils/firstLegOrder'
|
import { canClientEditFirstLegOrder, getFirstLegStatusLabel } from '@/modules/first-leg/utils/firstLegOrder'
|
||||||
import { loadAllWarehouseRecords } from '@/modules/first-leg/utils/warehousePagination'
|
import { loadAllWarehouseRecords } from '@/modules/first-leg/utils/warehousePagination'
|
||||||
|
import { requireOrderProgress } from '@/modules/first-leg/utils/orderProgress'
|
||||||
|
|
||||||
export type ClientWarehouseType = 'DOMESTIC' | 'FBA' | 'OVERSEAS'
|
export type ClientWarehouseType = 'DOMESTIC' | 'FBA' | 'OVERSEAS'
|
||||||
export type ClientWarehouse = PlatformReceivingWarehouse & {
|
export type ClientWarehouse = PlatformReceivingWarehouse & {
|
||||||
@@ -119,6 +120,7 @@ function savePayload(draft: FirstLegOrderDraft, warehouse: ClientWarehouse, cust
|
|||||||
productImageFileId: item.productImageFileId || undefined,
|
productImageFileId: item.productImageFileId || undefined,
|
||||||
})),
|
})),
|
||||||
pickupMethod: draft.pickupMethod,
|
pickupMethod: draft.pickupMethod,
|
||||||
|
expressTrackingNo: draft.pickupMethod === 'SELF_EXPRESS' ? draft.trackingNumber.trim() || undefined : undefined,
|
||||||
receivingWarehouseId: warehouse.id,
|
receivingWarehouseId: warehouse.id,
|
||||||
receivingWarehouseName: warehouse.name,
|
receivingWarehouseName: warehouse.name,
|
||||||
channelCode: draft.channelCode,
|
channelCode: draft.channelCode,
|
||||||
@@ -507,8 +509,12 @@ export const useFirstLegOrdersStore = defineStore('client-first-leg-orders', ()
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadDetail(id: string) {
|
async function loadDetail(id: string) {
|
||||||
|
delete orderProgress.value[id]
|
||||||
const detail = await getMerchantUnifiedOrder(id)
|
const detail = await getMerchantUnifiedOrder(id)
|
||||||
return upsertOrder(mapUnifiedOrderDetail(detail, warehouses.value))
|
const progress = requireOrderProgress(id, detail.progress)
|
||||||
|
const mapped = mapUnifiedOrderDetail(detail, warehouses.value)
|
||||||
|
orderProgress.value[id] = progress
|
||||||
|
return upsertOrder(mapped)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadWarehouseDetail(id: string) {
|
async function loadWarehouseDetail(id: string) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { readFileSync } from 'node:fs'
|
|||||||
import ts from 'typescript'
|
import ts from 'typescript'
|
||||||
import * as vue from 'vue'
|
import * as vue from 'vue'
|
||||||
import * as pinia from 'pinia'
|
import * as pinia from 'pinia'
|
||||||
|
import { requireOrderProgress } from '../utils/orderProgress.ts'
|
||||||
|
|
||||||
const source = readFileSync(new URL('./firstLegOrders.ts', import.meta.url), 'utf8')
|
const source = readFileSync(new URL('./firstLegOrders.ts', import.meta.url), 'utf8')
|
||||||
const code = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText
|
const code = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText
|
||||||
@@ -27,11 +28,12 @@ function harness(t, read) {
|
|||||||
getMerchantOrderWorkbench: query => { calls.push(structuredClone(query)); return read(query) },
|
getMerchantOrderWorkbench: query => { calls.push(structuredClone(query)); return read(query) },
|
||||||
getMerchantUnifiedOrder: async id => {
|
getMerchantUnifiedOrder: async id => {
|
||||||
details.push(id)
|
details.push(id)
|
||||||
return { summary: row(id, 'DRAFT'), order: { order: { id, status: 'DRAFT', version: 7 } }, timeline: [] }
|
return { progress: { orderId: id, stage: 'none', partial: false }, summary: row(id, 'DRAFT'), order: { order: { id, status: 'DRAFT', version: 7 } }, timeline: [] }
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'@/modules/first-leg/utils/firstLegOrder': { getFirstLegStatusLabel: value => value },
|
'@/modules/first-leg/utils/firstLegOrder': { getFirstLegStatusLabel: value => value },
|
||||||
'@/modules/first-leg/utils/warehousePagination': { loadAllWarehouseRecords: async type => { warehouseCalls.push(type); return [] } },
|
'@/modules/first-leg/utils/warehousePagination': { loadAllWarehouseRecords: async type => { warehouseCalls.push(type); return [] } },
|
||||||
|
'@/modules/first-leg/utils/orderProgress': { requireOrderProgress },
|
||||||
}
|
}
|
||||||
const exports = {}
|
const exports = {}
|
||||||
new Function('require', 'exports', code)(id => { assert.ok(id in deps, id); return deps[id] }, exports)
|
new Function('require', 'exports', code)(id => { assert.ok(id in deps, id); return deps[id] }, exports)
|
||||||
|
|||||||
@@ -183,6 +183,7 @@ export type FirstLegOrderDraft = Omit<Pick<
|
|||||||
| 'destinationWarehouseId'
|
| 'destinationWarehouseId'
|
||||||
| 'destinationWarehouseName'
|
| 'destinationWarehouseName'
|
||||||
| 'pickupMethod'
|
| 'pickupMethod'
|
||||||
|
| 'trackingNumber'
|
||||||
| 'channelCode'
|
| 'channelCode'
|
||||||
| 'channelName'
|
| 'channelName'
|
||||||
| 'pricingTaxMode'
|
| 'pricingTaxMode'
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
import { orderBillingMetric } from './orderDetailSummary.ts'
|
||||||
|
|
||||||
|
test('detail uses the published billing quantity without substituting physical gross weight', () => {
|
||||||
|
const billing = { inbound: { summary: { actualGrossWeightKg: 23, chargeableWeightKg: null } },
|
||||||
|
billingBasis: { chargeUnit: 'KG', chargeableQuantity: 17.0006, chargeableWeightKg: null } }
|
||||||
|
assert.deepEqual(orderBillingMetric(billing, null), { label: '计费重量', quantity: 17.0006, unit: 'kg' })
|
||||||
|
assert.equal(orderBillingMetric(billing, null).quantity.toLocaleString('zh-CN'), '17.001')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('volume charging retains its real unit and missing billing does not invent a quantity', () => {
|
||||||
|
assert.deepEqual(orderBillingMetric({ billingBasis: { chargeUnit: 'CBM', chargeableQuantity: 0.102 } }, 23),
|
||||||
|
{ label: '计费体积', quantity: 0.102, unit: 'm³' })
|
||||||
|
assert.equal(orderBillingMetric(null, null).quantity, null)
|
||||||
|
assert.equal(orderBillingMetric(null, 0).quantity, 0)
|
||||||
|
})
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { MerchantInboundBilling } from '../types/inboundBilling'
|
||||||
|
|
||||||
|
/** Published billing quantity and physical gross weight have different meanings. */
|
||||||
|
export function orderBillingMetric(billing: MerchantInboundBilling | null, measuredChargeableWeight: number | null) {
|
||||||
|
if (billing) {
|
||||||
|
const basis = billing.billingBasis
|
||||||
|
return basis.chargeUnit === 'KG'
|
||||||
|
? { label: '计费重量', quantity: basis.chargeableQuantity, unit: 'kg' }
|
||||||
|
: basis.chargeUnit === 'CBM'
|
||||||
|
? { label: '计费体积', quantity: basis.chargeableQuantity, unit: 'm³' }
|
||||||
|
: { label: '计费量', quantity: null, unit: '' }
|
||||||
|
}
|
||||||
|
return { label: '计费重量', quantity: measuredChargeableWeight, unit: 'kg' }
|
||||||
|
}
|
||||||
@@ -13,3 +13,13 @@ test('prototype queues preserve order and distinguish partial progress from comp
|
|||||||
assert.equal(exports.orderProgressLabel({ stage: 'none', partial: false }), '—')
|
assert.equal(exports.orderProgressLabel({ stage: 'none', partial: false }), '—')
|
||||||
assert.equal(exports.orderProgressLabel(), '—')
|
assert.equal(exports.orderProgressLabel(), '—')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('detail progress must belong to the requested order and retain partial fulfillment', () => {
|
||||||
|
const progress = { orderId: '2110663764278312961', stage: 'scheduled', partial: true }
|
||||||
|
assert.equal(exports.requireOrderProgress(progress.orderId, progress), progress)
|
||||||
|
assert.equal(exports.orderProgressLabel(exports.requireOrderProgress(progress.orderId, progress)), '部分已排车')
|
||||||
|
assert.throws(() => exports.requireOrderProgress('other-order', progress), /订单履约进度不完整/)
|
||||||
|
assert.throws(() => exports.requireOrderProgress(progress.orderId, undefined), /订单履约进度不完整/)
|
||||||
|
assert.throws(() => exports.requireOrderProgress(progress.orderId, { ...progress, stage: 'UNKNOWN' }), /订单履约进度不完整/)
|
||||||
|
assert.throws(() => exports.requireOrderProgress(progress.orderId, { ...progress, partial: null }), /订单履约进度不完整/)
|
||||||
|
})
|
||||||
|
|||||||
@@ -18,3 +18,12 @@ export function orderProgressLabel(progress?: MerchantOrderProgress) {
|
|||||||
const label = ORDER_QUEUES.find(q => q.key === progress.stage)?.label ?? '—'
|
const label = ORDER_QUEUES.find(q => q.key === progress.stage)?.label ?? '—'
|
||||||
return `${progress.partial ? '部分' : ''}${label}`
|
return `${progress.partial ? '部分' : ''}${label}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function requireOrderProgress(orderId: string, progress: MerchantOrderProgress) {
|
||||||
|
if (!progress || String(progress.orderId) !== orderId
|
||||||
|
|| !['none', 'loaded', 'declared', 'cleared', 'scheduled', 'delivering', 'delivered'].includes(progress.stage)
|
||||||
|
|| typeof progress.partial !== 'boolean') {
|
||||||
|
throw new Error('订单履约进度不完整,请重新加载')
|
||||||
|
}
|
||||||
|
return progress
|
||||||
|
}
|
||||||
|
|||||||
@@ -223,7 +223,8 @@ test('shows one unified progress line and C02 business summaries without detail
|
|||||||
assert.match(source, /progressSummaryItems/)
|
assert.match(source, /progressSummaryItems/)
|
||||||
assert.match(source, /summary\.preloadStatus/)
|
assert.match(source, /summary\.preloadStatus/)
|
||||||
assert.match(source, /summary\.expectedPickCount/)
|
assert.match(source, /summary\.expectedPickCount/)
|
||||||
assert.match(source, /summary\.deliveryTrackingNo/)
|
assert.match(source, /label: '后续履约', value: orderProgressLabel\(store\.orderProgress\[order\.id\]\)/)
|
||||||
|
assert.doesNotMatch(source, /待海外同步/)
|
||||||
assert.match(progressSource, /FIRST_LEG_PROGRESS_STAGES/)
|
assert.match(progressSource, /FIRST_LEG_PROGRESS_STAGES/)
|
||||||
assert.match(progressSource, /订单主节点与关联柜次统一展示/)
|
assert.match(progressSource, /订单主节点与关联柜次统一展示/)
|
||||||
assert.match(progressSource, /履约进度/)
|
assert.match(progressSource, /履约进度/)
|
||||||
@@ -548,9 +549,19 @@ test('shows fulfillment courier information as read-only client detail', () => {
|
|||||||
assert.match(source, /履约快递信息/)
|
assert.match(source, /履约快递信息/)
|
||||||
assert.match(source, /detailOrder\.expressCompanyName/)
|
assert.match(source, /detailOrder\.expressCompanyName/)
|
||||||
assert.match(source, /detailOrder\.trackingNumber/)
|
assert.match(source, /detailOrder\.trackingNumber/)
|
||||||
assert.match(source, /客户端仅只读展示/)
|
assert.match(source, /快递公司由平台维护/)
|
||||||
assert.doesNotMatch(source, /v-model="form\.expressCompanyName"/)
|
assert.doesNotMatch(source, /v-model="form\.expressCompanyName"/)
|
||||||
assert.doesNotMatch(source, /v-model="form\.trackingNumber"/)
|
})
|
||||||
|
|
||||||
|
test('orders pickup choices and offers an optional tracking number only for express pickup', () => {
|
||||||
|
const group = source.match(/<el-radio-group v-model="form\.pickupMethod"[\s\S]*?<\/el-radio-group>/)[0]
|
||||||
|
assert.deepEqual([...group.matchAll(/value="([A-Z_]+)"/g)].map(match => match[1]), ['SELF_TRUCK', 'PICKUP', 'SELF_EXPRESS'])
|
||||||
|
assert.match(group, /value="SELF_EXPRESS">快递自提/)
|
||||||
|
const field = source.match(/<el-form-item v-if="form\.pickupMethod === 'SELF_EXPRESS'"[^>]*label="快递单号"[\s\S]*?<\/el-form-item>/)[0]
|
||||||
|
assert.match(field, /v-model="form\.trackingNumber" maxlength="128"/)
|
||||||
|
assert.doesNotMatch(field, /\brequired\b|:rules=/)
|
||||||
|
assert.match(source, /trackingNumber: order\.pickupMethod === 'SELF_EXPRESS' \? order\.trackingNumber : ''/)
|
||||||
|
assert.match(source, /if \(form\.pickupMethod !== 'SELF_EXPRESS'\) form\.trackingNumber = ''/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('shows the full customer-safe order detail without platform-only controls', () => {
|
test('shows the full customer-safe order detail without platform-only controls', () => {
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ import {
|
|||||||
validateFirstLegOrderDraft,
|
validateFirstLegOrderDraft,
|
||||||
} from '@/modules/first-leg/utils/firstLegOrder'
|
} from '@/modules/first-leg/utils/firstLegOrder'
|
||||||
import { ORDER_QUEUES, orderProgressLabel } from '../utils/orderProgress'
|
import { ORDER_QUEUES, orderProgressLabel } from '../utils/orderProgress'
|
||||||
|
import { orderBillingMetric } from '../utils/orderDetailSummary'
|
||||||
import type { OrderQueue } from '../api/unifiedOrders'
|
import type { OrderQueue } from '../api/unifiedOrders'
|
||||||
import { validateProductImageFile } from '@/modules/first-leg/utils/productImage'
|
import { validateProductImageFile } from '@/modules/first-leg/utils/productImage'
|
||||||
import { createPriceEstimateBreakdown } from '@/modules/first-leg/utils/priceEstimate'
|
import { createPriceEstimateBreakdown } from '@/modules/first-leg/utils/priceEstimate'
|
||||||
@@ -371,9 +372,9 @@ function formatExportDateTime(value: string | null | undefined) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pickupMethodLabels: Record<FirstLegOrder['pickupMethod'], string> = {
|
const pickupMethodLabels: Record<FirstLegOrder['pickupMethod'], string> = {
|
||||||
SELF_EXPRESS: '自行快递到仓',
|
SELF_TRUCK: '卡车自送',
|
||||||
PICKUP: '平台上门提货',
|
PICKUP: '我司提货',
|
||||||
SELF_TRUCK: '自行送货到仓',
|
SELF_EXPRESS: '快递自提',
|
||||||
}
|
}
|
||||||
const destinationTypeLabels: Record<FirstLegOrder['destinationType'], string> = {
|
const destinationTypeLabels: Record<FirstLegOrder['destinationType'], string> = {
|
||||||
FBA: 'FBA 仓',
|
FBA: 'FBA 仓',
|
||||||
@@ -423,6 +424,7 @@ function emptyDraft(): FirstLegOrderDraft {
|
|||||||
destinationWarehouseId: '',
|
destinationWarehouseId: '',
|
||||||
destinationWarehouseName: '',
|
destinationWarehouseName: '',
|
||||||
pickupMethod: 'SELF_EXPRESS',
|
pickupMethod: 'SELF_EXPRESS',
|
||||||
|
trackingNumber: '',
|
||||||
channelCode: '',
|
channelCode: '',
|
||||||
channelName: '',
|
channelName: '',
|
||||||
pricingTaxMode: 'TAX_INCLUDED',
|
pricingTaxMode: 'TAX_INCLUDED',
|
||||||
@@ -603,8 +605,10 @@ const pagedDetailCargoItems = computed(() => {
|
|||||||
const progressSummaryItems = computed(() => {
|
const progressSummaryItems = computed(() => {
|
||||||
const order = detailOrder.value
|
const order = detailOrder.value
|
||||||
if (!order || order.status === 'CANCELLED') return []
|
if (!order || order.status === 'CANCELLED') return []
|
||||||
const inbound = inboundBilling.value?.inbound.summary
|
const billing = inboundBillingOrderId.value === order.id ? inboundBilling.value : null
|
||||||
|
const inbound = billing?.inbound.summary
|
||||||
const summary = order.businessSummary
|
const summary = order.businessSummary
|
||||||
|
const billingMetric = orderBillingMetric(billing, summary.chargeableWeightKg)
|
||||||
const formatMetric = (value: number | null | undefined, unit = '') => value == null ? '待更新' : `${value.toLocaleString()}${unit ? ` ${unit}` : ''}`
|
const formatMetric = (value: number | null | undefined, unit = '') => value == null ? '待更新' : `${value.toLocaleString()}${unit ? ` ${unit}` : ''}`
|
||||||
const preloadStatus = ({ PENDING: '待确认', CONFIRMING: '确认中', CONFIRMED: '已确认' } as Record<string, string>)[summary.preloadStatus] ?? summary.preloadStatus
|
const preloadStatus = ({ PENDING: '待确认', CONFIRMING: '确认中', CONFIRMED: '已确认' } as Record<string, string>)[summary.preloadStatus] ?? summary.preloadStatus
|
||||||
const transportReference = [summary.vesselName, summary.voyageNo].filter(Boolean).join(' · ')
|
const transportReference = [summary.vesselName, summary.voyageNo].filter(Boolean).join(' · ')
|
||||||
@@ -615,12 +619,12 @@ const progressSummaryItems = computed(() => {
|
|||||||
{ label: '实际箱数', value: formatMetric(summary.totalPackageCount ?? inbound?.totalPackageCount, '箱') },
|
{ label: '实际箱数', value: formatMetric(summary.totalPackageCount ?? inbound?.totalPackageCount, '箱') },
|
||||||
{ label: '实测毛重', value: formatMetric(summary.actualGrossWeightKg ?? inbound?.actualGrossWeightKg, 'kg') },
|
{ label: '实测毛重', value: formatMetric(summary.actualGrossWeightKg ?? inbound?.actualGrossWeightKg, 'kg') },
|
||||||
{ label: '实测体积', value: formatMetric(summary.actualVolumeCbm ?? inbound?.actualVolumeCbm, 'm³') },
|
{ label: '实测体积', value: formatMetric(summary.actualVolumeCbm ?? inbound?.actualVolumeCbm, 'm³') },
|
||||||
{ label: '预装清单', value: preloadStatus ? `${preloadStatus}${summary.preloadItemCount == null ? '' : ` · ${summary.preloadItemCount} 项`}` : '待更新' },
|
{ label: '预装确认', value: preloadStatus || '待更新' },
|
||||||
{ label: '拣货进度', value: pickingProgress },
|
{ label: '拣货进度', value: pickingProgress },
|
||||||
{ label: '关联柜次', value: summary.cabinetNo || summary.containerNo || '—' },
|
{ label: '关联柜次', value: summary.cabinetNo || summary.containerNo || '—' },
|
||||||
{ label: '船期摘要', value: transportReference || '待更新' },
|
{ label: '船期摘要', value: transportReference || '待更新' },
|
||||||
{ label: '计费重量', value: formatMetric(summary.chargeableWeightKg, 'kg') },
|
{ label: billingMetric.label, value: formatMetric(billingMetric.quantity, billingMetric.unit) },
|
||||||
{ label: '后续履约', value: summary.deliveryTrackingNo || (summary.deliveryCompletedAt ? `海外回传于 ${formatDateTime(summary.deliveryCompletedAt)}` : '待海外同步') },
|
{ label: '后续履约', value: orderProgressLabel(store.orderProgress[order.id]) },
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
const cabinetProgressItems = computed(() => {
|
const cabinetProgressItems = computed(() => {
|
||||||
@@ -917,6 +921,7 @@ function draftFromOrder(order: FirstLegOrder): FirstLegOrderDraft {
|
|||||||
destinationWarehouseId: order.destinationWarehouseId,
|
destinationWarehouseId: order.destinationWarehouseId,
|
||||||
destinationWarehouseName: order.destinationWarehouseName,
|
destinationWarehouseName: order.destinationWarehouseName,
|
||||||
pickupMethod: order.pickupMethod,
|
pickupMethod: order.pickupMethod,
|
||||||
|
trackingNumber: order.pickupMethod === 'SELF_EXPRESS' ? order.trackingNumber : '',
|
||||||
channelCode: order.channelCode,
|
channelCode: order.channelCode,
|
||||||
channelName: order.channelName,
|
channelName: order.channelName,
|
||||||
pricingTaxMode: order.pricingTaxMode,
|
pricingTaxMode: order.pricingTaxMode,
|
||||||
@@ -939,6 +944,7 @@ function copyDraftFromOrder(order: FirstLegOrder): FirstLegOrderDraft {
|
|||||||
return {
|
return {
|
||||||
...draft,
|
...draft,
|
||||||
inboundCode: '',
|
inboundCode: '',
|
||||||
|
trackingNumber: '',
|
||||||
items: draft.items.map((item) => ({
|
items: draft.items.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
id: `copied-cargo-${++cargoSequence}`,
|
id: `copied-cargo-${++cargoSequence}`,
|
||||||
@@ -1131,6 +1137,7 @@ async function changeDestinationType() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function changePickupMethod() {
|
function changePickupMethod() {
|
||||||
|
if (form.pickupMethod !== 'SELF_EXPRESS') form.trackingNumber = ''
|
||||||
if (form.pickupMethod === 'PICKUP') {
|
if (form.pickupMethod === 'PICKUP') {
|
||||||
form.shipperPartySnapshot ??= createPickupPartySnapshot()
|
form.shipperPartySnapshot ??= createPickupPartySnapshot()
|
||||||
} else {
|
} else {
|
||||||
@@ -2238,7 +2245,8 @@ async function searchOrders(model: QueryModel) {
|
|||||||
<div class="prototype-form-flow shipping-form-flow">
|
<div class="prototype-form-flow shipping-form-flow">
|
||||||
<el-form-item label="履约类型" prop="fulfillmentType" required class="prototype-form-row prototype-form-row--tabs"><el-radio-group v-model="form.fulfillmentType" class="segmented-options" @change="changeFulfillmentType"><el-radio-button value="STANDARD">普通头程</el-radio-button><el-radio-button value="ONE_CLICK_DROPSHIP">一件代发</el-radio-button></el-radio-group><small v-if="form.fulfillmentType === 'ONE_CLICK_DROPSHIP'" class="field-guidance">测量完成后不办理国内库存入库,将直接推送 PDA 合托。</small></el-form-item>
|
<el-form-item label="履约类型" prop="fulfillmentType" required class="prototype-form-row prototype-form-row--tabs"><el-radio-group v-model="form.fulfillmentType" class="segmented-options" @change="changeFulfillmentType"><el-radio-button value="STANDARD">普通头程</el-radio-button><el-radio-button value="ONE_CLICK_DROPSHIP">一件代发</el-radio-button></el-radio-group><small v-if="form.fulfillmentType === 'ONE_CLICK_DROPSHIP'" class="field-guidance">测量完成后不办理国内库存入库,将直接推送 PDA 合托。</small></el-form-item>
|
||||||
<el-form-item v-if="form.fulfillmentType === 'ONE_CLICK_DROPSHIP'" label="LX入库单号" prop="lxCode" class="prototype-form-row prototype-form-row--wide"><el-input v-model="form.lxCode" maxlength="128" placeholder="请输入LX入库单号" /><small class="field-guidance">用于绑定领星入库单,提交后可在尾程订单、入库单和出库单中查询。</small></el-form-item>
|
<el-form-item v-if="form.fulfillmentType === 'ONE_CLICK_DROPSHIP'" label="LX入库单号" prop="lxCode" class="prototype-form-row prototype-form-row--wide"><el-input v-model="form.lxCode" maxlength="128" placeholder="请输入LX入库单号" /><small class="field-guidance">用于绑定领星入库单,提交后可在尾程订单、入库单和出库单中查询。</small></el-form-item>
|
||||||
<el-form-item label="取货方式" prop="pickupMethod" required class="prototype-form-row prototype-form-row--tabs"><el-radio-group v-model="form.pickupMethod" class="segmented-options" @change="changePickupMethod"><el-radio-button value="SELF_EXPRESS">快递自送</el-radio-button><el-radio-button value="PICKUP">我司提货</el-radio-button><el-radio-button value="SELF_TRUCK">卡车自送</el-radio-button></el-radio-group></el-form-item>
|
<el-form-item label="取货方式" prop="pickupMethod" required class="prototype-form-row prototype-form-row--tabs"><el-radio-group v-model="form.pickupMethod" class="segmented-options" @change="changePickupMethod"><el-radio-button value="SELF_TRUCK">卡车自送</el-radio-button><el-radio-button value="PICKUP">我司提货</el-radio-button><el-radio-button value="SELF_EXPRESS">快递自提</el-radio-button></el-radio-group></el-form-item>
|
||||||
|
<el-form-item v-if="form.pickupMethod === 'SELF_EXPRESS'" label="快递单号" prop="trackingNumber" class="prototype-form-row prototype-form-row--wide"><el-input v-model="form.trackingNumber" maxlength="128" placeholder="请输入快递单号(选填)" /></el-form-item>
|
||||||
<template v-if="form.pickupMethod === 'PICKUP' && form.shipperPartySnapshot">
|
<template v-if="form.pickupMethod === 'PICKUP' && form.shipperPartySnapshot">
|
||||||
<el-form-item label="企业提货地址簿" prop="senderAddressId" required :rules="requiredSelectRules('请选择企业提货地址')" class="prototype-form-row prototype-form-row--wide address-book-row">
|
<el-form-item label="企业提货地址簿" prop="senderAddressId" required :rules="requiredSelectRules('请选择企业提货地址')" class="prototype-form-row prototype-form-row--wide address-book-row">
|
||||||
<div class="address-book-control"><el-select v-model="form.senderAddressId" clearable filterable :loading="orderAddressesLoading" placeholder="选择企业保存的提货地址" @change="selectPickupAddress"><el-option v-for="address in pickupAddresses" :key="String(address.id)" :label="addressLabel(address)" :value="String(address.id)" /></el-select><el-button @click="savePickupAddress">保存当前地址</el-button><el-button v-if="form.senderAddressId" link type="danger" @click="removeOrderAddress('PICKUP')">停用</el-button></div>
|
<div class="address-book-control"><el-select v-model="form.senderAddressId" clearable filterable :loading="orderAddressesLoading" placeholder="选择企业保存的提货地址" @change="selectPickupAddress"><el-option v-for="address in pickupAddresses" :key="String(address.id)" :label="addressLabel(address)" :value="String(address.id)" /></el-select><el-button @click="savePickupAddress">保存当前地址</el-button><el-button v-if="form.senderAddressId" link type="danger" @click="removeOrderAddress('PICKUP')">停用</el-button></div>
|
||||||
@@ -2471,7 +2479,7 @@ async function searchOrders(model: QueryModel) {
|
|||||||
<section v-if="detailOrder.status !== 'CANCELLED' || detailOrder.expressCompanyName || detailOrder.trackingNumber" class="detail-section">
|
<section v-if="detailOrder.status !== 'CANCELLED' || detailOrder.expressCompanyName || detailOrder.trackingNumber" class="detail-section">
|
||||||
<h3>履约快递信息</h3>
|
<h3>履约快递信息</h3>
|
||||||
<dl v-if="detailOrder.expressCompanyName || detailOrder.trackingNumber" class="express-information"><div><dt>快递公司</dt><dd>{{ detailOrder.expressCompanyName || (detailOrder.status === 'CANCELLED' ? '—' : '待平台补充') }}</dd></div><div><dt>快递单号</dt><dd class="table-code">{{ detailOrder.trackingNumber || (detailOrder.status === 'CANCELLED' ? '—' : '待平台补充') }}</dd></div></dl>
|
<dl v-if="detailOrder.expressCompanyName || detailOrder.trackingNumber" class="express-information"><div><dt>快递公司</dt><dd>{{ detailOrder.expressCompanyName || (detailOrder.status === 'CANCELLED' ? '—' : '待平台补充') }}</dd></div><div><dt>快递单号</dt><dd class="table-code">{{ detailOrder.trackingNumber || (detailOrder.status === 'CANCELLED' ? '—' : '待平台补充') }}</dd></div></dl>
|
||||||
<div v-else class="express-empty"><PackageCheck :size="20" /><div><strong>平台尚未维护快递信息</strong><span>快递公司和快递单号由平台后台维护,客户端仅只读展示。</span></div></div>
|
<div v-else class="express-empty"><PackageCheck :size="20" /><div><strong>暂无快递信息</strong><span>快递自提可在下单或编辑草稿时选填快递单号,快递公司由平台维护。</span></div></div>
|
||||||
</section>
|
</section>
|
||||||
<section class="detail-section">
|
<section class="detail-section">
|
||||||
<h3>货物明细</h3>
|
<h3>货物明细</h3>
|
||||||
|
|||||||
@@ -202,6 +202,7 @@ function buildOrderDraft(row: QuickOrderRow, channel: QuickChannelOption): First
|
|||||||
destinationWarehouseId: row.destinationWarehouseId,
|
destinationWarehouseId: row.destinationWarehouseId,
|
||||||
destinationWarehouseName: row.destinationWarehouseName,
|
destinationWarehouseName: row.destinationWarehouseName,
|
||||||
pickupMethod: 'SELF_EXPRESS',
|
pickupMethod: 'SELF_EXPRESS',
|
||||||
|
trackingNumber: '',
|
||||||
channelCode: channel.channelCode,
|
channelCode: channel.channelCode,
|
||||||
channelName: channel.channelName,
|
channelName: channel.channelName,
|
||||||
pricingTaxMode,
|
pricingTaxMode,
|
||||||
|
|||||||
@@ -104,17 +104,6 @@ interface DeliveryOrderDetailResponse {
|
|||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeliveryTransferResult {
|
|
||||||
dispatchNo: string
|
|
||||||
outboundNo: string
|
|
||||||
outboundStatus: string
|
|
||||||
deliveryNo: string
|
|
||||||
deliveryStatus: string
|
|
||||||
palletCount: number
|
|
||||||
packageCount: number
|
|
||||||
replayed: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DeliveryQuery {
|
export interface DeliveryQuery {
|
||||||
page: number
|
page: number
|
||||||
size: number
|
size: number
|
||||||
@@ -140,26 +129,6 @@ export function normalizeDeliveryPage(data: PageResult<DeliveryRow>): PageResult
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeliveryTransferPayload {
|
|
||||||
dispatchNo: string
|
|
||||||
idempotencyKey: string
|
|
||||||
plannedDeliveryAt: string
|
|
||||||
deliveryMethod: string
|
|
||||||
pickupMethod: string
|
|
||||||
loadingType: string
|
|
||||||
waybillNo?: string
|
|
||||||
carrierName?: string
|
|
||||||
vehicleType?: string
|
|
||||||
vehicleSize?: string
|
|
||||||
driverName?: string
|
|
||||||
driverPhone?: string
|
|
||||||
vehiclePlate?: string
|
|
||||||
appointmentAccount?: string
|
|
||||||
sellerName?: string
|
|
||||||
remark?: string
|
|
||||||
fees: DeliveryOrderFee[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function listDeliveryOrders(params: DeliveryQuery) {
|
export function listDeliveryOrders(params: DeliveryQuery) {
|
||||||
return apiRequest<PageResult<DeliveryRow>>({
|
return apiRequest<PageResult<DeliveryRow>>({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
@@ -179,13 +148,3 @@ export function getDeliveryOrder(deliveryNo: string) {
|
|||||||
items: skus,
|
items: skus,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createDeliveryOrder(payload: DeliveryTransferPayload) {
|
|
||||||
const { dispatchNo, ...request } = payload
|
|
||||||
return apiRequest<DeliveryTransferResult>({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/merchant/v1/last-leg/delivery-orders',
|
|
||||||
params: { dispatchNo },
|
|
||||||
data: request,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import { readFileSync } from 'node:fs'
|
import { existsSync, readFileSync } from 'node:fs'
|
||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
import ts from 'typescript'
|
import ts from 'typescript'
|
||||||
|
|
||||||
@@ -8,7 +8,6 @@ const routesSource = readFileSync(new URL('./routes.ts', import.meta.url), 'utf8
|
|||||||
const listSource = readFileSync(new URL('./views/DeliveryOrdersView.vue', import.meta.url), 'utf8')
|
const listSource = readFileSync(new URL('./views/DeliveryOrdersView.vue', import.meta.url), 'utf8')
|
||||||
const detailSource = readFileSync(new URL('./views/DeliveryOrderDetailView.vue', import.meta.url), 'utf8')
|
const detailSource = readFileSync(new URL('./views/DeliveryOrderDetailView.vue', import.meta.url), 'utf8')
|
||||||
const deliveryStyles = readFileSync(new URL('./views/deliveryOrders.css', import.meta.url), 'utf8')
|
const deliveryStyles = readFileSync(new URL('./views/deliveryOrders.css', import.meta.url), 'utf8')
|
||||||
const createSource = readFileSync(new URL('./views/DeliveryOrderCreateView.vue', import.meta.url), 'utf8')
|
|
||||||
const permissionSource = readFileSync(new URL('../tenant-management/contracts.ts', import.meta.url), 'utf8')
|
const permissionSource = readFileSync(new URL('../tenant-management/contracts.ts', import.meta.url), 'utf8')
|
||||||
|
|
||||||
async function importTypeScriptModule(source) {
|
async function importTypeScriptModule(source) {
|
||||||
@@ -33,22 +32,18 @@ for (const signedPackages of [null, 0, 3]) test(`preserves actual signed quantit
|
|||||||
test('uses only authenticated merchant delivery endpoints without customer scope or mock fallback', () => {
|
test('uses only authenticated merchant delivery endpoints without customer scope or mock fallback', () => {
|
||||||
assert.match(apiSource, /method: 'GET'[\s\S]*url: '\/merchant\/v1\/last-leg\/delivery-orders'/)
|
assert.match(apiSource, /method: 'GET'[\s\S]*url: '\/merchant\/v1\/last-leg\/delivery-orders'/)
|
||||||
assert.match(apiSource, /delivery-orders\/\$\{encodeURIComponent\(deliveryNo\)\}/)
|
assert.match(apiSource, /delivery-orders\/\$\{encodeURIComponent\(deliveryNo\)\}/)
|
||||||
assert.match(apiSource, /method: 'POST'[\s\S]*url: '\/merchant\/v1\/last-leg\/delivery-orders'/)
|
assert.doesNotMatch(apiSource, /method: '(?:POST|PUT|PATCH|DELETE)'|createDeliveryOrder|DeliveryTransferPayload|DeliveryTransferResult/)
|
||||||
assert.match(apiSource, /const \{ dispatchNo, \.\.\.request \} = payload/)
|
|
||||||
assert.match(apiSource, /params: \{ dispatchNo \}/)
|
|
||||||
assert.match(apiSource, /apiRequest<DeliveryOrderDetailResponse>/)
|
assert.match(apiSource, /apiRequest<DeliveryOrderDetailResponse>/)
|
||||||
assert.match(apiSource, /\.then\(\(\{ order, skus, fees \}\) => \(\{[\s\S]*\.\.\.order,[\s\S]*fees,[\s\S]*items: skus/)
|
assert.match(apiSource, /\.then\(\(\{ order, skus, fees \}\) => \(\{[\s\S]*\.\.\.order,[\s\S]*fees,[\s\S]*items: skus/)
|
||||||
assert.match(apiSource, /apiRequest<DeliveryTransferResult>/)
|
|
||||||
assert.doesNotMatch(apiSource, /customerId|tenantId|Record<string, unknown>|mock|fallback/i)
|
assert.doesNotMatch(apiSource, /customerId|tenantId|Record<string, unknown>|mock|fallback/i)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('keeps list, read-only detail and create permissions independent in active routes', () => {
|
test('keeps customer delivery list and read-only detail active without a creation route', () => {
|
||||||
const listRoute = routesSource.match(/path: 'orders\/delivery',[\s\S]*?\n\s*},/)?.[0] ?? ''
|
const listRoute = routesSource.match(/path: 'orders\/delivery',[\s\S]*?\n\s*},/)?.[0] ?? ''
|
||||||
assert.match(listRoute, /permission: 'merchant:warehouse:delivery:list'/)
|
assert.match(listRoute, /permission: 'merchant:warehouse:delivery:list'/)
|
||||||
assert.match(listRoute, /navigation: \{ key: 'orders-delivery', icon: Truck \}/)
|
assert.match(listRoute, /navigation: \{ key: 'orders-delivery', icon: Truck \}/)
|
||||||
assert.match(routesSource, /path: 'orders\/delivery\/create'[\s\S]*DeliveryOrderCreateView\.vue[\s\S]*permission: 'merchant:warehouse:delivery:create'/)
|
assert.doesNotMatch(routesSource, /orders\/delivery\/create|orders-delivery-create|DeliveryOrderCreateView/)
|
||||||
assert.match(routesSource, /path: 'orders\/delivery\/:deliveryNo'[\s\S]*DeliveryOrderDetailView\.vue[\s\S]*props: true[\s\S]*permission: 'merchant:warehouse:delivery:list'/)
|
assert.match(routesSource, /path: 'orders\/delivery\/:deliveryNo'[\s\S]*DeliveryOrderDetailView\.vue[\s\S]*props: true[\s\S]*permission: 'merchant:warehouse:delivery:list'/)
|
||||||
assert.match(routesSource, /orders-delivery-create[\s\S]*keepAlive: false/)
|
|
||||||
assert.match(routesSource, /orders-delivery-detail[\s\S]*keepAlive: false/)
|
assert.match(routesSource, /orders-delivery-detail[\s\S]*keepAlive: false/)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -70,10 +65,9 @@ test('renders the operation column first and covers loading, empty, error and pa
|
|||||||
assert.doesNotMatch(listSource, /row\.id[^a-zA-Z]|deliveryNo \|\| row\.id|customerId|tenantId/)
|
assert.doesNotMatch(listSource, /row\.id[^a-zA-Z]|deliveryNo \|\| row\.id|customerId|tenantId/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('shows the create button only with the independent create permission', () => {
|
test('does not expose customer delivery creation even to an account retaining an old permission snapshot', () => {
|
||||||
assert.match(listSource, /auth\.hasPermission\('merchant:warehouse:delivery:create'\)/)
|
assert.doesNotMatch(listSource, /delivery:create|canCreate|openCreate|orders-delivery-create|创建派送单/)
|
||||||
assert.match(listSource, /<el-button v-if="canCreate" type="primary" @click="openCreate"/)
|
assert.equal(existsSync(new URL('./views/DeliveryOrderCreateView.vue', import.meta.url)), false)
|
||||||
assert.doesNotMatch(listSource, /hasPermission\('merchant:warehouse:delivery:list'\)[\s\S]{0,120}创建派送单/)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('loads a typed detail route by delivery number and never exposes an internal id fallback', () => {
|
test('loads a typed detail route by delivery number and never exposes an internal id fallback', () => {
|
||||||
@@ -126,33 +120,12 @@ test('renders a compact customer-readable delivery detail surface', () => {
|
|||||||
assert.match(deliveryStyles, /delivery-detail-surface \.delivery-detail-grid div\{display:grid;min-height:42px;/)
|
assert.match(deliveryStyles, /delivery-detail-surface \.delivery-detail-grid div\{display:grid;min-height:42px;/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('submits the complete create contract with stable retry idempotency and recoverable failures', () => {
|
test('keeps merchant delivery reads and removes creation from the advertised permission map', async () => {
|
||||||
for (const model of ['dispatchNo', 'plannedDeliveryAt', 'deliveryMethod', 'pickupMethod', 'loadingType', 'waybillNo', 'carrierName', 'vehicleType', 'vehicleSize', 'driverName', 'driverPhone', 'vehiclePlate', 'appointmentAccount', 'sellerName', 'remark']) {
|
|
||||||
assert.match(createSource, new RegExp(`v-model="form\\.${model}"`))
|
|
||||||
}
|
|
||||||
assert.match(createSource, /if \(!pendingIdempotencyKey\) pendingIdempotencyKey = createIdempotencyKey\(\)/)
|
|
||||||
assert.match(createSource, /createDeliveryOrder\(requestPayload\(\)\)/)
|
|
||||||
assert.match(createSource, /fees: form\.fees\.map/)
|
|
||||||
assert.match(createSource, /if \(form\.fees\.length === 0\)/)
|
|
||||||
assert.match(createSource, /请至少添加一条费用明细/)
|
|
||||||
for (const value of ['FBA_PICKUP', 'CUSTOMER_PICKUP', 'TRUCK_PALLET', 'TRUCK_FLOOR', 'EXPRESS', 'OUTSOURCED', 'DEDICATED_TRUCK', 'LTL']) {
|
|
||||||
assert.match(createSource, new RegExp(`value="${value}"`))
|
|
||||||
}
|
|
||||||
for (const value of ['ONE_PICKUP_ONE_DROP', 'ONE_PICKUP_MULTI_DROP', 'MULTI_PICKUP_ONE_DROP', 'MULTI_PICKUP_MULTI_DROP', 'WAREHOUSE_PICKUP', 'PALLET', 'FLOOR', 'MIXED']) {
|
|
||||||
assert.match(createSource, new RegExp(`value="${value}"`))
|
|
||||||
}
|
|
||||||
assert.doesNotMatch(createSource, /value="(?:TRUCK|COURIER|SELF_PICKUP|DOOR_PICKUP|LOOSE_CARTON)"/)
|
|
||||||
assert.match(createSource, /submitError\.value = error instanceof Error/)
|
|
||||||
assert.match(createSource, /useUnsavedWorkRegistration/)
|
|
||||||
assert.doesNotMatch(createSource, /customerId|tenantId|mock|fallback/i)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('maps both merchant delivery permissions independently', async () => {
|
|
||||||
assert.match(permissionSource, /'merchant:warehouse:delivery:list': \['merchant:warehouse:delivery:list'\]/)
|
assert.match(permissionSource, /'merchant:warehouse:delivery:list': \['merchant:warehouse:delivery:list'\]/)
|
||||||
assert.match(permissionSource, /'merchant:warehouse:delivery:create': \['merchant:warehouse:delivery:create'\]/)
|
assert.doesNotMatch(permissionSource, /merchant:warehouse:delivery:create/)
|
||||||
const contracts = await importTypeScriptModule(permissionSource)
|
const contracts = await importTypeScriptModule(permissionSource)
|
||||||
assert.equal(contracts.hasMappedMerchantPermission('merchant:warehouse:delivery:list', new Set(['merchant:warehouse:delivery:list'])), true)
|
assert.equal(contracts.hasMappedMerchantPermission('merchant:warehouse:delivery:list', new Set(['merchant:warehouse:delivery:list'])), true)
|
||||||
assert.equal(contracts.hasMappedMerchantPermission('merchant:warehouse:delivery:list', new Set(['merchant:warehouse:delivery:create'])), false)
|
assert.equal(contracts.hasMappedMerchantPermission('merchant:warehouse:delivery:list', new Set(['merchant:warehouse:delivery:create'])), false)
|
||||||
assert.equal(contracts.hasMappedMerchantPermission('merchant:warehouse:delivery:create', new Set(['merchant:warehouse:delivery:create'])), true)
|
assert.equal(Object.hasOwn(contracts.FRONTEND_TO_MERCHANT_PERMISSION_CODES, 'merchant:warehouse:delivery:create'), false)
|
||||||
assert.equal(contracts.hasMappedMerchantPermission('merchant:warehouse:delivery:create', new Set(['merchant:warehouse:delivery:list'])), false)
|
assert.equal(contracts.hasMappedMerchantPermission('merchant:warehouse:delivery:create', new Set(['merchant:warehouse:delivery:list'])), false)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -37,13 +37,6 @@ export const orderManagementModule = {
|
|||||||
meta: { title: '派送订单列表', description: '查询海外仓派送订单', permission: 'merchant:warehouse:delivery:list' },
|
meta: { title: '派送订单列表', description: '查询海外仓派送订单', permission: 'merchant:warehouse:delivery:list' },
|
||||||
navigation: { key: 'orders-delivery', icon: Truck },
|
navigation: { key: 'orders-delivery', icon: Truck },
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: 'orders/delivery/create',
|
|
||||||
name: 'orders-delivery-create',
|
|
||||||
component: () => import('./views/DeliveryOrderCreateView.vue'),
|
|
||||||
release: 'active',
|
|
||||||
meta: { title: '创建派送单', workspaceTitle: '派送订单 · 新建', description: '从排车单创建海外仓派送单', permission: 'merchant:warehouse:delivery:create', keepAlive: false },
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: 'orders/delivery/:deliveryNo',
|
path: 'orders/delivery/:deliveryNo',
|
||||||
name: 'orders-delivery-detail',
|
name: 'orders-delivery-detail',
|
||||||
|
|||||||
@@ -1,201 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, reactive, ref } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { ArrowLeft, Plus, Trash2 } from '@lucide/vue'
|
|
||||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
|
||||||
import { useAuthStore } from '@/modules/auth/public'
|
|
||||||
import { useUnsavedWorkRegistration } from '@/navigation/workspace-tabs/unsavedWork'
|
|
||||||
import { createIdempotencyKey } from '@/utils/idempotencyKey'
|
|
||||||
import { createDeliveryOrder, type DeliveryOrderFee, type DeliveryTransferPayload } from '../api/deliveryOrders'
|
|
||||||
|
|
||||||
type DeliveryCreateForm = Omit<DeliveryTransferPayload, 'idempotencyKey'>
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const auth = useAuthStore()
|
|
||||||
const formRef = ref<FormInstance>()
|
|
||||||
const submitting = ref(false)
|
|
||||||
const submitError = ref('')
|
|
||||||
const submitted = ref(false)
|
|
||||||
let pendingIdempotencyKey = ''
|
|
||||||
|
|
||||||
const form = reactive<DeliveryCreateForm>({
|
|
||||||
dispatchNo: '',
|
|
||||||
plannedDeliveryAt: '',
|
|
||||||
deliveryMethod: '',
|
|
||||||
pickupMethod: '',
|
|
||||||
loadingType: '',
|
|
||||||
waybillNo: '',
|
|
||||||
carrierName: '',
|
|
||||||
vehicleType: '',
|
|
||||||
vehicleSize: '',
|
|
||||||
driverName: '',
|
|
||||||
driverPhone: '',
|
|
||||||
vehiclePlate: '',
|
|
||||||
appointmentAccount: '',
|
|
||||||
sellerName: '',
|
|
||||||
remark: '',
|
|
||||||
fees: [],
|
|
||||||
})
|
|
||||||
|
|
||||||
const rules: FormRules<DeliveryCreateForm> = {
|
|
||||||
dispatchNo: [{ required: true, message: '请输入排车单号', trigger: 'blur' }],
|
|
||||||
plannedDeliveryAt: [{ required: true, message: '请选择计划派送时间', trigger: 'change' }],
|
|
||||||
deliveryMethod: [{ required: true, message: '请选择派送方式', trigger: 'change' }],
|
|
||||||
pickupMethod: [{ required: true, message: '请选择提货方式', trigger: 'change' }],
|
|
||||||
loadingType: [{ required: true, message: '请选择装载类型', trigger: 'change' }],
|
|
||||||
}
|
|
||||||
|
|
||||||
const dirty = computed(() => !submitted.value && Boolean(
|
|
||||||
form.dispatchNo || form.plannedDeliveryAt || form.deliveryMethod || form.pickupMethod || form.loadingType
|
|
||||||
|| form.waybillNo || form.carrierName || form.vehicleType || form.vehicleSize || form.driverName
|
|
||||||
|| form.driverPhone || form.vehiclePlate || form.appointmentAccount || form.sellerName || form.remark || form.fees.length,
|
|
||||||
))
|
|
||||||
|
|
||||||
useUnsavedWorkRegistration({
|
|
||||||
key: 'delivery-order-create-form',
|
|
||||||
routeKey: 'orders-delivery-create',
|
|
||||||
label: '创建派送单',
|
|
||||||
dirty,
|
|
||||||
})
|
|
||||||
|
|
||||||
function optional(value: string | undefined) {
|
|
||||||
const normalized = value?.trim()
|
|
||||||
return normalized || undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
function addFee() {
|
|
||||||
form.fees.push({ feeType: '', currency: 'USD', amount: 0, remark: '' })
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeFee(index: number) {
|
|
||||||
form.fees.splice(index, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateFees() {
|
|
||||||
if (form.fees.length === 0) {
|
|
||||||
ElMessage.warning('请至少添加一条费用明细')
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
const invalidIndex = form.fees.findIndex((fee) => !fee.feeType.trim() || !fee.currency.trim() || fee.amount <= 0)
|
|
||||||
if (invalidIndex < 0) return true
|
|
||||||
ElMessage.warning(`请完整填写第 ${invalidIndex + 1} 条费用的类型、币种和大于 0 的金额`)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
function requestPayload(): DeliveryTransferPayload {
|
|
||||||
return {
|
|
||||||
dispatchNo: form.dispatchNo.trim(),
|
|
||||||
idempotencyKey: pendingIdempotencyKey,
|
|
||||||
plannedDeliveryAt: form.plannedDeliveryAt,
|
|
||||||
deliveryMethod: form.deliveryMethod,
|
|
||||||
pickupMethod: form.pickupMethod,
|
|
||||||
loadingType: form.loadingType,
|
|
||||||
waybillNo: optional(form.waybillNo),
|
|
||||||
carrierName: optional(form.carrierName),
|
|
||||||
vehicleType: optional(form.vehicleType),
|
|
||||||
vehicleSize: optional(form.vehicleSize),
|
|
||||||
driverName: optional(form.driverName),
|
|
||||||
driverPhone: optional(form.driverPhone),
|
|
||||||
vehiclePlate: optional(form.vehiclePlate),
|
|
||||||
appointmentAccount: optional(form.appointmentAccount),
|
|
||||||
sellerName: optional(form.sellerName),
|
|
||||||
remark: optional(form.remark),
|
|
||||||
fees: form.fees.map((fee): DeliveryOrderFee => ({
|
|
||||||
feeType: fee.feeType.trim(),
|
|
||||||
currency: fee.currency.trim().toUpperCase(),
|
|
||||||
amount: fee.amount,
|
|
||||||
remark: optional(fee.remark ?? ''),
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submit() {
|
|
||||||
submitError.value = ''
|
|
||||||
const valid = await formRef.value?.validate().catch(() => false)
|
|
||||||
if (!valid || !validateFees()) return
|
|
||||||
if (!pendingIdempotencyKey) pendingIdempotencyKey = createIdempotencyKey()
|
|
||||||
submitting.value = true
|
|
||||||
try {
|
|
||||||
const created = await createDeliveryOrder(requestPayload())
|
|
||||||
submitted.value = true
|
|
||||||
pendingIdempotencyKey = ''
|
|
||||||
ElMessage.success('派送单创建成功')
|
|
||||||
if (auth.hasPermission('merchant:warehouse:delivery:list')) {
|
|
||||||
await router.replace({ name: 'orders-delivery-detail', params: { deliveryNo: created.deliveryNo } })
|
|
||||||
} else {
|
|
||||||
await router.replace('/')
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
submitError.value = error instanceof Error ? error.message : '派送单创建失败,请检查后重试'
|
|
||||||
} finally {
|
|
||||||
submitting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function cancel() {
|
|
||||||
if (dirty.value) {
|
|
||||||
try {
|
|
||||||
await ElMessageBox.confirm('当前派送单尚未提交,离开后填写内容将丢失。', '放弃创建?', { type: 'warning', confirmButtonText: '放弃', cancelButtonText: '继续填写' })
|
|
||||||
} catch (error) {
|
|
||||||
if (error === 'cancel' || error === 'close') return
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
submitted.value = true
|
|
||||||
await router.push({ name: 'orders-delivery' })
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<section class="delivery-page" aria-labelledby="delivery-create-title">
|
|
||||||
<button type="button" class="delivery-back" @click="cancel"><ArrowLeft :size="16" />返回派送单列表</button>
|
|
||||||
<div class="delivery-heading"><div><h2 id="delivery-create-title">创建派送单</h2><p>从已确认的排车单创建本企业海外仓派送任务。</p></div></div>
|
|
||||||
|
|
||||||
<div v-if="submitError" class="delivery-card delivery-state delivery-state--error" role="alert"><p>{{ submitError }}</p></div>
|
|
||||||
|
|
||||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="delivery-card delivery-form" @submit.prevent="submit">
|
|
||||||
<section>
|
|
||||||
<h3>基础信息</h3>
|
|
||||||
<div class="delivery-form-grid">
|
|
||||||
<el-form-item label="排车单号" prop="dispatchNo" required><el-input v-model="form.dispatchNo" maxlength="64" placeholder="请输入排车单号" /></el-form-item>
|
|
||||||
<el-form-item label="计划派送时间" prop="plannedDeliveryAt" required><el-date-picker v-model="form.plannedDeliveryAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择计划派送时间" /></el-form-item>
|
|
||||||
<el-form-item label="派送方式" prop="deliveryMethod" required><el-select v-model="form.deliveryMethod" placeholder="请选择派送方式"><el-option label="FBA自提" value="FBA_PICKUP" /><el-option label="客户自提" value="CUSTOMER_PICKUP" /><el-option label="卡派打托" value="TRUCK_PALLET" /><el-option label="卡派地板" value="TRUCK_FLOOR" /><el-option label="快递" value="EXPRESS" /><el-option label="外配" value="OUTSOURCED" /><el-option label="专车直送" value="DEDICATED_TRUCK" /><el-option label="零担派送" value="LTL" /></el-select></el-form-item>
|
|
||||||
<el-form-item label="提货方式" prop="pickupMethod" required><el-select v-model="form.pickupMethod" placeholder="请选择提货方式"><el-option label="一提一卸" value="ONE_PICKUP_ONE_DROP" /><el-option label="一提多卸" value="ONE_PICKUP_MULTI_DROP" /><el-option label="多提一卸" value="MULTI_PICKUP_ONE_DROP" /><el-option label="多提多卸" value="MULTI_PICKUP_MULTI_DROP" /><el-option label="仓库自提" value="WAREHOUSE_PICKUP" /></el-select></el-form-item>
|
|
||||||
<el-form-item label="装载类型" prop="loadingType" required><el-select v-model="form.loadingType" placeholder="请选择装载类型"><el-option label="托盘" value="PALLET" /><el-option label="地板" value="FLOOR" /><el-option label="混装" value="MIXED" /></el-select></el-form-item>
|
|
||||||
<el-form-item label="运单号"><el-input v-model="form.waybillNo" maxlength="64" placeholder="选填" /></el-form-item>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h3>承运与车辆</h3>
|
|
||||||
<div class="delivery-form-grid">
|
|
||||||
<el-form-item label="承运商"><el-input v-model="form.carrierName" maxlength="128" placeholder="选填" /></el-form-item>
|
|
||||||
<el-form-item label="车型"><el-input v-model="form.vehicleType" maxlength="64" placeholder="选填" /></el-form-item>
|
|
||||||
<el-form-item label="车辆尺寸"><el-input v-model="form.vehicleSize" maxlength="64" placeholder="选填" /></el-form-item>
|
|
||||||
<el-form-item label="车牌号"><el-input v-model="form.vehiclePlate" maxlength="32" placeholder="选填" /></el-form-item>
|
|
||||||
<el-form-item label="司机姓名"><el-input v-model="form.driverName" maxlength="64" placeholder="选填" /></el-form-item>
|
|
||||||
<el-form-item label="司机电话"><el-input v-model="form.driverPhone" maxlength="32" placeholder="选填" /></el-form-item>
|
|
||||||
<el-form-item label="预约账号"><el-input v-model="form.appointmentAccount" maxlength="128" placeholder="选填" /></el-form-item>
|
|
||||||
<el-form-item label="卖家名称"><el-input v-model="form.sellerName" maxlength="128" placeholder="选填" /></el-form-item>
|
|
||||||
<el-form-item label="备注" class="delivery-span-3"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="选填" /></el-form-item>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<div class="delivery-fee-heading"><h3>费用明细</h3><el-button @click="addFee"><Plus :size="16" />添加费用</el-button></div>
|
|
||||||
<el-table v-if="form.fees.length" :data="form.fees" table-layout="fixed">
|
|
||||||
<el-table-column label="操作" width="88"><template #default="{ $index }"><el-button link type="danger" aria-label="删除费用" @click="removeFee($index)"><Trash2 :size="16" />删除</el-button></template></el-table-column>
|
|
||||||
<el-table-column label="费用类型" min-width="180"><template #default="{ row }"><el-input v-model="row.feeType" maxlength="64" placeholder="请输入费用类型" /></template></el-table-column>
|
|
||||||
<el-table-column label="币种" width="140"><template #default="{ row }"><el-input v-model="row.currency" maxlength="3" placeholder="USD" /></template></el-table-column>
|
|
||||||
<el-table-column label="金额" width="180"><template #default="{ row }"><el-input-number v-model="row.amount" :min="0" :precision="2" :step="1" controls-position="right" placeholder="请输入金额" /></template></el-table-column>
|
|
||||||
<el-table-column label="备注" min-width="220"><template #default="{ row }"><el-input v-model="row.remark" maxlength="200" placeholder="选填" /></template></el-table-column>
|
|
||||||
</el-table>
|
|
||||||
<div v-else class="delivery-fee-empty">请至少添加一条费用明细后再提交。</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div class="delivery-form-actions"><el-button :disabled="submitting" @click="cancel">取消</el-button><el-button type="primary" native-type="submit" :loading="submitting">提交创建</el-button></div>
|
|
||||||
</el-form>
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped src="./deliveryOrders.css"></style>
|
|
||||||
@@ -1,16 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { Plus } from '@lucide/vue'
|
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { useAuthStore } from '@/modules/auth/public'
|
|
||||||
import { listDeliveryOrders, type DeliveryQuery, type DeliveryRow } from '../api/deliveryOrders'
|
import { listDeliveryOrders, type DeliveryQuery, type DeliveryRow } from '../api/deliveryOrders'
|
||||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue'
|
import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue'
|
||||||
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
|
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
|
||||||
import type { TableAction } from '@/components/common/tableActions'
|
import type { TableAction } from '@/components/common/tableActions'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const auth = useAuthStore()
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
const records = ref<DeliveryRow[]>([])
|
const records = ref<DeliveryRow[]>([])
|
||||||
@@ -18,7 +15,6 @@ const total = ref(0)
|
|||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const pageSize = ref(20)
|
const pageSize = ref(20)
|
||||||
const filters = reactive({ documentNo: '', dispatchNo: '', waybillNo: '', status: '', deliveryMethod: '', plannedRange: [] as string[] })
|
const filters = reactive({ documentNo: '', dispatchNo: '', waybillNo: '', status: '', deliveryMethod: '', plannedRange: [] as string[] })
|
||||||
const canCreate = computed(() => auth.hasPermission('merchant:warehouse:delivery:create'))
|
|
||||||
let requestSequence = 0
|
let requestSequence = 0
|
||||||
|
|
||||||
const deliveryMethodOptions = [
|
const deliveryMethodOptions = [
|
||||||
@@ -142,9 +138,6 @@ function openDetail(row: DeliveryRow) {
|
|||||||
void router.push({ name: 'orders-delivery-detail', params: { deliveryNo: row.deliveryNo } })
|
void router.push({ name: 'orders-delivery-detail', params: { deliveryNo: row.deliveryNo } })
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCreate() {
|
|
||||||
void router.push({ name: 'orders-delivery-create' })
|
|
||||||
}
|
|
||||||
function rowActions(candidate: object): TableAction[] {
|
function rowActions(candidate: object): TableAction[] {
|
||||||
const row = candidate as DeliveryRow
|
const row = candidate as DeliveryRow
|
||||||
return [{ key: 'detail', label: '详情', disabled: !row.deliveryNo, run: () => openDetail(row) }]
|
return [{ key: 'detail', label: '详情', disabled: !row.deliveryNo, run: () => openDetail(row) }]
|
||||||
@@ -163,7 +156,6 @@ watch([page, pageSize], ([nextPage, nextSize], [previousPage, previousSize]) =>
|
|||||||
|
|
||||||
<div class="delivery-card delivery-table-card">
|
<div class="delivery-card delivery-table-card">
|
||||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="records" :columns="tableColumns" :loading="loading" :total="total" :page-sizes="[10, 20, 50, 100]" :row-actions="rowActions" row-key="deliveryNo" empty-text="暂无派送单">
|
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="records" :columns="tableColumns" :loading="loading" :total="total" :page-sizes="[10, 20, 50, 100]" :row-actions="rowActions" row-key="deliveryNo" empty-text="暂无派送单">
|
||||||
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />创建派送单</el-button></template>
|
|
||||||
<template v-if="errorMessage" #error><div class="delivery-state delivery-state--error" role="alert"><p>{{ errorMessage }}</p><el-button @click="loadOrders">重试</el-button></div></template>
|
<template v-if="errorMessage" #error><div class="delivery-state delivery-state--error" role="alert"><p>{{ errorMessage }}</p><el-button @click="loadOrders">重试</el-button></div></template>
|
||||||
<template #status="{ row }"><el-tag :type="statusType(row.status)" effect="light">{{ statusLabel(row.status) }}</el-tag></template>
|
<template #status="{ row }"><el-tag :type="statusType(row.status)" effect="light">{{ statusLabel(row.status) }}</el-tag></template>
|
||||||
<template #planned="{ row }">{{ formatDateTime(row.plannedDeliveryAt) }}</template>
|
<template #planned="{ row }">{{ formatDateTime(row.plannedDeliveryAt) }}</template>
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { apiRequest, apiBlobRequest } from '@/api/http'
|
||||||
|
|
||||||
|
export interface PickupProgress {
|
||||||
|
status: string; tripNo: string | null; vehiclePlate: string | null; driverName: string | null; driverPhone: string | null
|
||||||
|
plannedDepartureAt: string | null; actualPackages: number | null; pickedAt: string | null; arrivedAt: string | null; remark: string | null
|
||||||
|
evidence: { fileId: string; fileName: string }[]
|
||||||
|
}
|
||||||
|
/** 客户范围由统一会话确定,不允许调用方传入其他客户。 */
|
||||||
|
export function getTransferPickupProgress(transferNo: string) {
|
||||||
|
return apiRequest<PickupProgress | null>({ method: 'GET', url: `/merchant/v1/last-leg/warehouse-transfers/${encodeURIComponent(transferNo)}/pickup-progress` })
|
||||||
|
}
|
||||||
|
/** 只下载本票已经确认的凭证。 */
|
||||||
|
export function downloadTransferPickupEvidence(transferNo: string, fileId: string) {
|
||||||
|
return apiBlobRequest({ method: 'GET', url: `/merchant/v1/last-leg/warehouse-transfers/${encodeURIComponent(transferNo)}/pickup-evidence/${encodeURIComponent(fileId)}` })
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { apiRequest, apiBlobRequest } from '@/api/http'
|
import { apiRequest, apiBlobRequest } from '@/api/http'
|
||||||
import { createIdempotencyKey } from '@/utils/idempotencyKey'
|
import { createIdempotencyKey } from '@/utils/idempotencyKey'
|
||||||
|
|
||||||
export type RelabelDeliveryMethod = 'TRUCK' | 'EXPRESS' | 'TRUCK_PICKUP' | 'EXPRESS_PICKUP'
|
export type RelabelDeliveryMethod = 'TRUCK' | 'EXPRESS' | 'AMAZON_PICKUP' | 'CUSTOMER_PICKUP'
|
||||||
|
|
||||||
export interface PageResult<T> { records: T[]; page: number; size: number; total: number; pages: number }
|
export interface PageResult<T> { records: T[]; page: number; size: number; total: number; pages: number }
|
||||||
export interface LastlegOrderSummary { id: string; orderNo: string; inboundCode: string; orderType: string; packageCount: number; overseasWarehouseId?: string }
|
export interface LastlegOrderSummary { id: string; orderNo: string; inboundCode: string; orderType: string; packageCount: number; overseasWarehouseId?: string }
|
||||||
@@ -23,10 +23,11 @@ export interface RelabelDetail {
|
|||||||
remark?: string; items: RelabelItemDetail[]
|
remark?: string; items: RelabelItemDetail[]
|
||||||
history: Array<{ fromStatus?: string; toStatus?: string; operatorId?: string; remark?: string; createdAt: string }>
|
history: Array<{ fromStatus?: string; toStatus?: string; operatorId?: string; remark?: string; createdAt: string }>
|
||||||
tailOrders?: Array<{ id: string; orderNo: string; overseasWarehouseId: string; packageCount: number; status: string }>
|
tailOrders?: Array<{ id: string; orderNo: string; overseasWarehouseId: string; packageCount: number; status: string }>
|
||||||
completionEvidence?: { taskId: string; photoFileIds: string[]; completionRemark?: string; reviewerName?: string; reviewRemark?: string; completedAt?: string }
|
completionEvidence?: { taskId: string; photoFileIds: string[]; completionRemark?: string; reviewerName?: string; reviewRemark?: string; completedAt?: string; customerConfirmedAt?: string }
|
||||||
}
|
}
|
||||||
export interface DiscardSummary { id: string; discardNo: string; outboundNo?: string; inboundCodes: string; overseasWarehouseId: string; overseasWarehouseName: string; totalPackages: number; totalFee: number; currency: string; status: string; createdAt: string; completedAt?: string }
|
export interface DiscardSummary { id: string; discardNo: string; outboundNo?: string; inboundCodes: string; overseasWarehouseId: string; overseasWarehouseName: string; totalPackages: number; totalFee: number; currency: string; status: string; createdAt: string; completedAt?: string; customerConfirmedAt?: string }
|
||||||
export interface DiscardCandidate { orderId: string; sourceType?: 'DROPSHIP' | 'RETURN_CARTON'; returnCartonId?: string; cartonMark?: string; inboundCode: string; inboundOrderNo: string; overseasWarehouseId: string; overseasWarehouseName: string; availablePackages: number; skus: Array<{ skuCode: string; productName: string; availablePackages: number }> }
|
export interface DiscardBox { cartonId: string; cartonMark: string; palletNo?: string; skus: Array<{ skuCode: string; productName: string; availablePackages: number }> }
|
||||||
|
export interface DiscardCandidate { cartons?: DiscardBox[]; cartonSelectionSupported?: boolean; cartonSelectionBlockReason?: string; orderId: string; sourceType?: 'DROPSHIP' | 'RETURN_CARTON' | 'INVENTORY'; inventoryPositionId?: string; palletNo?: string; returnCartonId?: string; cartonMark?: string; inboundCode: string; inboundOrderNo: string; overseasWarehouseId: string; overseasWarehouseName: string; availablePackages: number; skus: Array<{ skuCode: string; productName: string; availablePackages: number }> }
|
||||||
export interface RelabelInventoryCandidate {
|
export interface RelabelInventoryCandidate {
|
||||||
cartonSkuItems: Array<{ skuCode: string; quantity: number }>; cartonAvailablePackages: number | string
|
cartonSkuItems: Array<{ skuCode: string; quantity: number }>; cartonAvailablePackages: number | string
|
||||||
skuConversions?: import('./returnInventory').ReturnSkuConversion[]
|
skuConversions?: import('./returnInventory').ReturnSkuConversion[]
|
||||||
@@ -44,10 +45,10 @@ export interface TransferCreate {
|
|||||||
inboundCode?: string; sourceLastlegOrderIds?: string[]; operatingWarehouseId: string; fbaWarehouseCode?: string; destinationType?: 'NONE' | 'FBA' | 'PRIVATE'
|
inboundCode?: string; sourceLastlegOrderIds?: string[]; operatingWarehouseId: string; fbaWarehouseCode?: string; destinationType?: 'NONE' | 'FBA' | 'PRIVATE'
|
||||||
destinationWarehouseId?: string; destinationWarehouseCode?: string; destinationWarehouseName?: string
|
destinationWarehouseId?: string; destinationWarehouseCode?: string; destinationWarehouseName?: string
|
||||||
privateDestinationAddress?: string; privateDestinationPostalCode?: string; privateDestinationContactName?: string; privateDestinationContactPhone?: string
|
privateDestinationAddress?: string; privateDestinationPostalCode?: string; privateDestinationContactName?: string; privateDestinationContactPhone?: string
|
||||||
deliveryAddress: string; packageCount: number; volumeCbm: number; deliveryMethod: string; estimatedArrivalAt: string; estimatedShipAt?: string
|
deliveryAddress?: string; packageCount: number; volumeCbm: number; deliveryMethod?: string; estimatedArrivalAt?: string; estimatedShipAt?: string
|
||||||
fbaShipmentNo?: string; trackingNo?: string; fbaBoxNos?: string; remark?: string; needRelabel: boolean; idempotencyKey: string
|
fbaShipmentNo?: string; trackingNo?: string; fbaBoxNos?: string; remark?: string; needRelabel: boolean; idempotencyKey: string
|
||||||
labelDeadline?: string; pickupContactName?: string; pickupContactPhone?: string; relabelContactName?: string; relabelContactPhone?: string; relabelChargeAmount?: number; relabelDeliveryMethod?: RelabelDeliveryMethod
|
labelDeadline?: string; pickupContactName?: string; pickupContactPhone?: string; relabelContactName?: string; relabelContactPhone?: string; relabelChargeAmount?: number; relabelDeliveryMethod?: RelabelDeliveryMethod
|
||||||
items: Array<{ skuCode?: string; productNameZh?: string; packageCount: number; shippingMark?: string }>
|
items: Array<{ skuCode?: string; productNameZh?: string; quantity: number; packageCount: number; shippingMark?: string; cartonMarks?: string[] }>
|
||||||
relabelItems?: Array<{ operationType: 'CARTON_LABEL' | 'SKU_LABEL' | 'CARTON_AND_SKU'; customerCartonMarks: string[]; originalSku?: string; newSku?: string; quantity: number; fbaCartonMark?: string; destinationWarehouseCode?: string; remark?: string }>
|
relabelItems?: Array<{ operationType: 'CARTON_LABEL' | 'SKU_LABEL' | 'CARTON_AND_SKU'; customerCartonMarks: string[]; originalSku?: string; newSku?: string; quantity: number; fbaCartonMark?: string; destinationWarehouseCode?: string; remark?: string }>
|
||||||
}
|
}
|
||||||
export interface RelabelCreate {
|
export interface RelabelCreate {
|
||||||
@@ -75,6 +76,10 @@ export function updateTransfer(transferNo: string, payload: TransferCreate) {
|
|||||||
export function submitTransfer(transferNo: string) {
|
export function submitTransfer(transferNo: string) {
|
||||||
return apiRequest<Record<string, unknown>>({ method: 'POST', url: `/merchant/v1/last-leg/warehouse-transfers/${encodeURIComponent(transferNo)}/submit` })
|
return apiRequest<Record<string, unknown>>({ method: 'POST', url: `/merchant/v1/last-leg/warehouse-transfers/${encodeURIComponent(transferNo)}/submit` })
|
||||||
}
|
}
|
||||||
|
/** 补充既有SKU的预报箱数,原箱数快照用于并发核对与安全重试。 */
|
||||||
|
export function increaseTransferForecastCartons(transferNo: string, payload: { expectedPackageCount: number; packageCount: number }) {
|
||||||
|
return apiRequest<Record<string, unknown>>({ method: 'POST', url: `/merchant/v1/last-leg/warehouse-transfers/${encodeURIComponent(transferNo)}/increase-forecast-cartons`, data: payload })
|
||||||
|
}
|
||||||
export function listRelabels(params: Record<string, unknown>) {
|
export function listRelabels(params: Record<string, unknown>) {
|
||||||
return apiRequest<PageResult<RelabelSummary>>({ method: 'GET', url: '/merchant/v1/last-leg/relabel-applications', params })
|
return apiRequest<PageResult<RelabelSummary>>({ method: 'GET', url: '/merchant/v1/last-leg/relabel-applications', params })
|
||||||
}
|
}
|
||||||
@@ -127,14 +132,28 @@ export function getDiscard(discardNo: string) {
|
|||||||
export function listDiscardCandidates(inboundCode?: string) {
|
export function listDiscardCandidates(inboundCode?: string) {
|
||||||
return apiRequest<DiscardCandidate[]>({ method: 'GET', url: '/merchant/v1/last-leg/cargo-discards/inventory-candidates', params: { inboundCode: inboundCode || undefined } })
|
return apiRequest<DiscardCandidate[]>({ method: 'GET', url: '/merchant/v1/last-leg/cargo-discards/inventory-candidates', params: { inboundCode: inboundCode || undefined } })
|
||||||
}
|
}
|
||||||
export function createDiscard(payload: { items: Array<{ orderId?: string; returnCartonId?: string; skuCode: string; discardPackages: number }>; idempotencyKey: string; remark?: string }) {
|
export function createDiscard(payload: { items: Array<{ orderId?: string; returnCartonId?: string; inventoryPositionId?: string; cartonIds?: string[]; skuCode: string; discardPackages: number }>; idempotencyKey: string; remark?: string }) {
|
||||||
return apiRequest<Record<string, unknown>>({ method: 'POST', url: '/merchant/v1/last-leg/cargo-discards', data: payload })
|
return apiRequest<Record<string, unknown>>({ method: 'POST', url: '/merchant/v1/last-leg/cargo-discards', data: payload })
|
||||||
}
|
}
|
||||||
export function cancelDiscard(discardNo: string) {
|
export function cancelDiscard(discardNo: string) {
|
||||||
return apiRequest<Record<string, unknown>>({ method: 'POST', url: `/merchant/v1/last-leg/cargo-discards/${encodeURIComponent(discardNo)}/cancel` })
|
return apiRequest<Record<string, unknown>>({ method: 'POST', url: `/merchant/v1/last-leg/cargo-discards/${encodeURIComponent(discardNo)}/cancel` })
|
||||||
}
|
}
|
||||||
|
export function confirmDiscardCompletion(discardNo: string) {
|
||||||
|
return apiRequest<Record<string, unknown>>({ method: 'POST', url: `/merchant/v1/last-leg/cargo-discards/${encodeURIComponent(discardNo)}/confirm-completion` })
|
||||||
|
}
|
||||||
|
|
||||||
export interface DiscardPhoto { fileId: string; palletNo: string; fileName: string; createdAt: string; submittedAt: string }
|
export interface DiscardPhoto { fileId: string; palletNo: string; fileName: string; createdAt: string; submittedAt: string }
|
||||||
export function downloadDiscardPhoto(discardNo: string, fileId: string) {
|
export function downloadDiscardPhoto(discardNo: string, fileId: string) {
|
||||||
return apiBlobRequest({ method: 'GET', url: `/merchant/v1/last-leg/cargo-discards/${encodeURIComponent(discardNo)}/photos/files/${encodeURIComponent(fileId)}/download` })
|
return apiBlobRequest({ method: 'GET', url: `/merchant/v1/last-leg/cargo-discards/${encodeURIComponent(discardNo)}/photos/files/${encodeURIComponent(fileId)}/download` })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TransferSourceCarton { cartonMark: string; productNameZh?: string; quantity?: number; destinationWarehouseCode?: string; destinationWarehouseName?: string; items: Array<{skuCode?: string; quantity?: number}> }
|
||||||
|
export function getTransferSourceCartons(operatingWarehouseId: string, inboundCode: string) {
|
||||||
|
return apiRequest<TransferSourceCarton[]>({method:'GET',url:'/merchant/v1/last-leg/warehouse-transfers/inventory-source-cartons',params:{operatingWarehouseId,inboundCode}})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 通过客户授权入口读取申请绑定的资料,不使用平台文件地址。 */
|
||||||
|
export function downloadRelabelDocument(relabelNo: string, fileId: string, scope: string, itemId?: string) {
|
||||||
|
const base = '/merchant/v1/last-leg/relabel-applications/' + encodeURIComponent(relabelNo)
|
||||||
|
return apiBlobRequest({ method: 'GET', url: scope === 'SHIPPING' ? base + '/shipping-label/' + encodeURIComponent(fileId) + '/download' : base + '/files/' + encodeURIComponent(fileId) + '/download', params: scope === 'SHIPPING' ? undefined : { scope, itemId } })
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import ts from 'typescript'
|
||||||
|
|
||||||
|
const source = readFileSync(new URL('./CompletionConfirmationDialog.vue', import.meta.url), 'utf8')
|
||||||
|
const script = source.match(/<script setup lang="ts">([\s\S]*?)<\/script>/)[1]
|
||||||
|
const js = ts.transpileModule(script, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext } }).outputText
|
||||||
|
.replace(/^import .*$/gm, '').replace(/^export \{\};?$/gm, '')
|
||||||
|
|
||||||
|
function harness(kind = 'disposal', overrides = {}) {
|
||||||
|
const props = { modelValue: true, kind, documentNo: 'CD/1' }
|
||||||
|
const calls = []; const events = []; const revoked = []
|
||||||
|
const api = {
|
||||||
|
getDiscard: async () => ({ summary: { status: 'PDA_COMPLETED' }, photos: [{ fileId: '101' }] }),
|
||||||
|
getRelabel: async () => ({ application: { status: 'CUSTOMER_CONFIRM_PENDING' }, completionEvidence: { photoFileIds: ['101'] } }),
|
||||||
|
getTransfer: async () => ({ status: 'WAIT_CUSTOMER_CONFIRMATION', measurementPhotos: [{ fileId: '101' }], cartons: [{ cartonMark: 'BOX-1', status: 'COUNTED', weight: 5 }], pallets: [{ palletNo: 'PLT-1', grossWeightKg: 5 }] }),
|
||||||
|
downloadTransferMeasurementPhoto: async () => ({}),
|
||||||
|
confirmTransferMeasurement: async no => calls.push(['transfer', no]),
|
||||||
|
downloadDiscardPhoto: async () => ({}), downloadRelabelCompletionPhoto: async () => ({}),
|
||||||
|
confirmDiscardCompletion: async no => calls.push(['discard', no]),
|
||||||
|
confirmRelabelCompletion: async no => calls.push(['relabel', no]), ...overrides,
|
||||||
|
}
|
||||||
|
const env = {
|
||||||
|
computed: fn => ({ get value() { return fn() } }), ref: value => ({ value }), watch: () => {}, onBeforeUnmount: () => {},
|
||||||
|
defineProps: () => props, defineEmits: () => (...args) => events.push(args),
|
||||||
|
useAuthStore: () => ({ hasPermission: () => true }), ElMessage: { success() {}, error() {} },
|
||||||
|
URL: { createObjectURL: () => 'blob:photo', revokeObjectURL: url => revoked.push(url) }, ...api,
|
||||||
|
}
|
||||||
|
const state = new Function(...Object.keys(env), js + '\nreturn { loadEvidence, loadPhoto, submit, clear, checked, canConfirm, urls, failed, error, busy, measurement };')(...Object.values(env))
|
||||||
|
return { ...state, props, calls, events, revoked }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('仓储转运客户确认测量弹窗不展示箱子明细和托盘尺寸', () => {
|
||||||
|
assert.doesNotMatch(source, /<el-table :data="measurement\.cartons\?\.filter\(carton => carton\.status === 'COUNTED'\)/)
|
||||||
|
assert.doesNotMatch(source, /<el-table-column label="尺寸\(cm\)"/)
|
||||||
|
assert.doesNotMatch(source, /箱数、尺寸、重量/)
|
||||||
|
assert.match(source, /<el-table-column prop="grossWeightKg" label="毛重\(kg\)"/)
|
||||||
|
assert.match(source, /<el-table-column prop="volumeCbm" label="体积\(m³\)"/)
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const kind of ['disposal', 'labeling', 'transfer']) {
|
||||||
|
test(`${kind}: opening loads photos without confirming; only reviewed evidence can be submitted`, async () => {
|
||||||
|
const state = harness(kind)
|
||||||
|
await state.loadEvidence()
|
||||||
|
assert.deepEqual(state.calls, [])
|
||||||
|
await state.submit(); assert.deepEqual(state.calls, [])
|
||||||
|
state.checked.value = true
|
||||||
|
assert.equal(state.canConfirm.value, true)
|
||||||
|
await state.submit()
|
||||||
|
assert.deepEqual(state.calls, [[kind === 'transfer' ? 'transfer' : kind === 'disposal' ? 'discard' : 'relabel', 'CD/1']])
|
||||||
|
assert.deepEqual(state.events, [['confirmed', 'CD/1'], ['update:modelValue', false]])
|
||||||
|
state.clear(); assert.deepEqual(state.revoked, ['blob:photo'])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test('transfer confirmation reads current measurements and blocks missing, failed or outdated evidence', async () => {
|
||||||
|
const state = harness('transfer', { downloadTransferMeasurementPhoto: async () => { throw Error('offline') } })
|
||||||
|
await state.loadEvidence()
|
||||||
|
assert.equal(state.measurement.value.pallets[0].grossWeightKg, 5)
|
||||||
|
assert.equal(state.measurement.value.cartons[0].cartonMark, 'BOX-1')
|
||||||
|
state.checked.value = true
|
||||||
|
await state.submit()
|
||||||
|
assert.equal(state.canConfirm.value, false)
|
||||||
|
assert.deepEqual(state.calls, [])
|
||||||
|
for (const detail of [
|
||||||
|
{ status: 'WAIT_CUSTOMER_CONFIRMATION', measurementPhotos: [] },
|
||||||
|
{ status: 'COMPLETED', measurementPhotos: [{ fileId: '101' }] },
|
||||||
|
{ status: 'WAIT_CUSTOMER_CONFIRMATION', customerConfirmedAt: '2026-10-16', measurementPhotos: [{ fileId: '101' }] },
|
||||||
|
]) {
|
||||||
|
const invalid = harness('transfer', { getTransfer: async () => detail })
|
||||||
|
await invalid.loadEvidence(); invalid.checked.value = true; await invalid.submit()
|
||||||
|
assert.equal(invalid.canConfirm.value, false)
|
||||||
|
assert.deepEqual(invalid.calls, [])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('photo failure blocks confirmation and supports retry', async () => {
|
||||||
|
let reject = true
|
||||||
|
const state = harness('disposal', { downloadDiscardPhoto: async () => { if (reject) throw Error('offline'); return {} } })
|
||||||
|
await state.loadEvidence(); state.checked.value = true
|
||||||
|
assert.equal(state.failed.value['101'], true); assert.equal(state.canConfirm.value, false)
|
||||||
|
reject = false; await state.loadPhoto('101')
|
||||||
|
assert.equal(state.canConfirm.value, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('closing while photos load prevents stale preview URLs', async () => {
|
||||||
|
let resolve
|
||||||
|
const state = harness('disposal', { downloadDiscardPhoto: () => new Promise(done => { resolve = done }) })
|
||||||
|
const pending = state.loadEvidence()
|
||||||
|
await Promise.resolve()
|
||||||
|
state.props.modelValue = false; state.clear(); resolve({}); await pending
|
||||||
|
assert.deepEqual(state.urls.value, {})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('completed orders and missing evidence cannot be confirmed', async () => {
|
||||||
|
for (const detail of [{ summary: { status: 'COMPLETED' }, photos: [{ fileId: '101' }] }, { summary: { status: 'PDA_COMPLETED' }, photos: [] }]) {
|
||||||
|
const state = harness('disposal', { getDiscard: async () => detail })
|
||||||
|
await state.loadEvidence(); state.checked.value = true; await state.submit()
|
||||||
|
assert.equal(state.canConfirm.value, false); assert.deepEqual(state.calls, [])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('failed submission preserves dialog and a pending submission is not sent twice', async () => {
|
||||||
|
let reject
|
||||||
|
const state = harness('disposal', { confirmDiscardCompletion: () => new Promise((_, fail) => { reject = fail }) })
|
||||||
|
await state.loadEvidence(); state.checked.value = true
|
||||||
|
const pending = state.submit()
|
||||||
|
assert.equal(state.busy.value, true); await state.submit()
|
||||||
|
reject(Error('offline')); await pending
|
||||||
|
assert.deepEqual(state.events, []); assert.equal(state.busy.value, false)
|
||||||
|
assert.equal(state.canConfirm.value, true)
|
||||||
|
})
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { useAuthStore } from '@/modules/auth/public'
|
||||||
|
import { confirmDiscardCompletion, confirmRelabelCompletion, confirmTransferMeasurement, downloadDiscardPhoto, downloadRelabelCompletionPhoto, downloadTransferMeasurementPhoto, getDiscard, getRelabel, getTransfer, type DiscardPhoto } from '../api/warehouseServices'
|
||||||
|
|
||||||
|
const props = defineProps<{ modelValue: boolean; kind: 'disposal' | 'labeling' | 'transfer'; documentNo: string }>()
|
||||||
|
const emit = defineEmits<{ 'update:modelValue': [value: boolean]; confirmed: [documentNo: string] }>()
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const title = computed(() => props.kind === 'transfer' ? '确认测量数据' : props.kind === 'disposal' ? '废弃确认' : '贴标完成确认')
|
||||||
|
interface MeasurementEvidence {
|
||||||
|
status?: string
|
||||||
|
customerConfirmedAt?: string
|
||||||
|
cartons?: Array<{ cartonMark: string; status: string; length?: number; width?: number; height?: number; weight?: number }>
|
||||||
|
pallets?: Array<{ palletNo?: string; sequenceNo?: number; cartonCount?: number; lengthCm?: number; widthCm?: number; heightCm?: number; grossWeightKg?: number; volumeCbm?: number }>
|
||||||
|
measurementPhotos?: Array<{ fileId: string | number }>
|
||||||
|
}
|
||||||
|
const measurement = ref<MeasurementEvidence | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const busy = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const fileIds = ref<string[]>([])
|
||||||
|
const urls = ref<Record<string, string>>({})
|
||||||
|
const failed = ref<Record<string, boolean>>({})
|
||||||
|
const photoLoading = ref<Record<string, boolean>>({})
|
||||||
|
const checked = ref(false)
|
||||||
|
const eligible = ref(false)
|
||||||
|
let generation = 0
|
||||||
|
const previewUrls = computed(() => fileIds.value.map(id => urls.value[id]).filter(Boolean))
|
||||||
|
const canConfirm = computed(() => eligible.value && checked.value && !loading.value && !busy.value && !error.value
|
||||||
|
&& fileIds.value.length > 0 && fileIds.value.every(id => Boolean(urls.value[id]))
|
||||||
|
&& auth.hasPermission(`merchant:overseas-warehouse:${props.kind === 'transfer' ? 'transfer' : props.kind === 'disposal' ? 'discard' : 'relabel'}:confirm`))
|
||||||
|
function clear() {
|
||||||
|
generation++
|
||||||
|
Object.values(urls.value).forEach(URL.revokeObjectURL)
|
||||||
|
urls.value = {}; failed.value = {}; photoLoading.value = {}; fileIds.value = []; checked.value = false; eligible.value = false
|
||||||
|
measurement.value = null
|
||||||
|
}
|
||||||
|
async function loadPhoto(fileId: string) {
|
||||||
|
if (photoLoading.value[fileId]) return
|
||||||
|
const currentGeneration = generation; const no = props.documentNo; const kind = props.kind
|
||||||
|
photoLoading.value[fileId] = true; failed.value[fileId] = false
|
||||||
|
try {
|
||||||
|
const blob = kind === 'transfer' ? await downloadTransferMeasurementPhoto(no, fileId) : kind === 'disposal' ? await downloadDiscardPhoto(no, fileId) : await downloadRelabelCompletionPhoto(no, fileId)
|
||||||
|
if (generation !== currentGeneration || !props.modelValue) return
|
||||||
|
if (urls.value[fileId]) URL.revokeObjectURL(urls.value[fileId])
|
||||||
|
urls.value[fileId] = URL.createObjectURL(blob)
|
||||||
|
} catch { if (generation === currentGeneration) failed.value[fileId] = true }
|
||||||
|
finally { if (generation === currentGeneration) photoLoading.value[fileId] = false }
|
||||||
|
}
|
||||||
|
async function loadEvidence() {
|
||||||
|
clear(); error.value = ''; loading.value = true
|
||||||
|
const currentGeneration = generation
|
||||||
|
try {
|
||||||
|
if (props.kind === 'transfer') {
|
||||||
|
const detail = await getTransfer(props.documentNo) as MeasurementEvidence
|
||||||
|
if (generation !== currentGeneration) return
|
||||||
|
measurement.value = detail
|
||||||
|
eligible.value = detail.status === 'WAIT_CUSTOMER_CONFIRMATION' && !detail.customerConfirmedAt
|
||||||
|
fileIds.value = (detail.measurementPhotos || []).map(photo => String(photo.fileId))
|
||||||
|
} else if (props.kind === 'disposal') {
|
||||||
|
const detail = await getDiscard(props.documentNo) as { summary?: { status?: string }; photos?: DiscardPhoto[] }
|
||||||
|
if (generation !== currentGeneration) return
|
||||||
|
eligible.value = detail.summary?.status === 'PDA_COMPLETED'
|
||||||
|
fileIds.value = (detail.photos || []).map(photo => String(photo.fileId))
|
||||||
|
} else {
|
||||||
|
const detail = await getRelabel(props.documentNo)
|
||||||
|
if (generation !== currentGeneration) return
|
||||||
|
eligible.value = detail.application.status === 'CUSTOMER_CONFIRM_PENDING' && !detail.application.customerConfirmedAt
|
||||||
|
fileIds.value = (detail.completionEvidence?.photoFileIds || []).map(String)
|
||||||
|
}
|
||||||
|
fileIds.value = [...new Set(fileIds.value)]
|
||||||
|
if (!eligible.value) error.value = '当前单据已确认或尚未完成 PDA 作业,请刷新列表核对。'
|
||||||
|
else if (!fileIds.value.length) error.value = props.kind === 'transfer' ? '暂无 PDA 测量打托照片,暂不能确认。' : '暂无 PDA 完工作业照片,暂不能确认。'
|
||||||
|
await Promise.all(fileIds.value.map(loadPhoto))
|
||||||
|
} catch (e) { if (generation === currentGeneration) error.value = e instanceof Error ? e.message : '作业照片加载失败' }
|
||||||
|
finally { if (generation === currentGeneration) loading.value = false }
|
||||||
|
}
|
||||||
|
async function submit() {
|
||||||
|
if (!canConfirm.value) return
|
||||||
|
busy.value = true
|
||||||
|
try {
|
||||||
|
if (props.kind === 'transfer') await confirmTransferMeasurement(props.documentNo)
|
||||||
|
else if (props.kind === 'disposal') await confirmDiscardCompletion(props.documentNo)
|
||||||
|
else await confirmRelabelCompletion(props.documentNo)
|
||||||
|
ElMessage.success(props.kind === 'transfer' ? '仓库实测数据已确认' : props.kind === 'disposal' ? '废弃已确认,单据已完结' : '贴标已确认,订单进入后续出库流程')
|
||||||
|
emit('confirmed', props.documentNo)
|
||||||
|
emit('update:modelValue', false)
|
||||||
|
} catch (e) { ElMessage.error(e instanceof Error ? e.message : '确认失败,请重试') }
|
||||||
|
finally { busy.value = false }
|
||||||
|
}
|
||||||
|
watch(() => [props.modelValue, props.kind, props.documentNo], () => { if (props.modelValue) void loadEvidence(); else clear() }, { immediate: true })
|
||||||
|
onBeforeUnmount(clear)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-dialog :model-value="modelValue" :title="title" width="760px" :close-on-click-modal="false" :close-on-press-escape="!busy" :show-close="!busy" @update:model-value="!busy && emit('update:modelValue', $event)">
|
||||||
|
<p>单号:{{ documentNo }}</p>
|
||||||
|
<p>{{ kind === 'transfer' ? '请核对 PDA 回传的箱数、重量、打托数据及现场照片,确认无误后再提交。' : '请核对以下 PDA 作业照片,确认作业结果无误后再提交。' }}</p>
|
||||||
|
<template v-if="kind === 'transfer' && measurement">
|
||||||
|
<p>实际签收箱数:{{ measurement.cartons?.filter(carton => carton.status === 'COUNTED').length ?? 0 }} 箱;托盘数:{{ measurement.pallets?.length ?? 0 }} 托</p>
|
||||||
|
<el-table :data="measurement.pallets || []" max-height="220" size="small" border empty-text="暂无 PDA 打托测量数据">
|
||||||
|
<el-table-column label="托盘号" min-width="180"><template #default="{ row }">{{ row.palletNo || `第${row.sequenceNo || '—'}托` }}</template></el-table-column>
|
||||||
|
<el-table-column prop="cartonCount" label="箱数" width="65" />
|
||||||
|
<el-table-column prop="grossWeightKg" label="毛重(kg)" width="95" />
|
||||||
|
<el-table-column prop="volumeCbm" label="体积(m³)" width="95" />
|
||||||
|
</el-table>
|
||||||
|
</template>
|
||||||
|
<p v-if="loading">正在读取作业照片…</p>
|
||||||
|
<el-alert v-if="error" :title="error" type="error" :closable="false" />
|
||||||
|
<el-button v-if="error" :disabled="loading || busy" @click="loadEvidence">重新读取</el-button>
|
||||||
|
<div class="completion-photo-grid">
|
||||||
|
<div v-for="(fileId, index) in fileIds" :key="fileId">
|
||||||
|
<el-image v-if="urls[fileId]" :src="urls[fileId]" :preview-src-list="previewUrls" :initial-index="previewUrls.indexOf(urls[fileId])" fit="contain" preview-teleported />
|
||||||
|
<span v-else-if="photoLoading[fileId]">照片 {{ index + 1 }} 加载中…</span>
|
||||||
|
<el-button v-else :disabled="busy" @click="loadPhoto(fileId)">{{ failed[fileId] ? '照片加载失败,点击重试' : '加载照片' }}</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-checkbox v-model="checked" :disabled="loading || busy || !!error || previewUrls.length !== fileIds.length">{{ kind === 'transfer' ? '我已核对 PDA 实测数据及现场照片,确认无误' : '我已核对 PDA 作业照片,确认作业结果无误' }}</el-checkbox>
|
||||||
|
<template #footer>
|
||||||
|
<el-button :disabled="busy" @click="emit('update:modelValue', false)">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="busy" :disabled="!canConfirm" @click="submit">{{ kind === 'transfer' ? '确认数据' : kind === 'disposal' ? '确认废弃完结' : '确认贴标完成' }}</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.completion-photo-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin: 16px 0; }
|
||||||
|
.completion-photo-grid .el-image { width: 100%; height: 180px; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch, onBeforeUnmount } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { getRelabel, downloadRelabelDocument, type RelabelDetail } from '../api/warehouseServices'
|
||||||
|
const props = defineProps<{ relabelNo: string; detail?: RelabelDetail | null; inline?: boolean }>()
|
||||||
|
const loaded = ref<RelabelDetail | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const busy = ref(false)
|
||||||
|
let generation = 0
|
||||||
|
const current = computed(() => props.detail || loaded.value)
|
||||||
|
/** 各申请分别加载,失败只影响资料区,不阻断订单详情。 */
|
||||||
|
async function load() {
|
||||||
|
const ticket = ++generation
|
||||||
|
loaded.value = null; error.value = ''
|
||||||
|
if (props.detail || !props.relabelNo) { loading.value = false; return }
|
||||||
|
loading.value = true
|
||||||
|
try { const result = await getRelabel(props.relabelNo); if (ticket === generation) loaded.value = result }
|
||||||
|
catch (cause) { if (ticket === generation) error.value = cause instanceof Error ? cause.message : '贴标资料加载失败' }
|
||||||
|
finally { if (ticket === generation) loading.value = false }
|
||||||
|
}
|
||||||
|
watch(() => [props.relabelNo, props.detail], () => { void load() }, { immediate: true })
|
||||||
|
interface DocumentRow { key: string; kind: string; fileId: string; fileName: string; scope: string; itemId?: string }
|
||||||
|
const documents = computed<DocumentRow[]>(() => {
|
||||||
|
const result: DocumentRow[] = []
|
||||||
|
const application = current.value?.application
|
||||||
|
if (!application) return result
|
||||||
|
// 旧总文件仅作为兼容回显,避免与已分类型的总文件重复展示。
|
||||||
|
const add = (kind: string, id: string | null | undefined, name: string | null | undefined, scope: string, itemId?: string) => {
|
||||||
|
if (id) result.push({ key: scope + ':' + (itemId || '') + ':' + id, kind, fileId: id, fileName: name || '未命名文件', scope, itemId })
|
||||||
|
}
|
||||||
|
add('箱唛贴标文件', application.cartonTotalFileId, application.cartonTotalFileName, 'CARTON_TOTAL')
|
||||||
|
add('SKU 贴标文件', application.skuTotalFileId, application.skuTotalFileName, 'SKU_TOTAL')
|
||||||
|
if (application.totalFileId !== application.cartonTotalFileId && application.totalFileId !== application.skuTotalFileId)
|
||||||
|
add('贴标总文件', application.totalFileId, application.totalFileName, 'TOTAL')
|
||||||
|
add('面单文件', application.shippingLabelFileId, application.shippingLabelFileName, 'SHIPPING')
|
||||||
|
for (const item of current.value?.items || []) add('明细文件', item.relabelFileId, item.relabelFileName, 'ITEM', String(item.id))
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
/** 使用带身份认证的下载接口,保留文件归属校验和原始文件名。 */
|
||||||
|
async function downloadFile(key: string) {
|
||||||
|
const row = documents.value.find(document => document.key === key)
|
||||||
|
if (!row || busy.value) return
|
||||||
|
busy.value = true
|
||||||
|
const ticket = generation
|
||||||
|
try {
|
||||||
|
const blob = await downloadRelabelDocument(props.relabelNo, row.fileId, row.scope, row.itemId)
|
||||||
|
if (ticket !== generation) return
|
||||||
|
const url = URL.createObjectURL(blob); const link = document.createElement('a')
|
||||||
|
link.href = url; link.download = row.fileName; link.click(); window.setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||||
|
} catch (cause) { ElMessage.error(cause instanceof Error ? cause.message : '文件读取失败,请重试') }
|
||||||
|
finally { busy.value = false }
|
||||||
|
}
|
||||||
|
onBeforeUnmount(() => { ++generation })
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<section class="relabel-documents" :class="{ 'relabel-documents--inline': inline }" aria-label="客户上传的贴标文件">
|
||||||
|
<h3>贴标文件</h3>
|
||||||
|
<p v-if="loading">资料加载中…</p>
|
||||||
|
<p v-else-if="error" role="alert">{{ error }} <el-button link type="primary" @click="load">重试</el-button></p>
|
||||||
|
<div v-else-if="inline" class="relabel-document-links">
|
||||||
|
<div v-for="row in documents" :key="row.key" class="relabel-document-link"><span>{{ row.kind }}</span><el-button link type="primary" :disabled="busy" @click="downloadFile(row.key)">下载</el-button></div>
|
||||||
|
<span v-if="!documents.length">暂无上传的贴标文件</span>
|
||||||
|
</div>
|
||||||
|
<el-table v-else :data="documents" row-key="key" border empty-text="暂无上传的贴标文件">
|
||||||
|
<el-table-column prop="kind" label="文件类型" min-width="150" />
|
||||||
|
<el-table-column label="操作" width="90"><template #default="{ row }"><el-button link type="primary" :disabled="busy" @click="downloadFile(String(row.key))">下载</el-button></template></el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
<style scoped>
|
||||||
|
.relabel-documents { margin: 16px 0; }
|
||||||
|
.relabel-documents h3 { margin: 0 0 12px; font-size: 14px; }
|
||||||
|
.relabel-documents--inline { display: flex; align-items: center; gap: 12px; margin: 0; min-width: 0; }
|
||||||
|
.relabel-documents--inline h3 { flex-shrink: 0; margin: 0; color: var(--muted); font-size: 12px; font-weight: 400; }
|
||||||
|
.relabel-document-links { display: flex; flex-wrap: wrap; gap: 4px 12px; }
|
||||||
|
.relabel-document-link { display: flex; align-items: center; gap: 8px; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { createSSRApp } from 'vue'
|
||||||
|
import { renderToString } from 'vue/server-renderer'
|
||||||
|
import { parse } from 'vue/compiler-sfc'
|
||||||
|
|
||||||
|
const source = readFileSync(new URL('./ReviewRejectionNotice.vue', import.meta.url), 'utf8')
|
||||||
|
const template = parse(source).descriptor.template.content
|
||||||
|
const render = (status, reason) => renderToString(createSSRApp({ template, props: ['status', 'reason'] }, { status, reason }))
|
||||||
|
|
||||||
|
test('rejected documents display the actual reason safely', async () => {
|
||||||
|
const html = await render('REJECTED', '箱数不符\n请核对 <SKU>')
|
||||||
|
assert.match(html, /驳回原因/)
|
||||||
|
assert.match(html, /箱数不符\n请核对 <SKU>/)
|
||||||
|
assert.match(html, /role="alert"/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('non-rejected documents do not show an old rejection reason', async () => {
|
||||||
|
for (const status of ['PENDING_REVIEW', 'APPROVED', 'COMPLETED', undefined]) {
|
||||||
|
assert.doesNotMatch(await render(status, '旧审核原因'), /旧审核原因|驳回原因/)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('legacy rejection without a reason has an explicit fallback', async () => {
|
||||||
|
for (const reason of [undefined, '', ' ']) {
|
||||||
|
assert.match(await render('REJECTED', reason), /暂未记录驳回原因/)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{ status?: string; reason?: string }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="status === 'REJECTED'" class="review-rejection-notice" role="alert">
|
||||||
|
<strong>驳回原因</strong>
|
||||||
|
<span>{{ reason?.trim() || '暂未记录驳回原因,请联系平台确认' }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.review-rejection-notice{display:flex;align-items:flex-start;gap:12px;margin:12px 16px;padding:10px 12px;border:1px solid var(--el-color-danger-light-7);border-radius:4px;background:var(--el-color-danger-light-9);color:var(--el-color-danger);font-size:13px;line-height:1.7}
|
||||||
|
.review-rejection-notice strong{flex-shrink:0;font-weight:600}
|
||||||
|
.review-rejection-notice span{min-width:0;white-space:pre-wrap;overflow-wrap:anywhere}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||||
|
import { getTransferPickupProgress, downloadTransferPickupEvidence, type PickupProgress } from '../api/transferPickups'
|
||||||
|
|
||||||
|
const props = defineProps<{ transferNo: string }>()
|
||||||
|
const progress = ref<PickupProgress | null>(null), error = ref(''), loading = ref(false)
|
||||||
|
const labels: Record<string, string> = { PENDING_REVIEW: '待审核', WAITING: '待安排取货', ASSIGNED: '已安排取货', PICKED: '已取货', ARRIVED: '已送仓' }
|
||||||
|
const time = (value: string | null) => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—'
|
||||||
|
let sequence = 0
|
||||||
|
/** 业务号变化后忽略旧响应,客户之间不复用取货数据。 */
|
||||||
|
async function load() {
|
||||||
|
const current = ++sequence; progress.value = null; error.value = ''; loading.value = true
|
||||||
|
try { const value = await getTransferPickupProgress(props.transferNo); if (current === sequence) progress.value = value }
|
||||||
|
catch (cause) { if (current === sequence) error.value = cause instanceof Error ? cause.message : '取货进度加载失败' }
|
||||||
|
finally { if (current === sequence) loading.value = false }
|
||||||
|
}
|
||||||
|
/** 凭证读取必须经过原单鉴权。 */
|
||||||
|
async function download(file: { fileId: string; fileName: string }) {
|
||||||
|
try { const blob = await downloadTransferPickupEvidence(props.transferNo, file.fileId); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = file.fileName; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000) }
|
||||||
|
catch (cause) { error.value = cause instanceof Error ? cause.message : '取货凭证下载失败' }
|
||||||
|
}
|
||||||
|
watch(() => props.transferNo, load, { immediate: true }); onBeforeUnmount(() => { sequence++ })
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<section v-if="loading || progress || error" v-loading="loading" class="pickup-progress" aria-label="取货安排">
|
||||||
|
<div class="pickup-heading"><h3>取货安排</h3><el-button size="small" :disabled="loading" @click="load">刷新进度</el-button></div>
|
||||||
|
<el-alert v-if="error" :title="error" type="error" :closable="false" />
|
||||||
|
<dl v-if="progress" class="pickup-fields">
|
||||||
|
<div><dt>取货进度</dt><dd>{{ labels[progress.status] || progress.status }}</dd></div><div><dt>车次号</dt><dd>{{ progress.tripNo || '待安排' }}</dd></div>
|
||||||
|
<div><dt>车牌</dt><dd>{{ progress.vehiclePlate || '待分配' }}</dd></div><div><dt>司机</dt><dd>{{ progress.driverName || '待分配' }}</dd></div>
|
||||||
|
<div><dt>司机电话</dt><dd>{{ progress.driverPhone || '—' }}</dd></div><div><dt>计划取货发车</dt><dd>{{ time(progress.plannedDepartureAt) }}</dd></div>
|
||||||
|
<div><dt>实提箱数</dt><dd>{{ progress.actualPackages ?? '待取货' }}</dd></div><div><dt>取货时间</dt><dd>{{ time(progress.pickedAt) }}</dd></div>
|
||||||
|
<div><dt>送仓时间</dt><dd>{{ time(progress.arrivedAt) }}</dd></div><div v-if="progress.remark"><dt>取货备注</dt><dd>{{ progress.remark }}</dd></div>
|
||||||
|
</dl>
|
||||||
|
<div v-if="progress?.evidence.length" class="pickup-files"><span>取货凭证</span><el-button v-for="file in progress.evidence" :key="file.fileId" link type="primary" @click="download(file)">{{ file.fileName }}</el-button></div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
<style scoped>.pickup-progress{padding:16px;border:1px solid var(--border,#e5e7eb);border-radius:6px;margin:16px 0}.pickup-heading{display:flex;justify-content:space-between;align-items:center}.pickup-heading h3{margin:0;font-size:15px}.pickup-fields{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px 20px;margin:16px 0 0}.pickup-fields>div{display:flex;gap:10px}.pickup-fields dt{color:var(--muted,#64748b);flex-shrink:0}.pickup-fields dd{margin:0;overflow-wrap:anywhere}.pickup-files{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-top:14px}</style>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import { readFileSync } from 'node:fs'
|
import { readFileSync } from 'node:fs'
|
||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
|
import ts from 'typescript'
|
||||||
|
|
||||||
const routes = readFileSync(new URL('./routes.ts', import.meta.url), 'utf8')
|
const routes = readFileSync(new URL('./routes.ts', import.meta.url), 'utf8')
|
||||||
const services = readFileSync(new URL('./views/WarehouseServiceView.vue', import.meta.url), 'utf8')
|
const services = readFileSync(new URL('./views/WarehouseServiceView.vue', import.meta.url), 'utf8')
|
||||||
@@ -41,13 +42,31 @@ 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]*?await goToServiceList\(\)/)
|
||||||
// Return-carton sources use the unified relabel application and its file endpoint.
|
// Return-carton sources use the unified relabel application and its file endpoint.
|
||||||
assert.match(services, /returnCartonId: item\.returnCartonId/)
|
assert.match(services, /returnCartonId: item\.returnCartonId/)
|
||||||
assert.match(services, /uploadRelabelFile\(created\.application\.relabelNo,[\s\S]*?router\.push\(\{ name: serviceListRouteName\(props\.service\) \}\)/)
|
assert.match(services, /uploadRelabelFile\(created\.application\.relabelNo,[\s\S]*?await goToServiceList\(\)/)
|
||||||
assert.match(services, /申请已创建,附件待补传/)
|
assert.match(services, /申请已创建,附件待补传/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('service list return navigates standalone pages and refreshes embedded lists', async () => {
|
||||||
|
const source = services.slice(services.indexOf('function serviceListRouteName('), services.indexOf('async function openEdit('))
|
||||||
|
const javascript = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText
|
||||||
|
for (const props of [
|
||||||
|
{ service: 'labeling', createPage: true, detailPage: false },
|
||||||
|
{ service: 'labeling', createPage: false, detailPage: true },
|
||||||
|
{ service: 'labeling', createPage: false, detailPage: false },
|
||||||
|
]) {
|
||||||
|
const calls = [], mode = { value: 'create' }
|
||||||
|
const goToServiceList = new Function('props', 'router', 'mode', 'loadPage', `${javascript}; return goToServiceList`)(
|
||||||
|
props, { push: async route => calls.push(route) }, mode, async () => calls.push('refresh'),
|
||||||
|
)
|
||||||
|
await goToServiceList()
|
||||||
|
assert.deepEqual(calls, props.createPage || props.detailPage ? [{ name: 'overseas-labeling' }] : ['refresh'])
|
||||||
|
if (!props.createPage && !props.detailPage) assert.equal(mode.value, 'list')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test('keeps overseas warehouse writes behind operation-specific button permissions', () => {
|
test('keeps overseas warehouse writes behind operation-specific button permissions', () => {
|
||||||
for (const permission of [
|
for (const permission of [
|
||||||
'merchant:overseas-warehouse:relabel:create',
|
'merchant:overseas-warehouse:relabel:create',
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import test from 'node:test'
|
||||||
|
import ts from 'typescript'
|
||||||
|
|
||||||
|
const compiled = ts.transpileModule(readFileSync(new URL('./discardDocuments.ts', import.meta.url), 'utf8'), { compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 } }).outputText
|
||||||
|
const { groupDiscardDocuments } = await import(`data:text/javascript;base64,${Buffer.from(compiled).toString('base64')}`)
|
||||||
|
|
||||||
|
const returned = (id, warehouse = 'WH', document = 'TT-BYWL-261019152551') => ({
|
||||||
|
orderId: `return:${id}`, returnCartonId: id, sourceType: 'RETURN_CARTON', overseasWarehouseId: warehouse,
|
||||||
|
inboundCode: document, inboundOrderNo: document, availablePackages: 5, cartonSelectionSupported: true,
|
||||||
|
cartons: [{ cartonId: id, cartonMark: `BOX-${id}`, skus: [{ skuCode: 'A', availablePackages: 5 }] }], skus: [{ skuCode: 'A', availablePackages: 5 }],
|
||||||
|
})
|
||||||
|
|
||||||
|
test('同单两箱合并成一个选项,可用量为两箱而非十件,保留原始提交身份', () => {
|
||||||
|
const sources = [returned('1'), returned('2')]
|
||||||
|
const result = groupDiscardDocuments(sources)
|
||||||
|
assert.equal(result.length, 1)
|
||||||
|
assert.equal(result[0].availablePackages, 2)
|
||||||
|
assert.deepEqual(result[0].sources, sources)
|
||||||
|
assert.equal(result[0].cartonSelectionSupported, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('不同单据或操作仓独立展示,混装SKU不重复计算物理箱', () => {
|
||||||
|
assert.equal(groupDiscardDocuments([returned('1'), returned('2', 'OTHER'), returned('3', 'WH', 'TT-OTHER')]).length, 3)
|
||||||
|
const source = { ...returned('1'), sourceType: 'INVENTORY', orderId: 'inventory:1', inventoryPositionId: '1', availablePackages: 10 }
|
||||||
|
const second = { ...source, orderId: 'inventory:2', inventoryPositionId: '2', cartons: source.cartons }
|
||||||
|
assert.equal(groupDiscardDocuments([source, second])[0].availablePackages, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('缺少箱级记录时合并仓位可用箱数并保持全部废弃明细', () => {
|
||||||
|
const first = { ...returned('1'), sourceType: 'INVENTORY', orderId: 'inventory:1', cartonSelectionSupported: false, availablePackages: 3 }
|
||||||
|
const second = { ...first, orderId: 'inventory:2', availablePackages: 4 }
|
||||||
|
const result = groupDiscardDocuments([first, second])[0]
|
||||||
|
assert.equal(result.availablePackages, 7)
|
||||||
|
assert.equal(result.cartonSelectionSupported, false)
|
||||||
|
assert.equal(result.sources.length, 2)
|
||||||
|
})
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type { DiscardCandidate } from '../api/warehouseServices'
|
||||||
|
|
||||||
|
export interface DiscardDocument extends DiscardCandidate {
|
||||||
|
sources: DiscardCandidate[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 下拉按来源单据和操作仓汇总;原始库存身份保留给选箱与提交。 */
|
||||||
|
export function groupDiscardDocuments(candidates: DiscardCandidate[]): DiscardDocument[] {
|
||||||
|
const groups = new Map<string, DiscardDocument>()
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const documentNo = candidate.inboundOrderNo || candidate.inboundCode
|
||||||
|
const key = JSON.stringify([candidate.overseasWarehouseId, candidate.sourceType, documentNo || candidate.orderId])
|
||||||
|
let document = groups.get(key)
|
||||||
|
if (!document) {
|
||||||
|
document = { ...candidate, orderId: key, sources: [], availablePackages: 0 }
|
||||||
|
groups.set(key, document)
|
||||||
|
}
|
||||||
|
document.sources.push(candidate)
|
||||||
|
}
|
||||||
|
for (const document of groups.values()) {
|
||||||
|
const boxes = new Set<string>()
|
||||||
|
let untrackedPackages = 0
|
||||||
|
for (const source of document.sources) {
|
||||||
|
if (source.sourceType === 'RETURN_CARTON' && source.returnCartonId) {
|
||||||
|
// 退货候选的 availablePackages 是箱内 SKU 件数,一条退货箱只计一箱。
|
||||||
|
boxes.add(`RETURN_CARTON:${source.returnCartonId}`)
|
||||||
|
} else if (source.cartonSelectionSupported && source.cartons?.length) {
|
||||||
|
for (const box of source.cartons) boxes.add(`${source.sourceType}:${box.cartonId}`)
|
||||||
|
} else {
|
||||||
|
untrackedPackages += source.availablePackages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.availablePackages = boxes.size + untrackedPackages
|
||||||
|
document.cartonSelectionSupported = document.sources.every(source => source.cartonSelectionSupported)
|
||||||
|
document.cartonSelectionBlockReason = document.sources.find(source => !source.cartonSelectionSupported)?.cartonSelectionBlockReason
|
||||||
|
}
|
||||||
|
return [...groups.values()]
|
||||||
|
}
|
||||||
@@ -39,6 +39,8 @@ test('箱子在签收箱号明细展示一次,真实托盘和缺少箱号明
|
|||||||
test('parcel waybills appear as tracking numbers while generated parcel codes remain physical codes', () => {
|
test('parcel waybills appear as tracking numbers while generated parcel codes remain physical codes', () => {
|
||||||
assert.equal(exports.receiptTrackingNo({ unitType: 'PARCEL', unitCode: 'sf561626' }), 'sf561626')
|
assert.equal(exports.receiptTrackingNo({ unitType: 'PARCEL', unitCode: 'sf561626' }), 'sf561626')
|
||||||
assert.equal(exports.receiptTrackingNo({ unitType: 'PARCEL', unitCode: 'PK0123456789ABCDEF' }), '')
|
assert.equal(exports.receiptTrackingNo({ unitType: 'PARCEL', unitCode: 'PK0123456789ABCDEF' }), '')
|
||||||
|
assert.equal(exports.receiptTrackingNo({ unitType: 'PARCEL', unitCode: 'PK-FUW3Q9N0B8RL' }), '')
|
||||||
|
assert.equal(exports.receiptTrackingNo({ unitType: 'PARCEL', unitCode: 'PK-FUW3Q9N0B8RL', trackingNo: ' SF123 ' }), 'SF123')
|
||||||
assert.equal(exports.receiptTrackingNo({ unitType: 'PARCEL', unitCode: 'PK0123456789ABCDEF', trackingNo: ' SF123 ' }), 'SF123')
|
assert.equal(exports.receiptTrackingNo({ unitType: 'PARCEL', unitCode: 'PK0123456789ABCDEF', trackingNo: ' SF123 ' }), 'SF123')
|
||||||
assert.equal(exports.receiptTrackingNo({ unitType: 'PALLET', unitCode: 'PLT123' }), '')
|
assert.equal(exports.receiptTrackingNo({ unitType: 'PALLET', unitCode: 'PLT123' }), '')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export function receiptTrackingNo(receipt: Pick<ReturnReceipt, 'trackingNo' | 'u
|
|||||||
const trackingNo = typeof receipt.trackingNo === 'string' ? receipt.trackingNo.trim() : ''
|
const trackingNo = typeof receipt.trackingNo === 'string' ? receipt.trackingNo.trim() : ''
|
||||||
if (trackingNo) return trackingNo
|
if (trackingNo) return trackingNo
|
||||||
const code = typeof receipt.unitCode === 'string' ? receipt.unitCode.trim() : ''
|
const code = typeof receipt.unitCode === 'string' ? receipt.unitCode.trim() : ''
|
||||||
return receipt.unitType === 'PARCEL' && code && !/^PK[0-9A-F]{16}$/.test(code) ? code : ''
|
return receipt.unitType === 'PARCEL' && code && !/^(?:PK[0-9A-F]{16}|PK-[0-9A-Z]+)$/.test(code) ? code : ''
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatReturnVolume(value?: number | null) {
|
export function formatReturnVolume(value?: number | null) {
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import ts from 'typescript'
|
||||||
|
const source = readFileSync(new URL('./transferCartonPrinting.ts', import.meta.url), 'utf8')
|
||||||
|
const js = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText
|
||||||
|
const exports = {}
|
||||||
|
new Function('exports', js)(exports)
|
||||||
|
const prepare = exports.prepareTransferCartonLabels
|
||||||
|
|
||||||
|
test('prints forecast counts independently of quantities or order fields and escapes cargo', () => {
|
||||||
|
const rows = [{ sku: 'A<script>', name: '货物 & 品名', expectedPackageCount: 12 }]
|
||||||
|
let next = 0
|
||||||
|
const html = prepare(rows, () => `BX-${++next}`)
|
||||||
|
assert.equal((html.match(/<section /g) || []).length, 12)
|
||||||
|
assert.match(html, /12 \/ 12/)
|
||||||
|
assert.match(html, /A<script>/)
|
||||||
|
assert.equal(rows[0].generatedCartonMarks.length, 12)
|
||||||
|
assert.equal(prepare(rows, () => { throw new Error('must retain labels') }), html)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('adding boxes or rows keeps previously printed identities', () => {
|
||||||
|
const rows = [{ sku: 'A', name: '', expectedPackageCount: 2 }]
|
||||||
|
let next = 0
|
||||||
|
prepare(rows, () => `BX-${++next}`)
|
||||||
|
rows[0].expectedPackageCount = 3
|
||||||
|
rows.push({ sku: 'B', name: '', expectedPackageCount: 1 })
|
||||||
|
const html = prepare(rows, () => `BX-${++next}`)
|
||||||
|
assert.deepEqual(rows[0].generatedCartonMarks, ['BX-1', 'BX-2', 'BX-3'])
|
||||||
|
assert.match(html, /BX-4/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('invalid or excessive forecasts do not generate any labels', () => {
|
||||||
|
for (const count of [0, -1, 1.5, undefined, 5001]) {
|
||||||
|
const row = { sku: '', name: '', expectedPackageCount: count }
|
||||||
|
assert.throws(() => prepare([row]), /预计箱数/)
|
||||||
|
assert.equal(row.generatedCartonMarks, undefined)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('print button cannot create, update or submit an order', () => {
|
||||||
|
const view = readFileSync(new URL('../views/WarehouseServiceView.vue', import.meta.url), 'utf8')
|
||||||
|
const handler = view.slice(view.indexOf('function printForecastCartons()'), view.indexOf('async function submit('))
|
||||||
|
assert.match(handler, /prepareTransferCartonLabels\(cargoRows.value\)/)
|
||||||
|
assert.doesNotMatch(handler, /createTransfer|updateTransfer|submitTransfer|getTransferCartonLabel|form\./)
|
||||||
|
assert.match(view, /cartonMarks: item.generatedCartonMarks/)
|
||||||
|
})
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
export interface PrintableCargoRow {
|
||||||
|
sku: string
|
||||||
|
name: string
|
||||||
|
expectedPackageCount?: number
|
||||||
|
generatedCartonMarks?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const escapeHtml = (value: string) => value.replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char]!)
|
||||||
|
|
||||||
|
/** Generate labels in memory only. The later order request carries these same identities. */
|
||||||
|
export function prepareTransferCartonLabels(rows: PrintableCargoRow[], newMark: () => string = () => {
|
||||||
|
const bytes = crypto.getRandomValues(new Uint8Array(16))
|
||||||
|
return `BX-${Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('').toUpperCase()}`
|
||||||
|
}) {
|
||||||
|
const total = rows.reduce((sum, row) => sum + Number(row.expectedPackageCount || 0), 0)
|
||||||
|
if (!rows.length || rows.some(row => !Number.isSafeInteger(row.expectedPackageCount) || Number(row.expectedPackageCount) < 1) || total > 5000) {
|
||||||
|
throw new Error('请填写货物的预计箱数(正整数),单次最多打印 5000 箱')
|
||||||
|
}
|
||||||
|
let index = 0
|
||||||
|
const labels = rows.flatMap(row => {
|
||||||
|
const count = Number(row.expectedPackageCount)
|
||||||
|
const marks = row.generatedCartonMarks ||= []
|
||||||
|
while (marks.length < count) marks.push(newMark())
|
||||||
|
return marks.slice(0, count).map(mark => `<section class="label"><div class="title">仓储转运箱唛</div><div class="mark">${escapeHtml(mark)}</div><div>${++index} / ${total}</div><div>SKU:${escapeHtml(row.sku)}</div><div>${escapeHtml(row.name)}</div></section>`)
|
||||||
|
})
|
||||||
|
return `<!doctype html><html><head><meta charset="utf-8"><title>仓储转运箱唛</title><style>@page{size:100mm 100mm;margin:0}body{margin:0;font-family:Arial,sans-serif}.label{box-sizing:border-box;width:100mm;height:100mm;padding:10mm;break-after:page;display:flex;flex-direction:column;justify-content:center;gap:6mm;text-align:center;overflow-wrap:anywhere}.label:last-child{break-after:auto}.title{font-size:22px;font-weight:bold}.mark{font-size:23px;font-weight:bold}</style></head><body>${labels.join('')}</body></html>`
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import { readFileSync } from 'node:fs'
|
import { readFileSync } from 'node:fs'
|
||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
|
import ts from 'typescript'
|
||||||
|
|
||||||
const view = readFileSync(new URL('./ReturnOrdersView.vue', import.meta.url), 'utf8')
|
const view = readFileSync(new URL('./ReturnOrdersView.vue', import.meta.url), 'utf8')
|
||||||
const api = readFileSync(new URL('../api/returnOrders.ts', import.meta.url), 'utf8')
|
const api = readFileSync(new URL('../api/returnOrders.ts', import.meta.url), 'utf8')
|
||||||
@@ -61,7 +62,6 @@ test('新建退货预报选择海外仓后带出联系人和电话', () => {
|
|||||||
assert.match(view, /label="海外仓联系电话"/)
|
assert.match(view, /label="海外仓联系电话"/)
|
||||||
assert.match(view, /:readonly="Boolean\(selectedOperationWarehouse\?\.contactName\)"/)
|
assert.match(view, /:readonly="Boolean\(selectedOperationWarehouse\?\.contactName\)"/)
|
||||||
assert.match(view, /:readonly="Boolean\(selectedOperationWarehouse\?\.contactPhone\)"/)
|
assert.match(view, /:readonly="Boolean\(selectedOperationWarehouse\?\.contactPhone\)"/)
|
||||||
assert.match(view, /该海外仓未维护,请手动填写/)
|
|
||||||
assert.match(view, /warehouseContactName: form\.warehouseContactName\.trim\(\)/)
|
assert.match(view, /warehouseContactName: form\.warehouseContactName\.trim\(\)/)
|
||||||
assert.match(view, /warehouseContactPhone: form\.warehouseContactPhone\.trim\(\)/)
|
assert.match(view, /warehouseContactPhone: form\.warehouseContactPhone\.trim\(\)/)
|
||||||
})
|
})
|
||||||
@@ -77,8 +77,8 @@ test('新建退货预报使用独立路由页面,编辑仍保留加宽弹框',
|
|||||||
assert.match(view, /class="return-create-actions"[\s\S]*?<el-button type="primary" :loading="creating" @click="submit">提交预报<\/el-button>/)
|
assert.match(view, /class="return-create-actions"[\s\S]*?<el-button type="primary" :loading="creating" @click="submit">提交预报<\/el-button>/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('新建预报居左限宽,联系字段与数量输入保持紧凑', () => {
|
test('新建预报铺满工作区,联系字段与数量输入保持紧凑', () => {
|
||||||
assert.match(view, /return-create-surface \{ width:calc\(100% - 24px\);[^}]*max-width:1120px;[^}]*margin:0 0 0 24px; justify-self:start;/)
|
assert.match(view, /return-create-surface \{ width:100%; min-width:0;[^}]*margin:0;/)
|
||||||
assert.match(view, /<el-form label-width="130px" class="return-create-form">/)
|
assert.match(view, /<el-form label-width="130px" class="return-create-form">/)
|
||||||
assert.match(view, /return-create-form :deep\(\.el-input\) \{ max-width:320px;/)
|
assert.match(view, /return-create-form :deep\(\.el-input\) \{ max-width:320px;/)
|
||||||
assert.match(view, /return-create-form :deep\(\.el-select\) \{ max-width:520px;/)
|
assert.match(view, /return-create-form :deep\(\.el-select\) \{ max-width:520px;/)
|
||||||
@@ -254,7 +254,25 @@ test('待审核退货可编辑,完成入库后从独立新增页发起贴标',
|
|||||||
assert.match(view, /name: 'overseas-labeling-create'/)
|
assert.match(view, /name: 'overseas-labeling-create'/)
|
||||||
assert.match(view, /query: \{ returnNo: order\.returnNo, warehouseId: String\(order\.overseasWarehouseId\)/)
|
assert.match(view, /query: \{ returnNo: order\.returnNo, warehouseId: String\(order\.overseasWarehouseId\)/)
|
||||||
assert.match(view, /cartonMark \? \{ cartonMark \}/)
|
assert.match(view, /cartonMark \? \{ cartonMark \}/)
|
||||||
assert.match(view, /openRelabel\(selected\.order, current\.carton\.cartonMark\)/)
|
assert.match(view, /v-if="canCreateRelabel && canApplyRelabel\(selected\.order\)"[^>]*@click="openRelabel\(selected\.order\)">贴标申请/)
|
||||||
assert.match(view, /v-for="application in row\.relabelApplications \|\| \[\]"/)
|
assert.match(view, /v-for="application in row\.relabelApplications \|\| \[\]"/)
|
||||||
assert.match(view, /relabelStatusLabel\(application\.status\)/)
|
assert.match(view, /relabelStatusLabel\(application\.status\)/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('贴标入口保留完成入库门禁与退货来源,箱号只作为目标页库存筛选', () => {
|
||||||
|
const source = view.slice(view.indexOf('function canApplyRelabel('), view.indexOf('async function submit()'))
|
||||||
|
const javascript = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText
|
||||||
|
const routes = []
|
||||||
|
const { canApplyRelabel, openRelabel } = new Function('router', `${javascript}; return { canApplyRelabel, openRelabel }`)({ push: route => routes.push(route) })
|
||||||
|
const order = { returnNo: 'RT-TEST', overseasWarehouseId: 'WH-TEST', status: 'COMPLETED', needRelabel: true }
|
||||||
|
assert.equal(canApplyRelabel(order), true)
|
||||||
|
assert.equal(canApplyRelabel({ ...order, status: 'PENDING_RECEIPT' }), false)
|
||||||
|
assert.equal(canApplyRelabel({ ...order, needRelabel: false }), false)
|
||||||
|
assert.equal(canApplyRelabel({ ...order, relabelNo: 'RL-EXISTS' }), false)
|
||||||
|
openRelabel(order)
|
||||||
|
openRelabel(order, 'RC-TEST')
|
||||||
|
assert.deepEqual(routes, [
|
||||||
|
{ name: 'overseas-labeling-create', query: { returnNo: 'RT-TEST', warehouseId: 'WH-TEST' } },
|
||||||
|
{ name: 'overseas-labeling-create', query: { returnNo: 'RT-TEST', warehouseId: 'WH-TEST', cartonMark: 'RC-TEST' } },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
import { computed, onActivated, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { ArrowLeft, ChevronDown, Plus } from '@lucide/vue'
|
import { ArrowLeft, ChevronDown, Plus } from '@lucide/vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
@@ -10,6 +10,7 @@ import { useAuthStore } from '@/modules/auth/public'
|
|||||||
import { getMerchantWarehouses, type MerchantWarehouse } from '@/modules/first-leg/public'
|
import { getMerchantWarehouses, type MerchantWarehouse } from '@/modules/first-leg/public'
|
||||||
import { createIdempotencyKey } from '@/utils/idempotencyKey'
|
import { createIdempotencyKey } from '@/utils/idempotencyKey'
|
||||||
import { createReturnOrder, downloadReturnPhoto, getReturnOrder, listReturnOrders, updateReturnOrder, type ReturnOrderDetail, type ReturnOrderSummary, type ReturnReceipt } from '../api/returnOrders'
|
import { createReturnOrder, downloadReturnPhoto, getReturnOrder, listReturnOrders, updateReturnOrder, type ReturnOrderDetail, type ReturnOrderSummary, type ReturnReceipt } from '../api/returnOrders'
|
||||||
|
import ReviewRejectionNotice from '../components/ReviewRejectionNotice.vue'
|
||||||
import { getReturnInventoryLabel } from '../api/returnInventory'
|
import { getReturnInventoryLabel } from '../api/returnInventory'
|
||||||
import { formatReturnVolume, receiptBoxDetails, receiptPhysicalDetails, receiptPrintableBoxCount, receiptTrackingNo } from '../utils/returnReceiptDisplay'
|
import { formatReturnVolume, receiptBoxDetails, receiptPhysicalDetails, receiptPrintableBoxCount, receiptTrackingNo } from '../utils/returnReceiptDisplay'
|
||||||
|
|
||||||
@@ -280,6 +281,11 @@ onMounted(() => {
|
|||||||
else if (props.detailPage) void Promise.all([loadDetail(), loadOperationWarehouses()])
|
else if (props.detailPage) void Promise.all([loadDetail(), loadOperationWarehouses()])
|
||||||
else void Promise.all([load(), loadOperationWarehouses()])
|
else void Promise.all([load(), loadOperationWarehouses()])
|
||||||
})
|
})
|
||||||
|
let activatedOnce = false
|
||||||
|
onActivated(() => {
|
||||||
|
if (activatedOnce && !props.createPage && !props.detailPage) void load()
|
||||||
|
activatedOnce = true
|
||||||
|
})
|
||||||
watch(() => [query.page, query.size] as const, ([nextPage, nextSize], [previousPage, previousSize]) => {
|
watch(() => [query.page, query.size] as const, ([nextPage, nextSize], [previousPage, previousSize]) => {
|
||||||
if (nextSize !== previousSize && nextPage !== 1) { query.page = 1; return }
|
if (nextSize !== previousSize && nextPage !== 1) { query.page = 1; return }
|
||||||
if (!props.createPage && !props.detailPage) void load()
|
if (!props.createPage && !props.detailPage) void load()
|
||||||
@@ -292,25 +298,23 @@ onBeforeUnmount(clearPhotoUrls)
|
|||||||
<template v-if="props.createPage">
|
<template v-if="props.createPage">
|
||||||
<article class="return-create-surface">
|
<article class="return-create-surface">
|
||||||
<header class="return-create-header">
|
<header class="return-create-header">
|
||||||
<div><h2>新建退货预报</h2><p>填写预计退回的 SKU 和数量,仓库签收后将自动更新实收结果。</p></div>
|
<div><h2>新建退货预报</h2></div>
|
||||||
<el-button @click="returnToList"><ArrowLeft :size="16" />返回列表</el-button>
|
<el-button @click="returnToList"><ArrowLeft :size="16" />返回列表</el-button>
|
||||||
</header>
|
</header>
|
||||||
<div class="return-create-body">
|
<div class="return-create-body">
|
||||||
<el-form label-width="130px" class="return-create-form">
|
<el-form label-width="130px" class="return-create-form">
|
||||||
<el-form-item label="海外仓" required><el-select v-model="form.overseasWarehouseId" filterable clearable :loading="operationWarehousesLoading" :disabled="Boolean(operationWarehousesError)" placeholder="请选择海外仓" @change="selectOperationWarehouse"><el-option v-for="warehouse in operationWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="String(warehouse.id)" /></el-select><div v-if="operationWarehousesError" class="warehouse-error">{{ operationWarehousesError }}</div></el-form-item>
|
<el-form-item label="海外仓" required><el-select v-model="form.overseasWarehouseId" filterable clearable :loading="operationWarehousesLoading" :disabled="Boolean(operationWarehousesError)" placeholder="" @change="selectOperationWarehouse"><el-option v-for="warehouse in operationWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="String(warehouse.id)" /></el-select><div v-if="operationWarehousesError" class="warehouse-error">{{ operationWarehousesError }}</div></el-form-item>
|
||||||
<el-form-item label="客户参考号"><el-input v-model="form.customerReferenceNo" placeholder="可填写客户侧参考号" /></el-form-item>
|
<el-form-item label="客户参考号"><el-input v-model="form.customerReferenceNo" placeholder="" /></el-form-item>
|
||||||
<el-form-item label="海外仓联系人" required><el-input v-model="form.warehouseContactName" :readonly="Boolean(selectedOperationWarehouse?.contactName)" :placeholder="selectedOperationWarehouse?.contactName ? '已从海外仓资料带出' : '该海外仓未维护,请手动填写'" /></el-form-item>
|
<el-form-item label="海外仓联系人" required><el-input v-model="form.warehouseContactName" :readonly="Boolean(selectedOperationWarehouse?.contactName)" placeholder="" /></el-form-item>
|
||||||
<el-form-item label="海外仓联系电话" required><el-input v-model="form.warehouseContactPhone" :readonly="Boolean(selectedOperationWarehouse?.contactPhone)" :placeholder="selectedOperationWarehouse?.contactPhone ? '已从海外仓资料带出' : '该海外仓未维护,请手动填写'" /></el-form-item>
|
<el-form-item label="海外仓联系电话" required><el-input v-model="form.warehouseContactPhone" :readonly="Boolean(selectedOperationWarehouse?.contactPhone)" placeholder="" /></el-form-item>
|
||||||
<el-form-item label="备注" class="return-form-wide"><el-input v-model="form.remark" type="textarea" :rows="2" placeholder="填写退货说明(选填)" /></el-form-item>
|
<el-form-item label="备注" class="return-form-wide"><el-input v-model="form.remark" type="textarea" :rows="2" placeholder="" /></el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<div class="items-title"><span>SKU 预报明细</span><el-button text type="primary" @click="addItem"><Plus :size="15" />添加 SKU</el-button></div>
|
<div class="items-title"><span>SKU 预报明细</span><el-button text type="primary" @click="addItem"><Plus :size="15" />添加 SKU</el-button></div>
|
||||||
<div class="return-relabel-option"><span>是否换标</span><el-radio-group v-model="form.needRelabel" aria-label="是否换标"><el-radio-button :value="false">不换标</el-radio-button><el-radio-button :value="true">需要换标</el-radio-button></el-radio-group></div>
|
<div class="return-relabel-option"><span>是否换标</span><el-radio-group v-model="form.needRelabel" aria-label="是否换标"><el-radio-button :value="false">不换标</el-radio-button><el-radio-button :value="true">需要换标</el-radio-button></el-radio-group></div>
<el-table class="return-forecast-table" :data="form.items" row-key="id" size="small">
|
||||||
<div class="items-hint">客户只需填写预计退回的 SKU 和数量;快递单号由仓库签收时按实际包裹扫描绑定。</div>
|
|
||||||
<el-table class="return-forecast-table" :data="form.items" row-key="id" size="small">
|
|
||||||
<el-table-column label="操作" width="80" fixed="left"><template #default="{ $index }"><el-button link type="danger" :disabled="form.items.length === 1" @click="removeItem($index)">删除</el-button></template></el-table-column>
|
<el-table-column label="操作" width="80" fixed="left"><template #default="{ $index }"><el-button link type="danger" :disabled="form.items.length === 1" @click="removeItem($index)">删除</el-button></template></el-table-column>
|
||||||
<el-table-column label="SKU" width="300"><template #default="{ row }"><div><el-input v-model="row.skuCode" :class="{ 'duplicate-sku-input': duplicateSkuIds.has(row.id) }" placeholder="填写 SKU" @blur="row.skuCode = row.skuCode.trim()" /><div v-if="duplicateSkuIds.has(row.id)" class="duplicate-sku-error">该 SKU 已在明细中填写</div></div></template></el-table-column>
|
<el-table-column label="SKU" width="300"><template #default="{ row }"><div><el-input v-model="row.skuCode" :class="{ 'duplicate-sku-input': duplicateSkuIds.has(row.id) }" placeholder="" @blur="row.skuCode = row.skuCode.trim()" /><div v-if="duplicateSkuIds.has(row.id)" class="duplicate-sku-error">该 SKU 已在明细中填写</div></div></template></el-table-column>
|
||||||
<el-table-column label="中文品名" min-width="320"><template #default="{ row }"><el-input v-model="row.productNameZh" placeholder="填写中文品名(选填)" /></template></el-table-column>
|
<el-table-column label="中文品名" min-width="320"><template #default="{ row }"><el-input v-model="row.productNameZh" placeholder="" /></template></el-table-column>
|
||||||
<el-table-column label="预报数量" width="150"><template #default="{ row }"><el-input-number v-model="row.expectedQuantity" :min="1" controls-position="right" placeholder="数量" /></template></el-table-column>
|
<el-table-column label="预报数量" width="150"><template #default="{ row }"><el-input-number v-model="row.expectedQuantity" :min="1" controls-position="right" placeholder="" /></template></el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
</div>
|
</div>
|
||||||
<footer class="return-create-actions"><el-button @click="cancelCreate">取消</el-button><el-button type="primary" :loading="creating" @click="submit">提交预报</el-button></footer>
|
<footer class="return-create-actions"><el-button @click="cancelCreate">取消</el-button><el-button type="primary" :loading="creating" @click="submit">提交预报</el-button></footer>
|
||||||
@@ -332,20 +336,18 @@ onBeforeUnmount(clearPhotoUrls)
|
|||||||
|
|
||||||
<el-dialog v-model="showCreate" :title="editingReturnNo ? '编辑退货预报' : '新建退货预报'" width="1120px" destroy-on-close>
|
<el-dialog v-model="showCreate" :title="editingReturnNo ? '编辑退货预报' : '新建退货预报'" width="1120px" destroy-on-close>
|
||||||
<el-form label-width="190px" class="return-create-form">
|
<el-form label-width="190px" class="return-create-form">
|
||||||
<el-form-item label="海外仓" required class="return-form-wide"><el-select v-model="form.overseasWarehouseId" filterable clearable :loading="operationWarehousesLoading" :disabled="Boolean(operationWarehousesError)" placeholder="请选择海外仓" @change="selectOperationWarehouse"><el-option v-for="warehouse in operationWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="String(warehouse.id)" /></el-select><div v-if="operationWarehousesError" class="warehouse-error">{{ operationWarehousesError }}</div></el-form-item>
|
<el-form-item label="海外仓" required class="return-form-wide"><el-select v-model="form.overseasWarehouseId" filterable clearable :loading="operationWarehousesLoading" :disabled="Boolean(operationWarehousesError)" placeholder="" @change="selectOperationWarehouse"><el-option v-for="warehouse in operationWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="String(warehouse.id)" /></el-select><div v-if="operationWarehousesError" class="warehouse-error">{{ operationWarehousesError }}</div></el-form-item>
|
||||||
<el-form-item label="海外仓联系人" required><el-input v-model="form.warehouseContactName" :readonly="Boolean(selectedOperationWarehouse?.contactName)" :placeholder="selectedOperationWarehouse?.contactName ? '已从海外仓资料带出' : '该海外仓未维护,请手动填写'" /></el-form-item>
|
<el-form-item label="海外仓联系人" required><el-input v-model="form.warehouseContactName" :readonly="Boolean(selectedOperationWarehouse?.contactName)" placeholder="" /></el-form-item>
|
||||||
<el-form-item label="海外仓联系电话" required><el-input v-model="form.warehouseContactPhone" :readonly="Boolean(selectedOperationWarehouse?.contactPhone)" :placeholder="selectedOperationWarehouse?.contactPhone ? '已从海外仓资料带出' : '该海外仓未维护,请手动填写'" /></el-form-item>
|
<el-form-item label="海外仓联系电话" required><el-input v-model="form.warehouseContactPhone" :readonly="Boolean(selectedOperationWarehouse?.contactPhone)" placeholder="" /></el-form-item>
|
||||||
<el-form-item label="客户参考号" class="return-form-wide"><el-input v-model="form.customerReferenceNo" placeholder="可填写客户侧参考号" /></el-form-item>
|
<el-form-item label="客户参考号" class="return-form-wide"><el-input v-model="form.customerReferenceNo" placeholder="" /></el-form-item>
|
||||||
<el-form-item label="备注" class="return-form-wide"><el-input v-model="form.remark" type="textarea" placeholder="填写退货说明(选填)" /></el-form-item>
|
<el-form-item label="备注" class="return-form-wide"><el-input v-model="form.remark" type="textarea" placeholder="" /></el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<div class="items-title"><span>SKU 预报明细</span><el-button text type="primary" @click="addItem"><Plus :size="15" />添加 SKU</el-button></div>
|
<div class="items-title"><span>SKU 预报明细</span><el-button text type="primary" @click="addItem"><Plus :size="15" />添加 SKU</el-button></div>
|
||||||
<div class="return-relabel-option"><span>是否换标</span><el-radio-group v-model="form.needRelabel" aria-label="是否换标"><el-radio-button :value="false">不换标</el-radio-button><el-radio-button :value="true">需要换标</el-radio-button></el-radio-group></div>
|
<div class="return-relabel-option"><span>是否换标</span><el-radio-group v-model="form.needRelabel" aria-label="是否换标"><el-radio-button :value="false">不换标</el-radio-button><el-radio-button :value="true">需要换标</el-radio-button></el-radio-group></div>
<el-table :data="form.items" row-key="id" size="small">
|
||||||
<div class="items-hint">客户只需填写预计退回的 SKU 和数量;快递单号由仓库签收时按实际包裹扫描绑定。</div>
|
|
||||||
<el-table :data="form.items" row-key="id" size="small">
|
|
||||||
<el-table-column label="操作" width="80" fixed="left"><template #default="{ $index }"><el-button link type="danger" :disabled="form.items.length === 1" @click="removeItem($index)">删除</el-button></template></el-table-column>
|
<el-table-column label="操作" width="80" fixed="left"><template #default="{ $index }"><el-button link type="danger" :disabled="form.items.length === 1" @click="removeItem($index)">删除</el-button></template></el-table-column>
|
||||||
<el-table-column label="SKU" min-width="200"><template #default="{ row }"><div><el-input v-model="row.skuCode" :class="{ 'duplicate-sku-input': duplicateSkuIds.has(row.id) }" placeholder="填写 SKU" @blur="row.skuCode = row.skuCode.trim()" /><div v-if="duplicateSkuIds.has(row.id)" class="duplicate-sku-error">该 SKU 已在明细中填写</div></div></template></el-table-column>
|
<el-table-column label="SKU" min-width="200"><template #default="{ row }"><div><el-input v-model="row.skuCode" :class="{ 'duplicate-sku-input': duplicateSkuIds.has(row.id) }" placeholder="" @blur="row.skuCode = row.skuCode.trim()" /><div v-if="duplicateSkuIds.has(row.id)" class="duplicate-sku-error">该 SKU 已在明细中填写</div></div></template></el-table-column>
|
||||||
<el-table-column label="中文品名" min-width="260"><template #default="{ row }"><el-input v-model="row.productNameZh" placeholder="填写中文品名(选填)" /></template></el-table-column>
|
<el-table-column label="中文品名" min-width="260"><template #default="{ row }"><el-input v-model="row.productNameZh" placeholder="" /></template></el-table-column>
|
||||||
<el-table-column label="预报数量" width="160"><template #default="{ row }"><el-input-number v-model="row.expectedQuantity" :min="1" controls-position="right" placeholder="数量" /></template></el-table-column>
|
<el-table-column label="预报数量" width="160"><template #default="{ row }"><el-input-number v-model="row.expectedQuantity" :min="1" controls-position="right" placeholder="" /></template></el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
<template #footer><el-button @click="showCreate=false; editingReturnNo=''">取消</el-button><el-button v-if="canCreate" type="primary" :loading="creating" @click="submit">{{ editingReturnNo ? '保存修改' : '提交预报' }}</el-button></template>
|
<template #footer><el-button @click="showCreate=false; editingReturnNo=''">取消</el-button><el-button v-if="canCreate" type="primary" :loading="creating" @click="submit">{{ editingReturnNo ? '保存修改' : '提交预报' }}</el-button></template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
@@ -358,6 +360,7 @@ onBeforeUnmount(clearPhotoUrls)
|
|||||||
<div class="return-detail-title"><strong>{{ selected.order.returnNo }}</strong><span>{{ operationWarehouseLabel(selected.order.overseasWarehouseId) }} · 退货签收</span></div>
|
<div class="return-detail-title"><strong>{{ selected.order.returnNo }}</strong><span>{{ operationWarehouseLabel(selected.order.overseasWarehouseId) }} · 退货签收</span></div>
|
||||||
<div class="return-detail-actions"><el-button v-if="canCreateRelabel && canApplyRelabel(selected.order)" type="primary" plain @click="openRelabel(selected.order)">贴标申请</el-button><el-tag :type="statusType(selected.order.status, selected.order.needRelabel)" :class="statusClass(selected.order.status, selected.order.needRelabel)" effect="light">{{ statusLabel(selected.order.status, selected.order.needRelabel) }}</el-tag></div>
|
<div class="return-detail-actions"><el-button v-if="canCreateRelabel && canApplyRelabel(selected.order)" type="primary" plain @click="openRelabel(selected.order)">贴标申请</el-button><el-tag :type="statusType(selected.order.status, selected.order.needRelabel)" :class="statusClass(selected.order.status, selected.order.needRelabel)" effect="light">{{ statusLabel(selected.order.status, selected.order.needRelabel) }}</el-tag></div>
|
||||||
</header>
|
</header>
|
||||||
|
<ReviewRejectionNotice :status="selected.order.status" :reason="selected.remark" />
|
||||||
<section class="return-detail-section">
|
<section class="return-detail-section">
|
||||||
<h3>签收概览</h3>
|
<h3>签收概览</h3>
|
||||||
<div class="return-detail-grid">
|
<div class="return-detail-grid">
|
||||||
@@ -369,7 +372,7 @@ onBeforeUnmount(clearPhotoUrls)
|
|||||||
<div><span>联系电话</span><strong>{{ selected.order.warehouseContactPhone || '—' }}</strong></div>
|
<div><span>联系电话</span><strong>{{ selected.order.warehouseContactPhone || '—' }}</strong></div>
|
||||||
<div><span>创建时间</span><strong>{{ formatDateTime(selected.order.createdAt) }}</strong></div>
|
<div><span>创建时间</span><strong>{{ formatDateTime(selected.order.createdAt) }}</strong></div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="selected.remark" class="return-detail-remark"><span>备注</span><strong>{{ selected.remark }}</strong></div>
|
<div v-if="selected.remark && selected.order.status !== 'REJECTED'" class="return-detail-remark"><span>备注</span><strong>{{ selected.remark }}</strong></div>
|
||||||
</section>
|
</section>
|
||||||
<section v-if="selected.order.relabelApplications?.length" class="return-detail-section">
|
<section v-if="selected.order.relabelApplications?.length" class="return-detail-section">
|
||||||
<h3>换标子单({{ selected.order.relabelApplications?.length || 0 }})</h3>
|
<h3>换标子单({{ selected.order.relabelApplications?.length || 0 }})</h3>
|
||||||
@@ -443,24 +446,6 @@ onBeforeUnmount(clearPhotoUrls)
|
|||||||
<template #footer><el-button @click="showCartons = false">关闭</el-button></template>
|
<template #footer><el-button @click="showCartons = false">关闭</el-button></template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
</section>
|
</section>
|
||||||
<section class="return-detail-section">
|
|
||||||
<h3>当前关联实物箱</h3>
|
|
||||||
<el-collapse v-if="selected.currentCartons?.length">
|
|
||||||
<el-collapse-item v-for="current in selected.currentCartons" :key="current.carton.cartonMark" :name="current.carton.cartonMark">
|
|
||||||
<template #title>{{ current.carton.cartonMark }} · 整箱 {{ current.carton.totalQuantity }} 件 · 本单 {{ current.orderQuantity }} 件 · {{ current.carton.sourceReturnNo?.includes(',') ? '跨来源单合箱' : '单一来源' }}</template>
|
|
||||||
<p>来源退货单:{{ current.carton.sourceReturnNo }} · 我方箱唛:{{ current.labelStatus === 'CONFIRMED' ? '已确认贴标' : '待贴标确认' }}</p>
|
|
||||||
<el-table :data="current.ownership" border>
|
|
||||||
<el-table-column prop="returnNo" label="来源退货单" min-width="200" />
|
|
||||||
<el-table-column prop="skuCode" label="SKU" min-width="180" />
|
|
||||||
<el-table-column prop="quantity" label="来源贡献数量" width="130" />
|
|
||||||
</el-table>
|
|
||||||
<p>换箱唛以当前实物箱为单位;跨来源单同 SKU 的部分贴标须先现场分拣确认。</p>
|
|
||||||
<el-button v-if="canCreateRelabel" :disabled="!current.canRelabel" @click="openRelabel(selected.order, current.carton.cartonMark)">选择此箱贴标</el-button>
|
|
||||||
<p v-if="!current.canRelabel">{{ current.blockReason }}</p>
|
|
||||||
</el-collapse-item>
|
|
||||||
</el-collapse>
|
|
||||||
<el-empty v-else description="暂无当前关联实物箱" :image-size="64" />
|
|
||||||
</section>
|
|
||||||
<section v-if="selected.photos?.length" class="return-detail-section">
|
<section v-if="selected.photos?.length" class="return-detail-section">
|
||||||
<h3>退货打托照片({{ selected.photos?.length || 0 }} 张)</h3>
|
<h3>退货打托照片({{ selected.photos?.length || 0 }} 张)</h3>
|
||||||
<div v-if="selected.photos?.length" class="return-photo-grid">
|
<div v-if="selected.photos?.length" class="return-photo-grid">
|
||||||
@@ -497,11 +482,11 @@ onBeforeUnmount(clearPhotoUrls)
|
|||||||
.return-query :deep(.query-toolbar__actions) { margin:0; padding:0; border:0; align-self:end; }
|
.return-query :deep(.query-toolbar__actions) { margin:0; padding:0; border:0; align-self:end; }
|
||||||
@media(max-width:1200px) { .return-query.query-toolbar--grid { grid-template-columns:repeat(2,minmax(0,320px)); } }
|
@media(max-width:1200px) { .return-query.query-toolbar--grid { grid-template-columns:repeat(2,minmax(0,320px)); } }
|
||||||
.relabel-suborder { display: flex; align-items: center; gap: 8px; min-height: 30px; white-space: nowrap; }
|
.relabel-suborder { display: flex; align-items: center; gap: 8px; min-height: 30px; white-space: nowrap; }
|
||||||
.return-orders-page { display:grid; gap:12px; } .return-create-form { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); column-gap:20px; } .return-form-wide { grid-column:1 / -1; } .items-title { display:flex; justify-content:space-between; align-items:center; margin:16px 0 4px; font-weight:600; } .items-hint { margin-bottom:10px; color:var(--el-text-color-secondary); font-size:12px; } h3 { margin:24px 0 10px; font-size:16px; } h4 { margin:16px 0 8px; font-size:14px; } .remark { white-space:pre-wrap; } .warehouse-error, .duplicate-sku-error { margin-top:6px; color:var(--el-color-danger); font-size:12px; } :deep(.duplicate-sku-input .el-input__wrapper) { box-shadow:0 0 0 1px var(--el-color-danger) inset; } :deep(.el-dialog .el-select) { width:100%; }
|
.return-orders-page { display:grid; gap:12px; } .return-create-form { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); column-gap:20px; } .return-form-wide { grid-column:1 / -1; } .items-title { display:flex; justify-content:space-between; align-items:center; margin:16px 0 4px; font-weight:600; } h3 { margin:24px 0 10px; font-size:16px; } h4 { margin:16px 0 8px; font-size:14px; } .remark { white-space:pre-wrap; } .warehouse-error, .duplicate-sku-error { margin-top:6px; color:var(--el-color-danger); font-size:12px; } :deep(.duplicate-sku-input .el-input__wrapper) { box-shadow:0 0 0 1px var(--el-color-danger) inset; } :deep(.el-dialog .el-select) { width:100%; }
|
||||||
.return-create-surface { width:calc(100% - 24px); max-width:1120px; min-width:0; margin:0 0 0 24px; justify-self:start; box-sizing:border-box; overflow:hidden; border:1px solid var(--client-border); border-radius:var(--client-radius); background:#fff; }
|
.return-create-surface { width:100%; min-width:0; min-height:calc(100dvh - 198px); margin:0; box-sizing:border-box; overflow:hidden; border:1px solid var(--client-border); border-radius:var(--client-radius); background:#fff; }
|
||||||
|
.return-create-header, .return-create-body, .return-create-actions { width:calc(100% - 24px); max-width:1118px; margin-left:24px; box-sizing:border-box; }
|
||||||
.return-create-header { display:flex; min-height:68px; align-items:center; justify-content:space-between; gap:20px; padding:12px 20px; border-bottom:1px solid var(--client-border); }
|
.return-create-header { display:flex; min-height:68px; align-items:center; justify-content:space-between; gap:20px; padding:12px 20px; border-bottom:1px solid var(--client-border); }
|
||||||
.return-create-header h2 { margin:0; color:#1f2d45; font-size:20px; }
|
.return-create-header h2 { margin:0; color:#1f2d45; font-size:20px; }
|
||||||
.return-create-header p { margin:4px 0 0; color:#7c899c; font-size:12px; }
|
|
||||||
.return-create-body { padding:18px 20px 14px; }
|
.return-create-body { padding:18px 20px 14px; }
|
||||||
.return-create-body :deep(.el-select), .return-create-body :deep(.el-input-number) { width:100%; }
|
.return-create-body :deep(.el-select), .return-create-body :deep(.el-input-number) { width:100%; }
|
||||||
.return-create-body .return-create-form { max-width:900px; column-gap:24px; }
|
.return-create-body .return-create-form { max-width:900px; column-gap:24px; }
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,117 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { transpileModule, ScriptTarget } from 'typescript'
|
||||||
|
import { computed, ref, reactive, watch } from 'vue'
|
||||||
|
|
||||||
|
const view = readFileSync(new URL('./WarehouseServiceView.vue', import.meta.url), 'utf8')
|
||||||
|
|
||||||
|
test('退货箱明细与来源下拉使用同一份逐箱库存,不请求普通入仓箱接口', async () => {
|
||||||
|
const body = view.slice(view.indexOf('async function loadTransferSourceCartons('), view.indexOf('// Snowflake IDs'))
|
||||||
|
const code = transpileModule(body, { compilerOptions: { target: ScriptTarget.ES2022 } }).outputText
|
||||||
|
const ctx = { transferSourceCartons: { value: [] }, transferSourceError: { value: '' },
|
||||||
|
transferSourceLoading: { value: false }, inventoryTransfer: { value: true },
|
||||||
|
selectedTransferInventory: { value: 'RETURN_ORDER:RET-1' }, form: { warehouse: 'WH' },
|
||||||
|
transferInventoryCandidates: { value: [
|
||||||
|
{ sourceType: 'RETURN_CARTON', inboundOrderNo: 'RET-1', cartonMark: 'BOX-1', cartonAvailablePackages: 1,
|
||||||
|
cartonSkuItems: [{ skuCode: 'A', quantity: 3 }, { skuCode: 'B', quantity: 2 }] },
|
||||||
|
{ sourceType: 'RETURN_PARCEL', inboundOrderNo: 'RET-1', cartonMark: 'BOX-2', cartonAvailablePackages: 1,
|
||||||
|
cartonSkuItems: [{ skuCode: 'A', quantity: 1 }] },
|
||||||
|
{ sourceType: 'RETURN_CARTON', inboundOrderNo: 'OTHER', cartonMark: 'FOREIGN', cartonAvailablePackages: 1 },
|
||||||
|
{ sourceType: 'RETURN_CARTON', inboundOrderNo: 'RET-1', cartonMark: 'OCCUPIED', cartonAvailablePackages: 0 },
|
||||||
|
{ sourceType: 'RETURN_CARTON', inboundOrderNo: 'RET-1', cartonMark: 'LABELLED', cartonAvailablePackages: 1, relabelStatus: 'COMPLETED' },
|
||||||
|
{ sourceType: 'RETURN_CARTON', inboundOrderNo: 'RET-1', cartonMark: 'PARTIAL', cartonAvailablePackages: 1, relabelledQuantity: 2 },
|
||||||
|
] }, getTransferSourceCartons: () => { throw Error('不应调用普通来源箱查询') } }
|
||||||
|
const load = new Function(...Object.keys(ctx), 'let transferSourceRequest=0;' + code + ';return loadTransferSourceCartons')(...Object.values(ctx))
|
||||||
|
await load()
|
||||||
|
assert.deepEqual(ctx.transferSourceCartons.value.map(box => box.cartonMark), ['BOX-1', 'BOX-2'])
|
||||||
|
assert.equal(ctx.transferSourceCartons.value[0].quantity, 5)
|
||||||
|
assert.equal(ctx.transferSourceCartons.value[0].items.length, 2)
|
||||||
|
assert.equal(ctx.transferSourceError.value, '')
|
||||||
|
assert.equal(ctx.transferSourceLoading.value, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('选择库存来源时隐藏保存草稿并阻止草稿动作', () => {
|
||||||
|
assert.match(view, /props\.service === 'transfer' && !inventoryTransfer/)
|
||||||
|
assert.match(view, /保存草稿/)
|
||||||
|
assert.match(view, /props\.service === 'transfer' && inventoryTransfer\.value && action === 'draft'/)
|
||||||
|
assert.match(view, /选择库存来源后不能保存草稿,请直接提交订单/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('来源库存发往FBA默认开启贴标,清除来源或切换私人仓后恢复选项', () => {
|
||||||
|
const source = ref(''), destination = ref('NONE'), form = reactive({ relabel: false })
|
||||||
|
const inventoryTransfer = computed(() => Boolean(source.value))
|
||||||
|
const body = view.slice(view.indexOf('const transferRelabelRequired ='), view.indexOf('const transferSourceCartons ='))
|
||||||
|
const required = new Function('computed', 'watch', 'inventoryTransfer', 'transferDestinationType', 'form', body + ';return transferRelabelRequired')(
|
||||||
|
computed, watch, inventoryTransfer, destination, form)
|
||||||
|
destination.value = 'FBA'
|
||||||
|
assert.equal(form.relabel, false)
|
||||||
|
source.value = 'IN-1'
|
||||||
|
assert.equal(required.value, true)
|
||||||
|
assert.equal(form.relabel, true)
|
||||||
|
destination.value = 'PRIVATE'
|
||||||
|
assert.equal(required.value, false)
|
||||||
|
form.relabel = false
|
||||||
|
destination.value = 'FBA'
|
||||||
|
assert.equal(form.relabel, true)
|
||||||
|
source.value = ''
|
||||||
|
assert.equal(required.value, false)
|
||||||
|
assert.match(view, /<el-radio-button v-if="!transferRelabelRequired" :value="false">不需要贴标/)
|
||||||
|
})
|
||||||
|
|
||||||
|
function setup() {
|
||||||
|
const body = view.slice(view.indexOf('async function selectTransferInventory('), view.indexOf('function selectTransferInboundMethod('))
|
||||||
|
const code = transpileModule(body, { compilerOptions: { target: ScriptTarget.ES2022 } }).outputText
|
||||||
|
const ctx = { selectedTransferInventory: {value:''}, selectedTransferSourceOrderIds:{value:[]},
|
||||||
|
transferSourceCartons:{value:[{cartonMark:'OLD'}]}, transferSourceError:{value:'OLD'}, transferSourceLoading:{value:true},
|
||||||
|
transferDestinationType:{value:'NONE'}, form:{warehouse:'WH',inboundNo:''},
|
||||||
|
transferInventoryCandidates:{value:[{inboundCode:'IN',skuCode:'SKU',productNameZh:'货品',availablePackages:2},
|
||||||
|
{inboundCode:'IN',skuCode:'SKU',availablePackages:3},{inboundCode:'OTHER',skuCode:'OTHER',availablePackages:99}]},
|
||||||
|
cargoRows:{value:[]}, inventoryQuantity:Number, createCargoRow:()=>({expectedPackageCount:1}),
|
||||||
|
loadTransferSourceCartons:async()=>{}, listRelabelHistoricalOrders:async()=>[{id:'1234567890123456789'}],
|
||||||
|
ElMessage:{error(message){throw Error(message)}} }
|
||||||
|
const select = new Function(...Object.keys(ctx), 'let transferSourceRequest=0;'+code+'; return selectTransferInventory')(...Object.values(ctx))
|
||||||
|
return {ctx,select}
|
||||||
|
}
|
||||||
|
test('inventory source populates full immutable cargo counts without mixing other inbound orders',async()=>{
|
||||||
|
const {ctx,select}=setup(); await select('IN')
|
||||||
|
assert.equal(ctx.transferDestinationType.value,'FBA')
|
||||||
|
assert.equal(ctx.cargoRows.value.length,1)
|
||||||
|
assert.equal(ctx.cargoRows.value[0].expectedPackageCount,5)
|
||||||
|
assert.equal(ctx.cargoRows.value[0].quantity,5)
|
||||||
|
assert.deepEqual(ctx.selectedTransferSourceOrderIds.value,['1234567890123456789'])
|
||||||
|
await select('')
|
||||||
|
assert.equal(ctx.form.inboundNo,'')
|
||||||
|
assert.deepEqual(ctx.selectedTransferSourceOrderIds.value,[])
|
||||||
|
assert.deepEqual(ctx.transferSourceCartons.value,[])
|
||||||
|
})
|
||||||
|
test('inventory view hides inbound fields and editing actions and shows original carton destination',()=>{
|
||||||
|
assert.match(view,/v-if="props.service === 'transfer' && !inventoryTransfer"[^>]*><span><i>\*<\/i>入仓方式/)
|
||||||
|
assert.match(view,/<el-radio-button v-if="!inventoryTransfer" value="NONE">/)
|
||||||
|
const panel=view.slice(view.indexOf('<section v-if="inventoryTransfer"'),view.indexOf('</section>',view.indexOf('<section v-if="inventoryTransfer"')))
|
||||||
|
assert.match(panel,/box.cartonMark/); assert.match(panel,/box.destinationWarehouseCode/)
|
||||||
|
assert.doesNotMatch(panel,/v-model|addCargoRow|removeCargoRow|清空/)
|
||||||
|
assert.match(view,/!inventoryTransfer\.value && \(!form.warehouse/)
|
||||||
|
assert.match(view,/estimatedArrivalAt: inventoryTransfer.value \? undefined : form.estimatedArrivalAt/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('仓储转运来源下拉包含退货单并使用独立来源编码', () => {
|
||||||
|
assert.match(view, /RETURN_ORDER:\$\{returnNo\}/)
|
||||||
|
assert.match(view, /退货单|\$\{returnNo\}/)
|
||||||
|
assert.match(view, /item\.sourceType === 'RETURN_CARTON' \|\| item\.sourceType === 'RETURN_PARCEL'/)
|
||||||
|
assert.match(view, /isReturnSource[\s\S]*item\.sourceType === 'RETURN_CARTON'/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('退货来源带出实物 SKU 数量并跳过普通历史订单查询', async () => {
|
||||||
|
const { ctx, select } = setup()
|
||||||
|
ctx.transferInventoryCandidates.value = [{ sourceType: 'RETURN_CARTON', inboundOrderNo: 'RET-1',
|
||||||
|
cartonAvailablePackages: 1, skuItems: [{ skuCode: 'SKU', quantity: 1 }],
|
||||||
|
cartonSkuItems: [{ skuCode: 'SKU', quantity: 6 }] },
|
||||||
|
{ sourceType: 'RETURN_CARTON', inboundOrderNo: 'OTHER', cartonAvailablePackages: 1,
|
||||||
|
cartonSkuItems: [{ skuCode: 'FOREIGN', quantity: 99 }] }]
|
||||||
|
await select('RETURN_ORDER:RET-1')
|
||||||
|
assert.equal(ctx.cargoRows.value.length, 1)
|
||||||
|
assert.equal(ctx.cargoRows.value[0].quantity, 6)
|
||||||
|
assert.equal(ctx.cargoRows.value[0].expectedPackageCount, 1)
|
||||||
|
assert.deepEqual(ctx.selectedTransferSourceOrderIds.value, [])
|
||||||
|
})
|
||||||
@@ -129,9 +129,9 @@ test('贴标货物明细通过单一换标类型选择三种模式', () => {
|
|||||||
test('箱唛相关换标必须选择四选一派送方式', () => {
|
test('箱唛相关换标必须选择四选一派送方式', () => {
|
||||||
assert.match(view, /label="卡车派送" value="TRUCK"/)
|
assert.match(view, /label="卡车派送" value="TRUCK"/)
|
||||||
assert.match(view, /label="快递派送" value="EXPRESS"/)
|
assert.match(view, /label="快递派送" value="EXPRESS"/)
|
||||||
assert.match(view, /label="卡车自提" value="TRUCK_PICKUP"/)
|
assert.match(view, /label="亚马逊自提" value="AMAZON_PICKUP"/)
|
||||||
assert.match(view, /label="快递自提" value="EXPRESS_PICKUP"/)
|
assert.match(view, /label="客户自提" value="CUSTOMER_PICKUP"/)
|
||||||
assert.match(api, /RelabelDeliveryMethod = 'TRUCK' \| 'EXPRESS' \| 'TRUCK_PICKUP' \| 'EXPRESS_PICKUP'/)
|
assert.match(api, /RelabelDeliveryMethod = 'TRUCK' \| 'EXPRESS' \| 'AMAZON_PICKUP' \| 'CUSTOMER_PICKUP'/)
|
||||||
assert.match(view, /labelingMode\.value !== 'sku' && !form\.deliveryMethod/)
|
assert.match(view, /labelingMode\.value !== 'sku' && !form\.deliveryMethod/)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -174,7 +174,7 @@ test('海外操作仓和FBA目的仓使用独立字段', () => {
|
|||||||
assert.match(api, /destinationFbaWarehouseCode\?: string/)
|
assert.match(api, /destinationFbaWarehouseCode\?: string/)
|
||||||
assert.match(view, /operationWarehouseId: form\.warehouse/)
|
assert.match(view, /operationWarehouseId: form\.warehouse/)
|
||||||
assert.match(view, /destinationFbaWarehouseCode: form\.destination/)
|
assert.match(view, /destinationFbaWarehouseCode: form\.destination/)
|
||||||
assert.match(view, /<el-select v-model="form\.destination"[^\n]+FBA 目的仓/)
|
assert.match(view, /FBA 目的仓<\/span><el-select v-model="form\.destination"/)
|
||||||
assert.doesNotMatch(view, /v-model="row\.destinationWarehouseId"[^\n]+请选择 FBA 目的仓/)
|
assert.doesNotMatch(view, /v-model="row\.destinationWarehouseId"[^\n]+请选择 FBA 目的仓/)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -230,7 +230,7 @@ test('进入复核后客户订单回显换标完成状态', () => {
|
|||||||
|
|
||||||
test('审核驳回后展示原因并允许替换资料后重新提交', () => {
|
test('审核驳回后展示原因并允许替换资料后重新提交', () => {
|
||||||
assert.match(api, /export function resubmitRelabel\(/)
|
assert.match(api, /export function resubmitRelabel\(/)
|
||||||
assert.match(view, /贴标单审核已驳回/)
|
assert.match(view, /<ReviewRejectionNotice/)
|
||||||
assert.match(view, /application\.reviewRemark/)
|
assert.match(view, /application\.reviewRemark/)
|
||||||
assert.match(view, /修改后重新提交/)
|
assert.match(view, /修改后重新提交/)
|
||||||
assert.match(view, /await resubmitRelabel\(/)
|
assert.match(view, /await resubmitRelabel\(/)
|
||||||
|
|||||||
@@ -5,6 +5,26 @@ import ts from 'typescript'
|
|||||||
const view = readFileSync(new URL('./WarehouseServiceView.vue', import.meta.url), 'utf8')
|
const view = readFileSync(new URL('./WarehouseServiceView.vue', import.meta.url), 'utf8')
|
||||||
const source = view.slice(view.indexOf('function returnSourceNos'), view.indexOf('const relabelInboundOptions'))
|
const source = view.slice(view.indexOf('function returnSourceNos'), view.indexOf('const relabelInboundOptions'))
|
||||||
const compiled = ts.transpileModule(source + '\nexports.sourceNos = returnSourceNos; exports.progress = returnRelabelProgress', { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText
|
const compiled = ts.transpileModule(source + '\nexports.sourceNos = returnSourceNos; exports.progress = returnRelabelProgress', { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText
|
||||||
|
test('carton validation ignores unselected stock after SKU relabel and consolidation', () => {
|
||||||
|
const start = view.indexOf('async function submit(')
|
||||||
|
const validation = view.slice(view.indexOf(' const invalidCargo =', start), view.indexOf(" if ((props.service === 'relabel'", start))
|
||||||
|
const compiledValidation = ts.transpileModule(validation, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText
|
||||||
|
const validate = new Function('props', 'labelingMode', 'relabelRows', 'cartonDetailRows', 'cargoRows', 'skuRelabelRows', compiledValidation + '; return invalidCargo')
|
||||||
|
const stockRows = [
|
||||||
|
{ included: false, name: '', sku: 'OLD-SKU', quantity: 1 },
|
||||||
|
{ included: false, name: '', sku: 'OLD-SKU,NEW-SKU', quantity: 1 },
|
||||||
|
]
|
||||||
|
const selected = { included: true, name: 'Product', sku: 'OLD-SKU,NEW-SKU', quantity: 1 }
|
||||||
|
function invalid(details) {
|
||||||
|
return validate({ service: 'labeling' }, { value: 'carton' }, details,
|
||||||
|
{ value: details }, { value: [...stockRows, ...details] }, { value: [] })
|
||||||
|
}
|
||||||
|
assert.equal(invalid([selected]), false)
|
||||||
|
assert.equal(invalid([{ ...selected, name: '' }]), true)
|
||||||
|
assert.equal(invalid([]), true)
|
||||||
|
assert.equal(invalid([{ ...selected, sku: '' }]), false, 'SKU is optional for carton labels')
|
||||||
|
})
|
||||||
|
|
||||||
const exports = {}
|
const exports = {}
|
||||||
new Function('exports', compiled)(exports)
|
new Function('exports', compiled)(exports)
|
||||||
test('合箱来源按单号集合精确匹配并去重,不能用子串匹配', () => {
|
test('合箱来源按单号集合精确匹配并去重,不能用子串匹配', () => {
|
||||||
@@ -22,7 +42,6 @@ test('退货 SKU 申请按实物箱选择,来源标识只读,部分数量转
|
|||||||
assert.match(view, /item.id === rows\[0\].sourceRowId/)
|
assert.match(view, /item.id === rows\[0\].sourceRowId/)
|
||||||
assert.match(view, /row.sourceRowId !== source\?\.id/)
|
assert.match(view, /row.sourceRowId !== source\?\.id/)
|
||||||
assert.match(view, /:readonly="isReturnRelabelSource"/)
|
assert.match(view, /:readonly="isReturnRelabelSource"/)
|
||||||
assert.match(view, /SKU 换标完成后扣减旧 SKU、增加新 SKU/)
|
|
||||||
assert.match(view, /labelingMode.value === 'sku' \? !skuTotalFile.value : !cartonTotalFile.value/)
|
assert.match(view, /labelingMode.value === 'sku' \? !skuTotalFile.value : !cartonTotalFile.value/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,46 @@ import test from 'node:test'
|
|||||||
const view = readFileSync(new URL('./WarehouseServiceView.vue', import.meta.url), 'utf8')
|
const view = readFileSync(new URL('./WarehouseServiceView.vue', import.meta.url), 'utf8')
|
||||||
|
|
||||||
test('货物废弃先选择海外操作仓,再加载并选择该仓入库单', () => {
|
test('货物废弃先选择海外操作仓,再加载并选择该仓入库单', () => {
|
||||||
assert.match(view, /props\.service === 'disposal'.*操作仓.*v-model="form\.warehouse".*请选择海外仓.*@change="selectDiscardWarehouse"/s)
|
assert.match(view, /props\.service === 'disposal'.*操作仓.*v-model="form\.warehouse".*@change="selectDiscardWarehouse"/s)
|
||||||
assert.match(view, /props\.service === 'disposal'.*选择入库单.*:disabled="!form\.warehouse".*请先选择操作仓/s)
|
assert.match(view, /props\.service === 'disposal'.*选择入库单.*:disabled="!form\.warehouse".*@change="selectDiscardInbound"/s)
|
||||||
assert.match(view, /async function selectDiscardWarehouse\(\).*selectedDiscardOrderId\.value = ''.*cargoRows\.value = \[\].*listRelabelInventoryCandidates\(form\.warehouse\).*grouped/s)
|
assert.match(view, /async function selectDiscardWarehouse\(\).*selectedDiscardOrderId\.value = ''.*cargoRows\.value = \[\].*listDiscardCandidates\(\)/s)
|
||||||
assert.match(view, /\|\| !primaryWarehouse \|\| invalidCargo/)
|
assert.match(view, /\|\| !primaryWarehouse \|\| invalidCargo/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
test('废弃来源包含通用仓位,选择后带出全部库存且不把选择键提交为订单ID', () => {
|
||||||
|
const body = view.match(/function selectDiscardInbound\(orderId: string\) \{([\s\S]*?)\n\}/)[1]
|
||||||
|
const select = new Function('discardSelectedBoxKeys', 'discardBoxSearch', 'selectAllDiscardBoxes', 'discardDocuments', 'cargoRows', 'form', 'cargoSequence', 'orderId', body)
|
||||||
|
const cargoRows = { value: [] }; const form = {}
|
||||||
|
const source = { orderId: 'inventory:42', sourceType: 'INVENTORY', inventoryPositionId: '42', overseasWarehouseId: '99', inboundCode: 'WT-1', skus: [{ skuCode: 'A', productName: '商品', availablePackages: 100 }] }
|
||||||
|
select({ value: [] }, { value: '' }, () => {}, { value: [{ ...source, orderId: 'document:WT-1', sources: [source] }] }, cargoRows, form, 0, 'document:WT-1')
|
||||||
|
assert.equal(cargoRows.value[0].quantity, 100)
|
||||||
|
assert.equal(cargoRows.value[0].sourceInventoryPositionId, '42')
|
||||||
|
assert.equal(form.inboundNo, 'WT-1')
|
||||||
|
assert.match(view, /orderId: item.sourceType === 'DROPSHIP' \? item.sourceOrderId : undefined/)
|
||||||
|
assert.match(view, /inventoryPositionId: item.sourceType === 'INVENTORY'/)
|
||||||
|
})
|
||||||
|
|
||||||
|
function selectionRows(boxes, selectedKeys) {
|
||||||
|
const body = view.match(/function syncDiscardSelection\(\) \{([\s\S]*?)\n\}/)[1].replace('new Map<string, CargoRow>()', 'new Map()')
|
||||||
|
const rows = { value: [] }
|
||||||
|
new Function('discardSelectableBoxes', 'discardSelectedBoxKeys', 'cargoRows', 'createCargoRow', body)({ value: boxes }, { value: selectedKeys }, rows, () => ({}))
|
||||||
|
return rows.value
|
||||||
|
}
|
||||||
|
test('部分废弃只提交勾选的箱子,数量由箱子汇总,清空后无明细', () => {
|
||||||
|
const source = { orderId: 'inventory:42', inventoryPositionId: '42', sourceType: 'INVENTORY', availablePackages: 3, skus: [{ skuCode: 'SKU', productName: '商品' }] }
|
||||||
|
const boxes = ['1', '2', '3'].map(id => ({ key: `INVENTORY:${id}`, cartonId: id, cartonMark: `BOX-${id}`, source, skus: source.skus }))
|
||||||
|
const rows = selectionRows(boxes, ['INVENTORY:2'])
|
||||||
|
assert.equal(rows.length, 1); assert.equal(rows[0].quantity, 1)
|
||||||
|
assert.deepEqual(rows[0].discardCartonIds, ['2']); assert.equal(rows[0].sourceInventoryPositionId, '42')
|
||||||
|
assert.equal(selectionRows(boxes, []).length, 0)
|
||||||
|
})
|
||||||
|
test('混合SKU箱按实物箱只计一次,退货箱仍整箱携带全部SKU', () => {
|
||||||
|
const skus = [{ skuCode: 'A', availablePackages: 2 }, { skuCode: 'B', availablePackages: 3 }]
|
||||||
|
const dropship = { orderId: '42', sourceType: 'DROPSHIP', availablePackages: 1, skus }
|
||||||
|
const rows = selectionRows([{ key: 'DROPSHIP:1', cartonId: '1', source: dropship, skus }], ['DROPSHIP:1'])
|
||||||
|
assert.equal(rows.length, 1); assert.equal(rows[0].quantity, 1); assert.deepEqual(rows[0].discardCartonIds, ['1'])
|
||||||
|
const returned = { orderId: 'return:2', returnCartonId: '2', sourceType: 'RETURN_CARTON', availablePackages: 5, skus }
|
||||||
|
const returnRows = selectionRows([{ key: 'RETURN_CARTON:2', cartonId: '2', source: returned, skus }], ['RETURN_CARTON:2'])
|
||||||
|
assert.deepEqual(returnRows.map(row => row.quantity), [2, 3]); assert.ok(returnRows.every(row => row.returnCartonId === '2' && row.discardCartonIds === undefined))
|
||||||
|
})
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { computed, effectScope, nextTick, reactive, ref, watch } from 'vue'
|
||||||
|
import ts from 'typescript'
|
||||||
|
|
||||||
|
const source = readFileSync(new URL('./WarehouseServiceView.vue', import.meta.url), 'utf8')
|
||||||
|
const detailNumber = source.match(/^const detailNumber = .*$/m)[0]
|
||||||
|
const routeWatcher = source.slice(source.indexOf('watch(() => [props.service,'), source.indexOf('watch(mode,'))
|
||||||
|
const activation = source.slice(source.indexOf('let activatedOnce = false'), source.indexOf('onBeforeUnmount(() =>'))
|
||||||
|
const code = ts.transpileModule([detailNumber, routeWatcher, activation].join('\n'), { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText
|
||||||
|
function harness(detailPage = false) {
|
||||||
|
const scope = effectScope(), props = reactive({ service: 'transfer', createPage: false, detailPage, detailNo: '' })
|
||||||
|
const route = reactive({ params: { detailNo: detailPage ? 'TRANSFER-A' : undefined } })
|
||||||
|
const mode = ref(detailPage ? 'detail' : 'list'), page = ref(3), filters = reactive({ number: 'TRANSFER-FILTER', warehouse: 'warehouse-1', status: 'RECEIVED' })
|
||||||
|
const calls = [], activationCallbacks = []
|
||||||
|
const resetFilters = () => { calls.push('resetFilters'); Object.assign(filters, { number: '', warehouse: '', status: '' }) }
|
||||||
|
const loadPage = () => { calls.push({ kind: 'list', page: page.value, ...filters }) }
|
||||||
|
scope.run(() => new Function('computed', 'watch', 'props', 'route', 'mode', 'page', 'resetFilters', 'resetForm', 'openCreate', 'loadDetailPage', 'loadPage', 'onActivated', code)(computed, watch, props, route, mode, page, resetFilters, () => calls.push('resetForm'), () => calls.push('create'), () => calls.push('detail'), loadPage, callback => activationCallbacks.push(callback)))
|
||||||
|
return { scope, props, route, mode, page, filters, calls, activate: () => activationCallbacks.forEach(callback => callback()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('cached transfer list ignores detail navigation and refreshes once on return with its original filters and page', async () => {
|
||||||
|
const state = harness()
|
||||||
|
try {
|
||||||
|
state.activate()
|
||||||
|
state.route.params.detailNo = 'TRANSFER-A'
|
||||||
|
await nextTick()
|
||||||
|
assert.deepEqual(state.calls, [])
|
||||||
|
assert.equal(state.page.value, 3)
|
||||||
|
assert.equal(state.filters.number, 'TRANSFER-FILTER')
|
||||||
|
state.route.params.detailNo = 'TRANSFER-B'
|
||||||
|
await nextTick()
|
||||||
|
assert.deepEqual(state.calls, [])
|
||||||
|
state.route.params.detailNo = undefined
|
||||||
|
await nextTick()
|
||||||
|
state.activate()
|
||||||
|
assert.deepEqual(state.calls, [{ kind: 'list', page: 3, number: 'TRANSFER-FILTER', warehouse: 'warehouse-1', status: 'RECEIVED' }])
|
||||||
|
} finally { state.scope.stop() }
|
||||||
|
})
|
||||||
|
|
||||||
|
test('real detail pages still react to another detail number without loading a background list', async () => {
|
||||||
|
const state = harness(true)
|
||||||
|
try {
|
||||||
|
state.route.params.detailNo = 'TRANSFER-B'
|
||||||
|
await nextTick()
|
||||||
|
assert.deepEqual(state.calls, ['resetFilters', 'resetForm', 'detail'])
|
||||||
|
} finally { state.scope.stop() }
|
||||||
|
})
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import ts from 'typescript'
|
||||||
|
|
||||||
|
const source = readFileSync(new URL('./WarehouseServiceView.vue', import.meta.url), 'utf8')
|
||||||
|
const functions = source.slice(source.indexOf('function transferCartonItem('), source.indexOf("const detailError = ref('')"))
|
||||||
|
const code = ts.transpileModule(functions, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText
|
||||||
|
const load = selectedDetail => new Function('selectedDetail', `${code}; return { transferCartonLines, palletSkuRows, palletQuantity, receivedItemCartons, receivedItemQuantity };`)(selectedDetail)
|
||||||
|
|
||||||
|
test('mixed carton detail and pallet summaries preserve every SKU without duplicating boxes', () => {
|
||||||
|
const mixed = { status: 'COUNTED', transferItemId: '1', transferPalletId: '9', quantity: 10, items: [{ transferItemId: '1', skuCode: 'A', quantity: 3 }, { transferItemId: '2', skuCode: 'B', quantity: 7 }] }
|
||||||
|
const display = load({ value: { items: [{ id: '1', skuCode: 'A', productNameZh: '产品A' }, { id: '2', skuCode: 'B', productNameZh: '产品B' }], cartons: [mixed, { ...mixed, status: 'NOT_RECEIVED', quantity: 999 }] } })
|
||||||
|
assert.equal(display.transferCartonLines(mixed).length, 2)
|
||||||
|
assert.equal(display.transferCartonLines(mixed)[1].productNameZh, '产品B')
|
||||||
|
assert.deepEqual(display.palletSkuRows({ id: '9' }), [{ sku: 'A', quantity: 3 }, { sku: 'B', quantity: 7 }])
|
||||||
|
assert.equal(display.palletQuantity({ id: '9' }), 10)
|
||||||
|
assert.equal(display.receivedItemQuantity('1'), 3)
|
||||||
|
assert.equal(display.receivedItemQuantity('2'), 7)
|
||||||
|
assert.equal(display.receivedItemCartons('2').length, 1)
|
||||||
|
})
|
||||||
@@ -30,7 +30,6 @@ test('仓储转运支持无目的地、FBA 仓和私人仓并显式选择是否
|
|||||||
assert.match(view, />是否贴标</)
|
assert.match(view, />是否贴标</)
|
||||||
assert.match(view, /:value="false">不需要贴标/)
|
assert.match(view, /:value="false">不需要贴标/)
|
||||||
assert.match(view, /:value="true">需要贴标/)
|
assert.match(view, /:value="true">需要贴标/)
|
||||||
assert.match(view, /FBA 仓和私人仓均可选择是否贴标/)
|
|
||||||
assert.doesNotMatch(view, /v-model="form\.relabel" disabled/)
|
assert.doesNotMatch(view, /v-model="form\.relabel" disabled/)
|
||||||
assert.doesNotMatch(view, /value === 'FBA'\) form\.relabel = true/)
|
assert.doesNotMatch(view, /value === 'FBA'\) form\.relabel = true/)
|
||||||
assert.doesNotMatch(view, /value === 'PRIVATE'.*form\.relabel = false/)
|
assert.doesNotMatch(view, /value === 'PRIVATE'.*form\.relabel = false/)
|
||||||
@@ -44,7 +43,8 @@ test('仓储转运支持无目的地、FBA 仓和私人仓并显式选择是否
|
|||||||
|
|
||||||
test('换标详情待客户确认时提供确认入口并刷新详情', () => {
|
test('换标详情待客户确认时提供确认入口并刷新详情', () => {
|
||||||
assert.match(view, /application\.status === 'CUSTOMER_CONFIRM_PENDING'[\s\S]*@click="confirmSelectedRelabel">确认换标完成/)
|
assert.match(view, /application\.status === 'CUSTOMER_CONFIRM_PENDING'[\s\S]*@click="confirmSelectedRelabel">确认换标完成/)
|
||||||
assert.match(view, /async function confirmSelectedRelabel\(\)[\s\S]*await confirmRelabelCompletion\(relabelNo\)[\s\S]*await openDetail\(\{ '0': relabelNo \}\)/)
|
assert.match(view, /async function confirmSelectedRelabel\(\)[\s\S]*openCompletionConfirmation\(relabelNo, 'labeling'\)/)
|
||||||
|
assert.match(view, /async function refreshAfterCompletion\(no: string\)[\s\S]*await openDetail\(\{ '0': no \}\)/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('仓储转运换标绑定明细自动同步只读的旧 SKU', () => {
|
test('仓储转运换标绑定明细自动同步只读的旧 SKU', () => {
|
||||||
@@ -55,7 +55,7 @@ test('仓储转运换标绑定明细自动同步只读的旧 SKU', () => {
|
|||||||
|
|
||||||
test('仓储转运换标整票只允许一个目的仓', () => {
|
test('仓储转运换标整票只允许一个目的仓', () => {
|
||||||
assert.match(view, /props\.service === 'transfer' && transferDestinationType === 'FBA'[^\n]+FBA 目的仓/)
|
assert.match(view, /props\.service === 'transfer' && transferDestinationType === 'FBA'[^\n]+FBA 目的仓/)
|
||||||
assert.match(view, /FBA 货件编号<\/span><el-input v-model="form\.fbaShipmentNo" placeholder="选填,可后续补充"/)
|
assert.match(view, /FBA 货件编号<\/span><el-input v-model="form\.fbaShipmentNo" placeholder=""/)
|
||||||
assert.match(view, /transferDestinationType\.value === 'FBA' && !form\.destination/)
|
assert.match(view, /transferDestinationType\.value === 'FBA' && !form\.destination/)
|
||||||
assert.match(view, /transferDestinationType\.value === 'PRIVATE'.*!form\.privateDestinationPostalCode/)
|
assert.match(view, /transferDestinationType\.value === 'PRIVATE'.*!form\.privateDestinationPostalCode/)
|
||||||
assert.match(view, /整票换标货物目的 FBA 仓/)
|
assert.match(view, /整票换标货物目的 FBA 仓/)
|
||||||
@@ -78,7 +78,7 @@ test('仓储转运不要求客户填写内部入仓号和我方箱唛,但换
|
|||||||
assert.doesNotMatch(transferConfig, /进仓编号/)
|
assert.doesNotMatch(transferConfig, /进仓编号/)
|
||||||
assert.doesNotMatch(transferSubmit, /inboundCode|sourceLastlegOrderIds/)
|
assert.doesNotMatch(transferSubmit, /inboundCode|sourceLastlegOrderIds/)
|
||||||
assert.match(view, /货物明细/)
|
assert.match(view, /货物明细/)
|
||||||
assert.match(view, /客户箱唛号 \*/)
|
assert.match(view, /仓库唛头 \*/)
|
||||||
assert.match(view, /downloadTransferCartonMarkTemplate/)
|
assert.match(view, /downloadTransferCartonMarkTemplate/)
|
||||||
assert.match(view, /importTransferCartonMarks/)
|
assert.match(view, /importTransferCartonMarks/)
|
||||||
assert.match(view, /<strong>SKU 关联<\/strong>/)
|
assert.match(view, /<strong>SKU 关联<\/strong>/)
|
||||||
@@ -101,15 +101,12 @@ test('仓储转运不要求客户填写内部入仓号和我方箱唛,但换
|
|||||||
assert.doesNotMatch(view, /<th v-if="props\.service === 'transfer'">FNSKU \/ 箱唛<\/th>/)
|
assert.doesNotMatch(view, /<th v-if="props\.service === 'transfer'">FNSKU \/ 箱唛<\/th>/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('仓储转运货物明细可按 SKU 和箱数打印客户预贴箱唛', () => {
|
test('仓储转运填写货物即可打印,保存或提交保留已打印箱号', () => {
|
||||||
assert.match(view, /printTransferDraftCartonMarks/)
|
assert.doesNotMatch(view, /printTransferDraftCartonMarks|CT-\$\{batch\}/)
|
||||||
assert.match(view, /请先填写商品 SKU 和对应的整数箱数/)
|
assert.match(view, /getTransferCartonLabel\(transferNo\)/)
|
||||||
assert.match(view, /Array\.from\(\{ length: Number\(row\.quantity\) \}/)
|
assert.match(view, /打印预报箱唛/)
|
||||||
assert.match(view, /row\.customerCartonMarks = marks\.join\('\\n'\)/)
|
assert.match(view, /@click="printForecastCartons"/)
|
||||||
assert.match(view, /仓储转运客户箱唛/)
|
assert.match(view, /cartonMarks: item.generatedCartonMarks/)
|
||||||
assert.match(view, /@click="printTransferDraftCartonMarks"/)
|
|
||||||
assert.match(view, /打印唛头/)
|
|
||||||
assert.match(view, /单次最多打印 500 张箱唛/)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('仓储转运换标资料按步骤分组展示', () => {
|
test('仓储转运换标资料按步骤分组展示', () => {
|
||||||
@@ -145,7 +142,7 @@ test('仓储转运换标总文件与明细模板在桌面端左右排列', () =>
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('上门提货不提交物流号,需要换标时随转运单登记申请', () => {
|
test('上门提货不提交物流号,需要换标时随转运单登记申请', () => {
|
||||||
assert.match(view, /trackingNo:\s*form\.deliveryMethod === 'CUSTOMER_DELIVERY'\s*\?[^:]+:\s*undefined/)
|
assert.match(view, /trackingNo:\s*!inventoryTransfer\.value && form\.deliveryMethod === 'CUSTOMER_DELIVERY'\s*\?[^:]+:\s*undefined/)
|
||||||
assert.match(view, /relabelItems: form\.relabel/)
|
assert.match(view, /relabelItems: form\.relabel/)
|
||||||
assert.match(view, /form\.deliveryMethod === 'CUSTOMER_DELIVERY'.*物流跟踪号/s)
|
assert.match(view, /form\.deliveryMethod === 'CUSTOMER_DELIVERY'.*物流跟踪号/s)
|
||||||
assert.match(view, /提货联系人/)
|
assert.match(view, /提货联系人/)
|
||||||
@@ -155,9 +152,8 @@ test('上门提货不提交物流号,需要换标时随转运单登记申请',
|
|||||||
assert.match(view, /form\.pickupPhone/)
|
assert.match(view, /form\.pickupPhone/)
|
||||||
assert.match(view, /form\.relabelContact/)
|
assert.match(view, /form\.relabelContact/)
|
||||||
assert.match(view, /form\.relabelPhone/)
|
assert.match(view, /form\.relabelPhone/)
|
||||||
assert.match(view, /选择海外仓后自动带出,也可手动填写/)
|
assert.match(view, /pickupContactName: !inventoryTransfer\.value && form\.deliveryMethod === 'WAREHOUSE_PICKUP' \? form\.pickupContact : undefined/)
|
||||||
assert.match(view, /pickupContactName: form\.deliveryMethod === 'WAREHOUSE_PICKUP' \? form\.pickupContact : undefined/)
|
assert.match(view, /pickupContactPhone: !inventoryTransfer\.value && form\.deliveryMethod === 'WAREHOUSE_PICKUP' \? form\.pickupPhone : undefined/)
|
||||||
assert.match(view, /pickupContactPhone: form\.deliveryMethod === 'WAREHOUSE_PICKUP' \? form\.pickupPhone : undefined/)
|
|
||||||
assert.match(view, /relabelContactName: form\.relabel \? form\.relabelContact/)
|
assert.match(view, /relabelContactName: form\.relabel \? form\.relabelContact/)
|
||||||
assert.match(api, /pickupContactName\?: string; pickupContactPhone\?: string/)
|
assert.match(api, /pickupContactName\?: string; pickupContactPhone\?: string/)
|
||||||
})
|
})
|
||||||
@@ -174,7 +170,7 @@ test('仓储转运期望发货时间可选并在订单概览回填发货时间',
|
|||||||
assert.doesNotMatch(view, /预计发货时间/)
|
assert.doesNotMatch(view, /预计发货时间/)
|
||||||
assert.match(view, /期望发货时间/)
|
assert.match(view, /期望发货时间/)
|
||||||
assert.match(view, /v-model="form\.estimatedShipAt"/)
|
assert.match(view, /v-model="form\.estimatedShipAt"/)
|
||||||
assert.match(view, /v-model="form\.estimatedShipAt"[^>]+placeholder="选填"/)
|
assert.match(view, /v-model="form\.estimatedShipAt"[^>]+placeholder=""/)
|
||||||
assert.doesNotMatch(view, /!form\.estimatedShipAt/)
|
assert.doesNotMatch(view, /!form\.estimatedShipAt/)
|
||||||
assert.match(view, /estimatedShipAt: form\.estimatedShipAt/)
|
assert.match(view, /estimatedShipAt: form\.estimatedShipAt/)
|
||||||
assert.match(view, /<span>期望发货时间<\/span><strong>\{\{ formatDateTime\(selectedDetail\?\.estimatedShipAt\) \}\}<\/strong>/)
|
assert.match(view, /<span>期望发货时间<\/span><strong>\{\{ formatDateTime\(selectedDetail\?\.estimatedShipAt\) \}\}<\/strong>/)
|
||||||
@@ -210,7 +206,7 @@ test('仓储转运列表展示换标单号、换标状态并为两类状态着
|
|||||||
test('仓储转运详情订单概览使用详情接口命名字段回填', () => {
|
test('仓储转运详情订单概览使用详情接口命名字段回填', () => {
|
||||||
assert.match(view, /v-else-if="props\.service === 'transfer'" class="warehouse-detail-grid warehouse-transfer-overview-grid"/)
|
assert.match(view, /v-else-if="props\.service === 'transfer'" class="warehouse-detail-grid warehouse-transfer-overview-grid"/)
|
||||||
assert.doesNotMatch(view, /入库箱数/)
|
assert.doesNotMatch(view, /入库箱数/)
|
||||||
assert.match(view, /'转运单号', '箱数', '海外仓'/)
|
assert.match(view, /'转运单号', '预计箱数', '海外仓'/)
|
||||||
for (const field of ['transferNo', 'packageCount', 'operatingWarehouseId', 'deliveryMethod', 'needRelabel', 'relabelNo', 'status', 'createdAt']) {
|
for (const field of ['transferNo', 'packageCount', 'operatingWarehouseId', 'deliveryMethod', 'needRelabel', 'relabelNo', 'status', 'createdAt']) {
|
||||||
assert.match(view, new RegExp(`selectedDetail\\?\\.${field}`))
|
assert.match(view, new RegExp(`selectedDetail\\?\\.${field}`))
|
||||||
}
|
}
|
||||||
@@ -258,7 +254,8 @@ test('贴标申请详情使用紧凑订单概览并中文展示换标状态', ()
|
|||||||
test('仓储转运详情按接口具名字段展示订单概览', () => {
|
test('仓储转运详情按接口具名字段展示订单概览', () => {
|
||||||
const overview = view.split('<h3>订单概览</h3>')[1]?.split('warehouse-transfer-cargo-card')[0] || ''
|
const overview = view.split('<h3>订单概览</h3>')[1]?.split('warehouse-transfer-cargo-card')[0] || ''
|
||||||
assert.match(overview, /v-if="props\.service === 'transfer'"/)
|
assert.match(overview, /v-if="props\.service === 'transfer'"/)
|
||||||
assert.match(overview, /selectedDetail\?\.transferNo/)
|
assert.doesNotMatch(overview, /<span>转运单号<\/span>/)
|
||||||
|
assert.match(view.split('<header class="warehouse-detail-header">')[1]?.split('</header>')[0] || '', /selectedDetail\?\.transferNo/)
|
||||||
assert.match(overview, /selectedDetail\?\.packageCount \?\? '—'/)
|
assert.match(overview, /selectedDetail\?\.packageCount \?\? '—'/)
|
||||||
assert.match(overview, /warehouseDisplay\(selectedDetail\?\.operatingWarehouseId\)/)
|
assert.match(overview, /warehouseDisplay\(selectedDetail\?\.operatingWarehouseId\)/)
|
||||||
assert.match(overview, /selectedDetail\?\.destinationWarehouseName \|\| warehouseDisplay\(selectedDetail\?\.destinationWarehouseCode \|\| selectedDetail\?\.fbaWarehouseCode\)/)
|
assert.match(overview, /selectedDetail\?\.destinationWarehouseName \|\| warehouseDisplay\(selectedDetail\?\.destinationWarehouseCode \|\| selectedDetail\?\.fbaWarehouseCode\)/)
|
||||||
@@ -272,26 +269,26 @@ test('PDA 打托完成后客户可从列表或详情确认并查看实测数据'
|
|||||||
assert.match(view, /confirmTransferFromList\(item\['0'\]\)/)
|
assert.match(view, /confirmTransferFromList\(item\['0'\]\)/)
|
||||||
assert.match(view, /selectedDetail\?\.status === 'WAIT_CUSTOMER_CONFIRMATION'/)
|
assert.match(view, /selectedDetail\?\.status === 'WAIT_CUSTOMER_CONFIRMATION'/)
|
||||||
assert.match(view, /PDA 打托实测数据/)
|
assert.match(view, /PDA 打托实测数据/)
|
||||||
for (const label of ['托盘号', '库位', '箱数', '长 × 宽 × 高(cm)', '毛重(kg)', '体积(m³)', '完成时间']) assert.equal(view.includes(label), true)
|
for (const label of ['托盘号', '库位', '箱数', 'SKU 汇总', '毛重(kg)', '体积(m³)', '完成时间']) assert.equal(view.includes(label), true)
|
||||||
assert.match(view, /pallet\.warehouseLocationCode/)
|
assert.match(view, /pallet\.warehouseLocationCode/)
|
||||||
assert.match(view, /pallet\.cartonCount/)
|
assert.match(view, /palletCartons\(pallet\)\.length/)
|
||||||
assert.match(view, /pallet\.grossWeightKg/)
|
assert.match(view, /pallet\.grossWeightKg/)
|
||||||
assert.match(view, /pallet\.volumeCbm/)
|
assert.match(view, /pallet\.volumeCbm/)
|
||||||
assert.doesNotMatch(view, /pallet\.items/)
|
assert.doesNotMatch(view, /pallet\.items/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('仓储转运详情按订单概览、PDA 实测数据、货物明细排序并放大测量照片', () => {
|
test('仓储转运详情按订单概览、货物预报、PDA 实测数据排序并放大测量照片', () => {
|
||||||
const detailStart = view.indexOf('<header class="warehouse-detail-header">')
|
const detailStart = view.indexOf('<header class="warehouse-detail-header">')
|
||||||
const detailSource = view.slice(detailStart)
|
const detailSource = view.slice(detailStart)
|
||||||
const overviewIndex = detailSource.indexOf('<h3>订单概览</h3>')
|
const overviewIndex = detailSource.indexOf('<h3>订单概览</h3>')
|
||||||
const measurementIndex = detailSource.indexOf('<h3>PDA 打托实测数据</h3>')
|
const measurementIndex = detailSource.indexOf('<h3>PDA 打托实测数据</h3>')
|
||||||
const cargoIndex = detailSource.indexOf('<h3>货物明细</h3>')
|
const cargoIndex = detailSource.indexOf('<h3>预报与实际签收</h3>')
|
||||||
assert.ok(overviewIndex >= 0 && overviewIndex < measurementIndex)
|
assert.ok(overviewIndex >= 0 && overviewIndex < measurementIndex)
|
||||||
assert.ok(measurementIndex < cargoIndex)
|
assert.ok(cargoIndex < measurementIndex)
|
||||||
assert.match(view, /class="warehouse-photo-grid warehouse-measurement-photo-grid"/)
|
assert.match(view, /class="warehouse-photo-grid warehouse-measurement-photo-grid"/)
|
||||||
assert.match(view, /class="warehouse-photo-item warehouse-measurement-photo-item"/)
|
assert.match(view, /class="warehouse-photo-item warehouse-measurement-photo-item"/)
|
||||||
assert.match(view, /warehouse-measurement-photo-grid\{flex:1;grid-template-columns:repeat\(auto-fill,minmax\(240px,1fr\)\)\}/)
|
assert.match(view, /warehouse-measurement-photo-grid\{flex:1;grid-template-columns:repeat\(auto-fill,minmax\(240px,1fr\)\)\}/)
|
||||||
assert.match(view, /warehouse-measurement-photo-item\{width:100%;height:180px\}/)
|
assert.match(view, /warehouse-measurement-photo-item\{width:100%;height:120px\}/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('仓储转运详情自动加载 PDA 测量照片并释放 Blob URL', () => {
|
test('仓储转运详情自动加载 PDA 测量照片并释放 Blob URL', () => {
|
||||||
@@ -328,7 +325,7 @@ test('仓储转运可在新建页直接提交,也可保存草稿后编辑提
|
|||||||
assert.match(view, /await updateTransfer\(editingTransferNo\.value, payload\)/)
|
assert.match(view, /await updateTransfer\(editingTransferNo\.value, payload\)/)
|
||||||
assert.match(view, /await submitTransfer\(transferNo\)/)
|
assert.match(view, /await submitTransfer\(transferNo\)/)
|
||||||
assert.match(view, /<form [^>]*@submit\.prevent="submit\('submit'\)"/)
|
assert.match(view, /<form [^>]*@submit\.prevent="submit\('submit'\)"/)
|
||||||
assert.match(view, /selectedDetail\?\.status !== 'DRAFT'[^>]*[\s\S]*?printTransferCartons[^>]*[\s\S]*?打印箱唛/)
|
assert.match(view, /selectedDetail\?\.status !== 'DRAFT'[^>]*[\s\S]*?printTransferCartons[^>]*[\s\S]*?打印预报箱唛/)
|
||||||
assert.doesNotMatch(view, /row\.__status !== 'DRAFT'.*printTransferCartons\(row\['0'\]\)/s)
|
assert.doesNotMatch(view, /row\.__status !== 'DRAFT'.*printTransferCartons\(row\['0'\]\)/s)
|
||||||
assert.doesNotMatch(view, /selectedDetail\?\.status === 'DRAFT'[^>]*[\s\S]*?>提交订单/)
|
assert.doesNotMatch(view, /selectedDetail\?\.status === 'DRAFT'[^>]*[\s\S]*?>提交订单/)
|
||||||
assert.match(view, /native-type="button"[^>]*:loading="saving"[^>]*@click="submit\('draft'\)"/)
|
assert.match(view, /native-type="button"[^>]*:loading="saving"[^>]*@click="submit\('draft'\)"/)
|
||||||
@@ -339,7 +336,7 @@ test('仓储转运可在新建页直接提交,也可保存草稿后编辑提
|
|||||||
test('编辑仓储转运时加载库存转运来源候选', () => {
|
test('编辑仓储转运时加载库存转运来源候选', () => {
|
||||||
assert.match(view, /async function loadTransferInventoryCandidates\(warehouseId: string\)/)
|
assert.match(view, /async function loadTransferInventoryCandidates\(warehouseId: string\)/)
|
||||||
assert.match(view, /if \(props\.service === 'transfer' && form\.warehouse\) \{[\s\S]*await loadTransferInventoryCandidates\(form\.warehouse\)/)
|
assert.match(view, /if \(props\.service === 'transfer' && form\.warehouse\) \{[\s\S]*await loadTransferInventoryCandidates\(form\.warehouse\)/)
|
||||||
assert.match(view, /transferInventoryCandidates\.value = await listRelabelInventoryCandidates\(warehouseId\)/)
|
assert.match(view, /transferInventoryCandidates\.value = \(await listRelabelInventoryCandidates\(warehouseId\)\)\.filter/)
|
||||||
assert.match(view, /transferInventoryOptions\.value\.some\(\(option\) => option\.value === sourceInboundCode\)/)
|
assert.match(view, /transferInventoryOptions\.value\.some\(\(option\) => option\.value === sourceInboundCode\)/)
|
||||||
assert.match(view, /selectedTransferInventory\.value = sourceInboundCode/)
|
assert.match(view, /selectedTransferInventory\.value = sourceInboundCode/)
|
||||||
assert.match(view, /detail\.sourceOrders \|\| \[\]/)
|
assert.match(view, /detail\.sourceOrders \|\| \[\]/)
|
||||||
@@ -361,9 +358,51 @@ test('转运贴标单在PDA确认库存前不误报附件上传失败', () => {
|
|||||||
test('仓储转运基础货物不强制 SKU 和中文品名,只换箱唛不强制旧 SKU', () => {
|
test('仓储转运基础货物不强制 SKU 和中文品名,只换箱唛不强制旧 SKU', () => {
|
||||||
assert.match(view, /商品 SKU\{\{ props\.service === 'transfer' \? '' : ' \*' \}\}/)
|
assert.match(view, /商品 SKU\{\{ props\.service === 'transfer' \? '' : ' \*' \}\}/)
|
||||||
assert.match(view, /中文品名\{\{ props\.service === 'transfer' \? '' : ' \*' \}\}/)
|
assert.match(view, /中文品名\{\{ props\.service === 'transfer' \? '' : ' \*' \}\}/)
|
||||||
assert.match(view, /props\.service === 'transfer' \? '选填' : '请输入商品 SKU'/)
|
assert.match(view, /props\.service === 'relabel' \? '请输入商品 SKU' : ''/)
|
||||||
assert.match(view, /v-if="labelingMode === 'carton'"[^\n]+<th>旧 SKU<\/th>/)
|
assert.match(view, /v-if="labelingMode === 'carton'"[^\n]+<th>旧 SKU<\/th>/)
|
||||||
assert.match(view, /labelingMode\.value !== 'carton' && \(!row\.sku\.trim\(\) \|\| !row\.newSkuCode\.trim\(\)\)/)
|
assert.match(view, /labelingMode\.value !== 'carton' && \(!row\.sku\.trim\(\) \|\| !row\.newSkuCode\.trim\(\)\)/)
|
||||||
assert.match(api, /skuCode\?: string; productNameZh\?: string/)
|
assert.match(api, /skuCode\?: string; productNameZh\?: string/)
|
||||||
assert.match(api, /originalSku\?: string/)
|
assert.match(api, /originalSku\?: string/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('SKU 件数和预计箱数独立提交,签收展示实际清点箱数', () => {
|
||||||
|
assert.match(view, /quantity: item.quantity, packageCount: Number\(item.expectedPackageCount\)/)
|
||||||
|
assert.match(view, /sum \+ Number\(item.expectedPackageCount \|\| 0\)/)
|
||||||
|
assert.match(view, /v-model="row.expectedPackageCount"/)
|
||||||
|
assert.match(view, /Number.isSafeInteger\(row.quantity\)/)
|
||||||
|
assert.match(view, /Number.isSafeInteger\(row.expectedPackageCount\)/)
|
||||||
|
assert.match(view, /carton.status === 'COUNTED'/)
|
||||||
|
assert.match(view, /实际签收箱数/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('转运客户详情保留预报并显示实际签收箱内明细和托盘汇总', () => {
|
||||||
|
for (const label of ['预报数量(件)', '预报 SKU 种数', '签收箱号明细', '查看箱号明细', 'SKU 汇总', '毛重(kg)', '体积(m³)']) assert.ok(view.includes(label), label)
|
||||||
|
assert.match(view, /carton.status === 'COUNTED' && pallet.id != null/)
|
||||||
|
assert.match(view, /String\(carton.transferPalletId\) === String\(pallet.id\)/)
|
||||||
|
assert.match(view, /carton.transferItemId != null && String\(item.id\) === String\(carton.transferItemId\)/)
|
||||||
|
assert.match(view, /@click="showTransferCartons = true"/)
|
||||||
|
assert.match(view, /<el-dialog v-model="showTransferCartons"[\s\S]*?<el-collapse-item v-for="carton in countedTransferCartons\(\)"/)
|
||||||
|
assert.doesNotMatch(view, /warehouse-transfer-receipt-summary|expandedTransferCartons/)
|
||||||
|
const receiptSection = view.slice(view.indexOf('warehouse-transfer-received-card', view.indexOf('<header class="warehouse-detail-header">')), view.indexOf('v-if="props.service === \'transfer\' && selectedDetail?.needRelabel"'))
|
||||||
|
assert.doesNotMatch(receiptSection.split('<el-dialog')[0], /<table|<el-table|<el-collapse/)
|
||||||
|
|
||||||
|
assert.match(view, /暂无实际签收箱,等待 PDA 清点/)
|
||||||
|
assert.doesNotMatch(view, /<td>\{\{ \[pallet.lengthCm/)
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
test('预报实收同区对照、托盘卡片换行、签收按钮紧邻标题', () => {
|
||||||
|
const start = view.indexOf('class="warehouse-card warehouse-form-section warehouse-transfer-cargo-card"', view.indexOf('<header class="warehouse-detail-header">'))
|
||||||
|
const section = view.slice(start, view.indexOf('warehouse-transfer-received-card', start))
|
||||||
|
assert.ok(section.includes('预报与实际签收'))
|
||||||
|
assert.ok(section.includes('PDA 打托实测数据'))
|
||||||
|
assert.ok(section.includes('receivedItemQuantity(item.id)'))
|
||||||
|
assert.ok(section.includes('receivedItemCartons(item.id).length'))
|
||||||
|
assert.ok(section.includes('class="warehouse-transfer-pallet-card"'))
|
||||||
|
assert.ok(section.includes('class="warehouse-transfer-pallet-sku"'))
|
||||||
|
assert.doesNotMatch(section, /打印箱唛/)
|
||||||
|
const receipt = view.slice(view.indexOf('warehouse-transfer-received-card', start)).split('<el-dialog')[0]
|
||||||
|
assert.ok(view.includes('打印预报箱唛')); assert.ok(view.includes('增加预报箱数'))
|
||||||
|
assert.ok(receipt.includes('查看箱号明细'))
|
||||||
|
assert.match(view, /warehouse-section-heading h3\{flex:0 0 auto/)
|
||||||
|
})
|
||||||
|
|||||||
@@ -57,7 +57,6 @@ export const TENANT_PERMISSION_GROUPS: readonly TenantPermissionGroup[] = [
|
|||||||
{ code: 'order:first-leg:submit', label: '提交订单', description: '提交草稿进入平台审核' },
|
{ code: 'order:first-leg:submit', label: '提交订单', description: '提交草稿进入平台审核' },
|
||||||
{ code: 'order:first-leg:cancel', label: '取消订单', description: '在允许的状态下取消订单' },
|
{ code: 'order:first-leg:cancel', label: '取消订单', description: '在允许的状态下取消订单' },
|
||||||
{ code: 'merchant:warehouse:delivery:list', label: '查看派送单', description: '分页查询本企业海外仓派送单' },
|
{ code: 'merchant:warehouse:delivery:list', label: '查看派送单', description: '分页查询本企业海外仓派送单' },
|
||||||
{ code: 'merchant:warehouse:delivery:create', label: '创建派送单', description: '从排车单创建海外仓派送单' },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -75,6 +74,7 @@ export const TENANT_PERMISSION_GROUPS: readonly TenantPermissionGroup[] = [
|
|||||||
{ code: 'merchant:overseas-warehouse:transfer:confirm', label: '确认转运实测', description: '确认仓库回传的箱数、尺寸、重量和打托数据' },
|
{ code: 'merchant:overseas-warehouse:transfer:confirm', label: '确认转运实测', description: '确认仓库回传的箱数、尺寸、重量和打托数据' },
|
||||||
{ code: 'merchant:overseas-warehouse:discard:list', label: '查看货物废弃', description: '查看本企业货物废弃申请列表和详情' },
|
{ code: 'merchant:overseas-warehouse:discard:list', label: '查看货物废弃', description: '查看本企业货物废弃申请列表和详情' },
|
||||||
{ code: 'merchant:overseas-warehouse:discard:create', label: '创建货物废弃', description: '使用本企业可用库存创建废弃申请' },
|
{ code: 'merchant:overseas-warehouse:discard:create', label: '创建货物废弃', description: '使用本企业可用库存创建废弃申请' },
|
||||||
|
{ code: 'merchant:overseas-warehouse:discard:confirm', label: '确认废弃完成', description: '核对本企业废弃现场照片并确认完结' },
|
||||||
{ code: 'merchant:overseas-warehouse:discard:cancel', label: '取消货物废弃', description: '取消本企业尚未审核的废弃申请' },
|
{ code: 'merchant:overseas-warehouse:discard:cancel', label: '取消货物废弃', description: '取消本企业尚未审核的废弃申请' },
|
||||||
{ code: 'merchant:overseas-warehouse:return-order:list', label: '查看退货预报', description: '查看本企业退货预报和仓库签收结果' },
|
{ code: 'merchant:overseas-warehouse:return-order:list', label: '查看退货预报', description: '查看本企业退货预报和仓库签收结果' },
|
||||||
{ code: 'merchant:overseas-warehouse:return-order:create', label: '创建退货预报', description: '创建本企业退货 SKU 和数量预报' },
|
{ code: 'merchant:overseas-warehouse:return-order:create', label: '创建退货预报', description: '创建本企业退货 SKU 和数量预报' },
|
||||||
@@ -144,7 +144,6 @@ export const FRONTEND_TO_MERCHANT_PERMISSION_CODES: Readonly<Record<string, read
|
|||||||
'order:first-leg:submit': ['merchant:order:submit'],
|
'order:first-leg:submit': ['merchant:order:submit'],
|
||||||
'order:first-leg:cancel': ['merchant:order:cancel'],
|
'order:first-leg:cancel': ['merchant:order:cancel'],
|
||||||
'merchant:warehouse:delivery:list': ['merchant:warehouse:delivery:list'],
|
'merchant:warehouse:delivery:list': ['merchant:warehouse:delivery:list'],
|
||||||
'merchant:warehouse:delivery:create': ['merchant:warehouse:delivery:create'],
|
|
||||||
'merchant:warehouse:inbound:list': ['merchant:warehouse:inbound:list'],
|
'merchant:warehouse:inbound:list': ['merchant:warehouse:inbound:list'],
|
||||||
'merchant:overseas-warehouse:return-inventory:list': ['merchant:overseas-warehouse:return-inventory:list'],
|
'merchant:overseas-warehouse:return-inventory:list': ['merchant:overseas-warehouse:return-inventory:list'],
|
||||||
'merchant:overseas-warehouse:last-mile-order:list': ['merchant:overseas-warehouse:last-mile-order:list'],
|
'merchant:overseas-warehouse:last-mile-order:list': ['merchant:overseas-warehouse:last-mile-order:list'],
|
||||||
@@ -157,6 +156,7 @@ export const FRONTEND_TO_MERCHANT_PERMISSION_CODES: Readonly<Record<string, read
|
|||||||
'merchant:overseas-warehouse:transfer:confirm': ['merchant:overseas-warehouse:transfer:confirm'],
|
'merchant:overseas-warehouse:transfer:confirm': ['merchant:overseas-warehouse:transfer:confirm'],
|
||||||
'merchant:overseas-warehouse:discard:list': ['merchant:overseas-warehouse:discard:list'],
|
'merchant:overseas-warehouse:discard:list': ['merchant:overseas-warehouse:discard:list'],
|
||||||
'merchant:overseas-warehouse:discard:create': ['merchant:overseas-warehouse:discard:create'],
|
'merchant:overseas-warehouse:discard:create': ['merchant:overseas-warehouse:discard:create'],
|
||||||
|
'merchant:overseas-warehouse:discard:confirm': ['merchant:overseas-warehouse:discard:confirm'],
|
||||||
'merchant:overseas-warehouse:discard:cancel': ['merchant:overseas-warehouse:discard:cancel'],
|
'merchant:overseas-warehouse:discard:cancel': ['merchant:overseas-warehouse:discard:cancel'],
|
||||||
'merchant:overseas-warehouse:return-order:list': ['merchant:overseas-warehouse:return-order:list'],
|
'merchant:overseas-warehouse:return-order:list': ['merchant:overseas-warehouse:return-order:list'],
|
||||||
'merchant:overseas-warehouse:return-order:create': ['merchant:overseas-warehouse:return-order:create'],
|
'merchant:overseas-warehouse:return-order:create': ['merchant:overseas-warehouse:return-order:create'],
|
||||||
|
|||||||
Reference in New Issue
Block a user