428 lines
15 KiB
TypeScript
428 lines
15 KiB
TypeScript
import assert from 'node:assert/strict'
|
|
import test from 'node:test'
|
|
import {
|
|
actualQuantity,
|
|
matchStopLinks,
|
|
stopFeedbacks,
|
|
freightFields,
|
|
loadMobileDetailReads
|
|
} from '../src/views/m/pl/plan/mobileDetailDisplay'
|
|
|
|
test('actual quantities use segment actuals including zero, never estimates', () => {
|
|
assert.equal(
|
|
actualQuantity({ inboundQty: 33.97, outboundQty: 34, planQty: 30 } as any, 'load'),
|
|
'33.97'
|
|
)
|
|
assert.equal(actualQuantity({ inboundQty: 33.97, outboundQty: 34 } as any, 'delivery'), '34')
|
|
assert.equal(actualQuantity({ inboundQty: 0 } as any, 'load'), '0')
|
|
assert.equal(actualQuantity({ planQty: 30 } as any, 'load'), '—')
|
|
})
|
|
test('exact stop identity, legacy only plan product identity, never catalog identity', () => {
|
|
const stop = {
|
|
id: 'a',
|
|
products: [
|
|
{ id: 'line1', productId: 'same', planProductId: 'p1' },
|
|
{ id: 'line2', productId: 'same', planProductId: 'p2' }
|
|
]
|
|
}
|
|
const links = [
|
|
{ stopType: 'delivery', stopId: 'b', planProductId: 'p1' },
|
|
{ stopType: 'load', stopId: 'a', planProductId: 'p2' },
|
|
{ planProductId: 'p1' },
|
|
{ productId: 'same' }
|
|
]
|
|
assert.deepEqual(matchStopLinks(links, stop, 'load'), [links[1], links[2]])
|
|
assert.equal(
|
|
stopFeedbacks(
|
|
{
|
|
driverStopFeedbacks: [
|
|
{ stopType: 'delivery', stopId: 'b' },
|
|
{ stopType: 'load', stopId: 'a' }
|
|
]
|
|
} as any,
|
|
stop,
|
|
'load'
|
|
).length,
|
|
1
|
|
)
|
|
})
|
|
test('legacy driver feedback remains distinct and keeps zero', () => {
|
|
const f = stopFeedbacks(
|
|
{ feedbackQty: 0, feedbackImages: '["a.pdf"]', feedbackRemark: '旧记录' } as any,
|
|
{},
|
|
'load'
|
|
)
|
|
assert.equal(f[0].quantity, 0)
|
|
assert.deepEqual(f[0].attachmentUrls, ['a.pdf'])
|
|
assert.equal(stopFeedbacks({ feedbackQty: 3 } as any, {}, 'delivery').length, 0)
|
|
})
|
|
test('eight freight sources, trip hides quantity and unit price, no freight blanks', () => {
|
|
const fields = freightFields(
|
|
{ hasFreight: 1, freightAmount: 500, products: [{ actualQty: 34 }] } as any,
|
|
{ freightType: 'by_trip', freightAmount: 999, transportQty: 0, hasInvoice: 0 } as any,
|
|
{ invoiceAmount: 10 } as any
|
|
)
|
|
assert.equal(fields.length, 8)
|
|
assert.equal(fields[3].value, '—')
|
|
assert.equal(fields[4].value, '—')
|
|
assert.equal(fields[5].value, '500.00')
|
|
assert.equal(fields[6].value, '否')
|
|
assert.ok(
|
|
freightFields({ hasFreight: 0 } as any, {} as any, null)
|
|
.slice(1)
|
|
.every((f) => f.value === '—')
|
|
)
|
|
assert.equal(
|
|
freightFields({ hasFreight: 1 } as any, { transportQty: 0 } as any, null)[3].value,
|
|
'0'
|
|
)
|
|
})
|
|
test('partial failures are visible and retry performs only declared reads; masked prices untouched', async () => {
|
|
let failed = true
|
|
const plan: any = {
|
|
id: 'p',
|
|
transportId: 't',
|
|
transportStatus: 'arranged',
|
|
products: [{ inboundPrice: null }]
|
|
}
|
|
const reads: any = {
|
|
transport: async () => {
|
|
if (failed) throw Error()
|
|
return { id: 't' }
|
|
},
|
|
freight: async () => null,
|
|
arrange: async () => ({ inventoryRecords: [] }),
|
|
inventory: async () => null,
|
|
related: async () => null,
|
|
io: async () => null,
|
|
vehicle: async () => null
|
|
}
|
|
const first = await loadMobileDetailReads(plan, reads)
|
|
assert.deepEqual(first.errors, ['运输反馈'])
|
|
failed = false
|
|
const next = await loadMobileDetailReads(plan, reads)
|
|
assert.deepEqual(next.errors, [])
|
|
assert.equal(plan.products[0].inboundPrice, null)
|
|
})
|
|
|
|
test('production page controller rejects late results from another route', async () => {
|
|
const { readFileSync } = await import('node:fs')
|
|
const { parse } = await import('@vue/compiler-sfc')
|
|
const ts = await import('typescript')
|
|
const { ref } = await import('vue')
|
|
const source = parse(
|
|
readFileSync(new URL('../src/views/m/pl/plan/detail.vue', import.meta.url), 'utf8')
|
|
).descriptor.scriptSetup!.content
|
|
const ast = ts.createSourceFile('page.ts', source, ts.ScriptTarget.Latest, true)
|
|
const fn = ast.statements.find(
|
|
(n) => ts.isFunctionDeclaration(n) && n.name?.text === 'loadDetail'
|
|
)!
|
|
let release!: (value: any) => void
|
|
const older = new Promise((resolve) => {
|
|
release = resolve
|
|
})
|
|
const plan = ref<any>(null),
|
|
recordId = ref('older'),
|
|
readDetail = ref<any>(null),
|
|
partialErrors = ref([])
|
|
const planReads: unknown[][] = []
|
|
const bindings: any = {
|
|
plan,
|
|
recordId,
|
|
readDetail,
|
|
partialErrors,
|
|
loading: ref(false),
|
|
errorText: ref(''),
|
|
siteRecords: ref([]),
|
|
inventoryRecords: ref([]),
|
|
planApi: {
|
|
get: async (id: string, includeMobileProductMeta?: boolean) => {
|
|
planReads.push([id, includeMobileProductMeta])
|
|
return id === 'older' ? older : { id, planType: 'transport', products: [] }
|
|
}
|
|
},
|
|
loadArrangeActions: async () => {},
|
|
resetArrangeActions: () => {},
|
|
loadMobileDetailReads: async () => ({ errors: [], transport: null }),
|
|
transportApi: {},
|
|
freightApi: {},
|
|
inventoryApi: {},
|
|
inboundOrderApi: {},
|
|
outboundOrderApi: {},
|
|
vehicleApi: {},
|
|
personnelApi: {}
|
|
}
|
|
const load = new Function(
|
|
...Object.keys(bindings),
|
|
`let shellReady=true, disposed=false, loadVersion=0; function invalidateRecord(){loadVersion++;plan.value=null;readDetail.value=null} ${ts.transpile(fn.getText(ast), { target: ts.ScriptTarget.ESNext })};return loadDetail`
|
|
)(...Object.values(bindings))
|
|
const first = load()
|
|
recordId.value = 'newer'
|
|
await load()
|
|
release({ id: 'older' })
|
|
await first
|
|
assert.equal(plan.value.id, 'newer')
|
|
assert.deepEqual(planReads, [
|
|
['older', true],
|
|
['newer', true]
|
|
])
|
|
})
|
|
|
|
test('prefetched arrange and historical snapshots avoid redundant reads and retain stop attachments', async () => {
|
|
const snapshot = { licensePlate: '历史车号', personnelList: [{ personnelName: '历史司机' }] }
|
|
const feedback: any = {
|
|
driverStopFeedbacks: [
|
|
{
|
|
stopType: 'load',
|
|
stopId: 'a',
|
|
attachmentUrls: ['load.pdf'],
|
|
products: [
|
|
{ stopProductId: 'r1', planProductId: 'p1', actualQty: 1 },
|
|
{ stopProductId: 'r2', planProductId: 'p2', actualQty: 2 }
|
|
]
|
|
},
|
|
{ stopType: 'delivery', stopId: 'b', attachmentUrls: ['delivery.mp4'] }
|
|
]
|
|
}
|
|
let forbidden = 0
|
|
const reads: any = {
|
|
transport: async () => feedback,
|
|
freight: async () => null,
|
|
arrange: async () => {
|
|
forbidden++
|
|
throw Error()
|
|
},
|
|
vehicle: async () => {
|
|
forbidden++
|
|
throw Error()
|
|
},
|
|
inventory: async () => ({ planNo: 'CRK' }),
|
|
related: async () => ({ weighing: { netWeight: 34 } })
|
|
}
|
|
const detail = await loadMobileDetailReads(
|
|
{
|
|
id: 'p',
|
|
planType: 'transport',
|
|
transportStatus: 'arranged',
|
|
transportId: 't',
|
|
vehicleId: 'v',
|
|
resourceSnapshot: snapshot
|
|
} as any,
|
|
reads,
|
|
{
|
|
inventoryRecords: [
|
|
{ stopType: 'load', stopId: 'a', dbInventoryId: 'db', planProductId: 'p1' }
|
|
]
|
|
}
|
|
)
|
|
assert.equal(forbidden, 0)
|
|
assert.equal(detail.snapshot, snapshot)
|
|
const load = stopFeedbacks(detail.transport, { id: 'a' }, 'load')
|
|
assert.deepEqual(load[0].attachmentUrls, ['load.pdf'])
|
|
assert.deepEqual(
|
|
load[0].products?.map((p) => p.stopProductId),
|
|
['r1', 'r2']
|
|
)
|
|
assert.equal(detail.dibangRows[0].weighing.netWeight, 34)
|
|
})
|
|
|
|
test('segment metadata overrides parent values including explicit empty values', async () => {
|
|
const mod: any = await import('../src/views/m/pl/plan/mobileDetailDisplay')
|
|
assert.equal(typeof mod.stopProductMetadata, 'function')
|
|
const parent: any = [{ id: 'p', aliasName: '装货别名', brandName: '装货品牌' }]
|
|
const row: any = {
|
|
planProductId: 'p',
|
|
mobileProductMeta: { aliasName: '送货别名', brandMode: '', brandId: '', brandName: '' }
|
|
}
|
|
assert.deepEqual(mod.stopProductMetadata(row, parent), row.mobileProductMeta)
|
|
assert.equal(
|
|
mod.stopProductMetadata({ ...row, mobileProductMeta: { aliasName: '', brandName: '' } }, parent)
|
|
.aliasName,
|
|
''
|
|
)
|
|
})
|
|
test('legacy vehicle personnel certificate failures stay visible and retryable', async () => {
|
|
let failed = true
|
|
const reads: any = {
|
|
vehicle: async () => ({
|
|
data: {
|
|
licensePlate: '旧车',
|
|
personnelList: [{ personnelId: 'driver', personnelName: '司机' }]
|
|
}
|
|
}),
|
|
personnel: async () => {
|
|
if (failed) throw Error('cert failed')
|
|
return { certList: [{ certName: '从业证', attachmentUrls: 'cert.pdf' }] }
|
|
}
|
|
}
|
|
const plan: any = { id: 'p', planType: 'transport', vehicleId: 'v' }
|
|
const result = await loadMobileDetailReads(plan, reads)
|
|
assert.deepEqual(result.errors, ['人员证书'])
|
|
assert.equal(result.vehicle.personnelList[0].personnelName, '司机')
|
|
assert.equal(result.vehicle.personnelList[0].certLoadFailed, true)
|
|
failed = false
|
|
const retry = await loadMobileDetailReads(plan, reads)
|
|
assert.deepEqual(retry.errors, [])
|
|
assert.equal(retry.vehicle.personnelList[0].certList[0].attachmentUrls, 'cert.pdf')
|
|
})
|
|
|
|
async function statusHarness(options: { failRead?: boolean; write?: () => Promise<any> } = {}) {
|
|
const { readFileSync } = await import('node:fs')
|
|
const { parse } = await import('@vue/compiler-sfc')
|
|
const ts = await import('typescript')
|
|
const { ref } = await import('vue')
|
|
const source = parse(
|
|
readFileSync(new URL('../src/views/m/pl/plan/detail.vue', import.meta.url), 'utf8')
|
|
).descriptor.scriptSetup!.content
|
|
const ast = ts.createSourceFile('page.ts', source, ts.ScriptTarget.Latest, true)
|
|
const names = ['invalidateRecord', 'loadDetail', 'changePlanStatus']
|
|
const code = names
|
|
.map((name) =>
|
|
ast.statements.find((n) => ts.isFunctionDeclaration(n) && n.name?.text === name)!.getText(ast)
|
|
)
|
|
.join('\n')
|
|
const calls: any[] = []
|
|
const full: any = {
|
|
id: 'p',
|
|
products: [{ id: 'product' }],
|
|
load: { products: [{ mobileProductMeta: { aliasName: '分段别名', brandName: '' } }] }
|
|
}
|
|
const b: any = {
|
|
recordId: ref('p'),
|
|
plan: ref(full),
|
|
readDetail: ref(null),
|
|
partialErrors: ref([]),
|
|
errorText: ref(''),
|
|
loading: ref(false),
|
|
acting: ref(false),
|
|
deliveryIndex: ref(0),
|
|
activeTab: ref('base'),
|
|
moreOpen: ref(false),
|
|
voidOpen: ref(false),
|
|
voidReason: ref(''),
|
|
vehicleOpen: ref(false),
|
|
siteRecords: ref([]),
|
|
inventoryRecords: ref([]),
|
|
resetArrangeActions: () => {},
|
|
loadArrangeActions: async () => {},
|
|
showCrmConfirmDialog: async () => {},
|
|
loadMobileDetailReads: async () => ({ errors: [], transport: { id: 'transport' } }),
|
|
transportApi: {},
|
|
freightApi: {},
|
|
inventoryApi: {},
|
|
inboundOrderApi: {},
|
|
outboundOrderApi: {},
|
|
vehicleApi: {},
|
|
personnelApi: {},
|
|
ElMessage: {
|
|
success: (s: string) => calls.push(['success', s]),
|
|
warning: (s: string) => calls.push(['warning', s]),
|
|
error: (s: string) => calls.push(['error', s])
|
|
}
|
|
}
|
|
const write = async () => {
|
|
calls.push(['write'])
|
|
return options.write ? options.write() : { id: 'p', followStatus: 'wait_notice' }
|
|
}
|
|
b.planApi = {
|
|
waitNotice: write,
|
|
cancelWaitNotice: write,
|
|
cancel: write,
|
|
get: async (id: string, mobile: boolean) => {
|
|
calls.push(['get', id, mobile])
|
|
if (options.failRead) throw Error('offline')
|
|
return { ...full, id }
|
|
}
|
|
}
|
|
const f = new Function(
|
|
...Object.keys(b),
|
|
`let shellReady=true,disposed=false,loadVersion=0,actionVersion=0;${ts.transpile(code, { target: ts.ScriptTarget.ESNext })};return {${names.join(',')}}`
|
|
)(...Object.values(b))
|
|
return { b, f, calls, full, options }
|
|
}
|
|
test('status success with scalar PlanRead reloads complete mobile detail for all three actions', async () => {
|
|
for (const kind of ['wait', 'cancel-wait', 'void']) {
|
|
const h = await statusHarness()
|
|
await h.f.changePlanStatus(kind, h.full)
|
|
assert.deepEqual(h.b.plan.value.load, h.full.load)
|
|
assert.equal(h.b.readDetail.value.transport.id, 'transport')
|
|
assert.deepEqual(
|
|
h.calls.filter((c) => c[0] === 'get'),
|
|
[['get', 'p', true]]
|
|
)
|
|
assert.equal(h.calls.filter((c) => c[0] === 'write').length, 1)
|
|
}
|
|
})
|
|
test('status write success and failed refresh reports saved and retries only GET', async () => {
|
|
const h = await statusHarness({ failRead: true })
|
|
await h.f.changePlanStatus('wait', h.full)
|
|
assert.match(h.b.errorText.value, /状态已保存.*刷新失败/)
|
|
assert.equal(h.b.plan.value, null)
|
|
h.options.failRead = false
|
|
await h.f.loadDetail()
|
|
assert.equal(h.calls.filter((c) => c[0] === 'write').length, 1)
|
|
assert.deepEqual(h.b.plan.value.load, h.full.load)
|
|
})
|
|
test('late status write cannot refresh or replace a new route', async () => {
|
|
let release!: (v: any) => void
|
|
const waiting = new Promise((resolve) => {
|
|
release = resolve
|
|
})
|
|
const h = await statusHarness({ write: () => waiting })
|
|
const action = h.f.changePlanStatus('wait', h.full)
|
|
await Promise.resolve()
|
|
h.b.recordId.value = 'other'
|
|
h.f.invalidateRecord()
|
|
await h.f.loadDetail()
|
|
release({ id: 'p' })
|
|
await action
|
|
assert.equal(h.b.plan.value.id, 'other')
|
|
assert.deepEqual(
|
|
h.calls.filter((c) => c[0] === 'get'),
|
|
[['get', 'other', true]]
|
|
)
|
|
})
|
|
|
|
test('plan total uses root quantity while stop totals use planQty', async () => {
|
|
const { readFileSync } = await import('node:fs')
|
|
const { parse } = await import('@vue/compiler-sfc')
|
|
const ts = await import('typescript')
|
|
const { computed, ref } = await import('vue')
|
|
const source = parse(
|
|
readFileSync(new URL('../src/views/m/pl/plan/detail.vue', import.meta.url), 'utf8')
|
|
).descriptor.scriptSetup!.content
|
|
const ast = ts.createSourceFile('page.ts', source, ts.ScriptTarget.Latest, true)
|
|
const names = ['sumProducts', 'loadQty', 'deliveryQty', 'qtyUnitLabel', 'planTotalQtyText']
|
|
const code = ast.statements
|
|
.filter((node) =>
|
|
ts.isFunctionDeclaration(node)
|
|
? names.includes(node.name!.text)
|
|
: ts.isVariableStatement(node) &&
|
|
node.declarationList.declarations.some((d) => names.includes(d.name.getText(ast)))
|
|
)
|
|
.map((node) => node.getText(ast))
|
|
.join('\n')
|
|
const state = {
|
|
computed,
|
|
plan: ref({
|
|
products: [
|
|
{ quantity: 1, unit: 'pcs' },
|
|
{ quantity: 2, unit: 'pcs' }
|
|
]
|
|
}),
|
|
load: ref({ products: [{ planQty: 1 }, { planQty: 2 }] }),
|
|
deliveries: ref([{ products: [{ planQty: 1 }] }, { products: [{ planQty: 2 }] }]),
|
|
resolveMpProductUnitLabel: () => '个'
|
|
}
|
|
const values = new Function(
|
|
...Object.keys(state),
|
|
ts.transpile(code, { target: ts.ScriptTarget.ESNext }) +
|
|
';return {planTotalQtyText,loadQty,deliveryQty}'
|
|
)(...Object.values(state))
|
|
assert.equal(values.planTotalQtyText.value, '3个')
|
|
assert.equal(values.loadQty.value, 3)
|
|
assert.equal(values.deliveryQty.value, 3)
|
|
state.plan.value.products[0].quantity = 0
|
|
assert.equal(values.planTotalQtyText.value, '2个')
|
|
})
|