135 lines
12 KiB
JavaScript
135 lines
12 KiB
JavaScript
const API = '/api/platform/v1/last-leg'
|
|
const OPTIONS = { scenario: 'looseboard-pool-receipt', deploymentBlocker: true }
|
|
const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH1kAAAAASUVORK5CYII=', 'base64')
|
|
const validVersion = value => /^(0|[1-9]\d*)$/.test(String(value)) && Number.isSafeInteger(Number(value))
|
|
const id = value => String(value ?? '')
|
|
|
|
/** 原承运来货经PDA实收及运营确认;测量值由调用方明确给出,不借历史库存。 */
|
|
export async function receiveLooseboardPool(context, { demand, tripProof, pdaToken, measurements }) {
|
|
const binding = demand?.poolBinding, cargo = binding?.createRequest?.cargo
|
|
if (context.createdResources?.phase4OverseasBusiness?.looseboard !== demand || demand?.marker !== context.marker
|
|
|| !context.createdResources?.fleetTrips?.includes(tripProof) || binding?.tripNo !== tripProof?.tripNo
|
|
|| binding?.status !== 'CONFIRMED_ORIGINAL_ORDER' || demand.receipt || !pdaToken
|
|
|| typeof context.checkpoint !== 'function' || !Array.isArray(measurements) || !measurements.length
|
|
|| measurements.length !== binding.createRequest.expectedPalletCount
|
|
|| measurements.some(m => !Number.isSafeInteger(m.cartonCount) || m.cartonCount <= 0
|
|
|| !Array.isArray(m.boxes) || m.boxes.length !== m.cartonCount
|
|
|| m.boxes.some(box => ['length', 'width', 'height', 'weight'].some(field => !Number.isFinite(box[field]) || box[field] <= 0)
|
|
|| !Number.isSafeInteger(box.quantity) || box.quantity <= 0)
|
|
|| Math.abs(m.boxes.reduce((sum, box) => sum + box.length * box.width * box.height / 1000000, 0) - m.volumeCbm) > 0.000001
|
|
|| m.boxes.reduce((sum, box) => sum + box.weight, 0) !== m.grossWeightKg
|
|
|| ['lengthCm', 'widthCm', 'heightCm', 'volumeCbm', 'grossWeightKg'].some(k => !Number.isFinite(m[k]) || m[k] <= 0))
|
|
|| measurements.reduce((sum, m) => sum + m.cartonCount, 0) !== cargo?.packageCount) {
|
|
throw Error('散板实收缺少本轮原单承运、PDA身份或逐托测量与箱数依据')
|
|
}
|
|
const proof = demand.receipt = { marker: context.marker, status: 'RECEIVING', bookingNo: binding.bookingNo, requests: [], pallets: [] }
|
|
const check = (name, condition) => {
|
|
context.assert('phase4-overseas-business', name, null, Boolean(condition), `orderNo=${demand.orderNo}`, OPTIONS)
|
|
if (!condition) throw Error(name)
|
|
}
|
|
const io = async (method, path, body, token, multipart = false) => {
|
|
const log = { method, path, status: 'PREPARED', ...(body && !multipart ? { body: structuredClone(body) } : {}) }
|
|
proof.requests.push(log); await context.checkpoint()
|
|
let response
|
|
try {
|
|
log.status = 'SENDING'
|
|
response = multipart ? await context.requestMultipart(method, path, body, token) : await context.request(method, path, body, token)
|
|
} catch (error) { log.status = 'UNKNOWN_RESULT'; throw error }
|
|
const ok = response?.http === 200 && id(response?.body?.code) === '200'
|
|
Object.assign(log, { status: ok ? 'SUCCEEDED' : !response?.http || response.http >= 500 ? 'UNKNOWN_RESULT' : 'REJECTED',
|
|
http: response?.http, code: response?.body?.code, requestId: response?.requestId, traceId: response?.traceId })
|
|
await context.checkpoint()
|
|
context.apiSuccess('phase4-overseas-business', '散板原承运实收', response, JSON.stringify({ http: log.http, requestId: log.requestId, traceId: log.traceId }), OPTIONS)
|
|
if (!ok) throw Error('散板实收请求失败;未知结果不自动重发')
|
|
return response.body.data
|
|
}
|
|
const key = action => `${context.marker}-looseboard-${action}`
|
|
const pool = `${API}/fleet/capacity-offers/inquiries/${encodeURIComponent(binding.bookingNo)}`
|
|
const trip = `${API}/fleet/trips/${encodeURIComponent(tripProof.tripNo)}`
|
|
const bookingMatches = row => row?.bookingNo === binding.bookingNo && row.tripNo === tripProof.tripNo && id(row.customerId) === id(demand.customerId)
|
|
try {
|
|
const list = await io('GET', `${trip}/bookings`)
|
|
const matches = list?.records?.filter(row => row.bookingNo === binding.bookingNo) || []
|
|
const booking = matches[0]
|
|
check('来货前保持原车原客户唯一承运且未登记来货', matches.length === 1 && bookingMatches(booking)
|
|
&& booking.status === 'CONFIRMED' && !booking.incomingNo && !booking.dispatchNo
|
|
&& validVersion(list.tripRecordVersion) && validVersion(booking.recordVersion)
|
|
&& JSON.stringify(booking.cargo) === JSON.stringify(cargo))
|
|
const body = { tripRecordVersion: list.tripRecordVersion, recordVersion: booking.recordVersion,
|
|
estimatedArrivalAt: new Date(Date.now() + 300000).toISOString(), deliveryInstructions: `${context.marker} TEST自送`, idempotencyKey: key('incoming') }
|
|
const created = await io('POST', `${pool}/incoming`, body)
|
|
check('登记来货保留原承运身份', bookingMatches(created?.booking) && created.booking.status === 'CONFIRMED'
|
|
&& typeof created.booking.incomingNo === 'string' && created.booking.incomingNo.length > 0 && !created.booking.receipt)
|
|
proof.incomingNo = created.booking.incomingNo
|
|
const replay = await io('POST', `${pool}/incoming`, body)
|
|
check('来货重放没有产生第二单', replay?.replayed === true && JSON.stringify(replay.booking) === JSON.stringify(created.booking)
|
|
&& replay.tripRecordVersion === created.tripRecordVersion)
|
|
const initial = (await io('GET', `${pool}/fulfillment`))?.preview?.incoming
|
|
check('来货范围与初始实收状态正确', initial?.incomingNo === proof.incomingNo && id(initial.warehouseId) === id(tripProof.warehouseId)
|
|
&& initial.destinationKey === cargo.destinationKey && initial.status === 'WAIT_PDA_RECEIVING' && initial.pallets?.length === 0)
|
|
const pda = `/api/pda/v1/last-leg/warehouse-transfers/${encodeURIComponent(proof.incomingNo)}`
|
|
const forecast = await io('GET', pda, undefined, pdaToken)
|
|
check('逐箱实收只使用原来货预报箱号和唯一预报SKU', forecast?.transferNo === proof.incomingNo
|
|
&& id(forecast.operatingWarehouseId) === id(tripProof.warehouseId) && forecast.packageCount === cargo.packageCount
|
|
&& forecast.items?.length === 1 && forecast.items[0].skuCode && forecast.cartons?.length === cargo.packageCount
|
|
&& new Set(forecast.cartons.map(box => box.cartonMark)).size === cargo.packageCount
|
|
&& forecast.cartons.every(box => box.cartonMark && box.status === 'EXPECTED' && id(box.transferItemId) === id(forecast.items[0].id)))
|
|
for (const [index, measurement] of measurements.entries()) {
|
|
const draft = await io('POST', `${pda}/pallets/drafts`, { idempotencyKey: key(`measure-${index}`) }, pdaToken)
|
|
const newPallets = draft?.pallets?.filter(row => !proof.pallets.some(p => id(p.id) === id(row.id))) || []
|
|
check('逐托测量只新增本次一个草稿', draft?.transferNo === proof.incomingNo && draft?.pallets?.length === index + 1 && newPallets.length === 1
|
|
&& newPallets[0].status === 'DRAFT' && newPallets[0].cartonCount === 0
|
|
&& /^[1-9]\d*$/.test(id(newPallets[0].id)))
|
|
const palletId = newPallets[0].id, path = `${pda}/pallets/${encodeURIComponent(palletId)}`
|
|
const generated = await io('POST', `${path}/generate`, { idempotencyKey: key(`generate-${index}`) }, pdaToken)
|
|
const pallet = generated?.pallets?.find(row => id(row.id) === id(palletId))
|
|
check('本托生成真实唯一托盘资源', generated?.transferNo === proof.incomingNo && pallet?.palletNo && /^[1-9]\d*$/.test(id(pallet.warehousePalletId))
|
|
&& !proof.pallets.some(row => row.palletNo === pallet.palletNo || id(row.warehousePalletId) === id(pallet.warehousePalletId)))
|
|
await io('POST', `${path}/bind`, { palletCode: pallet.palletNo, idempotencyKey: key(`bind-${index}`) }, pdaToken)
|
|
proof.pallets.push({ id: palletId, warehousePalletId: id(pallet.warehousePalletId), palletNo: pallet.palletNo, packages: measurement.cartonCount })
|
|
}
|
|
let offset = 0
|
|
const boxes = measurements.flatMap((measurement, index) => measurement.boxes.map(input => {
|
|
const carton = forecast.cartons[offset++]
|
|
return { cartonMark: carton.cartonMark, transferItemId: id(carton.transferItemId), transferPalletId: id(proof.pallets[index].id),
|
|
length: input.length, width: input.width, height: input.height, weight: input.weight,
|
|
items: [{ skuCode: forecast.items[0].skuCode, quantity: input.quantity }] }
|
|
}))
|
|
proof.controlledBoxInputs = { syntheticTestMaterials: true, boxes }
|
|
const counted = await io('POST', `${pda}/cartons/count`, { idempotencyKey: key('count-boxes'), forecastPackageCount: forecast.packageCount, boxes }, pdaToken)
|
|
check('逐箱尺寸重量SKU实点与实际托盘归属已保存', counted?.transferNo === proof.incomingNo && counted.cartons?.length === boxes.length
|
|
&& boxes.every(input => counted.cartons.some(box => box.cartonMark === input.cartonMark && box.status === 'COUNTED'
|
|
&& id(box.transferPalletId) === input.transferPalletId && ['length', 'width', 'height', 'weight'].every(field => Number(box[field]) === input[field])
|
|
&& box.items?.length === 1 && box.items[0].skuCode === input.items[0].skuCode && Number(box.items[0].quantity) === input.items[0].quantity))
|
|
&& proof.pallets.every((p, index) => counted.pallets?.some(row => id(row.id) === id(p.id) && row.cartonCount === p.packages
|
|
&& Number(row.volumeCbm) === measurements[index].volumeCbm && Number(row.grossWeightKg) === measurements[index].grossWeightKg)))
|
|
const uploaded = await io('POST', `${pda}/measurement-photos/files`, { name: `${context.marker}-TEST-looseboard.png`, type: 'image/png', content: PNG }, pdaToken, true)
|
|
check('实收测试图片已正式上传', /^[1-9]\d*$/.test(id(uploaded?.fileId)))
|
|
const received = await io('POST', `${pda}/palletize`, { idempotencyKey: key('palletize'), photoFileIds: [id(uploaded.fileId)] }, pdaToken)
|
|
check('本次PDA完成全部原实托', received?.transferNo === proof.incomingNo && received?.status === 'WAIT_CUSTOMER_CONFIRMATION' && received.pallets?.length === proof.pallets.length
|
|
&& proof.pallets.every(p => received.pallets.some(row => id(row.id) === id(p.id) && row.palletNo === p.palletNo
|
|
&& id(row.warehousePalletId) === p.warehousePalletId && row.cartonCount === p.packages && row.status === 'COMPLETED')))
|
|
const preview = (await io('GET', `${pool}/fulfillment`))?.preview, source = preview?.incoming
|
|
check('运营确认依据为同仓同目的地同箱数的实际实收', source?.incomingNo === proof.incomingNo && id(source.warehouseId) === id(tripProof.warehouseId)
|
|
&& source.destinationKey === cargo.destinationKey && source.status === 'WAIT_CUSTOMER_CONFIRMATION'
|
|
&& source.receiptHash && source.receivedPackages === cargo.packageCount && source.pallets?.length === proof.pallets.length
|
|
&& proof.pallets.every(p => source.pallets.some(row => id(row.warehousePalletId) === p.warehousePalletId && row.packages === p.packages))
|
|
&& validVersion(preview.tripRecordVersion) && validVersion(preview.recordVersion))
|
|
const receiptBody = { tripRecordVersion: preview.tripRecordVersion, recordVersion: preview.recordVersion, receiptHash: source.receiptHash,
|
|
pallets: proof.pallets.map(p => ({ warehousePalletId: p.warehousePalletId, palletSpaces: 1 })), unitPrice: cargo.unitPrice, idempotencyKey: key('receipt') }
|
|
const accepted = await io('POST', `${pool}/receipt-confirmation`, receiptBody)
|
|
check('运营确认回写原承运并达到可排车', bookingMatches(accepted?.booking) && accepted.booking.status === 'RECEIVED'
|
|
&& accepted.booking.incomingNo === proof.incomingNo && accepted.booking.receipt?.source?.status === 'READY_FOR_DISPATCH')
|
|
const again = await io('POST', `${pool}/receipt-confirmation`, receiptBody)
|
|
check('实收确认重放保持原结果和版本', again?.replayed === true && JSON.stringify(again.booking) === JSON.stringify(accepted.booking)
|
|
&& again.tripRecordVersion === accepted.tripRecordVersion)
|
|
proof.status = 'RECEIVED_READY_FOR_DISPATCH'
|
|
proof.acceptedReceipt = accepted.booking.receipt
|
|
await context.checkpoint()
|
|
return proof
|
|
} catch (error) {
|
|
proof.status = 'FAILED'; proof.failure = error.message
|
|
await context.checkpoint(); throw error
|
|
}
|
|
}
|