From 505188c3fd31f4f2f486baaf62f1c427ea0c2b3b Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 1 Sep 2026 10:16:07 +0800 Subject: [PATCH 01/37] test(firstleg): make trucking acceptance self-contained --- .../truck-appointments-contract.test.mjs | 13 +- .../truck-appointments.mjs | 207 ++++++++++++------ 2 files changed, 143 insertions(+), 77 deletions(-) diff --git a/scripts/admin-test-env-modules/truck-appointments-contract.test.mjs b/scripts/admin-test-env-modules/truck-appointments-contract.test.mjs index fe8d602ea..7b5ef104d 100644 --- a/scripts/admin-test-env-modules/truck-appointments-contract.test.mjs +++ b/scripts/admin-test-env-modules/truck-appointments-contract.test.mjs @@ -49,11 +49,16 @@ test('backend freezes released booking real container bill file and carrier rule } }) -test('HTTP regression covers no-customer response and submit idempotency matrix', () => { +test('HTTP regression is self-contained and covers no-customer and idempotency matrix', () => { const source = readFileSync(new URL('truck-appointments.mjs', import.meta.url), 'utf8') - for (const label of ['响应不含客户字段且冻结订舱柜号关系', '同键同载荷重放', '提交幂等重放零漂移', - '同键异载荷拒绝', '提单号与订舱不一致拒绝创建']) assert.match(source, new RegExp(label)) - assert.doesNotMatch(source, /customerId\s*:|customerName\s*:|独立拖车/) + for (const label of ['自包含创建 DRAFT 订舱', '上传当前有效 BILL_OF_LADING 文件', '放舱自包含订舱', + '绑定本轮真实 ISO 柜号', '拖车请求不含客户字段', '同柜第二条非终态预约拒绝且零漂移', + '同键同载荷重放', '同键重放版本零漂移', '同键异载荷拒绝', '错误订舱拒绝创建', + '错误柜号归属拒绝创建', '错误提单拒绝创建', '错误承运商拒绝创建']) assert.match(source, new RegExp(label)) + assert.doesNotMatch(source, /phase3ContainerLoading|phase3Loading/) + const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8') + assert.match(entry, /\['truck-appointments', \['merchant-qualification-fixture'\]\]/) + assert.doesNotMatch(entry, /\['truck-appointments', \[[^\]]*phase3-loading/) }) test('schema owns platform attachments and one active appointment per real container', () => { diff --git a/scripts/admin-test-env-modules/truck-appointments.mjs b/scripts/admin-test-env-modules/truck-appointments.mjs index 5b8a47660..49d7d8368 100644 --- a/scripts/admin-test-env-modules/truck-appointments.mjs +++ b/scripts/admin-test-env-modules/truck-appointments.mjs @@ -1,113 +1,174 @@ +import { ensureMatsonCarrierPartner } from './carrier-partner-fixture.mjs' +import { oceanBookingDraft } from './ocean-booking-fixture.mjs' import { truckAppointmentFieldGaps } from './fulfillment-list-field-contract.mjs' const MODULE = 'truck-appointments' const OPTIONS = { scenario: 'truck-appointment-lifecycle', deploymentBlocker: true } -const BASE = '/api/platform/v1/truck-appointments' +const TRUCKS = '/api/platform/v1/truck-appointments' +const BOOKINGS = '/api/platform/v1/ocean-bookings' +const CONTAINERS = '/api/platform/v1/containers' function id(value) { return value == null ? '' : String(value) } function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) } -function failure(response) { return response?.http === 200 && codeOf(response) !== '200' } +function data(response) { return codeOf(response) === '200' ? response.body.data : null } +function failure(response) { return response?.http === 0 || response?.http >= 400 || (response?.http === 200 && codeOf(response) !== '200') } +function records(value) { return Array.isArray(value) ? value : value?.records || [] } async function request(context, method, path, body, headers = {}) { try { const response = await fetch(`${context.options.gateway}${path}`, { - method, - headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}`, + method, headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}`, ...(body === undefined ? {} : { 'Content-Type': 'application/json; charset=utf-8' }), ...headers }, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(60_000), }) - const raw = await response.text(); let payload = null - try { payload = raw ? JSON.parse(raw) : null } catch { /* bounded raw evidence */ } - return { method, path, http: response.status, body: payload, raw: payload === null ? raw.slice(0, 500) : null, - requestId: payload?.requestId || response.headers.get('x-request-id'), traceId: payload?.traceId || response.headers.get('x-trace-id') } + const raw = await response.text(); let parsed = null + try { parsed = raw ? JSON.parse(raw) : null } catch { /* bounded raw evidence */ } + return { method, path, http: response.status, body: parsed, raw: parsed == null ? raw.slice(0, 500) : null, + requestId: parsed?.requestId || response.headers.get('x-request-id'), traceId: parsed?.traceId || response.headers.get('x-trace-id') } } catch (error) { return { method, path, http: 0, body: null, raw: String(error), requestId: null, traceId: null } } } export function inspectOceanBookingIdentity(marker, resource, detail = null) { - if (!resource?.id) return { identity: null, reason: '本轮 oceanBooking.id 缺失' } - if (resource.marker !== marker) return { identity: null, reason: `oceanBooking marker 不属于本轮:expected=${marker}, actual=${resource.marker || 'missing'}` } - const booking = detail?.oceanBooking || null - if (booking && id(booking.id) !== id(resource.id)) return { identity: null, reason: '订舱详情资源归属不一致' } - const identity = { oceanBookingId: id(resource.id), warehouseId: id(resource.warehouseId || booking?.warehouseId) } - const missingFields = Object.entries(identity).filter(([, value]) => !value).map(([key]) => key) - return missingFields.length ? { identity: null, reason: `本轮订舱身份字段缺失:${missingFields.join(',')}`, missingFields } - : { identity, reason: null, missingFields: [] } + if (!resource?.id || resource.marker !== marker) return { identity: null, reason: '订舱不属于本轮' } + const booking = detail?.oceanBooking || resource + if (id(booking.id) !== id(resource.id)) return { identity: null, reason: '订舱详情资源归属不一致' } + return { identity: { oceanBookingId: id(resource.id), warehouseId: id(booking.warehouseId) }, reason: null, missingFields: [] } } export function truckAppointmentPayload(context, suffix, identity, version = 0, overrides = {}) { - const supplier = context.createdResources?.truckSupplier || {} - return { - outboundOrderId: null, oceanBookingId: identity.oceanBookingId, - containerBookingId: identity.containerBookingId, loadingPlanId: identity.loadingPlanId || null, - warehouseId: identity.warehouseId || null, carrierId: id(supplier.supplierId) || null, - carrierName: supplier.supplierName || null, billOfLadingNo: identity.billOfLadingNo || null, - fleetContact: '车队联系人', fleetPhone: '13800000004', driverName: '司机验收', driverPhone: '13800000005', - vehicleNo: `粤B-${suffix.slice(-6)}`, storeName: `装柜点-${suffix}`, storeContact: '仓库联系人', - storePhone: '13800000006', storeAddress: '深圳市南山区验收地址', storeCity: '深圳', storeZipcode: '518000', + const supplier = identity.truckSupplier || context.createdResources?.truckSupplier || {} + return { outboundOrderId: null, oceanBookingId: identity.oceanBookingId, + containerBookingId: identity.containerBookingId, loadingPlanId: null, warehouseId: identity.warehouseId, + carrierId: id(supplier.supplierId), carrierName: supplier.supplierName, + billOfLadingNo: identity.billOfLadingNo, fleetContact: '车队联系人', fleetPhone: '13800000004', + driverName: '司机验收', driverPhone: '13800000005', vehicleNo: `粤B-${suffix.slice(-6)}`, + storeName: `装柜点-${suffix}`, storeContact: '仓库联系人', storePhone: '13800000006', + storeAddress: '深圳市南山区验收地址', storeCity: '深圳', storeZipcode: '518000', packageCount: 0, grossWeightKg: 0, volumeCbm: 0, palletCount: 0, poNo: `PO-${suffix}`, plannedLoadingAt: '2026-12-31T10:00:00', transitMinutes: 0, payableAmount: 0, receivableAmount: 0, - currency: 'RMB', remark: `拖车平台业务-${suffix}`, expectedVersion: version, ...overrides, - } + currency: 'RMB', remark: `拖车平台业务-${suffix}`, expectedVersion: version, ...overrides } } -function currentIdentity(context) { - const fixture = [context.createdResources?.phase3ContainerLoading, context.createdResources?.phase3Loading] - .find((item) => item?.marker === context.marker && id(item.oceanBookingId) && id(item.containerBookingId)) - if (!fixture) return null - return { oceanBookingId: id(fixture.oceanBookingId), containerBookingId: id(fixture.containerBookingId), - loadingPlanId: id(fixture.loadingPlanId) || null, warehouseId: id(fixture.warehouseId) || null, - billOfLadingNo: fixture.billOfLadingNo || null } +async function activeSupplier(context, category, suffix) { + const list = await request(context, 'GET', `/api/platform/v1/suppliers?tenantId=0&category=${category}&status=ACTIVE&page=1&size=100`) + const existing = records(data(list)).find((item) => item?.category === category && item?.status === 'ACTIVE') + if (existing) return { supplierId: id(existing.id), supplierName: existing.name } + const created = await request(context, 'POST', '/api/platform/v1/suppliers?tenantId=0', { + name: `${category}-验收-${suffix}`, category, carrierCodes: category === 'BOOKING' ? ['MATSON'] : [], + region: '深圳', contactName: '验收联系人', phone: '13800000000', status: 'ACTIVE', remark: context.marker, + }) + const value = data(created); const supplierId = id(value?.id || value) + context.apiSuccess(MODULE, `创建 ACTIVE ${category} 供应商`, created, '', OPTIONS) + return supplierId ? { supplierId, supplierName: value?.name || `${category}-验收-${suffix}` } : null +} + +async function bookingMaster(context, suffix) { + const tenantId = id(context.createdResources?.merchantQualificationFixture?.tenantId) + const customerName = String(context.options?.customerName || `全量验收下单客户-${suffix}`).trim() + const warehousesResponse = await request(context, 'GET', '/api/platform/v1/warehouse-admin?tenantId=0&status=ACTIVE&page=1&size=100') + for (const item of records(data(warehousesResponse))) { + const warehouseId = id(item?.id || item?.warehouse?.id) + if (!warehouseId) continue + const channelsResponse = await request(context, 'GET', `${BOOKINGS}/channels?originWarehouseId=${warehouseId}`) + const channel = (data(channelsResponse) || [])[0] + if (channel?.channelCode) return { customerId: tenantId, customerName, warehouseId, + channelCode: channel.channelCode, channelName: channel.channelName } + } + context.addStep(MODULE, '解析具备有效海运渠道的平台仓库', warehousesResponse, false, + `tenantId=${tenantId || 'missing'}`, OPTIONS) + return null +} + +function isoContainerNo(marker) { + const values = {}; let value = 10 + for (let code = 65; code <= 90; code++) { while (value % 11 === 0) value++; values[String.fromCharCode(code)] = value++ } + const serial = String([...marker].reduce((sum, char) => (sum * 31 + char.charCodeAt(0)) >>> 0, 0) % 1_000_000).padStart(6, '0') + const base = `TSTU${serial}`; let sum = 0 + ;[...base].forEach((char, index) => { sum += (values[char] ?? Number(char)) * (2 ** index) }) + return `${base}${(sum % 11) % 10}` +} + +async function fixture(context) { + const suffix = context.marker.replace(/[^A-Za-z0-9]/g, '').slice(-10) || 'TRUCK' + const master = await bookingMaster(context, suffix); if (!master?.customerId) return null + const bookingAgent = await activeSupplier(context, 'BOOKING', suffix) + const truckSupplier = await activeSupplier(context, 'TRUCKING', suffix) + const carrier = await ensureMatsonCarrierPartner(context, MODULE, OPTIONS) + if (!bookingAgent || !truckSupplier || !carrier?.id) return null + const draft = oceanBookingDraft(context, suffix, master, { + bookingAgentId: bookingAgent.supplierId, bookingAgentName: bookingAgent.supplierName, + shippingCompanyId: id(carrier.id), shippingCompanyName: carrier.name, carrierCode: 'MATSON', + containerRequirements: [{ containerType: '40HC', containerKind: 'FCL', quantity: 2, capacityCbm: 76, maxWeightKg: 28000 }], + }) + const createdResponse = await request(context, 'POST', BOOKINGS, { expectedVersion: 0, data: draft }, + { 'Idempotency-Key': `${context.marker}-truck-booking-create`.slice(0, 128) }) + const created = context.apiSuccess(MODULE, '自包含创建 DRAFT 订舱', createdResponse, '', OPTIONS) + const bookingId = id(created?.oceanBooking?.id); if (!bookingId) return null + const form = new FormData(); form.append('expectedVersion', String(created.oceanBooking.version)) + form.append('documentType', 'BILL_OF_LADING') + form.append('file', new Blob([`${context.marker}-BILL_OF_LADING`], { type: 'application/pdf' }), `bill-${suffix}.pdf`) + const uploadResponse = await context.requestMultipart('POST', `${BOOKINGS}/${bookingId}/documents/upload`, form, + context.token, { 'Idempotency-Key': `${context.marker}-truck-booking-file`.slice(0, 128) }) + const uploaded = context.apiSuccess(MODULE, '上传当前有效 BILL_OF_LADING 文件', uploadResponse, '', OPTIONS) + const releaseResponse = await request(context, 'POST', `${BOOKINGS}/${bookingId}/release`, + { expectedVersion: Number(uploaded?.bookingVersion), reason: context.marker }, + { 'Idempotency-Key': `${context.marker}-truck-booking-release`.slice(0, 128) }) + const released = context.apiSuccess(MODULE, '放舱自包含订舱', releaseResponse, '', OPTIONS) + const requirement = released?.containerRequirements?.[0]; if (released?.oceanBooking?.status !== 'RELEASED' || !requirement?.id) return null + const simulationResponse = await request(context, 'POST', CONTAINERS, { channelCode: master.channelCode, + warehouseId: master.warehouseId, containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000 }) + const simulation = context.apiSuccess(MODULE, '创建自包含模拟柜', simulationResponse, '', OPTIONS) + const containerNo = isoContainerNo(context.marker); if (!simulation?.id) return null + const linkResponse = await request(context, 'POST', `${CONTAINERS}/${simulation.id}/ocean-booking-link`, { + oceanBookingId: bookingId, requirementId: id(requirement.id), containerNo, expectedVersion: Number(simulation.version), + }) + const linked = context.apiSuccess(MODULE, '绑定本轮真实 ISO 柜号', linkResponse, '', OPTIONS) + if (linked?.containerNo !== containerNo) return null + return { suffix, oceanBookingId: bookingId, containerBookingId: id(linked.id), warehouseId: master.warehouseId, + billOfLadingNo: released.oceanBooking.billOfLadingNo, truckSupplier, bookingVersion: released.oceanBooking.version } } export async function run(context) { - const identity = currentIdentity(context) - const supplier = context.createdResources?.truckSupplier - context.assert(MODULE, '本轮拖车前置仅依赖已放舱订舱、真实柜和拖车供应商', null, - Boolean(identity?.oceanBookingId && identity?.containerBookingId && supplier?.supplierId), - `oceanBookingId=${identity?.oceanBookingId || 'missing'}, containerBookingId=${identity?.containerBookingId || 'missing'}, carrierId=${id(supplier?.supplierId) || 'missing'}`, OPTIONS) - if (!identity?.oceanBookingId || !identity?.containerBookingId || !supplier?.supplierId) return - - const suffix = context.marker.replace(/[^A-Za-z0-9]/g, '').slice(-10) - const base = truckAppointmentPayload(context, suffix, identity) + const identity = await fixture(context) + context.assert(MODULE, '拖车模块自包含前置链完整', null, Boolean(identity?.oceanBookingId && identity?.containerBookingId), + `oceanBookingId=${identity?.oceanBookingId || 'missing'}, containerBookingId=${identity?.containerBookingId || 'missing'}`, OPTIONS) + if (!identity) return + context.createdResources.truckSupplier = identity.truckSupplier + const base = truckAppointmentPayload(context, identity.suffix, identity) + context.assert(MODULE, '拖车请求不含客户字段', null, + !Object.hasOwn(base, 'customerId') && !Object.hasOwn(base, 'customerName'), Object.keys(base).join(','), OPTIONS) for (const [label, payload] of [ - ['缺少 oceanBookingId 拒绝创建', { ...base, oceanBookingId: null }], - ['缺少 containerBookingId 拒绝创建', { ...base, containerBookingId: null }], - ['缺少拖车供应商拒绝创建', { ...base, carrierId: null }], - ['提单号与订舱不一致拒绝创建', { ...base, billOfLadingNo: `WRONG-${suffix}` }], + ['错误订舱拒绝创建', { ...base, oceanBookingId: (BigInt(identity.oceanBookingId) + 1n).toString() }], + ['错误柜号归属拒绝创建', { ...base, containerBookingId: (BigInt(identity.containerBookingId) + 1n).toString() }], + ['错误提单拒绝创建', { ...base, billOfLadingNo: `WRONG-${identity.suffix}` }], + ['错误承运商拒绝创建', { ...base, carrierId: (BigInt(identity.truckSupplier.supplierId) + 1n).toString() }], ]) { - const response = await request(context, 'POST', BASE, payload) - context.addStep(MODULE, label, response, failure(response), `HTTP=${response.http}, code=${codeOf(response)}`, OPTIONS) + const response = await request(context, 'POST', TRUCKS, payload) + context.addStep(MODULE, label, response, failure(response), `code=${codeOf(response)}`, OPTIONS) } - - const createdResponse = await request(context, 'POST', BASE, base) - const appointment = context.apiSuccess(MODULE, '创建订舱真实柜拖车预约', createdResponse, '', OPTIONS) - const appointmentId = id(appointment?.id) - context.assert(MODULE, '响应不含客户字段且冻结订舱柜号关系', createdResponse, - Boolean(appointmentId) && appointment?.status === 'DRAFT' && !Object.hasOwn(appointment, 'customerId') - && !Object.hasOwn(appointment, 'customerName') && id(appointment?.oceanBookingId) === identity.oceanBookingId - && id(appointment?.containerBookingId) === identity.containerBookingId, - `appointmentId=${appointmentId || 'missing'}`, OPTIONS) - if (!appointmentId) return - context.createdResources.truckAppointment = { id: appointmentId, marker: context.marker, finalStatus: 'DISPATCHED' } - const path = `${BASE}/${appointmentId}` - let version = Number(appointment.version) - const submitKey = `${context.marker}-submit` - const submitBody = { expectedVersion: version, reason: '并发幂等提交' } + const createdResponse = await request(context, 'POST', TRUCKS, base) + const appointment = context.apiSuccess(MODULE, '创建合法拖车预约', createdResponse, '', OPTIONS) + const appointmentId = id(appointment?.id); if (!appointmentId) return + context.assert(MODULE, '拖车响应不含客户字段', createdResponse, + !Object.hasOwn(appointment, 'customerId') && !Object.hasOwn(appointment, 'customerName'), '', OPTIONS) + const duplicate = await request(context, 'POST', TRUCKS, base) + context.addStep(MODULE, '同柜第二条非终态预约拒绝且零漂移', duplicate, failure(duplicate), `code=${codeOf(duplicate)}`, OPTIONS) + const path = `${TRUCKS}/${appointmentId}`; const submitKey = `${context.marker}-truck-submit`.slice(0, 128) + const submitBody = { expectedVersion: Number(appointment.version), reason: '幂等提交' } const first = await request(context, 'POST', `${path}/submit`, submitBody, { 'Idempotency-Key': submitKey }) const firstData = context.apiSuccess(MODULE, '提交拖车预约', first, '', OPTIONS) const replay = await request(context, 'POST', `${path}/submit`, submitBody, { 'Idempotency-Key': submitKey }) const replayData = context.apiSuccess(MODULE, '同键同载荷重放', replay, '', OPTIONS) - context.assert(MODULE, '提交幂等重放零漂移', replay, - id(firstData?.id) === id(replayData?.id) && Number(firstData?.version) === Number(replayData?.version), - `firstVersion=${firstData?.version}, replayVersion=${replayData?.version}`, OPTIONS) - version = Number(firstData?.version) - const conflict = await request(context, 'POST', `${path}/submit`, { expectedVersion: version, reason: '异载荷' }, - { 'Idempotency-Key': submitKey }) + context.assert(MODULE, '同键重放版本零漂移', replay, Number(firstData?.version) === Number(replayData?.version), '', OPTIONS) + const conflict = await request(context, 'POST', `${path}/submit`, { ...submitBody, reason: '异载荷' }, { 'Idempotency-Key': submitKey }) context.addStep(MODULE, '同键异载荷拒绝', conflict, failure(conflict), `code=${codeOf(conflict)}`, OPTIONS) - const dispatch = await request(context, 'POST', `${path}/dispatch`, { expectedVersion: version, reason: '派车' }, - { 'Idempotency-Key': `${context.marker}-dispatch` }) + const dispatch = await request(context, 'POST', `${path}/dispatch`, + { expectedVersion: Number(firstData?.version), reason: '派车' }, + { 'Idempotency-Key': `${context.marker}-truck-dispatch`.slice(0, 128) }) const dispatched = context.apiSuccess(MODULE, '有效提单后派车', dispatch, '', OPTIONS) - context.assert(MODULE, '派车状态与稳定字段完整', dispatch, + context.assert(MODULE, '派车状态及稳定字段完整', dispatch, dispatched?.status === 'DISPATCHED' && truckAppointmentFieldGaps(dispatched).length === 0, `status=${dispatched?.status || 'missing'}`, OPTIONS) + context.createdResources.truckAppointment = { id: appointmentId, marker: context.marker, finalStatus: 'DISPATCHED', + oceanBookingId: identity.oceanBookingId, containerBookingId: identity.containerBookingId } } From 3d6216910f584208612432d01d426451ec72f7c5 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 1 Sep 2026 10:17:03 +0800 Subject: [PATCH 02/37] test(finance): align full acceptance with current workflows --- .../finance-downloads-contract.test.mjs | 1 + .../finance-downloads.mjs | 5 +- ...ance-reconciliation-full-contract.test.mjs | 18 ++++- .../finance-reconciliation-full.mjs | 11 +++ .../phase3-container-loading.mjs | 79 +++++++++++-------- ...se3-container-regression-contract.test.mjs | 4 + 6 files changed, 80 insertions(+), 38 deletions(-) diff --git a/scripts/admin-test-env-modules/finance-downloads-contract.test.mjs b/scripts/admin-test-env-modules/finance-downloads-contract.test.mjs index ee34f1406..cfdb8717c 100644 --- a/scripts/admin-test-env-modules/finance-downloads-contract.test.mjs +++ b/scripts/admin-test-env-modules/finance-downloads-contract.test.mjs @@ -37,6 +37,7 @@ test('验收只使用本轮 marker fixture,解析真实 XLSX 并覆盖门禁 assert.match(source, /无 Finance export 权限拒绝创建/) assert.match(source, /Provider 失败信息可见/) assert.match(source, /9223372036854775806/) + assert.match(source, /find\(\(item\) => item\?\.direction === 'RECEIVABLE'\)/) assert.doesNotMatch(source, /exportType:\s*['"]CUSTOMER_LIST/) assert.doesNotMatch(source, /FINANCE_FEE_SOURCE_LIST:[^\n]*参与结算/) }) diff --git a/scripts/admin-test-env-modules/finance-downloads.mjs b/scripts/admin-test-env-modules/finance-downloads.mjs index 9a1bc590c..7de156508 100644 --- a/scripts/admin-test-env-modules/finance-downloads.mjs +++ b/scripts/admin-test-env-modules/finance-downloads.mjs @@ -233,7 +233,10 @@ export async function run(context) { const ar = await official(context, `/api/platform/finance/v1/settlements/${encodeURIComponent(arId)}`, '读取应收结算导出事实') const ap = await official(context, `/api/platform/finance/v1/settlements/${encodeURIComponent(apId)}`, '读取应付结算导出事实') const fee = await official(context, `/api/platform/finance/v1/fee-pool?customerId=${encodeURIComponent(customerId)}&direction=ALL`, '读取费用来源导出事实') - const feeRow = values(fee.data)[0] + // PAYABLE rows are removed from the payable pool once an AP settlement owns + // them. Use a receivable row so the export filter still has an authoritative + // post-settlement match instead of asserting against an intentionally consumed row. + const feeRow = values(fee.data).find((item) => item?.direction === 'RECEIVABLE') || values(fee.data)[0] const statementNo = statement.data?.statementNo const arSummary = ar.data?.summary || ar.data const apSummary = ap.data?.summary || ap.data diff --git a/scripts/admin-test-env-modules/finance-reconciliation-full-contract.test.mjs b/scripts/admin-test-env-modules/finance-reconciliation-full-contract.test.mjs index 6ea490102..5d887de7a 100644 --- a/scripts/admin-test-env-modules/finance-reconciliation-full-contract.test.mjs +++ b/scripts/admin-test-env-modules/finance-reconciliation-full-contract.test.mjs @@ -16,6 +16,7 @@ test('Finance AP consumes only published fin_fee_source rows and never substitut }) const financePermissions = [ 'platform:finance:customer-account:list', 'platform:finance:customer-arap:list', + 'platform:finance:fee-source:edit', 'platform:finance:fee-source:review', 'platform:finance:customer-statement:list', 'platform:finance:reconciliation:create', 'platform:finance:reconciliation:review', 'platform:finance:reconciliation:issue', 'platform:finance:reconciliation:adjust', @@ -61,6 +62,7 @@ function fakeContext(withFixture = true, permissions = financePermissions, denyF const calls = [] const steps = [] const coverageGaps = [] + const submittedSourceIds = new Set() const reviewedSourceIds = new Set() function responseForStatement(statement) { return structuredClone(statement) } @@ -124,14 +126,24 @@ function fakeContext(withFixture = true, permissions = financePermissions, denyF const feeSourceDetailMatch = requestPath.match(/\/fee-sources\/(\d+)$/) if (method === 'GET' && feeSourceDetailMatch) { const localId = feeSourceDetailMatch[1] - return ok({ feeSourceId: localId, reviewStatus: reviewedSourceIds.has(localId) ? 'APPROVED' : 'PENDING', - sourceStatus: 'AVAILABLE', statementGenerated: false, operationVersion: reviewedSourceIds.has(localId) ? 1 : 0 }) + return ok({ feeSourceId: localId, reviewStatus: reviewedSourceIds.has(localId) ? 'APPROVED' + : submittedSourceIds.has(localId) ? 'PENDING' : 'DRAFT', + sourceStatus: 'AVAILABLE', statementGenerated: false, + operationVersion: reviewedSourceIds.has(localId) ? 2 : submittedSourceIds.has(localId) ? 1 : 0 }) + } + const feeSourceSubmitMatch = requestPath.match(/\/fee-sources\/(\d+)\/submit$/) + if (method === 'POST' && feeSourceSubmitMatch) { + submittedSourceIds.add(feeSourceSubmitMatch[1]) + const submitted = { feeSourceId: feeSourceSubmitMatch[1], reviewStatus: 'PENDING', + sourceStatus: 'AVAILABLE', statementGenerated: false, operationVersion: 1 } + if (idem) idempotency.set(idem, submitted) + return ok(submitted) } const feeSourceReviewMatch = requestPath.match(/\/fee-sources\/(\d+)\/review$/) if (method === 'POST' && feeSourceReviewMatch) { reviewedSourceIds.add(feeSourceReviewMatch[1]) const reviewed = { feeSourceId: feeSourceReviewMatch[1], reviewStatus: 'APPROVED', - sourceStatus: 'AVAILABLE', statementGenerated: false, operationVersion: 1 } + sourceStatus: 'AVAILABLE', statementGenerated: false, operationVersion: 2 } if (idem) idempotency.set(idem, reviewed) return ok(reviewed) } diff --git a/scripts/admin-test-env-modules/finance-reconciliation-full.mjs b/scripts/admin-test-env-modules/finance-reconciliation-full.mjs index 767136478..709324f85 100644 --- a/scripts/admin-test-env-modules/finance-reconciliation-full.mjs +++ b/scripts/admin-test-env-modules/finance-reconciliation-full.mjs @@ -8,6 +8,7 @@ const LEGACY_STATEMENT_VIEW_PERMISSION = 'platform:finance:bill:view' const REQUIRED_PERMISSIONS = [ 'platform:finance:customer-account:list', 'platform:finance:customer-arap:list', + 'platform:finance:fee-source:edit', 'platform:finance:fee-source:review', STATEMENT_LIST_PERMISSION, 'platform:finance:reconciliation:create', @@ -172,6 +173,16 @@ async function approveFixtureSources(context, fixture) { let detail = context.apiSuccess(MODULE, '回读本轮 Finance 费用来源审核状态', detailResponse, `feeSourceId=${source.feeSourceId}`, OPTIONS) if (!detail) return false + if (detail.reviewStatus === 'DRAFT' || detail.reviewStatus === 'REJECTED') { + const submitResponse = await write(context, 'POST', `${BASE}/fee-sources/${encodeURIComponent(source.feeSourceId)}/submit`, { + reason: context.marker, expectedVersion: Number(detail.operationVersion), + }, `fee-source-submit-${source.feeSourceId}`) + detail = context.apiSuccess(MODULE, '提交本轮 Finance 费用来源审核', submitResponse, + `feeSourceId=${source.feeSourceId}`, OPTIONS) + context.assert(MODULE, '费用来源提交后进入待审核', submitResponse, + detail?.reviewStatus === 'PENDING' && detail?.sourceStatus === 'AVAILABLE', + `feeSourceId=${source.feeSourceId}, reviewStatus=${detail?.reviewStatus ?? 'missing'}, sourceStatus=${detail?.sourceStatus ?? 'missing'}`, OPTIONS) + } if (detail.reviewStatus === 'PENDING') { const reviewResponse = await write(context, 'POST', `${BASE}/fee-sources/${encodeURIComponent(source.feeSourceId)}/review`, { action: 'APPROVE', reason: context.marker, expectedVersion: Number(detail.operationVersion), diff --git a/scripts/admin-test-env-modules/phase3-container-loading.mjs b/scripts/admin-test-env-modules/phase3-container-loading.mjs index e44d6cfcb..17a498f6f 100644 --- a/scripts/admin-test-env-modules/phase3-container-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-container-loading.mjs @@ -22,6 +22,21 @@ function failure(response) { } function records(page) { return Array.isArray(page?.records) ? page.records : [] } +async function waitForCompletedTask(context, taskId, warehouseId, attempts = 45) { + let response = null + let task = null + for (let attempt = 1; attempt <= attempts; attempt += 1) { + response = await context.request('GET', + `${PDA}/tasks/${taskId}?warehouseId=${encodeURIComponent(warehouseId)}`) + task = response?.http === 200 && codeOf(response) === '200' ? response.body?.data : null + if (task?.taskStatus === 'COMPLETED' && task?.workflowStatus === 'COMPLETED') { + return { response, task, attempts: attempt } + } + if (attempt < attempts) await new Promise((resolve) => setTimeout(resolve, 1_000)) + } + return { response, task, attempts } +} + async function request(context, method, path, body, headers = {}) { try { const response = await fetch(`${context.options.gateway}${path}`, { @@ -43,15 +58,21 @@ async function request(context, method, path, body, headers = {}) { } } -async function uploadLoadingEvidence(context, tenantId, inboundId, evidenceType) { - const path = `/api/platform/v1/files/loading-evidence/${tenantId}/${inboundId}?evidenceType=${encodeURIComponent(evidenceType)}` +async function uploadLoadingEvidence(context, taskId, warehouseId, evidenceType, sealNo) { + const path = `${PDA}/tasks/${taskId}/evidence` const form = new FormData() const png = Uint8Array.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,6,0,0,0,31,21,196,137,0,0,0,13,73,68,65,84,8,215,99,248,207,192,240,31,0,5,0,1,255,137,153,61,29,0,0,0,0,73,69,78,68,174,66,96,130]) + form.append('warehouseId', String(warehouseId)) + form.append('expectedVersion', '0') + form.append('evidenceType', evidenceType) + if (evidenceType === 'SEAL') form.append('sealNo', sealNo) + form.append('remark', context.marker) form.append('file', new Blob([png], { type: 'image/png' }), `${context.marker}-${evidenceType.toLowerCase()}.png`) try { const response = await fetch(`${context.options.gateway}${path}`, { method: 'POST', - headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}` }, + headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}`, + 'Idempotency-Key': `${context.marker}-cab-evidence-${evidenceType}`.slice(0, 128) }, body: form, signal: AbortSignal.timeout(60_000), }) @@ -66,14 +87,6 @@ async function uploadLoadingEvidence(context, tenantId, inboundId, evidenceType) } } -async function bindLoadingEvidence(context, inboundId, tenantId, evidenceType, fileId, sealNo) { - return request(context, 'POST', - `/api/platform/v1/inbounds/${inboundId}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`, - { expectedVersion: 0, evidenceType, fileId, fileName: 'client-must-not-override.png', - sealNo: evidenceType === 'SEAL' ? sealNo : null, remark: context.marker }, - { 'Idempotency-Key': `${context.marker}-cab-evidence-${evidenceType}`.slice(0, 128) }) -} - export function selectContainerLoadingFixture(context) { const candidates = [ context.createdResources?.orchestrationFixtures?.phase3Container, @@ -721,30 +734,22 @@ export async function run(context) { const evidenceTypes = ['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL'] const sealNo = `SEAL-${suffix}`.slice(0, 64) for (const evidenceType of evidenceTypes) { - const uploadResponse = await uploadLoadingEvidence(context, tenantId, inboundId, evidenceType) - const uploaded = context.apiSuccess(MODULE, `上传柜次 ${evidenceType} 真实证据文件`, uploadResponse, + const uploadResponse = await uploadLoadingEvidence(context, taskId, warehouseId, evidenceType, sealNo) + const bound = context.apiSuccess(MODULE, `上传并绑定柜次 ${evidenceType} 真实证据文件`, uploadResponse, responseEvidence(uploadResponse), OPTIONS) - const fileId = id(uploaded?.fileId) - context.assert(MODULE, `${evidenceType} 文件类型、归属和二进制元数据完整`, uploadResponse, - Boolean(fileId) && uploaded?.businessType === `LOADING_${evidenceType}` - && uploaded?.contentType === 'image/png' && Number(uploaded?.sizeBytes) > 0, - `fileId=${fileId || 'missing'}, businessType=${uploaded?.businessType || 'missing'}, size=${uploaded?.sizeBytes ?? 'missing'}`, OPTIONS) - if (!fileId) return - - const bindResponse = await bindLoadingEvidence(context, inboundId, tenantId, evidenceType, fileId, sealNo) - const bound = context.apiSuccess(MODULE, `绑定柜次 ${evidenceType} 装柜证据`, bindResponse, - responseEvidence(bindResponse), OPTIONS) - const boundItem = bound?.evidences?.evidences?.find?.((item) => - item.evidenceType === evidenceType && id(item.fileId) === fileId && item.status !== 'VOID') - context.assert(MODULE, `${evidenceType} 有效证据归属稳定且锁码门禁完整`, bindResponse, - bound?.mainStatus === 'LOADING' && Boolean(boundItem?.id) + const boundItem = bound?.evidences?.find?.((item) => item.evidenceType === evidenceType && item.status !== 'VOID') + const evidenceId = id(boundItem?.evidenceId) + const fileId = id(boundItem?.fileId) + context.assert(MODULE, `${evidenceType} 有效证据归属稳定且锁码门禁完整`, uploadResponse, + id(bound?.taskId) === taskId && id(bound?.containerBookingId) === containerId + && Boolean(evidenceId) && Boolean(fileId) && boundItem?.remark === context.marker && (evidenceType !== 'SEAL' || boundItem?.sealNo === sealNo), - `mainStatus=${bound?.mainStatus || 'missing'}, evidenceId=${id(boundItem?.id) || 'missing'}, sealNo=${boundItem?.sealNo || 'n/a'}`, OPTIONS) - if (!boundItem?.id) return + `taskId=${id(bound?.taskId) || 'missing'}, containerBookingId=${id(bound?.containerBookingId) || 'missing'}, evidenceId=${evidenceId || 'missing'}, sealNo=${boundItem?.sealNo || 'n/a'}`, OPTIONS) + if (!evidenceId) return const downloadResponse = await context.requestBinary('GET', - `/api/platform/v1/files/loading-evidence/${tenantId}/${inboundId}/${fileId}`) + `${PDA}/tasks/${taskId}/evidence/${encodeURIComponent(evidenceId)}/content?warehouseId=${encodeURIComponent(warehouseId)}`) context.assert(MODULE, `下载柜次 ${evidenceType} 证据为非空受控二进制`, downloadResponse, downloadResponse.http === 200 && downloadResponse.bytes.length > 0 && downloadResponse.contentType.toLowerCase().includes('image/png'), @@ -752,7 +757,7 @@ export async function run(context) { } const evidenceListResponse = await context.request('GET', - `/api/platform/v1/inbounds/${inboundId}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`) + `${PDA}/tasks/${taskId}/evidence?warehouseId=${encodeURIComponent(warehouseId)}`) const evidenceList = context.apiSuccess(MODULE, 'PDA 完成前回查五类有效装柜证据与锁码', evidenceListResponse, '', OPTIONS) const activeEvidence = Array.isArray(evidenceList?.evidences) @@ -771,13 +776,19 @@ export async function run(context) { remark: context.marker, } const completeResponse = await request(context, 'POST', completePath, completePayload) - const completed = context.apiSuccess(MODULE, 'PDA 人工确认装柜完成', completeResponse, '', OPTIONS) + const acceptedCompletion = context.apiSuccess(MODULE, 'PDA 提交人工确认装柜完成', completeResponse, '', OPTIONS) + context.assert(MODULE, '装柜完成命令已接受并进入处理或终态', completeResponse, + ['COMPLETING', 'COMPLETED'].includes(acceptedCompletion?.taskStatus), + `taskStatus=${acceptedCompletion?.taskStatus || 'missing'}, workflow=${acceptedCompletion?.workflowStatus || 'missing'}`, OPTIONS) + const completion = await waitForCompletedTask(context, taskId, warehouseId) + const completed = context.apiSuccess(MODULE, '等待 PDA 装柜完成异步收敛', completion.response, + `attempts=${completion.attempts}`, OPTIONS) const directPdaCompletion = completed?.taskStatus === 'COMPLETED' && completed?.workflowStatus === 'COMPLETED' - context.assert(MODULE, '人工确认后 PDA 任务进入 COMPLETED', completeResponse, + context.assert(MODULE, '人工确认后 PDA 任务进入 COMPLETED', completion.response, directPdaCompletion && completed?.items?.every?.((item) => Number(item.pickedPackageCount) === Number(item.plannedPackageCount)), - `taskStatus=${completed?.taskStatus || 'missing'}, workflow=${completed?.workflowStatus || 'missing'}`, OPTIONS) + `taskStatus=${completed?.taskStatus || 'missing'}, workflow=${completed?.workflowStatus || 'missing'}, attempts=${completion.attempts}`, OPTIONS) const operationsResponse = await request(context, 'GET', `${PDA}/tasks/${taskId}/operations`) const operations = context.apiSuccess(MODULE, '回查完成后的 PDA 操作审计', operationsResponse, '', OPTIONS) diff --git a/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs b/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs index ec8408736..743ca33d8 100644 --- a/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs @@ -97,7 +97,11 @@ test('PDA task opens only after no-number push, release, four-field link and pla assert.match(containerLoadingAcceptance, /订舱放舱为 RELEASED 且不预生成真实货柜/) assert.match(containerLoadingAcceptance, /订舱 RELEASED 但预装未 CONFIRMED 不生成 PDA 任务/) assert.match(containerLoadingAcceptance, /loading-plans\/\$\{planId\}\/confirm/) + assert.match(containerLoadingAcceptance, /PDA}\/tasks\/\$\{taskId\}\/evidence/) + assert.match(containerLoadingAcceptance, /evidence\/\$\{encodeURIComponent\(evidenceId\)\}\/content/) assert.match(containerLoadingAcceptance, /计划级原子确认整份预装并触发唯一 PDA 任务/) + assert.match(containerLoadingAcceptance, /waitForCompletedTask/) + assert.match(containerLoadingAcceptance, /taskStatus === 'COMPLETED' && task\?\.workflowStatus === 'COMPLETED'/) assert.match(containerLoadingAcceptance, /confirmationStatus === 'CONFIRMED'/) assert.match(containerLoadingAcceptance, /readiness\?\.readyForPda === true/) assert.match(containerLoadingAcceptance, /taskId === confirmedTaskId/) From cdcf3e0f4ff61dad10e7835605929cdeb9318892 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 1 Sep 2026 10:17:41 +0800 Subject: [PATCH 03/37] test(firstleg): parse platform warehouse scope explicitly --- .../truck-appointments-contract.test.mjs | 11 ++++++++++- scripts/admin-test-env-modules/truck-appointments.mjs | 8 ++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/scripts/admin-test-env-modules/truck-appointments-contract.test.mjs b/scripts/admin-test-env-modules/truck-appointments-contract.test.mjs index 7b5ef104d..718d26d41 100644 --- a/scripts/admin-test-env-modules/truck-appointments-contract.test.mjs +++ b/scripts/admin-test-env-modules/truck-appointments-contract.test.mjs @@ -2,7 +2,16 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' -import { inspectOceanBookingIdentity, truckAppointmentPayload } from './truck-appointments.mjs' +import { inspectOceanBookingIdentity, platformWarehouseIdentity, truckAppointmentPayload } from './truck-appointments.mjs' + +test('platform warehouse scope is fixed to zero without requiring response tenantId', () => { + assert.deepEqual(platformWarehouseIdentity({ warehouseId: '41', warehouseName: '宁波仓' }), + { warehouseId: '41', platformTenantId: '0' }) + assert.deepEqual(platformWarehouseIdentity({ id: 42, warehouseName: '义乌仓' }), + { warehouseId: '42', platformTenantId: '0' }) + const source = readFileSync(new URL('truck-appointments.mjs', import.meta.url), 'utf8') + assert.doesNotMatch(source, /item\?\.tenantId|item\.tenantId/) +}) test('booking identity is marker scoped and contains no customer snapshot', () => { const result = inspectOceanBookingIdentity('RUN-2', { id: '91', marker: 'RUN-2', warehouseId: '41' }) diff --git a/scripts/admin-test-env-modules/truck-appointments.mjs b/scripts/admin-test-env-modules/truck-appointments.mjs index 49d7d8368..900ac23a6 100644 --- a/scripts/admin-test-env-modules/truck-appointments.mjs +++ b/scripts/admin-test-env-modules/truck-appointments.mjs @@ -14,6 +14,10 @@ function data(response) { return codeOf(response) === '200' ? response.body.data function failure(response) { return response?.http === 0 || response?.http >= 400 || (response?.http === 200 && codeOf(response) !== '200') } function records(value) { return Array.isArray(value) ? value : value?.records || [] } +export function platformWarehouseIdentity(item) { + return { warehouseId: id(item?.warehouseId || item?.id || item?.warehouse?.id), platformTenantId: '0' } +} + async function request(context, method, path, body, headers = {}) { try { const response = await fetch(`${context.options.gateway}${path}`, { @@ -67,7 +71,7 @@ async function bookingMaster(context, suffix) { const customerName = String(context.options?.customerName || `全量验收下单客户-${suffix}`).trim() const warehousesResponse = await request(context, 'GET', '/api/platform/v1/warehouse-admin?tenantId=0&status=ACTIVE&page=1&size=100') for (const item of records(data(warehousesResponse))) { - const warehouseId = id(item?.id || item?.warehouse?.id) + const { warehouseId } = platformWarehouseIdentity(item) if (!warehouseId) continue const channelsResponse = await request(context, 'GET', `${BOOKINGS}/channels?originWarehouseId=${warehouseId}`) const channel = (data(channelsResponse) || [])[0] @@ -75,7 +79,7 @@ async function bookingMaster(context, suffix) { channelCode: channel.channelCode, channelName: channel.channelName } } context.addStep(MODULE, '解析具备有效海运渠道的平台仓库', warehousesResponse, false, - `tenantId=${tenantId || 'missing'}`, OPTIONS) + `platformTenantId=0, customerId=${tenantId || 'missing'}`, OPTIONS) return null } From 2c3efad70a2a00a04be13c315d0df9a2b7b76d13 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 1 Sep 2026 10:18:19 +0800 Subject: [PATCH 04/37] test: bound A0 location isolation writes --- scripts/admin-test-env-acceptance.mjs | 115 ++++++++++++++---- .../phase3-a0-isolation-contract.test.mjs | 45 +++++++ .../phase3-producer-chain-contract.test.mjs | 14 ++- 3 files changed, 146 insertions(+), 28 deletions(-) create mode 100644 scripts/admin-test-env-modules/phase3-a0-isolation-contract.test.mjs diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 9476688fd..3739f0d36 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -13,6 +13,33 @@ const OPTIONAL_MODULES = ['manual-order-splitting-fixture', 'manual-order-splitt 'manual-loading-complete-fixture', 'manual-loading-complete-verify', 'coload-groups'] const AVAILABLE_MODULES = new Set([...CONFIGURED_MODULES, ...OPTIONAL_MODULES]) +const A0_LOCATION_PAGE_SIZE = 200 +const A0_MAX_LOCATION_MUTATIONS = 8 + +export function planA0LocationIsolation(locations, requiredSlots = 1, + maxMutations = A0_MAX_LOCATION_MUTATIONS) { + const slots = Math.max(1, Number.isInteger(Number(requiredSlots)) ? Number(requiredSlots) : 1) + const limit = Math.max(0, Number.isInteger(Number(maxMutations)) ? Number(maxMutations) : 0) + const unique = new Map() + for (const location of Array.isArray(locations) ? locations : []) { + const locationId = location?.id == null ? '' : String(location.id) + if (!locationId || location?.sharedOverflow === true || unique.has(locationId)) continue + unique.set(locationId, location) + } + const normal = [...unique.values()].sort((left, right) => + String(left?.locationCode || '').localeCompare(String(right?.locationCode || '')) + || String(left.id).localeCompare(String(right.id))) + const remainingNormal = Math.max(0, slots - 1) + const mutationsRequired = Math.max(0, normal.length - remainingNormal) + return { + enabled: mutationsRequired <= limit, + requiredSlots: slots, + normalCount: normal.length, + expectedRemainingNormal: Math.min(normal.length, remainingNormal), + mutationsRequired, + targets: mutationsRequired <= limit ? normal.slice(0, mutationsRequired) : [], + } +} const MODULE_DEPENDENCIES = new Map([ ['suppliers', ['merchant-qualification-fixture']], @@ -1236,15 +1263,17 @@ export class AcceptanceRun { this.assert('phase3-loading-fixture', 'A0 隔离使用本轮订单收货仓', null, Boolean(warehouseId), `warehouseId=${warehouseId || 'missing'}`, { scenario: 'phase3-loading-a0-fixture', deploymentBlocker: true }) - const disabledLocations = requireSharedOverflow - ? await this.disableAvailableLocationsForA0(warehouseId) : [] - this.context.phase3LoadingRequireSharedOverflow = requireSharedOverflow + const requiredPalletCount = Math.max(1, Number(this.context?.waitSplitVariant?.palletCount || 1)) + const isolation = requireSharedOverflow + ? await this.disableAvailableLocationsForA0(warehouseId, requiredPalletCount) + : { forced: false, disabled: [] } + this.context.phase3LoadingRequireSharedOverflow = isolation.forced === true try { const measurement = await import(new URL('./admin-test-env-modules/inbound-measurement.mjs', import.meta.url)) await this.executeModule('inbound-measurement-phase3-loading-fixture', () => measurement.run(this)) } finally { if (requireSharedOverflow) { - await this.restoreAvailableLocationsAfterA0(warehouseId, disabledLocations) + await this.restoreAvailableLocationsAfterA0(warehouseId, isolation) } } const fixture = this.createdResources.inboundMeasurement || this.createdResources.orders @@ -1430,24 +1459,60 @@ export class AcceptanceRun { { scenario: 'phase3-phase4-producer-fixture', deploymentBlocker: true }) } - async disableAvailableLocationsForA0(warehouseId) { + async listAvailableLocationsForA0(warehouseId, module, options) { + const locations = [] + const seen = new Set() + let expectedTotal = null + for (let pageNumber = 1; pageNumber <= 100; pageNumber += 1) { + const response = await this.request('GET', + `/api/platform/v1/locations?tenantId=0&page=${pageNumber}&size=${A0_LOCATION_PAGE_SIZE}&warehouseId=${encodeURIComponent(warehouseId)}&status=AVAILABLE`) + const page = this.apiSuccess(module, `查询 A0 隔离前可用库位第 ${pageNumber} 页`, response, + `page=${pageNumber}`, options) + if (!page) return null + const pageTotal = Number(page.total) + const records = Array.isArray(page.records) ? page.records : [] + if (!Number.isInteger(pageTotal) || pageTotal < 0 + || (expectedTotal !== null && pageTotal !== expectedTotal)) { + this.assert(module, 'A0 隔离库位分页总数稳定', response, false, + `page=${pageNumber}, firstTotal=${expectedTotal ?? 'missing'}, currentTotal=${page.total ?? 'missing'}`, options) + return null + } + expectedTotal ??= pageTotal + for (const location of records) { + const locationId = location?.id == null ? '' : String(location.id) + if (locationId && !seen.has(locationId)) { + seen.add(locationId) + locations.push(location) + } + } + if (locations.length >= expectedTotal) break + if (records.length === 0) break + } + const complete = locations.length === expectedTotal + this.assert(module, 'A0 隔离库位分页完整且无重复', null, complete, + `total=${expectedTotal ?? 'missing'}, uniqueRecords=${locations.length}`, options) + return complete ? locations : null + } + + async disableAvailableLocationsForA0(warehouseId, requiredSlots = 1) { const module = 'phase3-loading-fixture' const options = { scenario: 'phase3-loading-a0-fixture', deploymentBlocker: true } if (!warehouseId) { this.assert(module, 'A0 隔离收货仓 ID 完整', null, false, 'warehouseId=missing', options) - return [] + return { forced: false, disabled: [] } + } + const locations = await this.listAvailableLocationsForA0(warehouseId, module, options) + if (!locations) return { forced: false, disabled: [] } + const isolation = planA0LocationIsolation(locations, requiredSlots) + if (!isolation.enabled) { + this.addStep(module, 'A0 大仓保护:不批量停用普通库位', null, true, + `normal=${isolation.normalCount}, requiredSlots=${isolation.requiredSlots}, mutationsRequired=${isolation.mutationsRequired}, safetyLimit=${A0_MAX_LOCATION_MUTATIONS}`, + { scenario: options.scenario }) + return { forced: false, disabled: [], isolation } } - const pageResponse = await this.request('GET', - `/api/platform/v1/locations?tenantId=0&page=1&size=200&warehouseId=${encodeURIComponent(warehouseId)}&status=AVAILABLE`) - const page = this.apiSuccess(module, '查询 A0 隔离前全部可用普通库位', pageResponse, '', options) - const records = Array.isArray(page?.records) ? page.records : [] - const locations = records.filter((item) => item.sharedOverflow !== true) - this.assert(module, 'A0 隔离库位分页完整', pageResponse, - Number(page?.total) === records.length && records.length < 200, - `total=${page?.total ?? 'missing'}, records=${records.length}, normal=${locations.length}`, options) const disabled = [] const skippedOccupied = [] - for (const location of locations) { + for (const location of isolation.targets) { const response = await this.request('PATCH', `/api/platform/v1/locations/${encodeURIComponent(location.id)}/status?tenantId=0&warehouseId=${encodeURIComponent(warehouseId)}`, { status: 'DISABLED', version: Number(location.version) }) @@ -1462,16 +1527,19 @@ export class AcceptanceRun { } const availableResponse = await this.request('GET', `/api/platform/v1/locations/available?tenantId=0&warehouseId=${encodeURIComponent(warehouseId)}&limit=200`) - const available = this.apiSuccess(module, '停用后回查收货仓无可用普通库位', availableResponse, '', options) - this.assert(module, '测量前 A0 溢出条件成立', availableResponse, - Array.isArray(available) && available.length === 0, - `disabled=${disabled.length}, skippedOccupied=${skippedOccupied.map((item) => `${item.code}:${item.id}`).join('|') || 'none'}, available=${Array.isArray(available) ? available.length : 'missing'}`, options) - return disabled + const available = this.apiSuccess(module, '停用后回查收货仓剩余普通库位', availableResponse, '', options) + const remainingNormal = Array.isArray(available) + ? available.filter((item) => item?.sharedOverflow !== true) : null + const forced = Array.isArray(remainingNormal) && remainingNormal.length < isolation.requiredSlots + this.assert(module, '测量前 A0 溢出条件成立', availableResponse, forced, + `requiredSlots=${isolation.requiredSlots}, disabled=${disabled.length}, skippedOccupied=${skippedOccupied.map((item) => `${item.code}:${item.id}`).join('|') || 'none'}, remainingNormal=${Array.isArray(remainingNormal) ? remainingNormal.length : 'missing'}`, options) + return { forced, disabled, isolation } } - async restoreAvailableLocationsAfterA0(warehouseId, disabledLocations) { + async restoreAvailableLocationsAfterA0(warehouseId, result) { const module = 'phase3-loading-fixture' const options = { scenario: 'phase3-loading-a0-fixture', deploymentBlocker: true } + const disabledLocations = Array.isArray(result?.disabled) ? result.disabled : [] for (const location of disabledLocations) { const response = await this.request('PATCH', `/api/platform/v1/locations/${encodeURIComponent(location.id)}/status?tenantId=0&warehouseId=${encodeURIComponent(warehouseId)}`, @@ -1481,7 +1549,10 @@ export class AcceptanceRun { `locationId=${location.id}, restoreVersion=${location.version}`, options) } this.createdResources.phase3LoadingA0Isolation = { - warehouseId, restored: disabledLocations.map((item) => item.id), marker: this.marker, + warehouseId, forced: result?.forced === true, + normalCount: result?.isolation?.normalCount ?? null, + mutationsRequired: result?.isolation?.mutationsRequired ?? 0, + restored: disabledLocations.map((item) => item.id), marker: this.marker, } } diff --git a/scripts/admin-test-env-modules/phase3-a0-isolation-contract.test.mjs b/scripts/admin-test-env-modules/phase3-a0-isolation-contract.test.mjs new file mode 100644 index 000000000..3b624b32f --- /dev/null +++ b/scripts/admin-test-env-modules/phase3-a0-isolation-contract.test.mjs @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { planA0LocationIsolation } from '../admin-test-env-acceptance.mjs' + +function locations(count) { + return Array.from({ length: count }, (_, index) => ({ + id: String(index + 1), locationCode: `L-${String(index + 1).padStart(4, '0')}`, + sharedOverflow: false, version: 0, + })) +} + +test('large warehouse is never selected for default mass disable', () => { + const plan = planA0LocationIsolation(locations(614), 1) + assert.equal(plan.enabled, false) + assert.equal(plan.mutationsRequired, 614) + assert.deepEqual(plan.targets, []) +}) + +test('minimum mutations leave fewer normal locations than required pallets', () => { + const plan = planA0LocationIsolation(locations(3), 3) + assert.equal(plan.enabled, true) + assert.equal(plan.mutationsRequired, 1) + assert.equal(plan.targets.length, 1) + assert.equal(plan.expectedRemainingNormal, 2) + assert.ok(plan.expectedRemainingNormal < plan.requiredSlots) +}) + +test('natural shortage proves A0 fallback without any location mutation', () => { + const plan = planA0LocationIsolation(locations(1), 2) + assert.equal(plan.enabled, true) + assert.equal(plan.mutationsRequired, 0) + assert.deepEqual(plan.targets, []) + assert.ok(plan.normalCount < plan.requiredSlots) +}) + +test('shared A0 and duplicate rows never inflate the normal location plan', () => { + const normal = locations(2) + const plan = planA0LocationIsolation([ + ...normal, normal[0], { id: 'A0', locationCode: 'A0', sharedOverflow: true, version: 0 }, + ], 2) + assert.equal(plan.normalCount, 2) + assert.equal(plan.mutationsRequired, 1) + assert.deepEqual(plan.targets.map((item) => item.id), ['1']) +}) diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs index b1c923ebc..c36677567 100644 --- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs @@ -511,20 +511,22 @@ test('phase3 loading resolves legal SKIP and PARTIAL exceptions before downstrea assert.doesNotMatch(loading, /if \(Number\(dynamicCandidate\?\.availablePackageCount\) < 2\) return/) }) -test('phase3 loading fixture forces A0 through reversible public location status APIs', () => { +test('phase3 loading fixture paginates A0 candidates and refuses mass location mutations', () => { const entry = source('../admin-test-env-acceptance.mjs') - const measurement = source('./inbound-measurement.mjs') const orders = source('./orders.mjs') assertOrder(entry, [ - 'disableAvailableLocationsForA0(warehouseId)', - 'this.context.phase3LoadingRequireSharedOverflow = true', + 'disableAvailableLocationsForA0(warehouseId, requiredPalletCount)', + 'this.context.phase3LoadingRequireSharedOverflow = isolation.forced === true', "executeModule('inbound-measurement-phase3-loading-fixture'", - 'restoreAvailableLocationsAfterA0(warehouseId, disabledLocations)', + 'restoreAvailableLocationsAfterA0(warehouseId, isolation)', ]) + assert.match(entry, /page=\$\{pageNumber\}&size=\$\{A0_LOCATION_PAGE_SIZE\}/) + assert.match(entry, /A0_MAX_LOCATION_MUTATIONS = 8/) + assert.match(entry, /A0 大仓保护:不批量停用普通库位/) + assert.match(entry, /remainingNormal\.length < isolation\.requiredSlots/) assert.match(entry, /\/locations\/\$\{encodeURIComponent\(location\.id\)\}\/status/) assert.match(entry, /\{ status: 'DISABLED', version: Number\(location\.version\) \}/) assert.match(entry, /\{ status: 'AVAILABLE', version: location\.version \}/) - assert.match(measurement, /phase3LoadingRequireSharedOverflow !== true/) assert.match(orders, /receivingWarehouseId: id\(master\.receiving\.id\)/) }) From 3d7990250f0b175bf636d7baf0bf4c2bf2bffe1f Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 1 Sep 2026 10:19:35 +0800 Subject: [PATCH 05/37] fix(finance): align operating fund export labels --- .../service/FinanceExportProviderService.java | 7 ++++--- .../FinanceExportProviderServiceTest.java | 18 ++++++++++++++++++ .../finance-downloads.mjs | 2 +- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceExportProviderService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceExportProviderService.java index 49895f9d6..ce9af6f84 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceExportProviderService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceExportProviderService.java @@ -59,14 +59,15 @@ public class FinanceExportProviderService { RECEIVABLE_STATISTICS); private static final Set SETTLEMENT_STATUSES = Set.of("ISSUED", "PARTIALLY_PAID", "PAID", "VOIDED"); private static final Set LEDGER_TYPES = Set.of("FEE_SOURCE_EDIT", "FEE_SOURCE_SUBMIT", "FEE_SOURCE_REVIEW_APPROVE", "FEE_SOURCE_REVIEW_REJECT", "FEE_SOURCE_ADJUST", "FEE_SOURCE_VOID", "RECONCILIATION_CREATED", - "SETTLEMENT_ISSUED", "RECEIPT", "PAYMENT", "ADJUSTMENT", "VOID"); + "SETTLEMENT_ISSUED", "RECEIPT", "PAYMENT", "SUPPLIER_DIRECT_PAID", + "SUPPLIER_ON_ACCOUNT_PAYMENT", "ADJUSTMENT", "VOID"); private static final List ACCOUNT_COLUMNS = List.of( column("customerCode", "客户编码", ExportColumnType.STRING, 18), column("customerName", "客户", ExportColumnType.STRING, 28), column("contactName", "联系人", ExportColumnType.STRING, 16), column("contactPhone", "联系电话", ExportColumnType.STRING, 18), - column("outstandingReceivable", "待收(CNY)", ExportColumnType.DECIMAL, 16), column("pendingBilling", "待开账(CNY)", ExportColumnType.DECIMAL, 16), + column("outstandingReceivable", "待收客户款(CNY)", ExportColumnType.DECIMAL, 18), column("pendingBilling", "待开账(CNY)", ExportColumnType.DECIMAL, 16), column("receivedInPeriod", "本期已收(CNY)", ExportColumnType.DECIMAL, 16), column("totalCost", "总成本(CNY)", ExportColumnType.DECIMAL, 16), - column("paidCost", "已付成本(CNY)", ExportColumnType.DECIMAL, 16), column("outstandingPayable", "待付(CNY)", ExportColumnType.DECIMAL, 16), + column("paidCost", "已线下支付供应商款(CNY)", ExportColumnType.DECIMAL, 24), column("outstandingPayable", "待付供应商款(CNY)", ExportColumnType.DECIMAL, 18), column("status", "客户状态", ExportColumnType.STRING, 14), column("lastChangedAt", "最近变动", ExportColumnType.DATETIME, 21)); private static final List LEDGER_COLUMNS = List.of( column("ledgerId", "流水号", ExportColumnType.STRING, 22), column("occurredAt", "发生时间", ExportColumnType.DATETIME, 21), diff --git a/finance/src/test/java/com/baiyue/finance/service/FinanceExportProviderServiceTest.java b/finance/src/test/java/com/baiyue/finance/service/FinanceExportProviderServiceTest.java index 6ead12ece..12976c3d2 100644 --- a/finance/src/test/java/com/baiyue/finance/service/FinanceExportProviderServiceTest.java +++ b/finance/src/test/java/com/baiyue/finance/service/FinanceExportProviderServiceTest.java @@ -112,6 +112,24 @@ class FinanceExportProviderServiceTest { verify(operationService).listDerivedCustomerAccountLedger(10001L, 1, 200, null, null, null); } + @Test + void accountExportUsesOperatingFundNamesAndAcceptsSupplierPaymentLedgerFilters() { + when(accountService.list(1, 100, null, null, 10001L)) + .thenReturn(PageEnvelope.success(List.of(), 1, 100, 0)); + var account = service.exportBatch(FinanceExportProviderService.CUSTOMER_ACCOUNT_LIST, + request(Map.of("customerId", "10001"), null, null)); + assertThat(account.columns()).extracting(column -> column.title()).contains( + "待收客户款(CNY)", "待付供应商款(CNY)", "已线下支付供应商款(CNY)"); + + for (String businessType : List.of("SUPPLIER_DIRECT_PAID", "SUPPLIER_ON_ACCOUNT_PAYMENT")) { + when(operationService.listDerivedCustomerAccountLedger(10001L, 1, 100, businessType, null, null)) + .thenReturn(PageEnvelope.success(List.of(), 1, 100, 0)); + service.exportBatch(FinanceExportProviderService.CUSTOMER_ACCOUNT_LEDGER, + request(Map.of("customerId", "10001", "businessType", businessType), null, null)); + verify(operationService).listDerivedCustomerAccountLedger(10001L, 1, 100, businessType, null, null); + } + } + @Test void exportsTheSameMonthlyCnyCustomerStatisticsWithoutExcludedPrototypeFields() { when(receivableStatisticsService.exportRows(YearMonth.of(2026, 7), 10001L)).thenReturn(List.of( diff --git a/scripts/admin-test-env-modules/finance-downloads.mjs b/scripts/admin-test-env-modules/finance-downloads.mjs index 7de156508..93ea11a74 100644 --- a/scripts/admin-test-env-modules/finance-downloads.mjs +++ b/scripts/admin-test-env-modules/finance-downloads.mjs @@ -13,7 +13,7 @@ export const REQUIRED_PERMISSIONS = Object.freeze([ ]) export const EXPORT_SPECS = Object.freeze({ - FINANCE_CUSTOMER_ACCOUNT_LIST: ['客户编码', '客户', '联系人', '联系电话', '待收(CNY)', '待开账(CNY)', '本期已收(CNY)', '总成本(CNY)', '已付成本(CNY)', '待付(CNY)', '客户状态', '最近变动'], + FINANCE_CUSTOMER_ACCOUNT_LIST: ['客户编码', '客户', '联系人', '联系电话', '待收客户款(CNY)', '待开账(CNY)', '本期已收(CNY)', '总成本(CNY)', '已线下支付供应商款(CNY)', '待付供应商款(CNY)', '客户状态', '最近变动'], FINANCE_CUSTOMER_ACCOUNT_LEDGER: ['流水号', '发生时间', '业务类型', '关联单据', '应收变动(CNY)', '应付变动(CNY)', '变动后未收(CNY)', '操作人', '备注'], FINANCE_FEE_SOURCE_LIST: ['客户', '收付方向', '来源类型', '来源单据ID', '来源版本', '入仓号', '柜次号', '业务发生日', '费用项目', '计费量', '单价(CNY)', '金额(CNY)', '审核状态', '来源状态', '已生成对账单'], FINANCE_RECONCILIATION_STATEMENT_LIST: ['对账单号', '客户', '结算起始日', '结算结束日', '账期(天)', '货币', '总应收金额(CNY)', '已收金额(CNY)', '未收金额(CNY)', '是否结清', '审核状态', '备注', '创建人', '创建时间', '更新人', '更新时间'], From 0613d1095e09f06ce2469b8faa0e57ea4c0c0adb Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 1 Sep 2026 10:21:37 +0800 Subject: [PATCH 06/37] test(firstleg): resolve trucking fixtures from active catalogs --- .../truck-appointments-contract.test.mjs | 32 +++++++++++- .../truck-appointments.mjs | 50 ++++++++++++++----- 2 files changed, 68 insertions(+), 14 deletions(-) diff --git a/scripts/admin-test-env-modules/truck-appointments-contract.test.mjs b/scripts/admin-test-env-modules/truck-appointments-contract.test.mjs index 718d26d41..842bdbf67 100644 --- a/scripts/admin-test-env-modules/truck-appointments-contract.test.mjs +++ b/scripts/admin-test-env-modules/truck-appointments-contract.test.mjs @@ -2,7 +2,34 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' -import { inspectOceanBookingIdentity, platformWarehouseIdentity, truckAppointmentPayload } from './truck-appointments.mjs' +import { activeCustomerIdentity, inspectOceanBookingIdentity, platformWarehouseIdentity, truckAppointmentPayload } from './truck-appointments.mjs' + +test('booking prerequisite resolves an ACTIVE platform customer without fixture state', () => { + assert.deepEqual(activeCustomerIdentity({ id: '21', tenantName: '现有客户' }), + { customerId: '21', customerName: '现有客户' }) + assert.deepEqual(activeCustomerIdentity({ tenantId: 22, customerName: '备用客户' }), + { customerId: '22', customerName: '备用客户' }) + assert.equal(activeCustomerIdentity({ id: '23' }), null) + const source = readFileSync(new URL('truck-appointments.mjs', import.meta.url), 'utf8') + assert.match(source, /\/api\/platform\/v1\/iam\/tenants\?page=1&size=100&status=ACTIVE/) + assert.match(source, /fixtureCustomerId[\s\S]*\|\| candidates\[0\]/) +}) + +test('channel catalog accepts the formal array response shape', () => { + const source = readFileSync(new URL('truck-appointments.mjs', import.meta.url), 'utf8') + assert.match(source, /const channelRows = records\(data\(channelsResponse\)\); const channel = channelRows\[0\]/) + assert.doesNotMatch(source, /\(data\(channelsResponse\) \|\| \[\]\)\[0\]/) +}) + +test('ACTIVE platform warehouses are scanned through bounded complete pagination', () => { + const source = readFileSync(new URL('truck-appointments.mjs', import.meta.url), 'utf8') + assert.match(source, /for \(let page = 1; page <= 100; page\+\+\)/) + assert.match(source, /warehouse-admin\?tenantId=0&status=ACTIVE&page=\$\{page\}&size=100/) + assert.match(source, /const total = Number\(warehousePage\?\.total \?\? warehouseRows\.length\)/) + assert.match(source, /page \* 100 >= total/) + assert.match(source, /完整分页平台仓并解析有效海运渠道/) + assert.doesNotMatch(source, /193000000000200001|193000000000200002/) +}) test('platform warehouse scope is fixed to zero without requiring response tenantId', () => { assert.deepEqual(platformWarehouseIdentity({ warehouseId: '41', warehouseName: '宁波仓' }), @@ -10,7 +37,8 @@ test('platform warehouse scope is fixed to zero without requiring response tenan assert.deepEqual(platformWarehouseIdentity({ id: 42, warehouseName: '义乌仓' }), { warehouseId: '42', platformTenantId: '0' }) const source = readFileSync(new URL('truck-appointments.mjs', import.meta.url), 'utf8') - assert.doesNotMatch(source, /item\?\.tenantId|item\.tenantId/) + assert.match(source, /platformWarehouseIdentity\(item\)[\s\S]*platformTenantId: '0'/) + assert.doesNotMatch(source, /platformWarehouseIdentity\(item\)[^{]*\{[^}]*tenantId/) }) test('booking identity is marker scoped and contains no customer snapshot', () => { diff --git a/scripts/admin-test-env-modules/truck-appointments.mjs b/scripts/admin-test-env-modules/truck-appointments.mjs index 900ac23a6..526ced7fa 100644 --- a/scripts/admin-test-env-modules/truck-appointments.mjs +++ b/scripts/admin-test-env-modules/truck-appointments.mjs @@ -18,6 +18,12 @@ export function platformWarehouseIdentity(item) { return { warehouseId: id(item?.warehouseId || item?.id || item?.warehouse?.id), platformTenantId: '0' } } +export function activeCustomerIdentity(item) { + const customerId = id(item?.id || item?.tenantId) + const customerName = String(item?.tenantName || item?.customerName || item?.name || '').trim() + return customerId && customerName ? { customerId, customerName } : null +} + async function request(context, method, path, body, headers = {}) { try { const response = await fetch(`${context.options.gateway}${path}`, { @@ -67,19 +73,39 @@ async function activeSupplier(context, category, suffix) { } async function bookingMaster(context, suffix) { - const tenantId = id(context.createdResources?.merchantQualificationFixture?.tenantId) - const customerName = String(context.options?.customerName || `全量验收下单客户-${suffix}`).trim() - const warehousesResponse = await request(context, 'GET', '/api/platform/v1/warehouse-admin?tenantId=0&status=ACTIVE&page=1&size=100') - for (const item of records(data(warehousesResponse))) { - const { warehouseId } = platformWarehouseIdentity(item) - if (!warehouseId) continue - const channelsResponse = await request(context, 'GET', `${BOOKINGS}/channels?originWarehouseId=${warehouseId}`) - const channel = (data(channelsResponse) || [])[0] - if (channel?.channelCode) return { customerId: tenantId, customerName, warehouseId, - channelCode: channel.channelCode, channelName: channel.channelName } + const fixtureCustomerId = id(context.createdResources?.merchantQualificationFixture?.tenantId + || context.context?.customerId || context.customerId) + const customersResponse = await request(context, 'GET', '/api/platform/v1/iam/tenants?page=1&size=100&status=ACTIVE') + const candidates = records(data(customersResponse)) + const customer = activeCustomerIdentity(candidates.find((item) => + fixtureCustomerId && id(item?.id || item?.tenantId) === fixtureCustomerId) || candidates[0]) + if (!customer) { + context.addStep(MODULE, '通过正式平台客户查询解析 ACTIVE 订舱客户', customersResponse, false, + `preferredCustomerId=${fixtureCustomerId || 'missing'}`, OPTIONS) + return null } - context.addStep(MODULE, '解析具备有效海运渠道的平台仓库', warehousesResponse, false, - `platformTenantId=0, customerId=${tenantId || 'missing'}`, OPTIONS) + let lastWarehousesResponse = null + for (let page = 1; page <= 100; page++) { + const warehousesResponse = await request(context, 'GET', + `/api/platform/v1/warehouse-admin?tenantId=0&status=ACTIVE&page=${page}&size=100`) + lastWarehousesResponse = warehousesResponse + const warehousePage = data(warehousesResponse); const warehouseRows = records(warehousePage) + for (const item of warehouseRows) { + const { warehouseId } = platformWarehouseIdentity(item) + if (!warehouseId) continue + const channelsResponse = await request(context, 'GET', `${BOOKINGS}/channels?originWarehouseId=${warehouseId}`) + const channelRows = records(data(channelsResponse)); const channel = channelRows[0] + if (channel?.channelCode) { + context.addStep(MODULE, '完整分页平台仓并解析有效海运渠道', channelsResponse, true, + `warehousePage=${page}, warehouseRecords=${warehouseRows.length}, channelRecords=${channelRows.length}, warehouseId=${warehouseId}`, OPTIONS) + return { ...customer, warehouseId, channelCode: channel.channelCode, channelName: channel.channelName } + } + } + const total = Number(warehousePage?.total ?? warehouseRows.length) + if (warehouseRows.length === 0 || page * 100 >= total) break + } + context.addStep(MODULE, '解析具备有效海运渠道的平台仓库', lastWarehousesResponse, false, + `platformTenantId=0, customerId=${customer.customerId}`, OPTIONS) return null } From bfc2a4dbd4e94a7c97695d809832dead904ad0a7 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 1 Sep 2026 10:22:15 +0800 Subject: [PATCH 07/37] test(firstleg): scope trucking warehouse lookup --- .../truck-appointments-contract.test.mjs | 4 ++-- scripts/admin-test-env-modules/truck-appointments.mjs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/admin-test-env-modules/truck-appointments-contract.test.mjs b/scripts/admin-test-env-modules/truck-appointments-contract.test.mjs index 842bdbf67..b1de24c5f 100644 --- a/scripts/admin-test-env-modules/truck-appointments-contract.test.mjs +++ b/scripts/admin-test-env-modules/truck-appointments-contract.test.mjs @@ -21,10 +21,10 @@ test('channel catalog accepts the formal array response shape', () => { assert.doesNotMatch(source, /\(data\(channelsResponse\) \|\| \[\]\)\[0\]/) }) -test('ACTIVE platform warehouses are scanned through bounded complete pagination', () => { +test('ACTIVE domestic platform warehouses are scanned through bounded pagination', () => { const source = readFileSync(new URL('truck-appointments.mjs', import.meta.url), 'utf8') assert.match(source, /for \(let page = 1; page <= 100; page\+\+\)/) - assert.match(source, /warehouse-admin\?tenantId=0&status=ACTIVE&page=\$\{page\}&size=100/) + assert.match(source, /warehouse-admin\?tenantId=0&warehouseType=DOMESTIC&status=ACTIVE&page=\$\{page\}&size=100/) assert.match(source, /const total = Number\(warehousePage\?\.total \?\? warehouseRows\.length\)/) assert.match(source, /page \* 100 >= total/) assert.match(source, /完整分页平台仓并解析有效海运渠道/) diff --git a/scripts/admin-test-env-modules/truck-appointments.mjs b/scripts/admin-test-env-modules/truck-appointments.mjs index 526ced7fa..1c819a1f1 100644 --- a/scripts/admin-test-env-modules/truck-appointments.mjs +++ b/scripts/admin-test-env-modules/truck-appointments.mjs @@ -87,7 +87,7 @@ async function bookingMaster(context, suffix) { let lastWarehousesResponse = null for (let page = 1; page <= 100; page++) { const warehousesResponse = await request(context, 'GET', - `/api/platform/v1/warehouse-admin?tenantId=0&status=ACTIVE&page=${page}&size=100`) + `/api/platform/v1/warehouse-admin?tenantId=0&warehouseType=DOMESTIC&status=ACTIVE&page=${page}&size=100`) lastWarehousesResponse = warehousesResponse const warehousePage = data(warehousesResponse); const warehouseRows = records(warehousePage) for (const item of warehouseRows) { From 7d9eaa7f431e0ac3d4af30c18b4f7258988f55a9 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 1 Sep 2026 10:26:22 +0800 Subject: [PATCH 08/37] test(firstleg): verify trucking detail after dispatch --- .../truck-appointments-contract.test.mjs | 7 ++++++- .../truck-appointments.mjs | 16 +++++++++++++--- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/scripts/admin-test-env-modules/truck-appointments-contract.test.mjs b/scripts/admin-test-env-modules/truck-appointments-contract.test.mjs index b1de24c5f..3d291b9b2 100644 --- a/scripts/admin-test-env-modules/truck-appointments-contract.test.mjs +++ b/scripts/admin-test-env-modules/truck-appointments-contract.test.mjs @@ -91,7 +91,12 @@ test('HTTP regression is self-contained and covers no-customer and idempotency m for (const label of ['自包含创建 DRAFT 订舱', '上传当前有效 BILL_OF_LADING 文件', '放舱自包含订舱', '绑定本轮真实 ISO 柜号', '拖车请求不含客户字段', '同柜第二条非终态预约拒绝且零漂移', '同键同载荷重放', '同键重放版本零漂移', '同键异载荷拒绝', '错误订舱拒绝创建', - '错误柜号归属拒绝创建', '错误提单拒绝创建', '错误承运商拒绝创建']) assert.match(source, new RegExp(label)) + '错误柜号归属拒绝创建', '错误提单拒绝创建', '错误承运商拒绝创建', + '派车命令返回状态身份与版本正确', '派车后回查拖车完整详情', + '详情字段完整且订舱柜号身份无客户字段漂移']) assert.match(source, new RegExp(label)) + assert.match(source, /const detailResponse = await request\(context, 'GET', path\); const detail = data\(detailResponse\)/) + assert.match(source, /const detailGaps = truckAppointmentFieldGaps\(detail\)/) + assert.doesNotMatch(source, /truckAppointmentFieldGaps\(dispatched\)/) assert.doesNotMatch(source, /phase3ContainerLoading|phase3Loading/) const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8') assert.match(entry, /\['truck-appointments', \['merchant-qualification-fixture'\]\]/) diff --git a/scripts/admin-test-env-modules/truck-appointments.mjs b/scripts/admin-test-env-modules/truck-appointments.mjs index 1c819a1f1..74702a1e9 100644 --- a/scripts/admin-test-env-modules/truck-appointments.mjs +++ b/scripts/admin-test-env-modules/truck-appointments.mjs @@ -196,9 +196,19 @@ export async function run(context) { { expectedVersion: Number(firstData?.version), reason: '派车' }, { 'Idempotency-Key': `${context.marker}-truck-dispatch`.slice(0, 128) }) const dispatched = context.apiSuccess(MODULE, '有效提单后派车', dispatch, '', OPTIONS) - context.assert(MODULE, '派车状态及稳定字段完整', dispatch, - dispatched?.status === 'DISPATCHED' && truckAppointmentFieldGaps(dispatched).length === 0, - `status=${dispatched?.status || 'missing'}`, OPTIONS) + context.assert(MODULE, '派车命令返回状态身份与版本正确', dispatch, + dispatched?.status === 'DISPATCHED' && id(dispatched?.id) === appointmentId + && Number(dispatched?.version) > Number(firstData?.version), + `id=${id(dispatched?.id) || 'missing'}, status=${dispatched?.status || 'missing'}, version=${dispatched?.version ?? 'missing'}`, OPTIONS) + const detailResponse = await request(context, 'GET', path); const detail = data(detailResponse) + context.apiSuccess(MODULE, '派车后回查拖车完整详情', detailResponse, '', OPTIONS) + const detailGaps = truckAppointmentFieldGaps(detail) + context.assert(MODULE, '详情字段完整且订舱柜号身份无客户字段漂移', detailResponse, + detail?.status === 'DISPATCHED' && detailGaps.length === 0 + && !Object.hasOwn(detail || {}, 'customerId') && !Object.hasOwn(detail || {}, 'customerName') + && id(detail?.oceanBookingId) === identity.oceanBookingId + && id(detail?.containerBookingId) === identity.containerBookingId, + `missing=${detailGaps.join(',') || 'none'}, oceanBookingId=${id(detail?.oceanBookingId) || 'missing'}, containerBookingId=${id(detail?.containerBookingId) || 'missing'}`, OPTIONS) context.createdResources.truckAppointment = { id: appointmentId, marker: context.marker, finalStatus: 'DISPATCHED', oceanBookingId: identity.oceanBookingId, containerBookingId: identity.containerBookingId } } From 6a0f51f1363965ac4a96bdc83ff6958d78270a99 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 1 Sep 2026 10:29:14 +0800 Subject: [PATCH 09/37] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 +- file/AGENTS.md | 1 + firstleg/AGENTS.md | 7 ++++--- scripts/agents-index-contract.ps1 | 26 +++++++++++++++++++++++++- 4 files changed, 31 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e22d2aabc..397b49211 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,7 +29,7 @@ | 分柜、柜次、智能排柜、预装/装柜清单、PDA | `firstleg/` | `firstleg/AGENTS.md`,再按 `split/`、`loading/`、`outbound/` 定位;预装/正式装柜详情阶段由 `/loading-plans/{planId}/lifecycle` 按稳定 planId 解析,不从 404 或文案推断 | | 合柜报关 / 同柜不可拆组、跨客户合柜、整组分配/移除 | `firstleg/` | `loading/coload/controller/PlatformColoadGroupController.java`、`service/ColoadGroupService.java`、`persistence/ColoadGroupMapper.java`、`src/main/resources/openapi/coload-groups-v1.yaml`;按订单建组并冻结全部可用入仓片段,仅要求同国内仓、同渠道 | | 智能装柜入仓码候选与箱数分配 | `firstleg/` | `controller/PlatformSmartLoadingController.java` 的 `/inbound-candidates`、`/plans/{planId}/inbound-assignments`,业务编排 `service/SmartLoadingService.java` | -| 订舱、船期、拖车、报关 | `firstleg/` | `oceanbooking/`、`portschedule/`、`outbound/dispatch/`、`customs/` | +| 订舱资料/打印、船期、订舱拥有的拖车、报关 | `firstleg/` | `oceanbooking/`、`portschedule/`、`TruckAppointmentService.java`、`outbound/dispatch/`、`customs/`;拖车不带客户归属,必须绑定 `RELEASED` 订舱及其真实柜,订舱文件联查 `file/AGENTS.md` | | 客户账期、费用池、账单、收款、成本、核销 | `finance/` | `finance/AGENTS.md`;费用来源和柜次成本快照再联查 `firstleg/AGENTS.md`,凭证再联查 `file/AGENTS.md` | | 入库/柜次应付、直接已付、付款流水与凭证 | `firstleg/`、`finance/`、`file/` | 业务费用与付款快照见 `firstleg/AGENTS.md` 的 `FeePaymentSnapshotValidator`;`/api/platform/finance/v1/vouchers` 上传凭证,File 按 `FINANCE_VOUCHER` 和客户范围校验 | | 仓库、往来单位、快递、托盘、库位 | `warehouse/` | `warehouse/AGENTS.md` | diff --git a/file/AGENTS.md b/file/AGENTS.md index 56be4d5de..032d85031 100644 --- a/file/AGENTS.md +++ b/file/AGENTS.md @@ -18,6 +18,7 @@ | 财务付款凭证、海外卸柜照片 | 通用业务文件入口 `InternalBusinessFileController.java`、`InternalBusinessFileService.java`;业务类型分别为 `FINANCE_VOUCHER`、`OVERSEAS_UNLOAD_PHOTO`,必须按客户与业务 ID 精确校验 | | 报关源文件、完成文件与买单明细导出 | `src/main/resources/openapi/customs-file-and-export-v1.yaml`;文件类别 `LOADING_LIST_CUSTOMS_SOURCE`、`LOADING_LIST_CUSTOMS_RESULT` 均为客户作用域且最多 50MB,异步导出类型 `LOADING_LIST_BUY_DECLARATION_ITEMS` 由 Firstleg Provider 按固定十三列供数 | | 订舱文件 | 通用内部业务文件入口,契约 `src/main/resources/openapi/ocean-booking-file-v1.yaml`;类型固定 `OCEAN_BOOKING_DOCUMENT`、`tenantId=订舱customerId`、`businessId=oceanBookingId`,任意格式且最多 50MiB,不得绑定拖车;`BOOKING_CONFIRMATION/BILL_OF_LADING` 文档类型及有效状态由 Firstleg 保存 | +| 拖车预约附件 | 通用内部业务文件入口;固定 `tenantId=0`、`businessType=TRUCK_APPOINTMENT_ATTACHMENT`、`businessId=appointmentId`,只允许派车单、司机/车辆资料和其他平台附件;提单文件归订舱 `OCEAN_BOOKING_DOCUMENT`,不得复制为拖车附件 | | 存储与实体 | `service/LocalQualificationStorage.java`、`MeasurementImageStorage.java`、`entity/FileObjectEntity.java` | | 下游 OCR | `client/WeightOcrClient.java` | | SQL/配置 | `src/main/resources/db/file.sql`、`application.yml` | diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 3ceb946fd..69fd3d5bf 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -35,11 +35,11 @@ | PDA 拣货与装柜 | `controller/PlatformLoadingPdaController.java`、`loading/controller/PlatformLoadingDeliveryController.java` | | 装柜完成跨区事件 | `service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`repository/LoadingCompletedOutboxRepository.java`、`mapper/LoadingCartonManifestMapper.java`、`client/EventBridgeClient.java`;从 `fl_inbound_order.destination_type` 冻结 V2 `orders[].destinationType=FBA|OVERSEAS`,OVERSEAS 按最终实装与稳定箱码冻结,FBA 可仅冻结箱数;真实生产验收 `src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`;事件模型见 `../common-event-contract/AGENTS.md` | | 海外卸柜完成事件回写 | 内部入口 `controller/InternalOverseasContainerEventController.java`、消费者 `service/OverseasContainerEventConsumer.java`、Inbox `entity/ContainerEventInboxEntity.java` 与 `mapper/ContainerEventInboxMapper.java`;事件模型见 `../common-event-contract/AGENTS.md` | -| 三期海运订舱、关联候选与出仓前船期资料 | `oceanbooking/controller/PlatformOceanBookingController.java`、`oceanbooking/service/OceanBookingService.java`、`oceanbooking/mapper/OceanBookingMapper.java`、`portschedule/controller/PlatformPortScheduleController.java`;关联候选入口 `/api/platform/v1/ocean-bookings/link-candidates`,订舱契约见 `src/main/resources/openapi/ocean-bookings-v1.yaml`,表结构见 `src/main/resources/db/phase3-ocean-booking-schema.sql` | +| 三期海运订舱、资料/打印、关联候选与出仓前船期资料 | `oceanbooking/controller/PlatformOceanBookingController.java`、`oceanbooking/service/OceanBookingService.java`、`OceanBookingDocumentService.java`、`OceanBookingPrintService.java`、`oceanbooking/mapper/OceanBookingMapper.java`、`portschedule/controller/PlatformPortScheduleController.java`;文件经 `client/OceanBookingFileClient.java` 进入 File,关联候选 `/api/platform/v1/ocean-bookings/link-candidates`,契约 `src/main/resources/openapi/ocean-bookings-v1.yaml` | | 四期国内出仓后船节点定时查询 | `outbound/dispatch/service/OutboundDispatchService.java` 在确认出仓事务内幂等创建现有 `CarrierScheduleQuery`;编排与租约 `portschedule/service/CarrierScheduleQueryScheduler.java`,任务/十节点事实/异常事实/不可变 attempt `CarrierScheduleQueryService.java`,平台查询与人工补录入口 `PlatformCarrierScheduleQueryController.java` 的 `/{queryId}/manual-nodes`;停止只由实际 `EMPTY_RETURNED` 节点触发,不提供无节点停止入口;外部船司/轨迹调用经 `integration-service/`;标准见 `../../docs/四期/船节点标准化契约.md` | | 船节点跨区交接事件 | `portschedule/service/CarrierScheduleNodeOutboxService.java`;实际节点落库后发布 `FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED` V1,复用 `LoadingCompletedOutboxRepository`/Relay 投 Event Bridge;海外消费见 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` | | Firstleg 业务单据编号 | `service/FirstlegDocumentNumberGenerator.java`;当前支持 `OB`、`TA`、`CB`、`CAB`、`LP` 前缀,按北京时间每日独立编号;具体业务归属以各调用服务为准 | -| 拖车与整柜出仓调度 | `controller/PlatformTruckAppointmentController.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java`;按真实 `containerBookingId` 查询聚合和 `gate`、确认离仓,契约 `src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml` | +| 订舱拥有的拖车与整柜出仓调度 | `controller/PlatformTruckAppointmentController.java`、`service/TruckAppointmentService.java`、`repository/TruckAppointmentRepository.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java`;拖车契约 `src/main/resources/openapi/truck-appointments-v1.yaml`,出仓契约 `phase3-outbound-dispatch-v1.yaml`。预约无客户字段,必须绑定 `RELEASED` 订舱及其已分配真实 ISO 柜,同柜最多一条有效预约;提单号和仓库取订舱/柜次权威事实,附件固定平台作用域 | | 整柜出口报关 | `customs/container/controller/PlatformContainerCustomsController.java`、`customs/container/controller/MerchantContainerCustomsController.java`、`customs/container/service/ContainerCustomsService.java`、`customs/container/mapper/ContainerCustomsMapper.java`;装柜完成后按柜收集退税资料,多对多覆盖入仓码,开始报关冻结,完成文件逐资料一对一回传,全买单柜由平台确认并通过导出中心取明细;契约 `src/main/resources/openapi/customs-v1.yaml`,基线 `src/main/resources/db/phase3-container-customs-schema.sql`;消费者为 Admin `../../apps/admin-web/src/modules/container-loading/components/ContainerCustomsPanel.vue` 和 Client `../../apps/client-web/src/modules/first-leg/components/CustomsDocumentsDialog.vue` | | 买单订单明细导出 Provider | `customs/container/controller/LoadingListBuyDeclarationExportController.java`、`service/LoadingListBuyDeclarationExportService.java`;只为 File 导出中心提供 `LOADING_LIST_BUY_DECLARATION_ITEMS` 的已完成装柜实际买单行,不在 Firstleg 生成文件 | | 跨模型桥接 | `loading/bridge/controller/PlatformLoadingModelBridgeController.java` | @@ -61,9 +61,10 @@ - 订舱柜型需求至少一项、最多 20 项,数量必须为正整数,容量和限重必须匹配平台柜型规格。起运仓必须是启用的国内中转仓;物流渠道必须来自该仓当前 ACTIVE 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。 - 订舱号统一使用 `OB-yyMMdd-####`,按北京时间创建日每日独立递增且单日最多 9999;不得以实体 ID 拼接生成。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。 - 订舱列表 `containerCount` 固定为该订舱全部柜型需求 `quantity` 之和,与真实柜关联/解除无关;关联候选只返回 `RELEASED`、剩余数量大于 0 且 `warehouseId + channelCode + containerType` 精确匹配的需求,由服务端集合查询 ACTIVE 分配量。前端不得扇出详情计算或用已关联柜数覆盖该字段,最终关联命令仍须重验目标货柜可修改性。 +- 拖车预约是平台级订舱履约资源,不是客户资源:请求、实体、查询和附件不得恢复 `customerId/customerName`。创建或编辑必须选择 `RELEASED` 订舱及其已分配、有效且带真实 ISO 6346 柜号的柜次,订舱还须有有效提单号和至少一份 ACTIVE `BOOKING_CONFIRMATION` 或 `BILL_OF_LADING`;同一真实柜通过查询锁与唯一约束最多保留一条非取消预约。拖车附件使用 `tenantId=0 + TRUCK_APPOINTMENT_ATTACHMENT + appointmentId`,提单文件继续归订舱,不复制到拖车附件。 - 四期船节点任务从确认柜子离开国内仓库后启用,必须绑定真实订舱和柜次;十个标准节点、预计/实际时间、异常事实、供应商缺失节点和还柜停止规则统一以 `../../docs/四期/船节点标准化契约.md` 为准。`CarrierScheduleQuery` 的正常业务查询槽位固定为 `Asia/Shanghai` 每日 09:00、12:30、16:00;每分钟任务只扫描已到期 `next_allowed_at`,不是每分钟请求船司。成功、无变化、需人工或永久失败进入下一固定槽位;仅技术性 `RETRYABLE_FAILURE` 在当前槽内按15/30分钟退避,含首次最多3次且不得跨下一槽,实际 `EMPTY_RETURNED` 后停止。错过的正常槽位直接顺延,不在服务恢复时补跑;单轮按受控批次持续领取并受最大任务数保护。租约、调度尝试和内部停止入口不得暴露为平台页面 API,外部失败不得推进业务节点,不得在三期另建查询任务。 - 国内出口报关在装柜完成后启用,不再阻塞 `LOADED`。整柜 `fl_customs_container.status=COMPLETED` 才通过离仓报关门禁;完成后以系统身份、确定性幂等键立即尝试确认国内离仓,其他门禁未齐时由受控定时补偿重试,禁止部分入仓成功或重复创建离仓/船司查询事实。 -- 国内出仓确认以真实 `containerBookingId` 聚合全部入仓绑定并锁定校验;装柜证据按物理货柜只计算一次,拖车状态和独立船期只作参考。全部门禁在同一事务通过后推进所有技术行、记录一条可见历史并幂等创建 `CarrierScheduleQuery/DEPARTED_WAREHOUSE`,不允许单入仓局部成功。 +- 国内出仓确认以真实 `containerBookingId` 聚合全部入仓绑定并锁定校验;装柜证据按物理货柜只计算一次,PDA `taskId` 仅用于来源审计和任务授权,File 所有权仍固定 `tenantId=0 + containerBookingId`;拖车状态和独立船期只作参考。全部门禁在同一事务通过后推进所有技术行、记录一条可见历史并幂等创建 `CarrierScheduleQuery/DEPARTED_WAREHOUSE`,不允许单入仓局部成功。 - 手工船节点的预计时间只追加事实,不推进 `latestStandardNode`;携带 `actualAt` 时标准节点序号必须严格大于最新实际节点,重复、相同或回退节点返回冲突。已审核实际时间修正只能更新同一事实的时间/证据,不得制造第二条同节点实际事实或绕过单调推进。 - 海外卸柜完成事件只接受 `OVERSEAS -> CN` 的 `OVERSEAS_CONTAINER_UNLOADED` V1,并通过内部服务令牌进入。消费者必须校验信封、Payload 哈希、来源货柜和时间顺序,以 event ID + Payload 哈希幂等,使用事件聚合版本阻止乱序覆盖;预计报空时间固定按卸柜照片上传时间加两天计算,实际报空时间可为空。 - Firstleg 只拥有业务费用及履约事实;Finance 拥有费用入池、账期、账单、收款、成本分摊结果和财务状态。修改费用来源版本、发布条件、柜次归属或体积快照时同步检查 `../finance/src/main/java/com/baiyue/finance/client/FirstlegFeeSourceClient.java`、同目录 `FirstlegCostSnapshotClient.java` 及双方契约测试,不在 Firstleg 复制财务状态机。 diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index 89089c664..ee5878c2c 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -125,6 +125,22 @@ $orderAgents = Read-RequiredText (Join-Path $backendRoot 'order/AGENTS.md') Assert-Contains $orderAgents '`channelCode` 直接来自 Order/Firstleg 权威快照' 'Order 稳定渠道编码' Assert-Contains $orderAgents 'InboundCodeAssociationService.java' 'Order 入仓码业务关系入口' Assert-Contains $firstlegAgents '/loading-plans/{planId}/lifecycle' 'Firstleg 装柜生命周期入口' +$fileAgents = Read-RequiredText (Join-Path $backendRoot 'file/AGENTS.md') +$truckOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/openapi/truck-appointments-v1.yaml') +$pdaLoadingApi = Read-RequiredText (Join-Path $workspaceRoot 'apps/pda-uniapp/api/pda-loading.js') +Assert-Contains $rootAgents '拖车预约由已放舱订舱拥有,不按客户归属' '根订舱拥有拖车语义' +Assert-Contains $rootAgents '证据以 `taskId + warehouseId` 进入任务专用 API' '根柜级证据入口' +Assert-Contains $adminAgents '订舱拥有的拖车预约' 'Admin 拖车快速入口' +Assert-Contains $adminAgents '不选择或提交客户' 'Admin 拖车无客户语义' +Assert-Contains $backendAgents '订舱拥有的拖车' '后端拖车快速入口' +Assert-Contains $firstlegAgents '请求、实体、查询和附件不得恢复 `customerId/customerName`' 'Firstleg 拖车无客户边界' +Assert-Contains $firstlegAgents '同一真实柜通过查询锁与唯一约束最多保留一条非取消预约' 'Firstleg 拖车唯一有效预约' +Assert-Contains $fileAgents '`businessType=TRUCK_APPOINTMENT_ATTACHMENT`' 'File 拖车平台附件入口' +Assert-Contains $truckOpenApi 'required: [oceanBookingId, containerBookingId, carrierId, plannedLoadingAt, expectedVersion]' '拖车请求无客户字段' +Assert-NotContains $truckOpenApi 'customerId:' '拖车 OpenAPI 客户字段' +Assert-Contains $pdaAgents '当前 `taskId + warehouseId` 走任务专用 API' '国内 PDA 柜级证据入口' +Assert-Contains $pdaLoadingApi '/first-leg/pda-loading/tasks/${encodeURIComponent(taskId)}/evidence?warehouseId=' '国内 PDA 证据查询 API' +Assert-NotContains $pdaLoadingApi '/inbounds/${encodeURIComponent(inboundId)}/loading/evidence' '国内 PDA 旧入仓证据 API' $mapperModuleAgents = @( @{ Module = 'audit-service'; Application = 'BaiyueAuditApplication.java' }, @{ Module = 'auth'; Application = 'BaiyueAuthApplication.java' }, @@ -431,6 +447,10 @@ $criticalPaths = @( 'apps/admin-web/src/modules/unified-orders/pages/UnifiedOrdersView.vue', 'apps/admin-web/src/modules/ocean-bookings/api/platformOceanBookings.ts', 'apps/admin-web/src/modules/ocean-bookings/pages/OceanBookingsView.vue', + 'apps/admin-web/src/modules/ocean-bookings/components/OceanBookingDocumentsPanel.vue', + 'apps/admin-web/src/modules/ocean-bookings/components/OceanBookingPrintDialog.vue', + 'apps/admin-web/src/modules/trucking/components/TruckAppointmentForm.vue', + 'apps/admin-web/src/modules/trucking/api/platformTrucking.ts', 'apps/admin-web/src/core/routing/navigationRecovery.ts', 'apps/admin-web/src/core/routing/navigationRecovery.test.ts', 'apps/admin-web/src/modules/overseas-warehouse/api/outbounds.ts', @@ -513,6 +533,10 @@ $criticalPaths = @( 'baiyue-firstleg/firstleg/src/main/resources/openapi/loading-lists-v1.yaml', 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/controller/PlatformColoadGroupController.java', 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/service/ColoadGroupService.java', + 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingDocumentService.java', + 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingPrintService.java', + 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java', + 'baiyue-firstleg/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml', 'baiyue-firstleg/file/src/main/resources/openapi/customs-file-and-export-v1.yaml', 'baiyue-firstleg/order/src/main/java/com/baiyue/order/service/InboundCodeAssociationService.java', 'baiyue-firstleg/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java', @@ -693,5 +717,5 @@ if ($failures.Count -gt 0) { exit 1 } -Write-Host "AGENTS 索引合同通过:$checked 项检查,覆盖快速表唯一性、部署单元、前端模块、Client active 路由、业务编号与内部 ID 隔离、装柜清单生命周期、四期范围漂移、仓库主档唯一权威、订舱候选与柜量语义、整柜出仓与船节点单调性、Gateway 覆盖、PDA 页面/运行配置/独立更新/语言预留/能力开关、人工分段验收链、Maven 模块、Lastleg 海外安全投影、同步 Trace、关键入口和排除目录。" -ForegroundColor Green +Write-Host "AGENTS 索引合同通过:$checked 项检查,覆盖快速表唯一性、部署单元、前端模块、Client active 路由、业务编号与内部 ID 隔离、装柜清单生命周期、订舱拥有拖车与柜级证据、四期范围漂移、仓库主档唯一权威、订舱候选与柜量语义、整柜出仓与船节点单调性、Gateway 覆盖、PDA 页面/运行配置/独立更新/语言预留/能力开关、人工分段验收链、Maven 模块、Lastleg 海外安全投影、同步 Trace、关键入口和排除目录。" -ForegroundColor Green exit 0 From 179a20fc38e8723950ae52211e988aaad4043c5b Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 1 Sep 2026 10:38:15 +0800 Subject: [PATCH 10/37] refactor(firstleg): keep trucking payable only --- deploy/sql/头程数据库统一增量-MySQL8.sql | 10 ++++---- .../firstleg/dto/TruckAppointmentDtos.java | 2 +- .../entity/TruckAppointmentEntity.java | 1 - .../mapper/TruckAppointmentMapper.java | 2 +- .../service/TruckAppointmentService.java | 4 ++- .../db/phase3-truck-appointment-schema.sql | 6 ++--- .../openapi/truck-appointments-v1.yaml | 4 +++ ...ruckAppointmentControllerContractTest.java | 3 ++- .../Phase3FinalUnifiedSqlContractTest.java | 2 +- .../TruckAppointmentMapperContractTest.java | 1 + .../service/TruckAppointmentServiceTest.java | 25 +++++++++++++++++++ .../fulfillment-list-field-contract.mjs | 2 ++ ...t-prototype-field-matrix-contract.test.mjs | 4 +-- .../manual-state-ocean-trucking.mjs | 2 +- .../service-center-flow.mjs | 2 +- .../truck-appointments-contract.test.mjs | 5 +++- .../truck-appointments.mjs | 24 +++++++++++++----- 17 files changed, 74 insertions(+), 25 deletions(-) diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index a34799f81..7deed37fe 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -9395,12 +9395,13 @@ CREATE TABLE IF NOT EXISTS fl_truck_appointment ( store_name VARCHAR(128) NULL COMMENT '到仓装柜地点名称快照', store_contact VARCHAR(64) NULL COMMENT '到仓地点联系人快照', store_phone VARCHAR(32) NULL COMMENT '到仓地点联系电话快照', store_address VARCHAR(255) NULL COMMENT '到仓装柜地点地址快照', store_city VARCHAR(64) NULL COMMENT '到仓地点城市快照', store_zipcode VARCHAR(16) NULL COMMENT '到仓地点邮编快照', package_count INT UNSIGNED NULL COMMENT '预约货物包装件数快照', gross_weight_kg DECIMAL(18,3) NULL COMMENT '预约货物毛重,单位千克', volume_cbm DECIMAL(18,4) NULL COMMENT '预约货物体积,单位立方米', pallet_count INT UNSIGNED NULL COMMENT '预约托盘数量', po_no VARCHAR(255) NULL COMMENT '关联采购单号', planned_loading_at DATETIME(3) NULL COMMENT '预约到仓装柜时间', - actual_arrived_warehouse_at DATETIME(3) NULL COMMENT '车辆实际到达收货仓时间', actual_loading_at DATETIME(3) NULL COMMENT '实际开始装柜时间', actual_departed_at DATETIME(3) NULL COMMENT '车辆实际离仓时间;DEPARTED后供LOADED门禁使用', transit_minutes INT UNSIGNED NULL COMMENT '从到仓到离仓的运输作业耗时,单位分钟', payable_amount DECIMAL(18,2) NULL COMMENT '应付拖车服务商金额;币种由currency指定', receivable_amount DECIMAL(18,2) NULL COMMENT '拖车业务应收金额;币种由currency指定', - currency VARCHAR(8) NOT NULL DEFAULT 'RMB' COMMENT '金额币种;首期默认RMB', status VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '拖车预约状态:DRAFT草稿、PENDING_DISPATCH待派车、DISPATCHED已派车、PICKUP_IN_PROGRESS提货中、ARRIVED_WAREHOUSE已到仓、LOADING装柜中、DEPARTED已离仓、COMPLETED已完成、RESCHEDULED已改约、EXCEPTION异常、CANCELLED已取消', active_container_booking_id BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN status NOT IN ('COMPLETED','CANCELLED','EXCEPTION') THEN container_booking_id ELSE NULL END) STORED COMMENT '非终态预约的柜次唯一键', revision INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '拖车预约业务修订号;每次状态或改约递增', + actual_arrived_warehouse_at DATETIME(3) NULL COMMENT '车辆实际到达收货仓时间', actual_loading_at DATETIME(3) NULL COMMENT '实际开始装柜时间', actual_departed_at DATETIME(3) NULL COMMENT '车辆实际离仓时间;DEPARTED后供LOADED门禁使用', transit_minutes INT UNSIGNED NULL COMMENT '从到仓到离仓的运输作业耗时,单位分钟', payable_amount DECIMAL(18,2) NULL COMMENT '应付拖车服务商金额;币种由currency指定', + currency VARCHAR(8) NOT NULL DEFAULT 'RMB' COMMENT '应付金额币种;仅RMB或USD', status VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '拖车预约状态:DRAFT草稿、PENDING_DISPATCH待派车、DISPATCHED已派车、PICKUP_IN_PROGRESS提货中、ARRIVED_WAREHOUSE已到仓、LOADING装柜中、DEPARTED已离仓、COMPLETED已完成、RESCHEDULED已改约、EXCEPTION异常、CANCELLED已取消', active_container_booking_id BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN status NOT IN ('COMPLETED','CANCELLED','EXCEPTION') THEN container_booking_id ELSE NULL END) STORED COMMENT '非终态预约的柜次唯一键', revision INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '拖车预约业务修订号;每次状态或改约递增', version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', remark VARCHAR(1000) NULL COMMENT '平台内部备注或异常说明', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '拖车预约创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '拖车预约最后更新时间', PRIMARY KEY(id), UNIQUE KEY uk_truck_appointment_no(appointment_no), UNIQUE KEY uk_truck_active_container(active_container_booking_id), KEY idx_truck_appointment_status(status,updated_at), KEY idx_truck_appointment_container(container_booking_id), - CONSTRAINT chk_truck_appointment_status CHECK(status IN ('DRAFT','PENDING_DISPATCH','DISPATCHED','PICKUP_IN_PROGRESS','ARRIVED_WAREHOUSE','LOADING','DEPARTED','COMPLETED','RESCHEDULED','EXCEPTION','CANCELLED')) + CONSTRAINT chk_truck_appointment_status CHECK(status IN ('DRAFT','PENDING_DISPATCH','DISPATCHED','PICKUP_IN_PROGRESS','ARRIVED_WAREHOUSE','LOADING','DEPARTED','COMPLETED','RESCHEDULED','EXCEPTION','CANCELLED')), + CONSTRAINT chk_truck_appointment_currency CHECK(currency IN ('RMB','USD')) ) ENGINE=InnoDB COMMENT='三期拖车预约派车聚合'; CREATE TABLE IF NOT EXISTS fl_truck_appointment_history ( id BIGINT UNSIGNED NOT NULL COMMENT '拖车预约历史ID', appointment_id BIGINT UNSIGNED NOT NULL COMMENT '所属拖车预约ID', revision INT UNSIGNED NOT NULL COMMENT '该预约业务修订号', action VARCHAR(32) NOT NULL COMMENT '触发状态或改约的业务动作', @@ -13909,7 +13910,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'carrier_id CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'carrier_name', '拖车服务商名称快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'container_booking_id', '关联柜次逻辑ID;拖车完成供装柜门禁校验'); CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'created_at', '拖车预约创建时间'); -CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'currency', '金额币种;首期默认RMB'); +CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'currency', '应付金额币种;仅RMB或USD'); CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'driver_id_card', '司机身份证号;平台内部敏感信息'); CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'driver_name', '司机姓名'); CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'driver_phone', '司机联系电话'); @@ -13925,7 +13926,6 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'pallet_cou CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'payable_amount', '应付拖车服务商金额;币种由currency指定'); CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'planned_loading_at', '预约到仓装柜时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'po_no', '关联采购单号'); -CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'receivable_amount', '拖车业务应收金额;币种由currency指定'); CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'remark', '平台内部备注或异常说明'); CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'revision', '拖车预约业务修订号;每次状态或改约递增'); CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'status', '拖车预约状态:DRAFT草稿、PENDING_DISPATCH待派车、DISPATCHED已派车、PICKUP_IN_PROGRESS提货中、ARRIVED_WAREHOUSE已到仓、LOADING装柜中、DEPARTED已离仓、COMPLETED已完成、RESCHEDULED已改约、EXCEPTION异常、CANCELLED已取消'); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/TruckAppointmentDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/TruckAppointmentDtos.java index 432654b08..43e6ae875 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/TruckAppointmentDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/TruckAppointmentDtos.java @@ -2,7 +2,7 @@ package com.baiyue.firstleg.dto; import jakarta.validation.constraints.*; import java.math.BigDecimal; import java.time.LocalDateTime; public final class TruckAppointmentDtos { private TruckAppointmentDtos(){} public record Query(@Size(max=64) String keyword, com.baiyue.firstleg.domain.TruckAppointmentStatus status, @Positive Long containerBookingId, @Min(1) long page, @Min(1) @Max(200) long size){ public Query{if(page==0)page=1;if(size==0)size=20;} } - public record SaveRequest(@Positive Long outboundOrderId,@NotNull @Positive Long oceanBookingId,@NotNull @Positive Long containerBookingId,@Positive Long loadingPlanId,@Positive Long warehouseId,@NotNull @Positive Long carrierId,@Size(max=128) String carrierName,@Size(max=128) String billOfLadingNo,@Size(max=64) String fleetContact,@Size(max=32) String fleetPhone,@Size(max=64) String driverName,@Size(max=32) String driverPhone,@Size(max=32) String driverIdCard,@Size(max=32) String vehicleNo,@Size(max=128) String storeName,@Size(max=64) String storeContact,@Size(max=32) String storePhone,@Size(max=255) String storeAddress,@Size(max=64) String storeCity,@Size(max=16) String storeZipcode,@PositiveOrZero Integer packageCount,@DecimalMin("0") BigDecimal grossWeightKg,@DecimalMin("0") BigDecimal volumeCbm,@PositiveOrZero Integer palletCount,@Size(max=255) String poNo,@NotNull LocalDateTime plannedLoadingAt,@PositiveOrZero Integer transitMinutes,@DecimalMin("0") BigDecimal payableAmount,@DecimalMin("0") BigDecimal receivableAmount,@Size(max=8) String currency,@Size(max=1000) String remark,@NotNull @PositiveOrZero Integer expectedVersion){} + public record SaveRequest(@Positive Long outboundOrderId,@NotNull @Positive Long oceanBookingId,@NotNull @Positive Long containerBookingId,@Positive Long loadingPlanId,@Positive Long warehouseId,@NotNull @Positive Long carrierId,@Size(max=128) String carrierName,@Size(max=128) String billOfLadingNo,@Size(max=64) String fleetContact,@Size(max=32) String fleetPhone,@Size(max=64) String driverName,@Size(max=32) String driverPhone,@Size(max=32) String driverIdCard,@Size(max=32) String vehicleNo,@Size(max=128) String storeName,@Size(max=64) String storeContact,@Size(max=32) String storePhone,@Size(max=255) String storeAddress,@Size(max=64) String storeCity,@Size(max=16) String storeZipcode,@PositiveOrZero Integer packageCount,@DecimalMin("0") BigDecimal grossWeightKg,@DecimalMin("0") BigDecimal volumeCbm,@PositiveOrZero Integer palletCount,@Size(max=255) String poNo,@NotNull LocalDateTime plannedLoadingAt,@PositiveOrZero Integer transitMinutes,@DecimalMin("0") BigDecimal payableAmount,@Pattern(regexp="RMB|USD") String currency,@Size(max=1000) String remark,@NotNull @PositiveOrZero Integer expectedVersion){} public record ActionRequest(@NotNull @PositiveOrZero Integer expectedVersion,@Size(max=500) String reason){} public record AttachmentRequest(@NotNull @Positive Long fileId,@NotBlank @Pattern(regexp="DISPATCH_ORDER|DRIVER_DOCUMENT|VEHICLE_DOCUMENT|OTHER") String attachmentType,@Size(max=255) String fileName,@Size(max=128) String fileHash){ } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/TruckAppointmentEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/TruckAppointmentEntity.java index 10cf72b9a..3d8b72ee1 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/TruckAppointmentEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/TruckAppointmentEntity.java @@ -45,7 +45,6 @@ public class TruckAppointmentEntity { private LocalDateTime actualDepartedAt; private Integer transitMinutes; private BigDecimal payableAmount; - private BigDecimal receivableAmount; private String currency; private TruckAppointmentStatus status; private Integer revision; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/TruckAppointmentMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/TruckAppointmentMapper.java index 0f842494b..8676a0dcc 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/TruckAppointmentMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/TruckAppointmentMapper.java @@ -7,5 +7,5 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.myba @Update("UPDATE fl_truck_appointment SET status='DISPATCHED',bill_of_lading_no=#{billOfLadingNo},revision=revision+1,version=version+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND status=#{fromStatus} AND version=#{version}") int dispatch(@Param("id") long id,@Param("fromStatus") String fromStatus,@Param("billOfLadingNo") String billOfLadingNo,@Param("version") int version); @Update("UPDATE fl_truck_appointment SET status='COMPLETED',actual_loading_at=COALESCE(actual_loading_at,CURRENT_TIMESTAMP(3)),revision=revision+1,version=version+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND status=#{fromStatus} AND version=#{version}") int completeLoadingFromPda(@Param("id") long id,@Param("fromStatus") String fromStatus,@Param("version") int version); @Update("UPDATE fl_truck_appointment SET planned_loading_at=#{at}, revision=revision+1, version=version+1, status='RESCHEDULED', remark=#{reason}, updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND version=#{version}") int reschedule(@Param("id") long id,@Param("at") java.time.LocalDateTime at,@Param("reason") String reason,@Param("version") int version); - @Update("UPDATE fl_truck_appointment SET outbound_order_id=#{e.outboundOrderId},ocean_booking_id=#{e.oceanBookingId},container_booking_id=#{e.containerBookingId},loading_plan_id=#{e.loadingPlanId},warehouse_id=#{e.warehouseId},carrier_id=#{e.carrierId},carrier_name=#{e.carrierName},bill_of_lading_no=#{e.billOfLadingNo},fleet_contact=#{e.fleetContact},fleet_phone=#{e.fleetPhone},driver_name=#{e.driverName},driver_phone=#{e.driverPhone},driver_id_card=#{e.driverIdCard},vehicle_no=#{e.vehicleNo},store_name=#{e.storeName},store_contact=#{e.storeContact},store_phone=#{e.storePhone},store_address=#{e.storeAddress},store_city=#{e.storeCity},store_zipcode=#{e.storeZipcode},package_count=#{e.packageCount},gross_weight_kg=#{e.grossWeightKg},volume_cbm=#{e.volumeCbm},pallet_count=#{e.palletCount},po_no=#{e.poNo},planned_loading_at=#{e.plannedLoadingAt},transit_minutes=#{e.transitMinutes},payable_amount=#{e.payableAmount},receivable_amount=#{e.receivableAmount},currency=#{e.currency},remark=#{e.remark},version=version+1,revision=revision+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{e.id} AND version=#{version}") int updateEdit(@Param("e") TruckAppointmentEntity e,@Param("version") int version); + @Update("UPDATE fl_truck_appointment SET outbound_order_id=#{e.outboundOrderId},ocean_booking_id=#{e.oceanBookingId},container_booking_id=#{e.containerBookingId},loading_plan_id=#{e.loadingPlanId},warehouse_id=#{e.warehouseId},carrier_id=#{e.carrierId},carrier_name=#{e.carrierName},bill_of_lading_no=#{e.billOfLadingNo},fleet_contact=#{e.fleetContact},fleet_phone=#{e.fleetPhone},driver_name=#{e.driverName},driver_phone=#{e.driverPhone},driver_id_card=#{e.driverIdCard},vehicle_no=#{e.vehicleNo},store_name=#{e.storeName},store_contact=#{e.storeContact},store_phone=#{e.storePhone},store_address=#{e.storeAddress},store_city=#{e.storeCity},store_zipcode=#{e.storeZipcode},package_count=#{e.packageCount},gross_weight_kg=#{e.grossWeightKg},volume_cbm=#{e.volumeCbm},pallet_count=#{e.palletCount},po_no=#{e.poNo},planned_loading_at=#{e.plannedLoadingAt},transit_minutes=#{e.transitMinutes},payable_amount=#{e.payableAmount},currency=#{e.currency},remark=#{e.remark},version=version+1,revision=revision+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{e.id} AND version=#{version}") int updateEdit(@Param("e") TruckAppointmentEntity e,@Param("version") int version); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java index 480b7b35b..500c7eeae 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java @@ -36,6 +36,7 @@ import org.springframework.web.multipart.MultipartFile; @Service public class TruckAppointmentService { private static final Set ATTACHMENT_TYPES = Set.of("DISPATCH_ORDER", "DRIVER_DOCUMENT", "VEHICLE_DOCUMENT", "OTHER"); + private static final Set CURRENCIES = Set.of("RMB", "USD"); @Resource TruckAppointmentRepository repo; @Resource OceanBookingRepository oceanBookings; @Resource OceanBookingDocumentService oceanBookingDocuments; @@ -79,7 +80,7 @@ public class TruckAppointmentService { private TruckAppointmentStatus next(TruckAppointmentStatus s,String a){return switch(a){case"SUBMIT"->s==TruckAppointmentStatus.DRAFT?TruckAppointmentStatus.PENDING_DISPATCH:badStatus();case"DISPATCH"->s==TruckAppointmentStatus.PENDING_DISPATCH||s==TruckAppointmentStatus.RESCHEDULED?TruckAppointmentStatus.DISPATCHED:badStatus();case"PICKUP"->s==TruckAppointmentStatus.DISPATCHED?TruckAppointmentStatus.PICKUP_IN_PROGRESS:badStatus();case"ARRIVE"->s==TruckAppointmentStatus.PICKUP_IN_PROGRESS?TruckAppointmentStatus.ARRIVED_WAREHOUSE:badStatus();case"LOAD"->s==TruckAppointmentStatus.ARRIVED_WAREHOUSE?TruckAppointmentStatus.LOADING:badStatus();case"DEPART"->s==TruckAppointmentStatus.LOADING?TruckAppointmentStatus.DEPARTED:badStatus();case"COMPLETE"->s==TruckAppointmentStatus.DEPARTED?TruckAppointmentStatus.COMPLETED:badStatus();case"EXCEPTION"->s!=TruckAppointmentStatus.COMPLETED&&s!=TruckAppointmentStatus.CANCELLED?TruckAppointmentStatus.EXCEPTION:badStatus();case"CANCEL"->s!=TruckAppointmentStatus.COMPLETED?TruckAppointmentStatus.CANCELLED:badStatus();default->badStatus();};} private TruckAppointmentStatus badStatus(){throw conflict("不允许的状态动作");} private TruckAppointmentEntity required(long id){TruckAppointmentEntity e=repo.find(id);if(e==null)throw new BizException(ResultCode.NOT_FOUND,"拖车预约不存在");return e;} private void checkVersion(TruckAppointmentEntity e,int v){if(e.getVersion()!=v)throw conflict("版本已变化,请刷新后重试");} private static BizException conflict(String m){return new BizException(ResultCode.CONFLICT,m);} private static BizException bad(String m){return new BizException(ResultCode.BAD_REQUEST,m);} private TruckAppointmentEntity enrich(TruckAppointmentEntity value){if(value.getOceanBookingId()!=null){OceanBookingEntity booking=oceanBookings.find(value.getOceanBookingId());if(booking!=null){value.setOceanBookingNo(booking.getBookingNo());value.setWarehouseName(booking.getWarehouseName());value.setShippingCompanyId(booking.getShippingCompanyId());value.setShippingCompanyName(booking.getShippingCompanyName());value.setBookingAgentId(booking.getBookingAgentId());value.setBookingAgentName(booking.getBookingAgentName());}}if(value.getContainerBookingId()!=null){ContainerBookingEntity container=containers.find(value.getContainerBookingId());if(container!=null){value.setCabinetNo(container.getCabinetNo());value.setContainerNo(container.getContainerNo());if(!StringUtils.hasText(value.getWarehouseName()))value.setWarehouseName(container.getWarehouseName());}}value.setAttachmentCount((long)repo.attachments(value.getId()).size());return value;} - private void copy(TruckAppointmentEntity e,SaveRequest r,AssociationContext links,String carrierName){e.setOutboundOrderId(r.outboundOrderId());e.setOceanBookingId(r.oceanBookingId());e.setContainerBookingId(r.containerBookingId());e.setLoadingPlanId(r.loadingPlanId());e.setWarehouseId(links.warehouseId());e.setCarrierId(r.carrierId());e.setCarrierName(carrierName);e.setBillOfLadingNo(links.billOfLadingNo());e.setFleetContact(r.fleetContact());e.setFleetPhone(r.fleetPhone());e.setDriverName(r.driverName());e.setDriverPhone(r.driverPhone());e.setDriverIdCard(r.driverIdCard());e.setVehicleNo(r.vehicleNo());e.setStoreName(r.storeName());e.setStoreContact(r.storeContact());e.setStorePhone(r.storePhone());e.setStoreAddress(r.storeAddress());e.setStoreCity(r.storeCity());e.setStoreZipcode(r.storeZipcode());e.setPackageCount(r.packageCount());e.setGrossWeightKg(r.grossWeightKg());e.setVolumeCbm(r.volumeCbm());e.setPalletCount(r.palletCount());e.setPoNo(r.poNo());e.setPlannedLoadingAt(r.plannedLoadingAt());e.setTransitMinutes(r.transitMinutes());e.setPayableAmount(r.payableAmount());e.setReceivableAmount(r.receivableAmount());e.setCurrency(r.currency()==null?"RMB":r.currency());e.setRemark(r.remark());} + private void copy(TruckAppointmentEntity e,SaveRequest r,AssociationContext links,String carrierName){e.setOutboundOrderId(r.outboundOrderId());e.setOceanBookingId(r.oceanBookingId());e.setContainerBookingId(r.containerBookingId());e.setLoadingPlanId(r.loadingPlanId());e.setWarehouseId(links.warehouseId());e.setCarrierId(r.carrierId());e.setCarrierName(carrierName);e.setBillOfLadingNo(links.billOfLadingNo());e.setFleetContact(r.fleetContact());e.setFleetPhone(r.fleetPhone());e.setDriverName(r.driverName());e.setDriverPhone(r.driverPhone());e.setDriverIdCard(r.driverIdCard());e.setVehicleNo(r.vehicleNo());e.setStoreName(r.storeName());e.setStoreContact(r.storeContact());e.setStorePhone(r.storePhone());e.setStoreAddress(r.storeAddress());e.setStoreCity(r.storeCity());e.setStoreZipcode(r.storeZipcode());e.setPackageCount(r.packageCount());e.setGrossWeightKg(r.grossWeightKg());e.setVolumeCbm(r.volumeCbm());e.setPalletCount(r.palletCount());e.setPoNo(r.poNo());e.setPlannedLoadingAt(r.plannedLoadingAt());e.setTransitMinutes(r.transitMinutes());e.setPayableAmount(r.payableAmount());e.setCurrency(currency(r.currency()));e.setRemark(r.remark());} private TruckAppointmentEntity claimOrReplay(long id,String action,String key,String hash){validateKey(key);String normalized=key.trim();if(repo.claimOperation(IdWorker.getId(),id,action,normalized,hash))return null;TruckAppointmentOperationEntity existing=repo.operation(normalized);if(existing==null||!Objects.equals(existing.getAppointmentId(),id)||!action.equals(existing.getAction())||!hash.equals(existing.getRequestHash()))throw conflict("Idempotency-Key已用于不同请求");if(!StringUtils.hasText(existing.getResponseSnapshot()))throw conflict("相同请求正在处理中,请稍后重试");try{return objectMapper.readValue(existing.getResponseSnapshot(),TruckAppointmentEntity.class);}catch(JsonProcessingException ex){throw new IllegalStateException("无法读取拖车预约幂等结果",ex);}} private TruckAppointmentEntity completeOperation(long id,String action,String key,TruckAppointmentEntity result){try{String normalized=key.trim();if(repo.completeOperation(id,action,normalized,objectMapper.writeValueAsString(result))!=1)throw conflict("拖车预约幂等请求完成状态冲突");return result;}catch(JsonProcessingException ex){throw new IllegalStateException("无法保存拖车预约幂等结果",ex);}} private String hash(String action,long id,Object request){try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest((action+'|'+id+'|'+objectMapper.writeValueAsString(request)).getBytes(StandardCharsets.UTF_8)));}catch(Exception ex){throw new IllegalStateException("无法计算拖车预约请求摘要",ex);}} @@ -102,6 +103,7 @@ public class TruckAppointmentService { } private static String releasedBookingBill(OceanBookingEntity booking,String requested){String authoritative=trim(booking.getBillOfLadingNo());if(!StringUtils.hasText(authoritative))throw conflict("关联已放舱订舱缺少提单号");if(StringUtils.hasText(requested)&&!authoritative.equals(requested))throw bad("billOfLadingNo与关联订舱不一致");return authoritative;} private static String attachmentType(String value){String normalized=trim(value);if(!ATTACHMENT_TYPES.contains(normalized))throw bad("不支持的拖车附件类型");return normalized;} + private static String currency(String value){if(!StringUtils.hasText(value))return "RMB";String normalized=value.trim();if(!CURRENCIES.contains(normalized))throw bad("currency仅支持RMB或USD");return normalized;} private static String trim(String value){return StringUtils.hasText(value)?value.trim():null;} private record AssociationContext(OceanBookingEntity booking,ContainerBookingEntity container,Long warehouseId,String billOfLadingNo){} private void record(TruckAppointmentEntity e,String from,String to,String action,String reason,long uid,String name){TruckAppointmentHistoryEntity h=new TruckAppointmentHistoryEntity();h.setId(IdWorker.getId());h.setAppointmentId(e.getId());h.setRevision(e.getRevision());h.setAction(action);h.setFromStatus(from);h.setToStatus(to);h.setReason(reason);h.setOperatorId(uid);h.setOperatorName(name);h.setCreatedAt(LocalDateTime.now());repo.history(h);} diff --git a/firstleg/src/main/resources/db/phase3-truck-appointment-schema.sql b/firstleg/src/main/resources/db/phase3-truck-appointment-schema.sql index f42423188..d271e784e 100644 --- a/firstleg/src/main/resources/db/phase3-truck-appointment-schema.sql +++ b/firstleg/src/main/resources/db/phase3-truck-appointment-schema.sql @@ -33,8 +33,7 @@ CREATE TABLE IF NOT EXISTS fl_truck_appointment ( actual_departed_at DATETIME(3) NULL COMMENT '车辆实际离仓时间;DEPARTED后供LOADED门禁使用', transit_minutes INT UNSIGNED NULL COMMENT '从到仓到离仓的运输作业耗时,单位分钟', payable_amount DECIMAL(18,2) NULL COMMENT '应付拖车服务商金额;币种由currency指定', - receivable_amount DECIMAL(18,2) NULL COMMENT '拖车业务应收金额;币种由currency指定', - currency VARCHAR(8) NOT NULL DEFAULT 'RMB' COMMENT '金额币种;首期默认RMB', + currency VARCHAR(8) NOT NULL DEFAULT 'RMB' COMMENT '应付金额币种;仅RMB或USD', status VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '拖车预约状态:DRAFT草稿、PENDING_DISPATCH待派车、DISPATCHED已派车、PICKUP_IN_PROGRESS提货中、ARRIVED_WAREHOUSE已到仓、LOADING装柜中、DEPARTED已离仓、COMPLETED已完成、RESCHEDULED已改约、EXCEPTION异常、CANCELLED已取消', active_container_booking_id BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN status NOT IN ('COMPLETED','CANCELLED','EXCEPTION') THEN container_booking_id ELSE NULL END) STORED COMMENT '非终态预约的柜次唯一键', revision INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '拖车预约业务修订号;每次状态或改约递增', @@ -44,7 +43,8 @@ CREATE TABLE IF NOT EXISTS fl_truck_appointment ( updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '拖车预约最后更新时间', PRIMARY KEY(id), UNIQUE KEY uk_truck_appointment_no(appointment_no), UNIQUE KEY uk_truck_active_container(active_container_booking_id), KEY idx_truck_appointment_status(status,updated_at), KEY idx_truck_appointment_container(container_booking_id), - CONSTRAINT chk_truck_appointment_status CHECK(status IN ('DRAFT','PENDING_DISPATCH','DISPATCHED','PICKUP_IN_PROGRESS','ARRIVED_WAREHOUSE','LOADING','DEPARTED','COMPLETED','RESCHEDULED','EXCEPTION','CANCELLED')) + CONSTRAINT chk_truck_appointment_status CHECK(status IN ('DRAFT','PENDING_DISPATCH','DISPATCHED','PICKUP_IN_PROGRESS','ARRIVED_WAREHOUSE','LOADING','DEPARTED','COMPLETED','RESCHEDULED','EXCEPTION','CANCELLED')), + CONSTRAINT chk_truck_appointment_currency CHECK(currency IN ('RMB','USD')) ) ENGINE=InnoDB COMMENT='三期拖车预约派车聚合'; CREATE TABLE IF NOT EXISTS fl_truck_appointment_history ( id BIGINT UNSIGNED NOT NULL COMMENT '拖车预约历史ID', appointment_id BIGINT UNSIGNED NOT NULL COMMENT '所属拖车预约ID', revision INT UNSIGNED NOT NULL COMMENT '该预约业务修订号', action VARCHAR(32) NOT NULL COMMENT '触发状态或改约的业务动作', diff --git a/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml b/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml index 7833a4d09..8beee8d42 100644 --- a/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml +++ b/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml @@ -187,6 +187,8 @@ components: carrierName: {type: string, maxLength: 128, nullable: true, description: Optional consistency hint only; the persisted snapshot comes from Warehouse supplier master data.} billOfLadingNo: {type: string, maxLength: 128, nullable: true, description: Optional consistency hint; the persisted snapshot is always frozen from the selected RELEASED booking.} plannedLoadingAt: {type: string, format: date-time} + payableAmount: {type: number, format: double, minimum: 0, nullable: true, description: Amount payable to the trucking supplier.} + currency: {type: string, enum: [RMB, USD], default: RMB, description: Payable amount currency.} expectedVersion: {type: integer, minimum: 0, description: Must be 0 on create; current version on edit.} DetailStableReferences: type: object @@ -209,4 +211,6 @@ components: carrierId: {type: integer, format: int64} carrierName: {type: string} billOfLadingNo: {type: string, maxLength: 128, nullable: true, description: Frozen bill of lading snapshot used by dispatch readiness.} + payableAmount: {type: number, format: double, minimum: 0, nullable: true} + currency: {type: string, enum: [RMB, USD]} attachmentCount: {type: integer, format: int64, minimum: 0, description: Number of attachment references owned by this appointment} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/TruckAppointmentControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/TruckAppointmentControllerContractTest.java index 350256a7b..a359e77f7 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/TruckAppointmentControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/TruckAppointmentControllerContractTest.java @@ -19,7 +19,7 @@ import static org.assertj.core.api.Assertions.assertThat; class TruckAppointmentControllerContractTest { @Test void exposesDedicatedEndpointAndPermissions() {String p=PlatformTruckAppointmentController.class.getAnnotation(org.springframework.web.bind.annotation.RequestMapping.class).value()[0];assertThat(p).isEqualTo("/api/platform/v1/truck-appointments"); for(String n:new String[]{"page","detail","history","attachments","attach","upload","deleteAttachment","create","edit","action","reschedule"}) assertThat(java.util.Arrays.stream(PlatformTruckAppointmentController.class.getDeclaredMethods()).anyMatch(m->m.getName().equals(n))).isTrue();} - @Test void schemaHasPlatformAggregateWithoutCustomerColumns() throws Exception {String s=new ClassPathResource("db/phase3-truck-appointment-schema.sql").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("fl_truck_appointment","fl_truck_appointment_history","fl_truck_appointment_attachment","fl_truck_appointment_operation","bill_of_lading_no VARCHAR(128)","chk_truck_attachment_type","DISPATCH_ORDER","PENDING_DISPATCH","PICKUP_IN_PROGRESS","ARRIVED_WAREHOUSE","RESCHEDULED","actual_arrived_warehouse_at","actual_loading_at","actual_departed_at","CALL phase3_truck_add_column","visibility VARCHAR(24) NOT NULL DEFAULT 'PLATFORM'","active_container_booking_id BIGINT UNSIGNED GENERATED ALWAYS AS","UNIQUE KEY uk_truck_active_container(active_container_booking_id)").doesNotContain("customer_id", "customer_name", "idx_truck_appointment_customer");} + @Test void schemaHasPlatformAggregateWithoutCustomerOrReceivableColumns() throws Exception {String s=new ClassPathResource("db/phase3-truck-appointment-schema.sql").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("fl_truck_appointment","fl_truck_appointment_history","fl_truck_appointment_attachment","fl_truck_appointment_operation","bill_of_lading_no VARCHAR(128)","chk_truck_attachment_type","DISPATCH_ORDER","PENDING_DISPATCH","PICKUP_IN_PROGRESS","ARRIVED_WAREHOUSE","RESCHEDULED","actual_arrived_warehouse_at","actual_loading_at","actual_departed_at","CALL phase3_truck_add_column","visibility VARCHAR(24) NOT NULL DEFAULT 'PLATFORM'","active_container_booking_id BIGINT UNSIGNED GENERATED ALWAYS AS","UNIQUE KEY uk_truck_active_container(active_container_booking_id)","chk_truck_appointment_currency CHECK(currency IN ('RMB','USD'))").doesNotContain("customer_id", "customer_name", "idx_truck_appointment_customer", "receivable_amount");} @Test void existingAppointmentTableReceivesAllLateColumns() throws Exception { String moduleSql=new ClassPathResource("db/phase3-truck-appointment-schema.sql").getContentAsString(StandardCharsets.UTF_8); Path direct=Path.of("deploy","sql","头程数据库统一增量-MySQL8.sql"); @@ -32,6 +32,7 @@ class TruckAppointmentControllerContractTest { @Test void openApiDocumentsLifecycleIdempotencyAndActualTimes() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("/api/platform/v1/truck-appointments/{id}/{action}:","/api/platform/v1/truck-appointments/{id}/reschedule:","name: Idempotency-Key","required: true","actual event timestamps");} @Test void openApiRequiresReleasedBookingAndAllocatedRealContainerWithoutCustomerScope() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("required: [oceanBookingId, containerBookingId, carrierId, plannedLoadingAt, expectedVersion]","RELEASED ocean booking","ACTIVE real-numbered container","OCEAN_BOOKING_DOCUMENT","valid unique ISO 6346 container number","schema: {$ref: '#/components/schemas/DetailEnvelope'}","required: [code, message, data, timestamp]","allOf: [{$ref: '#/components/schemas/DetailStableReferences'}]","nullable: true").doesNotContain("customerId", "customerName", "Filter by customer");} @Test void openApiKeepsOnlyNonBookingTruckAttachments() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("AttachmentRequest:","required: [fileId, attachmentType]","required: [attachmentType, file]","TRUCK_APPOINTMENT_ATTACHMENT","Deletes the appointment reference only","DetailStableReferences:","oceanBookingNo:","shippingCompanyName:","bookingAgentName:","cabinetNo:","billOfLadingNo:").doesNotContain("enum: [BILL_OF_LADING, DISPATCH_ORDER");} + @Test void openApiExposesOnlyPayableWithFixedCurrencies() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("payableAmount:","enum: [RMB, USD]","default: RMB").doesNotContain("receivableAmount");} @Test void methodsHavePlatformPermissionsAndAuthenticatedScope() { Map expected=Map.ofEntries( Map.entry("page","platform:firstleg:truck-appointment:list"), Map.entry("detail","platform:firstleg:truck-appointment:list"), diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java index 9b9ea8c0f..97b020c93 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java @@ -155,7 +155,7 @@ class Phase3FinalUnifiedSqlContractTest { .contains("存在缺失中文注释的业务字段") .doesNotContain("CREATE PROCEDURE upgrade_column_comment"); assertThat(count(sql, "CALL queue_table_comment(")).isEqualTo(144); - assertThat(count(sql, "CALL queue_column_comment(")).isGreaterThanOrEqualTo(2379); + assertThat(count(sql, "CALL queue_column_comment(")).isGreaterThanOrEqualTo(2378); assertThat(sql) .contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'supplier_id'") .contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'supplier_name'") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/TruckAppointmentMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/TruckAppointmentMapperContractTest.java index 6cbe4853a..e786d8061 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/TruckAppointmentMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/TruckAppointmentMapperContractTest.java @@ -34,5 +34,6 @@ class TruckAppointmentMapperContractTest { "planned_loading_at=#{e.plannedLoadingAt}", "bill_of_lading_no=#{e.billOfLadingNo}", "WHERE id=#{e.id} AND version=#{version}", "version=version+1", "revision=revision+1"); assertThat(edit).doesNotContain("customer_id", "customer_name", "customerId", "customerName"); + assertThat(edit).doesNotContain("receivable_amount", "receivableAmount"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java index 1dce4be86..4eac505d4 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java @@ -164,6 +164,31 @@ class TruckAppointmentServiceTest { assertThat(inserted.getValue().getPalletCount()).isZero(); assertThat(inserted.getValue().getGrossWeightKg()).isZero(); assertThat(inserted.getValue().getVolumeCbm()).isZero(); + assertThat(inserted.getValue().getCurrency()).isEqualTo("RMB"); + } + + @Test void createAcceptsUsd() { + SaveRequest request = validRequest(); + when(request.currency()).thenReturn("USD"); + when(request.payableAmount()).thenReturn(new java.math.BigDecimal("123.45")); + + service.create(7L, "admin", request); + + ArgumentCaptor inserted = ArgumentCaptor.forClass(TruckAppointmentEntity.class); + verify(repository).insert(inserted.capture()); + assertThat(inserted.getValue().getCurrency()).isEqualTo("USD"); + assertThat(inserted.getValue().getPayableAmount()).isEqualByComparingTo("123.45"); + } + + @Test void createRejectsUnsupportedCurrencyWithoutWriting() { + SaveRequest request = validRequest(); + when(request.currency()).thenReturn("CNY"); + + assertThatThrownBy(() -> service.create(7L, "admin", request)) + .isInstanceOf(BizException.class).hasMessageContaining("RMB或USD"); + + verify(repository, never()).insert(any()); + verify(repository, never()).history(any()); } @Test void platformOwnedAttachmentUsesZeroTenantWithoutCustomerSnapshot() { diff --git a/scripts/admin-test-env-modules/fulfillment-list-field-contract.mjs b/scripts/admin-test-env-modules/fulfillment-list-field-contract.mjs index b508e7b61..3a20ab712 100644 --- a/scripts/admin-test-env-modules/fulfillment-list-field-contract.mjs +++ b/scripts/admin-test-env-modules/fulfillment-list-field-contract.mjs @@ -122,6 +122,8 @@ export function truckAppointmentFieldGaps(record) { status: isNonBlank, carrierId: isPositiveId, carrierName: isNonBlank, + payableAmount: isNonNegativeNumber, + currency: (value) => value === 'RMB' || value === 'USD', attachmentCount: isNonNegativeNumber, }) } diff --git a/scripts/admin-test-env-modules/fulfillment-prototype-field-matrix-contract.test.mjs b/scripts/admin-test-env-modules/fulfillment-prototype-field-matrix-contract.test.mjs index f934da646..07070d4ea 100644 --- a/scripts/admin-test-env-modules/fulfillment-prototype-field-matrix-contract.test.mjs +++ b/scripts/admin-test-env-modules/fulfillment-prototype-field-matrix-contract.test.mjs @@ -56,7 +56,7 @@ test('Admin 当前列表逐字段列与验收矩阵一致', () => { 'loading-lists/components/LoadingListsView.vue': ['preloadNo', 'customers', 'skuCount', 'loadingListNo', 'totalPackageCount', 'totalGrossWeightKg', 'totalNetWeightKg', 'totalVolumeCbm', 'totalQuantity', 'createdByName'], 'ocean-bookings/pages/OceanBookingsView.vue': ['bookingNo', 'customerName', 'billOfLadingNo', 'carrierName', 'bookingAgent', 'vesselName', 'pol', 'pod', 'etd', 'eta', 'updatedAt'], 'port-schedules/pages/PortSchedulesView.vue': ['schedule.scheduleNo', 'schedule.sourceName', 'schedule.vesselName', 'schedule.departurePortName', 'schedule.destinationPortName', 'schedule.etdAt', 'schedule.etaAt', 'schedule.observedAt', 'expired'], - 'trucking/pages/TruckAppointmentsView.vue': ['appointmentNo', 'containerNo', 'company', 'driver', 'vehicleNo', 'store', 'payableAmount', 'receivableAmount', 'plannedLoadingAt', 'attachmentCount'], + 'trucking/pages/TruckAppointmentsView.vue': ['appointmentNo', 'containerNo', 'company', 'driver', 'vehicleNo', 'store', 'payableAmount', 'currency', 'plannedLoadingAt', 'attachmentCount'], 'customs/pages/CustomsCasesView.vue': ['customsNo', 'orderLink', 'inboundLink', 'checklistCode', 'checklistVersionNo', 'status', 'updatedAt'], 'outbound-dispatch/pages/OutboundDispatchesView.vue': ['inboundCode', 'customerName', 'containerNo', 'oceanBookingStatus', 'truckAppointmentStatus', 'pickTaskStatus', 'dispatchStatus', 'gate'], } @@ -89,7 +89,7 @@ test('字段校验器接受正式完整投影并精确暴露缺字段', () => { [loadingListFieldGaps, { planId: ids.plan, containerBookingId: ids.container, loadingListNo: 'LL-1', containerNo: 'MSCU1234567', cabinetNo: 'CAB-1', warehouseName: '宁波仓', status: 'COMPLETED', totalPackageCount: 1, totalGrossWeightKg: 10, totalNetWeightKg: 9, totalVolumeCbm: 1, totalQuantity: 2, createdAt: at }, undefined, 'containerNo'], [oceanBookingFieldGaps, { id: ids.id, bookingNo: 'BK-1', status: 'RELEASED', vesselName: 'VESSEL', voyageNo: 'V1', estimatedSailingAt: at, estimatedArrivalAt: at, customerId: ids.id, customerName: '测试客户', shippingCompanyId: null, shippingCompanyName: null, bookingAgentId: ids.id, bookingAgentName: '代理' }, undefined, 'voyageNo'], [portScheduleFieldGaps, { portSchedule: { schedule: { id: ids.id, scheduleNo: 'PS-1', sourceName: '船公司', vesselName: 'VESSEL', voyageNo: 'V1', departurePortCode: 'CNNGB', destinationPortCode: 'USLAX', etdAt: at, etaAt: at, observedAt: at, updatedAt: at, expiresAt: at }, expired: false } }, undefined, 'sourceName'], - [truckAppointmentFieldGaps, { id: ids.id, appointmentNo: 'TA-1', oceanBookingId: ids.id, containerBookingId: ids.container, loadingPlanId: ids.plan, warehouseId: ids.id, warehouseName: '宁波仓', carrierId: ids.id, carrierName: '拖车商', plannedLoadingAt: at, status: 'DRAFT', attachmentCount: 0 }, undefined, 'appointmentNo'], + [truckAppointmentFieldGaps, { id: ids.id, appointmentNo: 'TA-1', oceanBookingId: ids.id, containerBookingId: ids.container, loadingPlanId: ids.plan, warehouseId: ids.id, warehouseName: '宁波仓', carrierId: ids.id, carrierName: '拖车商', payableAmount: 0, currency: 'RMB', plannedLoadingAt: at, status: 'DRAFT', attachmentCount: 0 }, undefined, 'appointmentNo'], [customsCaseFieldGaps, { customsCase: { id: ids.id, customsNo: 'CUS-1', orderId: ids.order, inboundId: ids.inbound, containerBookingId: ids.container, checklistVersionId: ids.id, checklistCode: 'CHECK', checklistVersionNo: 'V1', status: 'DOCUMENTS_PENDING', updatedAt: at } }, undefined, 'checklistCode'], [outboundAggregateFieldGaps, { containerBookingId: ids.container, containerNo: 'MSCU1234567', loadingPlanId: ids.plan, loadingPlanStatus: 'COMPLETED', oceanBookingId: ids.id, oceanBookingStatus: 'RELEASED', dispatchStatus: 'READY_FOR_DISPATCH', version: 1, inbounds: [{ orderId: ids.order, inboundId: ids.inbound, inboundCode: 'IN-1', tenantId: ids.id, inboundStatus: 'LOADED', pickTaskId: ids.task, pickTaskStatus: 'COMPLETED' }], gate: { inbounds: [{ inboundId: ids.inbound, inboundCode: 'IN-1', gate: { items: [] } }] } }, undefined, 'containerNo'], ] diff --git a/scripts/admin-test-env-modules/manual-state-ocean-trucking.mjs b/scripts/admin-test-env-modules/manual-state-ocean-trucking.mjs index 232541435..ded979608 100644 --- a/scripts/admin-test-env-modules/manual-state-ocean-trucking.mjs +++ b/scripts/admin-test-env-modules/manual-state-ocean-trucking.mjs @@ -95,7 +95,7 @@ function truckPayload(context, oceanBooking, carrier, index) { storeAddress: '深圳市南山区人工验收地址', storeCity: '深圳市', storeZipcode: '518000', packageCount: 10, grossWeightKg: 100, volumeCbm: 2.5, palletCount: 1, poNo: `PO-${value}`, plannedLoadingAt: '2026-09-16T08:00:00', transitMinutes: 120, payableAmount: 100, - receivableAmount: 150, currency: 'RMB', remark: `${context.marker} 人工列表状态 ${index}`, expectedVersion: 0 } + currency: 'RMB', remark: `${context.marker} 人工列表状态 ${index}`, expectedVersion: 0 } } async function createTruck(context, oceanBooking, index) { diff --git a/scripts/admin-test-env-modules/service-center-flow.mjs b/scripts/admin-test-env-modules/service-center-flow.mjs index 9b049aab2..398a79d07 100644 --- a/scripts/admin-test-env-modules/service-center-flow.mjs +++ b/scripts/admin-test-env-modules/service-center-flow.mjs @@ -98,7 +98,7 @@ export async function run(context) { driverIdCard: null, vehicleNo: `粤D-${suffix.slice(-6)}`, storeName: `链路门店-${suffix}`, storeContact: '链路联系人', storePhone: '13800000009', storeAddress: '深圳市南山区链路地址', storeCity: '深圳市', storeZipcode: '518000', packageCount: 1, grossWeightKg: 1, volumeCbm: 0.1, palletCount: 0, poNo: `FLOW-PO-${suffix}`, - plannedLoadingAt: '2026-09-03T08:00:00', transitMinutes: 60, payableAmount: 1, receivableAmount: 2, + plannedLoadingAt: '2026-09-03T08:00:00', transitMinutes: 60, payableAmount: 1, currency: 'RMB', remark: context.marker, expectedVersion: 0, } if (linkContainer) { diff --git a/scripts/admin-test-env-modules/truck-appointments-contract.test.mjs b/scripts/admin-test-env-modules/truck-appointments-contract.test.mjs index 3d291b9b2..bfdd18387 100644 --- a/scripts/admin-test-env-modules/truck-appointments-contract.test.mjs +++ b/scripts/admin-test-env-modules/truck-appointments-contract.test.mjs @@ -58,6 +58,7 @@ test('payload requires booking container and carrier but never emits customer fi { oceanBookingId: '91', containerBookingId: '81', carrierId: '501', carrierName: '正式拖车商' }) assert.equal(Object.hasOwn(payload, 'customerId'), false) assert.equal(Object.hasOwn(payload, 'customerName'), false) + assert.equal(Object.hasOwn(payload, 'receivableAmount'), false) }) test('backend DTO entity mapper service and OpenAPI have no truck customer protocol', () => { @@ -91,13 +92,15 @@ test('HTTP regression is self-contained and covers no-customer and idempotency m for (const label of ['自包含创建 DRAFT 订舱', '上传当前有效 BILL_OF_LADING 文件', '放舱自包含订舱', '绑定本轮真实 ISO 柜号', '拖车请求不含客户字段', '同柜第二条非终态预约拒绝且零漂移', '同键同载荷重放', '同键重放版本零漂移', '同键异载荷拒绝', '错误订舱拒绝创建', - '错误柜号归属拒绝创建', '错误提单拒绝创建', '错误承运商拒绝创建', + '错误柜号归属拒绝创建', '错误提单拒绝创建', '错误承运商拒绝创建', '非法拖车币种拒绝创建', + 'RMB应付费用响应无客户及应收字段', '编辑为USD应付费用', 'USD应付费用保存且无应收字段', '派车命令返回状态身份与版本正确', '派车后回查拖车完整详情', '详情字段完整且订舱柜号身份无客户字段漂移']) assert.match(source, new RegExp(label)) assert.match(source, /const detailResponse = await request\(context, 'GET', path\); const detail = data\(detailResponse\)/) assert.match(source, /const detailGaps = truckAppointmentFieldGaps\(detail\)/) assert.doesNotMatch(source, /truckAppointmentFieldGaps\(dispatched\)/) assert.doesNotMatch(source, /phase3ContainerLoading|phase3Loading/) + assert.doesNotMatch(source, /receivableAmount\s*:/) const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8') assert.match(entry, /\['truck-appointments', \['merchant-qualification-fixture'\]\]/) assert.doesNotMatch(entry, /\['truck-appointments', \[[^\]]*phase3-loading/) diff --git a/scripts/admin-test-env-modules/truck-appointments.mjs b/scripts/admin-test-env-modules/truck-appointments.mjs index 74702a1e9..077376ab9 100644 --- a/scripts/admin-test-env-modules/truck-appointments.mjs +++ b/scripts/admin-test-env-modules/truck-appointments.mjs @@ -55,7 +55,7 @@ export function truckAppointmentPayload(context, suffix, identity, version = 0, storeName: `装柜点-${suffix}`, storeContact: '仓库联系人', storePhone: '13800000006', storeAddress: '深圳市南山区验收地址', storeCity: '深圳', storeZipcode: '518000', packageCount: 0, grossWeightKg: 0, volumeCbm: 0, palletCount: 0, poNo: `PO-${suffix}`, - plannedLoadingAt: '2026-12-31T10:00:00', transitMinutes: 0, payableAmount: 0, receivableAmount: 0, + plannedLoadingAt: '2026-12-31T10:00:00', transitMinutes: 0, payableAmount: 0, currency: 'RMB', remark: `拖车平台业务-${suffix}`, expectedVersion: version, ...overrides } } @@ -172,6 +172,7 @@ export async function run(context) { ['错误柜号归属拒绝创建', { ...base, containerBookingId: (BigInt(identity.containerBookingId) + 1n).toString() }], ['错误提单拒绝创建', { ...base, billOfLadingNo: `WRONG-${identity.suffix}` }], ['错误承运商拒绝创建', { ...base, carrierId: (BigInt(identity.truckSupplier.supplierId) + 1n).toString() }], + ['非法拖车币种拒绝创建', { ...base, currency: 'CNY' }], ]) { const response = await request(context, 'POST', TRUCKS, payload) context.addStep(MODULE, label, response, failure(response), `code=${codeOf(response)}`, OPTIONS) @@ -179,12 +180,21 @@ export async function run(context) { const createdResponse = await request(context, 'POST', TRUCKS, base) const appointment = context.apiSuccess(MODULE, '创建合法拖车预约', createdResponse, '', OPTIONS) const appointmentId = id(appointment?.id); if (!appointmentId) return - context.assert(MODULE, '拖车响应不含客户字段', createdResponse, - !Object.hasOwn(appointment, 'customerId') && !Object.hasOwn(appointment, 'customerName'), '', OPTIONS) + context.assert(MODULE, 'RMB应付费用响应无客户及应收字段', createdResponse, + appointment?.currency === 'RMB' && Number(appointment?.payableAmount) === 0 + && !Object.hasOwn(appointment, 'receivableAmount') + && !Object.hasOwn(appointment, 'customerId') && !Object.hasOwn(appointment, 'customerName'), '', OPTIONS) + const path = `${TRUCKS}/${appointmentId}` + const usdResponse = await request(context, 'PUT', path, + { ...base, currency: 'USD', payableAmount: 12.34, expectedVersion: Number(appointment.version) }) + const usdAppointment = context.apiSuccess(MODULE, '编辑为USD应付费用', usdResponse, '', OPTIONS) + context.assert(MODULE, 'USD应付费用保存且无应收字段', usdResponse, + usdAppointment?.currency === 'USD' && Number(usdAppointment?.payableAmount) === 12.34 + && !Object.hasOwn(usdAppointment || {}, 'receivableAmount'), '', OPTIONS) const duplicate = await request(context, 'POST', TRUCKS, base) context.addStep(MODULE, '同柜第二条非终态预约拒绝且零漂移', duplicate, failure(duplicate), `code=${codeOf(duplicate)}`, OPTIONS) - const path = `${TRUCKS}/${appointmentId}`; const submitKey = `${context.marker}-truck-submit`.slice(0, 128) - const submitBody = { expectedVersion: Number(appointment.version), reason: '幂等提交' } + const submitKey = `${context.marker}-truck-submit`.slice(0, 128) + const submitBody = { expectedVersion: Number(usdAppointment?.version), reason: '幂等提交' } const first = await request(context, 'POST', `${path}/submit`, submitBody, { 'Idempotency-Key': submitKey }) const firstData = context.apiSuccess(MODULE, '提交拖车预约', first, '', OPTIONS) const replay = await request(context, 'POST', `${path}/submit`, submitBody, { 'Idempotency-Key': submitKey }) @@ -206,9 +216,11 @@ export async function run(context) { context.assert(MODULE, '详情字段完整且订舱柜号身份无客户字段漂移', detailResponse, detail?.status === 'DISPATCHED' && detailGaps.length === 0 && !Object.hasOwn(detail || {}, 'customerId') && !Object.hasOwn(detail || {}, 'customerName') + && !Object.hasOwn(detail || {}, 'receivableAmount') + && detail?.currency === 'USD' && Number(detail?.payableAmount) === 12.34 && id(detail?.oceanBookingId) === identity.oceanBookingId && id(detail?.containerBookingId) === identity.containerBookingId, - `missing=${detailGaps.join(',') || 'none'}, oceanBookingId=${id(detail?.oceanBookingId) || 'missing'}, containerBookingId=${id(detail?.containerBookingId) || 'missing'}`, OPTIONS) + `missing=${detailGaps.join(',') || 'none'}, currency=${detail?.currency || 'missing'}, payableAmount=${detail?.payableAmount ?? 'missing'}, oceanBookingId=${id(detail?.oceanBookingId) || 'missing'}, containerBookingId=${id(detail?.containerBookingId) || 'missing'}`, OPTIONS) context.createdResources.truckAppointment = { id: appointmentId, marker: context.marker, finalStatus: 'DISPATCHED', oceanBookingId: identity.oceanBookingId, containerBookingId: identity.containerBookingId } } From 09e81b429c2a18d7a4d09fe40b506407bfc36368 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 1 Sep 2026 10:48:33 +0800 Subject: [PATCH 11/37] test(firstleg): align fulfillment matrix with container customs --- ...fillment-prototype-field-matrix-contract.test.mjs | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/scripts/admin-test-env-modules/fulfillment-prototype-field-matrix-contract.test.mjs b/scripts/admin-test-env-modules/fulfillment-prototype-field-matrix-contract.test.mjs index 07070d4ea..3f5eea1a1 100644 --- a/scripts/admin-test-env-modules/fulfillment-prototype-field-matrix-contract.test.mjs +++ b/scripts/admin-test-env-modules/fulfillment-prototype-field-matrix-contract.test.mjs @@ -6,7 +6,6 @@ import { fileURLToPath } from 'node:url' import { containerFieldGaps, - customsCaseFieldGaps, loadingListFieldGaps, oceanBookingFieldGaps, outboundAggregateFieldGaps, @@ -33,14 +32,13 @@ test('原型锁定货柜、预装、装柜、订舱与拖车关键列表字段', } }) -test('八类 Admin 列表均以左固定操作列开头', () => { +test('现行 Admin 履约列表均以左固定操作列开头', () => { const sources = [ ['container-loading/pages/ContainersView.vue', 1], ['loading-lists/components/LoadingListsView.vue', 2], ['ocean-bookings/pages/OceanBookingsView.vue', 1], ['port-schedules/pages/PortSchedulesView.vue', 1], ['trucking/pages/TruckAppointmentsView.vue', 1], - ['customs/pages/CustomsCasesView.vue', 1], ['outbound-dispatch/pages/OutboundDispatchesView.vue', 1], ] for (const [relative, count] of sources) { @@ -57,7 +55,6 @@ test('Admin 当前列表逐字段列与验收矩阵一致', () => { 'ocean-bookings/pages/OceanBookingsView.vue': ['bookingNo', 'customerName', 'billOfLadingNo', 'carrierName', 'bookingAgent', 'vesselName', 'pol', 'pod', 'etd', 'eta', 'updatedAt'], 'port-schedules/pages/PortSchedulesView.vue': ['schedule.scheduleNo', 'schedule.sourceName', 'schedule.vesselName', 'schedule.departurePortName', 'schedule.destinationPortName', 'schedule.etdAt', 'schedule.etaAt', 'schedule.observedAt', 'expired'], 'trucking/pages/TruckAppointmentsView.vue': ['appointmentNo', 'containerNo', 'company', 'driver', 'vehicleNo', 'store', 'payableAmount', 'currency', 'plannedLoadingAt', 'attachmentCount'], - 'customs/pages/CustomsCasesView.vue': ['customsNo', 'orderLink', 'inboundLink', 'checklistCode', 'checklistVersionNo', 'status', 'updatedAt'], 'outbound-dispatch/pages/OutboundDispatchesView.vue': ['inboundCode', 'customerName', 'containerNo', 'oceanBookingStatus', 'truckAppointmentStatus', 'pickTaskStatus', 'dispatchStatus', 'gate'], } for (const [relative, fields] of Object.entries(expectations)) { @@ -73,7 +70,8 @@ test('正式 DTO/OpenAPI 暴露矩阵使用的稳定字段', () => { ['firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java', ['record Detail', 'record RealContainer', 'bookingAgentName', 'estimatedSailingAt', 'estimatedArrivalAt']], ['firstleg/src/main/java/com/baiyue/firstleg/portschedule/dto/PortScheduleDtos.java', ['record ScheduleView', 'sourceName', 'etdAt', 'etaAt', 'expiresAt']], ['firstleg/src/main/java/com/baiyue/firstleg/entity/TruckAppointmentEntity.java', ['appointmentNo', 'carrierName', 'plannedLoadingAt', 'containerNo']], - ['firstleg/src/main/java/com/baiyue/firstleg/customs/entity/CustomsCaseEntity.java', ['customsNo', 'checklistCode', 'checklistVersionNo', 'updatedAt']], + ['firstleg/src/main/java/com/baiyue/firstleg/customs/container/dto/ContainerCustomsDtos.java', ['record ContainerView', 'aggregateId', 'loadingPlanId', 'containerBookingId', 'planNo', 'cabinetNo', 'merchantStatus', 'coveredTaxRefundCount', 'documents']], + ['firstleg/src/main/resources/openapi/customs-v1.yaml', ['ContainerView:', 'WAITING_CUSTOMER_DOCUMENTS', 'READY_TO_START', 'DECLARING', 'COMPLETED']], ['firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/dto/OutboundDispatchDtos.java', ['record Aggregate', 'customerName', 'pickTaskStatus', 'dispatchStatus', 'GateResult']], ] for (const [relative, fields] of sources) { @@ -90,13 +88,12 @@ test('字段校验器接受正式完整投影并精确暴露缺字段', () => { [oceanBookingFieldGaps, { id: ids.id, bookingNo: 'BK-1', status: 'RELEASED', vesselName: 'VESSEL', voyageNo: 'V1', estimatedSailingAt: at, estimatedArrivalAt: at, customerId: ids.id, customerName: '测试客户', shippingCompanyId: null, shippingCompanyName: null, bookingAgentId: ids.id, bookingAgentName: '代理' }, undefined, 'voyageNo'], [portScheduleFieldGaps, { portSchedule: { schedule: { id: ids.id, scheduleNo: 'PS-1', sourceName: '船公司', vesselName: 'VESSEL', voyageNo: 'V1', departurePortCode: 'CNNGB', destinationPortCode: 'USLAX', etdAt: at, etaAt: at, observedAt: at, updatedAt: at, expiresAt: at }, expired: false } }, undefined, 'sourceName'], [truckAppointmentFieldGaps, { id: ids.id, appointmentNo: 'TA-1', oceanBookingId: ids.id, containerBookingId: ids.container, loadingPlanId: ids.plan, warehouseId: ids.id, warehouseName: '宁波仓', carrierId: ids.id, carrierName: '拖车商', payableAmount: 0, currency: 'RMB', plannedLoadingAt: at, status: 'DRAFT', attachmentCount: 0 }, undefined, 'appointmentNo'], - [customsCaseFieldGaps, { customsCase: { id: ids.id, customsNo: 'CUS-1', orderId: ids.order, inboundId: ids.inbound, containerBookingId: ids.container, checklistVersionId: ids.id, checklistCode: 'CHECK', checklistVersionNo: 'V1', status: 'DOCUMENTS_PENDING', updatedAt: at } }, undefined, 'checklistCode'], [outboundAggregateFieldGaps, { containerBookingId: ids.container, containerNo: 'MSCU1234567', loadingPlanId: ids.plan, loadingPlanStatus: 'COMPLETED', oceanBookingId: ids.id, oceanBookingStatus: 'RELEASED', dispatchStatus: 'READY_FOR_DISPATCH', version: 1, inbounds: [{ orderId: ids.order, inboundId: ids.inbound, inboundCode: 'IN-1', tenantId: ids.id, inboundStatus: 'LOADED', pickTaskId: ids.task, pickTaskStatus: 'COMPLETED' }], gate: { inbounds: [{ inboundId: ids.inbound, inboundCode: 'IN-1', gate: { items: [] } }] } }, undefined, 'containerNo'], ] for (const [validator, sample, options, missing] of samples) { assert.deepEqual(validator(sample, options), []) const invalid = structuredClone(sample) - const target = invalid.portSchedule?.schedule ?? invalid.customsCase ?? invalid + const target = invalid.portSchedule?.schedule ?? invalid target[missing] = '' assert.ok(validator(invalid, options).includes(missing)) } @@ -108,7 +105,6 @@ test('字段断言接入正式生产模块和默认全量入口', () => { 'ocean-bookings.mjs': ['oceanBookingFieldGaps'], 'phase3-port-schedules.mjs': ['portScheduleFieldGaps'], 'truck-appointments.mjs': ['truckAppointmentFieldGaps'], - 'phase3-customs.mjs': ['customsCaseFieldGaps'], 'phase3-outbound-dispatch.mjs': ['outboundAggregateFieldGaps'], } for (const [file, tokens] of Object.entries(consumers)) { From 6eb53751a3f9cbda90f06969f386fc702e27bcba Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 1 Sep 2026 11:01:20 +0800 Subject: [PATCH 12/37] test(pda): keep overseas acceptance business-scoped --- scripts/admin-test-env-modules/phase4-overseas-pda.mjs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index af628bcaf..eb54b6b40 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -291,10 +291,14 @@ async function convertReceivingContainers(context, producers) { { 'Idempotency-Key': key }, context.overseasToken) const plans = success(convertedResponse) && Array.isArray(convertedResponse.body?.data) ? convertedResponse.body.data : [] + const convertedInboundCodes = new Set(plans + .flatMap((item) => Array.isArray(item?.lines) ? item.lines : []) + .map((line) => id(line?.inboundCode)).filter(nonBlank)) context.addStep(MODULE, `整柜转换为逐入仓码独立计划 ${containerNo}`, convertedResponse, plans.length === Number(summary.inboundCodeCount) - && plans.every((item) => nonBlank(item?.planNo) && Array.isArray(item?.lines) && item.lines.length > 0), - `plans=${plans.length}, expected=${summary.inboundCodeCount}; ${trace(convertedResponse)}`, OPTIONS) + && plans.every((item) => Array.isArray(item?.lines) && item.lines.length > 0) + && convertedInboundCodes.size === Number(summary.inboundCodeCount), + `plans=${plans.length}, inboundCodes=${convertedInboundCodes.size}, expected=${summary.inboundCodeCount}; ${trace(convertedResponse)}`, OPTIONS) if (plans.length !== Number(summary.inboundCodeCount)) return false const replayResponse = await context.requestOverseas('POST', '/api/platform/v1/last-leg/inbound-plans/convert-container', body, From 5af79df4bd4ac4e8f46ca7c66b42cb2b312b86ab Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 1 Sep 2026 11:01:34 +0800 Subject: [PATCH 13/37] test(firstleg): align trucking attachment regressions --- ...se3-container-regression-contract.test.mjs | 17 ++++++++------ .../truck-appointments-contract.test.mjs | 3 +++ .../truck-appointments.mjs | 23 +++++++++++++++++++ 3 files changed, 36 insertions(+), 7 deletions(-) diff --git a/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs b/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs index 743ca33d8..4445e2f2b 100644 --- a/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs @@ -71,9 +71,13 @@ test('truck attachment API and Admin upload-reference-delete flow are frozen', ( assert.match(truckAttachmentDialog, /uploadTruckAppointmentAttachment/) assert.match(truckAttachmentDialog, /上传并关联/) assert.match(truckAttachmentDialog, /confirmButtonText: '确认', cancelButtonText: '取消'/) - assert.match(truckAcceptance, /attachmentType=BILL_OF_LADING/) - assert.match(truckAcceptance, /派车前保留有效 BILL_OF_LADING 提单附件/) - assert.match(truckAcceptance, /缺提单号与文件明确拒绝/) + for (const type of ['DISPATCH_ORDER', 'DRIVER_DOCUMENT', 'VEHICLE_DOCUMENT', 'OTHER']) + assert.match(truckAttachmentDialog, new RegExp(type)) + assert.doesNotMatch(truckAttachmentDialog, /BILL_OF_LADING/) + for (const label of ['上传并关联DISPATCH_ORDER拖车附件', '删除首次上传的拖车附件引用', + '重新引用平台归属拖车附件', '附件列表包含重新引用的DISPATCH_ORDER', '删除重新引用的拖车附件']) + assert.match(truckAcceptance, new RegExp(label)) + assert.doesNotMatch(truckAcceptance, /attachmentType=BILL_OF_LADING|派车前保留有效 BILL_OF_LADING 提单附件/) assert.match(matrix, /拖车附件闭环[\s\S]*`LANDED`/) }) @@ -113,10 +117,9 @@ test('PDA task opens only after no-number push, release, four-field link and pla }) test('customer public display uses tenantName without a legalName fallback', () => { - for (const source of [truckList, containerList]) { - assert.match(source, /label: customer\.tenantName/) - } - assert.match(truckForm, /return customer\.tenantName/) + assert.match(containerList, /label: customer\.tenantName/) + for (const source of [truckList, truckForm]) + assert.doesNotMatch(source, /customer\.(?:tenantName|legalName)/) for (const source of [truckList, containerList, truckForm]) assert.doesNotMatch(source, /customer\.legalName\?\.trim\(\) \|\| customer\.tenantName/) assert.match(matrix, /客户公开名称一致性[\s\S]*已统一 `tenantName`[\s\S]*`LANDED`/) diff --git a/scripts/admin-test-env-modules/truck-appointments-contract.test.mjs b/scripts/admin-test-env-modules/truck-appointments-contract.test.mjs index bfdd18387..3eeaab7a3 100644 --- a/scripts/admin-test-env-modules/truck-appointments-contract.test.mjs +++ b/scripts/admin-test-env-modules/truck-appointments-contract.test.mjs @@ -94,6 +94,9 @@ test('HTTP regression is self-contained and covers no-customer and idempotency m '同键同载荷重放', '同键重放版本零漂移', '同键异载荷拒绝', '错误订舱拒绝创建', '错误柜号归属拒绝创建', '错误提单拒绝创建', '错误承运商拒绝创建', '非法拖车币种拒绝创建', 'RMB应付费用响应无客户及应收字段', '编辑为USD应付费用', 'USD应付费用保存且无应收字段', + '上传并关联DISPATCH_ORDER拖车附件', '拖车附件类型仅使用运营附件而非订舱提单', + '删除首次上传的拖车附件引用', '重新引用平台归属拖车附件', '附件列表包含重新引用的DISPATCH_ORDER', + '删除重新引用的拖车附件', '派车命令返回状态身份与版本正确', '派车后回查拖车完整详情', '详情字段完整且订舱柜号身份无客户字段漂移']) assert.match(source, new RegExp(label)) assert.match(source, /const detailResponse = await request\(context, 'GET', path\); const detail = data\(detailResponse\)/) diff --git a/scripts/admin-test-env-modules/truck-appointments.mjs b/scripts/admin-test-env-modules/truck-appointments.mjs index 077376ab9..3fc3fae36 100644 --- a/scripts/admin-test-env-modules/truck-appointments.mjs +++ b/scripts/admin-test-env-modules/truck-appointments.mjs @@ -185,6 +185,29 @@ export async function run(context) { && !Object.hasOwn(appointment, 'receivableAmount') && !Object.hasOwn(appointment, 'customerId') && !Object.hasOwn(appointment, 'customerName'), '', OPTIONS) const path = `${TRUCKS}/${appointmentId}` + const attachmentForm = new FormData(); attachmentForm.append('attachmentType', 'DISPATCH_ORDER') + attachmentForm.append('file', new Blob([`${context.marker}-DISPATCH_ORDER`], { type: 'application/pdf' }), + `dispatch-order-${identity.suffix}.pdf`) + const uploadAttachmentResponse = await context.requestMultipart('POST', `${path}/attachments/upload`, + attachmentForm, context.token) + const uploadedAttachment = context.apiSuccess(MODULE, '上传并关联DISPATCH_ORDER拖车附件', uploadAttachmentResponse, '', OPTIONS) + const uploadedAttachmentId = id(uploadedAttachment?.id); const uploadedFileId = id(uploadedAttachment?.fileId) + context.assert(MODULE, '拖车附件类型仅使用运营附件而非订舱提单', uploadAttachmentResponse, + Boolean(uploadedAttachmentId && uploadedFileId) && uploadedAttachment?.attachmentType === 'DISPATCH_ORDER', + `attachmentId=${uploadedAttachmentId || 'missing'}, fileId=${uploadedFileId || 'missing'}`, OPTIONS) + const deleteUploadedResponse = await request(context, 'DELETE', `${path}/attachments/${uploadedAttachmentId}`) + context.apiSuccess(MODULE, '删除首次上传的拖车附件引用', deleteUploadedResponse, '', OPTIONS) + const referenceResponse = await request(context, 'POST', `${path}/attachments`, + { fileId: uploadedFileId, attachmentType: 'DISPATCH_ORDER' }) + const referencedAttachment = context.apiSuccess(MODULE, '重新引用平台归属拖车附件', referenceResponse, '', OPTIONS) + const referencedAttachmentId = id(referencedAttachment?.id) + const attachmentListResponse = await request(context, 'GET', `${path}/attachments`) + const attachmentList = context.apiSuccess(MODULE, '查询拖车运营附件列表', attachmentListResponse, '', OPTIONS) + context.assert(MODULE, '附件列表包含重新引用的DISPATCH_ORDER', attachmentListResponse, + Array.isArray(attachmentList) && attachmentList.some((item) => id(item?.id) === referencedAttachmentId + && item?.attachmentType === 'DISPATCH_ORDER'), `attachmentId=${referencedAttachmentId || 'missing'}`, OPTIONS) + const deleteReferencedResponse = await request(context, 'DELETE', `${path}/attachments/${referencedAttachmentId}`) + context.apiSuccess(MODULE, '删除重新引用的拖车附件', deleteReferencedResponse, '', OPTIONS) const usdResponse = await request(context, 'PUT', path, { ...base, currency: 'USD', payableAmount: 12.34, expectedVersion: Number(appointment.version) }) const usdAppointment = context.apiSuccess(MODULE, '编辑为USD应付费用', usdResponse, '', OPTIONS) From 7ace1ded5630f2c779d4e4846d179e5b08b25e35 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 1 Sep 2026 11:02:10 +0800 Subject: [PATCH 14/37] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 4 ++ scripts/AGENTS.md | 38 +++++++++++++++++++ scripts/README.md | 4 +- ...se4-overseas-master-data-contract.test.mjs | 2 +- scripts/agents-index-contract.ps1 | 15 ++++++++ warehouse/AGENTS.md | 2 +- 6 files changed, 62 insertions(+), 3 deletions(-) create mode 100644 scripts/AGENTS.md diff --git a/AGENTS.md b/AGENTS.md index 397b49211..f60ae8011 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,7 @@ | 履约分配、入库、测量、审核、FBA 计费 | `firstleg/` | `firstleg/AGENTS.md` | | Firstleg 内部模块化与依赖基线 | `firstleg/` | `firstleg/docs/firstleg-internal-modularization-baseline.md`、`firstleg/scripts/analyze-firstleg-dependencies.mjs`;价格簿查询和客户统计按端口/适配器切片,不改变服务与数据库边界 | | Mapper 新增、移动、Bean 缺失与扫描遗漏 | 目标持久化服务 | 所有 Mapper 显式 `@Mapper`,启动类按服务根包注解过滤扫描;先运行 `node scripts/mapper-scan-contract.mjs`,再按目标模块执行 `test-compile/test`,不得等部署启动发现 | +| 脚本管理、全量验证、测试造数与诊断 | 仓库根 `scripts/`;服务专属脚本留在 `/scripts/` | 先读 `scripts/AGENTS.md` 和 `scripts/README.md`;Admin HTTP 内部模块继续读 `scripts/admin-test-env-modules/README.md`。新增入口必须登记用途、目标环境、数据副作用和报告位置,并通过 `scripts/script-documentation-contract.test.mjs` | | 分柜、柜次、智能排柜、预装/装柜清单、PDA | `firstleg/` | `firstleg/AGENTS.md`,再按 `split/`、`loading/`、`outbound/` 定位;预装/正式装柜详情阶段由 `/loading-plans/{planId}/lifecycle` 按稳定 planId 解析,不从 404 或文案推断 | | 合柜报关 / 同柜不可拆组、跨客户合柜、整组分配/移除 | `firstleg/` | `loading/coload/controller/PlatformColoadGroupController.java`、`service/ColoadGroupService.java`、`persistence/ColoadGroupMapper.java`、`src/main/resources/openapi/coload-groups-v1.yaml`;按订单建组并冻结全部可用入仓片段,仅要求同国内仓、同渠道 | | 智能装柜入仓码候选与箱数分配 | `firstleg/` | `controller/PlatformSmartLoadingController.java` 的 `/inbound-candidates`、`/plans/{planId}/inbound-assignments`,业务编排 `service/SmartLoadingService.java` | @@ -94,12 +95,15 @@ ## 全量 HTTP 验收脚本路由 +- 脚本管理的唯一总索引是 `scripts/README.md`,最近执行规则是 `scripts/AGENTS.md`。仓库级脚本、Admin 内部模块、服务局部合同和部署脚本的目录归属以这两个文件为准,不再通过无范围搜索猜测入口。 - 全量验收唯一指向测试环境 HTTP:平台端执行 `scripts/admin-test-env-acceptance.mjs`,客户端执行 `scripts/client-test-env-acceptance.mjs`;二期专项场景才单独执行 `scripts/client-phase2-test-env-acceptance.mjs`,不替代客户端全量入口。 +- 固定客户待审核订单使用 `scripts/create-pending-review-orders.mjs`;固定“IT测试”与“义乌仓”、随机真实线路并推进至 `WAIT_SPLIT` 使用 `scripts/it-test-wait-split-bulk.mjs`。二者都是造数入口,不属于全量验证;会写目标环境且不自动清理成功数据。 - Admin 总入口默认按模块列表覆盖平台全链路;需要定位单个业务时使用 `--modules `,模块名称和静态合同先查 `scripts/admin-test-env-modules/README.md`。 - 这些脚本会创建、更新或最终停用测试数据,必须在当前对话明确授权测试环境验收后执行,并保存 JSON 报告;未授权不得请求 Gateway。 - 后端 Maven、前端 `pnpm test/typecheck`、PDA `node --test` 和数据库 Dry-run 都是本地质量检查,不属于全量 HTTP 验收。 - Java 模块、OpenAPI 覆盖、JaCoCo 与 SpotBugs 聚合摘要使用 `node scripts/java-quality-summary.mjs`;默认仅报告存量缺口,是否阻断由显式 `--require-*` 参数决定,输出位于 `target/` 时不得作为业务事实或提交物。 - HTTP 全量通过后,才进入 Admin/Client 真实页面调用、浏览器样式和 PDA 页面/设备验收;自动化 HTTP 通过不代表视觉验收完成。 +- 修改脚本目录、入口或说明后,至少运行 `node --test scripts/script-documentation-contract.test.mjs`;修改 `AGENTS.md` 后继续运行 `./scripts/agents-index-contract.ps1`。两者均为离线只读合同,不能代替真实 HTTP 验收。 ## 阶段文档入口 diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md new file mode 100644 index 000000000..d49ead666 --- /dev/null +++ b/scripts/AGENTS.md @@ -0,0 +1,38 @@ +# 后端脚本管理指引 + +## 适用范围与读取顺序 + +- 本文件适用于后端根 `scripts/` 及其子目录;服务自己的 `/scripts/` 继续受对应服务 `AGENTS.md` 约束。 +- 先用本文件判断脚本类别,再到 `README.md` 查询仓库级入口的用途、参数边界和副作用;Admin HTTP 模块继续读 `admin-test-env-modules/README.md`。 +- 不扫描 `target/`、`reports/`、`.logs/`、`node_modules/`、`.codex-*`、`output/` 或 `outputs/` 寻找业务脚本和验收事实。 + +## 脚本分类与入口 + +| 类别 | 正式入口 | 管理边界 | +|---|---|---| +| Admin 全量 HTTP 验证 | `admin-test-env-acceptance.mjs` | 默认模块覆盖平台全链路;定向验证使用 `--modules`,模块注册和依赖以总入口及 `admin-test-env-modules/README.md` 为准 | +| Client 全量 HTTP 验证 | `client-test-env-acceptance.mjs` | 客户工作台完整链路;`client-phase2-test-env-acceptance.mjs` 只是二期专项,不替代全量入口 | +| 待审核订单造数 | `create-pending-review-orders.mjs` | 使用已配置客户创建并提交订单,停在待审核;不审核、不推进后续履约 | +| 批量待分柜造数 | `it-test-wait-split-bulk.mjs` | 固定“IT测试”和“义乌仓”,随机客户可选真实线路并尽力推进到 `WAIT_SPLIT`;失败记录后继续,不自动删除成功数据;只复用 `TP-001`/`YW-TP-002`,不新增托盘或库位,普通位不足落 `A0` | +| Admin HTTP 内部模块 | `admin-test-env-modules/*.mjs` | 由 Admin 总入口动态加载,除文档明确的独立编排器外不得当作独立全量入口;`*contract.test.mjs` 只做离线合同 | +| 定向 HTTP/诊断 | 以 `README.md` 的“仓库级入口清单”为准 | 必须明确是只读诊断还是会补证据、创建资源或推进状态,不能用诊断成功冒充全量通过 | +| 本地质量与静态合同 | `backend-full-regression.ps1`、`frontend-full-regression.ps1`、`mapper-scan-contract.mjs`、`script-documentation-contract.test.mjs` 等 | 默认不访问测试环境;数据库非 dry-run、Nacos 发布等有写入能力的入口必须按 README 单独识别 | +| 部署与基础设施 | `../deploy/scripts/`、`../deploy/rocketmq/` | 靠近部署组件管理,不移动到根脚本目录;部署、初始化数据库或发布配置必须获得当前轮明确授权 | +| 服务局部合同 | `../firstleg/scripts/`、`../lastleg-service/scripts/`、`../warehouse/scripts/` | 只服务所属模块,入口与命令登记在服务 `AGENTS.md` 或局部 README;不得散落到 `src/test/scripts/` | + +## 新增与修改规则 + +- 跨服务编排、全仓质量检查、测试环境验收和通用诊断放在根 `scripts/`;只理解单一服务源码或数据的脚本放在 `/scripts/`。 +- 可直接运行的脚本必须在 `README.md` 登记:用途、目标环境、是否写数据、凭据来源、报告位置、失败/重跑语义和清理边界。内部模块必须登记到 `admin-test-env-modules/README.md`,并按需同步总入口模块注册及依赖展开。 +- 文件命名使用稳定业务语义:离线 Node 合同以 `.test.mjs` 结尾;环境写入入口显式包含 `test-env` 或在 README 中标记目标环境;fixture/verify 必须区分准备写入和只读核验。 +- 不在源码、AGENTS、README 或报告中写入密码、Token、私钥、真实客户数据和固定环境密钥;环境地址与账号从入口已支持的参数或本机环境配置读取。 +- 不直接写业务数据库伪造 HTTP 验收成功,不吞掉 `deploymentBlocker`、业务错误码、`requestId` 或 `traceId`。造数脚本的部分成功数据默认保留,清理、重置或删除必须另行授权。 +- 不默认并发运行共享环境造数。只有用户明确要求并确认序号、报告目录和服务容量边界时,才允许拆分 worker;每个 worker 必须使用不重叠序号和独立报告目录。 +- 修改公共参数、上下文或模块签名时,定向检查总入口、生产模块、fixture、合同测试、README 和调用命令,禁止保留旧参数兼容分支。 + +## 最低验证 + +- 修改单个 Node 脚本先运行 `node --check ") Page page(Page p,@Param("q") Query q); @Update("UPDATE fl_truck_appointment SET status=#{toStatus}, actual_arrived_warehouse_at=CASE WHEN #{toStatus}='ARRIVED_WAREHOUSE' THEN CURRENT_TIMESTAMP(3) ELSE actual_arrived_warehouse_at END, actual_loading_at=CASE WHEN #{toStatus}='LOADING' THEN CURRENT_TIMESTAMP(3) ELSE actual_loading_at END, actual_departed_at=CASE WHEN #{toStatus}='DEPARTED' THEN CURRENT_TIMESTAMP(3) ELSE actual_departed_at END, revision=revision+1, version=version+1, updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND status=#{fromStatus} AND version=#{version}") int transition(@Param("id") long id,@Param("fromStatus") String from,@Param("toStatus") String to,@Param("version") int version); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/TruckAppointmentRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/TruckAppointmentRepository.java index 232ce6740..4bbcce2df 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/TruckAppointmentRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/TruckAppointmentRepository.java @@ -4,5 +4,5 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba public PageResult page(Query q){return PageResult.from(mapper.page(new Page<>(q.page(),q.size()),q));} public TruckAppointmentEntity find(long id){return mapper.selectById(id);} public void insert(TruckAppointmentEntity e){mapper.insert(e);} public int transition(long id,String from,String to,int v){return mapper.transition(id,from,to,v);} public int dispatch(long id,String from,String billOfLadingNo,int v){return mapper.dispatch(id,from,billOfLadingNo,v);} public int reschedule(long id,java.time.LocalDateTime at,String reason,int v){return mapper.reschedule(id,at,reason,v);} public void history(TruckAppointmentHistoryEntity e){history.insert(e);} public void operation(TruckAppointmentOperationEntity e){operations.insert(e);} public void attachment(TruckAppointmentAttachmentEntity e){attachments.insert(e);} public boolean claimOperation(long operationId,long appointmentId,String action,String key,String hash){return operations.claim(operationId,appointmentId,action,key,hash)==1;} public TruckAppointmentOperationEntity operation(String key){return operations.find(key);} public int completeOperation(long appointmentId,String action,String key,String response){return operations.complete(appointmentId,action,key,response);} public int updateEdit(TruckAppointmentEntity e,int v){return mapper.updateEdit(e,v);} public java.util.List histories(long id){return history.selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper().eq(TruckAppointmentHistoryEntity::getAppointmentId,id).orderByAsc(TruckAppointmentHistoryEntity::getCreatedAt));} public java.util.List attachments(long id){return attachments.selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper().eq(TruckAppointmentAttachmentEntity::getAppointmentId,id).orderByAsc(TruckAppointmentAttachmentEntity::getUploadedAt));} public int deleteAttachment(long appointmentId,long attachmentId){return attachments.delete(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper().eq(TruckAppointmentAttachmentEntity::getAppointmentId,appointmentId).eq(TruckAppointmentAttachmentEntity::getId,attachmentId));} - public java.util.List activeForUpdate(long containerBookingId){return mapper.activeForUpdate(containerBookingId);} public int completeLoadingFromPda(long id,String fromStatus,int version){return mapper.completeLoadingFromPda(id,fromStatus,version);} + public TruckAppointmentEntity firstActiveForUpdate(long containerBookingId){return mapper.firstActiveForUpdate(containerBookingId);} public java.util.List activeForUpdate(long containerBookingId){return mapper.activeForUpdate(containerBookingId);} public int completeLoadingFromPda(long id,String fromStatus,int version){return mapper.completeLoadingFromPda(id,fromStatus,version);} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java index 500c7eeae..bd2fe0d6f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java @@ -52,7 +52,7 @@ public class TruckAppointmentService { @Transactional public TruckAppointmentAttachmentEntity upload(long id,String type,MultipartFile file,long uid){String attachmentType=attachmentType(type);required(id);try{var response=files.upload(internalToken,uid,0L,"TRUCK_APPOINTMENT_ATTACHMENT",Long.toString(id),file);if(response==null||!response.successful()||response.data()==null)throw bad("附件上传失败");var m=response.data();TruckAppointmentAttachmentEntity a=new TruckAppointmentAttachmentEntity();a.setId(IdWorker.getId());a.setAppointmentId(id);a.setFileId(m.fileId());a.setAttachmentType(attachmentType);a.setFileName(m.fileName());a.setFileHash(m.sha256());a.setVisibility("PLATFORM");a.setUploadedBy(uid);a.setUploadedAt(LocalDateTime.now());repo.attachment(a);return a;}catch(FeignException ex){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");}} @Transactional public TruckAppointmentAttachmentEntity attach(long id,AttachmentRequest r,long uid){String attachmentType=attachmentType(r.attachmentType());required(id);TruckAppointmentFileClient.Metadata metadata;try{var response=files.metadata(internalToken,0L,"TRUCK_APPOINTMENT_ATTACHMENT",Long.toString(id),r.fileId());if(response==null||!response.successful()||response.data()==null)throw bad("附件不存在或归属不匹配");metadata=response.data();}catch(FeignException ex){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");} TruckAppointmentAttachmentEntity a=new TruckAppointmentAttachmentEntity();a.setId(IdWorker.getId());a.setAppointmentId(id);a.setFileId(metadata.fileId());a.setAttachmentType(attachmentType);a.setFileName(metadata.fileName());a.setFileHash(metadata.sha256());a.setVisibility("PLATFORM");a.setUploadedBy(uid);a.setUploadedAt(LocalDateTime.now());repo.attachment(a);return a;} @Transactional public void deleteAttachment(long id,long attachmentId){required(id);if(repo.deleteAttachment(id,attachmentId)!=1)throw new BizException(ResultCode.NOT_FOUND,"附件引用不存在");} - @Transactional public TruckAppointmentEntity create(long uid,String name,SaveRequest r){if(r.expectedVersion()==null||r.expectedVersion()!=0)throw bad("新增 expectedVersion 必须为0");validateRequired(r);AssociationContext links=validateAssociations(r);if(!repo.activeForUpdate(r.containerBookingId()).isEmpty())throw conflict("当前真实柜已有有效拖车预约");var carrier=suppliers.trucking(r.carrierId(),r.carrierName());TruckAppointmentEntity e=new TruckAppointmentEntity();e.setId(IdWorker.getId());e.setAppointmentNo(documentNumbers.next("TA"));e.setStatus(TruckAppointmentStatus.DRAFT);e.setRevision(0);e.setVersion(0);e.setCreatedAt(LocalDateTime.now());e.setUpdatedAt(e.getCreatedAt());copy(e,r,links,carrier.name());try{repo.insert(e);}catch(DuplicateKeyException exception){if(exception.getMessage()!=null&&exception.getMessage().contains("uk_truck_active_container"))throw conflict("当前真实柜已有有效拖车预约");throw exception;}record(e,null,e.getStatus().name(),"CREATE",null,uid,name);return enrich(e);} + @Transactional public TruckAppointmentEntity create(long uid,String name,SaveRequest r){if(r.expectedVersion()==null||r.expectedVersion()!=0)throw bad("新增 expectedVersion 必须为0");validateRequired(r);TruckAppointmentEntity active=repo.firstActiveForUpdate(r.containerBookingId());if(active!=null)throw existingAppointmentConflict(active);AssociationContext links=validateAssociations(r);var carrier=suppliers.trucking(r.carrierId(),r.carrierName());TruckAppointmentEntity e=new TruckAppointmentEntity();e.setId(IdWorker.getId());e.setAppointmentNo(documentNumbers.next("TA"));e.setStatus(TruckAppointmentStatus.DRAFT);e.setRevision(0);e.setVersion(0);e.setCreatedAt(LocalDateTime.now());e.setUpdatedAt(e.getCreatedAt());copy(e,r,links,carrier.name());try{repo.insert(e);}catch(DuplicateKeyException exception){if(exception.getMessage()!=null&&exception.getMessage().contains("uk_truck_active_container"))throw existingAppointmentConflict(repo.firstActiveForUpdate(r.containerBookingId()));throw exception;}record(e,null,e.getStatus().name(),"CREATE",null,uid,name);return enrich(e);} @Transactional public TruckAppointmentEntity edit(long id,long uid,String name,SaveRequest r){validateRequired(r);TruckAppointmentEntity e=required(id);checkVersion(e,r.expectedVersion());if(!(e.getStatus()==TruckAppointmentStatus.DRAFT||e.getStatus()==TruckAppointmentStatus.PENDING_DISPATCH||e.getStatus()==TruckAppointmentStatus.RESCHEDULED))throw conflict("当前状态不可编辑");AssociationContext links=validateAssociations(r);var carrier=suppliers.trucking(r.carrierId(),r.carrierName());copy(e,r,links,carrier.name());if(repo.updateEdit(e,r.expectedVersion())!=1)throw conflict("版本已变化,请刷新后重试");record(e,e.getStatus().name(),e.getStatus().name(),"EDIT",null,uid,name);return required(id);} @Transactional public TruckAppointmentEntity action(long id,String action,ActionRequest r,String idem,String clientHash,long uid,String name){String requestHash=hash(action,id,r);verifyClientHash(clientHash,requestHash);TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;TruckAppointmentEntity e=required(id); checkVersion(e,r.expectedVersion()); if("RESCHEDULE".equals(action))throw conflict("改约请使用专用接口"); TruckAppointmentStatus from=e.getStatus(),to=next(from,action); String dispatchBill="DISPATCH".equals(action)?requireDispatchReady(e):null; int changed="DISPATCH".equals(action)?repo.dispatch(id,from.name(),dispatchBill,e.getVersion()):repo.transition(id,from.name(),to.name(),e.getVersion()); if(changed!=1)throw conflict("状态或版本已变化,请刷新后重试"); TruckAppointmentEntity after=required(id);record(after,from.name(),to.name(),action,r.reason(),uid,name);return completeOperation(id,action,idem,after);} @Transactional public TruckAppointmentEntity reschedule(long id,LocalDateTime at,ActionRequest r,String idem,String clientHash,long uid,String name){String action="RESCHEDULE";String requestHash=hash(action,id,new ReschedulePayload(at,r));verifyClientHash(clientHash,requestHash);TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;TruckAppointmentEntity e=required(id);checkVersion(e,r.expectedVersion()); if(e.getStatus()==TruckAppointmentStatus.COMPLETED||e.getStatus()==TruckAppointmentStatus.CANCELLED)throw conflict("当前状态不可改约"); String from=e.getStatus().name(); if(repo.reschedule(id,at,r.reason(),e.getVersion())!=1)throw conflict("状态或版本已变化,请刷新后重试");TruckAppointmentEntity after=required(id);record(after,from,"RESCHEDULED",action,r.reason(),uid,name);return completeOperation(id,action,idem,after);} @@ -104,6 +104,7 @@ public class TruckAppointmentService { private static String releasedBookingBill(OceanBookingEntity booking,String requested){String authoritative=trim(booking.getBillOfLadingNo());if(!StringUtils.hasText(authoritative))throw conflict("关联已放舱订舱缺少提单号");if(StringUtils.hasText(requested)&&!authoritative.equals(requested))throw bad("billOfLadingNo与关联订舱不一致");return authoritative;} private static String attachmentType(String value){String normalized=trim(value);if(!ATTACHMENT_TYPES.contains(normalized))throw bad("不支持的拖车附件类型");return normalized;} private static String currency(String value){if(!StringUtils.hasText(value))return "RMB";String normalized=value.trim();if(!CURRENCIES.contains(normalized))throw bad("currency仅支持RMB或USD");return normalized;} + private static BizException existingAppointmentConflict(TruckAppointmentEntity existing){String appointmentNo=existing==null?null:trim(existing.getAppointmentNo());return conflict(StringUtils.hasText(appointmentNo)?"该柜已存在拖车预约 "+appointmentNo+",请继续编辑":"该柜已存在拖车预约,请刷新后继续编辑");} private static String trim(String value){return StringUtils.hasText(value)?value.trim():null;} private record AssociationContext(OceanBookingEntity booking,ContainerBookingEntity container,Long warehouseId,String billOfLadingNo){} private void record(TruckAppointmentEntity e,String from,String to,String action,String reason,long uid,String name){TruckAppointmentHistoryEntity h=new TruckAppointmentHistoryEntity();h.setId(IdWorker.getId());h.setAppointmentId(e.getId());h.setRevision(e.getRevision());h.setAction(action);h.setFromStatus(from);h.setToStatus(to);h.setReason(reason);h.setOperatorId(uid);h.setOperatorName(name);h.setCreatedAt(LocalDateTime.now());repo.history(h);} diff --git a/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml b/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml index 8beee8d42..57c9df103 100644 --- a/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml +++ b/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml @@ -21,6 +21,7 @@ paths: records: {type: array, items: {$ref: '#/components/schemas/DetailStableReferences'}} post: summary: Create a truck appointment for a released booking and its allocated real container + description: If the real container already has a non-terminal appointment, the server returns A0409 with the existing appointment number and instructs the operator to continue editing. This duplicate guard runs before booking-document validation; the database unique key provides the same response for concurrent creates. requestBody: required: true content: @@ -28,6 +29,7 @@ paths: schema: {$ref: '#/components/schemas/SaveRequest'} responses: '200': {description: 'Created appointment', content: {application/json: {schema: {$ref: '#/components/schemas/DetailEnvelope'}}}} + '409': {description: 'A0409 - this container already has a non-terminal appointment; message includes its appointment number and asks the operator to continue editing'} /api/platform/v1/truck-appointments/{id}: parameters: [{$ref: '#/components/parameters/AppointmentId'}] get: diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/TruckAppointmentControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/TruckAppointmentControllerContractTest.java index a359e77f7..4ffcde1c2 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/TruckAppointmentControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/TruckAppointmentControllerContractTest.java @@ -30,6 +30,7 @@ class TruckAppointmentControllerContractTest { } } @Test void openApiDocumentsLifecycleIdempotencyAndActualTimes() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("/api/platform/v1/truck-appointments/{id}/{action}:","/api/platform/v1/truck-appointments/{id}/reschedule:","name: Idempotency-Key","required: true","actual event timestamps");} + @Test void openApiDocumentsActionableDuplicateCreateConflict() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("A0409","existing appointment number","continue editing","before booking-document validation","database unique key");} @Test void openApiRequiresReleasedBookingAndAllocatedRealContainerWithoutCustomerScope() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("required: [oceanBookingId, containerBookingId, carrierId, plannedLoadingAt, expectedVersion]","RELEASED ocean booking","ACTIVE real-numbered container","OCEAN_BOOKING_DOCUMENT","valid unique ISO 6346 container number","schema: {$ref: '#/components/schemas/DetailEnvelope'}","required: [code, message, data, timestamp]","allOf: [{$ref: '#/components/schemas/DetailStableReferences'}]","nullable: true").doesNotContain("customerId", "customerName", "Filter by customer");} @Test void openApiKeepsOnlyNonBookingTruckAttachments() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("AttachmentRequest:","required: [fileId, attachmentType]","required: [attachmentType, file]","TRUCK_APPOINTMENT_ATTACHMENT","Deletes the appointment reference only","DetailStableReferences:","oceanBookingNo:","shippingCompanyName:","bookingAgentName:","cabinetNo:","billOfLadingNo:").doesNotContain("enum: [BILL_OF_LADING, DISPATCH_ORDER");} @Test void openApiExposesOnlyPayableWithFixedCurrencies() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("payableAmount:","enum: [RMB, USD]","default: RMB").doesNotContain("receivableAmount");} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/TruckAppointmentMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/TruckAppointmentMapperContractTest.java index e786d8061..d373d1acb 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/TruckAppointmentMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/TruckAppointmentMapperContractTest.java @@ -23,6 +23,9 @@ class TruckAppointmentMapperContractTest { String active = TruckAppointmentMapper.class.getMethod("activeForUpdate", long.class).getAnnotation(Select.class).value()[0]; assertThat(active).contains("container_booking_id=#{containerBookingId}", "FOR UPDATE") .contains("'COMPLETED','CANCELLED','EXCEPTION'"); + String firstActive = TruckAppointmentMapper.class.getMethod("firstActiveForUpdate", long.class).getAnnotation(Select.class).value()[0]; + assertThat(firstActive).contains("container_booking_id=#{containerBookingId}", "ORDER BY created_at,id", "LIMIT 1 FOR UPDATE") + .contains("'COMPLETED','CANCELLED','EXCEPTION'"); String pdaComplete = TruckAppointmentMapper.class.getMethod("completeLoadingFromPda", long.class, String.class, int.class).getAnnotation(Update.class).value()[0]; assertThat(pdaComplete).contains("status='COMPLETED'", "actual_loading_at=COALESCE", "status=#{fromStatus}", "version=#{version}", "revision=revision+1", "version=version+1"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java index 4eac505d4..b55a1d46c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java @@ -125,24 +125,35 @@ class TruckAppointmentServiceTest { @Test void createRejectsAnotherActiveAppointmentForTheSameRealContainerWithoutWriting() { SaveRequest request = validRequest(); - when(repository.activeForUpdate(88L)).thenReturn(List.of(entity(TruckAppointmentStatus.DRAFT, 0))); + TruckAppointmentEntity existing = entity(TruckAppointmentStatus.DRAFT, 0); + existing.setAppointmentNo("TA-260901-0003"); + when(repository.firstActiveForUpdate(88L)).thenReturn(existing); assertThatThrownBy(() -> service.create(7L, "admin", request)) - .isInstanceOf(BizException.class).hasMessageContaining("已有有效拖车预约"); + .isInstanceOf(BizException.class).hasMessageContaining("该柜已存在拖车预约 TA-260901-0003,请继续编辑"); + verifyNoInteractions(oceanBookingDocuments); verify(repository, never()).insert(any()); verify(repository, never()).history(any()); + assertThat(existing.getStatus()).isEqualTo(TruckAppointmentStatus.DRAFT); + assertThat(existing.getVersion()).isZero(); } @Test void concurrentCreateUniqueCollisionBecomesBusinessConflictWithoutHistory() { SaveRequest request = validRequest(); + TruckAppointmentEntity concurrent = entity(TruckAppointmentStatus.DRAFT, 0); + concurrent.setAppointmentNo("TA-260901-0003"); + when(repository.firstActiveForUpdate(88L)).thenReturn(null, concurrent); doThrow(new DuplicateKeyException("Duplicate entry for key 'uk_truck_active_container'")) .when(repository).insert(any()); assertThatThrownBy(() -> service.create(7L, "admin", request)) - .isInstanceOf(BizException.class).hasMessageContaining("已有有效拖车预约"); + .isInstanceOf(BizException.class).hasMessageContaining("该柜已存在拖车预约 TA-260901-0003,请继续编辑"); + verify(repository, times(2)).firstActiveForUpdate(88L); verify(repository, never()).history(any()); + assertThat(concurrent.getStatus()).isEqualTo(TruckAppointmentStatus.DRAFT); + assertThat(concurrent.getVersion()).isZero(); } @Test void createPreservesMaximumChineseRemarkAndBoundaryValues() { From 9a3b408de0a04df9e6093ed2a208441c4a59c4e8 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 1 Sep 2026 11:38:42 +0800 Subject: [PATCH 19/37] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/README.md | 4 +- .../it-test-wait-split-bulk-contract.test.mjs | 3 +- scripts/client-phase2-test-env-acceptance.mjs | 17 ++++----- scripts/client-test-env-acceptance.mjs | 22 +++++------ ...te-pending-review-orders-contract.test.mjs | 14 ++++--- scripts/create-pending-review-orders.mjs | 31 +++++++--------- .../http-script-environment-contract.test.mjs | 37 +++++++++++++++++++ 7 files changed, 82 insertions(+), 46 deletions(-) create mode 100644 scripts/http-script-environment-contract.test.mjs diff --git a/scripts/README.md b/scripts/README.md index cca052d0f..85c573b56 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -66,7 +66,7 @@ |---|---|---|---| | 平台后台全模块 HTTP 验收 | `scripts/admin-test-env-acceptance.mjs` | 会创建、更新或最终停用测试数据 | `--modules` 默认列表中的平台客户、仓储、订单、入库、分柜、装柜、船期、拖车、报关、财务、通知、统计等模块 | | 客户工作台完整 HTTP 链路验收 | `scripts/client-test-env-acceptance.mjs` | 会创建测试企业、订单、入库、权限和站内消息数据 | 注册/审核、客户隔离、订单、入库计费、文件、财务、客户侧权限,以及我的消息未读/已读/全部已读链路 | -| 固定客户批量创建待审核订单 | `scripts/create-pending-review-orders.mjs` | 只使用固定客户账号创建指定数量订单 | 登录 `17512095155`,选择真实可计价仓库/渠道,创建并提交订单,停在待审核,不创建客户、不审核订单 | +| 固定客户批量创建待审核订单 | `scripts/create-pending-review-orders.mjs` | 只使用 `MERCHANT_TEST_USERNAME/PASSWORD` 配置的客户账号创建指定数量订单 | 使用 `ADMIN_TEST_GATEWAY`,选择真实可计价仓库/渠道,创建并提交订单,停在待审核,不创建客户、不审核订单 | | Client 二期专项 HTTP 验收 | `scripts/client-phase2-test-env-acceptance.mjs` | 会写入测试环境业务数据 | 二期客户入库、测量、费用和权限专项场景;不是替代 Client 全量入口 | | 人工分柜至 LOADED 分段验收 | `scripts/admin-test-env-acceptance.mjs` 的 `manual-*-fixture/verify` 可选模块;步骤和参数见 `scripts/admin-test-env-modules/README.md` | fixture 通过正式 HTTP 准备本轮数据,verify 只读核验;不进入默认全量 | `WAIT_SPLIT → 人工分柜 → 推送预装 → 订舱关联/计划确认 → PDA 领取与原子 batch → 五类证据/完成 → LOADED` | @@ -76,7 +76,7 @@ ## 固定客户待审核订单 -需要为人工审核准备固定数量的订单时,使用 `create-pending-review-orders.mjs`。可直接在文件顶部 `CONFIG` 中填写 `GATEWAY`、`USERNAME`、`PASSWORD` 和 `COUNT`;固定账号为 `17512095155`。脚本不会创建客户,也不会调用平台审核、入库或后续履约接口。数量支持 `1-100`,每张订单创建后立即提交并回查客户端状态为 `SUBMITTED`,报告统一记录为 `PENDING_REVIEW`。 +需要为人工审核准备固定数量的订单时,使用 `create-pending-review-orders.mjs`。Gateway 统一读取 `ADMIN_TEST_GATEWAY`,客户账号统一读取 `MERCHANT_TEST_USERNAME/PASSWORD`;数量可通过 `--count` 或 `PENDING_REVIEW_ORDER_COUNT` 设置,默认 50。脚本不会创建客户,也不会调用平台审核、入库或后续履约接口。数量支持 `1-100`,每张订单创建后立即提交并回查客户端状态为 `SUBMITTED`,报告统一记录为 `PENDING_REVIEW`。 ```powershell node scripts/create-pending-review-orders.mjs diff --git a/scripts/admin-test-env-modules/it-test-wait-split-bulk-contract.test.mjs b/scripts/admin-test-env-modules/it-test-wait-split-bulk-contract.test.mjs index c070bb330..5a15afe65 100644 --- a/scripts/admin-test-env-modules/it-test-wait-split-bulk-contract.test.mjs +++ b/scripts/admin-test-env-modules/it-test-wait-split-bulk-contract.test.mjs @@ -8,7 +8,7 @@ const measurement = readFileSync(new URL('./inbound-measurement.mjs', import.met const runner = readFileSync(new URL('../it-test-wait-split-bulk.mjs', import.meta.url), 'utf8') const warehouseLocationResources = readFileSync(new URL('../../warehouse/src/main/java/com/baiyue/warehouse/service/LocationResourceService.java', import.meta.url), 'utf8') -test('单票随机箱数严格分配到各 SKU 并限制为1到1000', () => { +test('Admin 通用单票支持到1000箱且八千票专项入口限制为300箱', () => { assert.match(acceptance, /--wait-split-package-count /) assert.match(acceptance, /wait-split-package-count必须是1-1000的整数或0/) assert.match(acceptance, /packageCounts\.push\(remaining\)/) @@ -16,6 +16,7 @@ test('单票随机箱数严格分配到各 SKU 并限制为1到1000', () => { assert.match(orders, /packageCounts\[index\] \|\| packageCountPerSku/) assert.match(measurement, /detailPackageCounts\.reduce\(\(sum, count\) => sum \+ count, 0\)/) assert.match(measurement, /detailPackageCounts\.slice\(0, detailIndex\)/) + assert.match(runner, /boxMax > 300/) }) test('随机线路只固定义乌仓并从客户真实可计价候选选择', () => { diff --git a/scripts/client-phase2-test-env-acceptance.mjs b/scripts/client-phase2-test-env-acceptance.mjs index 1815ee4d5..5252ef7c5 100644 --- a/scripts/client-phase2-test-env-acceptance.mjs +++ b/scripts/client-phase2-test-env-acceptance.mjs @@ -16,22 +16,21 @@ import { resolve } from 'node:path' import { createInterface } from 'node:readline/promises' const args = parseArgs(process.argv.slice(2)) -const gateway = trimSlash(args.gateway ?? process.env.CLIENT_PHASE2_TEST_GATEWAY - ?? 'http://8.163.30.128:9000') -const clientUsername = args['client-username'] ?? process.env.CLIENT_PHASE2_USERNAME +const gateway = trimSlash(args.gateway ?? process.env.ADMIN_TEST_GATEWAY) +const clientUsername = args['client-username'] ?? process.env.MERCHANT_TEST_USERNAME ?? await promptText('二期客户端账号(必须由正式注册/建档产生)', '') -const clientPassword = args['client-password'] ?? process.env.CLIENT_PHASE2_PASSWORD +const clientPassword = args['client-password'] ?? process.env.MERCHANT_TEST_PASSWORD ?? await promptSecret('二期客户端密码') -const platformUsername = args['platform-username'] ?? process.env.PLATFORM_USERNAME - ?? await promptText('平台夹具查询账号', 'platform_admin') -const platformPassword = args['platform-password'] ?? process.env.PLATFORM_PASSWORD +const platformUsername = args['platform-username'] ?? process.env.ADMIN_TEST_USERNAME + ?? await promptText('平台夹具查询账号', '') +const platformPassword = args['platform-password'] ?? process.env.ADMIN_TEST_PASSWORD ?? await promptSecret('平台夹具查询密码') const forcedOrderId = args['order-id'] ?? process.env.CLIENT_PHASE2_ORDER_ID ?? null const reportDir = resolve(args['report-dir'] ?? process.env.CLIENT_PHASE2_REPORT_DIR ?? 'reports') const timeoutMs = Number(args['timeout-ms'] ?? process.env.CLIENT_PHASE2_TIMEOUT_MS ?? 30_000) -if (!clientUsername || !clientPassword || !platformPassword) { - console.error('An onboarded Client account and platform credentials are required') +if (!gateway || !clientUsername || !clientPassword || !platformUsername || !platformPassword) { + console.error('ADMIN_TEST_GATEWAY, an onboarded Client account, and platform credentials are required') process.exit(2) } if (!Number.isFinite(timeoutMs) || timeoutMs < 1_000) { diff --git a/scripts/client-test-env-acceptance.mjs b/scripts/client-test-env-acceptance.mjs index b7afa8553..a996d8588 100644 --- a/scripts/client-test-env-acceptance.mjs +++ b/scripts/client-test-env-acceptance.mjs @@ -5,8 +5,8 @@ * * Run directly and enter the platform/client test credentials when prompted. * Optional environment variables for CI: - * CLIENT_TEST_GATEWAY, PLATFORM_USERNAME, EXISTING_CLIENT_USERNAME, - * PLATFORM_PASSWORD, EXISTING_CLIENT_PASSWORD, CLIENT_TEST_REPORT_DIR + * ADMIN_TEST_GATEWAY, ADMIN_TEST_USERNAME, ADMIN_TEST_PASSWORD, + * MERCHANT_TEST_USERNAME, MERCHANT_TEST_PASSWORD, CLIENT_TEST_REPORT_DIR * * Example: * node scripts/client-test-env-acceptance.mjs @@ -18,20 +18,20 @@ import { resolve } from 'node:path' import { createInterface } from 'node:readline/promises' const args = parseArgs(process.argv.slice(2)) -const gateway = trimSlash(args.gateway ?? process.env.CLIENT_TEST_GATEWAY ?? 'http://8.163.30.128:9000') -const platformUsername = args['platform-username'] ?? process.env.PLATFORM_USERNAME - ?? await promptText('平台审核账号', 'platform_admin') -const platformPassword = args['platform-password'] ?? process.env.PLATFORM_PASSWORD +const gateway = trimSlash(args.gateway ?? process.env.ADMIN_TEST_GATEWAY) +const platformUsername = args['platform-username'] ?? process.env.ADMIN_TEST_USERNAME + ?? await promptText('平台审核账号', '') +const platformPassword = args['platform-password'] ?? process.env.ADMIN_TEST_PASSWORD ?? await promptSecret('平台审核密码') -const existingClientUsername = args['existing-client-username'] ?? process.env.EXISTING_CLIENT_USERNAME +const existingClientUsername = args['existing-client-username'] ?? process.env.MERCHANT_TEST_USERNAME ?? await promptText('跨客户隔离测试账号(必须由正式注册/建档产生)', '') -const existingClientPassword = args['existing-client-password'] ?? process.env.EXISTING_CLIENT_PASSWORD +const existingClientPassword = args['existing-client-password'] ?? process.env.MERCHANT_TEST_PASSWORD ?? await promptSecret('跨客户隔离测试密码') const reportDir = resolve(args['report-dir'] ?? process.env.CLIENT_TEST_REPORT_DIR ?? 'reports') const timeoutMs = Number(args['timeout-ms'] ?? process.env.CLIENT_TEST_TIMEOUT_MS ?? 30_000) -if (!platformPassword || !existingClientUsername || !existingClientPassword) { - console.error('Platform password and an independently onboarded client account are required') +if (!gateway || !platformUsername || !platformPassword || !existingClientUsername || !existingClientPassword) { + console.error('ADMIN_TEST_GATEWAY, platform credentials, and an independently onboarded client account are required') process.exit(2) } if (!Number.isFinite(timeoutMs) || timeoutMs < 1_000) { @@ -1290,7 +1290,7 @@ async function orderLifecycleModule() { async function isolationModule() { if (!existingClientUsername || !existingClientPassword) { recordSkipped('isolation', 'cross-client order access is rejected', - 'Set EXISTING_CLIENT_USERNAME and EXISTING_CLIENT_PASSWORD to enable this check') + 'Set MERCHANT_TEST_USERNAME and MERCHANT_TEST_PASSWORD to enable this check') return } const login = await apiStep('isolation', 'existing client login', { diff --git a/scripts/create-pending-review-orders-contract.test.mjs b/scripts/create-pending-review-orders-contract.test.mjs index ac4c7a2d3..5f3744f75 100644 --- a/scripts/create-pending-review-orders-contract.test.mjs +++ b/scripts/create-pending-review-orders-contract.test.mjs @@ -6,11 +6,14 @@ import { PRODUCT_CATALOG, buildOrderPayload, diversifyRoutes, parseArguments, ra const source = readFileSync(new URL('./create-pending-review-orders.mjs', import.meta.url), 'utf8') -test('uses the fixed merchant account and stops at pending review', () => { - assert.ok(source.includes("USERNAME: '17512095155'"), 'fixed username is missing') - assert.ok(/PASSWORD:\s*'[^']*'/.test(source), 'CONFIG.PASSWORD is missing') - assert.ok(/COUNT:\s*\d+/.test(source), 'CONFIG.COUNT is missing') - assert.match(source, /COUNT:\s*50/) +test('uses the configured merchant account and stops at pending review', () => { + assert.match(source, /process\.env\.ADMIN_TEST_GATEWAY/) + assert.match(source, /process\.env\.MERCHANT_TEST_USERNAME/) + assert.match(source, /process\.env\.MERCHANT_TEST_PASSWORD/) + assert.match(source, /const DEFAULT_COUNT = 50/) + assert.doesNotMatch(source, /const CONFIG\s*=/) + assert.doesNotMatch(source, /8\.163\.30\.128/) + assert.doesNotMatch(source, /PASSWORD:\s*'[^']+'/) assert.match(source, /randomInt\(1, 31\)/) assert.match(source, /loadPricedRoutes/) assert.match(source, /destinationWarehouseCode/) @@ -28,6 +31,7 @@ test('uses the fixed merchant account and stops at pending review', () => { }) test('supports a bounded fixed order count', () => { + assert.equal(validateCount(undefined), 50) assert.equal(validateCount('1'), 1) assert.equal(validateCount('10'), 10) assert.throws(() => validateCount('0'), /between 1 and 100/) diff --git a/scripts/create-pending-review-orders.mjs b/scripts/create-pending-review-orders.mjs index 4da72837d..d83a44ea4 100644 --- a/scripts/create-pending-review-orders.mjs +++ b/scripts/create-pending-review-orders.mjs @@ -29,13 +29,7 @@ export const PRODUCT_CATALOG = [ { cargoName: '宠物橡胶玩具', cargoNameEn: 'Pet rubber toy', hsCode: '4016999090', material: '天然橡胶 / Natural rubber', usage: '宠物玩耍 / Pet play', brand: 'PETTEST', modelPrefix: 'PET', lengthCm: 46, widthCm: 36, heightCm: 34, unitsPerPackage: 48, cartonGrossWeightKg: 15.6, unitDeclaredValue: 3.4, productLengthCm: 12, productWidthCm: 7, productHeightCm: 7 }, ] -// Local test-environment settings. Keep PASSWORD empty in committed code. -const CONFIG = { - GATEWAY: 'http://8.163.30.128:9000', - USERNAME: '17512095155', - PASSWORD: 'M4mmp4YWcH5DiYPT', - COUNT: 50, -} +const DEFAULT_COUNT = 50 export function parseArguments(values) { const options = {} @@ -58,7 +52,7 @@ export function parseArguments(values) { } export function validateCount(value) { - const count = Number(value ?? CONFIG.COUNT) + const count = Number(value ?? DEFAULT_COUNT) if (!Number.isInteger(count) || count < 1 || count > MAX_COUNT) { throw new Error(`--count must be an integer between 1 and ${MAX_COUNT}`) } @@ -342,12 +336,13 @@ function printHelp() { node scripts/create-pending-review-orders.mjs --gateway --count <1-${MAX_COUNT}> [--same-channel] Environment: - CLIENT_TEST_GATEWAY Test environment Gateway URL + ADMIN_TEST_GATEWAY Test environment Gateway URL MERCHANT_TEST_PASSWORD Password for the configured fixed account - MERCHANT_TEST_USERNAME Optional username override + MERCHANT_TEST_USERNAME Username for the configured fixed account + PENDING_REVIEW_ORDER_COUNT Optional order count, defaults to ${DEFAULT_COUNT} PENDING_ORDER_REPORT Optional JSON report path -The GATEWAY, USERNAME, PASSWORD, and COUNT fields can also be filled in CONFIG at the top of this file.`) +Command-line arguments override environment variables. Credentials must not be written into this file.`) } export async function main(argv = process.argv.slice(2)) { @@ -356,13 +351,13 @@ export async function main(argv = process.argv.slice(2)) { printHelp() return 0 } - const gateway = trimSlash(args.gateway ?? process.env.CLIENT_TEST_GATEWAY ?? CONFIG.GATEWAY) - if (!gateway) throw new Error('Gateway is required via CONFIG.GATEWAY, --gateway, or CLIENT_TEST_GATEWAY') - const count = validateCount(args.count) - const username = args.username ?? process.env.MERCHANT_TEST_USERNAME ?? CONFIG.USERNAME - const password = args.password ?? process.env.MERCHANT_TEST_PASSWORD ?? CONFIG.PASSWORD - if (!username) throw new Error('Fixed merchant username is required in CONFIG.USERNAME') - if (!password) throw new Error('Merchant password is required in CONFIG.PASSWORD or MERCHANT_TEST_PASSWORD') + const gateway = trimSlash(args.gateway ?? process.env.ADMIN_TEST_GATEWAY) + if (!gateway) throw new Error('Gateway is required via --gateway or ADMIN_TEST_GATEWAY') + const count = validateCount(args.count ?? process.env.PENDING_REVIEW_ORDER_COUNT) + const username = args.username ?? process.env.MERCHANT_TEST_USERNAME + const password = args.password ?? process.env.MERCHANT_TEST_PASSWORD + if (!username) throw new Error('Merchant username is required via --username or MERCHANT_TEST_USERNAME') + if (!password) throw new Error('Merchant password is required via --password or MERCHANT_TEST_PASSWORD') const timeoutMs = Number(args['timeout-ms'] ?? process.env.CLIENT_TEST_TIMEOUT_MS ?? 30_000) if (!Number.isFinite(timeoutMs) || timeoutMs < 1_000) throw new Error('--timeout-ms must be at least 1000') diff --git a/scripts/http-script-environment-contract.test.mjs b/scripts/http-script-environment-contract.test.mjs new file mode 100644 index 000000000..063689a7b --- /dev/null +++ b/scripts/http-script-environment-contract.test.mjs @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +const read = (name) => readFileSync(new URL(name, import.meta.url), 'utf8') +const client = read('./client-test-env-acceptance.mjs') +const clientPhase2 = read('./client-phase2-test-env-acceptance.mjs') +const pendingOrders = read('./create-pending-review-orders.mjs') + +const activeHttpScripts = [client, clientPhase2, pendingOrders] +const removedEnvironmentVariables = [ + 'CLIENT_TEST_GATEWAY', 'CLIENT_PHASE2_TEST_GATEWAY', + 'PLATFORM_USERNAME', 'PLATFORM_PASSWORD', + 'EXISTING_CLIENT_USERNAME', 'EXISTING_CLIENT_PASSWORD', + 'CLIENT_PHASE2_USERNAME', 'CLIENT_PHASE2_PASSWORD', +] + +test('active Client and data-generation scripts share the canonical environment variables', () => { + for (const source of activeHttpScripts) { + assert.match(source, /process\.env\.ADMIN_TEST_GATEWAY/) + assert.match(source, /process\.env\.MERCHANT_TEST_USERNAME/) + assert.match(source, /process\.env\.MERCHANT_TEST_PASSWORD/) + } + assert.match(client, /process\.env\.ADMIN_TEST_USERNAME/) + assert.match(client, /process\.env\.ADMIN_TEST_PASSWORD/) + assert.match(clientPhase2, /process\.env\.ADMIN_TEST_USERNAME/) + assert.match(clientPhase2, /process\.env\.ADMIN_TEST_PASSWORD/) +}) + +test('active scripts no longer carry the retired variables, fixed Gateway, or source password config', () => { + for (const source of activeHttpScripts) { + for (const variable of removedEnvironmentVariables) assert.doesNotMatch(source, new RegExp(`process\\.env\\.${variable}`)) + assert.doesNotMatch(source, /8\.163\.30\.128/) + } + assert.doesNotMatch(pendingOrders, /const CONFIG\s*=/) + assert.doesNotMatch(pendingOrders, /PASSWORD:\s*'[^']+'/) +}) From 49d216cc138d1c6296a44300083edd9e134457b4 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 1 Sep 2026 12:02:35 +0800 Subject: [PATCH 20/37] test(order): harden demo order lifecycle fixture --- scripts/admin-test-env-modules/README.md | 2 +- ...rs-lifecycle-idempotency-contract.test.mjs | 48 +++++++++++++++++++ scripts/admin-test-env-modules/orders.mjs | 37 ++++++++++++++ 3 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 scripts/admin-test-env-modules/orders-lifecycle-idempotency-contract.test.mjs diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 5df154b64..f46f6459f 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -60,7 +60,7 @@ export async function run(context) { |---|---|---| | 商户资质与下单身份 | `merchant-qualification-fixture.mjs` | `merchant-qualification-fixture-contract.test.mjs` | | 公共主数据与往来单位 | `master-data.mjs` | `master-data-partner-contract.test.mjs` | -| 订单创建、可计价主数据与审核异步收敛 | `orders.mjs` | `orders-pricing-fixture-contract.test.mjs`、`orders-approval-convergence-contract.test.mjs` | +| 订单创建、可计价主数据、幂等零漂移与审核异步收敛 | `orders.mjs` | `orders-pricing-fixture-contract.test.mjs`、`orders-lifecycle-idempotency-contract.test.mjs`、`orders-approval-convergence-contract.test.mjs` | | 入库测量、首次打印冻结箱码与墨家扫码 | `inbound-measurement.mjs` | `inbound-measurement-box-code-contract.test.mjs` | | 入仓自动头程费用、派生详情、人工发布与 MANUAL 其他费用版本 | `inbound-fees.mjs`(前置:`merchant-qualification-fixture,orders,inbound-measurement`) | `inbound-fees-contract.test.mjs` | | 三期模拟柜关联、柜次概览、渠道隔离、装柜模型桥接、PDA、动态增量、四类货柜资料、报关与证据/出仓门禁 | `phase3-container-loading.mjs`、`ocean-booking-fixture.mjs`、`phase3-container-overview.mjs`、`phase3-loading.mjs`、`phase3-loading-model-bridge.mjs`、`phase3-pda-convergence.mjs`、`phase3-evidence-gate.mjs`、`phase3-loading-finalize.mjs`、`phase3-container-documents.mjs`、`phase3-customs.mjs`、`phase3-outbound-dispatch.mjs` | `container-simulation-association-contract.test.mjs`、`container-specification-fixture-contract.test.mjs`、`ocean-booking-channel-contract.test.mjs`、`phase3-container-regression-contract.test.mjs`、`phase3-loading-model-bridge-contract.test.mjs`、`phase3-loading-dynamic-final-contract.test.mjs`、`phase3-container-documents-contract.test.mjs`、`phase3-phase4-departure-pickup-chain-contract.test.mjs`、`phase3-producer-chain-contract.test.mjs`、`manual-state-loading-pda-contract.test.mjs` | diff --git a/scripts/admin-test-env-modules/orders-lifecycle-idempotency-contract.test.mjs b/scripts/admin-test-env-modules/orders-lifecycle-idempotency-contract.test.mjs new file mode 100644 index 000000000..8c71f0bff --- /dev/null +++ b/scripts/admin-test-env-modules/orders-lifecycle-idempotency-contract.test.mjs @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +import { orderPersistenceFacts, requestedOrderDestinationType } from './orders.mjs' + +const source = readFileSync(new URL('orders.mjs', import.meta.url), 'utf8') + +test('order demo payload covers multi-box multi-SKU images and both destination families', () => { + for (const boxType of ['SMALL', 'MEDIUM', 'LARGE', 'EXTRA_LARGE']) { + assert.match(source, new RegExp(`boxType: '${boxType}'`)) + } + assert.match(source, /cargoes: selectedBoxes\.map/) + assert.match(source, /productImageFileId/) + assert.equal(requestedOrderDestinationType({ context: { orderDestinationType: 'FBA' } }), 'FBA') + assert.equal(requestedOrderDestinationType({ context: { orderDestinationType: 'OVERSEAS' } }), 'OVERSEAS') +}) + +test('create and submit lifecycle covers replay payload conflict and zero drift', () => { + assert.match(source, /Client 创建订单幂等重放/) + assert.match(source, /Client 创建订单同键异载荷返回A0409/) + assert.match(source, /创建同键异载荷失败后订单全事实零漂移/) + assert.match(source, /Client 提交订单同键同载荷精确重放/) + assert.match(source, /已提交订单使用新键重复提交返回A0409/) + assert.match(source, /重复提交失败后订单状态货物与历史零漂移/) + assert.match(source, /orderPersistenceFacts\(draftAfterConflict\) === orderPersistenceFacts\(draft\)/) + assert.match(source, /orderPersistenceFacts\(submittedAfterFailure\) === orderPersistenceFacts\(submitted\)/) +}) + +test('persistence fact comparison includes order cargo parties notes and history', () => { + const detail = { + order: { id: '1', status: 'DRAFT' }, cargoes: [{ skuNo: 'SKU-1' }], + parties: [{ role: 'SENDER' }], internalNotes: [{ content: 'note' }], + statusHistory: [{ toStatus: 'DRAFT' }], + } + const baseline = orderPersistenceFacts(detail) + assert.equal(orderPersistenceFacts(structuredClone(detail)), baseline) + assert.notEqual(orderPersistenceFacts({ ...detail, order: { ...detail.order, status: 'SUBMITTED' } }), baseline) + assert.notEqual(orderPersistenceFacts({ ...detail, cargoes: [{ skuNo: 'SKU-2' }] }), baseline) + assert.notEqual(orderPersistenceFacts({ ...detail, statusHistory: [...detail.statusHistory, { toStatus: 'SUBMITTED' }] }), baseline) +}) + +test('approval convergence is a hard WAIT_MEASURE prerequisite without coverage downgrade', () => { + assert.match(source, /审核后入库单与履约任务异步收敛/) + assert.match(source, /MEASURABLE_STATUSES/) + assert.match(source, /throw new Error\('订单审核后的入库单与履约任务未在30秒内收敛/) + assert.doesNotMatch(source, /审核后入库单与履约任务异步收敛[\s\S]{0,300}addCoverageGap/) +}) diff --git a/scripts/admin-test-env-modules/orders.mjs b/scripts/admin-test-env-modules/orders.mjs index 9e8e4e7c8..6f6404e3d 100644 --- a/scripts/admin-test-env-modules/orders.mjs +++ b/scripts/admin-test-env-modules/orders.mjs @@ -25,6 +25,15 @@ function rotate(values, offset) { return [...values.slice(shift), ...values.slice(0, shift)] } function key(context, operation) { return `${context.marker}-${operation}`.slice(0, 128) } +export function orderPersistenceFacts(detail) { + return JSON.stringify({ + order: detail?.order ?? null, + cargoes: Array.isArray(detail?.cargoes) ? detail.cargoes : [], + parties: Array.isArray(detail?.parties) ? detail.parties : [], + internalNotes: Array.isArray(detail?.internalNotes) ? detail.internalNotes : [], + statusHistory: Array.isArray(detail?.statusHistory) ? detail.statusHistory : [], + }) +} export function requestedOrderDestinationType(context) { const value = String(context?.context?.orderDestinationType || 'FBA').trim().toUpperCase() return ['FBA', 'OVERSEAS', 'PRIVATE'].includes(value) ? value : 'FBA' @@ -547,13 +556,41 @@ export async function run(context) { assertCargoContract(context, draftResponse, draft?.cargoes, payload) context.createdResources.orders.orderNo = orderNo + const createConflictPayload = { + ...payload, + customerRemark: `${payload.customerRemark} 同键异载荷`, + } + const createConflictResponse = await request(context, 'POST', createPath, createConflictPayload, merchantToken, + { 'Idempotency-Key': key(context, 'client-create') }) + context.assert(MODULE, 'Client 创建订单同键异载荷返回A0409', createConflictResponse, + createConflictResponse.http === 200 && String(createConflictResponse.body?.code) === 'A0409', + `http=${createConflictResponse.http}, code=${createConflictResponse.body?.code ?? 'missing'}`, CONTRACT_OPTIONS) + const draftAfterConflictResponse = await request(context, 'GET', clientDetailPath, undefined, merchantToken) + const draftAfterConflict = context.apiSuccess(MODULE, 'Client 回查创建冲突后的订单草稿', draftAfterConflictResponse) + context.assert(MODULE, '创建同键异载荷失败后订单全事实零漂移', draftAfterConflictResponse, + orderPersistenceFacts(draftAfterConflict) === orderPersistenceFacts(draft), + `orderId=${orderId}, status=${draftAfterConflict?.order?.status ?? 'missing'}`, CONTRACT_OPTIONS) + const submitResponse = await request(context, 'POST', `${clientDetailPath}/submit`, undefined, merchantToken, { 'Idempotency-Key': key(context, 'client-submit') }) context.apiSuccess(MODULE, 'Client 提交隔离订单', submitResponse, `orderId=${orderId}`) + const submitReplayResponse = await request(context, 'POST', `${clientDetailPath}/submit`, undefined, + merchantToken, { 'Idempotency-Key': key(context, 'client-submit') }) + context.apiSuccess(MODULE, 'Client 提交订单同键同载荷精确重放', submitReplayResponse, `orderId=${orderId}`) const submittedResponse = await request(context, 'GET', clientDetailPath, undefined, merchantToken) const submitted = context.apiSuccess(MODULE, 'Client 回查已提交订单', submittedResponse) context.assert(MODULE, 'Client 订单已进入待审核状态', submittedResponse, submitted?.order?.status === 'SUBMITTED', `status=${submitted?.order?.status ?? 'missing'}`) + const duplicateSubmitResponse = await request(context, 'POST', `${clientDetailPath}/submit`, undefined, + merchantToken, { 'Idempotency-Key': key(context, 'client-submit-new-key') }) + context.assert(MODULE, '已提交订单使用新键重复提交返回A0409', duplicateSubmitResponse, + duplicateSubmitResponse.http === 200 && String(duplicateSubmitResponse.body?.code) === 'A0409', + `http=${duplicateSubmitResponse.http}, code=${duplicateSubmitResponse.body?.code ?? 'missing'}`, CONTRACT_OPTIONS) + const submittedAfterFailureResponse = await request(context, 'GET', clientDetailPath, undefined, merchantToken) + const submittedAfterFailure = context.apiSuccess(MODULE, 'Client 回查重复提交失败后的订单', submittedAfterFailureResponse) + context.assert(MODULE, '重复提交失败后订单状态货物与历史零漂移', submittedAfterFailureResponse, + orderPersistenceFacts(submittedAfterFailure) === orderPersistenceFacts(submitted), + `orderId=${orderId}, status=${submittedAfterFailure?.order?.status ?? 'missing'}`, CONTRACT_OPTIONS) const unifiedListResponse = await context.request('GET', `/api/platform/v1/orders?page=1&size=100&tenantId=${encodeURIComponent(tenantId)}&orderNo=${encodeURIComponent(orderNo)}`) From 53078b4281d2b5eba571a84f0581e257b4ecc1d0 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 1 Sep 2026 12:02:47 +0800 Subject: [PATCH 21/37] test(firstleg): extend shipment demo regression --- .../ContainerCustomsServiceTest.java | 51 ++++++++++++++++++ .../OceanBookingDocumentServiceTest.java | 52 +++++++++++++++++++ 2 files changed, 103 insertions(+) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsServiceTest.java index 33976049a..6f51a5a7d 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsServiceTest.java @@ -3,6 +3,7 @@ package com.baiyue.firstleg.customs.container; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.customs.container.dto.ContainerCustomsDtos.ActionRequest; +import com.baiyue.firstleg.customs.container.dto.ContainerCustomsDtos.ScopeRequest; import com.baiyue.firstleg.customs.container.mapper.ContainerCustomsMapper; import com.baiyue.firstleg.customs.container.service.ContainerCustomsService; import com.baiyue.firstleg.customs.client.CustomsFileClient; @@ -108,6 +109,35 @@ class ContainerCustomsServiceTest { verify(mapper,never()).freezeScopes(anyLong()); } + @Test void declaringContainerFreezesUploadReplacementScopeAndDeletionWithoutDrift(){ + var inbound=new ContainerCustomsMapper.InboundRow(9L,"LP9",8L,"COMPLETED","CAB","C",11L,21L,"O","I",7L,"客户","TAX_REFUND"); + var declaring=container(ContainerCustomsService.DECLARING,3); + var source=document(1L); + var file=new MockMultipartFile("file","source.bin","application/octet-stream",new byte[]{1}); + when(mapper.planInbounds(9L)).thenReturn(List.of(inbound)); + when(mapper.container(9L)).thenReturn(declaring); + when(mapper.document(1L)).thenReturn(source); + when(mapper.lockContainerById(2L)).thenReturn(declaring); + + assertThatThrownBy(()->service.uploadSource(7L,9L,5L,"upload-after-start",List.of(11L),file)) + .isInstanceOf(BizException.class).hasMessageContaining("资料已锁定"); + assertThatThrownBy(()->service.replaceSource(7L,1L,5L,"replace-after-start",new ScopeRequest(List.of(11L),1),file)) + .isInstanceOf(BizException.class).hasMessageContaining("资料已锁定"); + assertThatThrownBy(()->service.updateScope(7L,1L,5L,"scope-after-start",new ScopeRequest(List.of(11L),1))) + .isInstanceOf(BizException.class).hasMessageContaining("资料已锁定"); + assertThatThrownBy(()->service.deleteSource(7L,1L,5L,"delete-after-start",1)) + .isInstanceOf(BizException.class).hasMessageContaining("资料已锁定"); + + verifyNoInteractions(files); + verify(mapper,never()).insertDocument(anyLong(),anyLong(),anyLong(),anyLong(),anyString(),anyString(),anyString(),anyLong(),anyLong()); + verify(mapper,never()).replaceDocument(anyLong(),anyLong(),anyInt(),anyLong(),anyString(),anyString(),anyString(),anyLong(),anyLong()); + verify(mapper,never()).touchScope(anyLong(),anyLong(),anyInt()); + verify(mapper,never()).deleteDocument(anyLong(),anyLong(),anyInt(),anyLong()); + verify(mapper,never()).insertDocumentVersion(anyLong(),anyLong(),anyInt(),anyLong(),anyString(),anyString(),anyString(),anyLong(),anyString(),anyString(),anyLong()); + assertThat(source.version()).isEqualTo(1); + assertThat(source.status()).isEqualTo("ACTIVE"); + } + @Test void concurrentStartWithSameKeyReplaysOneAggregateAndDifferentPayloadConflicts() throws Exception { var row=new ContainerCustomsMapper.InboundRow(9L,"LP9",8L,"COMPLETED","CAB","C",11L,21L,"O","I",7L,"客户","TAX_REFUND"); var ready=container(ContainerCustomsService.READY,2); @@ -271,6 +301,27 @@ class ContainerCustomsServiceTest { verify(mapper,never()).complete(anyLong(),anyInt(),anyLong(),anyString()); } + @Test void allBuyContainerCompletesOnceAndPublishesAutoDispatchTrigger(){ + var declaring=container(ContainerCustomsService.DECLARING,3); + var completed=container(ContainerCustomsService.COMPLETED,4); + var buy=new ContainerCustomsMapper.InboundRow(9L,"LP9",8L,"COMPLETED","CAB","C",11L,21L,"O","I",7L,"客户","BUY_DECLARATION"); + when(mapper.claimCommand(anyLong(),eq(5L),eq("COMPLETE_ALL_BUY_CUSTOMS_9"),eq("all-buy-key"),anyString())).thenReturn(1); + when(mapper.lockContainer(9L)).thenReturn(declaring); + when(mapper.planInbounds(9L)).thenReturn(List.of(buy)); + when(mapper.complete(2L,3,5L,"admin")).thenReturn(1); + when(mapper.completeCommand(5L,"COMPLETE_ALL_BUY_CUSTOMS_9","all-buy-key",2L)).thenReturn(1); + when(mapper.container(9L)).thenReturn(completed); + when(mapper.documents(2L,null)).thenReturn(List.of()); + + var result=service.completeAllBuy(9L,5L,"admin","all-buy-key",new ActionRequest(3,true,"代理已完成")); + + assertThat(result.status()).isEqualTo(ContainerCustomsService.COMPLETED); + verify(mapper,times(1)).complete(2L,3,5L,"admin"); + verify(mapper,times(1)).insertHistory(anyLong(),eq(2L),eq("COMPLETE_CUSTOMS"), + eq(ContainerCustomsService.DECLARING),eq(ContainerCustomsService.COMPLETED),eq("代理已完成"),eq(5L),eq("admin")); + verify(events,times(1)).publishEvent(new ContainerCustomsService.CustomsCompletedEvent(8L)); + } + private static ContainerCustomsMapper.DocumentRow document(long id){return new ContainerCustomsMapper.DocumentRow(id,2L,7L,10L,"source.bin","a".repeat(64),"application/octet-stream",1L,"ACTIVE",5L,java.time.LocalDateTime.now(),1,java.time.LocalDateTime.now(),1);} private static ContainerCustomsMapper.ContainerRow container(String status,int version){return new ContainerCustomsMapper.ContainerRow(2L,9L,8L,status,null,null,null,null,null,null,null,null,version);} private void assertMerchantOrderNotFound(long tenantId,long orderId){ diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingDocumentServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingDocumentServiceTest.java index e64e74a02..aa534a998 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingDocumentServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingDocumentServiceTest.java @@ -67,6 +67,37 @@ class OceanBookingDocumentServiceTest { verify(repository, times(1)).insert(any(OceanBookingDocumentEntity.class)); } + @Test void draftAcceptsBookingConfirmationAndBillOfLadingAsTwoVersionedFiles() { + MockMultipartFile confirmation = new MockMultipartFile("file", "confirmation.pdf", + "application/pdf", "confirmation".getBytes()); + MockMultipartFile bill = new MockMultipartFile("file", "bill.pdf", + "application/pdf", "bill".getBytes()); + when(repository.claimOperation(anyLong(), eq(7L), eq("UPLOAD_DOCUMENT"), anyString(), anyString())) + .thenReturn(true); + when(repository.findForUpdate(10L)).thenReturn(draft(0), draft(1)); + when(files.upload("token", 7L, 9L, "OCEAN_BOOKING_DOCUMENT", "10", confirmation)) + .thenReturn(ApiResponse.success(metadata(91L))); + when(files.upload("token", 7L, 9L, "OCEAN_BOOKING_DOCUMENT", "10", bill)) + .thenReturn(ApiResponse.success(metadata(92L))); + when(repository.touchDraft(eq(10L), anyInt(), eq(7L), eq("admin"))).thenReturn(1); + when(repository.completeOperation(eq(10L), eq(7L), eq("UPLOAD_DOCUMENT"), anyString(), anyString())) + .thenReturn(1); + + var first = service.upload(10L, 0, "BOOKING_CONFIRMATION", "confirmation-key", + confirmation, 7L, "admin"); + var second = service.upload(10L, 1, "BILL_OF_LADING", "bill-key", bill, 7L, "admin"); + + assertThat(first.bookingVersion()).isEqualTo(1); + assertThat(second.bookingVersion()).isEqualTo(2); + ArgumentCaptor documents = + ArgumentCaptor.forClass(OceanBookingDocumentEntity.class); + verify(repository, times(2)).insert(documents.capture()); + assertThat(documents.getAllValues()).extracting(OceanBookingDocumentEntity::getDocumentType) + .containsExactly("BOOKING_CONFIRMATION", "BILL_OF_LADING"); + assertThat(documents.getAllValues()).extracting(OceanBookingDocumentEntity::getFileId) + .containsExactly(91L, 92L); + } + @Test void attachRejectsCrossScopeFileBeforePersisting() { when(repository.claimOperation(any(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true); when(repository.findForUpdate(10L)).thenReturn(draft(1)); @@ -121,6 +152,27 @@ class OceanBookingDocumentServiceTest { verify(files).metadata("token", 9L, "OCEAN_BOOKING_DOCUMENT", "10", 91L); } + @Test void releaseGateRejectsWhenBothActiveFileReferencesNoLongerExist() { + OceanBookingEntity booking = draft(3); + OceanBookingDocumentEntity confirmation = activeDocument(20L, 91L, "BOOKING_CONFIRMATION"); + OceanBookingDocumentEntity bill = activeDocument(21L, 92L, "BILL_OF_LADING"); + when(repository.activeDocuments(10L)).thenReturn(List.of(confirmation, bill)); + + assertThatThrownBy(() -> service.requireActiveValidDocument(booking)) + .isInstanceOf(BizException.class).hasMessageContaining("有效订舱确认或提单文件"); + + verify(files).metadata("token", 9L, "OCEAN_BOOKING_DOCUMENT", "10", 91L); + verify(files).metadata("token", 9L, "OCEAN_BOOKING_DOCUMENT", "10", 92L); + verify(repository, never()).touchDraft(anyLong(), anyInt(), anyLong(), anyString()); + } + + private static OceanBookingDocumentEntity activeDocument(long id, long fileId, String type) { + OceanBookingDocumentEntity document = new OceanBookingDocumentEntity(); + document.setId(id); document.setOceanBookingId(10L); document.setFileId(fileId); + document.setDocumentType(type); document.setValid(true); + return document; + } + private static OceanBookingEntity draft(int version) { OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(10L); booking.setCustomerId(9L); booking.setStatus("DRAFT"); booking.setVersion(version); From 6eaf484fcd7997fb9c402b6d7d79dbedb3841354 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 1 Sep 2026 12:02:57 +0800 Subject: [PATCH 22/37] test(firstleg): package carrier node demo checks --- .../phase3-carrier-schedule-queries.mjs | 15 ++++++++++++ .../phase3-carrier-schedule-queries.test.mjs | 15 +++++++++--- ...4-departure-pickup-chain-contract.test.mjs | 24 +++++++++++++++++++ .../phase4-carrier-nodes-contract.test.mjs | 4 ++-- .../phase4-carrier-nodes.mjs | 16 +++++++++++-- 5 files changed, 67 insertions(+), 7 deletions(-) diff --git a/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.mjs b/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.mjs index 886f39387..f202c34fb 100644 --- a/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.mjs +++ b/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.mjs @@ -50,6 +50,18 @@ function blocker(context, name, evidence) { } function validLongId(value) { return typeof value === 'string' && /^\d{16,}$/.test(value) } +export function validContainerNo(value) { + if (typeof value !== 'string' || !/^[A-Z]{3}[UJZ][0-9]{7}$/.test(value)) return false + const letterValues = [10, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 23, 24, + 25, 26, 27, 28, 29, 30, 31, 32, 34, 35, 36, 37, 38] + let sum = 0 + for (let index = 0; index < 10; index += 1) { + const code = value.charCodeAt(index) + const number = code >= 48 && code <= 57 ? code - 48 : letterValues[code - 65] + sum += number * (2 ** index) + } + return Number(value[10]) === (sum % 11) % 10 +} export async function run(context) { context.addCoverageGap(MODULE, OPTIONS.scenario, 'COSCO官方API自动查询', @@ -84,6 +96,7 @@ export async function run(context) { const task = item?.query return id(task?.customerId) === fixture.customerId && id(task?.oceanBookingId) === fixture.oceanBookingId && id(task?.containerBookingId) === fixture.containerBookingId && task?.trackingNo === fixture.trackingNo + && task?.trackingType === 'CONTAINER_NO' && validContainerNo(task?.trackingNo) && task?.triggerSource === 'OUTBOUND_CONFIRM' }) context.assert(MODULE, '本轮出仓范围唯一命中CarrierScheduleQuery', pageResponse, @@ -104,6 +117,8 @@ export async function run(context) { && item.actualAt != null && item.estimatedAt == null) context.assert(MODULE, '出仓自动任务携带实际DEPARTED_WAREHOUSE事实与长ID', detailResponse, id(detail?.query?.query?.id) === queryId && validLongId(detail?.query?.query?.id) + && detail?.query?.query?.trackingType === 'CONTAINER_NO' + && validContainerNo(detail?.query?.query?.trackingNo) && departed.length >= 1 && departed.every((item) => validLongId(item.id) && item.providerCode === 'FIRSTLEG' && item.providerEventId && item.providerNodeCode && typeof item.rawPayloadHash === 'string' && /^[0-9a-f]{64}$/.test(item.rawPayloadHash)), diff --git a/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.test.mjs b/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.test.mjs index 1917c0ccb..1a98665b4 100644 --- a/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.test.mjs +++ b/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.test.mjs @@ -4,21 +4,21 @@ import path from 'node:path' import test from 'node:test' import { fileURLToPath } from 'node:url' -import { currentOutboundFixture, ownsCarrierQuery } from './phase3-carrier-schedule-queries.mjs' +import { currentOutboundFixture, ownsCarrierQuery, validContainerNo } from './phase3-carrier-schedule-queries.mjs' const here = path.dirname(fileURLToPath(import.meta.url)) const source = fs.readFileSync(path.join(here, 'phase3-carrier-schedule-queries.mjs'), 'utf8') function context(marker = 'RUN') { return { marker, createdResources: { - phase3Loading: { marker, tenantId: '10001', oceanBookingId: '20001', containerBookingId: '30001', containerNo: 'TSTU1234567' }, + phase3Loading: { marker, tenantId: '10001', oceanBookingId: '20001', containerBookingId: '30001', containerNo: 'ZCSU7881970' }, phase3OutboundDispatch: { marker, tenantId: '10001', containerBookingId: '30001', confirmedStatus: 'WAITING_FOR_LOADING' }, } } } test('outbound fixture requires exact current marker and matching loading/outbound container', () => { assert.deepEqual(currentOutboundFixture(context()), { - marker: 'RUN', customerId: '10001', oceanBookingId: '20001', containerBookingId: '30001', trackingNo: 'TSTU1234567', + marker: 'RUN', customerId: '10001', oceanBookingId: '20001', containerBookingId: '30001', trackingNo: 'ZCSU7881970', }) const old = context(); old.createdResources.phase3OutboundDispatch.marker = 'OLD' assert.equal(currentOutboundFixture(old), null) @@ -26,6 +26,13 @@ test('outbound fixture requires exact current marker and matching loading/outbou assert.equal(currentOutboundFixture(mismatch), null) }) +test('carrier fixture only accepts normalized ISO 6346 physical container numbers', () => { + assert.equal(validContainerNo('ZCSU7881970'), true) + assert.equal(validContainerNo('zcsu7881970'), false) + assert.equal(validContainerNo('ZCSU7881971'), false) + assert.equal(validContainerNo('CAB-260901-0001'), false) +}) + test('carrier query ownership requires explicit outbound-confirm marker registration', () => { assert.equal(ownsCarrierQuery({ queryId: '1', marker: 'RUN', ownedByModule: true, triggerSource: 'OUTBOUND_CONFIRM' }, 'RUN'), true) @@ -41,6 +48,8 @@ test('phase3 script only discovers the current outbound task and blocks missing assert.match(source, /缺少本轮phase3出仓fixture,禁止借历史Query验收/) assert.match(source, /deploymentBlocker:\s*true/) assert.match(source, /triggerSource === 'OUTBOUND_CONFIRM'/) + assert.match(source, /trackingType === 'CONTAINER_NO'/) + assert.match(source, /validContainerNo\(task\?\.trackingNo\)/) assert.match(source, /DEPARTED_WAREHOUSE/) assert.match(source, /providerEventId/) assert.match(source, /\^\\d\{16,/) diff --git a/scripts/admin-test-env-modules/phase3-phase4-departure-pickup-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-phase4-departure-pickup-chain-contract.test.mjs index 0552e9393..b9c9d1c20 100644 --- a/scripts/admin-test-env-modules/phase3-phase4-departure-pickup-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-phase4-departure-pickup-chain-contract.test.mjs @@ -6,6 +6,8 @@ import { fileURLToPath } from 'node:url' const here = dirname(fileURLToPath(import.meta.url)) const read = (name) => readFileSync(resolve(here, name), 'utf8') +const readFirstlegTest = (name) => readFileSync(resolve(here, + '../../firstleg/src/test/java/com/baiyue/firstleg/portschedule/service', name), 'utf8') test('current-marker departure batch proves four documents photos customs and container-level confirm', () => { const customs = read('phase3-customs.mjs') @@ -48,3 +50,25 @@ test('outbound-created query supports automated or manual carrier then reaches P assert.match(pda, /deploymentBlocker: true/) assert.doesNotMatch(pda, /addCoverageGap\([^\n]*PICKUP_READY/) }) + +test('demo package locks provider failure retry and refuses stray facts on failed calls', () => { + const scheduler = readFirstlegTest('CarrierScheduleQuerySchedulerTest.java') + const service = readFirstlegTest('CarrierScheduleQueryServiceTest.java') + assert.match(scheduler, /retryableProviderResultCannotAdvanceEvenWhenItCarriesStrayFacts/) + assert.match(scheduler, /verify\(service, never\(\)\)\.applyProviderObservation/) + assert.match(scheduler, /completeScheduledAttempt\(1L, "RETRYABLE_FAILURE", "HTTP_503", "unavailable"\)/) + assert.match(service, /retryableFailureUsesFifteenThenThirtyMinutesAndStopsAfterThreeCallsInTheSlot/) + assert.match(service, /containsExactly\(1, 2, 3\)/) + assert.match(service, /retryThatWouldReachTheNextSlotWaitsForTheSlotInstead/) +}) + +test('demo package exposes the stable Admin task, node, exception and history DTO contract', () => { + const nodes = read('phase4-carrier-nodes.mjs') + assert.match(nodes, /trackingType === 'CONTAINER_NO'/) + assert.match(nodes, /validContainerNo\(task\?\.trackingNo\)/) + assert.match(nodes, /Array\.isArray\(detail\?\.nodes\)/) + assert.match(nodes, /nodes\.every\(validNode\)/) + assert.match(nodes, /exceptions\.every\(validException\)/) + assert.match(nodes, /histories\.every\(validHistory\)/) + assert.match(nodes, /!Object\.hasOwn\(detail \|\| \{\}, 'attempts'\)/) +}) diff --git a/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs b/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs index 12ed6ecb0..7caafaa4e 100644 --- a/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs @@ -125,7 +125,7 @@ function markerOwnedContext() { const task = { id: QUERY_ID, queryNo: `CSQ${QUERY_ID}`, customerId: CUSTOMER_ID, oceanBookingId: OCEAN_ID, containerBookingId: CONTAINER_ID, carrierCode: 'ZIM', trackingType: 'CONTAINER_NO', - trackingNo: 'TSTU1234567', status: 'READY', triggerSource: 'OUTBOUND_CONFIRM', + trackingNo: 'ZCSU7881970', status: 'READY', triggerSource: 'OUTBOUND_CONFIRM', latestActualNode: 'DEPARTED_WAREHOUSE', latestActualAt: '2026-08-24T08:00:00', version: 0, lastSuccessAt: null, sourceUpdatedAt: '2026-08-24T08:00:00', expiresAt: null, stoppedAt: null, stopReason: null, @@ -165,7 +165,7 @@ function markerOwnedContext() { accessMode: 'OFFICIAL_API', automated: true, coverageGap: false, }]) if (method === 'GET' && requestPath === `${BASE}?page=1&size=20`) return ok({ records: [{ - query: { ...task, id: '9007199254740993999', trackingNo: 'HISTORICAL000' }, expired: false, + query: { ...task, id: '9007199254740993999', trackingNo: 'MATU4646247' }, expired: false, }], page: 1, size: 20, total: 2, pages: 1 }) if (method === 'GET' && requestPath === `${BASE}/${QUERY_ID}`) return ok(detail()) if (method === 'GET' && requestPath === `${BASE}/${QUERY_ID}/history`) return ok(clone(histories)) diff --git a/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs b/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs index 3402042e5..f89fc08e3 100644 --- a/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs +++ b/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs @@ -10,14 +10,25 @@ export const STANDARD_NODES = Object.freeze([ ]) export const EXCEPTION_CODES = Object.freeze(['CUSTOMS_HOLD', 'TERMINAL_HOLD', 'DOCUMENT_HOLD', 'UNKNOWN_HOLD']) export const NODE_SEQUENCES = Object.freeze(Object.fromEntries(STANDARD_NODES.map((node, index) => [node, (index + 1) * 10]))) +const ISO_CONTAINER_PATTERN = /^[A-Z]{3}[UJZ][0-9]{7}$/ function id(value) { return value == null ? '' : String(value) } function number(value) { return Number.isFinite(Number(value)) } function nullableText(value) { return value == null || typeof value === 'string' } -function nullableId(value) { return value == null || validLongId(value) } function nullableIso(value) { return value == null || (typeof value === 'string' && !Number.isNaN(Date.parse(value))) } function validLongId(value) { return typeof value === 'string' && /^\d{16,}$/.test(value) } function validNumericId(value) { return typeof value === 'string' && /^\d+$/.test(value) && value !== '0' } +function validContainerNo(value) { + if (typeof value !== 'string' || !ISO_CONTAINER_PATTERN.test(value)) return false + const letterValues = [10, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 23, 24, + 25, 26, 27, 28, 29, 30, 31, 32, 34, 35, 36, 37, 38] + let sum = 0 + for (let index = 0; index < 10; index += 1) { + const code = value.charCodeAt(index) + sum += (code >= 48 && code <= 57 ? code - 48 : letterValues[code - 65]) * (2 ** index) + } + return Number(value[10]) === (sum % 11) % 10 +} function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) } function rejected(response) { return response?.http === 200 && ['A0409', '409'].includes(codeOf(response)) } function monotonicRejected(response) { @@ -45,7 +56,8 @@ export function manualNodePayload({ expectedVersion, standardNode, observedAt, e function validTask(task) { return validLongId(task?.id) && Boolean(task?.queryNo) && validNumericId(task?.customerId) && validLongId(task?.oceanBookingId) && QUERY_STATUSES.includes(task?.status) - && nullableId(task?.containerBookingId) && nullableText(task?.latestActualNode) + && validLongId(task?.containerBookingId) && task?.trackingType === 'CONTAINER_NO' + && validContainerNo(task?.trackingNo) && nullableText(task?.latestActualNode) && (task?.latestActualNode == null || STANDARD_NODES.includes(task.latestActualNode)) && number(task?.version) && ['latestActualAt', 'lastSuccessAt', 'sourceUpdatedAt', 'expiresAt', 'stoppedAt'] From 9e32efdd90d1aee7cb21eec2e1ddb25d7019f9d7 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 1 Sep 2026 12:03:21 +0800 Subject: [PATCH 23/37] test(firstleg): harden measurement demo package --- .../service/InboundMeasurementService.java | 26 +++++---- .../service/MeasurementReviewService.java | 24 +++++++-- .../InboundMeasurementServiceTest.java | 53 ++++++++++++++++--- .../service/MeasurementReviewServiceTest.java | 53 +++++++++++++++++-- ...und-measurement-box-code-contract.test.mjs | 39 ++++++++++++++ .../inbound-measurement.mjs | 45 ++++++++++++++-- 6 files changed, 208 insertions(+), 32 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java index b5a77648f..d833afbbc 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java @@ -123,11 +123,13 @@ public class InboundMeasurementService { long tenantId, long inboundId, long operatorId, InboundMeasurementRequests.SubmitRequest request) { InboundOrderEntity inbound = requireInbound(tenantId, inboundId); + if (!Set.of("WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED") + .contains(inbound.getProcessStatus())) { + throw new BizException(ResultCode.CONFLICT, "测量已提交或审核完成,不能重复提交或修改冻结结果"); + } InboundMeasurementEntity measurement = requireMeasurement(tenantId, inboundId, request.measurementId()); requireVersion(request.version(), measurement.getVersion()); - boolean alreadyAdvanced = !Set.of("WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED") - .contains(inbound.getProcessStatus()); - if (!alreadyAdvanced && !Set.of(MeasurementBatchService.STATUS_DRAFT, MeasurementBatchService.STATUS_RECEIVED) + if (!Set.of(MeasurementBatchService.STATUS_DRAFT, MeasurementBatchService.STATUS_RECEIVED) .contains(measurement.getStatus())) { throw new BizException(ResultCode.CONFLICT, "当前测量批次不能重复提交审核"); } @@ -135,19 +137,15 @@ public class InboundMeasurementService { List weights = repository.listWeights(tenantId, inboundId); ConfirmedWeightTotals confirmedTotals = confirmedWeightTotals(weights); - if (!alreadyAdvanced) { - requireConfirmedPackageTotal(inbound, items); - measurement.setStatus(STATUS_SUBMITTED); - if (!repository.updateMeasurement(measurement)) { - throw concurrentModification(); - } + requireConfirmedPackageTotal(inbound, items); + measurement.setStatus(STATUS_SUBMITTED); + if (!repository.updateMeasurement(measurement)) { + throw concurrentModification(); } - if (!alreadyAdvanced) { - inbound.setProcessStatus("MEASURE_PENDING"); - inbound.setMeasureReviewStatus("PENDING"); - inbound.setMeasureCompletedAt(LocalDateTime.now()); - } + inbound.setProcessStatus("MEASURE_PENDING"); + inbound.setMeasureReviewStatus("PENDING"); + inbound.setMeasureCompletedAt(LocalDateTime.now()); if (measurement.getTotalPackageCount() != null) { inbound.setTotalPackageCount(measurement.getTotalPackageCount()); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java index 6700292b4..6ec6882ef 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java @@ -72,8 +72,14 @@ public class MeasurementReviewService { } if (approved) { var items = repository.listMeasurementItems(tenantId, measurement.getId()); + if (items.isEmpty() || items.stream().anyMatch(item -> item.getPackageCount() == null + || item.getPackageCount() <= 0 || item.getTotalVolumeCbm() == null + || item.getTotalVolumeCbm().signum() <= 0 || item.getVolumeWeightKg() == null + || item.getVolumeWeightKg().signum() <= 0)) { + throw new BizException(ResultCode.VALIDATION_FAILED, + "测量明细箱数、体积和体积重必须完整且大于零,才能审核通过"); + } long confirmed = items.stream().map(com.baiyue.firstleg.entity.InboundMeasurementItemEntity::getPackageCount) - .filter(java.util.Objects::nonNull) .mapToLong(Integer::longValue) .sum(); if (order.getTotalPackageCount() == null || confirmed != order.getTotalPackageCount()) { @@ -97,13 +103,23 @@ public class MeasurementReviewService { .map(com.baiyue.firstleg.entity.InboundWeightResultEntity::getNetWeightKg) .reduce(BigDecimal.ZERO, BigDecimal::add); BigDecimal volume = items.stream().map(com.baiyue.firstleg.entity.InboundMeasurementItemEntity::getTotalVolumeCbm) - .filter(java.util.Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add); + .reduce(BigDecimal.ZERO, BigDecimal::add); + BigDecimal volumeWeight = items.stream() + .map(com.baiyue.firstleg.entity.InboundMeasurementItemEntity::getVolumeWeightKg) + .reduce(BigDecimal.ZERO, BigDecimal::add); if (order.getActualGrossWeightKg() == null || order.getActualNetWeightKg() == null || order.getActualGrossWeightKg().compareTo(grossWeight) != 0 || order.getActualNetWeightKg().compareTo(netWeight) != 0 - || volume.signum() <= 0) { + || order.getActualVolumeCbm() == null || order.getActualVolumeCbm().compareTo(volume) != 0 + || order.getVolumeWeightKg() == null || order.getVolumeWeightKg().compareTo(volumeWeight) != 0 + || measurement.getTotalPackageCount() == null + || measurement.getTotalPackageCount() != confirmed + || measurement.getTotalVolumeCbm() == null + || measurement.getTotalVolumeCbm().compareTo(volume) != 0 + || measurement.getVolumeWeightKg() == null + || measurement.getVolumeWeightKg().compareTo(volumeWeight) != 0) { throw new BizException(ResultCode.VALIDATION_FAILED, - "审核通过前称重毛重、货物净重汇总和体积必须完整且一致"); + "审核通过前箱数、称重、体积和体积重必须在明细、测量批次与入库汇总间完整且一致"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java index f411552d7..0d43b500d 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java @@ -234,6 +234,38 @@ class InboundMeasurementServiceTest { assertThat(result.confidence()).isEqualByComparingTo("0.50972"); } + @Test + void ocrFailureCreatesManualWeightThatCanBeAdjustedAndConfirmed() { + InboundOrderEntity inbound = inbound("WAIT_MEASURE"); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + + InboundMeasurementResponses.WeightResult created = service.createWeightResult( + 1L, 10L, 9L, + new InboundMeasurementRequests.CreateWeightResultRequest( + 120L, 501L, "FILE_WEIGHT_OCR", null, null, null, null)); + + assertThat(created.recognitionStatus()).isEqualTo("NEEDS_MANUAL"); + ArgumentCaptor saved = + ArgumentCaptor.forClass(InboundWeightResultEntity.class); + verify(repository).insertWeight(saved.capture()); + InboundWeightResultEntity weight = saved.getValue(); + weight.setId(20L); + when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight); + when(repository.updateWeight(weight)).thenReturn(true); + when(repository.listWeights(1L, 10L)).thenReturn(List.of(weight)); + when(repository.updateInboundWeightSummary(inbound)).thenReturn(true); + + InboundMeasurementResponses.WeightResult confirmed = service.confirmWeightResult( + 1L, 10L, 20L, 9L, "操作员", + new InboundMeasurementRequests.ConfirmWeightResultRequest( + 0, new BigDecimal("30.000"), "KG", "OCR失败后人工确认")); + + assertThat(confirmed.recognitionStatus()).isEqualTo("CONFIRMED"); + assertThat(confirmed.grossWeightKg()).isEqualByComparingTo("30.000"); + assertThat(confirmed.netWeightKg()).isEqualByComparingTo("11.500"); + verify(repository, org.mockito.Mockito.times(2)).insertRevision(any(InboundWeightRevisionEntity.class)); + } + @Test void weightPalletOptionsUsesInboundWarehouseWithoutCustomerScope() { InboundOrderEntity inbound = inbound("WAIT_MEASURE"); @@ -799,9 +831,13 @@ class InboundMeasurementServiceTest { } @Test - void editingAndResubmittingInboundMeasurementDoesNotRollBackMainStatus() { + void approvedMeasurementCannotBeResubmittedOrMutateFrozenTotals() { InboundOrderEntity inbound = inbound("WAIT_SPLIT"); inbound.setMeasureReviewStatus("APPROVED"); + inbound.setActualGrossWeightKg(new BigDecimal("40.000")); + inbound.setActualNetWeightKg(new BigDecimal("30.000")); + inbound.setActualVolumeCbm(new BigDecimal("2.0000")); + inbound.setVolumeWeightKg(new BigDecimal("334.000")); InboundMeasurementEntity measurement = new InboundMeasurementEntity(); measurement.setId(30L); measurement.setStatus("APPROVED"); @@ -815,14 +851,17 @@ class InboundMeasurementServiceTest { when(repository.listWeights(1L, 10L)).thenReturn(List.of()); when(repository.updateInbound(inbound)).thenReturn(true); - InboundMeasurementResponses.MeasurementSubmitted result = service.submit(1L, 10L, 9L, - new InboundMeasurementRequests.SubmitRequest(30L, 2)); + assertThatThrownBy(() -> service.submit(1L, 10L, 9L, + new InboundMeasurementRequests.SubmitRequest(30L, 2))) + .isInstanceOf(BizException.class) + .hasMessageContaining("审核完成"); - assertThat(result.processStatus()).isEqualTo("WAIT_SPLIT"); - assertThat(result.measureReviewStatus()).isEqualTo("APPROVED"); - assertThat(measurement.getStatus()).isEqualTo("APPROVED"); + assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("40.000"); + assertThat(inbound.getActualNetWeightKg()).isEqualByComparingTo("30.000"); + assertThat(inbound.getActualVolumeCbm()).isEqualByComparingTo("2.0000"); + assertThat(inbound.getVolumeWeightKg()).isEqualByComparingTo("334.000"); verify(repository, never()).updateMeasurement(any()); - verify(repository).updateInbound(inbound); + verify(repository, never()).updateInbound(any()); } @Test diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java index 54792c723..78aa5cca2 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java @@ -91,6 +91,7 @@ class MeasurementReviewServiceTest { InboundMeasurementItemEntity item = new InboundMeasurementItemEntity(); item.setPackageCount(5); item.setTotalVolumeCbm(new BigDecimal("0.7480")); + item.setVolumeWeightKg(new BigDecimal("124.916")); when(fixture.repository.listMeasurementItems(10001L, 20L)).thenReturn(List.of(item)); when(fixture.repository.transitionReview(10001L, 10L, 9L, 3, true, 20L)).thenReturn(1); when(fixture.repository.transitionMeasurement(10001L, 10L, 20L, "APPROVED")).thenReturn(1); @@ -118,6 +119,40 @@ class MeasurementReviewServiceTest { verify(fixture.repository, never()).transitionReview(anyLong(), anyLong(), anyLong(), anyInt(), anyBoolean(), anyLong()); } + @Test + @DisplayName("多SKU测量行的箱数、重量、体积和体积重守恒后才能进入WAIT_SPLIT") + void shouldApproveMultipleCargoLinesOnlyWhenAllFrozenTotalsMatch() { + Fixture fixture = fixture(false); + InboundMeasurementItemEntity first = measurementItem(2, "0.3000", "50.100"); + InboundMeasurementItemEntity second = measurementItem(3, "0.4480", "74.816"); + when(fixture.repository.listMeasurementItems(10001L, 20L)).thenReturn(List.of(first, second)); + when(fixture.repository.transitionReview(10001L, 10L, 9L, 3, true, 20L)).thenReturn(1); + when(fixture.repository.transitionMeasurement(10001L, 10L, 20L, "APPROVED")).thenReturn(1); + + fixture.service.approve(10001L, 9L, "审核员", 10L, "approve-multi-sku", + new MeasurementReviewRequest(20L, 3, null)); + + ArgumentCaptor history = + ArgumentCaptor.forClass(InboundStatusHistoryEntity.class); + verify(fixture.repository).insertHistory(history.capture()); + assertThat(history.getValue().getToStatus()).isEqualTo("WAIT_SPLIT"); + } + + @Test + @DisplayName("体积或体积重任一层汇总不一致时审核零写入") + void shouldRejectApprovalWhenVolumeSnapshotDoesNotConserve() { + Fixture fixture = fixture(false); + fixture.order.setActualVolumeCbm(new BigDecimal("0.7479")); + + assertThatThrownBy(() -> fixture.service.approve(10001L, 9L, "审核员", 10L, + "approve-volume-mismatch", new MeasurementReviewRequest(20L, 3, null))) + .isInstanceOf(BizException.class) + .hasMessageContaining("体积和体积重"); + + verify(fixture.repository, never()).transitionReview(anyLong(), anyLong(), anyLong(), anyInt(), anyBoolean(), anyLong()); + verify(fixture.repository, never()).transitionMeasurement(anyLong(), anyLong(), anyLong(), anyString()); + } + @Test @DisplayName("驳回原因必填且状态推进为测量驳回") void shouldRequireRejectReason() { @@ -217,13 +252,15 @@ class MeasurementReviewServiceTest { order.setTotalPackageCount(5); order.setActualGrossWeightKg(new BigDecimal("1787.500")); order.setActualNetWeightKg(new BigDecimal("1747.500")); + order.setActualVolumeCbm(new BigDecimal("0.7480")); + order.setVolumeWeightKg(new BigDecimal("124.916")); InboundMeasurementEntity measurement = new InboundMeasurementEntity(); measurement.setId(20L); measurement.setStatus("SUBMITTED"); - InboundMeasurementItemEntity item = new InboundMeasurementItemEntity(); - item.setPackageCount(5); - item.setTotalVolumeCbm(new BigDecimal("0.7480")); - item.setVolumeWeightKg(new BigDecimal("124.916")); + measurement.setTotalPackageCount(5); + measurement.setTotalVolumeCbm(new BigDecimal("0.7480")); + measurement.setVolumeWeightKg(new BigDecimal("124.916")); + InboundMeasurementItemEntity item = measurementItem(5, "0.7480", "124.916"); when(repository.findOwned(10001L, 10L)).thenReturn(order); when(repository.findMeasurement(10001L, 10L, 20L)).thenReturn(measurement); when(repository.listMeasurementItems(10001L, 20L)).thenReturn(List.of(item)); @@ -236,6 +273,14 @@ class MeasurementReviewServiceTest { return new Fixture(service, repository, idempotency, order, measurement); } + private InboundMeasurementItemEntity measurementItem(int packages, String volume, String volumeWeight) { + InboundMeasurementItemEntity item = new InboundMeasurementItemEntity(); + item.setPackageCount(packages); + item.setTotalVolumeCbm(new BigDecimal(volume)); + item.setVolumeWeightKg(new BigDecimal(volumeWeight)); + return item; + } + private InboundWeightResultEntity confirmedWeight(String grossWeightKg, String netWeightKg) { InboundWeightResultEntity weight = new InboundWeightResultEntity(); weight.setRecognitionStatus("CONFIRMED"); diff --git a/scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs b/scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs index 394982e55..d89ddff9c 100644 --- a/scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs +++ b/scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs @@ -3,6 +3,11 @@ import { readFileSync } from 'node:fs' import test from 'node:test' const source = readFileSync(new URL('inbound-measurement.mjs', import.meta.url), 'utf8') +const read = (relativeUrl) => readFileSync(new URL(relativeUrl, import.meta.url), 'utf8') +const labelTests = read('../../firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelServiceTest.java') +const measurementTests = read('../../firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java') +const cartonTests = read('../../firstleg/src/test/java/com/baiyue/firstleg/service/CartonMeasurementServiceTest.java') +const reviewTests = read('../../firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java') test('measurement acceptance scans the stable box code frozen by first print', () => { assert.match(source, /const boxCode = `\$\{inboundCode\}#\$\{originalPackageCount\}-1`/) @@ -32,3 +37,37 @@ test('measurement fixture derives pallet resources from confirmed image count', test('volume ceiling removes binary floating point noise before scaling', () => { assert.match(source, /Number\.EPSILON \* Math\.max\(1, Math\.abs\(value\)\) \* 4/) }) + +test('demo package freezes repeated label printing and Mojia dimension replacement', () => { + assert.match(labelTests, /repeatedAndPartialPrintsReadFrozenMappingWithoutReallocation/) + assert.match(labelTests, /changedCargoKeepsTheFirstFrozenBoxCodes/) + assert.match(cartonTests, /repeatedScanReplacesDeviceValuesButPreservesManualCountAndOldImageIsDeleted/) + assert.match(source, /增量 fixture 形成动态真实独立货物测量明细/) +}) + +test('demo package covers OCR failure, manual confirmation, aggregation and stale versions', () => { + assert.match(source, /OCR无有效重量时转人工确认/) + assert.match(source, /称重旧版本确认被拒绝且不覆盖已确认结果/) + assert.match(source, /图片张数决定托数且净重汇总已落地/) + assert.match(measurementTests, /ocrFailureCreatesManualWeightThatCanBeAdjustedAndConfirmed/) + assert.match(measurementTests, /confirmingFifthPalletSeparatelyAggregatesGrossAndCargoNetWeight/) + assert.match(measurementTests, /confirmingWithStaleVersionDoesNotTreatRequestAsIdempotent/) +}) + +test('demo package keeps pallet pages bounded and historical snapshots read-only', () => { + assert.match(source, /weight-pallet-options[^\n]+page=1&size=20&keyword=/) + assert.match(source, /records\.length <= 20/) + assert.match(measurementTests, /detailUsesStoredPalletSnapshotAfterCandidateBecomesUnavailable/) +}) + +test('approval freezes measurement and requires quantity weight and volume conservation', () => { + assert.match(source, /审核进入WAIT_SPLIT后箱码尺寸与图片快照不可再覆盖/) + assert.match(measurementTests, /approvedMeasurementCannotBeResubmittedOrMutateFrozenTotals/) + assert.match(reviewTests, /shouldApproveMultipleCargoLinesOnlyWhenAllFrozenTotalsMatch/) + assert.match(reviewTests, /shouldRejectApprovalWhenVolumeSnapshotDoesNotConserve/) + assert.match(source, /actualPackageCount[^\n]+originalPackageCount/) + assert.match(source, /actualGrossWeightKg[^\n]+weightEvidence\.grossWeightKg/) + assert.match(source, /actualNetWeightKg[^\n]+weightEvidence\.netWeightKg/) + assert.match(source, /actualVolumeCbm[^\n]+totalVolume/) + assert.match(source, /volumeWeightKg[^\n]+volumeWeight/) +}) diff --git a/scripts/admin-test-env-modules/inbound-measurement.mjs b/scripts/admin-test-env-modules/inbound-measurement.mjs index 1171ab60e..856c8594e 100644 --- a/scripts/admin-test-env-modules/inbound-measurement.mjs +++ b/scripts/admin-test-env-modules/inbound-measurement.mjs @@ -84,7 +84,7 @@ async function waitForCompletedMeasurement(context, measurementPath, attempts = return { response, measurement, attempts } } -async function provisionWarehouseResources(context, { inboundId, warehouseId, palletCount }) { +async function provisionWarehouseResources(context, { inboundId, tenantId, warehouseId, palletCount }) { if (!warehouseId || !Number.isInteger(palletCount) || palletCount < 1) return null const resources = [] @@ -92,6 +92,20 @@ async function provisionWarehouseResources(context, { inboundId, warehouseId, pa if (!candidates) return null const palletIds = randomlyReusePalletIds(candidates, palletCount, context.context?.waitSplitVariant?.randomValue) + const candidateKeyword = candidates.find((item) => item.id === palletIds[0])?.code || '' + const candidateResponse = await context.request('GET', + `/api/platform/v1/first-leg/inbounds/${inboundId}/weight-pallet-options?${query(tenantId)}&page=1&size=20&keyword=${encodeURIComponent(candidateKeyword)}`) + const candidatePage = context.apiSuccess(MODULE, '按关键词分页查询当前入库仓称重托盘候选', candidateResponse, + `warehouseId=${warehouseId}, keyword=${candidateKeyword}`, OPTIONS) + const pageControlled = Array.isArray(candidatePage?.records) && candidatePage.records.length <= 20 + && Number(candidatePage?.page) === 1 && Number(candidatePage?.size) === 20 + && Number(candidatePage?.total) >= candidatePage.records.length + && candidatePage.records.some((item) => asId(item?.palletId) === palletIds[0]) + context.assert(MODULE, '托盘候选保持服务端关键词分页且不返回无界数组', candidateResponse, + pageControlled, + `records=${candidatePage?.records?.length ?? 'missing'}, page=${candidatePage?.page ?? 'missing'}, size=${candidatePage?.size ?? 'missing'}, total=${candidatePage?.total ?? 'missing'}`, + OPTIONS) + if (!pageControlled) return null for (let index = 1; index <= palletCount; index += 1) { const palletId = palletIds[index - 1] context.assert(MODULE, `随机复用原有称重托盘 ${index}/${palletCount}`, null, @@ -134,6 +148,10 @@ async function createConfirmedWeightEvidence(context, { const created = context.apiSuccess(MODULE, `登记称重图片与托盘快照 ${index + 1}/${palletIds.length}`, createResponse, `fileId=${fileId}, palletId=${palletId}`, OPTIONS) if (!created?.id) return null + context.assert(MODULE, `OCR无有效重量时转人工确认 ${index + 1}/${palletIds.length}`, createResponse, + created.recognitionStatus === 'NEEDS_MANUAL' && created.ocrWeightValue == null, + `recognitionStatus=${created.recognitionStatus || 'missing'}, ocrWeightValue=${created.ocrWeightValue ?? 'null'}`, + OPTIONS) const grossWeight = Number(grossWeightPerPalletKg) + index * 5 const confirmResponse = await context.request('POST', @@ -148,6 +166,16 @@ async function createConfirmedWeightEvidence(context, { if (confirmed?.recognitionStatus !== 'CONFIRMED' || !Number.isFinite(netWeightKg) || netWeightKg <= 0) { return null } + const staleConfirmResponse = await context.request('POST', + `/api/platform/v1/first-leg/inbounds/${inboundId}/weight-results/${created.id}/confirm?${query(tenantId)}`, { + version: Number(created.version), adjustedWeightValue: grossWeight + 1, + adjustedWeightUnit: 'KG', adjustmentReason: `${context.marker} 旧版本冲突验证`, + }) + context.assert(MODULE, `称重旧版本确认被拒绝且不覆盖已确认结果 ${index + 1}/${palletIds.length}`, + staleConfirmResponse, + staleConfirmResponse.http === 200 && String(staleConfirmResponse.body?.code) === 'A0409', + `HTTP=${staleConfirmResponse.http}, code=${staleConfirmResponse.body?.code ?? 'missing'}, requestId=${staleConfirmResponse.requestId || 'missing'}`, + OPTIONS) confirmedResults.push({ id: asId(created.id), palletId, netWeightKg, grossWeightKg }) } @@ -161,7 +189,9 @@ async function createConfirmedWeightEvidence(context, { const netWeightKg = confirmedResults.reduce((sum, result) => sum + result.netWeightKg, 0) const persisted = confirmedRows.length === palletIds.length && confirmedResults.every((expected) => confirmedRows.some((row) => - asId(row?.id) === expected.id && asId(row?.palletId) === expected.palletId)) + asId(row?.id) === expected.id && asId(row?.palletId) === expected.palletId + && String(row?.palletCode || '').trim() && String(row?.palletType || '').trim() + && Number.isFinite(Number(row?.palletTareWeightKg)))) && closeTo(workspace?.actualGrossWeightKg, grossWeightKg, 0.001) && closeTo(workspace?.actualNetWeightKg, netWeightKg, 0.001) context.assert(MODULE, '图片张数决定托数且净重汇总已落地', workspaceResponse, persisted, @@ -533,7 +563,7 @@ export async function run(context) { const palletCount = Math.max(1, Number(context.context?.waitSplitVariant?.palletCount || 1)) const warehouseResourcesReady = await provisionWarehouseResources(context, { - inboundId, warehouseId: asId(summary?.warehouseId), palletCount, + inboundId, tenantId, warehouseId: asId(summary?.warehouseId), palletCount, }) context.assert(MODULE, '测量提交前按称重图片数准备真实库位', draftResponse, Boolean(warehouseResourcesReady), @@ -606,6 +636,15 @@ export async function run(context) { && closeTo(finalMeasurement?.volumeWeightKg, volumeWeight), `processStatus=${finalMeasurement?.processStatus ?? 'missing'}, measurementStatus=${finalMeasurement?.measurementStatus ?? 'missing'}, packages=${finalMeasurement?.actualPackageCount ?? 'missing'}, pallets=${weightEvidence.count}, grossWeight=${finalMeasurement?.actualGrossWeightKg ?? 'missing'}, netWeight=${finalMeasurement?.actualNetWeightKg ?? 'missing'}`, OPTIONS) + const approvedScanResponse = await multipartRequest(context, scanPath, { + boxCode, ...replacement, machineNo: 'MJ-ADMIN-E2E-AFTER-APPROVAL', + measuredAt: new Date().toISOString().slice(0, 19), + }) + context.assert(MODULE, '审核进入WAIT_SPLIT后箱码尺寸与图片快照不可再覆盖', approvedScanResponse, + approvedScanResponse.http === 200 && String(approvedScanResponse.body?.code) === 'A0409', + `HTTP=${approvedScanResponse.http}, code=${approvedScanResponse.body?.code ?? 'missing'}, requestId=${approvedScanResponse.requestId || 'missing'}`, + OPTIONS) + context.createdResources.inboundMeasurement = { marker: context.marker, tenantId, inboundId, measurementId: asId(draft.measurementId), boxCode, finalStatus: finalMeasurement?.measurementStatus || null, From 97168b19d80dfebcb3ee88c03f824bc5137f8d76 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 1 Sep 2026 14:24:59 +0800 Subject: [PATCH 24/37] feat: complete phase 5 overseas fulfillment contracts --- AGENTS.md | 4 + deploy/sql/README.md | 16 +- deploy/sql/头程数据库统一增量-MySQL8.sql | 198 ++++++++++++ deploy/sql/海外区域统一增量-MySQL8.sql | 180 ++++++++++- .../OverseasLastlegRouteContractTest.java | 16 + .../db/phase5-delivery-permissions.sql | 195 ++++++++++++ iam/src/main/resources/openapi/iam-v1.yaml | 4 +- .../controller/IamOpenApiContractTest.java | 5 + ...erseasPhase4PermissionSqlContractTest.java | 4 + ...ase5DeliveryPermissionSqlContractTest.java | 123 ++++++++ .../AuthorizationSnapshotServiceTest.java | 16 + lastleg-service/AGENTS.md | 7 +- ...astleg-internal-modularization-baseline.md | 10 +- .../DeliveryTransferController.java | 49 +-- .../MerchantDeliveryController.java | 7 +- .../controller/OutboundPdaController.java | 37 ++- .../delivery/dto/DeliveryRequests.java | 1 + .../delivery/dto/DeliveryResponses.java | 7 + .../delivery/entity/OutboundPdaTask.java | 1 + .../delivery/mapper/OutboundOrderMapper.java | 4 + .../mapper/OutboundPdaTaskMapper.java | 18 ++ .../service/DeliveryTransferService.java | 201 ++++++++++-- .../controller/DispatchController.java | 53 +--- .../dispatch/dto/DispatchRequests.java | 4 +- .../dispatch/mapper/DispatchOrderMapper.java | 9 +- .../pda/controller/DispatchPdaController.java | 75 +++-- .../dispatch/pda/dto/DispatchPdaRequests.java | 15 +- .../mapper/DispatchPdaOperationMapper.java | 5 + .../pda/mapper/DispatchPdaTaskMapper.java | 7 + .../service/DispatchPdaWorkflowService.java | 121 ++++---- .../dispatch/service/DispatchService.java | 46 +-- .../lastleg/security/OverseasPermissions.java | 11 +- .../src/main/resources/db/lastleg.sql | 12 +- .../openapi/overseas-business-v1.yaml | 156 +++++++++- .../openapi/overseas-pda-operations-v1.yaml | 125 +++++++- .../Phase5PdaDeploymentSqlContractTest.java | 42 +++ .../PlatformDispatchOutboundContractTest.java | 166 ++++++++++ .../MerchantDeliveryControllerTest.java | 139 +++++++++ .../service/DeliveryTransferServiceTest.java | 270 ++++++++++++++-- .../SmartDispatchOpenApiContractTest.java | 2 +- .../DispatchPdaWorkflowServiceTest.java | 291 +++++++++++------- .../dispatch/service/DispatchServiceTest.java | 62 +++- .../pda/PdaOperationsOpenApiContractTest.java | 24 +- .../pda/PdaOutboundDispatchContractTest.java | 109 +++++++ ...verseasProcurementOpenApiContractTest.java | 6 +- .../lastleg-slice-dependencies.json | 2 + 46 files changed, 2446 insertions(+), 409 deletions(-) create mode 100644 iam/src/main/resources/db/phase5-delivery-permissions.sql create mode 100644 iam/src/test/java/com/baiyue/iam/repository/Phase5DeliveryPermissionSqlContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/delivery/Phase5PdaDeploymentSqlContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/delivery/PlatformDispatchOutboundContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryControllerTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOutboundDispatchContractTest.java diff --git a/AGENTS.md b/AGENTS.md index e22d2aabc..fe2d5c9d4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,6 +45,9 @@ | 柜级入仓计划进度与整柜转换 | `lastleg-service/` | `orders/container-summaries` 按 `sourceContainerBookingId` 跨客户聚合全柜进度与 `allPlansReady`,详情返回柜内全部订单/入仓码/计划且不受订单分页截断;`plannedArrivalAt` 只取装柜完成事件 `estimatedWarehouseArrivalAt` 冻结事实;`inbound-plans/convert-container` 锁定同柜订单,每票生成独立计划,整批原子且幂等 | | 海外入仓分页、FBA 预约、PDA 柜号签收与入仓 | `lastleg-service/` | 平台入口 `inboundplan/controller/InboundPlanController.java`、`InboundPlanQueryMapper.java`、`InboundFbaAppointmentService.java`;PDA 公网入口 `inboundplan/pda/controller/ReceivingContainerController.java`、`ReceivingContainerService.java`,旧 planNo/allocationId Controller 已移除 | | 海外仓排车查询、候选与智能预排 | `lastleg-service/` | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`;草稿柜、托盘锁和预排归 `dispatch/`,只读排车和预排创建独立开关,不自动开放编辑、取消、发车、PDA 出库或派送 | +| 五期海外排车执行、PDA 出库、派送与一件代发 | `lastleg-service/` | `dispatch/controller/DispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`、`delivery/controller/DeliveryTransferController.java`、`OutboundPdaController.java`、`MerchantDeliveryController.java`,一件代发库存归 `dropship/`;统一经 `gateway-overseas/`,设备端是否已有正式入口继续查 `../apps/pda-overseas-uniapp/AGENTS.md` | +| 五期海外异常退回与客户回访 | `lastleg-service/` | `exceptionorder/`、`customervisit/`;平台异常建档和关系解析、派送退回入仓、派送完成/状态、回访资料与附件均以 Lastleg 自身客户范围和状态为准 | +| 五期一件代发 SKU/箱唛与暂扣跨区协同 | `order/`、`firstleg/`、`common-event-contract/`、`lastleg-service/` | Order `InternalOrderCargoController` 提供强制 SKU;Firstleg `InboundCartonLabelService` 冻结 SKU、`LoadingWorkPalletService` 维护装柜工作托盘并消费 `OverseasInboundOrderDetainedChangedV1`;Lastleg `dropship/` 建立库存并发布暂扣变更 | | 客户海外仓贴标、转运、废弃与退货换标 | `lastleg-service/` | `relabel/`、`transfer/`、`discard/` 的 Merchant Controller/Service;Client 消费端 `../apps/client-web/src/modules/overseas-warehouse/`,统一经 `gateway-overseas/` | | 海外仓 PDA 盘点、移库、托盘、换标与查询 | `lastleg-service/`、`warehouse-overseas-service/` | Lastleg `pda/stocktake/`、`pda/move/`、`pda/warehouse/`、`palletops/`、`relabel/pda/`;契约 `lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml`;物理托盘流转与补偿进入 Warehouse `InternalPdaPalletTransitionController`,设备端入口是否开放以 `../apps/pda-overseas-uniapp/AGENTS.md` 为准 | | 国内船节点向海外接柜门禁事件 | `firstleg/`、`common-event-contract/`、`lastleg-service/` | Firstleg `portschedule/service/CarrierScheduleNodeOutboxService.java` 发布 `FirstLegContainerShippingNodeRecordedV1`;Lastleg `shipping/service/ContainerShippingNodeConsumer.java` 单调消费,只有实际 `PICKUP_READY` 及以后节点允许海外 PDA 候选进入可收货 | @@ -107,6 +110,7 @@ - 二期:`../docs/二期/README.md`、`../docs/二期/二期需求优化版-v2-测量计费入库主流程.md`、需求确认清单及联调报告 - 三期:`../docs/三期/README.md`、`../docs/三期/三期开工门禁.md`、`../docs/三期/三期开发规划.md`、`../docs/三期/三期需求确认表.md`、页面与原型清单 - 四期:`../docs/四期/README.md`;海外入口 `gateway-overseas/`,尾程与入仓库存 `lastleg-service/`,海外仓储资源 `warehouse-overseas-service/` +- 五期:当前以本轮确认的正式 Controller、前端路由、权限、SQL 与跨区事件契约为范围依据;海外排车执行、出库、派送、一件代发、异常退回和客户回访进入 `lastleg-service/`,国内 SKU/箱唛及暂扣协同进入 `order/`、`firstleg/`、`common-event-contract/` - 财务:`../docs/财务管理/README.md` 中已确认的范围、对象、状态和接口约束 全量验收任务再读取对应阶段进度表、验收报告和证据矩阵;普通代码定位只读取当前链路涉及的范围、契约、模块指引和代码。 diff --git a/deploy/sql/README.md b/deploy/sql/README.md index 5a946294c..135a4f942 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -225,4 +225,18 @@ SELECT - 固定入口已同步 `iam/src/main/resources/db/lastleg-permissions.sql` 的 `platform:overseas-*` 权限,并默认授予平台系统管理员。 - 旧 `platform:lastleg:*` 权限会撤销角色关联并停用;不保留旧码兼容映射。 -- 本批不创建箱号、产品库、海外出库、退货出库或海外派送权限。 +- 四期目录保留海外出库、派送列表入口;五期写操作和客户派送权限由后续五期区块直接收敛,不使用旧码兼容映射。 + +## 五期海外排车、出库与派送权限 + +- 国内固定入口已在四期 `lastleg-permissions.sql` 权限段之后完整内联 `iam/src/main/resources/db/phase5-delivery-permissions.sql`;测试和部署只执行固定入口,不再单独执行模块脚本。 +- 平台端固定使用排车编辑/取消/转派送、出库列表/创建/取消和派送列表七项权限;列表权限包含详情只读,写操作保持独立授权。 +- 客户端固定使用 `merchant:warehouse:delivery:list` 和 `merchant:warehouse:delivery:create`;客户默认管理员获得两项权限,其他客户角色不自动扩权。 +- `WAREHOUSE_OPERATOR` 获得七项后台作业权限,`WAREHOUSE_TALLY` 只获得出库和派送列表只读;国内、海外 PDA 岗位不得获得上述后台权限。 + +## 五期海外排车与出库结构升级 + +- 五期数据库部署仍只执行 `海外区域统一增量-MySQL8.sql`;空库先执行 Lastleg 与 Warehouse Overseas 当前基线,再完整执行固定入口并确认最末 `migration_status = PASS`。 +- 固定入口只补目标字段并做 fail-fast 校验,不回填、改号、清理或从其他表推断开发旧行。`ll_dispatch_pda_task.warehouse_id` 存在 `NULL`、空值或 `0`,或任一待收敛全局业务号/任务号、完成幂等键发生冲突时,脚本立即 `SIGNAL`;应重新造数,重置或清理仍需用户另行授权。 +- 数据门禁通过后,脚本原位把 `dispatch_no`、`outbound_no`、排车 PDA `task_no`、出库 PDA `task_no` 收敛为全局唯一,并建立 `(customer_id, completion_idempotency_key)` 完成命令唯一约束;旧客户复合索引不保留。 +- 必须在启动或重启 Lastleg Service 前完成固定入口;不得单独执行模块基线补丁、日期脚本或手工兼容 SQL。 diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index a8af86c29..aa410bda8 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -16340,6 +16340,204 @@ WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND UPPER(role.role_code)='PLATFORM_CUSTOM_ADMIN' AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0; +-- 五期海外排车、出库与派送权限(来源:iam/db/phase5-delivery-permissions.sql) +USE baiyue_iam; +SET NAMES utf8mb4; + +-- 五期海外排车、出库与派送权限。列表权限同时覆盖列表和详情只读,写操作保持独立授权。 +DROP PROCEDURE IF EXISTS guard_phase5_delivery_permission_ids; +DELIMITER $$ +CREATE PROCEDURE guard_phase5_delivery_permission_ids() +BEGIN + IF EXISTS ( + SELECT 1 FROM iam_permission + WHERE (id = 5107016 AND permission_code <> 'platform:overseas-warehouse:dispatch:edit') + OR (id = 5107017 AND permission_code <> 'platform:overseas-warehouse:dispatch:cancel') + OR (id = 5107018 AND permission_code <> 'platform:overseas-warehouse:dispatch:transfer-delivery') + OR (id = 5107090 AND permission_code <> 'platform:overseas-warehouse:outbound:list') + OR (id = 5107091 AND permission_code <> 'platform:overseas-warehouse:outbound:create') + OR (id = 5107092 AND permission_code <> 'platform:overseas-warehouse:delivery:list') + OR (id = 5107093 AND permission_code <> 'platform:overseas-warehouse:outbound:cancel') + OR (id = 5306000 AND permission_code <> 'merchant:warehouse:delivery:list') + OR (id = 5306001 AND permission_code <> 'merchant:warehouse:delivery:create') + ) OR EXISTS ( + SELECT 1 FROM iam_permission + WHERE (permission_code = 'platform:overseas-warehouse:dispatch:edit' AND id <> 5107016) + OR (permission_code = 'platform:overseas-warehouse:dispatch:cancel' AND id <> 5107017) + OR (permission_code = 'platform:overseas-warehouse:dispatch:transfer-delivery' AND id <> 5107018) + OR (permission_code = 'platform:overseas-warehouse:outbound:list' AND id <> 5107090) + OR (permission_code = 'platform:overseas-warehouse:outbound:create' AND id <> 5107091) + OR (permission_code = 'platform:overseas-warehouse:delivery:list' AND id <> 5107092) + OR (permission_code = 'platform:overseas-warehouse:outbound:cancel' AND id <> 5107093) + OR (permission_code = 'merchant:warehouse:delivery:list' AND id <> 5306000) + OR (permission_code = 'merchant:warehouse:delivery:create' AND id <> 5306001) + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:权限ID或编码冲突'; + END IF; +END$$ +DELIMITER ; +CALL guard_phase5_delivery_permission_ids(); +DROP PROCEDURE guard_phase5_delivery_permission_ids; + +INSERT INTO iam_permission + (id, tenant_id, app_code, permission_type, parent_id, permission_code, + permission_name, route_path, http_method, sort_no, status, built_in, deleted) +VALUES + (5107016,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:dispatch:edit','编辑海外排车',NULL,'PUT',716,'ACTIVE',1,0), + (5107017,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:dispatch:cancel','取消海外排车',NULL,'POST',717,'ACTIVE',1,0), + (5107018,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:dispatch:transfer-delivery','排车转派送',NULL,'POST',718,'ACTIVE',1,0), + (5107090,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:outbound:list','海外出库单','/overseas-warehouse/outbounds','GET',765,'ACTIVE',1,0), + (5107091,0,'PLATFORM','BUTTON',5107090,'platform:overseas-warehouse:outbound:create','创建海外出库单',NULL,'POST',766,'ACTIVE',1,0), + (5107092,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:delivery:list','海外派送单','/overseas-warehouse/delivery-orders','GET',767,'ACTIVE',1,0), + (5107093,0,'PLATFORM','BUTTON',5107090,'platform:overseas-warehouse:outbound:cancel','取消海外出库单',NULL,'POST',768,'ACTIVE',1,0), + (5306000,0,'MERCHANT','MENU',0,'merchant:warehouse:delivery:list','海外仓派送单','/overseas-warehouse/delivery-orders','GET',60,'ACTIVE',1,0), + (5306001,0,'MERCHANT','BUTTON',5306000,'merchant:warehouse:delivery:create','创建海外仓派送单',NULL,'POST',10,'ACTIVE',1,0) +ON DUPLICATE KEY UPDATE + permission_type=VALUES(permission_type), parent_id=VALUES(parent_id), + permission_name=VALUES(permission_name), route_path=VALUES(route_path), + http_method=VALUES(http_method), sort_no=VALUES(sort_no), status='ACTIVE', + built_in=1, deleted=0; + +-- 平台管理员默认拥有五期全部后台动作;已有的平台自定义管理员保持同等五期操作能力。 +INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) +SELECT 0, role.id, permission.id, CURRENT_TIMESTAMP(3) + FROM iam_role role + JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' + WHERE role.tenant_id=0 AND role.app_code='PLATFORM' + AND UPPER(role.role_code) IN ('PLATFORM_ADMIN','PLATFORM_CUSTOM_ADMIN') + AND role.status='ACTIVE' AND role.deleted=0 + AND permission.permission_code IN ( + 'platform:overseas-warehouse:dispatch:edit', + 'platform:overseas-warehouse:dispatch:cancel', + 'platform:overseas-warehouse:dispatch:transfer-delivery', + 'platform:overseas-warehouse:outbound:list', + 'platform:overseas-warehouse:outbound:create', + 'platform:overseas-warehouse:outbound:cancel', + 'platform:overseas-warehouse:delivery:list') + AND permission.status='ACTIVE' AND permission.deleted=0; + +-- 仓库后台岗位按最小职责授权;PDA 岗位不得获得后台排车、出库或派送权限。 +DROP TEMPORARY TABLE IF EXISTS tmp_phase5_delivery_role_permission; +CREATE TEMPORARY TABLE tmp_phase5_delivery_role_permission ( + role_code VARCHAR(64) NOT NULL, + permission_code VARCHAR(160) NOT NULL, + PRIMARY KEY (role_code, permission_code) +) ENGINE=MEMORY; +INSERT INTO tmp_phase5_delivery_role_permission (role_code, permission_code) VALUES + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:dispatch:edit'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:dispatch:cancel'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:dispatch:transfer-delivery'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:outbound:list'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:outbound:create'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:outbound:cancel'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:delivery:list'), + ('WAREHOUSE_TALLY','platform:overseas-warehouse:outbound:list'), + ('WAREHOUSE_TALLY','platform:overseas-warehouse:delivery:list'); + +DELETE grant_row FROM iam_role_permission grant_row +JOIN iam_role role ON role.id=grant_row.role_id AND role.tenant_id=grant_row.tenant_id +JOIN iam_permission permission ON permission.id=grant_row.permission_id AND permission.tenant_id=grant_row.tenant_id +LEFT JOIN tmp_phase5_delivery_role_permission expected + ON expected.role_code=role.role_code AND expected.permission_code=permission.permission_code +WHERE role.tenant_id=0 AND role.app_code='PLATFORM' + AND role.role_code IN ('WAREHOUSE_OPERATOR','WAREHOUSE_TALLY','OVERSEAS_WAREHOUSE_PDA_OPERATOR','CN_FIRSTLEG_PDA_OPERATOR') + AND permission.permission_code IN ( + 'platform:overseas-warehouse:dispatch:edit', + 'platform:overseas-warehouse:dispatch:cancel', + 'platform:overseas-warehouse:dispatch:transfer-delivery', + 'platform:overseas-warehouse:outbound:list', + 'platform:overseas-warehouse:outbound:create', + 'platform:overseas-warehouse:outbound:cancel', + 'platform:overseas-warehouse:delivery:list') + AND expected.permission_code IS NULL; + +INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) +SELECT 0, role.id, permission.id, CURRENT_TIMESTAMP(3) + FROM tmp_phase5_delivery_role_permission expected + JOIN iam_role role ON role.tenant_id=0 AND role.app_code='PLATFORM' + AND role.role_code=expected.role_code AND role.status='ACTIVE' AND role.deleted=0 + JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' + AND permission.permission_code=expected.permission_code + AND permission.status='ACTIVE' AND permission.deleted=0; + +-- 客户默认管理员获得派送列表/详情只读及独立创建;其他客户角色不自动扩权。 +INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) +SELECT 0, role.id, permission.id, CURRENT_TIMESTAMP(3) + FROM iam_role role + JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='MERCHANT' + WHERE role.tenant_id=0 AND role.app_code='MERCHANT' AND role.role_code='MERCHANT_OWNER' + AND role.status='ACTIVE' AND role.deleted=0 + AND permission.permission_code IN ( + 'merchant:warehouse:delivery:list', + 'merchant:warehouse:delivery:create') + AND permission.status='ACTIVE' AND permission.deleted=0; + +DROP PROCEDURE IF EXISTS assert_phase5_delivery_permissions; +DELIMITER $$ +CREATE PROCEDURE assert_phase5_delivery_permissions() +BEGIN + IF (SELECT COUNT(*) FROM iam_permission + WHERE tenant_id=0 AND status='ACTIVE' AND deleted=0 + AND permission_code IN ( + 'platform:overseas-warehouse:dispatch:edit', + 'platform:overseas-warehouse:dispatch:cancel', + 'platform:overseas-warehouse:dispatch:transfer-delivery', + 'platform:overseas-warehouse:outbound:list', + 'platform:overseas-warehouse:outbound:create', + 'platform:overseas-warehouse:outbound:cancel', + 'platform:overseas-warehouse:delivery:list', + 'merchant:warehouse:delivery:list', + 'merchant:warehouse:delivery:create')) <> 9 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:权限目录不完整'; + END IF; + IF (SELECT COUNT(*) FROM iam_role_permission grant_row + JOIN iam_role role ON role.id=grant_row.role_id AND role.tenant_id=grant_row.tenant_id + JOIN iam_permission permission ON permission.id=grant_row.permission_id + WHERE role.role_code='PLATFORM_ADMIN' + AND permission.permission_code IN ( + 'platform:overseas-warehouse:dispatch:edit', + 'platform:overseas-warehouse:dispatch:cancel', + 'platform:overseas-warehouse:dispatch:transfer-delivery', + 'platform:overseas-warehouse:outbound:list', + 'platform:overseas-warehouse:outbound:create', + 'platform:overseas-warehouse:outbound:cancel', + 'platform:overseas-warehouse:delivery:list')) <> 7 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:平台管理员授权不完整'; + END IF; + IF (SELECT COUNT(*) FROM tmp_phase5_delivery_role_permission WHERE role_code='WAREHOUSE_OPERATOR') <> 7 + OR (SELECT COUNT(*) FROM tmp_phase5_delivery_role_permission WHERE role_code='WAREHOUSE_TALLY') <> 2 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:仓库岗位最小权限矩阵异常'; + END IF; + IF EXISTS (SELECT 1 FROM iam_role_permission grant_row + JOIN iam_role role ON role.id=grant_row.role_id AND role.tenant_id=grant_row.tenant_id + JOIN iam_permission permission ON permission.id=grant_row.permission_id + WHERE role.role_code IN ('OVERSEAS_WAREHOUSE_PDA_OPERATOR','CN_FIRSTLEG_PDA_OPERATOR') + AND permission.permission_code IN ( + 'platform:overseas-warehouse:dispatch:edit', + 'platform:overseas-warehouse:dispatch:cancel', + 'platform:overseas-warehouse:dispatch:transfer-delivery', + 'platform:overseas-warehouse:outbound:list', + 'platform:overseas-warehouse:outbound:create', + 'platform:overseas-warehouse:outbound:cancel', + 'platform:overseas-warehouse:delivery:list')) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:PDA岗位错误获得后台权限'; + END IF; + IF (SELECT COUNT(*) FROM iam_role_permission grant_row + JOIN iam_role role ON role.id=grant_row.role_id AND role.tenant_id=grant_row.tenant_id + JOIN iam_permission permission ON permission.id=grant_row.permission_id + WHERE role.role_code='MERCHANT_OWNER' AND role.app_code='MERCHANT' + AND permission.permission_code IN ( + 'merchant:warehouse:delivery:list', + 'merchant:warehouse:delivery:create')) <> 2 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:客户默认管理员授权不完整'; + END IF; +END$$ +DELIMITER ; +CALL assert_phase5_delivery_permissions(); +DROP PROCEDURE assert_phase5_delivery_permissions; +DROP TEMPORARY TABLE tmp_phase5_delivery_role_permission; + + -- 海外智能排车草稿锁:拖入即锁定,移除或关闭草稿即释放。 CREATE DATABASE IF NOT EXISTS baiyue_lastleg DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 4483acc53..75c01a4e5 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -400,7 +400,7 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_order ( shipped_package_count INT NOT NULL DEFAULT 0 COMMENT '累计已出库箱数', partial_completion_reason VARCHAR(1000) NULL COMMENT '部分完成原因', created_by VARCHAR(128) NULL COMMENT '创建人ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', - PRIMARY KEY(id), UNIQUE KEY uk_ll_dispatch_order_no(customer_id,dispatch_no), + PRIMARY KEY(id), UNIQUE KEY uk_ll_dispatch_order_no(dispatch_no), KEY idx_ll_dispatch_order_list(customer_id,status,depart_at), KEY idx_ll_dispatch_order_vehicle(customer_id,vehicle_plate) ) ENGINE=InnoDB COMMENT='尾程派车单'; @@ -1097,9 +1097,11 @@ CREATE TABLE IF NOT EXISTS ll_order_operation_log ( CREATE TABLE IF NOT EXISTS ll_outbound_pda_task ( id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', outbound_order_id BIGINT NOT NULL COMMENT '出库单ID', task_no VARCHAR(64) NOT NULL COMMENT 'PDA任务号', status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '任务状态', + completion_idempotency_key VARCHAR(255) NULL COMMENT '完成命令幂等键', pushed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '推送时间', completed_at DATETIME(3) NULL COMMENT '完成时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', - PRIMARY KEY(id), UNIQUE KEY uk_ll_outbound_pda_task_order(customer_id,outbound_order_id), UNIQUE KEY uk_ll_outbound_pda_task_no(customer_id,task_no) + PRIMARY KEY(id), UNIQUE KEY uk_ll_outbound_pda_task_order(customer_id,outbound_order_id), UNIQUE KEY uk_ll_outbound_pda_task_no(task_no), + UNIQUE KEY uk_ll_outbound_pda_complete_key(customer_id,completion_idempotency_key) ) ENGINE=InnoDB COMMENT='一件代发出库PDA拣货任务'; CREATE TABLE IF NOT EXISTS ll_outbound_pda_pick ( @@ -1109,6 +1111,180 @@ CREATE TABLE IF NOT EXISTS ll_outbound_pda_pick ( PRIMARY KEY(id), UNIQUE KEY uk_ll_outbound_pda_pick_key(customer_id,idempotency_key) ) ENGINE=InnoDB COMMENT='一件代发出库PDA托盘拣货记录'; +-- 五期排车、出库与海外PDA正式业务号/幂等约束。只补结构并 fail-fast 校验开发数据,不回填或修复旧行。 +SET @ddl = (SELECT IF(EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_task' AND column_name='warehouse_id' +), 'SELECT 1', + 'ALTER TABLE ll_dispatch_pda_task ADD COLUMN warehouse_id VARCHAR(128) NULL COMMENT ''Token绑定的海外仓库ID'' AFTER operator_id')); +PREPARE phase5_pda_scope_ddl FROM @ddl; EXECUTE phase5_pda_scope_ddl; DEALLOCATE PREPARE phase5_pda_scope_ddl; + +SET @ddl = (SELECT IF(EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='ll_outbound_pda_task' AND column_name='completion_idempotency_key' +), 'SELECT 1', + 'ALTER TABLE ll_outbound_pda_task ADD COLUMN completion_idempotency_key VARCHAR(255) NULL COMMENT ''完成命令幂等键'' AFTER status')); +PREPARE phase5_pda_scope_ddl FROM @ddl; EXECUTE phase5_pda_scope_ddl; DEALLOCATE PREPARE phase5_pda_scope_ddl; + +DROP PROCEDURE IF EXISTS assert_phase5_lastleg_identifiers_migratable; +DELIMITER $$ +CREATE PROCEDURE assert_phase5_lastleg_identifiers_migratable() +BEGIN + IF (SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema='baiyue_lastleg' AND table_name IN ( + 'll_dispatch_order','ll_outbound_order','ll_dispatch_pda_task','ll_outbound_pda_task')) <> 4 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期排车、出库或PDA任务表未完整部署'; + END IF; + IF EXISTS (SELECT dispatch_no FROM ll_dispatch_order GROUP BY dispatch_no HAVING COUNT(*)>1) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='ll_dispatch_order存在跨客户重复dispatch_no,无法收敛全局唯一'; + END IF; + IF EXISTS (SELECT outbound_no FROM ll_outbound_order GROUP BY outbound_no HAVING COUNT(*)>1) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='ll_outbound_order存在跨客户重复outbound_no,无法收敛全局唯一'; + END IF; + IF EXISTS (SELECT task_no FROM ll_dispatch_pda_task GROUP BY task_no HAVING COUNT(*)>1) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='ll_dispatch_pda_task存在跨客户重复task_no,无法收敛全局唯一'; + END IF; + IF EXISTS (SELECT task_no FROM ll_outbound_pda_task GROUP BY task_no HAVING COUNT(*)>1) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='ll_outbound_pda_task存在跨客户重复task_no,无法收敛全局唯一'; + END IF; + IF EXISTS (SELECT customer_id,completion_idempotency_key FROM ll_outbound_pda_task + WHERE completion_idempotency_key IS NOT NULL + GROUP BY customer_id,completion_idempotency_key HAVING COUNT(*)>1) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='ll_outbound_pda_task存在重复完成幂等键,无法创建客户范围唯一约束'; + END IF; + IF EXISTS (SELECT 1 FROM ll_dispatch_pda_task + WHERE warehouse_id IS NULL OR TRIM(warehouse_id)='' OR TRIM(warehouse_id)='0') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='ll_dispatch_pda_task存在空或0仓库范围;请重新造数后重跑,脚本不回填旧行'; + END IF; +END$$ +DELIMITER ; +CALL assert_phase5_lastleg_identifiers_migratable(); +DROP PROCEDURE assert_phase5_lastleg_identifiers_migratable; + +SET @ddl = (SELECT IF(EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_task' AND column_name='warehouse_id' + AND is_nullable='NO' AND column_type='varchar(128)' AND column_comment='Token绑定的海外仓库ID' +), 'SELECT 1', + 'ALTER TABLE ll_dispatch_pda_task MODIFY COLUMN warehouse_id VARCHAR(128) NOT NULL COMMENT ''Token绑定的海外仓库ID''')); +PREPARE phase5_pda_scope_ddl FROM @ddl; EXECUTE phase5_pda_scope_ddl; DEALLOCATE PREPARE phase5_pda_scope_ddl; + +SET @index_ready = (SELECT COUNT(*)=1 AND MIN(non_unique)=0 + AND MAX(seq_in_index=1 AND column_name='dispatch_no')=1 + FROM information_schema.statistics WHERE table_schema=DATABASE() + AND table_name='ll_dispatch_order' AND index_name='uk_ll_dispatch_order_no'); +SET @index_exists = (SELECT COUNT(*)>0 FROM information_schema.statistics WHERE table_schema=DATABASE() + AND table_name='ll_dispatch_order' AND index_name='uk_ll_dispatch_order_no'); +SET @ddl = IF(@index_ready, 'SELECT 1', IF(@index_exists, + 'ALTER TABLE ll_dispatch_order DROP INDEX uk_ll_dispatch_order_no, ADD UNIQUE KEY uk_ll_dispatch_order_no(dispatch_no)', + 'ALTER TABLE ll_dispatch_order ADD UNIQUE KEY uk_ll_dispatch_order_no(dispatch_no)')); +PREPARE phase5_identifier_ddl FROM @ddl; EXECUTE phase5_identifier_ddl; DEALLOCATE PREPARE phase5_identifier_ddl; + +SET @index_ready = (SELECT COUNT(*)=1 AND MIN(non_unique)=0 + AND MAX(seq_in_index=1 AND column_name='outbound_no')=1 + FROM information_schema.statistics WHERE table_schema=DATABASE() + AND table_name='ll_outbound_order' AND index_name='uk_ll_outbound_no'); +SET @index_exists = (SELECT COUNT(*)>0 FROM information_schema.statistics WHERE table_schema=DATABASE() + AND table_name='ll_outbound_order' AND index_name='uk_ll_outbound_no'); +SET @ddl = IF(@index_ready, 'SELECT 1', IF(@index_exists, + 'ALTER TABLE ll_outbound_order DROP INDEX uk_ll_outbound_no, ADD UNIQUE KEY uk_ll_outbound_no(outbound_no)', + 'ALTER TABLE ll_outbound_order ADD UNIQUE KEY uk_ll_outbound_no(outbound_no)')); +PREPARE phase5_identifier_ddl FROM @ddl; EXECUTE phase5_identifier_ddl; DEALLOCATE PREPARE phase5_identifier_ddl; + +SET @index_ready = (SELECT COUNT(*)=1 AND MIN(non_unique)=0 + AND MAX(seq_in_index=1 AND column_name='task_no')=1 + FROM information_schema.statistics WHERE table_schema=DATABASE() + AND table_name='ll_dispatch_pda_task' AND index_name='uk_ll_dispatch_pda_task_no'); +SET @index_exists = (SELECT COUNT(*)>0 FROM information_schema.statistics WHERE table_schema=DATABASE() + AND table_name='ll_dispatch_pda_task' AND index_name='uk_ll_dispatch_pda_task_no'); +SET @ddl = IF(@index_ready, 'SELECT 1', IF(@index_exists, + 'ALTER TABLE ll_dispatch_pda_task DROP INDEX uk_ll_dispatch_pda_task_no, ADD UNIQUE KEY uk_ll_dispatch_pda_task_no(task_no)', + 'ALTER TABLE ll_dispatch_pda_task ADD UNIQUE KEY uk_ll_dispatch_pda_task_no(task_no)')); +PREPARE phase5_identifier_ddl FROM @ddl; EXECUTE phase5_identifier_ddl; DEALLOCATE PREPARE phase5_identifier_ddl; + +SET @index_ready = (SELECT COUNT(*)=1 AND MIN(non_unique)=0 + AND MAX(seq_in_index=1 AND column_name='task_no')=1 + FROM information_schema.statistics WHERE table_schema=DATABASE() + AND table_name='ll_outbound_pda_task' AND index_name='uk_ll_outbound_pda_task_no'); +SET @index_exists = (SELECT COUNT(*)>0 FROM information_schema.statistics WHERE table_schema=DATABASE() + AND table_name='ll_outbound_pda_task' AND index_name='uk_ll_outbound_pda_task_no'); +SET @ddl = IF(@index_ready, 'SELECT 1', IF(@index_exists, + 'ALTER TABLE ll_outbound_pda_task DROP INDEX uk_ll_outbound_pda_task_no, ADD UNIQUE KEY uk_ll_outbound_pda_task_no(task_no)', + 'ALTER TABLE ll_outbound_pda_task ADD UNIQUE KEY uk_ll_outbound_pda_task_no(task_no)')); +PREPARE phase5_identifier_ddl FROM @ddl; EXECUTE phase5_identifier_ddl; DEALLOCATE PREPARE phase5_identifier_ddl; + +SET @index_ready = (SELECT COUNT(*)=2 AND MIN(non_unique)=0 + AND SUM(seq_in_index=1 AND column_name='customer_id')=1 + AND SUM(seq_in_index=2 AND column_name='completion_idempotency_key')=1 + FROM information_schema.statistics WHERE table_schema=DATABASE() + AND table_name='ll_outbound_pda_task' AND index_name='uk_ll_outbound_pda_complete_key'); +SET @index_exists = (SELECT COUNT(*)>0 FROM information_schema.statistics WHERE table_schema=DATABASE() + AND table_name='ll_outbound_pda_task' AND index_name='uk_ll_outbound_pda_complete_key'); +SET @ddl = IF(@index_ready, 'SELECT 1', IF(@index_exists, + 'ALTER TABLE ll_outbound_pda_task DROP INDEX uk_ll_outbound_pda_complete_key, ADD UNIQUE KEY uk_ll_outbound_pda_complete_key(customer_id,completion_idempotency_key)', + 'ALTER TABLE ll_outbound_pda_task ADD UNIQUE KEY uk_ll_outbound_pda_complete_key(customer_id,completion_idempotency_key)')); +PREPARE phase5_identifier_ddl FROM @ddl; EXECUTE phase5_identifier_ddl; DEALLOCATE PREPARE phase5_identifier_ddl; + +DROP PROCEDURE IF EXISTS assert_phase5_lastleg_identifiers_ready; +DELIMITER $$ +CREATE PROCEDURE assert_phase5_lastleg_identifiers_ready() +BEGIN + IF (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_lastleg' + AND table_name='ll_dispatch_pda_task' AND column_name='warehouse_id' AND is_nullable='NO') <> 1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='ll_dispatch_pda_task.warehouse_id未收敛为非空'; + END IF; + IF EXISTS (SELECT 1 FROM ll_dispatch_pda_task + WHERE warehouse_id IS NULL OR TRIM(warehouse_id)='' OR TRIM(warehouse_id)='0') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='ll_dispatch_pda_task仍存在空或0仓库范围'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_lastleg' + AND table_name='ll_outbound_pda_task' AND column_name='completion_idempotency_key' + AND column_type='varchar(255)' AND is_nullable='YES') <> 1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='ll_outbound_pda_task完成幂等字段未部署'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema='baiyue_lastleg' + AND table_name='ll_dispatch_order' AND index_name='uk_ll_dispatch_order_no') <> 1 + OR (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema='baiyue_lastleg' + AND table_name='ll_dispatch_order' AND index_name='uk_ll_dispatch_order_no' + AND non_unique=0 AND seq_in_index=1 AND column_name='dispatch_no') <> 1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='dispatch_no全局唯一索引未部署'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema='baiyue_lastleg' + AND table_name='ll_outbound_order' AND index_name='uk_ll_outbound_no') <> 1 + OR (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema='baiyue_lastleg' + AND table_name='ll_outbound_order' AND index_name='uk_ll_outbound_no' + AND non_unique=0 AND seq_in_index=1 AND column_name='outbound_no') <> 1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='outbound_no全局唯一索引未部署'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema='baiyue_lastleg' + AND table_name='ll_dispatch_pda_task' AND index_name='uk_ll_dispatch_pda_task_no') <> 1 + OR (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema='baiyue_lastleg' + AND table_name='ll_dispatch_pda_task' AND index_name='uk_ll_dispatch_pda_task_no' + AND non_unique=0 AND seq_in_index=1 AND column_name='task_no') <> 1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='排车PDA task_no全局唯一索引未部署'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema='baiyue_lastleg' + AND table_name='ll_outbound_pda_task' AND index_name='uk_ll_outbound_pda_task_no') <> 1 + OR (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema='baiyue_lastleg' + AND table_name='ll_outbound_pda_task' AND index_name='uk_ll_outbound_pda_task_no' + AND non_unique=0 AND seq_in_index=1 AND column_name='task_no') <> 1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='出库PDA task_no全局唯一索引未部署'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema='baiyue_lastleg' + AND table_name='ll_outbound_pda_task' AND index_name='uk_ll_outbound_pda_complete_key') <> 2 + OR (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema='baiyue_lastleg' + AND table_name='ll_outbound_pda_task' AND index_name='uk_ll_outbound_pda_complete_key' + AND non_unique=0 AND seq_in_index=1 AND column_name='customer_id') <> 1 + OR (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema='baiyue_lastleg' + AND table_name='ll_outbound_pda_task' AND index_name='uk_ll_outbound_pda_complete_key' + AND non_unique=0 AND seq_in_index=2 AND column_name='completion_idempotency_key') <> 1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='出库PDA客户范围完成幂等唯一索引未部署'; + END IF; +END$$ +DELIMITER ; +CALL assert_phase5_lastleg_identifiers_ready(); +DROP PROCEDURE assert_phase5_lastleg_identifiers_ready; + USE baiyue_lastleg; SET @ddl = (SELECT IF(EXISTS ( SELECT 1 FROM information_schema.columns diff --git a/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasLastlegRouteContractTest.java b/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasLastlegRouteContractTest.java index 345210960..70757edf3 100644 --- a/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasLastlegRouteContractTest.java +++ b/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasLastlegRouteContractTest.java @@ -38,4 +38,20 @@ class OverseasLastlegRouteContractTest { .doesNotContain("event-bridge-service/v3/api-docs/pda"); } } + + @Test + void lastlegPdaRouteCoversFrozenDispatchAndOutboundTaskPaths() throws IOException { + try (InputStream input = getClass().getResourceAsStream("/application-overseas.yml")) { + assertThat(input).isNotNull(); + String yaml = new String(input.readAllBytes(), StandardCharsets.UTF_8); + assertThat(yaml).contains("/api/pda/v1/last-leg/**"); + for (String path : new String[] { + "/api/pda/v1/last-leg/dispatches/DSP-1", + "/api/pda/v1/last-leg/dispatches/DSP-1/depart", + "/api/pda/v1/last-leg/outbound-tasks/PDA-1/complete" + }) { + assertThat(path).startsWith("/api/pda/v1/last-leg/"); + } + } + } } diff --git a/iam/src/main/resources/db/phase5-delivery-permissions.sql b/iam/src/main/resources/db/phase5-delivery-permissions.sql new file mode 100644 index 000000000..3dd23f7b6 --- /dev/null +++ b/iam/src/main/resources/db/phase5-delivery-permissions.sql @@ -0,0 +1,195 @@ +USE baiyue_iam; +SET NAMES utf8mb4; + +-- 五期海外排车、出库与派送权限。列表权限同时覆盖列表和详情只读,写操作保持独立授权。 +DROP PROCEDURE IF EXISTS guard_phase5_delivery_permission_ids; +DELIMITER $$ +CREATE PROCEDURE guard_phase5_delivery_permission_ids() +BEGIN + IF EXISTS ( + SELECT 1 FROM iam_permission + WHERE (id = 5107016 AND permission_code <> 'platform:overseas-warehouse:dispatch:edit') + OR (id = 5107017 AND permission_code <> 'platform:overseas-warehouse:dispatch:cancel') + OR (id = 5107018 AND permission_code <> 'platform:overseas-warehouse:dispatch:transfer-delivery') + OR (id = 5107090 AND permission_code <> 'platform:overseas-warehouse:outbound:list') + OR (id = 5107091 AND permission_code <> 'platform:overseas-warehouse:outbound:create') + OR (id = 5107092 AND permission_code <> 'platform:overseas-warehouse:delivery:list') + OR (id = 5107093 AND permission_code <> 'platform:overseas-warehouse:outbound:cancel') + OR (id = 5306000 AND permission_code <> 'merchant:warehouse:delivery:list') + OR (id = 5306001 AND permission_code <> 'merchant:warehouse:delivery:create') + ) OR EXISTS ( + SELECT 1 FROM iam_permission + WHERE (permission_code = 'platform:overseas-warehouse:dispatch:edit' AND id <> 5107016) + OR (permission_code = 'platform:overseas-warehouse:dispatch:cancel' AND id <> 5107017) + OR (permission_code = 'platform:overseas-warehouse:dispatch:transfer-delivery' AND id <> 5107018) + OR (permission_code = 'platform:overseas-warehouse:outbound:list' AND id <> 5107090) + OR (permission_code = 'platform:overseas-warehouse:outbound:create' AND id <> 5107091) + OR (permission_code = 'platform:overseas-warehouse:delivery:list' AND id <> 5107092) + OR (permission_code = 'platform:overseas-warehouse:outbound:cancel' AND id <> 5107093) + OR (permission_code = 'merchant:warehouse:delivery:list' AND id <> 5306000) + OR (permission_code = 'merchant:warehouse:delivery:create' AND id <> 5306001) + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:权限ID或编码冲突'; + END IF; +END$$ +DELIMITER ; +CALL guard_phase5_delivery_permission_ids(); +DROP PROCEDURE guard_phase5_delivery_permission_ids; + +INSERT INTO iam_permission + (id, tenant_id, app_code, permission_type, parent_id, permission_code, + permission_name, route_path, http_method, sort_no, status, built_in, deleted) +VALUES + (5107016,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:dispatch:edit','编辑海外排车',NULL,'PUT',716,'ACTIVE',1,0), + (5107017,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:dispatch:cancel','取消海外排车',NULL,'POST',717,'ACTIVE',1,0), + (5107018,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:dispatch:transfer-delivery','排车转派送',NULL,'POST',718,'ACTIVE',1,0), + (5107090,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:outbound:list','海外出库单','/overseas-warehouse/outbounds','GET',765,'ACTIVE',1,0), + (5107091,0,'PLATFORM','BUTTON',5107090,'platform:overseas-warehouse:outbound:create','创建海外出库单',NULL,'POST',766,'ACTIVE',1,0), + (5107092,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:delivery:list','海外派送单','/overseas-warehouse/delivery-orders','GET',767,'ACTIVE',1,0), + (5107093,0,'PLATFORM','BUTTON',5107090,'platform:overseas-warehouse:outbound:cancel','取消海外出库单',NULL,'POST',768,'ACTIVE',1,0), + (5306000,0,'MERCHANT','MENU',0,'merchant:warehouse:delivery:list','海外仓派送单','/overseas-warehouse/delivery-orders','GET',60,'ACTIVE',1,0), + (5306001,0,'MERCHANT','BUTTON',5306000,'merchant:warehouse:delivery:create','创建海外仓派送单',NULL,'POST',10,'ACTIVE',1,0) +ON DUPLICATE KEY UPDATE + permission_type=VALUES(permission_type), parent_id=VALUES(parent_id), + permission_name=VALUES(permission_name), route_path=VALUES(route_path), + http_method=VALUES(http_method), sort_no=VALUES(sort_no), status='ACTIVE', + built_in=1, deleted=0; + +-- 平台管理员默认拥有五期全部后台动作;已有的平台自定义管理员保持同等五期操作能力。 +INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) +SELECT 0, role.id, permission.id, CURRENT_TIMESTAMP(3) + FROM iam_role role + JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' + WHERE role.tenant_id=0 AND role.app_code='PLATFORM' + AND UPPER(role.role_code) IN ('PLATFORM_ADMIN','PLATFORM_CUSTOM_ADMIN') + AND role.status='ACTIVE' AND role.deleted=0 + AND permission.permission_code IN ( + 'platform:overseas-warehouse:dispatch:edit', + 'platform:overseas-warehouse:dispatch:cancel', + 'platform:overseas-warehouse:dispatch:transfer-delivery', + 'platform:overseas-warehouse:outbound:list', + 'platform:overseas-warehouse:outbound:create', + 'platform:overseas-warehouse:outbound:cancel', + 'platform:overseas-warehouse:delivery:list') + AND permission.status='ACTIVE' AND permission.deleted=0; + +-- 仓库后台岗位按最小职责授权;PDA 岗位不得获得后台排车、出库或派送权限。 +DROP TEMPORARY TABLE IF EXISTS tmp_phase5_delivery_role_permission; +CREATE TEMPORARY TABLE tmp_phase5_delivery_role_permission ( + role_code VARCHAR(64) NOT NULL, + permission_code VARCHAR(160) NOT NULL, + PRIMARY KEY (role_code, permission_code) +) ENGINE=MEMORY; +INSERT INTO tmp_phase5_delivery_role_permission (role_code, permission_code) VALUES + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:dispatch:edit'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:dispatch:cancel'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:dispatch:transfer-delivery'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:outbound:list'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:outbound:create'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:outbound:cancel'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:delivery:list'), + ('WAREHOUSE_TALLY','platform:overseas-warehouse:outbound:list'), + ('WAREHOUSE_TALLY','platform:overseas-warehouse:delivery:list'); + +DELETE grant_row FROM iam_role_permission grant_row +JOIN iam_role role ON role.id=grant_row.role_id AND role.tenant_id=grant_row.tenant_id +JOIN iam_permission permission ON permission.id=grant_row.permission_id AND permission.tenant_id=grant_row.tenant_id +LEFT JOIN tmp_phase5_delivery_role_permission expected + ON expected.role_code=role.role_code AND expected.permission_code=permission.permission_code +WHERE role.tenant_id=0 AND role.app_code='PLATFORM' + AND role.role_code IN ('WAREHOUSE_OPERATOR','WAREHOUSE_TALLY','OVERSEAS_WAREHOUSE_PDA_OPERATOR','CN_FIRSTLEG_PDA_OPERATOR') + AND permission.permission_code IN ( + 'platform:overseas-warehouse:dispatch:edit', + 'platform:overseas-warehouse:dispatch:cancel', + 'platform:overseas-warehouse:dispatch:transfer-delivery', + 'platform:overseas-warehouse:outbound:list', + 'platform:overseas-warehouse:outbound:create', + 'platform:overseas-warehouse:outbound:cancel', + 'platform:overseas-warehouse:delivery:list') + AND expected.permission_code IS NULL; + +INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) +SELECT 0, role.id, permission.id, CURRENT_TIMESTAMP(3) + FROM tmp_phase5_delivery_role_permission expected + JOIN iam_role role ON role.tenant_id=0 AND role.app_code='PLATFORM' + AND role.role_code=expected.role_code AND role.status='ACTIVE' AND role.deleted=0 + JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' + AND permission.permission_code=expected.permission_code + AND permission.status='ACTIVE' AND permission.deleted=0; + +-- 客户默认管理员获得派送列表/详情只读及独立创建;其他客户角色不自动扩权。 +INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) +SELECT 0, role.id, permission.id, CURRENT_TIMESTAMP(3) + FROM iam_role role + JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='MERCHANT' + WHERE role.tenant_id=0 AND role.app_code='MERCHANT' AND role.role_code='MERCHANT_OWNER' + AND role.status='ACTIVE' AND role.deleted=0 + AND permission.permission_code IN ( + 'merchant:warehouse:delivery:list', + 'merchant:warehouse:delivery:create') + AND permission.status='ACTIVE' AND permission.deleted=0; + +DROP PROCEDURE IF EXISTS assert_phase5_delivery_permissions; +DELIMITER $$ +CREATE PROCEDURE assert_phase5_delivery_permissions() +BEGIN + IF (SELECT COUNT(*) FROM iam_permission + WHERE tenant_id=0 AND status='ACTIVE' AND deleted=0 + AND permission_code IN ( + 'platform:overseas-warehouse:dispatch:edit', + 'platform:overseas-warehouse:dispatch:cancel', + 'platform:overseas-warehouse:dispatch:transfer-delivery', + 'platform:overseas-warehouse:outbound:list', + 'platform:overseas-warehouse:outbound:create', + 'platform:overseas-warehouse:outbound:cancel', + 'platform:overseas-warehouse:delivery:list', + 'merchant:warehouse:delivery:list', + 'merchant:warehouse:delivery:create')) <> 9 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:权限目录不完整'; + END IF; + IF (SELECT COUNT(*) FROM iam_role_permission grant_row + JOIN iam_role role ON role.id=grant_row.role_id AND role.tenant_id=grant_row.tenant_id + JOIN iam_permission permission ON permission.id=grant_row.permission_id + WHERE role.role_code='PLATFORM_ADMIN' + AND permission.permission_code IN ( + 'platform:overseas-warehouse:dispatch:edit', + 'platform:overseas-warehouse:dispatch:cancel', + 'platform:overseas-warehouse:dispatch:transfer-delivery', + 'platform:overseas-warehouse:outbound:list', + 'platform:overseas-warehouse:outbound:create', + 'platform:overseas-warehouse:outbound:cancel', + 'platform:overseas-warehouse:delivery:list')) <> 7 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:平台管理员授权不完整'; + END IF; + IF (SELECT COUNT(*) FROM tmp_phase5_delivery_role_permission WHERE role_code='WAREHOUSE_OPERATOR') <> 7 + OR (SELECT COUNT(*) FROM tmp_phase5_delivery_role_permission WHERE role_code='WAREHOUSE_TALLY') <> 2 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:仓库岗位最小权限矩阵异常'; + END IF; + IF EXISTS (SELECT 1 FROM iam_role_permission grant_row + JOIN iam_role role ON role.id=grant_row.role_id AND role.tenant_id=grant_row.tenant_id + JOIN iam_permission permission ON permission.id=grant_row.permission_id + WHERE role.role_code IN ('OVERSEAS_WAREHOUSE_PDA_OPERATOR','CN_FIRSTLEG_PDA_OPERATOR') + AND permission.permission_code IN ( + 'platform:overseas-warehouse:dispatch:edit', + 'platform:overseas-warehouse:dispatch:cancel', + 'platform:overseas-warehouse:dispatch:transfer-delivery', + 'platform:overseas-warehouse:outbound:list', + 'platform:overseas-warehouse:outbound:create', + 'platform:overseas-warehouse:outbound:cancel', + 'platform:overseas-warehouse:delivery:list')) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:PDA岗位错误获得后台权限'; + END IF; + IF (SELECT COUNT(*) FROM iam_role_permission grant_row + JOIN iam_role role ON role.id=grant_row.role_id AND role.tenant_id=grant_row.tenant_id + JOIN iam_permission permission ON permission.id=grant_row.permission_id + WHERE role.role_code='MERCHANT_OWNER' AND role.app_code='MERCHANT' + AND permission.permission_code IN ( + 'merchant:warehouse:delivery:list', + 'merchant:warehouse:delivery:create')) <> 2 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:客户默认管理员授权不完整'; + END IF; +END$$ +DELIMITER ; +CALL assert_phase5_delivery_permissions(); +DROP PROCEDURE assert_phase5_delivery_permissions; +DROP TEMPORARY TABLE tmp_phase5_delivery_role_permission; diff --git a/iam/src/main/resources/openapi/iam-v1.yaml b/iam/src/main/resources/openapi/iam-v1.yaml index fa52a8d25..ebedb134f 100644 --- a/iam/src/main/resources/openapi/iam-v1.yaml +++ b/iam/src/main/resources/openapi/iam-v1.yaml @@ -74,7 +74,7 @@ paths: get: tags: [IAM Resources / IAM 资源] summary: 查询当前用户有效权限 - description: 返回当前有效平台成员权限编码;只要求有效 PLATFORM JWT,不依赖 `platform:role:list`。 + description: 返回当前有效平台成员权限编码;只要求有效 PLATFORM JWT,不依赖 `platform:role:list`。五期海外排车、出库与派送分别返回 `platform:overseas-warehouse:dispatch:edit`、`platform:overseas-warehouse:dispatch:cancel`、`platform:overseas-warehouse:dispatch:transfer-delivery`、`platform:overseas-warehouse:outbound:list`、`platform:overseas-warehouse:outbound:create`、`platform:overseas-warehouse:outbound:cancel`、`platform:overseas-warehouse:delivery:list`;列表权限包含详情只读,写操作独立授权。 responses: '200': description: 当前平台成员权限查询成功 @@ -276,7 +276,7 @@ paths: get: tags: [IAM Resources / IAM 资源] summary: 查询当前用户有效权限 - description: 返回当前有效商户成员权限编码。 + description: 返回当前有效客户成员权限编码。五期客户派送返回 `merchant:warehouse:delivery:list` 和 `merchant:warehouse:delivery:create`;列表权限包含详情只读,创建权限独立授权。 responses: '200': description: 当前商户成员权限查询成功 diff --git a/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java index c8d8a0959..5ae67d606 100644 --- a/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java @@ -20,6 +20,11 @@ class IamOpenApiContractTest { assertThat(document).contains("/api/platform/v1/iam/authorization/permissions:") .contains("/api/merchant/v1/iam/authorization/permissions:") + .contains("platform:overseas-warehouse:dispatch:transfer-delivery") + .contains("platform:overseas-warehouse:outbound:cancel") + .contains("merchant:warehouse:delivery:list") + .contains("merchant:warehouse:delivery:create") + .contains("列表权限包含详情只读") .contains("PermissionCodesApiResponse:") .contains("TenantPageApiResponse:") .contains("TenantApiResponse:") diff --git a/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java index a691937d0..073da84a4 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java @@ -96,6 +96,10 @@ class OverseasPhase4PermissionSqlContractTest { void servicePermissionConstantsExactlyMatchTheIamCatalog() throws Exception { Set moduleCatalog = codes(new ClassPathResource("db/lastleg-permissions.sql") .getContentAsString(StandardCharsets.UTF_8)); + codes(new ClassPathResource("db/phase5-delivery-permissions.sql") + .getContentAsString(StandardCharsets.UTF_8)).stream() + .filter(code -> code.startsWith("platform:")) + .forEach(moduleCatalog::add); Set serviceCatalog = new LinkedHashSet<>(); serviceCatalog.addAll(codes(repositoryFile( "lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java"))); diff --git a/iam/src/test/java/com/baiyue/iam/repository/Phase5DeliveryPermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/Phase5DeliveryPermissionSqlContractTest.java new file mode 100644 index 000000000..00d195e31 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/repository/Phase5DeliveryPermissionSqlContractTest.java @@ -0,0 +1,123 @@ +package com.baiyue.iam.repository; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.assertThat; + +class Phase5DeliveryPermissionSqlContractTest { + private static final Pattern CODE = Pattern.compile("(?:platform:overseas-warehouse|merchant:warehouse):[a-z0-9:-]+"); + private static final Set REQUIRED = Set.of( + "platform:overseas-warehouse:dispatch:edit", + "platform:overseas-warehouse:dispatch:cancel", + "platform:overseas-warehouse:dispatch:transfer-delivery", + "platform:overseas-warehouse:outbound:list", + "platform:overseas-warehouse:outbound:create", + "platform:overseas-warehouse:outbound:cancel", + "platform:overseas-warehouse:delivery:list", + "merchant:warehouse:delivery:list", + "merchant:warehouse:delivery:create"); + + @Test + void definesOnlyTheFrozenPhase5DeliveryCatalogWithoutAliases() throws Exception { + String sql = sql(); + + assertThat(codes(sql)).containsExactlyInAnyOrderElementsOf(REQUIRED); + assertThat(codes(sql)).noneMatch(code -> code.endsWith(":view")); + assertThat(sql).contains( + "guard_phase5_delivery_permission_ids", + "权限ID或编码冲突", + "permission_type=VALUES(permission_type)", + "列表权限同时覆盖列表和详情只读"); + } + + @Test + void grantsAdministratorsAndWarehouseRolesByTheFrozenMinimumMatrix() throws Exception { + String sql = sql(); + + assertThat(sql).contains( + "UPPER(role.role_code) IN ('PLATFORM_ADMIN','PLATFORM_CUSTOM_ADMIN')", + "tmp_phase5_delivery_role_permission", + "('WAREHOUSE_OPERATOR','platform:overseas-warehouse:dispatch:edit')", + "('WAREHOUSE_OPERATOR','platform:overseas-warehouse:outbound:create')", + "('WAREHOUSE_TALLY','platform:overseas-warehouse:outbound:list')", + "('WAREHOUSE_TALLY','platform:overseas-warehouse:delivery:list')", + "role.role_code IN ('OVERSEAS_WAREHOUSE_PDA_OPERATOR','CN_FIRSTLEG_PDA_OPERATOR')", + "PDA岗位错误获得后台权限"); + assertThat(sql).doesNotContain( + "('WAREHOUSE_TALLY','platform:overseas-warehouse:dispatch:edit')", + "('WAREHOUSE_TALLY','platform:overseas-warehouse:outbound:create')", + "('OVERSEAS_WAREHOUSE_PDA_OPERATOR','platform:overseas-warehouse:", + "('CN_FIRSTLEG_PDA_OPERATOR','platform:overseas-warehouse:"); + } + + @Test + void grantsOnlyTheDefaultCustomerAdministratorAutomatically() throws Exception { + String sql = sql(); + int marker = sql.indexOf("-- 客户默认管理员获得派送列表/详情只读及独立创建"); + int assertion = sql.indexOf("DROP PROCEDURE IF EXISTS assert_phase5_delivery_permissions", marker); + assertThat(marker).isGreaterThanOrEqualTo(0); + assertThat(assertion).isGreaterThan(marker); + + String grant = sql.substring(marker, assertion); + assertThat(grant).contains( + "role.role_code='MERCHANT_OWNER'", + "merchant:warehouse:delivery:list", + "merchant:warehouse:delivery:create"); + assertThat(grant).doesNotContain("MERCHANT_OPERATOR", "ORDER_VIEWER", "role.role_code IN"); + } + + @Test + void domesticUnifiedEntryInlinesTheCompleteModuleSqlAfterPhase4Permissions() throws Exception { + String moduleSql = sql().trim(); + String unifiedSql = repositoryFile("deploy/sql/头程数据库统一增量-MySQL8.sql"); + int phase4 = unifiedSql.indexOf("-- 四期海外权限统一目录"); + int phase5 = unifiedSql.indexOf("-- 五期海外排车、出库与派送权限(来源:iam/db/phase5-delivery-permissions.sql)"); + int bodyStart = unifiedSql.indexOf("USE baiyue_iam;", phase5); + int nextSection = unifiedSql.indexOf("-- 海外智能排车草稿锁", bodyStart); + + assertThat(phase4).isGreaterThanOrEqualTo(0); + assertThat(phase5).isGreaterThan(phase4); + assertThat(bodyStart).isGreaterThan(phase5); + assertThat(nextSection).isGreaterThan(bodyStart); + assertThat(unifiedSql.substring(bodyStart, nextSection).trim()).isEqualTo(moduleSql); + assertThat(codes(unifiedSql.substring(phase5, nextSection))) + .containsExactlyInAnyOrderElementsOf(REQUIRED); + } + + @Test + void deploymentGuideUsesOnlyTheDomesticUnifiedEntry() throws Exception { + String readme = repositoryFile("deploy/sql/README.md"); + + assertThat(readme).contains( + "五期海外排车、出库与派送权限", + "测试和部署只执行固定入口,不再单独执行模块脚本", + "merchant:warehouse:delivery:list", + "merchant:warehouse:delivery:create", + "PDA 岗位不得获得上述后台权限"); + } + + private static String sql() throws Exception { + return new ClassPathResource("db/phase5-delivery-permissions.sql") + .getContentAsString(StandardCharsets.UTF_8); + } + + private static Set codes(String source) { + Set values = new LinkedHashSet<>(); + CODE.matcher(source).results().map(result -> result.group()).forEach(values::add); + return values; + } + + private static String repositoryFile(String relativePath) throws Exception { + Path path = Path.of(relativePath); + if (!Files.exists(path)) path = Path.of("..").resolve(relativePath); + return Files.readString(path, StandardCharsets.UTF_8); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java index eeab42b78..48276b09c 100644 --- a/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java @@ -74,6 +74,22 @@ class AuthorizationSnapshotServiceTest { verify(mapper).findPermissions(42L, 0L, 9L, "PLATFORM"); } + @Test + void exposesPhase5DeliveryPermissionsFromCurrentRoleAssignments() { + when(mapper.countActiveIdentity(42L, 7L, 9L)).thenReturn(1); + when(mapper.findRoles(42L, 7L, 9L, "MERCHANT")).thenReturn(List.of("MERCHANT_OWNER")); + when(mapper.findPermissions(42L, 7L, 9L, "MERCHANT")).thenReturn(List.of( + "merchant:warehouse:delivery:list", + "merchant:warehouse:delivery:create")); + + var result = service.currentPermissions(new AuthenticatedUser(42L, 7L, 7L, 9L, + "owner", "MERCHANT", "MERCHANT", java.util.Set.of()), "MERCHANT"); + + assertThat(result).containsExactlyInAnyOrder( + "merchant:warehouse:delivery:create", + "merchant:warehouse:delivery:list"); + } + @Test void rejectsIncompleteCurrentIdentityAsBusinessError() { assertThatThrownBy(() -> service diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 1a8050e9b..a7530a8a1 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -50,10 +50,11 @@ | 测试 | `src/test/java/com/baiyue/lastleg/` | | 真实事件消费验收 | `src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java`;覆盖真实 V2 建单、独立入仓单快照及下游转运/换标可消费性 | -## 四期开发范围路由 +## 四期/五期开发范围路由 -- 尾程服务覆盖尾程订单、入仓计划/PDA 入仓、换标、产品与箱唛快照、库存、海外采购、本土卡车、本土散板、排车、出库、派送、客户拜访及异常退回等完整业务域。 -- 排车编辑/取消/出库、PDA 出库和 `delivery/` Controller 与其他尾程业务接口一同注册,不受功能开关控制。平台排车列表与详情仍由 `lastleg.dispatch-query-api.enabled=true` 独立启用;入库单智能排车候选、草稿锁定/解锁/释放和预排创建仍由 `lastleg.smart-dispatch-api.enabled=true` 独立启用。这些排车平台入口均不接收 `customerId`:排车列表跨客户分页,详情按全局排车单号定位后使用记录自身客户范围加载明细;智能排车候选未传计划 ID 时跨客户查询,传入时根据计划 ID 解析范围;托盘移入草稿时立即以操作人和随机草稿标识锁定,移除或关闭时释放,草稿锁超时失效;预排创建根据所选托盘 ID 在服务端解析并校验唯一客户范围,并将当前操作人的草稿锁原子转换为正式排车锁。 +- 四期范围止于尾程订单、入仓计划/PDA 入仓、换标、产品与箱唛快照、库存、海外采购、本土卡车、本土散板以及排车查询/智能预排;不把出库、派送、异常退回或回访倒灌为四期验收项。 +- 五期范围承接排车编辑/取消/发车、PDA 排车拣货与出库任务、一件代发出库、排车转派送、客户派送单、异常退回和客户回访。服务端正式入口为 `dispatch/controller/DispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`、`delivery/controller/DeliveryTransferController.java`、`delivery/controller/OutboundPdaController.java`、`delivery/controller/MerchantDeliveryController.java`、`exceptionorder/` 与 `customervisit/`;海外 PDA 前端工作台尚未接入时只视为后端能力,不得声称设备页面可用。 +- 排车执行与 `delivery/` Controller 和其他尾程业务接口一同注册,不受功能开关控制。平台排车列表与详情仍由 `lastleg.dispatch-query-api.enabled=true` 独立启用;入库单智能排车候选、草稿锁定/解锁/释放和预排创建仍由 `lastleg.smart-dispatch-api.enabled=true` 独立启用。这些排车平台入口均不接收 `customerId`:排车列表跨客户分页,详情按全局排车单号定位后使用记录自身客户范围加载明细;智能排车候选未传计划 ID 时跨客户查询,传入时根据计划 ID 解析范围;托盘移入草稿时立即以操作人和随机草稿标识锁定,移除或关闭时释放,草稿锁超时失效;预排创建根据所选托盘 ID 在服务端解析并校验唯一客户范围,并将当前操作人的草稿锁原子转换为正式排车锁。 - 仓库 ID、类型、状态、地址和能力唯一权威归国内 `warehouse-service`;海外库位、实物托盘和资源锁归 `warehouse-overseas-service/` 且只引用同一国内 `warehouseId`。Lastleg PDA 通过 Warehouse Overseas 内部资源入口校验该国内共享收货仓,不得要求海外数据库重复建仓;浏览器 API 统一经 `gateway-overseas/`。 ## 数据库表索引与注释约束 diff --git a/lastleg-service/docs/lastleg-internal-modularization-baseline.md b/lastleg-service/docs/lastleg-internal-modularization-baseline.md index f0627b41b..7e8119c64 100644 --- a/lastleg-service/docs/lastleg-internal-modularization-baseline.md +++ b/lastleg-service/docs/lastleg-internal-modularization-baseline.md @@ -66,6 +66,7 @@ com.baiyue.lastleg.transfer.service | 来源 | 目标 | 类边数 | |---|---|---:| +| dispatch-delivery | dropship | 5 | | dispatch-delivery | inbound-pda | 6 | | dispatch-delivery | inventory | 4 | | dispatch-delivery | shared-technical | 2 | @@ -88,12 +89,13 @@ com.baiyue.lastleg.transfer.service | relabel-transfer-discard | shared-technical | 3 | | relabel-transfer-discard | tailleg-handoff | 14 | | security-projection | shared-technical | 1 | +| tailleg-handoff | dispatch-delivery | 8 | | tailleg-handoff | dropship | 2 | | tailleg-handoff | inbound-pda | 6 | | tailleg-handoff | inventory | 2 | | tailleg-handoff | shared-technical | 2 | -当前 26 条切片边冻结在 `src/test/resources/architecture/lastleg-slice-dependencies.json`。合同允许删除历史边,不允许增加新边;海外主数据继续锁定为只允许出向 `shared-technical`,只允许采购/卡车/散板切片入向;安全投影只允许出向 `shared-technical` 且禁止任何业务切片入向依赖。 +当前 28 条切片边冻结在 `src/test/resources/architecture/lastleg-slice-dependencies.json`。合同允许删除历史边,不允许增加新边;海外主数据继续锁定为只允许出向 `shared-technical`,只允许采购/卡车/散板切片入向;安全投影只允许出向 `shared-technical` 且禁止任何业务切片入向依赖。 ## 5. Entity/DTO/Mapper 泄漏基线 @@ -208,3 +210,9 @@ node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set all - - MySQL 表固定使用 Lastleg 前缀 `ll_security_event_inbox`、`ll_security_projection`;没有 `overseas_security_*` 表或独立数据库归属。 - `loading`、`shipping`、`inboundplan` 及其他业务切片不得直接依赖 `security/controller|dto|service`;当前 Java 类型图零入向,Node 与 ArchUnit 双重锁定。 - 安全投影不进入主逻辑 SCC,唯一出向为内部 Controller 复用共享令牌守卫,不注册为 loading/shipping/inbound 的模型、Mapper 或应用服务。 + +## 12. 五期出库派送稳定边界 + +- `dispatch-delivery -> dropship` 是一件代发出库的稳定业务依赖:出库应用服务按客户范围读取一件代发托盘、箱唛和 SKU 库存,并在 PDA 出库事务中完成扣减。 +- `tailleg-handoff -> dispatch-delivery` 是异常退回与客户回访的稳定业务依赖:异常单和回访应用服务按派送业务号读取派送单及操作记录,不引入反向兼容入口或跨服务共享模型。 +- 两条边均属于同一 `lastleg-service` 部署单元内的五期正式链路;本次只冻结已存在的生产依赖,不扩大海外主数据或安全投影的允许方向。 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java index 4ce4ba9ec..838bf0905 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java @@ -24,43 +24,50 @@ public class DeliveryTransferController { @Resource private DeliveryTransferService service; @PostMapping("/dispatches/{dispatchNo}/transfer-delivery") + @PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_TRANSFER_DELIVERY + "')") @Operation(summary = "Create one pending outbound order and one pending delivery order") public ApiResponse transfer( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String dispatchNo, - @RequestParam(required = false) String customerId, @Valid @RequestBody DeliveryRequests.Transfer request) { - return ApiResponse.success(service.transfer(scope(user, customerId), dispatchNo, operator(user), request)); + requirePlatform(user); + return ApiResponse.success(service.platformTransfer(dispatchNo, operator(user), request)); } @PostMapping("/outbound-orders/one-click-dropship") + @PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_CREATE + "')") @Operation(summary = "Create one-click dropship outbound and push a PDA picking task") public ApiResponse createOneClick( - @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, @Valid @RequestBody DeliveryRequests.OneClickOutbound request) { - return ApiResponse.success(service.createOneClick(scope(user, customerId), operator(user), request)); + return ApiResponse.success(service.createOneClick(requiredCustomer(user, customerId), operator(user), request)); } @GetMapping("/outbound-orders") + @PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_LIST + "')") public ApiResponse> outboundPage( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @Valid DeliveryRequests.Query query) { + requirePlatform(user); return ApiResponse.success(service.outboundPage(listScope(user, customerId), query)); } @GetMapping("/outbound-orders/{outboundNo}") + @PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_LIST + "')") public ApiResponse outboundDetail( - @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String outboundNo, @Valid DeliveryRequests.PalletQuery query) { - return ApiResponse.success(service.outboundDetail(scope(user, customerId), outboundNo, query)); + requirePlatform(user); + return ApiResponse.success(service.outboundDetail(outboundNo, query)); } @PostMapping("/outbound-orders/{outboundNo}/cancel") - @Operation(summary = "Cancel outbound and linked delivery, restoring inventory") + @PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_CANCEL + "')") + @Operation(summary = "Cancel an unexecuted outbound order and its linked delivery") public ApiResponse cancelOutbound(@AuthenticationPrincipal AuthenticatedUser user, - @RequestParam(required = false) String customerId, @PathVariable String outboundNo, @Valid @RequestBody DeliveryRequests.Cancel request) { - service.cancelOutbound(scope(user, customerId), operator(user), outboundNo, request); + requirePlatform(user); + service.cancelOutbound(outboundNo, request); return ApiResponse.success(null); } @@ -77,25 +84,27 @@ public class DeliveryTransferController { public ApiResponse deliveryDetail( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @PathVariable String deliveryNo) { - return ApiResponse.success(service.deliveryDetail(scope(user, customerId), deliveryNo)); + return ApiResponse.success(service.deliveryDetail(requiredCustomer(user, customerId), deliveryNo)); } - private String scope(AuthenticatedUser user, String requestedCustomer) { + private String requiredCustomer(AuthenticatedUser user, String requestedCustomer) { + requirePlatform(user); + if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target customer"); + } + return requestedCustomer.trim(); + } + private void requirePlatform(AuthenticatedUser user) { if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); - if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) { - if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target tenant"); - } - return requestedCustomer.trim(); + if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Platform application required"); } - return String.valueOf(user.tenantId()); } private String operator(AuthenticatedUser user) { return user.userId() == null ? user.username() : String.valueOf(user.userId()); } private String listScope(AuthenticatedUser user, String requestedCustomer) { - if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); - if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) return requestedCustomer == null || requestedCustomer.isBlank() ? null : requestedCustomer.trim(); - return String.valueOf(user.tenantId()); + requirePlatform(user); + return requestedCustomer == null || requestedCustomer.isBlank() ? null : requestedCustomer.trim(); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryController.java index a2eea0102..5adc2ebe3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryController.java @@ -6,9 +6,11 @@ import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.lastleg.delivery.dto.DeliveryRequests; import com.baiyue.lastleg.delivery.dto.DeliveryResponses; import com.baiyue.lastleg.delivery.service.DeliveryTransferService; +import com.baiyue.lastleg.security.OverseasPermissions; import jakarta.annotation.Resource; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; @@ -19,19 +21,22 @@ public class MerchantDeliveryController { @Resource private DeliveryTransferService service; @GetMapping + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DELIVERY_LIST + "')") public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, @Valid DeliveryRequests.Query query) { return ApiResponse.success(service.deliveryPage(customer(user), query)); } @PostMapping + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DELIVERY_CREATE + "')") public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String dispatchNo, @Valid @RequestBody DeliveryRequests.Transfer request) { - return ApiResponse.success(service.transfer(customer(user), dispatchNo, operator(user), request)); + return ApiResponse.success(service.merchantTransfer(customer(user), dispatchNo, operator(user), request)); } @GetMapping("/{deliveryNo}") + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DELIVERY_LIST + "')") public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String deliveryNo) { return ApiResponse.success(service.deliveryDetail(customer(user), deliveryNo)); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/OutboundPdaController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/OutboundPdaController.java index 4c88c2ff4..ad1d097db 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/OutboundPdaController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/OutboundPdaController.java @@ -5,37 +5,46 @@ import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.lastleg.delivery.dto.DeliveryRequests; import com.baiyue.lastleg.delivery.dto.DeliveryResponses; import com.baiyue.lastleg.delivery.service.DeliveryTransferService; +import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; import jakarta.annotation.Resource; import jakarta.validation.Valid; -import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; -import org.springframework.web.server.ResponseStatusException; @RestController @RequestMapping("/api/pda/v1/last-leg/outbound-tasks") +@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')") public class OutboundPdaController { @Resource private DeliveryTransferService service; + @PostMapping("/{taskNo}/pick") public ApiResponse pick(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable String taskNo, @RequestParam(required=false) String customerId, + @PathVariable String taskNo, @Valid @RequestBody DeliveryRequests.PdaPick request) { - return ApiResponse.success(service.pdaPick(scope(user, customerId), operator(user), taskNo, request)); + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.pdaPick(String.valueOf(identity.overseasWarehouseId()), + identity.operatorKey(), taskNo, request)); } + @GetMapping public ApiResponse> list(@AuthenticationPrincipal AuthenticatedUser user, - @RequestParam(required=false) String customerId, @RequestParam(required=false) String status) { - return ApiResponse.success(service.pdaTasks(scope(user, customerId), status)); + @RequestParam(required=false) String status) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.pdaTasks(String.valueOf(identity.overseasWarehouseId()), status)); } + + @GetMapping("/{taskNo}") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String taskNo) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.pdaTaskDetail(String.valueOf(identity.overseasWarehouseId()), taskNo)); + } + @PostMapping("/{taskNo}/complete") public ApiResponse complete(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable String taskNo, @RequestParam(required=false) String customerId) { - return ApiResponse.success(service.completePda(scope(user, customerId), taskNo)); + @PathVariable String taskNo, @Valid @RequestBody DeliveryRequests.PdaComplete request) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.completePda(String.valueOf(identity.overseasWarehouseId()), taskNo, request)); } - private String scope(AuthenticatedUser user, String requested) { - if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); - if (requested != null && !requested.isBlank()) return requested.trim(); - return String.valueOf(user.tenantId()); - } - private String operator(AuthenticatedUser user) { return user.userId() == null ? user.username() : String.valueOf(user.userId()); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java index cc75ed53e..78b133d04 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java @@ -56,5 +56,6 @@ public final class DeliveryRequests { @NotBlank @Size(max=128) String sku) {} public record PdaPick(@NotNull Long outboundPalletItemId, @NotNull Long inventoryPositionId, @NotNull @Min(1) Long packages, @NotBlank @Size(max=255) String idempotencyKey) {} + public record PdaComplete(@NotBlank @Size(max=255) String idempotencyKey) {} public record Cancel(@NotBlank @Size(max=1000) String reason) {} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java index 116cbd2ad..ec83a3c31 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java @@ -42,4 +42,11 @@ public final class DeliveryResponses { public record OutboundDetail(OutboundRow outbound, DeliveryRow delivery) {} public record PdaTask(Long id, String taskNo, String status, String outboundNo, LocalDateTime pushedAt, LocalDateTime completedAt) {} + public record PdaPickPosition(Long inventoryPositionId, String palletNo, String cartonMark, + String skuCode, String inboundCode, long availablePackages, + long pickedPackages) {} + public record PdaTaskItem(Long outboundPalletItemId, String palletNo, String skuCode, String inboundCode, + int packageCount, int pickedPackageCount, String status, + List positions) {} + public record PdaTaskDetail(PdaTask task, List items) {} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundPdaTask.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundPdaTask.java index bb1640a6e..a715e0cd6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundPdaTask.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundPdaTask.java @@ -14,6 +14,7 @@ public class OutboundPdaTask { private Long outboundOrderId; private String taskNo; private String status; + private String completionIdempotencyKey; private LocalDateTime pushedAt; private LocalDateTime completedAt; private LocalDateTime createdAt; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundOrderMapper.java index 2059ee200..9bd7af835 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundOrderMapper.java @@ -10,10 +10,14 @@ import org.apache.ibatis.annotations.Select; public interface OutboundOrderMapper extends BaseMapper { @Select("SELECT * FROM ll_outbound_order WHERE customer_id=#{customerId} AND outbound_no=#{outboundNo} LIMIT 1") OutboundOrder findByNo(@Param("customerId") String customerId, @Param("outboundNo") String outboundNo); + @Select("SELECT * FROM ll_outbound_order WHERE outbound_no=#{outboundNo} LIMIT 1") + OutboundOrder findGlobalByNo(@Param("outboundNo") String outboundNo); @Select("SELECT * FROM ll_outbound_order WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1") OutboundOrder findByIdempotency(@Param("customerId") String customerId, @Param("key") String key); @Select("SELECT * FROM ll_outbound_order WHERE customer_id=#{customerId} AND dispatch_order_id=#{dispatchOrderId} LIMIT 1 FOR UPDATE") OutboundOrder lockByDispatch(@Param("customerId") String customerId, @Param("dispatchOrderId") Long dispatchOrderId); @Select("SELECT * FROM ll_outbound_order WHERE customer_id=#{customerId} AND outbound_no=#{outboundNo} LIMIT 1 FOR UPDATE") OutboundOrder lockByNo(@Param("customerId") String customerId, @Param("outboundNo") String outboundNo); + @Select("SELECT * FROM ll_outbound_order WHERE outbound_no=#{outboundNo} LIMIT 1 FOR UPDATE") + OutboundOrder lockGlobalByNo(@Param("outboundNo") String outboundNo); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundPdaTaskMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundPdaTaskMapper.java index 88307fa3a..624436ef4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundPdaTaskMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundPdaTaskMapper.java @@ -13,4 +13,22 @@ public interface OutboundPdaTaskMapper extends BaseMapper { OutboundPdaTask lockByNo(@Param("customerId") String customerId, @Param("taskNo") String taskNo); @Select("SELECT * FROM ll_outbound_pda_task WHERE customer_id=#{customerId} AND (#{status} IS NULL OR status=#{status}) ORDER BY pushed_at DESC LIMIT 200") List list(@Param("customerId") String customerId, @Param("status") String status); + + @Select("SELECT t.* FROM ll_outbound_pda_task t JOIN ll_outbound_order o ON o.id=t.outbound_order_id " + + "AND o.customer_id=t.customer_id WHERE t.task_no=#{taskNo} AND o.overseas_warehouse_id=#{warehouseId} " + + "LIMIT 1 FOR UPDATE") + OutboundPdaTask lockByNoAndWarehouse(@Param("taskNo") String taskNo, @Param("warehouseId") String warehouseId); + + @Select("SELECT t.* FROM ll_outbound_pda_task t JOIN ll_outbound_order o ON o.id=t.outbound_order_id " + + "AND o.customer_id=t.customer_id WHERE t.task_no=#{taskNo} AND o.overseas_warehouse_id=#{warehouseId} LIMIT 1") + OutboundPdaTask findByNoAndWarehouse(@Param("taskNo") String taskNo, @Param("warehouseId") String warehouseId); + + @Select("SELECT t.* FROM ll_outbound_pda_task t JOIN ll_outbound_order o ON o.id=t.outbound_order_id " + + "AND o.customer_id=t.customer_id WHERE o.overseas_warehouse_id=#{warehouseId} " + + "AND (#{status} IS NULL OR t.status=#{status}) ORDER BY t.pushed_at DESC LIMIT 200") + List listByWarehouse(@Param("warehouseId") String warehouseId, @Param("status") String status); + + @Select("SELECT * FROM ll_outbound_pda_task WHERE customer_id=#{customerId} " + + "AND completion_idempotency_key=#{key} LIMIT 1") + OutboundPdaTask findByCompletionKey(@Param("customerId") String customerId, @Param("key") String key); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java index 35e419205..84fa93aa8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java @@ -34,6 +34,8 @@ import com.baiyue.lastleg.delivery.mapper.OutboundPdaPickMapper; import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.inventory.entity.InventoryBalance; +import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper; import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile; import com.baiyue.lastleg.dropship.entity.DropshipCarton; @@ -69,6 +71,7 @@ public class DeliveryTransferService { @Resource private OutboundPdaTaskMapper pdaTasks; @Resource private OutboundPdaPickMapper pdaPicks; @Resource private InventoryPositionMapper inventoryPositions; + @Resource private InventoryBalanceMapper inventoryBalances; @Resource private InventoryCommandService inventoryCommands; @Resource private DropshipOrderProfileMapper dropshipProfiles; @Resource private DropshipCartonMapper dropshipCartons; @@ -81,11 +84,26 @@ public class DeliveryTransferService { @Resource private LastlegOrderNoGenerator orderNoGenerator; @Transactional - public DeliveryResponses.TransferResult transfer(String customerId, String dispatchNo, String operatorId, - DeliveryRequests.Transfer request) { + public DeliveryResponses.TransferResult platformTransfer(String dispatchNo, String operatorId, + DeliveryRequests.Transfer request) { + DispatchOrder dispatch = dispatchOrders.lockGlobalByDispatchNo(trim(dispatchNo)); + if (dispatch == null) throw notFound("Dispatch order not found"); + return transferResolved(dispatch, operatorId, request); + } + + @Transactional + public DeliveryResponses.TransferResult merchantTransfer(String customerId, String dispatchNo, String operatorId, + DeliveryRequests.Transfer request) { DispatchOrder found = dispatchOrders.findByDispatchNo(customerId, dispatchNo); if (found == null) throw notFound("Dispatch order not found"); DispatchOrder dispatch = dispatchOrders.lockById(customerId, found.getId()); + if (dispatch == null) throw notFound("Dispatch order not found"); + return transferResolved(dispatch, operatorId, request); + } + + private DeliveryResponses.TransferResult transferResolved(DispatchOrder dispatch, String operatorId, + DeliveryRequests.Transfer request) { + String customerId = dispatch.getCustomerId(); OutboundOrder replay = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim()); if (replay != null) { if (!dispatch.getId().equals(replay.getDispatchOrderId())) throw conflict("Idempotency key belongs to another dispatch"); @@ -228,15 +246,42 @@ public class DeliveryTransferService { } @Transactional - public DeliveryResponses.PdaTask pdaPick(String customerId, String operatorId, String taskNo, DeliveryRequests.PdaPick request) { - OutboundPdaTask task = pdaTasks.lockByNo(customerId, taskNo); if (task == null) throw notFound("PDA task not found"); - OutboundPdaPick replay = pdaPicks.findByKey(customerId, request.idempotencyKey().trim()); if (replay != null) return taskResult(outboundOrders.selectById(task.getOutboundOrderId()), task); - OutboundOrder outbound = outboundOrders.lockByNo(customerId, outboundNo(task.getOutboundOrderId())); + public DeliveryResponses.PdaTask pdaPick(String warehouseId, String operatorId, String taskNo, + DeliveryRequests.PdaPick request) { + OutboundPdaTask task = pdaTasks.lockByNoAndWarehouse(required(taskNo, "PDA task number is required"), + required(warehouseId, "PDA warehouse binding is required")); + if (task == null) throw notFound("PDA task not found in the bound warehouse"); + String customerId = task.getCustomerId(); + OutboundPdaPick replay = pdaPicks.findByKey(customerId, request.idempotencyKey().trim()); + if (replay != null) { + if (!task.getId().equals(replay.getTaskId()) + || !request.outboundPalletItemId().equals(replay.getOutboundPalletItemId()) + || !request.inventoryPositionId().equals(replay.getInventoryPositionId()) + || !request.packages().equals(replay.getPackages())) { + throw conflict("Idempotency key was reused with a different outbound pick request"); + } + return taskResult(requireOutbound(task), task); + } + OutboundOrder existingOutbound = requireOutbound(task); + OutboundOrder outbound = outboundOrders.lockByNo(customerId, existingOutbound.getOutboundNo()); + if (outbound == null || !required(warehouseId, "PDA warehouse binding is required") + .equals(outbound.getOverseasWarehouseId())) throw notFound("Outbound order not found in the bound warehouse"); + if (!Set.of("PENDING", "IN_PROGRESS").contains(task.getStatus())) { + throw conflict("PDA task is not available for picking"); + } OutboundPalletItem item = outboundPallets.selectById(request.outboundPalletItemId()); - if (item == null || !task.getOutboundOrderId().equals(item.getOutboundOrderId())) throw notFound("Outbound pallet item not found"); + if (item == null || !customerId.equals(item.getCustomerId()) + || !task.getOutboundOrderId().equals(item.getOutboundOrderId())) throw notFound("Outbound pallet item not found"); if (request.packages() > item.getPackageCount() - value(item.getPickedPackageCount())) throw conflict("Picked quantity exceeds outbound pallet quantity"); InventoryPosition selectedPosition = inventoryPositions.selectById(request.inventoryPositionId()); if (selectedPosition == null || !customerId.equals(selectedPosition.getCustomerId()) || !Objects.equals(selectedPosition.getPalletId(), item.getWarehousePalletId())) throw conflict("Scanned inventory position does not belong to the outbound pallet"); + if (!warehouseId.equals(selectedPosition.getOverseasWarehouseId())) { + throw conflict("Scanned inventory position does not belong to the bound warehouse"); + } + InventoryBalance selectedBalance = inventoryBalances.selectById(selectedPosition.getBalanceId()); + if (!matchesOutboundItem(customerId, warehouseId, item, selectedBalance)) { + throw conflict("Scanned inventory position does not belong to the outbound item"); + } DropshipOrderProfile profile = dropshipProfiles.findPosition(customerId, request.inventoryPositionId()); if (profile == null) throw conflict("Scanned pallet is not one-click dropship inventory"); List cartons = dropshipCartons.lockAvailable(customerId, profile.getId(), item.getSkuCode(), request.packages()); @@ -258,37 +303,60 @@ public class DeliveryTransferService { } @Transactional - public DeliveryResponses.PdaTask completePda(String customerId, String taskNo) { - OutboundPdaTask task = pdaTasks.lockByNo(customerId, taskNo); if (task == null) throw notFound("PDA task not found"); - OutboundOrder outbound = outboundOrders.selectById(task.getOutboundOrderId()); + public DeliveryResponses.PdaTask completePda(String warehouseId, String taskNo, + DeliveryRequests.PdaComplete request) { + OutboundPdaTask task = pdaTasks.lockByNoAndWarehouse(required(taskNo, "PDA task number is required"), + required(warehouseId, "PDA warehouse binding is required")); + if (task == null) throw notFound("PDA task not found in the bound warehouse"); + String customerId = task.getCustomerId(); + OutboundOrder outbound = requireOutbound(task); + String completionKey = request.idempotencyKey().trim(); + OutboundPdaTask completionReplay = pdaTasks.findByCompletionKey(customerId, completionKey); + if (completionReplay != null && !task.getId().equals(completionReplay.getId())) { + throw conflict("Idempotency key belongs to another outbound PDA task"); + } + if ("COMPLETED".equals(task.getStatus())) { + if (completionKey.equals(task.getCompletionIdempotencyKey())) { + return taskResult(outbound, task); + } + throw conflict("PDA task was already completed with another idempotency key"); + } + if (!Set.of("PENDING", "IN_PROGRESS").contains(task.getStatus())) { + throw conflict("PDA task is not available for completion"); + } List items = outboundPallets.findByOutbound(customerId, outbound.getId()); - if (items.stream().anyMatch(i -> value(i.getPickedPackageCount()) < value(i.getPackageCount()))) throw conflict("All outbound pallets must be picked before completion"); - task.setStatus("COMPLETED"); task.setCompletedAt(LocalDateTime.now()); task.setUpdatedAt(LocalDateTime.now()); pdaTasks.updateById(task); outbound.setStatus("OUTBOUNDED"); outbound.setUpdatedAt(LocalDateTime.now()); outboundOrders.updateById(outbound); return taskResult(outbound, task); + if (items.isEmpty() || items.stream().anyMatch(i -> value(i.getPickedPackageCount()) != value(i.getPackageCount()))) { + throw conflict("All outbound pallets and packages must be picked before completion"); + } + LocalDateTime now = LocalDateTime.now(); + task.setStatus("COMPLETED"); task.setCompletionIdempotencyKey(completionKey); + task.setCompletedAt(now); task.setUpdatedAt(now); pdaTasks.updateById(task); + outbound.setStatus("OUTBOUNDED"); outbound.setUpdatedAt(now); outboundOrders.updateById(outbound); + return taskResult(outbound, task); } @Transactional - public void cancelOutbound(String customerId, String operatorId, String outboundNo, DeliveryRequests.Cancel request) { - OutboundOrder outbound = outboundOrders.lockByNo(customerId, outboundNo); + public void cancelOutbound(String outboundNo, DeliveryRequests.Cancel request) { + OutboundOrder outbound = outboundOrders.lockGlobalByNo(trim(outboundNo)); if (outbound == null) throw notFound("Outbound order not found"); if ("CANCELLED".equals(outbound.getStatus())) return; + if (!Set.of("WAIT_OUTBOUND", "WAIT_PICK").contains(outbound.getStatus())) { + throw conflict("Only an unexecuted outbound order can be cancelled"); + } + String customerId = outbound.getCustomerId(); List outboundItems = outboundPallets.findByOutbound(customerId, outbound.getId()); - if (outboundItems.isEmpty() || outboundItems.stream().anyMatch(i -> !"PICKED".equals(i.getStatus()))) - throw conflict("Outbound order can only be cancelled after picking is completed"); + if (outboundItems.isEmpty() || outboundItems.stream().anyMatch(i -> value(i.getPickedPackageCount()) > 0 + || !Set.of("WAIT_PICK", "WAIT_OUTBOUND").contains(i.getStatus()))) { + throw conflict("Outbound order cannot be cancelled after execution has started"); + } DeliveryOrder existingDelivery = deliveryOrders.lockByOutbound(customerId, outbound.getId()); if (existingDelivery != null && !"WAIT_DELIVERY".equals(existingDelivery.getStatus())) throw conflict("Outbound order cannot be cancelled while delivery is in progress"); LocalDateTime now = LocalDateTime.now(); - for (OutboundPdaPick pick : pdaPicks.findByOutbound(customerId, outbound.getId())) { - inventoryCommands.adjust(customerId, operatorId, - new InventoryCommandRequests.Adjustment(pick.getInventoryPositionId(), pick.getPackages(), "OUTBOUND_CANCEL", - outbound.getOutboundNo(), outbound.getOutboundNo() + ":CANCEL:" + pick.getId(), request.reason())); - OutboundPalletItem pickedItem = outboundItems.stream().filter(i -> Objects.equals(i.getId(), pick.getOutboundPalletItemId())).findFirst().orElse(null); - DropshipOrderProfile profile = dropshipProfiles.findPosition(customerId, pick.getInventoryPositionId()); - if (profile != null && pickedItem != null) dropshipSkuInventory.restore(customerId, profile.getId(), pickedItem.getSkuCode(), pick.getPackages()); - } - dropshipCartons.restoreOutbound(customerId, outbound.getOutboundNo()); DeliveryOrder delivery = existingDelivery; if (delivery != null) { delivery.setStatus("CANCELLED"); delivery.setUpdatedAt(now); deliveryOrders.updateById(delivery); } + OutboundPdaTask task = pdaTasks.lockByOutbound(customerId, outbound.getId()); + if (task != null) { task.setStatus("CANCELLED"); task.setUpdatedAt(now); pdaTasks.updateById(task); } outbound.setStatus("CANCELLED"); outbound.setUpdatedAt(now); outboundOrders.updateById(outbound); for (OutboundPalletItem item : outboundItems) { item.setStatus("CANCELLED"); outboundPallets.updateById(item); } if (delivery != null) { @@ -299,10 +367,79 @@ public class DeliveryTransferService { } } - public List pdaTasks(String customerId, String status) { return pdaTasks.list(customerId, StringUtils.hasText(status) ? status : null).stream().map(task -> taskResult(outboundOrders.selectById(task.getOutboundOrderId()), task)).toList(); } + public List pdaTasks(String warehouseId, String status) { + String boundWarehouseId = required(warehouseId, "PDA warehouse binding is required"); + return pdaTasks.listByWarehouse(boundWarehouseId, StringUtils.hasText(status) ? status.trim() : null).stream() + .map(task -> taskResult(requireOutboundInWarehouse(task, boundWarehouseId), task)).toList(); + } + + public DeliveryResponses.PdaTaskDetail pdaTaskDetail(String warehouseId, String taskNo) { + String boundWarehouseId = required(warehouseId, "PDA warehouse binding is required"); + OutboundPdaTask task = pdaTasks.findByNoAndWarehouse(required(taskNo, "PDA task number is required"), + boundWarehouseId); + if (task == null) throw notFound("PDA task not found in the bound warehouse"); + OutboundOrder outbound = requireOutboundInWarehouse(task, boundWarehouseId); + List items = outboundPallets.findByOutbound(task.getCustomerId(), + task.getOutboundOrderId()).stream().map(item -> new DeliveryResponses.PdaTaskItem(item.getId(), + item.getPalletNo(), item.getSkuCode(), item.getInboundCode(), value(item.getPackageCount()), + value(item.getPickedPackageCount()), item.getStatus(), + pdaPickPositions(task.getCustomerId(), boundWarehouseId, item))).toList(); + return new DeliveryResponses.PdaTaskDetail(taskResult(outbound, task), items); + } + + private List pdaPickPositions(String customerId, String warehouseId, + OutboundPalletItem item) { + if (item.getWarehousePalletId() == null) return List.of(); + List result = new ArrayList<>(); + for (InventoryPosition position : inventoryPositions.listPallet(customerId, item.getWarehousePalletId())) { + long available = value(position.getAvailablePackages()); + if (available <= 0 || !warehouseId.equals(position.getOverseasWarehouseId())) continue; + InventoryBalance balance = inventoryBalances.selectById(position.getBalanceId()); + if (!matchesOutboundItem(customerId, warehouseId, item, balance)) continue; + DropshipOrderProfile profile = dropshipProfiles.findPosition(customerId, position.getId()); + List marks = profile == null ? List.of() : dropshipCartons.listProfile(customerId, profile.getId()) + .stream().filter(carton -> "AVAILABLE".equals(carton.getStatus())) + .filter(carton -> Objects.equals(item.getSkuCode(), carton.getSkuCode())) + .map(DropshipCarton::getCartonMark).filter(StringUtils::hasText).distinct().limit(available).toList(); + if (marks.isEmpty()) { + result.add(pdaPosition(position, item, balance, null, available, value(position.getPickedPackages()))); + } else { + for (String mark : marks) result.add(pdaPosition(position, item, balance, mark, 1, 0)); + } + } + return result; + } + + private DeliveryResponses.PdaPickPosition pdaPosition(InventoryPosition position, OutboundPalletItem item, + InventoryBalance balance, String cartonMark, + long available, long picked) { + return new DeliveryResponses.PdaPickPosition(position.getId(), position.getPalletNo(), cartonMark, + balance.getSkuCode(), balance.getInboundCode(), available, picked); + } + + private boolean matchesOutboundItem(String customerId, String warehouseId, OutboundPalletItem item, + InventoryBalance balance) { + return balance != null && customerId.equals(balance.getCustomerId()) + && warehouseId.equals(balance.getOverseasWarehouseId()) + && Objects.equals(item.getSkuCode(), balance.getSkuCode()) + && Objects.equals(item.getInboundCode(), balance.getInboundCode()); + } private DeliveryResponses.PdaTask taskResult(OutboundOrder outbound, OutboundPdaTask task) { return new DeliveryResponses.PdaTask(task.getId(), task.getTaskNo(), task.getStatus(), outbound.getOutboundNo(), task.getPushedAt(), task.getCompletedAt()); } - private String outboundNo(Long id) { OutboundOrder value = outboundOrders.selectById(id); return value == null ? "" : value.getOutboundNo(); } + private OutboundOrder requireOutbound(OutboundPdaTask task) { + OutboundOrder outbound = outboundOrders.selectById(task.getOutboundOrderId()); + if (outbound == null || !task.getCustomerId().equals(outbound.getCustomerId())) { + throw new IllegalStateException("Outbound PDA task has no scoped outbound order"); + } + return outbound; + } + private OutboundOrder requireOutboundInWarehouse(OutboundPdaTask task, String warehouseId) { + OutboundOrder outbound = requireOutbound(task); + if (!warehouseId.equals(outbound.getOverseasWarehouseId())) { + throw notFound("PDA task not found in the bound warehouse"); + } + return outbound; + } private int value(Integer value) { return value == null ? 0 : value; } private long value(Long value) { return value == null ? 0L : value; } @@ -316,10 +453,11 @@ public class DeliveryTransferService { return page(page, page.getRecords().stream().map(this::outboundRow).toList()); } - public DeliveryResponses.OutboundDetail outboundDetail(String customerId, String outboundNo, + public DeliveryResponses.OutboundDetail outboundDetail(String outboundNo, DeliveryRequests.PalletQuery query) { - OutboundOrder outbound = outboundOrders.findByNo(customerId, outboundNo); + OutboundOrder outbound = outboundOrders.findGlobalByNo(trim(outboundNo)); if (outbound == null) throw notFound("Outbound order not found"); + String customerId = outbound.getCustomerId(); DeliveryOrder delivery = requireDelivery(customerId, outbound.getId()); return new DeliveryResponses.OutboundDetail(outboundRow(outbound), deliveryRow(delivery)); } @@ -404,6 +542,11 @@ public class DeliveryTransferService { private LastlegOrder firstTailOrder(String customerId, List items) { return items.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).map(tailOrders::selectById).filter(Objects::nonNull).filter(order -> customerId.equals(order.getCustomerId())).findFirst().orElse(null); } private BigDecimal totalWeight(String customerId, List items) { List ids = items.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).distinct().toList(); if (ids.isEmpty()) return BigDecimal.ZERO; return tailOrderDetails.selectList(new LambdaQueryWrapper().eq(LastlegOrderDetail::getCustomerId, customerId).in(LastlegOrderDetail::getOrderId, ids)).stream().map(LastlegOrderDetail::getGrossWeightKg).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add); } private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; } + private static String required(String value, String message) { + String normalized = trim(value); + if (normalized == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, message); + return normalized; + } private static String firstText(String primary, String fallback) { return StringUtils.hasText(primary) ? primary.trim() : trim(fallback); } private static PageResult page(Page page, List records) { return new PageResult<>(records, page.getCurrent(), page.getSize(), page.getTotal(), page.getPages()); } private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java index dfad563da..22ebb49c4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java @@ -4,24 +4,20 @@ import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.lastleg.dispatch.dto.DispatchRequests; import com.baiyue.lastleg.dispatch.dto.DispatchResponse; -import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests; -import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaResponses; -import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService; -import com.baiyue.lastleg.dispatch.pda.controller.DispatchPdaController; import com.baiyue.lastleg.dispatch.service.DispatchService; +import com.baiyue.lastleg.security.OverseasPermissions; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; @@ -30,60 +26,35 @@ import org.springframework.web.server.ResponseStatusException; @Tag(name = "Tail-leg vehicle dispatch") public class DispatchController { @Resource private DispatchService service; - @Resource private DispatchPdaWorkflowService pdaWorkflow; @PutMapping("/{dispatchNo}") - @Operation(summary = "Edit vehicle, driver, appointment, route and departure data") + @PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_EDIT + "')") + @Operation(summary = "Edit vehicle, driver, appointment and route data") public ApiResponse update( @AuthenticationPrincipal AuthenticatedUser user, - @RequestParam(required = false) String customerId, @PathVariable String dispatchNo, @Valid @RequestBody DispatchRequests.Update request) { - return ApiResponse.success(service.update(scope(user, customerId), dispatchNo, request)); + requirePlatform(user); + return ApiResponse.success(service.update(dispatchNo, request)); } @DeleteMapping("/{dispatchNo}") + @PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_CANCEL + "')") @Operation(summary = "Cancel a pending dispatch and release its pallet locks") public ApiResponse cancel( @AuthenticationPrincipal AuthenticatedUser user, - @RequestParam(required = false) String customerId, @PathVariable String dispatchNo) { - service.cancel(scope(user, customerId), dispatchNo); + requirePlatform(user); + service.cancel(dispatchNo); return ApiResponse.success(null); } - @PostMapping("/{dispatchNo}/depart") - @Operation(summary = "Ship all PDA-picked pallets and mark the dispatch departed") - public ApiResponse depart( - @AuthenticationPrincipal AuthenticatedUser user, - @RequestParam(required = false) String customerId, - @PathVariable String dispatchNo, - @Valid @RequestBody DispatchPdaRequests.Depart request) { - requirePdaOperate(user); - String scopedTenant = scope(user, customerId); - String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId()); - return ApiResponse.success(pdaWorkflow.depart(scopedTenant, dispatchNo, operatorId, request)); - } - - private String scope(AuthenticatedUser user, String requestedCustomer) { + private void requirePlatform(AuthenticatedUser user) { if (user == null || user.tenantId() == null) { throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); } - if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) { - if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, - "Platform request must specify target tenant"); - } - return requestedCustomer; - } - return String.valueOf(user.tenantId()); - } - - private void requirePdaOperate(AuthenticatedUser user) { - if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); - if (user.permissions() == null || !user.permissions().contains(DispatchPdaController.OPERATE_PERMISSION)) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, - DispatchPdaController.OPERATE_PERMISSION + " permission is required"); + if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Platform application required"); } } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java index b4627ab55..10e58f95a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java @@ -46,10 +46,10 @@ public final class DispatchRequests { public record Update(@Size(max = 64) String vehiclePlate, @Size(max = 128) String driverName, @Size(max = 64) String driverPhone, @Size(max = 255) String carrierName, - @Size(max = 255) String route, LocalDateTime departAt, + @Size(max = 255) String route, @Size(max = 64) String fbaAppointmentNo, @Size(max = 32) String fbaAppointmentStatus, @Size(max = 32) String vehicleAppointmentStatus, - LocalDateTime estimatedArrivalAt, @Size(max = 32) String status) { + LocalDateTime estimatedArrivalAt) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchOrderMapper.java index 3dd3fe6e6..97250cbc5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchOrderMapper.java @@ -6,15 +6,16 @@ import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; -import java.util.List; - @Mapper public interface DispatchOrderMapper extends BaseMapper { @Select("SELECT * FROM ll_dispatch_order WHERE customer_id=#{customerId} AND dispatch_no=#{dispatchNo} LIMIT 1") DispatchOrder findByDispatchNo(@Param("customerId") String customerId, @Param("dispatchNo") String dispatchNo); - @Select("SELECT * FROM ll_dispatch_order WHERE dispatch_no=#{dispatchNo} ORDER BY customer_id LIMIT 2") - List findAcrossCustomersByDispatchNo(@Param("dispatchNo") String dispatchNo); + @Select("SELECT * FROM ll_dispatch_order WHERE dispatch_no=#{dispatchNo} LIMIT 1") + DispatchOrder findGlobalByDispatchNo(@Param("dispatchNo") String dispatchNo); + + @Select("SELECT * FROM ll_dispatch_order WHERE dispatch_no=#{dispatchNo} LIMIT 1 FOR UPDATE") + DispatchOrder lockGlobalByDispatchNo(@Param("dispatchNo") String dispatchNo); @Select("SELECT * FROM ll_dispatch_order WHERE customer_id=#{customerId} AND id=#{id} LIMIT 1 FOR UPDATE") DispatchOrder lockById(@Param("customerId") String customerId, @Param("id") Long id); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/DispatchPdaController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/DispatchPdaController.java index 56bf9a70e..bc05e0a53 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/DispatchPdaController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/DispatchPdaController.java @@ -5,65 +5,60 @@ import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests; import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaResponses; import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService; +import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; import jakarta.annotation.Resource; import jakarta.validation.Valid; -import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; -import org.springframework.web.server.ResponseStatusException; + +import java.util.List; @RestController -@RequestMapping("/api/pda/v1/last-leg/dispatches/{dispatchNo}") +@RequestMapping("/api/pda/v1/last-leg/dispatches") +@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')") public class DispatchPdaController { - public static final String OPERATE_PERMISSION = "lastleg:pda:operate"; @Resource private DispatchPdaWorkflowService service; - @PostMapping("/push") - public ApiResponse push(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable String dispatchNo, - @RequestParam(required = false) String customerId, - @Valid @RequestBody DispatchPdaRequests.Push request) { - requireOperate(user); - return ApiResponse.success(service.push(scope(user, customerId), dispatchNo, request)); - } @GetMapping - public ApiResponse task(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable String dispatchNo, - @RequestParam(required = false) String customerId) { - return ApiResponse.success(service.task(scope(user, customerId), dispatchNo)); + public ApiResponse> list(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String status) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.list(String.valueOf(identity.overseasWarehouseId()), status)); } - @PostMapping("/items/{itemId}/pick") + + @GetMapping("/{dispatchNo}") + public ApiResponse task(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String dispatchNo) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.task(String.valueOf(identity.overseasWarehouseId()), dispatchNo)); + } + + @PostMapping("/{dispatchNo}/items/{itemId}/pick") public ApiResponse pick(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String dispatchNo, @PathVariable long itemId, - @RequestParam(required = false) String customerId, @Valid @RequestBody DispatchPdaRequests.Pick request) { - requireOperate(user); - return ApiResponse.success(service.pick(scope(user, customerId), dispatchNo, itemId, operator(user), request)); + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.pick(String.valueOf(identity.overseasWarehouseId()), dispatchNo, itemId, + identity.operatorKey(), identity.operatorName(), request)); } - @PostMapping("/complete") + + @PostMapping("/{dispatchNo}/complete") public ApiResponse completePicking( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String dispatchNo, - @RequestParam(required = false) String customerId, @Valid @RequestBody DispatchPdaRequests.CompletePicking request) { - requireOperate(user); - return ApiResponse.success(service.completePicking(scope(user, customerId), dispatchNo, request)); + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.completePicking(String.valueOf(identity.overseasWarehouseId()), + dispatchNo, request)); } - private String scope(AuthenticatedUser user, String requestedCustomer) { - if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); - if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) { - if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target tenant"); - } - return requestedCustomer.trim(); - } - return String.valueOf(user.tenantId()); - } - private String operator(AuthenticatedUser user) { return user.userId() == null ? user.username() : String.valueOf(user.userId()); } - private void requireOperate(AuthenticatedUser user) { - if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); - if (user.permissions() == null || !user.permissions().contains(OPERATE_PERMISSION)) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, OPERATE_PERMISSION + " permission is required"); - } + + @PostMapping("/{dispatchNo}/depart") + public ApiResponse depart(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String dispatchNo, + @Valid @RequestBody DispatchPdaRequests.Depart request) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.depart(String.valueOf(identity.overseasWarehouseId()), dispatchNo, + identity.operatorKey(), request)); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaRequests.java index e98c0cdcc..0d5613033 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaRequests.java @@ -8,19 +8,8 @@ import jakarta.validation.constraints.Size; public final class DispatchPdaRequests { private DispatchPdaRequests() {} - public record Push(@NotBlank String idempotencyKey, String operatorId, String operatorName, - String warehouseId) { - public Push(String idempotencyKey, String operatorId, String operatorName) { - this(idempotencyKey, operatorId, operatorName, null); - } - } public record Pick(@NotNull Long positionId, @Positive long packages, @PositiveOrZero long productQuantity, - @NotBlank String idempotencyKey, String remark, String warehouseId) { - public Pick(Long positionId, long packages, long productQuantity, String idempotencyKey, String remark) { - this(positionId, packages, productQuantity, idempotencyKey, remark, null); - } - } + @NotBlank String idempotencyKey, String remark) {} public record Depart(@NotBlank String idempotencyKey, String remark) {} - public record CompletePicking(@NotBlank String idempotencyKey, - @NotBlank @Size(max = 1000) String reason) {} + public record CompletePicking(@NotBlank String idempotencyKey) {} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaOperationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaOperationMapper.java index a94613689..98f84e699 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaOperationMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaOperationMapper.java @@ -8,4 +8,9 @@ import org.apache.ibatis.annotations.*; public interface DispatchPdaOperationMapper extends BaseMapper { @Select("SELECT * FROM ll_dispatch_pda_operation WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1") DispatchPdaOperation findKey(@Param("customerId") String customerId, @Param("key") String key); + + @Select("SELECT * FROM ll_dispatch_pda_operation WHERE customer_id=#{customerId} AND task_id=#{taskId} " + + "AND operation_type=#{operationType} AND status='COMPLETED' ORDER BY created_at DESC LIMIT 1") + DispatchPdaOperation findCompleted(@Param("customerId") String customerId, @Param("taskId") Long taskId, + @Param("operationType") String operationType); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaTaskMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaTaskMapper.java index 6c548691e..c92c6232f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaTaskMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaTaskMapper.java @@ -4,6 +4,8 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaTask; import org.apache.ibatis.annotations.*; +import java.util.List; + @Mapper public interface DispatchPdaTaskMapper extends BaseMapper { @Select("SELECT * FROM ll_dispatch_pda_task WHERE customer_id=#{customerId} AND dispatch_order_id=#{dispatchOrderId} LIMIT 1 FOR UPDATE") @@ -11,4 +13,9 @@ public interface DispatchPdaTaskMapper extends BaseMapper { @Select("SELECT * FROM ll_dispatch_pda_task WHERE customer_id=#{customerId} AND dispatch_order_id=#{dispatchOrderId} LIMIT 1") DispatchPdaTask findByDispatch(@Param("customerId") String customerId, @Param("dispatchOrderId") Long dispatchOrderId); + + @Select("SELECT * FROM ll_dispatch_pda_task WHERE warehouse_id=#{warehouseId} " + + "AND (#{status} IS NULL OR status=#{status}) ORDER BY pushed_at DESC LIMIT 200") + List listByWarehouse(@Param("warehouseId") String warehouseId, + @Param("status") String status); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java index e988a9ba5..30fc798ed 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java @@ -44,59 +44,48 @@ public class DispatchPdaWorkflowService { @Resource private DispatchPickOrderAttributionService pickAttribution; @Resource private ObjectMapper objectMapper; - @Transactional - public DispatchPdaResponses.Task push(String customerId, String dispatchNo, DispatchPdaRequests.Push request) { - DispatchOrder order = requireOrder(customerId, dispatchNo); - requirePdaStatus(order); - requireVehicle(order); - String warehouseId = warehouse(request.warehouseId()); - requireDispatchWarehouse(order, warehouseId); - DispatchPdaOperation replay = replay(customerId, "PUSH", request.idempotencyKey(), request); - if (replay != null) return read(replay, DispatchPdaResponses.Task.class); - items.bindReceivedPallets(customerId, order.getId()); - List dispatchItems = items.findByDispatchOrder(customerId, order.getId()); - if (dispatchItems.isEmpty()) throw conflict("Dispatch has no pallets to push"); - if (dispatchItems.stream().anyMatch(item -> item.getWarehousePalletId() == null)) { - throw conflict("All pre-arranged pallets must be received before PDA push"); + @Transactional(readOnly = true) + public List list(String warehouseId, String status) { + String boundWarehouseId = warehouse(warehouseId); + List result = new ArrayList<>(); + for (DispatchPdaTask pdaTask : tasks.listByWarehouse(boundWarehouseId, trim(status))) { + DispatchOrder order = orders.selectById(pdaTask.getDispatchOrderId()); + if (order == null || !pdaTask.getCustomerId().equals(order.getCustomerId()) + || !boundWarehouseId.equals(trim(order.getOverseasWarehouseId()))) { + throw conflict("Dispatch PDA task scope is inconsistent"); + } + result.add(task(order.getCustomerId(), order, pdaTask, + items.findByDispatchOrder(order.getCustomerId(), order.getId()))); } - DispatchPdaTask task = tasks.lockByDispatch(customerId, order.getId()); - LocalDateTime now = LocalDateTime.now(); - if (task == null) { - task = new DispatchPdaTask(); - task.setId(IdWorker.getId()); task.setCustomerId(customerId); task.setDispatchOrderId(order.getId()); - task.setTaskNo("DPDA" + task.getId()); task.setStatus("PDA_PENDING"); - task.setWarehouseId(warehouseId); - task.setOperatorId(trim(request.operatorId())); task.setOperatorName(trim(request.operatorName())); - task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); tasks.insert(task); - } - if ("PENDING_DEPARTURE".equals(order.getStatus())) { order.setStatus("PICKING"); order.setUpdatedAt(now); orders.updateById(order); } - if (!warehouseId.equals(task.getWarehouseId())) throw conflict("PDA task warehouse does not match selected warehouse"); - DispatchPdaResponses.Task result = task(customerId, order, task, dispatchItems); - save(customerId, task.getId(), "PUSH", request.idempotencyKey(), request, result); return result; } - @Transactional(readOnly = true) - public DispatchPdaResponses.Task task(String customerId, String dispatchNo) { - DispatchOrder order = requireOrder(customerId, dispatchNo); + @Transactional + public DispatchPdaResponses.Task task(String warehouseId, String dispatchNo) { + DispatchOrder order = requireWarehouseOrder(warehouseId, dispatchNo); + String customerId = order.getCustomerId(); + items.bindReceivedPallets(customerId, order.getId()); DispatchPdaTask task = tasks.findByDispatch(customerId, order.getId()); if (task == null) throw notFound("Dispatch PDA task not found"); + requireTaskWarehouse(task, warehouseId); return task(customerId, order, task, items.findByDispatchOrder(customerId, order.getId())); } @Transactional - public DispatchPdaResponses.Picked pick(String customerId, String dispatchNo, long itemId, - String operatorId, DispatchPdaRequests.Pick request) { - DispatchOrder order = requireOrder(customerId, dispatchNo); + public DispatchPdaResponses.Picked pick(String warehouseId, String dispatchNo, long itemId, + String operatorId, String operatorName, + DispatchPdaRequests.Pick request) { + DispatchOrder order = requireWarehouseOrder(warehouseId, dispatchNo); + String customerId = order.getCustomerId(); requirePdaStatus(order); requireVehicle(order); - String warehouseId = warehouse(request.warehouseId()); - requireDispatchWarehouse(order, warehouseId); + items.bindReceivedPallets(customerId, order.getId()); DispatchPdaTask task = tasks.lockByDispatch(customerId, order.getId()); if (task == null || !("PDA_PENDING".equals(task.getStatus()) || "PICKING".equals(task.getStatus()))) { throw conflict("Dispatch PDA task is not available for picking"); } if (!warehouseId.equals(task.getWarehouseId())) throw conflict("PDA pick warehouse does not match task warehouse"); + bindOperator(task, operatorId, operatorName); DispatchItem item = items.selectById(itemId); if (item == null || !customerId.equals(item.getCustomerId()) || !order.getId().equals(item.getDispatchOrderId())) { throw notFound("Dispatch pallet item not found"); @@ -145,9 +134,10 @@ public class DispatchPdaWorkflowService { } @Transactional - public DispatchPdaResponses.PickingCompleted completePicking(String customerId, String dispatchNo, + public DispatchPdaResponses.PickingCompleted completePicking(String warehouseId, String dispatchNo, DispatchPdaRequests.CompletePicking request) { - DispatchOrder found = requireOrder(customerId, dispatchNo); + DispatchOrder found = requireWarehouseOrder(warehouseId, dispatchNo); + String customerId = found.getCustomerId(); DispatchOrder order = orders.lockById(customerId, found.getId()); if (order == null) throw notFound("Dispatch order not found"); DispatchPdaOperation replay = replay(customerId, "COMPLETE_PICKING", request.idempotencyKey(), request); @@ -159,8 +149,12 @@ public class DispatchPdaWorkflowService { long planned = completedItems.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull) .mapToLong(Integer::longValue).sum(); long picked = picks.listTask(customerId, task.getId()).stream().mapToLong(p -> n(p.getPackages())).sum(); - return new DispatchPdaResponses.PickingCompleted(order.getDispatchNo(), order.getStatus(), planned, - picked, Math.max(0, planned - picked), picked < planned, true); + DispatchPdaResponses.PickingCompleted result = new DispatchPdaResponses.PickingCompleted( + order.getDispatchNo(), order.getStatus(), planned, picked, Math.max(0, planned - picked), false, false); + DispatchPdaOperation completed = operations.findCompleted(customerId, task.getId(), "COMPLETE_PICKING"); + if (completed != null) throw conflict("PDA picking was already completed with another idempotency key"); + save(customerId, task.getId(), "COMPLETE_PICKING", request.idempotencyKey(), request, result); + return result; } if (task == null || !("PDA_PENDING".equals(task.getStatus()) || "PICKING".equals(task.getStatus()))) { throw conflict("Dispatch PDA task is not available for completion"); @@ -169,25 +163,27 @@ public class DispatchPdaWorkflowService { long planned = source.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull) .mapToLong(Integer::longValue).sum(); long picked = picks.listTask(customerId, task.getId()).stream().mapToLong(p -> n(p.getPackages())).sum(); - if (picked <= 0 || picked > planned) throw conflict("PDA completion requires a valid picked package count"); - boolean partial = picked < planned; + if (planned <= 0 || picked != planned || !allPicked(customerId, task.getId(), source)) { + throw conflict("Every dispatch pallet and package must be fully picked before completion"); + } LocalDateTime now = LocalDateTime.now(); task.setStatus("COMPLETED"); task.setCompletedAt(now); task.setUpdatedAt(now); tasks.updateById(task); - order.setStatus(partial ? "PARTIAL_READY_TO_DEPART" : "READY_TO_DEPART"); - order.setPartialCompletionReason(partial ? request.reason().trim() : null); + order.setStatus("READY_TO_DEPART"); + order.setPartialCompletionReason(null); order.setPickedPackageCount((int) picked); order.setUpdatedAt(now); orders.updateById(order); DispatchPdaResponses.PickingCompleted result = new DispatchPdaResponses.PickingCompleted(order.getDispatchNo(), - order.getStatus(), planned, picked, planned - picked, partial, false); + order.getStatus(), planned, picked, 0, false, false); save(customerId, task.getId(), "COMPLETE_PICKING", request.idempotencyKey(), request, result); return result; } /** Final warehouse exit: only fully PDA-picked dispatches can consume picked inventory. */ @Transactional - public DispatchPdaResponses.Departed depart(String customerId, String dispatchNo, String operatorId, + public DispatchPdaResponses.Departed depart(String warehouseId, String dispatchNo, String operatorId, DispatchPdaRequests.Depart request) { - DispatchOrder found = requireOrder(customerId, dispatchNo); + DispatchOrder found = requireWarehouseOrder(warehouseId, dispatchNo); + String customerId = found.getCustomerId(); DispatchOrder order = orders.lockById(customerId, found.getId()); if (order == null) throw notFound("Dispatch order not found"); DispatchPdaOperation replay = replay(customerId, "DEPART", request.idempotencyKey(), request); @@ -199,13 +195,13 @@ public class DispatchPdaWorkflowService { } DispatchPdaTask task = tasks.lockByDispatch(customerId, order.getId()); if (task == null || !"COMPLETED".equals(task.getStatus()) - || !("READY_TO_DEPART".equals(order.getStatus()) || "PARTIAL_READY_TO_DEPART".equals(order.getStatus()))) { + || !"READY_TO_DEPART".equals(order.getStatus())) { throw conflict("Dispatch must complete PDA picking before departure"); } + requireTaskWarehouse(task, warehouseId); List dispatchItems = items.findByDispatchOrder(customerId, order.getId()); - boolean partial = "PARTIAL_READY_TO_DEPART".equals(order.getStatus()); - if (!partial && !allPicked(customerId, task.getId(), dispatchItems)) { + if (!allPicked(customerId, task.getId(), dispatchItems)) { throw conflict("Every dispatch pallet must be fully picked before departure"); } List dispatchPicks = picks.listTask(customerId, task.getId()); @@ -217,7 +213,7 @@ public class DispatchPdaWorkflowService { outbound.setUpdatedAt(now); outboundOrders.updateById(outbound); order.setShippedPackageCount((int) shipped); - order.setStatus(partial ? "PARTIAL_DEPARTED" : "DEPARTED"); + order.setStatus("DEPARTED"); order.setUpdatedAt(now); orders.updateById(order); releasePalletLocks(customerId, order.getId(), now); @@ -258,18 +254,33 @@ public class DispatchPdaWorkflowService { palletLocks.updateById(lock); } } - private DispatchOrder requireOrder(String customerId, String dispatchNo) { DispatchOrder order = orders.findByDispatchNo(customerId, dispatchNo); if (order == null) throw notFound("Dispatch order not found"); return order; } + private DispatchOrder requireWarehouseOrder(String warehouseId, String dispatchNo) { + String boundWarehouseId = warehouse(warehouseId); + DispatchOrder order = orders.findGlobalByDispatchNo(required(dispatchNo, "Dispatch number is required")); + if (order == null || !boundWarehouseId.equals(trim(order.getOverseasWarehouseId()))) { + throw notFound("Dispatch order not found in the bound warehouse"); + } + return order; + } private void requirePdaStatus(DispatchOrder order) { if (!("PENDING_DEPARTURE".equals(order.getStatus()) || "PICKING".equals(order.getStatus()) || "READY_TO_DEPART".equals(order.getStatus()))) throw conflict("Dispatch has already departed and cannot be changed by PDA"); } private void requireVehicle(DispatchOrder order) { if (trim(order.getVehiclePlate()) == null) throw conflict("Vehicle plate is required before PDA push or picking"); } private static String warehouse(String value) { return required(value, "Warehouse selection is required for PDA work"); } - private static void requireDispatchWarehouse(DispatchOrder order, String warehouseId) { - if (trim(order.getOverseasWarehouseId()) == null || !warehouseId.equals(order.getOverseasWarehouseId().trim())) { - throw conflict("Selected warehouse does not match dispatch warehouse"); + private static void requireTaskWarehouse(DispatchPdaTask task, String warehouseId) { + if (!warehouse(warehouseId).equals(trim(task.getWarehouseId()))) { + throw notFound("Dispatch PDA task not found in the bound warehouse"); } } + private static void bindOperator(DispatchPdaTask task, String operatorId, String operatorName) { + String normalizedOperator = required(operatorId, "PDA operator is required"); + if (trim(task.getOperatorId()) != null && !normalizedOperator.equals(trim(task.getOperatorId()))) { + throw conflict("Dispatch PDA task is already assigned to another operator"); + } + task.setOperatorId(normalizedOperator); + task.setOperatorName(trim(operatorName)); + } private DispatchPdaOperation replay(String customerId, String type, String key, Object request) { DispatchPdaOperation op = operations.findKey(customerId, required(key, "Idempotency key is required")); if (op == null) return null; if (!type.equals(op.getOperationType()) || !hash(request).equals(op.getRequestHash())) throw conflict("Idempotency key was reused with a different request"); if (!"COMPLETED".equals(op.getStatus())) throw conflict("PDA operation is still processing"); return op; } private void save(String customerId, Long taskId, String type, String key, Object request, Object result) { DispatchPdaOperation op = new DispatchPdaOperation(); op.setId(IdWorker.getId()); op.setCustomerId(customerId); op.setTaskId(taskId); op.setOperationType(type); op.setIdempotencyKey(required(key,"Idempotency key is required")); op.setRequestHash(hash(request)); op.setResultJson(json(result)); op.setStatus("COMPLETED"); op.setCreatedAt(LocalDateTime.now()); op.setUpdatedAt(op.getCreatedAt()); operations.insert(op); } private T read(DispatchPdaOperation op, Class type) { try { return objectMapper.readValue(op.getResultJson(), type); } catch (Exception e) { throw new IllegalStateException("Cannot read PDA replay", e); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java index 9fcd08079..e326e2166 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java @@ -20,6 +20,8 @@ import com.baiyue.lastleg.dispatch.mapper.DispatchDraftContainerMapper; import com.baiyue.lastleg.dispatch.dto.DispatchContainerResponse; import com.baiyue.lastleg.dispatch.dto.DispatchDraftResponse; import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate; +import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaTask; +import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaTaskMapper; import jakarta.annotation.Resource; import org.springframework.dao.DuplicateKeyException; import org.springframework.http.HttpStatus; @@ -49,6 +51,7 @@ public class DispatchService { @Resource private DispatchDraftMapper drafts; @Resource private DispatchDraftContainerMapper draftContainers; @Resource private DispatchCandidateMapper candidates; + @Resource private DispatchPdaTaskMapper pdaTasks; @Resource private LastlegOrderNoGenerator orderNoGenerator; public String resolveCustomerScopeForPlans(List planIds) { @@ -315,6 +318,17 @@ public class DispatchService { item.setCreatedAt(now); items.insert(item); } + DispatchPdaTask pdaTask = new DispatchPdaTask(); + pdaTask.setId(IdWorker.getId()); + pdaTask.setCustomerId(customerId); + pdaTask.setDispatchOrderId(dispatch.getId()); + pdaTask.setTaskNo("DPDA" + pdaTask.getId()); + pdaTask.setStatus("PDA_PENDING"); + pdaTask.setWarehouseId(dispatch.getOverseasWarehouseId()); + pdaTask.setPushedAt(now); + pdaTask.setCreatedAt(now); + pdaTask.setUpdatedAt(now); + pdaTasks.insert(pdaTask); return response(dispatch, items.findByDispatchOrder(customerId, dispatch.getId())); } @@ -351,49 +365,45 @@ public class DispatchService { } public DispatchResponse platformDetail(String dispatchNo) { - List matches = orders.findAcrossCustomersByDispatchNo(dispatchNo); - if (matches == null || matches.isEmpty()) { + DispatchOrder dispatch = orders.findGlobalByDispatchNo(trimToNull(dispatchNo)); + if (dispatch == null) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found"); } - if (matches.size() > 1) { - throw new ResponseStatusException(HttpStatus.CONFLICT, - "Dispatch number is not unique across customers"); - } - DispatchOrder dispatch = matches.get(0); return response(dispatch, items.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId())); } @Transactional - public DispatchResponse update(String customerId, String dispatchNo, DispatchRequests.Update request) { - DispatchOrder dispatch = orders.lockById(customerId, find(customerId, dispatchNo).getId()); + public DispatchResponse update(String dispatchNo, DispatchRequests.Update request) { + DispatchOrder dispatch = orders.lockGlobalByDispatchNo(trimToNull(dispatchNo)); if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found"); - // Vehicle plate is intentionally editable through all dispatch states. + if (!PENDING_DEPARTURE.equals(dispatch.getStatus())) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "Only a pending dispatch can be edited"); + } + String customerId = dispatch.getCustomerId(); dispatch.setVehiclePlate(trimToNull(request.vehiclePlate())); dispatch.setDriverName(trimToNull(request.driverName())); dispatch.setDriverPhone(trimToNull(request.driverPhone())); dispatch.setCarrierName(trimToNull(request.carrierName())); dispatch.setRoute(trimToNull(request.route())); - dispatch.setDepartAt(request.departAt()); dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo())); dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus())); dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus())); dispatch.setEstimatedArrivalAt(request.estimatedArrivalAt()); - if (StringUtils.hasText(request.status())) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, - "Dispatch status is PDA-managed and cannot be changed by the edit endpoint"); - } dispatch.setUpdatedAt(LocalDateTime.now()); orders.updateById(dispatch); return response(dispatch, items.findByDispatchOrder(customerId, dispatch.getId())); } @Transactional - public void cancel(String customerId, String dispatchNo) { - DispatchOrder dispatch = orders.lockById(customerId, find(customerId, dispatchNo).getId()); + public void cancel(String dispatchNo) { + DispatchOrder dispatch = orders.lockGlobalByDispatchNo(trimToNull(dispatchNo)); if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found"); + if (CANCELLED.equals(dispatch.getStatus())) return; if (!(PENDING_DEPARTURE.equals(dispatch.getStatus()))) { - throw new ResponseStatusException(HttpStatus.CONFLICT, "Dispatched order cannot be cancelled"); + throw new ResponseStatusException(HttpStatus.CONFLICT, "Only a pending dispatch can be cancelled"); } + String customerId = dispatch.getCustomerId(); LocalDateTime now = LocalDateTime.now(); dispatch.setStatus(CANCELLED); dispatch.setUpdatedAt(now); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java b/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java index 4bd4f198f..76ae1b4bd 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java @@ -1,6 +1,6 @@ package com.baiyue.lastleg.security; -/** Canonical Admin authorities for fourth-phase overseas APIs. */ +/** Canonical authorities for overseas browser APIs. */ public final class OverseasPermissions { public static final String LAST_MILE_ORDER_LIST = "platform:overseas-warehouse:last-mile-order:list"; public static final String LAST_MILE_ORDER_EDIT = "platform:overseas-warehouse:last-mile-order:edit"; @@ -20,6 +20,15 @@ public final class OverseasPermissions { public static final String INVENTORY_EDIT = "platform:overseas-warehouse:inventory:edit"; public static final String INVENTORY_ADJUST = "platform:overseas-warehouse:inventory:adjust"; public static final String DELIVERY_LIST = "platform:overseas-warehouse:delivery:list"; + public static final String DISPATCH_EDIT = "platform:overseas-warehouse:dispatch:edit"; + public static final String DISPATCH_CANCEL = "platform:overseas-warehouse:dispatch:cancel"; + public static final String DISPATCH_TRANSFER_DELIVERY = + "platform:overseas-warehouse:dispatch:transfer-delivery"; + public static final String OUTBOUND_LIST = "platform:overseas-warehouse:outbound:list"; + public static final String OUTBOUND_CREATE = "platform:overseas-warehouse:outbound:create"; + public static final String OUTBOUND_CANCEL = "platform:overseas-warehouse:outbound:cancel"; + public static final String MERCHANT_DELIVERY_LIST = "merchant:warehouse:delivery:list"; + public static final String MERCHANT_DELIVERY_CREATE = "merchant:warehouse:delivery:create"; public static final String PURCHASE_LIST = "platform:overseas-business:purchase:list"; public static final String PURCHASE_EDIT = "platform:overseas-business:purchase:edit"; public static final String PURCHASE_REVIEW = "platform:overseas-business:purchase:review"; diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index e7081d1ea..a772330e4 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -1040,7 +1040,7 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_order ( created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', PRIMARY KEY (id), - UNIQUE KEY uk_ll_dispatch_order_no (customer_id,dispatch_no), + UNIQUE KEY uk_ll_dispatch_order_no (dispatch_no), KEY idx_ll_dispatch_order_list (customer_id,status,depart_at), KEY idx_ll_dispatch_order_vehicle (customer_id,vehicle_plate) ) ENGINE=InnoDB COMMENT='尾程派车单'; @@ -1134,12 +1134,12 @@ PREPARE dispatch_schema_stmt FROM @ddl; EXECUTE dispatch_schema_stmt; DEALLOCATE CREATE TABLE IF NOT EXISTS ll_dispatch_pda_task ( id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', dispatch_order_id BIGINT NOT NULL COMMENT '派车单ID', task_no VARCHAR(64) NOT NULL COMMENT 'PDA任务号', status VARCHAR(32) NOT NULL COMMENT '业务状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID', - warehouse_id VARCHAR(128) NULL COMMENT '仓库ID', + warehouse_id VARCHAR(128) NOT NULL COMMENT 'Token绑定的海外仓库ID', operator_name VARCHAR(128) NULL COMMENT '操作人姓名', pushed_at DATETIME(3) NOT NULL COMMENT '任务下发时间', completed_at DATETIME(3) NULL COMMENT '完成时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', PRIMARY KEY (id), UNIQUE KEY uk_ll_dispatch_pda_task_dispatch (customer_id,dispatch_order_id), - UNIQUE KEY uk_ll_dispatch_pda_task_no (customer_id,task_no) + UNIQUE KEY uk_ll_dispatch_pda_task_no (task_no) ) ENGINE=InnoDB COMMENT='派车PDA拣货任务'; CREATE TABLE IF NOT EXISTS ll_dispatch_pda_pick ( @@ -1178,7 +1178,7 @@ CREATE TABLE IF NOT EXISTS ll_outbound_order ( created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', outbound_type VARCHAR(32) NOT NULL DEFAULT 'DISPATCH_DELIVERY' COMMENT '出库类型:派送转出库、一件代发', recipient_info VARCHAR(1000) NULL COMMENT '一件代发收件信息', - PRIMARY KEY (id), UNIQUE KEY uk_ll_outbound_no (customer_id,outbound_no), + PRIMARY KEY (id), UNIQUE KEY uk_ll_outbound_no (outbound_no), UNIQUE KEY uk_ll_outbound_idempotency (customer_id,idempotency_key), UNIQUE KEY uk_ll_outbound_dispatch (customer_id,dispatch_order_id), KEY idx_ll_outbound_list (customer_id,status,created_at) ) ENGINE=InnoDB COMMENT='尾程出库单'; @@ -1196,9 +1196,11 @@ CREATE TABLE IF NOT EXISTS ll_outbound_pallet_item ( CREATE TABLE IF NOT EXISTS ll_outbound_pda_task ( id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', outbound_order_id BIGINT NOT NULL COMMENT '出库单ID', task_no VARCHAR(64) NOT NULL COMMENT 'PDA任务号', status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '任务状态:待拣货、拣货中、已完成', + completion_idempotency_key VARCHAR(255) NULL COMMENT '完成命令幂等键', pushed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '推送时间', completed_at DATETIME(3) NULL COMMENT '完成时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', - PRIMARY KEY(id), UNIQUE KEY uk_ll_outbound_pda_task_order(customer_id,outbound_order_id), UNIQUE KEY uk_ll_outbound_pda_task_no(customer_id,task_no) + PRIMARY KEY(id), UNIQUE KEY uk_ll_outbound_pda_task_order(customer_id,outbound_order_id), UNIQUE KEY uk_ll_outbound_pda_task_no(task_no), + UNIQUE KEY uk_ll_outbound_pda_complete_key(customer_id,completion_idempotency_key) ) ENGINE=InnoDB COMMENT='一件代发出库PDA拣货任务'; CREATE TABLE IF NOT EXISTS ll_outbound_pda_pick ( diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index d1ffa4091..39b455819 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -386,7 +386,19 @@ paths: responses: '200': { description: 统一 ApiResponse 包装的 DispatchResponse } '404': { description: 排车单不存在 } - '409': { description: 排车单号在不同客户下重复,无法唯一定位 } + put: + summary: 编辑待发车排车单 + description: 权限 platform:overseas-warehouse:dispatch:edit;只按全局唯一 dispatchNo 锁定记录并从记录解析客户范围,不接收 customerId。仅允许 PENDING_DEPARTURE,且请求不包含 status/departAt,发车事实只能由 PDA 作业命令产生。 + security: [{ bearerAuth: [] }] + parameters: [{ name: dispatchNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DispatchUpdate' } } } } + responses: { '200': { description: 更新后的 DispatchResponse }, '404': { description: 排车单不存在 }, '409': { description: 排车单已执行或不可编辑 } } + delete: + summary: 取消待发车排车单 + description: 权限 platform:overseas-warehouse:dispatch:cancel;只按全局唯一 dispatchNo 锁定记录并解析客户范围,不接收 customerId。仅 PENDING_DEPARTURE 可取消并释放正式托盘锁;重复取消幂等成功。 + security: [{ bearerAuth: [] }] + parameters: [{ name: dispatchNo, in: path, required: true, schema: { type: string } }] + responses: { '200': { description: 取消成功或已取消 }, '404': { description: 排车单不存在 }, '409': { description: 排车已开始执行 } } /api/platform/v1/last-leg/dispatches/candidates: get: summary: 查询智能排车候选托盘 @@ -411,7 +423,7 @@ paths: /api/platform/v1/last-leg/dispatches/pre-arrange: post: summary: 创建智能预排车单 - description: 权限 platform:overseas-warehouse:inbound-order:smart-dispatch;服务端根据所选托盘解析并校验唯一客户范围,锁定同一海外仓的托盘并创建 PENDING_DEPARTURE 排车单,前端不传 customerId;不开放编辑、取消、depart、PDA 出库或派送。 + description: 权限 platform:overseas-warehouse:inbound-order:smart-dispatch;服务端根据所选托盘解析并校验唯一客户范围,锁定同一海外仓的托盘并创建 PENDING_DEPARTURE 排车单,前端不传 customerId;本接口只负责预排车创建,编辑、取消、转出库派送和 PDA 发车使用各自独立接口与权限。 security: [{ bearerAuth: [] }] requestBody: required: true @@ -467,6 +479,50 @@ paths: - { name: draftToken, in: path, required: true, schema: { type: string, maxLength: 64 } } - { name: containerId, in: path, required: true, schema: { type: string } } responses: { '200': { description: 空车厢删除成功 }, '409': { description: 车厢仍绑定托盘或草稿状态冲突 } } + /api/platform/v1/last-leg/dispatches/{dispatchNo}/transfer-delivery: + post: + summary: 排车转出库与派送单 + description: 权限 platform:overseas-warehouse:dispatch:transfer-delivery;只按全局唯一 dispatchNo 锁定记录并从记录解析客户范围,不接收 customerId;同一幂等键或已转换排车重复调用返回既有单据。 + security: [{ bearerAuth: [] }] + parameters: [{ name: dispatchNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DeliveryTransfer' } } } } + responses: { '200': { description: 出库与派送单转换结果 }, '404': { description: 排车单不存在 }, '409': { description: 状态、托盘或幂等冲突 } } + /api/platform/v1/last-leg/outbound-orders: + get: + summary: 分页查询出库单 + description: 权限 platform:overseas-warehouse:outbound:list;customerId 仅为可选平台筛选条件。 + security: [{ bearerAuth: [] }] + parameters: + - { name: customerId, in: query, required: false, schema: { type: string, pattern: '^[1-9][0-9]*$' } } + - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } } + - { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } } + - { name: documentNo, in: query, schema: { type: string } } + - { name: warehouseId, in: query, schema: { type: string } } + - { name: status, in: query, schema: { type: string } } + responses: { '200': { description: 出库单分页 } } + /api/platform/v1/last-leg/outbound-orders/one-click-dropship: + post: + summary: 创建一件代发出库单 + description: 权限 platform:overseas-warehouse:outbound:create;因尚无既有业务单号可解析归属,平台必须显式传 customerId。 + security: [{ bearerAuth: [] }] + parameters: [{ name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/OneClickOutboundCreate' } } } } + responses: { '200': { description: 一件代发出库 PDA 任务 }, '409': { description: 库存或幂等冲突 } } + /api/platform/v1/last-leg/outbound-orders/{outboundNo}: + get: + summary: 查询出库单详情 + description: 权限 platform:overseas-warehouse:outbound:list;只按全局唯一 outboundNo 定位并从记录解析客户范围,不接收 customerId。 + security: [{ bearerAuth: [] }] + parameters: [{ name: outboundNo, in: path, required: true, schema: { type: string } }] + responses: { '200': { description: 出库单及关联派送单详情 }, '404': { description: 出库单不存在 } } + /api/platform/v1/last-leg/outbound-orders/{outboundNo}/cancel: + post: + summary: 取消未执行出库单 + description: 权限 platform:overseas-warehouse:outbound:cancel;只按全局唯一 outboundNo 锁定并解析客户范围,不接收 customerId。仅未开始拣货/出库的单据可取消,重复取消幂等成功。 + security: [{ bearerAuth: [] }] + parameters: [{ name: outboundNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/OutboundCancel' } } } } + responses: { '200': { description: 取消成功或已取消 }, '404': { description: 出库单不存在 }, '409': { description: 出库已开始执行 } } /api/platform/v1/last-leg/overseas-procurements: get: summary: 分页查询海外采购订单 @@ -533,6 +589,39 @@ paths: required: true content: { application/json: { schema: { $ref: '#/components/schemas/ProcurementReview' } } } responses: { '200': { description: 统一 ApiResponse 包装的采购订单详情 } } + /api/merchant/v1/last-leg/delivery-orders: + get: + summary: 客户分页查询自己的派送单 + description: 权限 merchant:warehouse:delivery:list;客户范围只取认证 Token,不接收 customerId。 + security: [{ bearerAuth: [] }] + parameters: + - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } } + - { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } } + - { name: documentNo, in: query, schema: { type: string } } + - { name: warehouseId, in: query, schema: { type: string } } + - { name: status, in: query, schema: { type: string } } + - { name: dispatchNo, in: query, schema: { type: string } } + - { name: waybillNo, in: query, schema: { type: string } } + - { name: vehiclePlate, in: query, schema: { type: string } } + - { name: driverName, in: query, schema: { type: string } } + - { name: deliveryMethod, in: query, schema: { type: string } } + - { name: plannedFrom, in: query, schema: { type: string, format: date-time } } + - { name: plannedTo, in: query, schema: { type: string, format: date-time } } + responses: { '200': { description: 当前客户派送单分页 }, '403': { description: 缺少客户派送查看权限 } } + post: + summary: 客户从自己的排车单创建派送单 + description: 权限 merchant:warehouse:delivery:create;客户范围只取认证 Token,dispatchNo 必填,不接收 customerId;同一幂等键或已转换排车重复调用返回既有单据。 + security: [{ bearerAuth: [] }] + parameters: [{ name: dispatchNo, in: query, required: true, schema: { type: string, minLength: 1 } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DeliveryTransfer' } } } } + responses: { '200': { description: 出库与派送单转换结果 }, '403': { description: 缺少客户派送创建权限 }, '404': { description: 当前客户下排车单不存在 }, '409': { description: 状态、参数或幂等冲突 } } + /api/merchant/v1/last-leg/delivery-orders/{deliveryNo}: + get: + summary: 客户查询自己的派送单详情 + description: 权限 merchant:warehouse:delivery:list;列表权限包含详情只读,客户范围只取认证 Token,不接收 customerId。 + security: [{ bearerAuth: [] }] + parameters: [{ name: deliveryNo, in: path, required: true, schema: { type: string } }] + responses: { '200': { description: 当前客户派送单详情 }, '403': { description: 缺少客户派送查看权限 }, '404': { description: 当前客户下派送单不存在 } } /api/merchant/v1/last-leg/overseas-procurements: post: summary: 客户创建海外采购订单 @@ -1246,6 +1335,69 @@ components: id: { type: string } capacity: { type: integer } palletIds: { type: array, items: { type: string } } + DispatchUpdate: + type: object + description: 普通编辑字段;不包含 status 或 departAt。 + properties: + vehiclePlate: { type: string, nullable: true, maxLength: 64 } + driverName: { type: string, nullable: true, maxLength: 128 } + driverPhone: { type: string, nullable: true, maxLength: 64 } + carrierName: { type: string, nullable: true, maxLength: 255 } + route: { type: string, nullable: true, maxLength: 255 } + fbaAppointmentNo: { type: string, nullable: true, maxLength: 64 } + fbaAppointmentStatus: { type: string, nullable: true, maxLength: 32 } + vehicleAppointmentStatus: { type: string, nullable: true, maxLength: 32 } + estimatedArrivalAt: { type: string, format: date-time, nullable: true } + DeliveryTransfer: + type: object + required: [idempotencyKey, plannedDeliveryAt, deliveryMethod, pickupMethod, loadingType, fees] + properties: + idempotencyKey: { type: string, minLength: 1, maxLength: 255 } + plannedDeliveryAt: { type: string, format: date-time } + deliveryMethod: { type: string, maxLength: 32 } + waybillNo: { type: string, nullable: true, maxLength: 128 } + carrierName: { type: string, nullable: true, maxLength: 255 } + pickupMethod: { type: string, maxLength: 64 } + loadingType: { type: string, maxLength: 64 } + vehicleType: { type: string, nullable: true, maxLength: 64 } + vehicleSize: { type: string, nullable: true, maxLength: 64 } + driverName: { type: string, nullable: true, maxLength: 128 } + driverPhone: { type: string, nullable: true, maxLength: 64 } + vehiclePlate: { type: string, nullable: true, maxLength: 64 } + appointmentAccount: { type: string, nullable: true, maxLength: 128 } + sellerName: { type: string, nullable: true, maxLength: 128 } + remark: { type: string, nullable: true, maxLength: 1000 } + fees: { type: array, minItems: 1, maxItems: 20, items: { $ref: '#/components/schemas/DeliveryFeeInput' } } + DeliveryFeeInput: + type: object + required: [feeType, currency, amount] + properties: + feeType: { type: string, maxLength: 64 } + currency: { type: string, maxLength: 8 } + amount: { type: number, minimum: 0 } + remark: { type: string, nullable: true, maxLength: 500 } + OneClickOutboundCreate: + type: object + required: [recipient, items, idempotencyKey] + properties: + recipient: { type: string, maxLength: 1000 } + remark: { type: string, nullable: true, maxLength: 1000 } + idempotencyKey: { type: string, minLength: 1, maxLength: 255 } + items: + type: array + minItems: 1 + maxItems: 200 + items: + type: object + required: [inventoryPositionId, packages, sku] + properties: + inventoryPositionId: { type: string, pattern: '^[1-9][0-9]*$' } + packages: { type: integer, format: int64, minimum: 1 } + sku: { type: string, maxLength: 128 } + OutboundCancel: + type: object + required: [reason] + properties: { reason: { type: string, minLength: 1, maxLength: 1000 } } DispatchResponse: type: object properties: diff --git a/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml index e455f6f48..346f4ebf8 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml @@ -2,10 +2,71 @@ openapi: 3.0.3 info: title: Lastleg Overseas PDA Warehouse Operations API version: 1.0.0 - description: 海外仓PDA盘点、同仓整托移库、托盘作业与换标现场作业;全部范围从PDA Token解析。 + description: 海外仓PDA排车拣货发车、一件代发出库、盘点、同仓整托移库、托盘与换标作业;全部范围从PDA Token解析,派送不进入PDA。 security: - bearerAuth: [] paths: + /api/pda/v1/last-leg/dispatches: + get: + summary: 查询Token绑定仓的排车PDA任务 + description: 仅 OVERSEAS_WAREHOUSE_PDA_OPERATOR + lastleg:pda:operate;跨客户读取绑定仓任务,不接收customerId或warehouseId。 + parameters: [{ name: status, in: query, required: false, schema: { type: string } }] + responses: { '200': { description: 排车任务列表 }, '403': { description: 角色、权限或仓库绑定缺失 } } + /api/pda/v1/last-leg/dispatches/{dispatchNo}: + get: + summary: 查询绑定仓排车任务详情 + parameters: [{ name: dispatchNo, in: path, required: true, schema: { type: string } }] + responses: { '200': { description: 排车托盘、目的仓与库存位置详情 }, '404': { description: 当前绑定仓无此排车任务 } } + /api/pda/v1/last-leg/dispatches/{dispatchNo}/items/{itemId}/pick: + post: + summary: 扫码拣取排车托盘包裹 + parameters: + - { name: dispatchNo, in: path, required: true, schema: { type: string } } + - { name: itemId, in: path, required: true, schema: { type: string, pattern: '^[0-9]+$' } } + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DispatchPick' } } } } + responses: { '200': { description: 本次及累计拣货结果;同键同请求重放既有结果 }, '409': { description: 跨仓、跨客户、库存、数量或幂等冲突 } } + /api/pda/v1/last-leg/dispatches/{dispatchNo}/complete: + post: + summary: 完成排车拣货 + description: 每个要求托盘及全部包裹必须精确完成;不允许部分完成。 + parameters: [{ name: dispatchNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/IdempotentAction' } } } } + responses: { '200': { description: READY_TO_DEPART结果 }, '409': { description: 未全部拣货或幂等冲突 } } + /api/pda/v1/last-leg/dispatches/{dispatchNo}/depart: + post: + summary: 排车发车并完成出库门禁 + description: 仅完整拣货且存在待出库单时发车;同事务推进出库与排车状态、订单归属并释放托盘锁,不重复扣减扫码时已扣库存。 + parameters: [{ name: dispatchNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DepartAction' } } } } + responses: { '200': { description: DEPARTED结果;同键同请求返回既有结果 }, '409': { description: 拣货、出库或幂等门禁未满足 } } + /api/pda/v1/last-leg/outbound-tasks: + get: + summary: 查询Token绑定仓的一件代发出库任务 + description: 一件代发复用统一出库任务模型;不接收customerId或warehouseId,派送不进入PDA。 + parameters: [{ name: status, in: query, required: false, schema: { type: string } }] + responses: { '200': { description: 出库任务列表 } } + /api/pda/v1/last-leg/outbound-tasks/{taskNo}: + get: + summary: 查询一件代发出库任务详情 + parameters: [{ name: taskNo, in: path, required: true, schema: { type: string } }] + responses: + '200': + description: 出库任务、出库项及每条可拣库存位置;inventoryPositionId仅作为pick提交键,不作为用户展示编号 + content: { application/json: { schema: { $ref: '#/components/schemas/PdaOutboundTaskDetail' } } } + '404': { description: 当前绑定仓无此任务 } + /api/pda/v1/last-leg/outbound-tasks/{taskNo}/pick: + post: + summary: 扫码拣取一件代发箱件 + parameters: [{ name: taskNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/OutboundPick' } } } } + responses: { '200': { description: 更新后的统一出库任务 }, '409': { description: 库存、数量或幂等冲突 } } + /api/pda/v1/last-leg/outbound-tasks/{taskNo}/complete: + post: + summary: 完成一件代发出库任务 + description: 全部托盘及箱件精确拣完后完成;同一完成幂等键返回既有结果,其他键明确冲突。 + parameters: [{ name: taskNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/IdempotentAction' } } } } + responses: { '200': { description: COMPLETED任务 }, '409': { description: 未全部拣货或幂等冲突 } } /api/pda/v1/last-leg/stocktakes: get: summary: 查询Token绑定仓盘点任务 @@ -128,6 +189,68 @@ components: type: object required: [idempotencyKey] properties: { idempotencyKey: { type: string, minLength: 1, maxLength: 255 } } + DepartAction: + type: object + required: [idempotencyKey] + properties: + idempotencyKey: { type: string, minLength: 1, maxLength: 255 } + remark: { type: string, maxLength: 1000, nullable: true } + DispatchPick: + type: object + required: [positionId, packages, productQuantity, idempotencyKey] + properties: + positionId: { type: string, pattern: '^[0-9]+$' } + packages: { type: integer, format: int64, minimum: 1 } + productQuantity: { type: integer, format: int64, minimum: 0 } + idempotencyKey: { type: string, minLength: 1, maxLength: 255 } + remark: { type: string, maxLength: 1000, nullable: true } + OutboundPick: + type: object + required: [outboundPalletItemId, inventoryPositionId, packages, idempotencyKey] + properties: + outboundPalletItemId: { type: string, pattern: '^[0-9]+$' } + inventoryPositionId: { type: string, pattern: '^[0-9]+$' } + packages: { type: integer, format: int64, minimum: 1 } + idempotencyKey: { type: string, minLength: 1, maxLength: 255 } + PdaOutboundTaskDetail: + type: object + required: [task, items] + properties: + task: + type: object + required: [taskNo, status, outboundNo] + properties: + taskNo: { type: string } + status: { type: string } + outboundNo: { type: string } + pushedAt: { type: string, format: date-time, nullable: true } + completedAt: { type: string, format: date-time, nullable: true } + items: + type: array + items: + type: object + required: [outboundPalletItemId, palletNo, skuCode, inboundCode, packageCount, pickedPackageCount, status, positions] + properties: + outboundPalletItemId: { type: string, description: pick请求提交键,不作为用户展示编号 } + palletNo: { type: string } + skuCode: { type: string } + inboundCode: { type: string } + packageCount: { type: integer } + pickedPackageCount: { type: integer } + status: { type: string } + positions: + type: array + items: + type: object + required: [inventoryPositionId, palletNo, cartonMark, skuCode, inboundCode, availablePackages, pickedPackages] + properties: + inventoryPositionId: { type: string, description: pick请求提交键,不作为用户展示编号 } + palletNo: { type: string } + cartonMark: { type: string, nullable: true } + skuCode: { type: string } + inboundCode: { type: string } + availablePackages: { type: integer, format: int64, minimum: 0 } + pickedPackages: { type: integer, format: int64, minimum: 0 } StocktakeCreate: type: object required: [scopeType, scopeCode, idempotencyKey] diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/Phase5PdaDeploymentSqlContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/Phase5PdaDeploymentSqlContractTest.java new file mode 100644 index 000000000..4907ad79b --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/Phase5PdaDeploymentSqlContractTest.java @@ -0,0 +1,42 @@ +package com.baiyue.lastleg.delivery; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class Phase5PdaDeploymentSqlContractTest { + @Test + void overseasUnifiedMigrationConvergesPhase5IdentifiersWithoutBackfill() throws Exception { + String sql = Files.readString(Path.of("..", "deploy", "sql", "海外区域统一增量-MySQL8.sql"), + StandardCharsets.UTF_8); + int start = sql.indexOf("-- 五期排车、出库与海外PDA正式业务号/幂等约束"); + int end = sql.indexOf("USE baiyue_lastleg;", start); + assertTrue(start >= 0 && end > start); + String phase5 = sql.substring(start, end); + + assertTrue(phase5.contains("assert_phase5_lastleg_identifiers_migratable")); + assertTrue(phase5.contains("assert_phase5_lastleg_identifiers_ready")); + assertTrue(phase5.contains("warehouse_id IS NULL OR TRIM(warehouse_id)='' OR TRIM(warehouse_id)='0'")); + assertTrue(phase5.contains("SIGNAL SQLSTATE '45000'")); + assertTrue(phase5.contains("MODIFY COLUMN warehouse_id VARCHAR(128) NOT NULL")); + assertTrue(phase5.contains("ADD COLUMN completion_idempotency_key VARCHAR(255) NULL")); + assertTrue(phase5.contains("ADD UNIQUE KEY uk_ll_dispatch_order_no(dispatch_no)")); + assertTrue(phase5.contains("ADD UNIQUE KEY uk_ll_outbound_no(outbound_no)")); + assertTrue(phase5.contains("ADD UNIQUE KEY uk_ll_dispatch_pda_task_no(task_no)")); + assertTrue(phase5.contains("ADD UNIQUE KEY uk_ll_outbound_pda_task_no(task_no)")); + assertTrue(phase5.contains("ADD UNIQUE KEY uk_ll_outbound_pda_complete_key(customer_id,completion_idempotency_key)")); + assertFalse(Pattern.compile("(?im)^\\s*UPDATE\\s+").matcher(phase5).find()); + assertFalse(phase5.contains("JOIN ll_dispatch_order")); + + assertFalse(sql.contains("uk_ll_dispatch_order_no(customer_id,dispatch_no)")); + assertFalse(sql.contains("uk_ll_outbound_no(customer_id,outbound_no)")); + assertFalse(sql.contains("uk_ll_dispatch_pda_task_no(customer_id,task_no)")); + assertFalse(sql.contains("uk_ll_outbound_pda_task_no(customer_id,task_no)")); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/PlatformDispatchOutboundContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/PlatformDispatchOutboundContractTest.java new file mode 100644 index 000000000..963d52f15 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/PlatformDispatchOutboundContractTest.java @@ -0,0 +1,166 @@ +package com.baiyue.lastleg.delivery; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.delivery.controller.DeliveryTransferController; +import com.baiyue.lastleg.delivery.dto.DeliveryRequests; +import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; +import com.baiyue.lastleg.dispatch.controller.DispatchController; +import com.baiyue.lastleg.dispatch.dto.DispatchRequests; +import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper; +import com.baiyue.lastleg.security.OverseasPermissions; +import org.apache.ibatis.annotations.Select; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.server.ResponseStatusException; + +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PlatformDispatchOutboundContractTest { + @Test + void platformActionsHaveIndependentPermissionsAndNoCustomerParameter() throws Exception { + Method update = DispatchController.class.getMethod("update", AuthenticatedUser.class, + String.class, DispatchRequests.Update.class); + Method cancel = DispatchController.class.getMethod("cancel", AuthenticatedUser.class, String.class); + Method transfer = DeliveryTransferController.class.getMethod("transfer", AuthenticatedUser.class, + String.class, DeliveryRequests.Transfer.class); + Method outboundDetail = DeliveryTransferController.class.getMethod("outboundDetail", + AuthenticatedUser.class, String.class, DeliveryRequests.PalletQuery.class); + Method outboundCancel = DeliveryTransferController.class.getMethod("cancelOutbound", + AuthenticatedUser.class, String.class, DeliveryRequests.Cancel.class); + + assertPermission(update, OverseasPermissions.DISPATCH_EDIT); + assertPermission(cancel, OverseasPermissions.DISPATCH_CANCEL); + assertPermission(transfer, OverseasPermissions.DISPATCH_TRANSFER_DELIVERY); + assertPermission(outboundDetail, OverseasPermissions.OUTBOUND_LIST); + assertPermission(outboundCancel, OverseasPermissions.OUTBOUND_CANCEL); + for (Method method : Set.of(update, cancel, transfer, outboundDetail, outboundCancel)) { + assertTrue(Arrays.stream(method.getParameters()) + .noneMatch(parameter -> parameter.isAnnotationPresent(RequestParam.class))); + } + assertTrue(Arrays.stream(DispatchController.class.getDeclaredMethods()) + .noneMatch(method -> "depart".equals(method.getName()))); + assertEquals(Set.of("vehiclePlate", "driverName", "driverPhone", "carrierName", "route", + "fbaAppointmentNo", "fbaAppointmentStatus", "vehicleAppointmentStatus", "estimatedArrivalAt"), + Arrays.stream(DispatchRequests.Update.class.getRecordComponents()) + .map(component -> component.getName()).collect(java.util.stream.Collectors.toSet())); + } + + @Test + void oneClickRequiresCustomerWhileOutboundListKeepsOptionalFilter() throws Exception { + Method create = DeliveryTransferController.class.getMethod("createOneClick", AuthenticatedUser.class, + String.class, DeliveryRequests.OneClickOutbound.class); + Method page = DeliveryTransferController.class.getMethod("outboundPage", AuthenticatedUser.class, + String.class, DeliveryRequests.Query.class); + + assertPermission(create, OverseasPermissions.OUTBOUND_CREATE); + assertPermission(page, OverseasPermissions.OUTBOUND_LIST); + RequestParam createCustomer = create.getParameters()[1].getAnnotation(RequestParam.class); + RequestParam pageCustomer = page.getParameters()[1].getAnnotation(RequestParam.class); + assertNotNull(createCustomer); + assertTrue(createCustomer.required()); + assertNotNull(pageCustomer); + assertFalse(pageCustomer.required()); + + Method deliveryPage = DeliveryTransferController.class.getMethod("deliveryPage", AuthenticatedUser.class, + String.class, DeliveryRequests.Query.class); + RequestParam deliveryCustomer = deliveryPage.getParameters()[1].getAnnotation(RequestParam.class); + assertNotNull(deliveryCustomer); + assertFalse(deliveryCustomer.required()); + } + + @Test + void platformActionsRejectMerchantIdentityBeforeBusinessLookup() { + AuthenticatedUser merchant = new AuthenticatedUser(7L, 100L, 100L, 8L, "merchant", + "MERCHANT", "WEB", Set.of()); + DispatchController dispatchController = new DispatchController(); + DeliveryTransferController deliveryController = new DeliveryTransferController(); + + ResponseStatusException dispatchRejected = org.junit.jupiter.api.Assertions.assertThrows( + ResponseStatusException.class, + () -> dispatchController.cancel(merchant, "DSP-101")); + ResponseStatusException outboundRejected = org.junit.jupiter.api.Assertions.assertThrows( + ResponseStatusException.class, + () -> deliveryController.outboundDetail(merchant, "OUT-101", + new DeliveryRequests.PalletQuery(1, 20, null))); + + assertEquals(403, dispatchRejected.getStatusCode().value()); + assertEquals(403, outboundRejected.getStatusCode().value()); + } + + @Test + void globalBusinessNumberMappersAndSchemaDoNotUseCustomerForIdentity() throws Exception { + String dispatchFind = select(DispatchOrderMapper.class, "findGlobalByDispatchNo", String.class); + String dispatchLock = select(DispatchOrderMapper.class, "lockGlobalByDispatchNo", String.class); + String outboundFind = select(OutboundOrderMapper.class, "findGlobalByNo", String.class); + String outboundLock = select(OutboundOrderMapper.class, "lockGlobalByNo", String.class); + + for (String sql : Set.of(dispatchFind, dispatchLock, outboundFind, outboundLock)) { + assertFalse(sql.contains("customer_id")); + } + assertTrue(dispatchLock.contains("FOR UPDATE")); + assertTrue(outboundLock.contains("FOR UPDATE")); + + String schema = Files.readString(Path.of("src/main/resources/db/lastleg.sql"), StandardCharsets.UTF_8); + assertTrue(schema.contains("UNIQUE KEY uk_ll_dispatch_order_no (dispatch_no)")); + assertTrue(schema.contains("UNIQUE KEY uk_ll_outbound_no (outbound_no)")); + assertFalse(schema.contains("uk_ll_dispatch_order_no (customer_id,dispatch_no)")); + assertFalse(schema.contains("uk_ll_outbound_no (customer_id,outbound_no)")); + } + + @Test + void openApiPublishesOnlyTheFrozenPlatformProtocol() throws Exception { + String yaml = Files.readString(Path.of("src/main/resources/openapi/overseas-business-v1.yaml"), + StandardCharsets.UTF_8); + String dispatchAction = slice(yaml, " /api/platform/v1/last-leg/dispatches/{dispatchNo}:", + " /api/platform/v1/last-leg/dispatches/candidates:"); + String transfer = slice(yaml, + " /api/platform/v1/last-leg/dispatches/{dispatchNo}/transfer-delivery:", + " /api/platform/v1/last-leg/outbound-orders:"); + String outboundDetail = slice(yaml, + " /api/platform/v1/last-leg/outbound-orders/{outboundNo}:", + " /api/platform/v1/last-leg/outbound-orders/{outboundNo}/cancel:"); + String outboundCancel = slice(yaml, + " /api/platform/v1/last-leg/outbound-orders/{outboundNo}/cancel:", + " /api/platform/v1/last-leg/overseas-procurements:"); + + for (String action : Set.of(dispatchAction, transfer, outboundDetail, outboundCancel)) { + assertFalse(action.contains("name: customerId")); + } + assertTrue(yaml.contains("platform:overseas-warehouse:dispatch:edit")); + assertTrue(yaml.contains("platform:overseas-warehouse:dispatch:cancel")); + assertTrue(yaml.contains("platform:overseas-warehouse:dispatch:transfer-delivery")); + assertTrue(yaml.contains("platform:overseas-warehouse:outbound:list")); + assertTrue(yaml.contains("platform:overseas-warehouse:outbound:create")); + assertTrue(yaml.contains("platform:overseas-warehouse:outbound:cancel")); + } + + private static void assertPermission(Method method, String permission) { + PreAuthorize annotation = method.getAnnotation(PreAuthorize.class); + assertNotNull(annotation); + assertEquals("hasAuthority('" + permission + "')", annotation.value()); + } + + private static String select(Class mapper, String method, Class... parameterTypes) throws Exception { + Select annotation = mapper.getMethod(method, parameterTypes).getAnnotation(Select.class); + assertNotNull(annotation); + return String.join("", annotation.value()); + } + + private static String slice(String source, String start, String end) { + int from = source.indexOf(start); + int to = source.indexOf(end, from + start.length()); + assertTrue(from >= 0 && to > from); + return source.substring(from, to); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryControllerTest.java new file mode 100644 index 000000000..6c4e33bd2 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryControllerTest.java @@ -0,0 +1,139 @@ +package com.baiyue.lastleg.delivery.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.delivery.dto.DeliveryRequests; +import com.baiyue.lastleg.delivery.service.DeliveryTransferService; +import com.baiyue.lastleg.security.OverseasPermissions; +import jakarta.validation.Validation; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.server.ResponseStatusException; + +import java.lang.reflect.Method; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +class MerchantDeliveryControllerTest { + private final DeliveryTransferService service = mock(DeliveryTransferService.class); + private final MerchantDeliveryController controller = controller(); + private final DeliveryRequests.Query query = new DeliveryRequests.Query( + 1, 20, null, null, null, null, null, null, null, null, null, null); + + @Test + void fixedRoutesUseIndependentListAndCreatePermissionsWithoutCustomerParameter() throws Exception { + RequestMapping mapping = MerchantDeliveryController.class.getAnnotation(RequestMapping.class); + assertEquals(List.of("/api/merchant/v1/last-leg/delivery-orders"), List.of(mapping.value())); + + Method page = MerchantDeliveryController.class.getMethod("page", AuthenticatedUser.class, + DeliveryRequests.Query.class); + Method create = MerchantDeliveryController.class.getMethod("create", AuthenticatedUser.class, + String.class, DeliveryRequests.Transfer.class); + Method detail = MerchantDeliveryController.class.getMethod("detail", AuthenticatedUser.class, String.class); + + assertPermission(page, OverseasPermissions.MERCHANT_DELIVERY_LIST); + assertPermission(detail, OverseasPermissions.MERCHANT_DELIVERY_LIST); + assertPermission(create, OverseasPermissions.MERCHANT_DELIVERY_CREATE); + assertNotEquals(OverseasPermissions.MERCHANT_DELIVERY_LIST, + OverseasPermissions.MERCHANT_DELIVERY_CREATE); + assertTrue(Arrays.stream(page.getParameters()).noneMatch(this::isCustomerParameter)); + assertTrue(Arrays.stream(detail.getParameters()).noneMatch(this::isCustomerParameter)); + assertTrue(Arrays.stream(create.getParameters()).noneMatch(this::isCustomerParameter)); + assertTrue(Arrays.stream(DeliveryRequests.Transfer.class.getRecordComponents()) + .noneMatch(component -> "customerId".equals(component.getName()))); + } + + @Test + void allOperationsUseOnlyAuthenticatedCustomerScope() { + AuthenticatedUser merchant = user(100L, "MERCHANT"); + DeliveryRequests.Transfer request = transferRequest(); + + controller.page(merchant, query); + controller.detail(merchant, "DEL-100"); + controller.create(merchant, "DSP-100", request); + + verify(service).deliveryPage("100", query); + verify(service).deliveryDetail("100", "DEL-100"); + verify(service).merchantTransfer("100", "DSP-100", "1", request); + } + + @Test + void merchantEndpointsRejectPlatformIdentity() { + AuthenticatedUser platform = user(0L, "PLATFORM"); + + assertThrows(ResponseStatusException.class, () -> controller.page(platform, query)); + assertThrows(ResponseStatusException.class, () -> controller.detail(platform, "DEL-100")); + assertThrows(ResponseStatusException.class, () -> controller.create(platform, "DSP-100", transferRequest())); + } + + @Test + void invalidCreatePayloadIsRejectedByBeanValidationContract() { + DeliveryRequests.Transfer invalid = new DeliveryRequests.Transfer(" ", null, " ", null, null, + " ", " ", null, null, null, null, null, null, null, null, null); + try (var factory = Validation.buildDefaultValidatorFactory()) { + assertTrue(factory.getValidator().validate(invalid).size() >= 6); + } + } + + @Test + void openApiPublishesOnlyTokenScopedMerchantDeliveryProtocol() throws Exception { + String yaml = Files.readString(Path.of("src/main/resources/openapi/overseas-business-v1.yaml"), + StandardCharsets.UTF_8); + String contract = slice(yaml, " /api/merchant/v1/last-leg/delivery-orders:", + " /api/merchant/v1/last-leg/overseas-procurements:"); + + assertTrue(contract.contains("merchant:warehouse:delivery:list")); + assertTrue(contract.contains("merchant:warehouse:delivery:create")); + assertFalse(contract.contains("name: customerId")); + } + + private boolean isCustomerParameter(java.lang.reflect.Parameter parameter) { + RequestParam annotation = parameter.getAnnotation(RequestParam.class); + return annotation != null && ("customerId".equals(annotation.name()) + || "customerId".equals(annotation.value())); + } + + private static void assertPermission(Method method, String permission) { + PreAuthorize annotation = method.getAnnotation(PreAuthorize.class); + assertEquals("hasAuthority('" + permission + "')", annotation.value()); + } + + private static String slice(String source, String start, String end) { + int from = source.indexOf(start); + int to = source.indexOf(end, from + start.length()); + assertTrue(from >= 0 && to > from); + return source.substring(from, to); + } + + private static AuthenticatedUser user(long tenantId, String appCode) { + return new AuthenticatedUser(1L, tenantId, tenantId, 9L, "merchant", appCode, "WEB", Set.of()); + } + + private MerchantDeliveryController controller() { + MerchantDeliveryController value = new MerchantDeliveryController(); + ReflectionTestUtils.setField(value, "service", service); + return value; + } + + private static DeliveryRequests.Transfer transferRequest() { + return new DeliveryRequests.Transfer("merchant-key", LocalDateTime.now(), "SELF_DELIVERY", null, null, + "ONE_PICKUP_ONE_DROP", "PALLET", null, null, null, null, null, null, null, null, + List.of(new DeliveryRequests.FeeItem("DELIVERY_FEE", "USD", BigDecimal.ZERO, null))); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java index 17703c2da..c0c0aeb6b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java @@ -6,11 +6,14 @@ import com.baiyue.lastleg.delivery.dto.DeliveryResponses; import com.baiyue.lastleg.delivery.entity.DeliveryOrder; import com.baiyue.lastleg.delivery.entity.OutboundOrder; import com.baiyue.lastleg.delivery.entity.OutboundPalletItem; +import com.baiyue.lastleg.delivery.entity.OutboundPdaTask; import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper; import com.baiyue.lastleg.delivery.mapper.DeliveryFeeMapper; import com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper; import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper; +import com.baiyue.lastleg.delivery.mapper.OutboundPdaTaskMapper; +import com.baiyue.lastleg.delivery.mapper.OutboundPdaPickMapper; import com.baiyue.lastleg.dispatch.entity.DispatchItem; import com.baiyue.lastleg.dispatch.entity.DispatchOrder; import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper; @@ -23,6 +26,14 @@ import com.baiyue.lastleg.mapper.LastlegOrderMapper; import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper; import com.baiyue.lastleg.service.LastlegOrderNoGenerator; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.inventory.entity.InventoryBalance; +import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper; +import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile; +import com.baiyue.lastleg.dropship.entity.DropshipCarton; +import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper; +import com.baiyue.lastleg.dropship.mapper.DropshipCartonMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -30,6 +41,7 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.annotation.Transactional; import org.springframework.web.server.ResponseStatusException; import java.math.BigDecimal; @@ -59,6 +71,12 @@ class DeliveryTransferServiceTest { @Mock LastlegOrderMapper tailOrders; @Mock LastlegOrderStatusHistoryMapper histories; @Mock LastlegOrderDetailMapper tailOrderDetails; + @Mock OutboundPdaTaskMapper pdaTasks; + @Mock OutboundPdaPickMapper pdaPicks; + @Mock InventoryPositionMapper inventoryPositions; + @Mock InventoryBalanceMapper inventoryBalances; + @Mock DropshipOrderProfileMapper dropshipProfiles; + @Mock DropshipCartonMapper dropshipCartons; private DeliveryTransferService service; @@ -78,19 +96,24 @@ class DeliveryTransferServiceTest { ReflectionTestUtils.setField(service, "tailOrders", tailOrders); ReflectionTestUtils.setField(service, "histories", histories); ReflectionTestUtils.setField(service, "tailOrderDetails", tailOrderDetails); + ReflectionTestUtils.setField(service, "pdaTasks", pdaTasks); + ReflectionTestUtils.setField(service, "pdaPicks", pdaPicks); + ReflectionTestUtils.setField(service, "inventoryPositions", inventoryPositions); + ReflectionTestUtils.setField(service, "inventoryBalances", inventoryBalances); + ReflectionTestUtils.setField(service, "dropshipProfiles", dropshipProfiles); + ReflectionTestUtils.setField(service, "dropshipCartons", dropshipCartons); } @Test void firstTransferCreatesOneOutboundOneDeliveryAndAllPalletSnapshots() { DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A"); - when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-101")).thenReturn(dispatch); - when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch); + when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch); when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of( item(201L, 501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12), item(202L, 502L, "PLT-502", "LAX9", "Los Angeles", "IN-B", 8) )); - DeliveryResponses.TransferResult result = service.transfer("TENANT-A", "DSP-101", "U-1", + DeliveryResponses.TransferResult result = service.platformTransfer("DSP-101", "U-1", transferRequest("transfer-101", "ready")); ArgumentCaptor outbound = ArgumentCaptor.forClass(OutboundOrder.class); @@ -122,12 +145,11 @@ class DeliveryTransferServiceTest { DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A"); OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-101"); DeliveryOrder delivery = delivery(401L, outbound, "DEL-401"); - when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-101")).thenReturn(dispatch); - when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch); + when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch); when(outboundOrders.findByIdempotency("TENANT-A", "transfer-101")).thenReturn(outbound); when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(delivery); - DeliveryResponses.TransferResult result = service.transfer("TENANT-A", "DSP-101", "U-1", + DeliveryResponses.TransferResult result = service.platformTransfer("DSP-101", "U-1", transferRequest(" transfer-101 ", null)); assertTrue(result.replayed()); @@ -142,12 +164,11 @@ class DeliveryTransferServiceTest { void idempotencyKeyCannotBeReusedForAnotherDispatch() { DispatchOrder dispatch = dispatch(102L, "DSP-102", "TENANT-A"); OutboundOrder other = outbound(301L, 101L, "DSP-101", "OUT-301", "shared-key"); - when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-102")).thenReturn(dispatch); - when(dispatchOrders.lockById("TENANT-A", 102L)).thenReturn(dispatch); + when(dispatchOrders.lockGlobalByDispatchNo("DSP-102")).thenReturn(dispatch); when(outboundOrders.findByIdempotency("TENANT-A", "shared-key")).thenReturn(other); ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> - service.transfer("TENANT-A", "DSP-102", "U-1", transferRequest("shared-key", null))); + service.platformTransfer("DSP-102", "U-1", transferRequest("shared-key", null))); assertEquals(409, exception.getStatusCode().value()); verify(outboundOrders, never()).insert((OutboundOrder) any()); @@ -158,15 +179,14 @@ class DeliveryTransferServiceTest { DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A"); OutboundOrder winner = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-101"); DeliveryOrder delivery = delivery(401L, winner, "DEL-401"); - when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-101")).thenReturn(dispatch); - when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch); + when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch); when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of( item(201L, 501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12))); when(outboundOrders.insert(any(OutboundOrder.class))).thenThrow(new org.springframework.dao.DuplicateKeyException("duplicate")); when(outboundOrders.findByIdempotency("TENANT-A", "transfer-101")).thenReturn(null, winner); when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(delivery); - DeliveryResponses.TransferResult result = service.transfer("TENANT-A", "DSP-101", "U-1", + DeliveryResponses.TransferResult result = service.platformTransfer("DSP-101", "U-1", transferRequest("transfer-101", null)); assertTrue(result.replayed()); @@ -183,12 +203,11 @@ class DeliveryTransferServiceTest { source.setOrderId(901L); LastlegOrder tailOrder = new LastlegOrder(); tailOrder.setId(901L); tailOrder.setCustomerId("TENANT-A"); tailOrder.setCurrentStatus("PENDING_ASSIGNMENT"); - when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-101")).thenReturn(dispatch); - when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch); + when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch); when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(source)); when(tailOrders.selectList(any())).thenReturn(List.of(tailOrder)); - service.transfer("TENANT-A", "DSP-101", "U-1", transferRequest("direct-order", null)); + service.platformTransfer("DSP-101", "U-1", transferRequest("direct-order", null)); assertEquals("WAIT_OUTBOUND", tailOrder.getCurrentStatus()); assertNotNull(tailOrder.getOutboundOrderNo()); @@ -204,12 +223,11 @@ class DeliveryTransferServiceTest { DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A"); DispatchItem planned = item(201L, null, "PLAN-1", "ONT8", "Ontario", "IN-A", 12); planned.setSourceStage("PLAN"); - when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-101")).thenReturn(dispatch); - when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch); + when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch); when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(planned)); ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> - service.transfer("TENANT-A", "DSP-101", "U-1", transferRequest("transfer-101", null))); + service.platformTransfer("DSP-101", "U-1", transferRequest("transfer-101", null))); assertEquals(409, exception.getStatusCode().value()); assertTrue(exception.getReason().contains("received")); @@ -220,10 +238,10 @@ class DeliveryTransferServiceTest { void outboundDetailDoesNotExposeExcludedPalletOrOutboundFulfillmentRows() { OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-101"); DeliveryOrder delivery = delivery(401L, outbound, "DEL-401"); - when(outboundOrders.findByNo("TENANT-A", "OUT-301")).thenReturn(outbound); + when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound); when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(delivery); - DeliveryResponses.OutboundDetail detail = service.outboundDetail("TENANT-A", "OUT-301", + DeliveryResponses.OutboundDetail detail = service.outboundDetail("OUT-301", new DeliveryRequests.PalletQuery(1, 20, null)); assertEquals("OUT-301", detail.outbound().outboundNo()); @@ -234,16 +252,216 @@ class DeliveryTransferServiceTest { } @Test - void detailLookupCannotCrossTenantBoundary() { - when(outboundOrders.findByNo("TENANT-B", "OUT-301")).thenReturn(null); + void globalOutboundDetailUsesTheCustomerResolvedFromTheBusinessNumber() { + OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-101"); + DeliveryOrder delivery = delivery(401L, outbound, "DEL-401"); + when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound); + when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(delivery); + + DeliveryResponses.OutboundDetail detail = service.outboundDetail("OUT-301", + new DeliveryRequests.PalletQuery(1, 20, null)); + + assertEquals("OUT-301", detail.outbound().outboundNo()); + verify(deliveryOrders).lockByOutbound("TENANT-A", 301L); + } + + @Test + void merchantTransferCannotResolveAnotherCustomersDispatch() { + when(dispatchOrders.findByDispatchNo("TENANT-B", "DSP-101")).thenReturn(null); ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> - service.outboundDetail("TENANT-B", "OUT-301", new DeliveryRequests.PalletQuery(1, 20, null))); + service.merchantTransfer("TENANT-B", "DSP-101", "U-2", transferRequest("merchant-key", null))); assertEquals(404, exception.getStatusCode().value()); - verify(outboundOrders).findByNo("TENANT-B", "OUT-301"); - verify(deliveryOrders, never()).lockByOutbound(any(), any()); - verify(outboundPallets, never()).selectPage(any(), any()); + verify(dispatchOrders, never()).lockGlobalByDispatchNo(any()); + verify(outboundOrders, never()).insert((OutboundOrder) any()); + } + + @Test + void merchantTransferReplaysExistingDocumentsWithinAuthenticatedCustomer() { + DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A"); + OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "merchant-key"); + DeliveryOrder delivery = delivery(401L, outbound, "DEL-401"); + when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-101")).thenReturn(dispatch); + when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch); + when(outboundOrders.findByIdempotency("TENANT-A", "merchant-key")).thenReturn(outbound); + when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(delivery); + + DeliveryResponses.TransferResult result = service.merchantTransfer("TENANT-A", "DSP-101", "U-2", + transferRequest(" merchant-key ", null)); + + assertTrue(result.replayed()); + assertEquals("DEL-401", result.deliveryNo()); + verify(outboundOrders, never()).insert((OutboundOrder) any()); + } + + @Test + void merchantTransferRejectsCancelledDispatch() { + DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A"); + dispatch.setStatus("CANCELLED"); + when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-101")).thenReturn(dispatch); + when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> + service.merchantTransfer("TENANT-A", "DSP-101", "U-2", transferRequest("merchant-key", null))); + + assertEquals(409, exception.getStatusCode().value()); + verify(outboundOrders, never()).insert((OutboundOrder) any()); + } + + @Test + void merchantDeliveryDetailCannotCrossCustomerBoundary() { + when(deliveryOrders.findByNo("TENANT-B", "DEL-401")).thenReturn(null); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> service.deliveryDetail("TENANT-B", "DEL-401")); + + assertEquals(404, exception.getStatusCode().value()); + verify(dispatchItems, never()).findByDispatchOrder(any(), any()); + } + + @Test + void globalOutboundCancelIsIdempotentForAnUnexecutedOrder() { + OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "one-click-key"); + outbound.setStatus("WAIT_PICK"); + OutboundPalletItem item = pallet(501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12); + item.setStatus("WAIT_PICK"); item.setPickedPackageCount(0); + OutboundPdaTask task = new OutboundPdaTask(); + task.setId(701L); task.setCustomerId("TENANT-A"); task.setOutboundOrderId(301L); task.setStatus("PENDING"); + when(outboundOrders.lockGlobalByNo("OUT-301")).thenReturn(outbound); + when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(item)); + when(pdaTasks.lockByOutbound("TENANT-A", 301L)).thenReturn(task); + + service.cancelOutbound("OUT-301", new DeliveryRequests.Cancel("cancelled by platform")); + service.cancelOutbound("OUT-301", new DeliveryRequests.Cancel("duplicate command")); + + assertEquals("CANCELLED", outbound.getStatus()); + assertEquals("CANCELLED", item.getStatus()); + assertEquals("CANCELLED", task.getStatus()); + verify(outboundPallets, org.mockito.Mockito.times(1)).findByOutbound("TENANT-A", 301L); + } + + @Test + void globalOutboundCancelRejectsAnExecutedOrderWithoutDrift() { + OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-key"); + outbound.setStatus("PICKED"); + when(outboundOrders.lockGlobalByNo("OUT-301")).thenReturn(outbound); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> + service.cancelOutbound("OUT-301", new DeliveryRequests.Cancel("too late"))); + + assertEquals(409, exception.getStatusCode().value()); + assertEquals("PICKED", outbound.getStatus()); + verify(outboundOrders, never()).updateById(any(OutboundOrder.class)); + verify(outboundPallets, never()).findByOutbound(any(), any()); + } + + @Test + void outboundPdaListAndDetailUseOnlyBoundWarehouse() throws Exception { + assertNotNull(DeliveryTransferService.class.getMethod("pdaPick", String.class, String.class, + String.class, DeliveryRequests.PdaPick.class).getAnnotation(Transactional.class)); + OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "one-click-key"); + outbound.setOverseasWarehouseId("WH-1"); + OutboundPdaTask task = new OutboundPdaTask(); + task.setId(701L); task.setCustomerId("TENANT-A"); task.setOutboundOrderId(301L); + task.setTaskNo("PDA-701"); task.setStatus("PENDING"); + when(pdaTasks.listByWarehouse("WH-1", null)).thenReturn(List.of(task)); + when(pdaTasks.findByNoAndWarehouse("PDA-701", "WH-1")).thenReturn(task); + when(outboundOrders.selectById(301L)).thenReturn(outbound); + when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of()); + + assertEquals(1, service.pdaTasks("WH-1", null).size()); + assertEquals("PDA-701", service.pdaTaskDetail("WH-1", "PDA-701").task().taskNo()); + verify(pdaTasks).listByWarehouse("WH-1", null); + } + + @Test + void outboundPdaDetailReturnsEveryPickablePositionWithStableBusinessCodes() { + OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "one-click-key"); + outbound.setOverseasWarehouseId("WH-1"); + OutboundPdaTask task = new OutboundPdaTask(); + task.setId(701L); task.setCustomerId("TENANT-A"); task.setOutboundOrderId(301L); + task.setTaskNo("PDA-701"); task.setStatus("PENDING"); + OutboundPalletItem item = pallet(501L, "PLT-501", "ONT8", "Ontario", "IN-A", 2); + item.setSkuCode("SKU-1"); item.setInboundCode("IN-A"); + InventoryPosition position = new InventoryPosition(); + position.setId(901L); position.setCustomerId("TENANT-A"); position.setBalanceId(801L); + position.setOverseasWarehouseId("WH-1"); position.setPalletId(501L); position.setPalletNo("PLT-501"); + position.setAvailablePackages(2L); position.setPickedPackages(0L); + InventoryBalance balance = new InventoryBalance(); + balance.setId(801L); balance.setCustomerId("TENANT-A"); balance.setOverseasWarehouseId("WH-1"); + balance.setSkuCode("SKU-1"); balance.setInboundCode("IN-A"); + DropshipOrderProfile profile = new DropshipOrderProfile(); profile.setId(1001L); + DropshipCarton first = new DropshipCarton(); first.setStatus("AVAILABLE"); first.setSkuCode("SKU-1"); first.setCartonMark("CTN-A"); + DropshipCarton second = new DropshipCarton(); second.setStatus("AVAILABLE"); second.setSkuCode("SKU-1"); second.setCartonMark("CTN-B"); + when(pdaTasks.findByNoAndWarehouse("PDA-701", "WH-1")).thenReturn(task); + when(outboundOrders.selectById(301L)).thenReturn(outbound); + when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(item)); + when(inventoryPositions.listPallet("TENANT-A", 501L)).thenReturn(List.of(position)); + when(inventoryBalances.selectById(801L)).thenReturn(balance); + when(dropshipProfiles.findPosition("TENANT-A", 901L)).thenReturn(profile); + when(dropshipCartons.listProfile("TENANT-A", 1001L)).thenReturn(List.of(first, second)); + + DeliveryResponses.PdaTaskDetail detail = service.pdaTaskDetail("WH-1", "PDA-701"); + + assertEquals(2, detail.items().get(0).positions().size()); + assertEquals(List.of("CTN-A", "CTN-B"), detail.items().get(0).positions().stream() + .map(DeliveryResponses.PdaPickPosition::cartonMark).toList()); + DeliveryResponses.PdaPickPosition projected = detail.items().get(0).positions().get(0); + assertEquals(901L, projected.inventoryPositionId()); + assertEquals("PLT-501", projected.palletNo()); + assertEquals("SKU-1", projected.skuCode()); + assertEquals("IN-A", projected.inboundCode()); + assertEquals(1L, projected.availablePackages()); + } + + @Test + void outboundPdaPickReplayDoesNotDeductInventoryAgainAndRejectsChangedRequest() { + OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "one-click-key"); + outbound.setOverseasWarehouseId("WH-1"); + OutboundPdaTask task = new OutboundPdaTask(); + task.setId(701L); task.setCustomerId("TENANT-A"); task.setOutboundOrderId(301L); + task.setTaskNo("PDA-701"); task.setStatus("IN_PROGRESS"); + com.baiyue.lastleg.delivery.entity.OutboundPdaPick replay = new com.baiyue.lastleg.delivery.entity.OutboundPdaPick(); + replay.setTaskId(701L); replay.setOutboundPalletItemId(801L); replay.setInventoryPositionId(901L); + replay.setPackages(2L); + when(pdaTasks.lockByNoAndWarehouse("PDA-701", "WH-1")).thenReturn(task); + when(pdaPicks.findByKey("TENANT-A", "pick-1")).thenReturn(replay); + when(outboundOrders.selectById(301L)).thenReturn(outbound); + + service.pdaPick("WH-1", "7", "PDA-701", new DeliveryRequests.PdaPick(801L, 901L, 2L, "pick-1")); + verify(outboundPallets, never()).selectById(any()); + + ResponseStatusException conflict = assertThrows(ResponseStatusException.class, + () -> service.pdaPick("WH-1", "7", "PDA-701", + new DeliveryRequests.PdaPick(801L, 901L, 3L, "pick-1"))); + assertEquals(409, conflict.getStatusCode().value()); + } + + @Test + void outboundPdaCompleteRequiresAllPackagesAndReplaysOnlySameKey() { + OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "one-click-key"); + outbound.setOverseasWarehouseId("WH-1"); + OutboundPdaTask task = new OutboundPdaTask(); + task.setId(701L); task.setCustomerId("TENANT-A"); task.setOutboundOrderId(301L); + task.setTaskNo("PDA-701"); task.setStatus("PENDING"); + OutboundPalletItem item = pallet(501L, "PLT-501", "ONT8", "Ontario", "IN-A", 2); + item.setPickedPackageCount(1); + when(pdaTasks.lockByNoAndWarehouse("PDA-701", "WH-1")).thenReturn(task); + when(outboundOrders.selectById(301L)).thenReturn(outbound); + when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(item)); + + assertEquals(409, assertThrows(ResponseStatusException.class, + () -> service.completePda("WH-1", "PDA-701", new DeliveryRequests.PdaComplete("done-1"))) + .getStatusCode().value()); + + item.setPickedPackageCount(2); + service.completePda("WH-1", "PDA-701", new DeliveryRequests.PdaComplete("done-1")); + assertEquals("COMPLETED", task.getStatus()); + service.completePda("WH-1", "PDA-701", new DeliveryRequests.PdaComplete("done-1")); + assertEquals(409, assertThrows(ResponseStatusException.class, + () -> service.completePda("WH-1", "PDA-701", new DeliveryRequests.PdaComplete("done-2"))) + .getStatusCode().value()); } private static DispatchOrder dispatch(Long id, String dispatchNo, String customerId) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/SmartDispatchOpenApiContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/SmartDispatchOpenApiContractTest.java index 0adbd2f75..4817f8d90 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/SmartDispatchOpenApiContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/SmartDispatchOpenApiContractTest.java @@ -35,7 +35,7 @@ class SmartDispatchOpenApiContractTest { yaml.indexOf("name: orderNo", yaml.indexOf("name: planIds"))); assertTrue(planIdsParameter.contains("required: false")); String dispatchContract = yaml.substring(yaml.indexOf("/api/platform/v1/last-leg/dispatches:"), - yaml.indexOf("/api/platform/v1/last-leg/overseas-procurements:")); + yaml.indexOf("/api/platform/v1/last-leg/outbound-orders:")); assertFalse(dispatchContract.contains("name: customerId")); assertFalse(yaml.contains("/api/platform/v1/last-leg/dispatches/{dispatchNo}/depart:")); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowServiceTest.java index df38d04ca..fb7e76694 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowServiceTest.java @@ -1,10 +1,12 @@ package com.baiyue.lastleg.dispatch.pda.service; +import com.baiyue.lastleg.delivery.entity.OutboundOrder; import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; -import com.baiyue.lastleg.dispatch.entity.DispatchOrder; import com.baiyue.lastleg.dispatch.entity.DispatchItem; +import com.baiyue.lastleg.dispatch.entity.DispatchOrder; import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper; import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper; +import com.baiyue.lastleg.dispatch.mapper.DispatchPalletLockMapper; import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests; import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaResponses; import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaOperation; @@ -13,7 +15,10 @@ import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaTask; import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaOperationMapper; import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickMapper; import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaTaskMapper; +import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; +import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; @@ -21,21 +26,52 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.web.server.ResponseStatusException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.HexFormat; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class DispatchPdaWorkflowServiceTest { - @Mock private DispatchOrderMapper orders; - @Mock private DispatchPdaOperationMapper operations; - @Mock private OutboundOrderMapper outboundOrders; - @Mock private DispatchItemMapper items; - @Mock private DispatchPdaTaskMapper tasks; - @Mock private DispatchPdaPickMapper picks; + @Mock DispatchOrderMapper orders; + @Mock OutboundOrderMapper outboundOrders; + @Mock DispatchItemMapper items; + @Mock DispatchPdaTaskMapper tasks; + @Mock DispatchPdaPickMapper picks; + @Mock DispatchPdaOperationMapper operations; + @Mock DispatchPalletLockMapper palletLocks; + @Mock InventoryPositionMapper positions; + @Mock InventoryCommandService inventory; + @Mock DispatchPickOrderAttributionService attribution; + private DispatchPdaWorkflowService service; + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + service = new DispatchPdaWorkflowService(); + objectMapper = new ObjectMapper(); + ReflectionTestUtils.setField(service, "orders", orders); + ReflectionTestUtils.setField(service, "outboundOrders", outboundOrders); + ReflectionTestUtils.setField(service, "items", items); + ReflectionTestUtils.setField(service, "tasks", tasks); + ReflectionTestUtils.setField(service, "picks", picks); + ReflectionTestUtils.setField(service, "operations", operations); + ReflectionTestUtils.setField(service, "palletLocks", palletLocks); + ReflectionTestUtils.setField(service, "positions", positions); + ReflectionTestUtils.setField(service, "inventory", inventory); + ReflectionTestUtils.setField(service, "pickAttribution", attribution); + ReflectionTestUtils.setField(service, "objectMapper", objectMapper); + } @Test void labelsTransferredPalletsWithOriginalAndCurrentFbaWarehouses() { @@ -45,126 +81,157 @@ class DispatchPdaWorkflowServiceTest { } @Test - void pushRequiresVehiclePlateBeforeCreatingPdaTask() { - DispatchPdaWorkflowService service = new DispatchPdaWorkflowService(); - ReflectionTestUtils.setField(service, "orders", orders); - DispatchOrder order = new DispatchOrder(); - order.setId(1L); order.setCustomerId("100"); order.setDispatchNo("PC1"); order.setVehiclePlate(" "); - when(orders.findByDispatchNo("100", "PC1")).thenReturn(order); + void taskListUsesOnlyTokenBoundWarehouseAndResolvedCustomer() { + DispatchOrder order = order("100", "DSP-1", "WH-1", "PENDING_DEPARTURE"); + DispatchPdaTask task = task(11L, "100", "WH-1", "PDA_PENDING"); + when(tasks.listByWarehouse("WH-1", null)).thenReturn(List.of(task)); + when(orders.selectById(1L)).thenReturn(order); + when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of()); + + List result = service.list("WH-1", null); + + assertEquals(1, result.size()); + assertEquals("DSP-1", result.get(0).dispatchNo()); + verify(tasks).listByWarehouse("WH-1", null); + } + + @Test + void detailRejectsDispatchFromAnotherWarehouseBeforeCustomerLookup() { + when(orders.findGlobalByDispatchNo("DSP-1")) + .thenReturn(order("100", "DSP-1", "WH-2", "PENDING_DEPARTURE")); ResponseStatusException exception = assertThrows(ResponseStatusException.class, - () -> service.push("100", "PC1", new DispatchPdaRequests.Push("push-1", null, null))); + () -> service.task("WH-1", "DSP-1")); - assertEquals(409, exception.getStatusCode().value()); + assertEquals(404, exception.getStatusCode().value()); + verify(tasks, never()).findByDispatch(any(), any()); } @Test - void pushRequiresWarehouseSelection() { - DispatchPdaWorkflowService service = new DispatchPdaWorkflowService(); - ReflectionTestUtils.setField(service, "orders", orders); - DispatchOrder order = new DispatchOrder(); - order.setId(1L); order.setCustomerId("100"); order.setDispatchNo("PC1"); - order.setStatus("PENDING_DEPARTURE"); order.setVehiclePlate("CA-100"); - order.setOverseasWarehouseId("WH-1"); - when(orders.findByDispatchNo("100", "PC1")).thenReturn(order); + void exactPickRetryReturnsPersistedResultWithoutInventoryDeduction() throws Exception { + DispatchOrder order = order("100", "DSP-1", "WH-1", "PICKING"); + order.setVehiclePlate("CA-100"); + DispatchPdaTask task = task(11L, "100", "WH-1", "PICKING"); + task.setOperatorId("7"); + DispatchItem item = item(21L, "100", 5); + DispatchPdaRequests.Pick request = new DispatchPdaRequests.Pick(31L, 2, 2, "pick-1", null); + DispatchPdaResponses.Picked persisted = new DispatchPdaResponses.Picked(21L, 31L, 2, 2, false, false); + DispatchPdaOperation replay = operation("PICK", request, persisted); + when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order); + when(tasks.lockByDispatch("100", 1L)).thenReturn(task); + when(items.selectById(21L)).thenReturn(item); + when(operations.findKey("100", "pick-1")).thenReturn(replay); - ResponseStatusException exception = assertThrows(ResponseStatusException.class, - () -> service.push("100", "PC1", new DispatchPdaRequests.Push("push-1", null, null))); + DispatchPdaResponses.Picked result = service.pick("WH-1", "DSP-1", 21L, "7", "Picker", request); - assertEquals(400, exception.getStatusCode().value()); + assertEquals(2, result.packages()); + verify(inventory, never()).allocate(any(), any(), any()); + verify(picks, never()).insert((DispatchPdaPick) any()); } @Test - void departRequiresVehiclePlate() { - DispatchPdaWorkflowService service = new DispatchPdaWorkflowService(); - ReflectionTestUtils.setField(service, "orders", orders); - ReflectionTestUtils.setField(service, "operations", operations); - DispatchOrder order = new DispatchOrder(); - order.setId(1L); order.setCustomerId("100"); order.setDispatchNo("PC1"); - order.setStatus("READY_TO_DEPART"); order.setVehiclePlate(" "); - when(orders.findByDispatchNo("100", "PC1")).thenReturn(order); + void completionRejectsPartialPickingWithoutStatusDrift() { + DispatchOrder order = order("100", "DSP-1", "WH-1", "PICKING"); + order.setVehiclePlate("CA-100"); + DispatchPdaTask task = task(11L, "100", "WH-1", "PICKING"); + DispatchItem item = item(21L, "100", 6); + DispatchPdaPick pick = new DispatchPdaPick(); pick.setPackages(3L); + when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order); when(orders.lockById("100", 1L)).thenReturn(order); - when(operations.findKey("100", "depart-1")).thenReturn(null); - - ResponseStatusException exception = assertThrows(ResponseStatusException.class, - () -> service.depart("100", "PC1", "operator", new DispatchPdaRequests.Depart("depart-1", null))); - - assertEquals(409, exception.getStatusCode().value()); - } - - @Test - void departRequiresPendingOutboundOrder() { - DispatchPdaWorkflowService service = new DispatchPdaWorkflowService(); - ReflectionTestUtils.setField(service, "orders", orders); - ReflectionTestUtils.setField(service, "operations", operations); - ReflectionTestUtils.setField(service, "outboundOrders", outboundOrders); - DispatchOrder order = new DispatchOrder(); - order.setId(1L); order.setCustomerId("100"); order.setDispatchNo("PC1"); - order.setStatus("READY_TO_DEPART"); order.setVehiclePlate("CA-100"); - when(orders.findByDispatchNo("100", "PC1")).thenReturn(order); - when(orders.lockById("100", 1L)).thenReturn(order); - when(operations.findKey("100", "depart-1")).thenReturn(null); - when(outboundOrders.lockByDispatch("100", 1L)).thenReturn(null); - - ResponseStatusException exception = assertThrows(ResponseStatusException.class, - () -> service.depart("100", "PC1", "operator", new DispatchPdaRequests.Depart("depart-1", null))); - - assertEquals(409, exception.getStatusCode().value()); - } - - @Test - void departedRequestReplaysAfterStatusChanged() throws Exception { - DispatchPdaWorkflowService service = new DispatchPdaWorkflowService(); - ObjectMapper objectMapper = new ObjectMapper(); - ReflectionTestUtils.setField(service, "orders", orders); - ReflectionTestUtils.setField(service, "operations", operations); - ReflectionTestUtils.setField(service, "objectMapper", objectMapper); - DispatchOrder order = new DispatchOrder(); - order.setId(1L); order.setCustomerId("100"); order.setDispatchNo("PC1"); order.setStatus("DEPARTED"); - when(orders.findByDispatchNo("100", "PC1")).thenReturn(order); - when(orders.lockById("100", 1L)).thenReturn(order); - DispatchPdaOperation operation = new DispatchPdaOperation(); - operation.setOperationType("DEPART"); operation.setStatus("COMPLETED"); - operation.setResultJson(objectMapper.writeValueAsString(new DispatchPdaResponses.Departed("PC1", "DEPARTED", 12, false))); - when(operations.findKey("100", "depart-1")).thenReturn(operation); - - // This exact key/hash pair models the persisted first execution result. - DispatchPdaRequests.Depart request = new DispatchPdaRequests.Depart("depart-1", null); - operation.setRequestHash(java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256") - .digest(objectMapper.writeValueAsString(request).getBytes(java.nio.charset.StandardCharsets.UTF_8)))); - DispatchPdaResponses.Departed result = service.depart("100", "PC1", "operator", request); - - assertEquals("DEPARTED", result.status()); - assertEquals(true, result.replayed()); - } - - @Test - void completingAnAutomaticallyCompletedTaskReturnsIdempotentSuccess() { - DispatchPdaWorkflowService service = new DispatchPdaWorkflowService(); - ReflectionTestUtils.setField(service, "orders", orders); - ReflectionTestUtils.setField(service, "operations", operations); - ReflectionTestUtils.setField(service, "items", items); - ReflectionTestUtils.setField(service, "tasks", tasks); - ReflectionTestUtils.setField(service, "picks", picks); - DispatchOrder order = new DispatchOrder(); - order.setId(1L); order.setCustomerId("100"); order.setDispatchNo("PC1"); - order.setStatus("READY_TO_DEPART"); order.setVehiclePlate("CA-100"); - DispatchPdaTask task = new DispatchPdaTask(); - task.setId(2L); task.setStatus("COMPLETED"); - DispatchItem item = new DispatchItem(); item.setPackageCount(6); - DispatchPdaPick pick = new DispatchPdaPick(); pick.setPackages(6L); - DispatchPdaRequests.CompletePicking request = new DispatchPdaRequests.CompletePicking("complete-1", "normal"); - when(orders.findByDispatchNo("100", "PC1")).thenReturn(order); - when(orders.lockById("100", 1L)).thenReturn(order); - when(operations.findKey("100", "complete-1")).thenReturn(null); when(tasks.lockByDispatch("100", 1L)).thenReturn(task); when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of(item)); - when(picks.listTask("100", 2L)).thenReturn(List.of(pick)); + when(picks.listTask("100", 11L)).thenReturn(List.of(pick)); - DispatchPdaResponses.PickingCompleted result = service.completePicking("100", "PC1", request); + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> service.completePicking("WH-1", "DSP-1", new DispatchPdaRequests.CompletePicking("done-1"))); - assertEquals("READY_TO_DEPART", result.status()); - assertEquals(6, result.pickedPackages()); - assertEquals(true, result.replayed()); + assertEquals(409, exception.getStatusCode().value()); + assertEquals("PICKING", order.getStatus()); + verify(orders, never()).updateById((DispatchOrder) any()); + } + + @Test + void automaticallyCompletedTaskPersistsFirstCompleteKeyThenReplaysIt() throws Exception { + DispatchOrder order = order("100", "DSP-1", "WH-1", "READY_TO_DEPART"); + order.setVehiclePlate("CA-100"); + DispatchPdaTask task = task(11L, "100", "WH-1", "COMPLETED"); + DispatchItem item = item(21L, "100", 6); + DispatchPdaPick pick = new DispatchPdaPick(); pick.setPackages(6L); + DispatchPdaRequests.CompletePicking request = new DispatchPdaRequests.CompletePicking("done-1"); + when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order); + when(orders.lockById("100", 1L)).thenReturn(order); + when(tasks.lockByDispatch("100", 1L)).thenReturn(task); + when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of(item)); + when(picks.listTask("100", 11L)).thenReturn(List.of(pick)); + + DispatchPdaResponses.PickingCompleted first = service.completePicking("WH-1", "DSP-1", request); + assertFalse(first.replayed()); + verify(operations).insert((DispatchPdaOperation) any()); + + DispatchPdaOperation replay = operation("COMPLETE_PICKING", request, first); + when(operations.findKey("100", "done-1")).thenReturn(replay); + DispatchPdaResponses.PickingCompleted second = service.completePicking("WH-1", "DSP-1", request); + assertTrue(second.replayed()); + } + + @Test + void departRequiresFullyCompletedPickingAndPendingOutbound() { + DispatchOrder order = order("100", "DSP-1", "WH-1", "PARTIAL_READY_TO_DEPART"); + order.setVehiclePlate("CA-100"); + OutboundOrder outbound = new OutboundOrder(); outbound.setStatus("WAIT_OUTBOUND"); + when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order); + when(orders.lockById("100", 1L)).thenReturn(order); + when(outboundOrders.lockByDispatch("100", 1L)).thenReturn(outbound); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> service.depart("WH-1", "DSP-1", "7", new DispatchPdaRequests.Depart("depart-1", null))); + + assertEquals(409, exception.getStatusCode().value()); + verify(outboundOrders, never()).updateById((OutboundOrder) any()); + } + + @Test + void exactDepartRetryReturnsPersistedResultAfterStatusChanged() throws Exception { + DispatchOrder order = order("100", "DSP-1", "WH-1", "DEPARTED"); + DispatchPdaRequests.Depart request = new DispatchPdaRequests.Depart("depart-1", null); + DispatchPdaResponses.Departed persisted = new DispatchPdaResponses.Departed("DSP-1", "DEPARTED", 12, false); + when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order); + when(orders.lockById("100", 1L)).thenReturn(order); + when(operations.findKey("100", "depart-1")).thenReturn(operation("DEPART", request, persisted)); + + DispatchPdaResponses.Departed result = service.depart("WH-1", "DSP-1", "7", request); + + assertEquals("DEPARTED", result.status()); + assertTrue(result.replayed()); + } + + private DispatchPdaOperation operation(String type, Object request, Object result) throws Exception { + DispatchPdaOperation value = new DispatchPdaOperation(); + value.setOperationType(type); value.setStatus("COMPLETED"); + value.setRequestHash(HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(objectMapper.writeValueAsString(request).getBytes(StandardCharsets.UTF_8)))); + value.setResultJson(objectMapper.writeValueAsString(result)); + return value; + } + + private static DispatchOrder order(String customerId, String dispatchNo, String warehouseId, String status) { + DispatchOrder value = new DispatchOrder(); + value.setId(1L); value.setCustomerId(customerId); value.setDispatchNo(dispatchNo); + value.setOverseasWarehouseId(warehouseId); value.setStatus(status); + return value; + } + + private static DispatchPdaTask task(Long id, String customerId, String warehouseId, String status) { + DispatchPdaTask value = new DispatchPdaTask(); + value.setId(id); value.setCustomerId(customerId); value.setDispatchOrderId(1L); + value.setTaskNo("DPDA-1"); value.setWarehouseId(warehouseId); value.setStatus(status); + return value; + } + + private static DispatchItem item(Long id, String customerId, int packages) { + DispatchItem value = new DispatchItem(); + value.setId(id); value.setCustomerId(customerId); value.setDispatchOrderId(1L); + value.setOverseasWarehouseId("WH-1"); value.setWarehousePalletId(41L); value.setPackageCount(packages); + return value; } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java index 46a243918..5a0a9f39a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java @@ -3,6 +3,7 @@ package com.baiyue.lastleg.dispatch.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.lastleg.dispatch.dto.DispatchRequests; +import com.baiyue.lastleg.dispatch.dto.DispatchResponse; import com.baiyue.lastleg.dispatch.entity.DispatchItem; import com.baiyue.lastleg.dispatch.entity.DispatchOrder; import com.baiyue.lastleg.dispatch.entity.DispatchPalletLock; @@ -13,6 +14,8 @@ import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper; import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper; import com.baiyue.lastleg.dispatch.mapper.DispatchPalletLockMapper; import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate; +import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaTask; +import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaTaskMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -45,6 +48,7 @@ class DispatchServiceTest { @Mock DispatchCandidateMapper candidates; @Mock DispatchDraftMapper drafts; @Mock DispatchDraftContainerMapper draftContainers; + @Mock DispatchPdaTaskMapper pdaTasks; private DispatchService service; @BeforeEach @@ -56,6 +60,7 @@ class DispatchServiceTest { ReflectionTestUtils.setField(service, "candidates", candidates); ReflectionTestUtils.setField(service, "drafts", drafts); ReflectionTestUtils.setField(service, "draftContainers", draftContainers); + ReflectionTestUtils.setField(service, "pdaTasks", pdaTasks); ReflectionTestUtils.setField(service, "orderNoGenerator", new com.baiyue.lastleg.service.LastlegOrderNoGenerator()); lenient().when(items.findByDispatchOrder(any(), any())).thenReturn(List.of()); } @@ -73,6 +78,11 @@ class DispatchServiceTest { assertEquals("ONT8", result.route()); ArgumentCaptor dispatch = ArgumentCaptor.forClass(DispatchOrder.class); verify(orders).insert(dispatch.capture()); + ArgumentCaptor pdaTask = ArgumentCaptor.forClass(DispatchPdaTask.class); + verify(pdaTasks).insert(pdaTask.capture()); + assertEquals(dispatch.getValue().getId(), pdaTask.getValue().getDispatchOrderId()); + assertEquals("WH-1", pdaTask.getValue().getWarehouseId()); + assertEquals("PDA_PENDING", pdaTask.getValue().getStatus()); assertEquals(1, dispatch.getValue().getPalletCount()); verify(locks).insert(any(DispatchPalletLock.class)); verify(items).insert(any(DispatchItem.class)); @@ -204,20 +214,35 @@ class DispatchServiceTest { } @Test - void editCannotBypassPdaManagedDispatchStatus() { + void platformEditResolvesCustomerFromGlobalDispatchNumberAndPreservesStatus() { DispatchOrder dispatch = new DispatchOrder(); dispatch.setId(1L); dispatch.setCustomerId("100"); dispatch.setDispatchNo("PC1"); dispatch.setStatus(DispatchService.PENDING_DEPARTURE); - when(orders.findByDispatchNo("100", "PC1")).thenReturn(dispatch); - when(orders.lockById("100", 1L)).thenReturn(dispatch); + when(orders.lockGlobalByDispatchNo("PC1")).thenReturn(dispatch); + DispatchRequests.Update request = new DispatchRequests.Update("ABC-1", "Driver", null, null, null, + null, null, null, LocalDateTime.now()); + + DispatchResponse result = service.update("PC1", request); + + assertEquals(DispatchService.PENDING_DEPARTURE, dispatch.getStatus()); + assertEquals("ABC-1", result.vehiclePlate()); + verify(orders).updateById(dispatch); + verify(items).findByDispatchOrder("100", 1L); + } + + @Test + void platformEditRejectsAnExecutedDispatchWithoutWriting() { + DispatchOrder dispatch = new DispatchOrder(); + dispatch.setId(1L); dispatch.setCustomerId("100"); dispatch.setDispatchNo("PC1"); + dispatch.setStatus("DEPARTED"); + when(orders.lockGlobalByDispatchNo("PC1")).thenReturn(dispatch); DispatchRequests.Update request = new DispatchRequests.Update(null, null, null, null, null, - LocalDateTime.now(), null, null, null, null, "COMPLETED"); + null, null, null, null); ResponseStatusException exception = assertThrows(ResponseStatusException.class, - () -> service.update("100", "PC1", request)); + () -> service.update("PC1", request)); - assertEquals(400, exception.getStatusCode().value()); - assertEquals(DispatchService.PENDING_DEPARTURE, dispatch.getStatus()); + assertEquals(409, exception.getStatusCode().value()); verify(orders, never()).updateById(any(DispatchOrder.class)); } @@ -255,13 +280,34 @@ class DispatchServiceTest { void platformDetailUsesCustomerFromMatchedDispatch() { DispatchOrder dispatch = new DispatchOrder(); dispatch.setId(1L); dispatch.setCustomerId("200"); dispatch.setDispatchNo("PC1"); - when(orders.findAcrossCustomersByDispatchNo("PC1")).thenReturn(List.of(dispatch)); + when(orders.findGlobalByDispatchNo("PC1")).thenReturn(dispatch); service.platformDetail("PC1"); verify(items).findByDispatchOrder("200", 1L); } + @Test + void globalCancelIsIdempotentAndReleasesOnlyTheResolvedCustomerLocks() { + DispatchOrder dispatch = new DispatchOrder(); + dispatch.setId(1L); dispatch.setCustomerId("200"); dispatch.setDispatchNo("PC1"); + dispatch.setStatus(DispatchService.PENDING_DEPARTURE); + DispatchPalletLock lock = new DispatchPalletLock(); + lock.setStatus("ACTIVE"); lock.setActiveKey("PALLET:1"); + when(orders.lockGlobalByDispatchNo("PC1")).thenReturn(dispatch); + when(locks.selectList(any())).thenReturn(List.of(lock)); + + service.cancel("PC1"); + + assertEquals(DispatchService.CANCELLED, dispatch.getStatus()); + assertEquals("RELEASED", lock.getStatus()); + assertEquals(null, lock.getActiveKey()); + verify(locks).updateById(lock); + + service.cancel("PC1"); + verify(locks, org.mockito.Mockito.times(1)).selectList(any()); + } + private DispatchPalletCandidate candidate(long planPalletId, Long warehousePalletId, String destination) { return candidate(planPalletId, warehousePalletId, destination, 12); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOperationsOpenApiContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOperationsOpenApiContractTest.java index c1a5118a4..b5bea24e6 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOperationsOpenApiContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOperationsOpenApiContractTest.java @@ -4,6 +4,8 @@ import com.baiyue.lastleg.palletops.dto.PalletOperationRequests; import com.baiyue.lastleg.pda.move.dto.PdaInventoryMoveRequest; import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeRequests; import com.baiyue.lastleg.relabel.pda.dto.PdaRelabelRequests; +import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests; +import com.baiyue.lastleg.delivery.dto.DeliveryRequests; import org.junit.jupiter.api.Test; import java.io.IOException; @@ -32,6 +34,11 @@ class PdaOperationsOpenApiContractTest { } } assertNoServerOwnedFields(PdaInventoryMoveRequest.class); + assertNoServerOwnedFields(DispatchPdaRequests.Pick.class); + assertNoServerOwnedFields(DispatchPdaRequests.CompletePicking.class); + assertNoServerOwnedFields(DispatchPdaRequests.Depart.class); + assertNoServerOwnedFields(DeliveryRequests.PdaPick.class); + assertNoServerOwnedFields(DeliveryRequests.PdaComplete.class); } @Test @@ -56,16 +63,23 @@ class PdaOperationsOpenApiContractTest { "/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/exceptions:", "/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/photos/files:", "/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/complete:", - "/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/review:"); + "/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/review:", + "/api/pda/v1/last-leg/dispatches:", + "/api/pda/v1/last-leg/dispatches/{dispatchNo}:", + "/api/pda/v1/last-leg/dispatches/{dispatchNo}/items/{itemId}/pick:", + "/api/pda/v1/last-leg/dispatches/{dispatchNo}/complete:", + "/api/pda/v1/last-leg/dispatches/{dispatchNo}/depart:", + "/api/pda/v1/last-leg/outbound-tasks:", + "/api/pda/v1/last-leg/outbound-tasks/{taskNo}:", + "/api/pda/v1/last-leg/outbound-tasks/{taskNo}/pick:", + "/api/pda/v1/last-leg/outbound-tasks/{taskNo}/complete:"); for (String path : paths) assertTrue(yaml.contains(path), path); - assertEquals(19, paths.size()); + assertEquals(28, paths.size()); assertTrue(yaml.contains("enum: [LOCATION, PALLET]")); assertTrue(yaml.contains("enum: [APPROVE, REWORK]")); assertTrue(yaml.contains("enum: [DAMAGED, MISSING_LABEL, LABEL_MISMATCH, UNREADABLE, OTHER]")); for (String field : SERVER_OWNED) assertFalse(yaml.contains(field + ":"), field); - for (String excluded : List.of("/outbound", "/dispatch", "/delivery")) { - assertFalse(yaml.contains(excluded), excluded); - } + assertFalse(yaml.contains("/api/pda/v1/last-leg/delivery")); } private static void assertNoServerOwnedFields(Class type) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOutboundDispatchContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOutboundDispatchContractTest.java new file mode 100644 index 000000000..ddc13ae8a --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOutboundDispatchContractTest.java @@ -0,0 +1,109 @@ +package com.baiyue.lastleg.pda; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.delivery.controller.OutboundPdaController; +import com.baiyue.lastleg.delivery.dto.DeliveryRequests; +import com.baiyue.lastleg.delivery.dto.DeliveryResponses; +import com.baiyue.lastleg.delivery.service.DeliveryTransferService; +import com.baiyue.lastleg.dispatch.pda.controller.DispatchPdaController; +import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests; +import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.server.ResponseStatusException; + +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +class PdaOutboundDispatchContractTest { + private static final String SECURITY = + "hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')"; + + @Test + void controllersRequirePdaRoleAndNeverAcceptCustomerOrWarehouseScope() { + assertEquals(SECURITY, DispatchPdaController.class.getAnnotation(PreAuthorize.class).value()); + assertEquals(SECURITY, OutboundPdaController.class.getAnnotation(PreAuthorize.class).value()); + for (Class controller : Set.of(DispatchPdaController.class, OutboundPdaController.class)) { + for (Method method : controller.getDeclaredMethods()) { + assertTrue(Arrays.stream(method.getParameters()).noneMatch(parameter -> { + RequestParam query = parameter.getAnnotation(RequestParam.class); + return query != null && Set.of("customerId", "warehouseId") + .contains(query.name().isBlank() ? query.value() : query.name()); + })); + } + } + assertTrue(Arrays.stream(DispatchPdaRequests.Pick.class.getRecordComponents()) + .noneMatch(component -> "warehouseId".equals(component.getName()) || "customerId".equals(component.getName()))); + assertTrue(Arrays.stream(DeliveryRequests.PdaPick.class.getRecordComponents()) + .noneMatch(component -> "warehouseId".equals(component.getName()) || "customerId".equals(component.getName()))); + } + + @Test + void controllersPassOnlyTokenBoundWarehouseToServices() { + DispatchPdaWorkflowService dispatchService = mock(DispatchPdaWorkflowService.class); + DeliveryTransferService outboundService = mock(DeliveryTransferService.class); + DispatchPdaController dispatch = new DispatchPdaController(); + OutboundPdaController outbound = new OutboundPdaController(); + ReflectionTestUtils.setField(dispatch, "service", dispatchService); + ReflectionTestUtils.setField(outbound, "service", outboundService); + AuthenticatedUser user = pda(Set.of("lastleg:pda:operate", "ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"), 900L); + + dispatch.list(user, null); + outbound.list(user, null); + + verify(dispatchService).list("900", null); + verify(outboundService).pdaTasks("900", null); + } + + @Test + void missingRolePermissionOrWarehouseBindingIsRejected() { + DispatchPdaController controller = new DispatchPdaController(); + assertThrows(ResponseStatusException.class, + () -> controller.list(pda(Set.of("lastleg:pda:operate"), 900L), null)); + assertThrows(ResponseStatusException.class, + () -> controller.list(pda(Set.of("ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"), 900L), null)); + assertThrows(ResponseStatusException.class, + () -> controller.list(pda(Set.of("lastleg:pda:operate", "ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"), null), null)); + } + + @Test + void pdaOpenApiContainsOnlyFrozenDispatchAndOutboundWorkflow() throws Exception { + String yaml = Files.readString(Path.of("src/main/resources/openapi/overseas-pda-operations-v1.yaml"), + StandardCharsets.UTF_8); + assertTrue(yaml.contains("/api/pda/v1/last-leg/dispatches:")); + assertTrue(yaml.contains("/api/pda/v1/last-leg/dispatches/{dispatchNo}/depart:")); + assertTrue(yaml.contains("/api/pda/v1/last-leg/outbound-tasks/{taskNo}/complete:")); + assertFalse(yaml.contains("name: customerId")); + assertFalse(yaml.contains("name: warehouseId")); + assertFalse(yaml.contains("/api/pda/v1/last-leg/delivery")); + assertTrue(yaml.contains("inventoryPositionId:")); + assertTrue(yaml.contains("cartonMark:")); + assertTrue(yaml.contains("availablePackages:")); + assertTrue(yaml.contains("pickedPackages:")); + assertEquals(Set.of("inventoryPositionId", "palletNo", "cartonMark", "skuCode", "inboundCode", + "availablePackages", "pickedPackages"), + Arrays.stream(DeliveryResponses.PdaPickPosition.class.getRecordComponents()) + .map(component -> component.getName()).collect(java.util.stream.Collectors.toSet())); + assertEquals(Set.of("idempotencyKey"), + Arrays.stream(DeliveryRequests.PdaComplete.class.getRecordComponents()) + .map(component -> component.getName()).collect(java.util.stream.Collectors.toSet())); + } + + private static AuthenticatedUser pda(Set permissions, Long warehouseId) { + return new AuthenticatedUser(7L, 0L, 0L, 8L, "picker", "PLATFORM", "PDA", permissions, + false, null, null, false, warehouseId); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementOpenApiContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementOpenApiContractTest.java index a56f94cc7..f70a2e3c7 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementOpenApiContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementOpenApiContractTest.java @@ -46,7 +46,9 @@ class OverseasProcurementOpenApiContractTest { assertTrue(yaml.contains("platform:overseas-business:looseboard:create")); assertTrue(yaml.contains("platform:overseas-business:looseboard:edit")); assertTrue(yaml.contains("platform:overseas-business:looseboard:status")); - assertFalse(yaml.contains("/api/platform/v1/last-leg/outbound")); - assertFalse(yaml.contains("/api/platform/v1/last-leg/delivery")); + String businessContract = yaml.substring(yaml.indexOf("/api/platform/v1/last-leg/overseas-procurements:"), + yaml.indexOf("/api/platform/v1/last-leg/relabel-applications:")); + assertFalse(businessContract.contains("/api/platform/v1/last-leg/outbound")); + assertFalse(businessContract.contains("/api/platform/v1/last-leg/delivery")); } } diff --git a/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json b/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json index 2966a26c2..9fce02c05 100644 --- a/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json +++ b/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json @@ -2,6 +2,7 @@ "schemaVersion": 1, "description": "Development-stage Lastleg slice dependency baseline. Existing edges may be removed; new edges require architecture review.", "allowedSliceEdges": [ + "dispatch-delivery->dropship", "dispatch-delivery->inbound-pda", "dispatch-delivery->inventory", "dispatch-delivery->shared-technical", @@ -24,6 +25,7 @@ "relabel-transfer-discard->shared-technical", "relabel-transfer-discard->tailleg-handoff", "security-projection->shared-technical", + "tailleg-handoff->dispatch-delivery", "tailleg-handoff->dropship", "tailleg-handoff->inbound-pda", "tailleg-handoff->inventory", From d2092bf0b6460ad125506d995d6fd1aa6bce6629 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 1 Sep 2026 14:27:04 +0800 Subject: [PATCH 25/37] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/AGENTS.md | 1 + scripts/README.md | 35 +++++++++++++++++++ .../http-script-environment-contract.test.mjs | 12 +++++++ 3 files changed, 48 insertions(+) diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md index d49ead666..fbfdad14e 100644 --- a/scripts/AGENTS.md +++ b/scripts/AGENTS.md @@ -26,6 +26,7 @@ - 可直接运行的脚本必须在 `README.md` 登记:用途、目标环境、是否写数据、凭据来源、报告位置、失败/重跑语义和清理边界。内部模块必须登记到 `admin-test-env-modules/README.md`,并按需同步总入口模块注册及依赖展开。 - 文件命名使用稳定业务语义:离线 Node 合同以 `.test.mjs` 结尾;环境写入入口显式包含 `test-env` 或在 README 中标记目标环境;fixture/verify 必须区分准备写入和只读核验。 - 不在源码、AGENTS、README 或报告中写入密码、Token、私钥、真实客户数据和固定环境密钥;环境地址与账号从入口已支持的参数或本机环境配置读取。 +- 开发与测试验收配置分别只维护在 `$env:USERPROFILE\.codex\chongchong-global-dev-acceptance.env`、`$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env`。脚本不得自动猜测环境或读取另一份文件;运行者必须通过位于脚本路径之前的 `node --env-file="<明确文件>"` 选择环境。 - 不直接写业务数据库伪造 HTTP 验收成功,不吞掉 `deploymentBlocker`、业务错误码、`requestId` 或 `traceId`。造数脚本的部分成功数据默认保留,清理、重置或删除必须另行授权。 - 不默认并发运行共享环境造数。只有用户明确要求并确认序号、报告目录和服务容量边界时,才允许拆分 worker;每个 worker 必须使用不重叠序号和独立报告目录。 - 修改公共参数、上下文或模块签名时,定向检查总入口、生产模块、fixture、合同测试、README 和调用命令,禁止保留旧参数兼容分支。 diff --git a/scripts/README.md b/scripts/README.md index 85c573b56..2c5a2dd2f 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -4,6 +4,41 @@ 进入本目录维护或运行脚本前先读 `scripts/AGENTS.md`;本文件是脚本入口、用途和副作用的唯一总索引,具体模块说明继续下沉到相邻 README。 +## 验收环境变量文件 + +开发与测试环境的账号、密码和 Gateway 只维护在用户目录的两份外部文件中,不写入仓库: + +| 环境 | 文件 | +|---|---| +| 开发环境 | `$env:USERPROFILE\.codex\chongchong-global-dev-acceptance.env` | +| 测试环境 | `$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env` | + +两份文件统一提供以下变量: + +```text +ADMIN_TEST_GATEWAY +ADMIN_OVERSEAS_GATEWAY +ADMIN_TEST_USERNAME +ADMIN_TEST_PASSWORD +MERCHANT_TEST_USERNAME +MERCHANT_TEST_PASSWORD +``` + +脚本只读取当前 Node 进程的环境变量,不会自行查找或加载上述文件。运行时必须把 `--env-file` 放在脚本路径之前,明确选择环境: + +```powershell +# 开发环境 +node --env-file="$env:USERPROFILE\.codex\chongchong-global-dev-acceptance.env" scripts/admin-test-env-acceptance.mjs + +# 测试环境 +node --env-file="$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env" scripts/admin-test-env-acceptance.mjs + +# 测试环境批量待分柜造数 +node --env-file="$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env" scripts/it-test-wait-split-bulk.mjs +``` + +Client 全量、Client 二期、待审核订单和批量待分柜均复用这套变量,不再维护各自的 Gateway、管理员或客户账号变量。外部文件必须保留在仓库之外,不得复制到项目目录、报告或提交记录。 + ## 脚本目录归属 脚本按作用域放置,不要求全部集中到根 `scripts/`: diff --git a/scripts/http-script-environment-contract.test.mjs b/scripts/http-script-environment-contract.test.mjs index 063689a7b..2e534e6ad 100644 --- a/scripts/http-script-environment-contract.test.mjs +++ b/scripts/http-script-environment-contract.test.mjs @@ -6,6 +6,8 @@ const read = (name) => readFileSync(new URL(name, import.meta.url), 'utf8') const client = read('./client-test-env-acceptance.mjs') const clientPhase2 = read('./client-phase2-test-env-acceptance.mjs') const pendingOrders = read('./create-pending-review-orders.mjs') +const readme = read('./README.md') +const agents = read('./AGENTS.md') const activeHttpScripts = [client, clientPhase2, pendingOrders] const removedEnvironmentVariables = [ @@ -35,3 +37,13 @@ test('active scripts no longer carry the retired variables, fixed Gateway, or so assert.doesNotMatch(pendingOrders, /const CONFIG\s*=/) assert.doesNotMatch(pendingOrders, /PASSWORD:\s*'[^']+'/) }) + +test('documentation names both external environment files and requires explicit selection', () => { + for (const source of [readme, agents]) { + assert.match(source, /chongchong-global-dev-acceptance\.env/) + assert.match(source, /chongchong-global-test-acceptance\.env/) + assert.match(source, /--env-file/) + } + assert.match(readme, /不会自行查找或加载上述文件/) + assert.match(readme, /ADMIN_OVERSEAS_GATEWAY/) +}) From f551cd8c7ae1d45368ad2d7ef375c4e48eece2b7 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 1 Sep 2026 14:27:16 +0800 Subject: [PATCH 26/37] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/agents-index-contract.ps1 | 49 +++++++++++++++++++++---------- 1 file changed, 33 insertions(+), 16 deletions(-) diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index 89089c664..efa3ed13a 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -119,7 +119,7 @@ Assert-Contains $rootAgents '用户可见页面、提示和错误不得展示或 Assert-Contains $adminAgents '/loading-plans/{planId}/lifecycle' 'Admin 装柜清单生命周期入口' Assert-Contains $adminAgents '合柜报关 / 同柜不可拆组' 'Admin 合柜报关别名入口' Assert-Contains $clientAgents 'api/inboundCodeAssociations.ts' 'Client 入仓码业务关系入口' -Assert-Contains $pdaAgents 'utils/user-facing.js' '国内 PDA 业务编号入口' +Assert-Contains $pdaAgents 'tests/page-contract.test.mjs' '国内 PDA 页面合同入口' Assert-Contains $overseasPdaAgents 'tests/business-identifiers-ui.test.mjs' '海外 PDA 内部 ID 隔离合同' $orderAgents = Read-RequiredText (Join-Path $backendRoot 'order/AGENTS.md') Assert-Contains $orderAgents '`channelCode` 直接来自 Order/Firstleg 权威快照' 'Order 稳定渠道编码' @@ -232,7 +232,7 @@ Assert-Contains $overseasCompose 'LASTLEG_SECURITY_SERVICE_URL' 'Lastleg 安全 Assert-NotContains $serviceJenkinsfile "'overseas-security-service'" '独立海外安全服务构建入口' # 高变化语义必须在最近指引与服务契约中保持一致。 -Assert-Contains $adminAgents '海外出库、派送、异常与回访(范围外现存代码)' 'Admin 四期范围边界' +Assert-Contains $adminAgents '海外模块内部快速索引(四期/五期)' 'Admin 四五期范围边界' Assert-Contains $pdaAgents 'VITE_H5_API_BASE_URL' '国内 PDA H5 运行配置' Assert-Contains $overseasPdaAgents 'tests/app-icons.test.mjs' '海外 PDA 图标合同' Assert-Contains $backendAgents 'RestClientTraceInterceptor.java' '后端同步 Trace 入口' @@ -248,22 +248,28 @@ Assert-Contains $adminOceanBookingApi '/link-candidates' 'Admin 订舱关联候 Assert-Contains $adminOceanBookingView "label: '订舱柜量'" 'Admin 订舱柜量文案' Assert-Contains $adminOceanBookingViewTest 'without detail N+1' 'Admin 订舱列表无详情扇出合同' -# 已注册但超出当前四期边界的海外出库及派生运营页只能作为范围漂移事实存在,不得进入根正式业务路由。 +# 五期海外出库、派送、异常和回访必须从根路由贯通到 Admin 与 Lastleg;PDA 后端存在不等于设备端已挂载。 $adminOverseasRoutes = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/overseas-warehouse/routes.ts') -foreach ($outOfScopeOverseasRoute in @( +$lastlegAgents = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/AGENTS.md') +foreach ($phase5OverseasRoute in @( 'overseas-warehouse/outbounds', 'overseas-warehouse/delivery-orders', 'overseas-warehouse/exception-orders', 'overseas-warehouse/customer-visits' )) { - Assert-Contains $adminOverseasRoutes "path: '$outOfScopeOverseasRoute'" "Admin 范围外现存路由 $outOfScopeOverseasRoute" + Assert-Contains $adminOverseasRoutes "path: '$phase5OverseasRoute'" "Admin 五期正式路由 $phase5OverseasRoute" } -Assert-Contains $adminAgents '海外出库、派送、异常与回访(范围外现存代码)' 'Admin 海外范围漂移警示' -foreach ($outOfScopeRootEntry in @( +foreach ($phase5RootEntry in @( 'OutboundsView.vue', 'DeliveryOrdersView.vue', 'ExceptionOrdersView.vue', 'CustomerVisitsView.vue' )) { - Assert-NotContains $rootAgents $outOfScopeRootEntry '根正式四期业务路由' + Assert-Contains $rootAgents $phase5RootEntry '根正式五期业务路由' } +Assert-Contains $adminAgents '五期海外排车、出库与派送' 'Admin 五期出库派送入口' +Assert-Contains $adminAgents '五期海外异常退回与客户回访' 'Admin 五期异常回访入口' +Assert-Contains $backendAgents '五期海外排车执行、PDA 出库、派送与一件代发' '后端五期出库派送入口' +Assert-Contains $backendAgents '五期一件代发 SKU/箱唛与暂扣跨区协同' '后端五期跨区协同入口' +Assert-Contains $lastlegAgents '五期范围承接排车编辑/取消/发车' 'Lastleg 五期范围边界' +Assert-Contains $overseasPdaAgents '本工程尚未挂载对应 API、页面或工作台能力' '海外 PDA 五期未挂载边界' # Admin 仅对静态资源过期做一次受控恢复,不能吞掉普通路由错误或覆盖未保存编辑。 $navigationRecovery = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/core/routing/navigationRecovery.ts') @@ -394,9 +400,9 @@ Assert-Contains $lastlegBusinessOpenApi 'allPlansReady' 'Lastleg 全柜计划就 Assert-Contains $lastlegBusinessOpenApi 'plannedArrivalAt' 'Lastleg 计划到仓冻结事实' Assert-Contains $lastlegAgents 'service/FbaWarehouseValidator.java' 'Lastleg FBA 权威仓校验入口' -# 国内 PDA 部分拣货必须展示真实可用库位事实,并在无库位时保持页面整洁。 -Assert-Contains $pdaAgents 'tests/partial-layout.test.mjs' '国内 PDA 部分拣货布局合同' -Assert-Contains $pdaAgents 'tests/picking-location-display.test.mjs' '国内 PDA 库位展示合同' +# 国内 PDA 当前以页面、API 和作业状态合同覆盖部分拣货及库位不足反馈。 +Assert-Contains $pdaAgents 'tests/page-contract.test.mjs' '国内 PDA 页面合同' +Assert-Contains $pdaAgents 'tests/pda-loading-api.test.mjs' '国内 PDA API 合同' # Maven 聚合声明的全部历史/当前模块必须在后端根指引中可定位。 [xml]$pom = Get-Content -LiteralPath (Join-Path $backendRoot 'pom.xml') -Raw @@ -462,10 +468,8 @@ $criticalPaths = @( 'apps/pda-uniapp/vite.config.js', 'apps/pda-uniapp/utils/app-update.js', 'apps/pda-uniapp/tests/app-update.test.mjs', - 'apps/pda-uniapp/tests/partial-layout.test.mjs', - 'apps/pda-uniapp/tests/picking-location-display.test.mjs', - 'apps/pda-uniapp/utils/user-facing.js', - 'apps/pda-uniapp/tests/user-facing-identifiers.test.mjs', + 'apps/pda-uniapp/tests/page-contract.test.mjs', + 'apps/pda-uniapp/tests/pda-loading-api.test.mjs', 'apps/pda-uniapp/utils/evidence-upload-queue.js', 'apps/pda-overseas-uniapp/package.json', 'apps/pda-overseas-uniapp/src/pages.json', @@ -499,6 +503,19 @@ $criticalPaths = @( 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/UnloadPhotoFileGateway.java', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryController.java', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/DispatchPdaController.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/OutboundPdaController.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryController.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/controller/ExceptionOrderController.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/controller/CustomerVisitController.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/service/DropshipOrderService.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/service/InboundOrderDetainedOutboxService.java', + 'baiyue-firstleg/common-event-contract/src/main/java/com/baiyue/common/event/OverseasInboundOrderDetainedChangedV1.java', + 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingWorkPalletService.java', + 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasInboundOrderDetainedConsumer.java', + 'baiyue-firstleg/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchDraftContainer.java', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftContainerMapper.java', 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java', @@ -693,5 +710,5 @@ if ($failures.Count -gt 0) { exit 1 } -Write-Host "AGENTS 索引合同通过:$checked 项检查,覆盖快速表唯一性、部署单元、前端模块、Client active 路由、业务编号与内部 ID 隔离、装柜清单生命周期、四期范围漂移、仓库主档唯一权威、订舱候选与柜量语义、整柜出仓与船节点单调性、Gateway 覆盖、PDA 页面/运行配置/独立更新/语言预留/能力开关、人工分段验收链、Maven 模块、Lastleg 海外安全投影、同步 Trace、关键入口和排除目录。" -ForegroundColor Green +Write-Host "AGENTS 索引合同通过:$checked 项检查,覆盖快速表唯一性、部署单元、前端模块、Client active 路由、业务编号与内部 ID 隔离、装柜清单生命周期、四/五期范围边界、仓库主档唯一权威、订舱候选与柜量语义、整柜出仓与船节点单调性、Gateway 覆盖、PDA 页面/运行配置/独立更新/语言预留/能力开关、人工分段验收链、Maven 模块、Lastleg 海外安全投影、同步 Trace、关键入口和排除目录。" -ForegroundColor Green exit 0 From 2ccfc38cdf2a5f4e5df5810529c7bc076a15289f Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 1 Sep 2026 14:31:01 +0800 Subject: [PATCH 27/37] test(firstleg): seed booking file before loading release --- ...e3-loading-dynamic-final-contract.test.mjs | 19 ++++++++ .../admin-test-env-modules/phase3-loading.mjs | 46 ++++++++++++++++++- 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs b/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs index 01225936e..bc6e01079 100644 --- a/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs @@ -52,6 +52,25 @@ test('default fixture publishes a second marker-owned inbound for the same-plan }) test('HTTP chain refreshes after stale inventory and keeps one stable PDA identity', () => { + ordered(loading, [ + '创建三期 DRAFT 订舱(不生成真实柜)', + '三期 DRAFT 订舱返回稳定身份和初始版本', + '三期订舱上传当前有效订舱确认文件', + '订舱文件归属当前订舱且上传递增版本', + '订舱文件同键同载荷重放', + '订舱文件精确重放不新增文件或版本', + '回查订舱文件有效归属', + '同键重放后当前订舱仅保留一条目标有效文件', + '三期订舱 RELEASED 冻结运输快照', + ]) + assert.match(loading, /documentType', 'BOOKING_CONFIRMATION'/) + assert.match(loading, /id\(bookingDocument\?\.document\?\.oceanBookingId\) === bookingId/) + assert.match(loading, /releaseVersion > draftVersion/) + assert.match(loading, /'Idempotency-Key': bookingDocumentKey/) + assert.equal((loading.match(/'Idempotency-Key': bookingDocumentKey/g) || []).length, 2) + assert.match(loading, /Number\(bookingDocumentReplay\?\.bookingVersion\) === releaseVersion/) + assert.match(loading, /expectedVersion: releaseVersion, reason: context\.marker/) + assert.doesNotMatch(loading, /expectedVersion: draftVersion, reason: context\.marker/) ordered(loading, [ 'PDA 原子批次完成拣货并等待人工确认', '五类装柜证据完整且铅封号持久化', diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 98e34c0a5..e90bd23eb 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -440,8 +440,52 @@ export async function run(context) { const bookingDraftResponse = context.apiSuccess(MODULE, '创建三期 DRAFT 订舱(不生成真实柜)', bookingCreateResponse, '', OPTIONS) const bookingId = id(bookingDraftResponse?.oceanBooking?.id) const draftVersion = Number(bookingDraftResponse?.oceanBooking?.version) + context.assert(MODULE, '三期 DRAFT 订舱返回稳定身份和初始版本', bookingCreateResponse, + Boolean(bookingId) && bookingDraftResponse?.oceanBooking?.status === 'DRAFT' + && Number.isInteger(draftVersion) && draftVersion >= 0, + `bookingId=${bookingId || 'missing'}, status=${bookingDraftResponse?.oceanBooking?.status || 'missing'}, version=${bookingDraftResponse?.oceanBooking?.version ?? 'missing'}`, OPTIONS) + if (!bookingId || !Number.isInteger(draftVersion) || draftVersion < 0) return + const bookingDocumentKey = `${context.marker}-p3-booking-document`.slice(0, 128) + const bookingDocumentForm = () => { + const form = new FormData() + form.append('expectedVersion', String(draftVersion)) + form.append('documentType', 'BOOKING_CONFIRMATION') + form.append('file', new Blob([`${context.marker}-BOOKING_CONFIRMATION`], { type: 'text/plain' }), + `booking-confirmation-${context.marker}.txt`) + return form + } + const bookingDocumentResponse = await context.requestMultipart('POST', + `/api/platform/v1/ocean-bookings/${bookingId}/documents/upload`, bookingDocumentForm(), context.token, + { 'Idempotency-Key': bookingDocumentKey }) + const bookingDocument = context.apiSuccess(MODULE, '三期订舱上传当前有效订舱确认文件', bookingDocumentResponse, '', OPTIONS) + const releaseVersion = Number(bookingDocument?.bookingVersion) + context.assert(MODULE, '订舱文件归属当前订舱且上传递增版本', bookingDocumentResponse, + Boolean(bookingDocument?.document?.id) && id(bookingDocument?.document?.oceanBookingId) === bookingId + && bookingDocument?.document?.documentType === 'BOOKING_CONFIRMATION' + && bookingDocument?.document?.valid === true && Number.isInteger(releaseVersion) + && releaseVersion > draftVersion, + `documentId=${id(bookingDocument?.document?.id) || 'missing'}, oceanBookingId=${id(bookingDocument?.document?.oceanBookingId) || 'missing'}, draftVersion=${draftVersion}, bookingVersion=${bookingDocument?.bookingVersion ?? 'missing'}`, OPTIONS) + if (!bookingId || !Number.isInteger(releaseVersion) || releaseVersion <= draftVersion) return + + const bookingDocumentReplayResponse = await context.requestMultipart('POST', + `/api/platform/v1/ocean-bookings/${bookingId}/documents/upload`, bookingDocumentForm(), context.token, + { 'Idempotency-Key': bookingDocumentKey }) + const bookingDocumentReplay = context.apiSuccess(MODULE, '订舱文件同键同载荷重放', bookingDocumentReplayResponse, '', OPTIONS) + context.assert(MODULE, '订舱文件精确重放不新增文件或版本', bookingDocumentReplayResponse, + id(bookingDocumentReplay?.document?.id) === id(bookingDocument?.document?.id) + && id(bookingDocumentReplay?.document?.oceanBookingId) === bookingId + && Number(bookingDocumentReplay?.bookingVersion) === releaseVersion, + `firstDocumentId=${id(bookingDocument?.document?.id) || 'missing'}, replayDocumentId=${id(bookingDocumentReplay?.document?.id) || 'missing'}, firstVersion=${releaseVersion}, replayVersion=${bookingDocumentReplay?.bookingVersion ?? 'missing'}`, OPTIONS) + + const bookingAfterDocumentResponse = await context.request('GET', `/api/platform/v1/ocean-bookings/${bookingId}`) + const bookingAfterDocument = context.apiSuccess(MODULE, '回查订舱文件有效归属', bookingAfterDocumentResponse, '', OPTIONS) + const activeBookingDocuments = (bookingAfterDocument?.documents || []).filter((document) => document?.valid === true) + context.assert(MODULE, '同键重放后当前订舱仅保留一条目标有效文件', bookingAfterDocumentResponse, + activeBookingDocuments.filter((document) => id(document?.id) === id(bookingDocument?.document?.id) + && id(document?.oceanBookingId) === bookingId && document?.documentType === 'BOOKING_CONFIRMATION').length === 1, + `activeDocuments=${activeBookingDocuments.length}, targetDocumentId=${id(bookingDocument?.document?.id) || 'missing'}`, OPTIONS) const releaseResponse = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${bookingId}/release`, - { expectedVersion: draftVersion, reason: context.marker }, + { expectedVersion: releaseVersion, reason: context.marker }, { 'Idempotency-Key': `${context.marker}-p3-booking-release`.slice(0, 128) }) const bookingConfirmed = context.apiSuccess(MODULE, '三期订舱 RELEASED 冻结运输快照', releaseResponse, '', OPTIONS) const transportFields = ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt'] From 105660bd09fa0328f24d889c1ade5172e9f8a516 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 1 Sep 2026 14:37:02 +0800 Subject: [PATCH 28/37] test(firstleg): scope loading evidence by cabinet --- ...phase3-loading-dynamic-final-contract.test.mjs | 11 +++++++++++ scripts/admin-test-env-modules/phase3-loading.mjs | 15 ++++++++------- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs b/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs index bc6e01079..156049cef 100644 --- a/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs @@ -52,6 +52,17 @@ test('default fixture publishes a second marker-owned inbound for the same-plan }) test('HTTP chain refreshes after stale inventory and keeps one stable PDA identity', () => { + assert.match(loading, + /async function uploadEvidence\(context, containerBookingId, evidenceType\)/) + assert.match(loading, + /files\/loading-evidence\/\$\{containerBookingId\}\?evidenceType=/) + assert.match(loading, + /files\/loading-evidence\/\$\{containerBookingId\}\/\$\{fileId\}/) + assert.equal((loading.match(/uploadEvidence\(context, containerBookingId,/g) || []).length, 5) + assert.doesNotMatch(loading, + /files\/loading-evidence\/\$\{tenantId\}\/\$\{inboundId\}/) + assert.doesNotMatch(loading, + /uploadEvidence\(context, tenantId, inboundId,/) ordered(loading, [ '创建三期 DRAFT 订舱(不生成真实柜)', '三期 DRAFT 订舱返回稳定身份和初始版本', diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index e90bd23eb..578277af8 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -77,8 +77,8 @@ async function request(context, method, path, body, headers = {}) { } catch (error) { return { method, path, http: 0, body: null, raw: String(error), requestId: null, traceId: null } } } -async function uploadEvidence(context, tenantId, inboundId, evidenceType) { - const path = `/api/platform/v1/files/loading-evidence/${tenantId}/${inboundId}?evidenceType=${encodeURIComponent(evidenceType)}` +async function uploadEvidence(context, containerBookingId, evidenceType) { + const path = `/api/platform/v1/files/loading-evidence/${containerBookingId}?evidenceType=${encodeURIComponent(evidenceType)}` const form = new FormData() const png = Uint8Array.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,6,0,0,0,31,21,196,137,0,0,0,13,73,68,65,84,8,215,99,248,207,192,240,31,0,5,0,1,255,137,153,61,29,0,0,0,0,73,69,78,68,174,66,96,130]) const fileName = `${context.marker}-${evidenceType.toLowerCase()}.png` @@ -896,7 +896,7 @@ export async function run(context) { const evidenceTypes = ['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL'] const evidenceItems = [] for (const evidenceType of evidenceTypes) { - const upload = await uploadEvidence(context, tenantId, inboundId, evidenceType) + const upload = await uploadEvidence(context, containerBookingId, evidenceType) const uploaded = context.apiSuccess(MODULE, `上传本轮 ${evidenceType} 真实证据文件`, upload, '', OPTIONS) const fileId = id(uploaded?.fileId) context.assert(MODULE, `${evidenceType} 文件归属类型和媒体元数据正确`, upload, @@ -934,7 +934,8 @@ export async function run(context) { `HTTP=${conflict.http}, code=${codeOf(conflict)}, requestId=${conflict.requestId || 'missing'}, traceId=${conflict.traceId || 'missing'}`, OPTIONS) } - const download = await context.requestBinary('GET', `/api/platform/v1/files/loading-evidence/${tenantId}/${inboundId}/${fileId}`) + const download = await context.requestBinary('GET', + `/api/platform/v1/files/loading-evidence/${containerBookingId}/${fileId}`) context.assert(MODULE, `下载本轮 ${evidenceType} 证据文件`, download, download.http === 200 && download.contentType.toLowerCase().includes('image/png') && download.bytes.length > 0, `bytes=${download.bytes.length}, contentType=${download.contentType || 'missing'}`, OPTIONS) @@ -948,7 +949,7 @@ export async function run(context) { const firstPhoto = evidenceItems.find((item) => item.evidenceType === 'PHOTO_STEP_1') const sealEvidence = evidenceItems.find((item) => item.evidenceType === 'SEAL') - const appendedUpload = await uploadEvidence(context, tenantId, inboundId, 'PHOTO_STEP_1') + const appendedUpload = await uploadEvidence(context, containerBookingId, 'PHOTO_STEP_1') const appendedFile = context.apiSuccess(MODULE, '追加同类型第二张装柜证据文件', appendedUpload, '', OPTIONS) const appendedFileId = id(appendedFile?.fileId) const appended = appendedFileId @@ -1045,7 +1046,7 @@ export async function run(context) { context.addStep(MODULE, '作废唯一证据类型后不提前占用异步完成命令', null, true, 'PHOTO_STEP_4 已真实作废并回读;补传恢复后才允许最终阶段提交唯一完成命令', OPTIONS) - const replacementUpload = await uploadEvidence(context, tenantId, inboundId, 'PHOTO_STEP_4') + const replacementUpload = await uploadEvidence(context, containerBookingId, 'PHOTO_STEP_4') const replacementFile = context.apiSuccess(MODULE, '补传缺失 PHOTO_STEP_4 真实文件', replacementUpload, '', OPTIONS) const replacementFileId = id(replacementFile?.fileId) const replacementBind = replacementFileId @@ -1078,7 +1079,7 @@ export async function run(context) { wrongInboundEvidence.http === 200 && codeOf(wrongInboundEvidence) === 'A0404', `HTTP=${wrongInboundEvidence.http}, code=${codeOf(wrongInboundEvidence)}`, OPTIONS) - const postLoadedUpload = await uploadEvidence(context, tenantId, inboundId, 'PHOTO_STEP_2') + const postLoadedUpload = await uploadEvidence(context, containerBookingId, 'PHOTO_STEP_2') const postLoadedFile = context.apiSuccess(MODULE, '预置 LOADED 后新增证据边界文件', postLoadedUpload, '', OPTIONS) const postLoadedFileId = id(postLoadedFile?.fileId) From f0ac0c42c903c5e70004245121d93596c588ba60 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 1 Sep 2026 14:44:30 +0800 Subject: [PATCH 29/37] test(firstleg): use valid PDF truck fixtures --- .../truck-appointments-contract.test.mjs | 14 ++++++++++++- .../truck-appointments.mjs | 20 +++++++++++++++++-- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/scripts/admin-test-env-modules/truck-appointments-contract.test.mjs b/scripts/admin-test-env-modules/truck-appointments-contract.test.mjs index 3eeaab7a3..7c31f133c 100644 --- a/scripts/admin-test-env-modules/truck-appointments-contract.test.mjs +++ b/scripts/admin-test-env-modules/truck-appointments-contract.test.mjs @@ -2,7 +2,19 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' -import { activeCustomerIdentity, inspectOceanBookingIdentity, platformWarehouseIdentity, truckAppointmentPayload } from './truck-appointments.mjs' +import { activeCustomerIdentity, inspectOceanBookingIdentity, minimalPdfBytes, platformWarehouseIdentity, truckAppointmentPayload } from './truck-appointments.mjs' + +test('PDF upload fixtures contain real PDF magic bytes instead of MIME-only text', () => { + const bytes = minimalPdfBytes('RUN-2-DISPATCH_ORDER') + const content = Buffer.from(bytes).toString('ascii') + assert.equal(content.slice(0, 5), '%PDF-') + assert.match(content, /%%EOF\n$/) + + const source = readFileSync(new URL('truck-appointments.mjs', import.meta.url), 'utf8') + assert.match(source, /new Blob\(\[minimalPdfBytes\(`\$\{context\.marker\}-BILL_OF_LADING`\)\], \{ type: 'application\/pdf' \}\)/) + assert.match(source, /new Blob\(\[minimalPdfBytes\(`\$\{context\.marker\}-DISPATCH_ORDER`\)\], \{ type: 'application\/pdf' \}\)/) + assert.doesNotMatch(source, /new Blob\(\[\s*`\$\{context\.marker\}-(?:BILL_OF_LADING|DISPATCH_ORDER)`\s*\], \{ type: 'application\/pdf' \}\)/) +}) test('booking prerequisite resolves an ACTIVE platform customer without fixture state', () => { assert.deepEqual(activeCustomerIdentity({ id: '21', tenantName: '现有客户' }), diff --git a/scripts/admin-test-env-modules/truck-appointments.mjs b/scripts/admin-test-env-modules/truck-appointments.mjs index 3fc3fae36..502eed3a5 100644 --- a/scripts/admin-test-env-modules/truck-appointments.mjs +++ b/scripts/admin-test-env-modules/truck-appointments.mjs @@ -14,6 +14,22 @@ function data(response) { return codeOf(response) === '200' ? response.body.data function failure(response) { return response?.http === 0 || response?.http >= 400 || (response?.http === 200 && codeOf(response) !== '200') } function records(value) { return Array.isArray(value) ? value : value?.records || [] } +export function minimalPdfBytes(label = 'truck-appointment-fixture') { + const safeLabel = String(label).replace(/[^\x20-\x7E]/g, '_').slice(0, 64) + return new TextEncoder().encode(`%PDF-1.4 +% ${safeLabel} +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Kids [] /Count 0 >> +endobj +trailer +<< /Root 1 0 R >> +%%EOF +`) +} + export function platformWarehouseIdentity(item) { return { warehouseId: id(item?.warehouseId || item?.id || item?.warehouse?.id), platformTenantId: '0' } } @@ -136,7 +152,7 @@ async function fixture(context) { const bookingId = id(created?.oceanBooking?.id); if (!bookingId) return null const form = new FormData(); form.append('expectedVersion', String(created.oceanBooking.version)) form.append('documentType', 'BILL_OF_LADING') - form.append('file', new Blob([`${context.marker}-BILL_OF_LADING`], { type: 'application/pdf' }), `bill-${suffix}.pdf`) + form.append('file', new Blob([minimalPdfBytes(`${context.marker}-BILL_OF_LADING`)], { type: 'application/pdf' }), `bill-${suffix}.pdf`) const uploadResponse = await context.requestMultipart('POST', `${BOOKINGS}/${bookingId}/documents/upload`, form, context.token, { 'Idempotency-Key': `${context.marker}-truck-booking-file`.slice(0, 128) }) const uploaded = context.apiSuccess(MODULE, '上传当前有效 BILL_OF_LADING 文件', uploadResponse, '', OPTIONS) @@ -186,7 +202,7 @@ export async function run(context) { && !Object.hasOwn(appointment, 'customerId') && !Object.hasOwn(appointment, 'customerName'), '', OPTIONS) const path = `${TRUCKS}/${appointmentId}` const attachmentForm = new FormData(); attachmentForm.append('attachmentType', 'DISPATCH_ORDER') - attachmentForm.append('file', new Blob([`${context.marker}-DISPATCH_ORDER`], { type: 'application/pdf' }), + attachmentForm.append('file', new Blob([minimalPdfBytes(`${context.marker}-DISPATCH_ORDER`)], { type: 'application/pdf' }), `dispatch-order-${identity.suffix}.pdf`) const uploadAttachmentResponse = await context.requestMultipart('POST', `${path}/attachments/upload`, attachmentForm, context.token) From 46f5a73c472f7434f2d03271af5b44d59ea5edb9 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 1 Sep 2026 14:46:44 +0800 Subject: [PATCH 30/37] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/agents-index-contract.ps1 | 33 +++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index 05fe90d99..562a5c1f9 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -94,6 +94,9 @@ $overseasPdaAgents = Read-RequiredText $overseasPdaAgentsPath $backendAgents = Read-RequiredText $backendAgentsPath $scriptsAgents = Read-RequiredText (Join-Path $backendRoot 'scripts/AGENTS.md') $engineeringConstraints = Read-RequiredText (Join-Path $workspaceRoot 'docs/AI工程执行与协作约束.md') +$docsIndex = Read-RequiredText (Join-Path $workspaceRoot 'docs/README.md') +$testTaskRegistry = Read-RequiredText (Join-Path $workspaceRoot 'docs/独立任务协作登记.md') +$devTaskRegistry = Read-RequiredText (Join-Path $workspaceRoot 'docs/开发环境独立任务协作登记.md') $mapperScanContract = Read-RequiredText (Join-Path $backendRoot 'scripts/mapper-scan-contract.mjs') # 当前未上线阶段的无兼容原则必须覆盖全部实现与运行状态,不能再次缩窄为文件或数据库专项规则。 @@ -113,6 +116,29 @@ Assert-Contains $backendAgents 'scripts/it-test-wait-split-bulk.mjs' '后端批 Assert-Contains $backendAgents 'scripts/create-pending-review-orders.mjs' '后端待审核订单造数入口' Assert-Contains $scriptsAgents 'admin-test-env-acceptance.mjs' '脚本指引 Admin 全量入口' Assert-Contains $scriptsAgents 'client-test-env-acceptance.mjs' '脚本指引 Client 全量入口' + +# 开发与测试独立任务必须按保存项目、cwd 和登记表完全隔离,task_id 不得跨表复用。 +Assert-Contains $rootAgents 'docs/开发环境独立任务协作登记.md' '根开发协作登记入口' +Assert-Contains $rootAgents 'docs/独立任务协作登记.md' '根测试协作登记入口' +Assert-Contains $engineeringConstraints '不得同时加载两表后混合选取任务' '工程协作登记选择门禁' +Assert-Contains $docsIndex '[测试环境独立任务协作登记](独立任务协作登记.md)' '文档测试登记入口' +Assert-Contains $docsIndex '[开发环境独立任务协作登记](开发环境独立任务协作登记.md)' '文档开发登记入口' +Assert-Contains $testTaskRegistry 'projectId `c64729ff-6710-43c6-8ef2-d84e9666cc1e`' '测试登记保存项目' +Assert-Contains $testTaskRegistry 'C:\Users\BY-OY\Desktop\code\chongchong-global`' '测试登记工作目录' +Assert-Contains $testTaskRegistry '`01a0194a-259c-7171-8967-074731a27618`' '测试环境系统总控' +Assert-NotContains $testTaskRegistry '01a05b10-a5fc-7d51-8203-c671f3a823ad' '测试登记开发总控' +Assert-NotContains $testTaskRegistry '6f874ec3-d3b2-49bd-8625-327f4d685f98' '测试登记开发 projectId' +Assert-Contains $devTaskRegistry 'projectId `6f874ec3-d3b2-49bd-8625-327f4d685f98`' '开发登记保存项目' +Assert-Contains $devTaskRegistry 'C:\Users\BY-OY\Desktop\code\chongchong-global-dev`' '开发登记工作目录' +Assert-Contains $devTaskRegistry '`01a05b10-a5fc-7d51-8203-c671f3a823ad`' '开发环境五期总控' +Assert-NotContains $devTaskRegistry '01a0194a-259c-7171-8967-074731a27618' '开发登记测试总控' +Assert-NotContains $devTaskRegistry 'c64729ff-6710-43c6-8ef2-d84e9666cc1e' '开发登记测试 projectId' +$testTaskIds = @([regex]::Matches($testTaskRegistry, '(?m)^\|\s*`([0-9a-f-]{36})`\s*\|') | ForEach-Object { $_.Groups[1].Value }) +$devTaskIds = @([regex]::Matches($devTaskRegistry, '(?m)^\|\s*`([0-9a-f-]{36})`\s*\|') | ForEach-Object { $_.Groups[1].Value }) +foreach ($sharedTaskId in @($testTaskIds | Where-Object { $devTaskIds -contains $_ } | Select-Object -Unique)) { + $script:checked++ + Add-Failure "开发/测试登记复用了 task_id: $sharedTaskId" +} Assert-Contains $scriptsAgents 'script-documentation-contract.test.mjs' '脚本说明防漏门禁' Assert-Contains $scriptsAgents '不得散落到 `src/test/scripts/`' '服务局部脚本目录边界' Assert-Path 'baiyue-firstleg/scripts/AGENTS.md' @@ -300,7 +326,8 @@ Assert-Contains $adminAgents '五期海外异常退回与客户回访' 'Admin Assert-Contains $backendAgents '五期海外排车执行、PDA 出库、派送与一件代发' '后端五期出库派送入口' Assert-Contains $backendAgents '五期一件代发 SKU/箱唛与暂扣跨区协同' '后端五期跨区协同入口' Assert-Contains $lastlegAgents '五期范围承接排车编辑/取消/发车' 'Lastleg 五期范围边界' -Assert-Contains $overseasPdaAgents '本工程尚未挂载对应 API、页面或工作台能力' '海外 PDA 五期未挂载边界' +Assert-Contains $overseasPdaAgents '五期排车拣货与发车' '海外 PDA 五期排车入口' +Assert-Contains $overseasPdaAgents '五期普通/一件代发出库' '海外 PDA 五期出库入口' # Admin 仅对静态资源过期做一次受控恢复,不能吞掉普通路由错误或覆盖未保存编辑。 $navigationRecovery = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/core/routing/navigationRecovery.ts') @@ -678,6 +705,8 @@ $criticalPaths = @( 'docs/墨家设备测量上传接口说明-V3.md', 'docs/三期/三期需求确认表.md', 'docs/统一订单主状态与操作契约.md', + 'docs/开发环境独立任务协作登记.md', + 'docs/独立任务协作登记.md', 'docs/财务管理/财务管理产品确认表.md', 'docs/财务管理/Firstleg-Finance跨服务契约.md' ) @@ -749,5 +778,5 @@ if ($failures.Count -gt 0) { exit 1 } -Write-Host "AGENTS 索引合同通过:$checked 项检查,覆盖快速表唯一性、部署单元、前端模块、Client active 路由、业务编号与内部 ID 隔离、装柜清单生命周期、订舱拥有拖车与柜级证据、四/五期范围边界、仓库主档唯一权威、订舱候选与柜量语义、整柜出仓与船节点单调性、Gateway 覆盖、PDA 页面/运行配置/独立更新/语言预留/能力开关、人工分段验收链、Maven 模块、Lastleg 海外安全投影、同步 Trace、关键入口和排除目录。" -ForegroundColor Green +Write-Host "AGENTS 索引合同通过:$checked 项检查,覆盖快速表唯一性、部署单元、开发/测试任务登记隔离、前端模块、Client active 路由、业务编号与内部 ID 隔离、装柜清单生命周期、订舱拥有拖车与柜级证据、四/五期范围边界、仓库主档唯一权威、订舱候选与柜量语义、整柜出仓与船节点单调性、Gateway 覆盖、PDA 页面/运行配置/独立更新/语言预留/能力开关、人工分段验收链、Maven 模块、Lastleg 海外安全投影、同步 Trace、关键入口和排除目录。" -ForegroundColor Green exit 0 From 76d2da5bf9ce41b5ee3d0c676278e6395cc019cd Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 1 Sep 2026 14:46:48 +0800 Subject: [PATCH 31/37] test(firstleg): use task-scoped loading evidence --- .../phase3-evidence-gate.mjs | 22 +- .../phase3-evidence-stage-contract.test.mjs | 4 +- ...e3-loading-dynamic-final-contract.test.mjs | 13 +- .../admin-test-env-modules/phase3-loading.mjs | 242 +++++++++--------- .../phase3-producer-chain-contract.test.mjs | 12 +- 5 files changed, 149 insertions(+), 144 deletions(-) diff --git a/scripts/admin-test-env-modules/phase3-evidence-gate.mjs b/scripts/admin-test-env-modules/phase3-evidence-gate.mjs index 9639671a9..f8c6f6da4 100644 --- a/scripts/admin-test-env-modules/phase3-evidence-gate.mjs +++ b/scripts/admin-test-env-modules/phase3-evidence-gate.mjs @@ -54,14 +54,18 @@ export async function run(context) { return } - const path = `/api/platform/v1/inbounds/${inboundId}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}` + const path = `/api/platform/v1/first-leg/pda-loading/tasks/${encodeURIComponent(taskId)}/evidence?warehouseId=${encodeURIComponent(warehouseId)}` const response = await context.request('GET', path) - const data = context.apiSuccess(MODULE, '复核本轮装柜证据聚合', response, `inboundId=${fixture.inboundId}`, OPTIONS) + const data = context.apiSuccess(MODULE, '复核本轮 task 柜级装柜证据聚合', response, + `taskId=${taskId}, containerBookingId=${containerBookingId}`, OPTIONS) const evidences = Array.isArray(data?.evidences) ? data.evidences : [] const required = ['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL'] const active = evidences.filter((item) => item.status !== 'VOID') - const voided = evidences.find((item) => id(item.id) === id(fixture.voidedEvidenceId)) - const missingTypeVoided = evidences.find((item) => id(item.id) === id(fixture.missingTypeVoidedEvidenceId)) + const voided = evidences.find((item) => id(item.evidenceId) === id(fixture.voidedEvidenceId)) + const missingTypeVoided = evidences.find((item) => id(item.evidenceId) === id(fixture.missingTypeVoidedEvidenceId)) + context.assert(MODULE, 'task 证据聚合归属稳定 PDA 任务和物理柜次', response, + id(data?.taskId) === taskId && id(data?.containerBookingId) === containerBookingId, + `taskId=${id(data?.taskId) || 'missing'}, containerBookingId=${id(data?.containerBookingId) || 'missing'}`, OPTIONS) context.assert(MODULE, '五类有效证据齐全且同类型多图追加不覆盖历史', response, required.every((type) => active.some((item) => item.evidenceType === type)) && evidences.filter((item) => item.evidenceType === 'PHOTO_STEP_1').length >= 2 @@ -69,15 +73,13 @@ export async function run(context) { && active.every((item) => String(item.fileName || '').includes(context.marker) && String(item.remark || '').includes(context.marker)), `active=${active.length}, types=${[...new Set(active.map((item) => item.evidenceType))].join(',')}`, OPTIONS) - context.assert(MODULE, '作废证据保留原因、操作者和时间且不计入有效门禁', response, - Boolean(voided) && voided.status === 'VOID' && voided.voidReason === `${context.marker}-wrong-photo` - && Boolean(voided.voidedBy) && Boolean(voided.voidedAt) - && !active.some((item) => id(item.id) === id(fixture.voidedEvidenceId)), + context.assert(MODULE, '作废证据保留稳定记录与更新时间且不计入有效门禁', response, + Boolean(voided) && voided.status === 'VOID' && Boolean(voided.updatedAt) + && !active.some((item) => id(item.evidenceId) === id(fixture.voidedEvidenceId)), `voidedEvidenceId=${id(fixture.voidedEvidenceId) || 'missing'}, status=${voided?.status || 'missing'}`, OPTIONS) context.assert(MODULE, '缺类作废审计保留且补传证据恢复有效门禁', response, Boolean(missingTypeVoided) && missingTypeVoided.status === 'VOID' - && missingTypeVoided.voidReason === `${context.marker}-temporary-missing-type` - && Boolean(missingTypeVoided.voidedBy) && Boolean(missingTypeVoided.voidedAt) + && Boolean(missingTypeVoided.updatedAt) && active.some((item) => item.evidenceType === 'PHOTO_STEP_4' && id(item.fileId) === id(fixture.replacementEvidenceFileId)), `voidedEvidenceId=${id(fixture.missingTypeVoidedEvidenceId) || 'missing'}, replacementFileId=${id(fixture.replacementEvidenceFileId) || 'missing'}`, OPTIONS) diff --git a/scripts/admin-test-env-modules/phase3-evidence-stage-contract.test.mjs b/scripts/admin-test-env-modules/phase3-evidence-stage-contract.test.mjs index aaf0d0a81..a7dbf7da9 100644 --- a/scripts/admin-test-env-modules/phase3-evidence-stage-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-evidence-stage-contract.test.mjs @@ -19,5 +19,7 @@ test('evidence gate requires stable PDA identity and server LOADING before any e assert.match(source, /AWAITING_REVIEW 不作为证据开放条件/) const gate = source.slice(source.indexOf('const taskResponse'), source.indexOf('const path =')) assert.doesNotMatch(gate, /loading\/evidence/) - assert.match(source, /const path = `\/api\/platform\/v1\/inbounds\/\$\{inboundId\}\/loading\/evidence/) + assert.match(source, /const path = `\/api\/platform\/v1\/first-leg\/pda-loading\/tasks\/\$\{encodeURIComponent\(taskId\)\}\/evidence\?warehouseId=/) + assert.doesNotMatch(source, /api\/platform\/v1\/inbounds\/\$\{inboundId\}\/loading\/evidence/) + assert.doesNotMatch(source, /loading\/evidence\?tenantId=/) }) diff --git a/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs b/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs index 156049cef..e71b8e220 100644 --- a/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs @@ -53,16 +53,17 @@ test('default fixture publishes a second marker-owned inbound for the same-plan test('HTTP chain refreshes after stale inventory and keeps one stable PDA identity', () => { assert.match(loading, - /async function uploadEvidence\(context, containerBookingId, evidenceType\)/) + /async function uploadEvidence\(context, taskId, warehouseId, evidenceType, expectedVersion, keySuffix,/) assert.match(loading, - /files\/loading-evidence\/\$\{containerBookingId\}\?evidenceType=/) + /first-leg\/pda-loading\/tasks\/\$\{taskId\}\/evidence\?\$\{query\}/) assert.match(loading, - /files\/loading-evidence\/\$\{containerBookingId\}\/\$\{fileId\}/) - assert.equal((loading.match(/uploadEvidence\(context, containerBookingId,/g) || []).length, 5) + /\$\{evidencePath\}\/\$\{encodeURIComponent\(evidenceItem\.id\)\}\/content\?warehouseId=/) + assert.match(loading, /\$\{evidencePath\}\/\$\{encodeURIComponent\(id\(voidTarget\.id\)\)\}\/void/) assert.doesNotMatch(loading, - /files\/loading-evidence\/\$\{tenantId\}\/\$\{inboundId\}/) + /api\/platform\/v1\/files\/loading-evidence/) assert.doesNotMatch(loading, - /uploadEvidence\(context, tenantId, inboundId,/) + /\$\{base\}\/loading\/evidence|api\/platform\/v1\/inbounds\/\$\{inboundId\}\/loading\/evidence/) + assert.doesNotMatch(loading, /uploadEvidence\(context, (tenantId|inboundId|containerBookingId),/) ordered(loading, [ '创建三期 DRAFT 订舱(不生成真实柜)', '三期 DRAFT 订舱返回稳定身份和初始版本', diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 578277af8..4db161c7f 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -77,15 +77,20 @@ async function request(context, method, path, body, headers = {}) { } catch (error) { return { method, path, http: 0, body: null, raw: String(error), requestId: null, traceId: null } } } -async function uploadEvidence(context, containerBookingId, evidenceType) { - const path = `/api/platform/v1/files/loading-evidence/${containerBookingId}?evidenceType=${encodeURIComponent(evidenceType)}` +async function uploadEvidence(context, taskId, warehouseId, evidenceType, expectedVersion, keySuffix, + { sealNo = null, remark = context.marker, fileSuffix = evidenceType } = {}) { + const query = new URLSearchParams({ warehouseId: String(warehouseId), expectedVersion: String(expectedVersion), + evidenceType, remark }) + if (sealNo != null) query.set('sealNo', sealNo) + const path = `/api/platform/v1/first-leg/pda-loading/tasks/${taskId}/evidence?${query}` const form = new FormData() const png = Uint8Array.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,6,0,0,0,31,21,196,137,0,0,0,13,73,68,65,84,8,215,99,248,207,192,240,31,0,5,0,1,255,137,153,61,29,0,0,0,0,73,69,78,68,174,66,96,130]) - const fileName = `${context.marker}-${evidenceType.toLowerCase()}.png` + const fileName = `${context.marker}-${String(fileSuffix).toLowerCase()}.png` form.append('file', new Blob([png], { type: 'image/png' }), fileName) try { const response = await fetch(`${context.options.gateway}${path}`, { - method: 'POST', headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}` }, + method: 'POST', headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}`, + 'Idempotency-Key': `${context.marker}-p3-evidence-${keySuffix}`.slice(0, 128) }, body: form, signal: AbortSignal.timeout(60_000), }) const raw = await response.text(); let payload = null @@ -99,17 +104,6 @@ async function uploadEvidence(context, containerBookingId, evidenceType) { } } -async function bindEvidence(context, base, tenantId, evidenceType, fileId, suffix, keySuffix = evidenceType) { - return request(context, 'POST', `${base}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`, { - expectedVersion: 0, - evidenceType, - fileId, - fileName: 'client-must-not-override.png', - sealNo: evidenceType === 'SEAL' ? `SEAL-${suffix}` : null, - remark: context.marker, - }, { 'Idempotency-Key': `${context.marker}-p3-evidence-${keySuffix}`.slice(0, 128) }) -} - function fixture(context) { return context.createdResources?.orchestrationFixtures?.phase3Loading || context.createdResources?.orchestrationFixtures?.inboundMeasurement @@ -895,47 +889,45 @@ export async function run(context) { const evidenceTypes = ['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL'] const evidenceItems = [] + const evidencePath = `${pdaBase}/tasks/${canonicalTaskId}/evidence` + const normalizedEvidence = (item) => item ? { ...item, id: id(item.evidenceId) } : null for (const evidenceType of evidenceTypes) { - const upload = await uploadEvidence(context, containerBookingId, evidenceType) - const uploaded = context.apiSuccess(MODULE, `上传本轮 ${evidenceType} 真实证据文件`, upload, '', OPTIONS) - const fileId = id(uploaded?.fileId) - context.assert(MODULE, `${evidenceType} 文件归属类型和媒体元数据正确`, upload, - Boolean(fileId) && uploaded?.businessType === `LOADING_${evidenceType}` - && uploaded?.contentType === 'image/png' && Number(uploaded?.sizeBytes) > 0, - `fileId=${fileId || 'missing'}, businessType=${uploaded?.businessType || 'missing'}`, OPTIONS) - if (!fileId) return - - const evidence = await bindEvidence(context, base, tenantId, evidenceType, fileId, suffix) - const evidenceData = context.apiSuccess(MODULE, `绑定本轮 ${evidenceType} 装柜证据`, evidence, '', OPTIONS) - const evidenceItem = evidenceData?.evidences?.evidences?.find?.((item) => item.evidenceType === evidenceType) - context.assert(MODULE, `${evidenceType} 证据归属、marker 和版本正确`, evidence, - evidenceData?.mainStatus === 'LOADING' && id(evidenceItem?.fileId) === fileId + const sealNo = evidenceType === 'SEAL' ? `SEAL-${suffix}` : null + const upload = await uploadEvidence(context, canonicalTaskId, warehouseId, evidenceType, 0, + evidenceType, { sealNo }) + const evidenceData = context.apiSuccess(MODULE, `通过 PDA task 原子上传并绑定 ${evidenceType} 证据`, upload, '', OPTIONS) + const evidenceItem = normalizedEvidence(evidenceData?.evidences?.find?.((item) => + item.evidenceType === evidenceType && item.status !== 'VOID')) + const fileId = id(evidenceItem?.fileId) + context.assert(MODULE, `${evidenceType} 证据归属 task、柜次、marker 和版本`, upload, + id(evidenceData?.taskId) === canonicalTaskId + && id(evidenceData?.containerBookingId) === containerBookingId + && Boolean(evidenceItem?.id) && Boolean(fileId) && evidenceItem?.fileName === `${context.marker}-${evidenceType.toLowerCase()}.png` && evidenceItem?.remark === context.marker && Number(evidenceItem?.version) === 0 - && (evidenceType !== 'SEAL' || evidenceItem?.sealNo === `SEAL-${suffix}`), - `mainStatus=${evidenceData?.mainStatus || 'missing'}, evidenceId=${id(evidenceItem?.id) || 'missing'}`, OPTIONS) + && (evidenceType !== 'SEAL' || evidenceItem?.sealNo === sealNo), + `taskId=${id(evidenceData?.taskId) || 'missing'}, containerBookingId=${id(evidenceData?.containerBookingId) || 'missing'}, evidenceId=${id(evidenceItem?.id) || 'missing'}`, OPTIONS) if (!evidenceItem?.id) return evidenceItems.push(evidenceItem) if (evidenceType === 'PHOTO_STEP_1') { - const replay = await bindEvidence(context, base, tenantId, evidenceType, fileId, suffix) - const replayData = context.apiSuccess(MODULE, '装柜证据绑定同键同参幂等重放', replay, '', OPTIONS) - const replayItems = replayData?.evidences?.evidences?.filter?.((item) => item.evidenceType === evidenceType) || [] - context.assert(MODULE, '证据绑定重放返回同一记录且不重复追加', replay, - replayItems.length === 1 && id(replayItems[0]?.id) === id(evidenceItem.id), - `evidenceId=${id(replayItems[0]?.id) || 'missing'}, count=${replayItems.length}`, OPTIONS) - const conflict = await request(context, 'POST', `${base}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`, { - expectedVersion: 0, evidenceType, fileId, - fileName: 'client-must-not-override.png', sealNo: null, remark: `${context.marker}-different`, - }, { 'Idempotency-Key': `${context.marker}-p3-evidence-${evidenceType}`.slice(0, 128) }) - context.addStep(MODULE, '装柜证据绑定同键异参返回冲突', conflict, + const replay = await uploadEvidence(context, canonicalTaskId, warehouseId, evidenceType, 0, + evidenceType) + const replayData = context.apiSuccess(MODULE, '装柜证据 task 上传同键同载荷重放', replay, '', OPTIONS) + const replayItems = replayData?.evidences?.filter?.((item) => item.evidenceType === evidenceType) || [] + context.assert(MODULE, '证据 task 上传重放返回同一记录且不重复追加', replay, + replayItems.length === 1 && id(replayItems[0]?.evidenceId) === id(evidenceItem.id), + `evidenceId=${id(replayItems[0]?.evidenceId) || 'missing'}, count=${replayItems.length}`, OPTIONS) + const conflict = await uploadEvidence(context, canonicalTaskId, warehouseId, evidenceType, 0, + evidenceType, { remark: `${context.marker}-different` }) + context.addStep(MODULE, '装柜证据 task 上传同键异载荷返回冲突', conflict, conflict.http === 200 && codeOf(conflict) === 'A0409' && Boolean(conflict.requestId) && Boolean(conflict.traceId), `HTTP=${conflict.http}, code=${codeOf(conflict)}, requestId=${conflict.requestId || 'missing'}, traceId=${conflict.traceId || 'missing'}`, OPTIONS) } const download = await context.requestBinary('GET', - `/api/platform/v1/files/loading-evidence/${containerBookingId}/${fileId}`) + `${evidencePath}/${encodeURIComponent(evidenceItem.id)}/content?warehouseId=${encodeURIComponent(warehouseId)}`) context.assert(MODULE, `下载本轮 ${evidenceType} 证据文件`, download, download.http === 200 && download.contentType.toLowerCase().includes('image/png') && download.bytes.length > 0, `bytes=${download.bytes.length}, contentType=${download.contentType || 'missing'}`, OPTIONS) @@ -949,95 +941,100 @@ export async function run(context) { const firstPhoto = evidenceItems.find((item) => item.evidenceType === 'PHOTO_STEP_1') const sealEvidence = evidenceItems.find((item) => item.evidenceType === 'SEAL') - const appendedUpload = await uploadEvidence(context, containerBookingId, 'PHOTO_STEP_1') - const appendedFile = context.apiSuccess(MODULE, '追加同类型第二张装柜证据文件', appendedUpload, '', OPTIONS) - const appendedFileId = id(appendedFile?.fileId) - const appended = appendedFileId - ? await bindEvidence(context, base, tenantId, 'PHOTO_STEP_1', appendedFileId, suffix, 'PHOTO_STEP_1_APPEND') - : appendedUpload - const appendedData = context.apiSuccess(MODULE, '追加同类型第二张装柜证据', appended, '', OPTIONS) - const appendedItems = appendedData?.evidences?.evidences?.filter?.((item) => item.evidenceType === 'PHOTO_STEP_1') || [] - context.assert(MODULE, '同类型装柜证据支持多图追加且不替换历史', appended, + const appended = await uploadEvidence(context, canonicalTaskId, warehouseId, 'PHOTO_STEP_1', + Number(firstPhoto?.version), 'PHOTO_STEP_1_APPEND', { fileSuffix: 'PHOTO_STEP_1_APPEND' }) + const appendedData = context.apiSuccess(MODULE, '追加同类型第二张 task 装柜证据', appended, '', OPTIONS) + const appendedItems = appendedData?.evidences?.filter?.((item) => item.evidenceType === 'PHOTO_STEP_1') || [] + const appendedActive = normalizedEvidence(appendedItems.find((item) => item.status !== 'VOID')) + const appendedFileId = id(appendedActive?.fileId) + context.assert(MODULE, '同类型装柜证据追加保留历史并唯一激活新图', appended, Boolean(appendedFileId) && appendedItems.length >= 2 - && appendedItems.some((item) => id(item.fileId) === id(firstPhoto?.fileId)) - && appendedItems.some((item) => id(item.fileId) === appendedFileId), + && appendedItems.some((item) => id(item.fileId) === id(firstPhoto?.fileId) && item.status === 'VOID') + && id(appendedActive?.fileId) === appendedFileId, `photoStep1Count=${appendedItems.length}, original=${id(firstPhoto?.fileId) || 'missing'}, appended=${appendedFileId || 'missing'}`, OPTIONS) - const wrongType = await bindEvidence(context, base, tenantId, 'PHOTO_STEP_2', id(firstPhoto?.fileId), suffix, 'wrong-type') - context.addStep(MODULE, '不同证据类型的真实 fileId 不可交叉绑定', wrongType, failure(wrongType), + const wrongType = await uploadEvidence(context, canonicalTaskId, warehouseId, 'PHOTO_STEP_9', 0, + 'wrong-type', { fileSuffix: 'wrong-type' }) + context.addStep(MODULE, '不在白名单的证据类型不可上传绑定', wrongType, failure(wrongType), `HTTP=${wrongType.http}, code=${codeOf(wrongType)}`, OPTIONS) - const forged = await request(context, 'POST', `${base}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`, { - expectedVersion: Number(sealEvidence?.version), evidenceType: 'SEAL', fileId: '999999999999999999', - fileName: `${context.marker}-forged.png`, sealNo: `FORGED-${suffix}`, remark: context.marker, - }, { 'Idempotency-Key': `${context.marker}-p3-forged-evidence`.slice(0, 128) }) - context.addStep(MODULE, '伪造装柜证据 fileId 归属校验拒绝', forged, failure(forged), + const forged = await context.request('GET', + `${evidencePath}/999999999999999999/content?warehouseId=${encodeURIComponent(warehouseId)}`) + context.addStep(MODULE, '伪造装柜 evidenceId 无法读取任务证据内容', forged, + (forged.http === 200 && codeOf(forged) === 'A0404') || forged.http === 404, `HTTP=${forged.http}, code=${codeOf(forged)}`, OPTIONS) - const missingSealText = await request(context, 'POST', `${base}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`, { - expectedVersion: Number(sealEvidence?.version), evidenceType: 'SEAL', fileId: id(sealEvidence?.fileId), - fileName: `${context.marker}-missing-seal-text.png`, sealNo: ' ', remark: context.marker, - }, { 'Idempotency-Key': `${context.marker}-p3-missing-seal-text`.slice(0, 128) }) + const beforeMissingSealResponse = await context.request('GET', + `${evidencePath}?warehouseId=${encodeURIComponent(warehouseId)}`) + const beforeMissingSeal = beforeMissingSealResponse.body?.data?.evidences || [] + const missingSealText = await uploadEvidence(context, canonicalTaskId, warehouseId, 'SEAL', + Number(sealEvidence?.version), 'missing-seal-text', { sealNo: ' ', fileSuffix: 'missing-seal-text' }) context.addStep(MODULE, '带锁码照片缺少锁码文本时拒绝绑定', missingSealText, failure(missingSealText), `HTTP=${missingSealText.http}, code=${codeOf(missingSealText)}`, OPTIONS) + const afterMissingSealResponse = await context.request('GET', + `${evidencePath}?warehouseId=${encodeURIComponent(warehouseId)}`) + context.assert(MODULE, '缺锁码文本失败后 task 证据零漂移', afterMissingSealResponse, + JSON.stringify(afterMissingSealResponse.body?.data?.evidences || []) === JSON.stringify(beforeMissingSeal), + `before=${beforeMissingSeal.length}, after=${afterMissingSealResponse.body?.data?.evidences?.length ?? 'missing'}`, OPTIONS) - const voidTarget = appendedItems.find((item) => id(item.fileId) === appendedFileId) + const voidTarget = appendedActive const voidResponse = voidTarget - ? await request(context, 'POST', `${base}/loading/evidence/${encodeURIComponent(id(voidTarget.id))}/void?tenantId=${encodeURIComponent(tenantId)}`, - { expectedVersion: Number(voidTarget.version), reason: `${context.marker}-wrong-photo` }, - { 'Idempotency-Key': `${context.marker}-p3-evidence-void`.slice(0, 128) }) + ? await request(context, 'POST', `${evidencePath}/${encodeURIComponent(id(voidTarget.id))}/void`, + { warehouseId, expectedVersion: Number(voidTarget.version), reason: `${context.marker}-wrong-photo` }) : appended const voidData = context.apiSuccess(MODULE, '作废错误装柜证据并保留审计', voidResponse, '', OPTIONS) context.assert(MODULE, '错图作废后原记录保留且不再计入有效证据', voidResponse, - Boolean(voidTarget) && voidData?.evidences?.evidences?.some?.((item) => id(item.id) === id(voidTarget.id) - && (item.status === 'VOID' || item.valid === false) && item.voidReason === `${context.marker}-wrong-photo`), + Boolean(voidTarget) && voidData?.evidences?.some?.((item) => id(item.evidenceId) === id(voidTarget.id) + && item.status === 'VOID' && Boolean(item.updatedAt)), `evidenceId=${id(voidTarget?.id) || 'missing'}, status=${voidTarget?.status || 'missing'}`, OPTIONS) + const restoredPhotoResponse = await uploadEvidence(context, canonicalTaskId, warehouseId, 'PHOTO_STEP_1', 0, + 'PHOTO_STEP_1_RESTORE', { fileSuffix: 'PHOTO_STEP_1_RESTORE' }) + const restoredPhotoData = context.apiSuccess(MODULE, '错图作废后恢复 PHOTO_STEP_1 有效证据', restoredPhotoResponse, '', OPTIONS) + const restoredPhoto = normalizedEvidence(restoredPhotoData?.evidences?.find?.((item) => + item.evidenceType === 'PHOTO_STEP_1' && item.status !== 'VOID')) + context.assert(MODULE, 'PHOTO_STEP_1 恢复后旧记录保留且新记录唯一有效', restoredPhotoResponse, + Boolean(restoredPhoto?.id) && restoredPhotoData?.evidences?.some?.((item) => + id(item.evidenceId) === id(voidTarget?.id) && item.status === 'VOID'), + `restoredEvidenceId=${id(restoredPhoto?.id) || 'missing'}`, OPTIONS) + const missingTypeTarget = evidenceItems.find((item) => item.evidenceType === 'PHOTO_STEP_4') - const missingTypePath = `${base}/loading/evidence/${encodeURIComponent(id(missingTypeTarget?.id))}/void?tenantId=${encodeURIComponent(tenantId)}` + const missingTypePath = `${evidencePath}/${encodeURIComponent(id(missingTypeTarget?.id))}/void` const staleMissingTypeVoid = missingTypeTarget ? await request(context, 'POST', missingTypePath, - { expectedVersion: Number(missingTypeTarget.version) + 1, reason: `${context.marker}-stale-version` }, - { 'Idempotency-Key': `${context.marker}-p3-evidence-void-stale`.slice(0, 128) }) + { warehouseId, expectedVersion: Number(missingTypeTarget.version) + 1, reason: `${context.marker}-stale-version` }) : voidResponse context.addStep(MODULE, '装柜证据作废旧版本返回冲突', staleMissingTypeVoid, Boolean(missingTypeTarget) && staleMissingTypeVoid.http === 200 && codeOf(staleMissingTypeVoid) === 'A0409', `HTTP=${staleMissingTypeVoid.http}, code=${codeOf(staleMissingTypeVoid)}`, OPTIONS) - const missingTypeVoidKey = `${context.marker}-p3-evidence-void-missing-type`.slice(0, 128) const missingTypeVoidBody = { - expectedVersion: Number(missingTypeTarget?.version), reason: `${context.marker}-temporary-missing-type`, + warehouseId, expectedVersion: Number(missingTypeTarget?.version), reason: `${context.marker}-temporary-missing-type`, } const missingTypeVoid = missingTypeTarget - ? await request(context, 'POST', missingTypePath, missingTypeVoidBody, - { 'Idempotency-Key': missingTypeVoidKey }) + ? await request(context, 'POST', missingTypePath, missingTypeVoidBody) : voidResponse const missingTypeVoidData = context.apiSuccess(MODULE, '临时作废唯一 PHOTO_STEP_4 证据', missingTypeVoid, '', OPTIONS) context.assert(MODULE, '临时作废后 PHOTO_STEP_4 不再计入有效证据', missingTypeVoid, - Boolean(missingTypeTarget) && missingTypeVoidData?.evidences?.evidences?.some?.((item) => - id(item.id) === id(missingTypeTarget.id) && item.status === 'VOID'), + Boolean(missingTypeTarget) && missingTypeVoidData?.evidences?.some?.((item) => + id(item.evidenceId) === id(missingTypeTarget.id) && item.status === 'VOID'), `evidenceId=${id(missingTypeTarget?.id) || 'missing'}`, OPTIONS) const missingTypeVoidReplay = missingTypeTarget - ? await request(context, 'POST', missingTypePath, missingTypeVoidBody, - { 'Idempotency-Key': missingTypeVoidKey }) + ? await request(context, 'POST', missingTypePath, missingTypeVoidBody) : voidResponse - context.assert(MODULE, '证据作废同键同参重放保持同一 VOID 结果', missingTypeVoidReplay, - missingTypeVoidReplay.http === 200 && codeOf(missingTypeVoidReplay) === '200' - && missingTypeVoidReplay.body?.data?.evidences?.evidences?.some?.((item) => - id(item.id) === id(missingTypeTarget?.id) && item.status === 'VOID'), + context.assert(MODULE, '已作废 task 证据重复作废返回冲突', missingTypeVoidReplay, + missingTypeVoidReplay.http === 200 && codeOf(missingTypeVoidReplay) === 'A0409', `HTTP=${missingTypeVoidReplay.http}, code=${codeOf(missingTypeVoidReplay)}`, OPTIONS) const missingTypeVoidConflict = missingTypeTarget ? await request(context, 'POST', missingTypePath, - { ...missingTypeVoidBody, reason: `${context.marker}-different-void-reason` }, - { 'Idempotency-Key': missingTypeVoidKey }) + { ...missingTypeVoidBody, reason: `${context.marker}-different-void-reason` }) : voidResponse - context.addStep(MODULE, '证据作废同键异参返回冲突', missingTypeVoidConflict, + context.addStep(MODULE, '已作废 task 证据异参重试仍返回冲突', missingTypeVoidConflict, missingTypeVoidConflict.http === 200 && codeOf(missingTypeVoidConflict) === 'A0409', `HTTP=${missingTypeVoidConflict.http}, code=${codeOf(missingTypeVoidConflict)}`, OPTIONS) const duplicateVoid = missingTypeTarget - ? await request(context, 'POST', missingTypePath, missingTypeVoidBody, - { 'Idempotency-Key': `${context.marker}-p3-evidence-void-duplicate`.slice(0, 128) }) + ? await request(context, 'POST', missingTypePath, missingTypeVoidBody) : voidResponse context.addStep(MODULE, '已作废证据使用新请求重复作废返回冲突', duplicateVoid, duplicateVoid.http === 200 && codeOf(duplicateVoid) === 'A0409', @@ -1046,51 +1043,53 @@ export async function run(context) { context.addStep(MODULE, '作废唯一证据类型后不提前占用异步完成命令', null, true, 'PHOTO_STEP_4 已真实作废并回读;补传恢复后才允许最终阶段提交唯一完成命令', OPTIONS) - const replacementUpload = await uploadEvidence(context, containerBookingId, 'PHOTO_STEP_4') - const replacementFile = context.apiSuccess(MODULE, '补传缺失 PHOTO_STEP_4 真实文件', replacementUpload, '', OPTIONS) - const replacementFileId = id(replacementFile?.fileId) - const replacementBind = replacementFileId - ? await bindEvidence(context, base, tenantId, 'PHOTO_STEP_4', replacementFileId, suffix, 'PHOTO_STEP_4_RESTORE') - : replacementUpload - const replacementData = context.apiSuccess(MODULE, '补传恢复 PHOTO_STEP_4 有效证据', replacementBind, '', OPTIONS) - context.assert(MODULE, '补传后五类有效证据恢复且旧 VOID 记录保留', replacementBind, + const replacementUpload = await uploadEvidence(context, canonicalTaskId, warehouseId, 'PHOTO_STEP_4', 0, + 'PHOTO_STEP_4_RESTORE', { fileSuffix: 'PHOTO_STEP_4_RESTORE' }) + const replacementData = context.apiSuccess(MODULE, '补传恢复 PHOTO_STEP_4 有效证据', replacementUpload, '', OPTIONS) + const replacementItem = normalizedEvidence(replacementData?.evidences?.find?.((item) => + item.evidenceType === 'PHOTO_STEP_4' && item.status !== 'VOID')) + const replacementFileId = id(replacementItem?.fileId) + context.assert(MODULE, '补传后五类有效证据恢复且旧 VOID 记录保留', replacementUpload, Boolean(replacementFileId) - && replacementData?.evidences?.evidences?.some?.((item) => item.evidenceType === 'PHOTO_STEP_4' + && replacementData?.evidences?.some?.((item) => item.evidenceType === 'PHOTO_STEP_4' && id(item.fileId) === replacementFileId && item.status !== 'VOID') - && replacementData?.evidences?.evidences?.some?.((item) => id(item.id) === id(missingTypeTarget?.id) + && replacementData?.evidences?.some?.((item) => id(item.evidenceId) === id(missingTypeTarget?.id) && item.status === 'VOID'), `replacementFileId=${replacementFileId || 'missing'}`, OPTIONS) const customerEvidence = merchantToken - ? await request(context, 'GET', `${base}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`, + ? await request(context, 'GET', `${evidencePath}?warehouseId=${encodeURIComponent(warehouseId)}`, undefined, { Authorization: `Bearer ${merchantToken}` }) : merchantLogin context.addStep(MODULE, '客户身份无权访问平台装柜证据', customerEvidence, customerEvidence.http === 403 && codeOf(customerEvidence) === 'A0403' && Boolean(customerEvidence.requestId) && Boolean(customerEvidence.traceId), `HTTP=${customerEvidence.http}, code=${codeOf(customerEvidence)}, requestId=${customerEvidence.requestId || 'missing'}, traceId=${customerEvidence.traceId || 'missing'}`, OPTIONS) - const wrongTenantEvidence = await context.request('GET', `${base}/loading/evidence?tenantId=${encodeURIComponent(Number(tenantId) + 1)}`) - context.addStep(MODULE, '错误客户作用域不可读取本轮装柜证据', wrongTenantEvidence, - wrongTenantEvidence.http === 200 && codeOf(wrongTenantEvidence) === 'A0404', - `HTTP=${wrongTenantEvidence.http}, code=${codeOf(wrongTenantEvidence)}`, OPTIONS) - const wrongInboundEvidence = await context.request('GET', - `/api/platform/v1/inbounds/999999999999999999/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`) - context.addStep(MODULE, '错误入库单不可读取本轮装柜证据', wrongInboundEvidence, - wrongInboundEvidence.http === 200 && codeOf(wrongInboundEvidence) === 'A0404', - `HTTP=${wrongInboundEvidence.http}, code=${codeOf(wrongInboundEvidence)}`, OPTIONS) + const wrongWarehouseEvidence = await context.request('GET', + `${evidencePath}?warehouseId=${encodeURIComponent(Number(warehouseId) + 1)}`) + context.addStep(MODULE, '错误仓库作用域不可读取本轮 task 装柜证据', wrongWarehouseEvidence, + wrongWarehouseEvidence.http === 403 && codeOf(wrongWarehouseEvidence) === 'A0403', + `HTTP=${wrongWarehouseEvidence.http}, code=${codeOf(wrongWarehouseEvidence)}`, OPTIONS) + const wrongTaskEvidence = await context.request('GET', + `${pdaBase}/tasks/999999999999999999/evidence?warehouseId=${encodeURIComponent(warehouseId)}`) + context.addStep(MODULE, '错误 taskId 不可读取本轮装柜证据', wrongTaskEvidence, + wrongTaskEvidence.http === 200 && codeOf(wrongTaskEvidence) === 'A0404', + `HTTP=${wrongTaskEvidence.http}, code=${codeOf(wrongTaskEvidence)}`, OPTIONS) - const postLoadedUpload = await uploadEvidence(context, containerBookingId, 'PHOTO_STEP_2') - const postLoadedFile = context.apiSuccess(MODULE, '预置 LOADED 后新增证据边界文件', postLoadedUpload, '', OPTIONS) - const postLoadedFileId = id(postLoadedFile?.fileId) - - const evidenceList = await context.request('GET', `${base}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`) + const evidenceList = await context.request('GET', + `${evidencePath}?warehouseId=${encodeURIComponent(warehouseId)}`) const evidenceListData = context.apiSuccess(MODULE, '回查五类装柜证据列表', evidenceList, '', OPTIONS) + const activeEvidenceList = evidenceListData?.evidences?.filter?.((item) => item.status !== 'VOID') || [] context.assert(MODULE, '五类装柜证据完整且铅封号持久化', evidenceList, - evidenceListData?.evidences?.length >= evidenceTypes.length - && evidenceTypes.every((type) => evidenceListData.evidences.some((item) => item.evidenceType === type)) - && evidenceListData.evidences.some((item) => item.evidenceType === 'SEAL' && item.sealNo === `SEAL-${suffix}`), + id(evidenceListData?.taskId) === canonicalTaskId + && id(evidenceListData?.containerBookingId) === containerBookingId + && evidenceTypes.every((type) => activeEvidenceList.some((item) => item.evidenceType === type)) + && activeEvidenceList.some((item) => item.evidenceType === 'SEAL' && item.sealNo === `SEAL-${suffix}`), `evidences=${evidenceListData?.evidences?.length ?? 'n/a'}`, OPTIONS) + const immutablePhoto = restoredPhoto + const postLoadedFileId = null + const incrementalFixture = context.createdResources?.orchestrationFixtures?.phase3LoadingIncremental const incrementalInboundId = id(incrementalFixture?.inboundId) let skipBatchVerified = false @@ -1242,7 +1241,8 @@ export async function run(context) { const incrementedTask = context.apiSuccess(MODULE, '增量后回查稳定 PDA 四元身份', incrementedTaskResponse, '', OPTIONS) const incrementalTaskItem = incrementedTask?.items?.find?.((item) => id(item?.loadingPlanItemId) === id(incrementalPlanItem?.id)) - const evidenceAfterIncrementResponse = await context.request('GET', `${base}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`) + const evidenceAfterIncrementResponse = await context.request('GET', + `${evidencePath}?warehouseId=${encodeURIComponent(warehouseId)}`) const evidenceAfterIncrement = context.apiSuccess(MODULE, '增量后回查既有五类证据', evidenceAfterIncrementResponse, '', OPTIONS) context.assert(MODULE, '增量保持稳定四元身份、LOADING 不回退并撤回最终完成门禁', incrementResponse, id(incrementedPlan?.id) === loadingPlanId && incrementedPlan?.status === 'LOADING' @@ -1418,7 +1418,7 @@ export async function run(context) { evidenceIds: evidenceItems.map((item) => id(item.id)), voidedEvidenceId: id(voidTarget?.id), evidenceTypes, sealNo: `SEAL-${suffix}`, missingTypeVoidedEvidenceId: id(missingTypeTarget?.id), replacementEvidenceFileId: replacementFileId, - immutableEvidenceId: id(firstPhoto?.id), immutableEvidenceVersion: Number(firstPhoto?.version), + immutableEvidenceId: id(immutablePhoto?.id), immutableEvidenceVersion: Number(immutablePhoto?.version), postLoadedFileId, oceanBookingId: bookingId, loadingPlanId, containerBookingId, containerNo, warehouseId, canonicalPdaTaskId: canonicalTaskId, diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs index 4ac14ceee..8573d0854 100644 --- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs @@ -669,18 +669,18 @@ test('customs and finalize consume the same loading inbound identity', () => { test('loading evidence acceptance covers idempotency, reversible missing-type gate and scope isolation', () => { const loading = source('./phase3-loading.mjs') assertOrder(loading, [ - '装柜证据绑定同键同参幂等重放', - '装柜证据绑定同键异参返回冲突', + '装柜证据 task 上传同键同载荷重放', + '装柜证据 task 上传同键异载荷返回冲突', '装柜证据作废旧版本返回冲突', '临时作废唯一 PHOTO_STEP_4 证据', - '证据作废同键同参重放保持同一 VOID 结果', - '证据作废同键异参返回冲突', + '已作废 task 证据重复作废返回冲突', + '已作废 task 证据异参重试仍返回冲突', '已作废证据使用新请求重复作废返回冲突', '作废唯一证据类型后不提前占用异步完成命令', '补传恢复 PHOTO_STEP_4 有效证据', '客户身份无权访问平台装柜证据', - '错误客户作用域不可读取本轮装柜证据', - '错误入库单不可读取本轮装柜证据', + '错误仓库作用域不可读取本轮 task 装柜证据', + '错误 taskId 不可读取本轮装柜证据', ]) assert.match(loading, /missingTypeVoidedEvidenceId/) assert.match(loading, /replacementEvidenceFileId/) From 0e1ad4ed452a9d9c78138c3f83fb49a485c5c51a Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 1 Sep 2026 14:47:43 +0800 Subject: [PATCH 32/37] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../baiyue/lastleg/service/LoadingCompletedConsumerTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java index 33e235730..be116ca8c 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java @@ -5,8 +5,8 @@ class LoadingCompletedConsumerTest { private final Map frozenContainerNos=new HashMap<>(); @Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegInboundOrderMapper inboundOrders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock InboundPlanCartonMapper cartons; @Mock DropshipOrderService dropshipOrders; @Mock InventoryCommandService inventory; @Mock com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper shippingGates; @Mock LastlegOrderNoDailySequenceMapper sequences; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service; @BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"inboundOrderMapper",inboundOrders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inventory",inventory); ReflectionTestUtils.setField(service,"shippingGates",shippingGates); ReflectionTestUtils.setField(service,"objectMapper",json); LastlegOrderNoGenerator generator=new LastlegOrderNoGenerator(); ReflectionTestUtils.setField(generator,"sequenceMapper",sequences); ReflectionTestUtils.setField(service,"orderNoGenerator",generator); lenient().when(sequences.currentValue()).thenReturn(1L); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); lenient().when(orders.lockContainerNumbers(anyString())).thenReturn(List.of()); lenient().when(shippingGates.initialize(anyString(),anyString(),anyString(),anyString(),anyLong(),anyLong())).thenAnswer(i->{ String key=i.getArgument(0); String value=i.getArgument(3); return frozenContainerNos.putIfAbsent(key,value)==null?1:0; }); lenient().when(shippingGates.lock(anyString())).thenAnswer(i->{ String key=i.getArgument(0); String value=frozenContainerNos.get(key); if(value==null)return null; var gate=new com.baiyue.lastleg.shipping.entity.ContainerShippingGate(); gate.setContainerBookingId(key); gate.setContainerNo(value); return gate; }); } - @Test void createsPendingOrdersAndInTransitSnapshotsWithoutInboundPlans(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); assertThrows(NoSuchFieldException.class,()->LoadingCompletedConsumer.class.getDeclaredField("inboundPlans")); verify(orders,times(2)).insert(any(LastlegOrder.class)); ArgumentCaptor inTransit=ArgumentCaptor.forClass(InventoryCommandRequests.InTransit.class); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),inTransit.capture()); assertEquals(Set.of("RK1","RK2"),inTransit.getAllValues().stream().map(InventoryCommandRequests.InTransit::inboundOrderNo).collect(java.util.stream.Collectors.toSet())); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getInboundOrderNo()==null)); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU6639870".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"FBA".equals(o.getDestinationType()))); assertTrue(c.getAllValues().stream().allMatch(o->"Customer 100".equals(o.getCustomerName()))); assertTrue(c.getAllValues().stream().allMatch(o->"ow-1".equals(o.getSourceWarehouseId())&&o.getOverseasWarehouseId()==null)); ArgumentCaptor snapshots=ArgumentCaptor.forClass(LastlegInboundOrder.class); verify(inboundOrders,times(2)).insert(snapshots.capture()); assertTrue(snapshots.getAllValues().stream().allMatch(o->"100".equals(o.getCustomerId())&&"FBA".equals(o.getDestinationType()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(c.getAllValues().get(0).getOrderNo().matches("LMO-[0-9]{6}-0001")); } - @Test void oneContainerWith15InboundRowsSharesOneNewLmoNumber(){ EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); com.fasterxml.jackson.databind.node.ArrayNode rows=json.createArrayNode(); for(int index=1;index<=15;index++){ com.fasterxml.jackson.databind.node.ObjectNode row=node.path("orders").get(0).deepCopy(); row.put("sourceInboundId","in-"+index).put("rootInboundId","root-"+index).put("sourceOrderId","order-"+index).put("sourceOrderNo","FL-"+index).put("rootOrderId","root-order-"+index).put("inboundCode","RK-"+index); rows.add(row); } node.set("orders",rows); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope fifteen=new EventEnvelope("123e4567-e89b-12d3-a456-426614174015",source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); assertEquals(15,service.consume(fifteen)); ArgumentCaptor captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(15)).insert(captured.capture()); assertEquals(1,captured.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(captured.getAllValues().get(0).getOrderNo().matches("^LMO-\\d{6}-\\d{4}$")); verify(sequences,times(1)).nextValue(any(java.time.LocalDate.class)); } + @Test void createsPendingOrdersAndInTransitSnapshotsWithoutInboundPlans(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); assertThrows(NoSuchFieldException.class,()->LoadingCompletedConsumer.class.getDeclaredField("inboundPlans")); verify(orders,times(2)).insert(any(LastlegOrder.class)); ArgumentCaptor inTransit=ArgumentCaptor.forClass(InventoryCommandRequests.InTransit.class); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),inTransit.capture()); assertEquals(Set.of("RK1","RK2"),inTransit.getAllValues().stream().map(InventoryCommandRequests.InTransit::inboundOrderNo).collect(java.util.stream.Collectors.toSet())); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getInboundOrderNo()==null)); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU6639870".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"FBA".equals(o.getDestinationType()))); assertTrue(c.getAllValues().stream().allMatch(o->"Customer 100".equals(o.getCustomerName()))); assertTrue(c.getAllValues().stream().allMatch(o->"ow-1".equals(o.getSourceWarehouseId())&&o.getOverseasWarehouseId()==null)); ArgumentCaptor snapshots=ArgumentCaptor.forClass(LastlegInboundOrder.class); verify(inboundOrders,times(2)).insert(snapshots.capture()); assertTrue(snapshots.getAllValues().stream().allMatch(o->"100".equals(o.getCustomerId())&&"FBA".equals(o.getDestinationType()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(c.getAllValues().get(0).getOrderNo().matches("FL-WH-[0-9]{8}-[0-9]{6}")); } + @Test void oneContainerWith15InboundRowsSharesOneStableContainerOrderNumber(){ EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); com.fasterxml.jackson.databind.node.ArrayNode rows=json.createArrayNode(); for(int index=1;index<=15;index++){ com.fasterxml.jackson.databind.node.ObjectNode row=node.path("orders").get(0).deepCopy(); row.put("sourceInboundId","in-"+index).put("rootInboundId","root-"+index).put("sourceOrderId","order-"+index).put("sourceOrderNo","FL-"+index).put("rootOrderId","root-order-"+index).put("inboundCode","RK-"+index); rows.add(row); } node.set("orders",rows); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope fifteen=new EventEnvelope("123e4567-e89b-12d3-a456-426614174015",source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); assertEquals(15,service.consume(fifteen)); ArgumentCaptor captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(15)).insert(captured.capture()); assertEquals(1,captured.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(captured.getAllValues().get(0).getOrderNo().matches("^FL-WH-\\d{8}-\\d{6}$")); verify(sequences,never()).nextValue(any(java.time.LocalDate.class)); } @Test void rejectsMissingOwnerSnapshotBeforeAnyLastlegWrite(){ EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node.path("orders").get(0)).put("customerName"," "); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope missing=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(missing)); assertTrue(error.getMessage().contains("尾程事件负载无效")||error.getMessage().contains("货主快照")); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); } @Test void freezesFormalPlannedWarehouseArrivalFromLoadingEvent(){ EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node).put("estimatedWarehouseArrivalAt","2026-10-03T09:00"); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope changed=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); service.consume(changed); ArgumentCaptor captor=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(captor.capture()); assertTrue(captor.getAllValues().stream().allMatch(order->java.time.LocalDateTime.of(2026,10,3,9,0).equals(order.getEstimatedWarehouseAt()))); } @Test void loadingCreatesAwaitingPickupReadyGateWithoutPollutingShippingVersion(){ service.consume(event()); verify(shippingGates).initialize("book-1","100","","MSCU6639870",0L,1L); } From f2958bef6ecbbaedd8e6511f219e131f876a6fd3 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 1 Sep 2026 14:47:45 +0800 Subject: [PATCH 33/37] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/agents-index-contract.ps1 | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index 562a5c1f9..167316597 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -537,6 +537,11 @@ $criticalPaths = @( 'apps/pda-overseas-uniapp/src/pages.json', 'apps/pda-overseas-uniapp/src/data/overseas-workbench.js', 'apps/pda-overseas-uniapp/src/pages/operation/operation.vue', + 'apps/pda-overseas-uniapp/src/pages/dispatch/dispatch.vue', + 'apps/pda-overseas-uniapp/src/pages/outbound/outbound.vue', + 'apps/pda-overseas-uniapp/src/api/fulfillment.js', + 'apps/pda-overseas-uniapp/src/stores/fulfillment.js', + 'apps/pda-overseas-uniapp/tests/fulfillment-workflows.test.mjs', 'apps/pda-overseas-uniapp/src/stores/receiving.js', 'apps/pda-overseas-uniapp/tests/receiving-photo-ui-contract.test.mjs', 'apps/pda-overseas-uniapp/src/config/runtime.js', From a10e32a012366fdb2b21746e67d208951e206c07 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 1 Sep 2026 14:48:51 +0800 Subject: [PATCH 34/37] test(firstleg): finish task evidence migration --- .../admin-test-env-modules/phase3-container-overview.mjs | 2 +- scripts/admin-test-env-modules/phase3-loading.mjs | 2 +- scripts/admin-test-env-modules/phase3-pda-convergence.mjs | 6 ++++-- .../phase3-pda-pending-consistency-contract.test.mjs | 2 ++ 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/scripts/admin-test-env-modules/phase3-container-overview.mjs b/scripts/admin-test-env-modules/phase3-container-overview.mjs index 8da3091d7..43f9bca8e 100644 --- a/scripts/admin-test-env-modules/phase3-container-overview.mjs +++ b/scripts/admin-test-env-modules/phase3-container-overview.mjs @@ -107,7 +107,7 @@ export async function run(context) { context.apiSuccess(MODULE, '查询出口报关资料', customs, `loadingPlanId=${planId}, warehouseId=${warehouseId}`, OPTIONS) } else { context.assert(MODULE, '运行中计划尚未生成完成装柜清单,出口报关资料拒绝读取', customs, - customs.http === 200 && String(customs.body?.code) === 'A0409', + customs.http === 200 && String(customs.body?.code) === 'A0404', `loadingPlanId=${planId}, status=${fixture?.finalStatus || 'missing'}`, OPTIONS) } diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 4db161c7f..ed57a829b 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -1068,7 +1068,7 @@ export async function run(context) { const wrongWarehouseEvidence = await context.request('GET', `${evidencePath}?warehouseId=${encodeURIComponent(Number(warehouseId) + 1)}`) context.addStep(MODULE, '错误仓库作用域不可读取本轮 task 装柜证据', wrongWarehouseEvidence, - wrongWarehouseEvidence.http === 403 && codeOf(wrongWarehouseEvidence) === 'A0403', + wrongWarehouseEvidence.http === 200 && codeOf(wrongWarehouseEvidence) === 'A0403', `HTTP=${wrongWarehouseEvidence.http}, code=${codeOf(wrongWarehouseEvidence)}`, OPTIONS) const wrongTaskEvidence = await context.request('GET', `${pdaBase}/tasks/999999999999999999/evidence?warehouseId=${encodeURIComponent(warehouseId)}`) diff --git a/scripts/admin-test-env-modules/phase3-pda-convergence.mjs b/scripts/admin-test-env-modules/phase3-pda-convergence.mjs index f76ab00e3..937804c87 100644 --- a/scripts/admin-test-env-modules/phase3-pda-convergence.mjs +++ b/scripts/admin-test-env-modules/phase3-pda-convergence.mjs @@ -71,10 +71,12 @@ export async function run(context) { && pendingActions.length === 0, `workflow=${taskDetail?.workflowStatus || 'missing'}, unresolved=${unresolvedItems.length}, actions=${pendingActions.length}`, OPTIONS) const evidenceResponse = await request(context, 'GET', - `/api/platform/v1/inbounds/${inboundId}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`) + `${taskPath}/evidence?warehouseId=${encodeURIComponent(warehouseId)}`) const evidence = context.apiSuccess(MODULE, 'AWAITING_REVIEW 可进入装柜取证只读入口', evidenceResponse, '', OPTIONS) context.assert(MODULE, 'AWAITING_REVIEW 取证 GET 开放且不依赖重复 batch', evidenceResponse, - taskDetail?.workflowStatus === 'AWAITING_REVIEW' && Array.isArray(evidence?.evidences), + taskDetail?.workflowStatus === 'AWAITING_REVIEW' && id(evidence?.taskId) === taskId + && id(evidence?.containerBookingId) === id(fixture?.containerBookingId) + && Array.isArray(evidence?.evidences), `workflow=${taskDetail?.workflowStatus || 'missing'}, evidences=${evidence?.evidences?.length ?? 'missing'}, actions=${pendingActions.length}`, OPTIONS) const operationsResponse = await request(context, 'GET', `${taskPath}/operations`) diff --git a/scripts/admin-test-env-modules/phase3-pda-pending-consistency-contract.test.mjs b/scripts/admin-test-env-modules/phase3-pda-pending-consistency-contract.test.mjs index caaea1192..060cb8158 100644 --- a/scripts/admin-test-env-modules/phase3-pda-pending-consistency-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-pda-pending-consistency-contract.test.mjs @@ -24,6 +24,8 @@ test('AWAITING_REVIEW derives zero client actions and never needs a repeated bat assert.match(convergence, /pending 与 detail 使用同一 workflowStatus 和未完成数量事实/) assert.match(convergence, /AWAITING_REVIEW 已全拣任务 actions 为空且不再提交 batch/) assert.match(convergence, /AWAITING_REVIEW 可进入装柜取证只读入口/) + assert.match(convergence, /\$\{taskPath\}\/evidence\?warehouseId=\$\{encodeURIComponent\(warehouseId\)\}/) + assert.doesNotMatch(convergence, /api\/platform\/v1\/inbounds\/\$\{inboundId\}\/loading\/evidence/) assert.match(convergence, /filter\(\(item\) => item\.actionType === 'BATCH'/) assert.match(convergence, /expectedBatchOperationIds\.length === 4/) assert.match(convergence, /actualBatchOperationIds\.length === 4/) From 579c61879d9d593d3f70eae3d360ee6f22f03743 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 1 Sep 2026 14:54:47 +0800 Subject: [PATCH 35/37] fix: include phase 3 customs baseline in unified sql --- deploy/sql/头程数据库统一增量-MySQL8.sql | 260 ++++++++++++------ .../Phase3FinalUnifiedSqlContractTest.java | 31 ++- 2 files changed, 203 insertions(+), 88 deletions(-) diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index e44663631..2d3c8eff9 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -9913,24 +9913,181 @@ WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR') USE baiyue_firstleg; --- Latest container customs permissions; old case/review/receipt actions are removed. +-- 来源:firstleg/src/main/resources/db/phase3-container-customs-schema.sql +SET NAMES utf8mb4; +USE baiyue_firstleg; + +CREATE TABLE IF NOT EXISTS fl_customs_container ( + id BIGINT UNSIGNED NOT NULL, + loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '已完成装柜计划ID;报关柜级聚合唯一键', + container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '物理货柜ID', + status VARCHAR(40) NOT NULL COMMENT 'WAITING_CUSTOMER_DOCUMENTS、READY_TO_START、DECLARING、COMPLETED', + started_by BIGINT UNSIGNED NULL, + started_by_name VARCHAR(64) NULL, + started_at DATETIME(3) NULL, + completed_by BIGINT UNSIGNED NULL, + completed_by_name VARCHAR(64) NULL, + completed_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + version INT UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY(id), + UNIQUE KEY uk_customs_container_plan(loading_plan_id), + UNIQUE KEY uk_customs_container_booking(container_booking_id), + KEY idx_customs_container_status(status,updated_at), + CONSTRAINT chk_customs_container_status CHECK(status IN + ('WAITING_CUSTOMER_DOCUMENTS','READY_TO_START','DECLARING','COMPLETED')) +) ENGINE=InnoDB COMMENT='装柜完成后的整柜出口报关聚合'; + +CREATE TABLE IF NOT EXISTS fl_customs_customer_document ( + id BIGINT UNSIGNED NOT NULL, + customs_container_id BIGINT UNSIGNED NOT NULL, + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离键;只允许关联同客户退税票', + file_id BIGINT UNSIGNED NOT NULL, + file_name VARCHAR(255) NOT NULL, + file_hash CHAR(64) NOT NULL, + content_type VARCHAR(128) NULL, + size_bytes BIGINT UNSIGNED NOT NULL, + status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE', + uploaded_by BIGINT UNSIGNED NOT NULL, + uploaded_at DATETIME(3) NOT NULL, + frozen_version INT UNSIGNED NULL, + frozen_at DATETIME(3) NULL, + deleted_by BIGINT UNSIGNED NULL, + deleted_at DATETIME(3) NULL, + version INT UNSIGNED NOT NULL DEFAULT 1, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), + KEY idx_customs_customer_document_container(customs_container_id,status,tenant_id), + CONSTRAINT fk_customs_customer_document_container FOREIGN KEY(customs_container_id) REFERENCES fl_customs_container(id), + CONSTRAINT chk_customs_customer_document_status CHECK(status IN('ACTIVE','DELETED')), + CONSTRAINT chk_customs_customer_document_freeze CHECK((frozen_at IS NULL AND frozen_version IS NULL) OR + (frozen_at IS NOT NULL AND frozen_version IS NOT NULL)) +) ENGINE=InnoDB COMMENT='客户整份退税报送资料逻辑根'; + +CREATE TABLE IF NOT EXISTS fl_customs_customer_document_inbound ( + document_id BIGINT UNSIGNED NOT NULL, + inbound_id BIGINT UNSIGNED NOT NULL, + tenant_id BIGINT UNSIGNED NOT NULL, + order_id BIGINT UNSIGNED NOT NULL, + inbound_code VARCHAR(40) NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY(document_id,inbound_id), + KEY idx_customs_document_inbound(inbound_id,tenant_id), + CONSTRAINT fk_customs_document_inbound_document FOREIGN KEY(document_id) REFERENCES fl_customs_customer_document(id) +) ENGINE=InnoDB COMMENT='客户报送资料与当前柜退税入仓码多对多关系'; + +CREATE TABLE IF NOT EXISTS fl_customs_customer_document_version ( + id BIGINT UNSIGNED NOT NULL, + document_id BIGINT UNSIGNED NOT NULL, + document_version INT UNSIGNED NOT NULL, + file_id BIGINT UNSIGNED NOT NULL, + file_name VARCHAR(255) NOT NULL, + file_hash CHAR(64) NOT NULL, + content_type VARCHAR(128) NULL, + size_bytes BIGINT UNSIGNED NOT NULL, + scope_snapshot_json JSON NOT NULL, + action VARCHAR(24) NOT NULL COMMENT 'UPLOAD、REPLACE、SCOPE_CHANGE、DELETE', + operator_id BIGINT UNSIGNED NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), + UNIQUE KEY uk_customs_customer_document_version(document_id,document_version), + CONSTRAINT fk_customs_customer_document_version FOREIGN KEY(document_id) REFERENCES fl_customs_customer_document(id), + CONSTRAINT chk_customs_customer_document_version_action CHECK(action IN('UPLOAD','REPLACE','SCOPE_CHANGE','DELETE')) +) ENGINE=InnoDB COMMENT='客户报送资料文件与覆盖范围不可变版本'; + +CREATE TABLE IF NOT EXISTS fl_customs_frozen_document_inbound ( + document_id BIGINT UNSIGNED NOT NULL, + inbound_id BIGINT UNSIGNED NOT NULL, + tenant_id BIGINT UNSIGNED NOT NULL, + order_id BIGINT UNSIGNED NOT NULL, + inbound_code VARCHAR(40) NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY(document_id,inbound_id), + CONSTRAINT fk_customs_frozen_document FOREIGN KEY(document_id) REFERENCES fl_customs_customer_document(id) +) ENGINE=InnoDB COMMENT='开始报关时冻结的客户资料覆盖范围'; + +CREATE TABLE IF NOT EXISTS fl_customs_completion_document ( + id BIGINT UNSIGNED NOT NULL, + source_document_id BIGINT UNSIGNED NOT NULL COMMENT '一份冻结客户资料严格对应一份完成文件逻辑根', + file_id BIGINT UNSIGNED NOT NULL, + file_name VARCHAR(255) NOT NULL, + file_hash CHAR(64) NOT NULL, + content_type VARCHAR(128) NULL, + size_bytes BIGINT UNSIGNED NOT NULL, + uploaded_by BIGINT UNSIGNED NOT NULL, + uploaded_by_name VARCHAR(64) NULL, + uploaded_at DATETIME(3) NOT NULL, + version INT UNSIGNED NOT NULL DEFAULT 1, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), + UNIQUE KEY uk_customs_completion_source(source_document_id), + CONSTRAINT fk_customs_completion_source FOREIGN KEY(source_document_id) REFERENCES fl_customs_customer_document(id) +) ENGINE=InnoDB COMMENT='报关完成文件当前版本'; + +CREATE TABLE IF NOT EXISTS fl_customs_completion_document_version ( + id BIGINT UNSIGNED NOT NULL, + completion_document_id BIGINT UNSIGNED NOT NULL, + document_version INT UNSIGNED NOT NULL, + file_id BIGINT UNSIGNED NOT NULL, + file_name VARCHAR(255) NOT NULL, + file_hash CHAR(64) NOT NULL, + content_type VARCHAR(128) NULL, + size_bytes BIGINT UNSIGNED NOT NULL, + operator_id BIGINT UNSIGNED NOT NULL, + operator_name VARCHAR(64) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), + UNIQUE KEY uk_customs_completion_version(completion_document_id,document_version), + CONSTRAINT fk_customs_completion_version FOREIGN KEY(completion_document_id) REFERENCES fl_customs_completion_document(id) +) ENGINE=InnoDB COMMENT='报关完成文件不可变覆盖版本'; + +CREATE TABLE IF NOT EXISTS fl_customs_container_history ( + id BIGINT UNSIGNED NOT NULL, + customs_container_id BIGINT UNSIGNED NOT NULL, + action VARCHAR(40) NOT NULL, + from_status VARCHAR(40) NULL, + to_status VARCHAR(40) NULL, + remark VARCHAR(500) NULL, + operator_id BIGINT UNSIGNED NOT NULL, + operator_name VARCHAR(64) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), + KEY idx_customs_container_history(customs_container_id,created_at,id), + CONSTRAINT fk_customs_container_history FOREIGN KEY(customs_container_id) REFERENCES fl_customs_container(id) +) ENGINE=InnoDB COMMENT='整柜报关状态与系统触发历史'; + +CREATE TABLE IF NOT EXISTS fl_customs_command ( + id BIGINT UNSIGNED NOT NULL, + actor_id BIGINT UNSIGNED NOT NULL, + action VARCHAR(80) NOT NULL, + idempotency_key VARCHAR(128) NOT NULL, + request_hash CHAR(64) NOT NULL, + status VARCHAR(16) NOT NULL, + result_id BIGINT UNSIGNED NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + completed_at DATETIME(3) NULL, + PRIMARY KEY(id), + UNIQUE KEY uk_customs_command(actor_id,action,idempotency_key), + CONSTRAINT chk_customs_command_status CHECK(status IN('PROCESSING','SUCCESS')) +) ENGINE=InnoDB COMMENT='整柜报关写命令幂等事实'; + USE baiyue_iam; -DELETE rp FROM iam_role_permission rp WHERE rp.permission_id IN (5102171,5102173,5102174,5102175); -DELETE FROM iam_permission WHERE id IN (5102171,5102173,5102174,5102175) - AND permission_code IN ('platform:firstleg:customs:view','platform:firstleg:customs:review','platform:firstleg:customs:submit','platform:firstleg:customs:receipt'); -INSERT INTO iam_permission - (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,deleted) -VALUES - (5102170,0,'PLATFORM','MENU',5102000,'platform:firstleg:customs:list','整柜报关','/first-leg/customs','GET',100,'ACTIVE',1,0), - (5102172,0,'PLATFORM','BUTTON',5102170,'platform:firstleg:customs:manage','维护整柜报关',NULL,'POST',20,'ACTIVE',1,0) -ON DUPLICATE KEY UPDATE tenant_id=0,app_code='PLATFORM',permission_type=VALUES(permission_type), - permission_code=VALUES(permission_code),permission_name=VALUES(permission_name),parent_id=VALUES(parent_id), - route_path=VALUES(route_path),http_method=VALUES(http_method),sort_no=VALUES(sort_no),status='ACTIVE',built_in=1,deleted=0; +DELETE rp FROM iam_role_permission rp WHERE rp.permission_id IN(5102171,5102173,5102174,5102175); +DELETE FROM iam_permission WHERE id IN(5102171,5102173,5102174,5102175) + AND permission_code IN('platform:firstleg:customs:view','platform:firstleg:customs:review','platform:firstleg:customs:submit','platform:firstleg:customs:receipt'); +INSERT INTO iam_permission(id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name, + route_path,http_method,sort_no,status,built_in,deleted) VALUES + (5102170,0,'PLATFORM','MENU',5102000,'platform:firstleg:customs:list','整柜报关','/first-leg/customs','GET',100,'ACTIVE',1,0), + (5102172,0,'PLATFORM','BUTTON',5102170,'platform:firstleg:customs:manage','维护整柜报关',NULL,'POST',20,'ACTIVE',1,0) +ON DUPLICATE KEY UPDATE permission_code=VALUES(permission_code),permission_name=VALUES(permission_name), + parent_id=VALUES(parent_id),route_path=VALUES(route_path),http_method=VALUES(http_method),status='ACTIVE',deleted=0; INSERT IGNORE INTO iam_role_permission(role_id,permission_id,tenant_id) -SELECT role.id,permission.id,0 FROM iam_role role JOIN iam_permission permission - ON permission.tenant_id=0 AND permission.app_code='PLATFORM' - AND permission.permission_code IN ('platform:firstleg:customs:list','platform:firstleg:customs:manage') -WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR'); +SELECT r.id,p.id,0 FROM iam_role r JOIN iam_permission p ON p.tenant_id=0 AND p.app_code='PLATFORM' + AND p.permission_code IN('platform:firstleg:customs:list','platform:firstleg:customs:manage') +WHERE r.tenant_id=0 AND r.role_code IN('PLATFORM_ADMIN','ORDER_OPERATOR'); USE baiyue_firstleg; /* Phase 3: link inbound preload plans to one or more container loading plans. */ CREATE TABLE IF NOT EXISTS fl_preload_loading_plan_link ( @@ -16776,77 +16933,6 @@ BEGIN END$$ DELIMITER ; --- 最新整柜报关基线:装柜完成后收集退税资料,按柜冻结、回传并自动收敛离仓。 -USE baiyue_firstleg; -CREATE TABLE IF NOT EXISTS fl_customs_container( - id BIGINT UNSIGNED NOT NULL,loading_plan_id BIGINT UNSIGNED NOT NULL,container_booking_id BIGINT UNSIGNED NOT NULL, - status VARCHAR(40) NOT NULL,started_by BIGINT UNSIGNED NULL,started_by_name VARCHAR(64) NULL,started_at DATETIME(3) NULL, - completed_by BIGINT UNSIGNED NULL,completed_by_name VARCHAR(64) NULL,completed_at DATETIME(3) NULL, - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), - version INT UNSIGNED NOT NULL DEFAULT 0,PRIMARY KEY(id),UNIQUE KEY uk_customs_container_plan(loading_plan_id), - UNIQUE KEY uk_customs_container_booking(container_booking_id),KEY idx_customs_container_status(status,updated_at), - CONSTRAINT chk_customs_container_status CHECK(status IN('WAITING_CUSTOMER_DOCUMENTS','READY_TO_START','DECLARING','COMPLETED')) -)ENGINE=InnoDB COMMENT='装柜完成后的整柜出口报关聚合'; -CREATE TABLE IF NOT EXISTS fl_customs_customer_document( - id BIGINT UNSIGNED NOT NULL,customs_container_id BIGINT UNSIGNED NOT NULL,tenant_id BIGINT UNSIGNED NOT NULL, - file_id BIGINT UNSIGNED NOT NULL,file_name VARCHAR(255) NOT NULL,file_hash CHAR(64) NOT NULL,content_type VARCHAR(128) NULL, - size_bytes BIGINT UNSIGNED NOT NULL,status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',uploaded_by BIGINT UNSIGNED NOT NULL, - uploaded_at DATETIME(3) NOT NULL,frozen_version INT UNSIGNED NULL,frozen_at DATETIME(3) NULL,deleted_by BIGINT UNSIGNED NULL, - deleted_at DATETIME(3) NULL,version INT UNSIGNED NOT NULL DEFAULT 1,created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),PRIMARY KEY(id), - KEY idx_customs_customer_document_container(customs_container_id,status,tenant_id), - CONSTRAINT fk_customs_customer_document_container FOREIGN KEY(customs_container_id) REFERENCES fl_customs_container(id), - CONSTRAINT chk_customs_customer_document_status CHECK(status IN('ACTIVE','DELETED')), - CONSTRAINT chk_customs_customer_document_freeze CHECK((frozen_at IS NULL AND frozen_version IS NULL)OR(frozen_at IS NOT NULL AND frozen_version IS NOT NULL)) -)ENGINE=InnoDB COMMENT='客户整份退税报送资料逻辑根'; -CREATE TABLE IF NOT EXISTS fl_customs_customer_document_inbound( - document_id BIGINT UNSIGNED NOT NULL,inbound_id BIGINT UNSIGNED NOT NULL,tenant_id BIGINT UNSIGNED NOT NULL, - order_id BIGINT UNSIGNED NOT NULL,inbound_code VARCHAR(40) NOT NULL,created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), - PRIMARY KEY(document_id,inbound_id),KEY idx_customs_document_inbound(inbound_id,tenant_id), - CONSTRAINT fk_customs_document_inbound_document FOREIGN KEY(document_id) REFERENCES fl_customs_customer_document(id) -)ENGINE=InnoDB COMMENT='客户报送资料与当前柜退税入仓码多对多关系'; -CREATE TABLE IF NOT EXISTS fl_customs_customer_document_version( - id BIGINT UNSIGNED NOT NULL,document_id BIGINT UNSIGNED NOT NULL,document_version INT UNSIGNED NOT NULL,file_id BIGINT UNSIGNED NOT NULL, - file_name VARCHAR(255) NOT NULL,file_hash CHAR(64) NOT NULL,content_type VARCHAR(128) NULL,size_bytes BIGINT UNSIGNED NOT NULL, - scope_snapshot_json JSON NOT NULL,action VARCHAR(24) NOT NULL,operator_id BIGINT UNSIGNED NOT NULL, - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),PRIMARY KEY(id),UNIQUE KEY uk_customs_customer_document_version(document_id,document_version), - CONSTRAINT fk_customs_customer_document_version FOREIGN KEY(document_id) REFERENCES fl_customs_customer_document(id), - CONSTRAINT chk_customs_customer_document_version_action CHECK(action IN('UPLOAD','REPLACE','SCOPE_CHANGE','DELETE')) -)ENGINE=InnoDB COMMENT='客户报送资料文件与覆盖范围不可变版本'; -CREATE TABLE IF NOT EXISTS fl_customs_frozen_document_inbound( - document_id BIGINT UNSIGNED NOT NULL,inbound_id BIGINT UNSIGNED NOT NULL,tenant_id BIGINT UNSIGNED NOT NULL, - order_id BIGINT UNSIGNED NOT NULL,inbound_code VARCHAR(40) NOT NULL,created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), - PRIMARY KEY(document_id,inbound_id),CONSTRAINT fk_customs_frozen_document FOREIGN KEY(document_id) REFERENCES fl_customs_customer_document(id) -)ENGINE=InnoDB COMMENT='开始报关时冻结的客户资料覆盖范围'; -CREATE TABLE IF NOT EXISTS fl_customs_completion_document( - id BIGINT UNSIGNED NOT NULL,source_document_id BIGINT UNSIGNED NOT NULL,file_id BIGINT UNSIGNED NOT NULL, - file_name VARCHAR(255) NOT NULL,file_hash CHAR(64) NOT NULL,content_type VARCHAR(128) NULL,size_bytes BIGINT UNSIGNED NOT NULL, - uploaded_by BIGINT UNSIGNED NOT NULL,uploaded_by_name VARCHAR(64) NULL,uploaded_at DATETIME(3) NOT NULL,version INT UNSIGNED NOT NULL DEFAULT 1, - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), - PRIMARY KEY(id),UNIQUE KEY uk_customs_completion_source(source_document_id), - CONSTRAINT fk_customs_completion_source FOREIGN KEY(source_document_id) REFERENCES fl_customs_customer_document(id) -)ENGINE=InnoDB COMMENT='报关完成文件当前版本'; -CREATE TABLE IF NOT EXISTS fl_customs_completion_document_version( - id BIGINT UNSIGNED NOT NULL,completion_document_id BIGINT UNSIGNED NOT NULL,document_version INT UNSIGNED NOT NULL, - file_id BIGINT UNSIGNED NOT NULL,file_name VARCHAR(255) NOT NULL,file_hash CHAR(64) NOT NULL,content_type VARCHAR(128) NULL, - size_bytes BIGINT UNSIGNED NOT NULL,operator_id BIGINT UNSIGNED NOT NULL,operator_name VARCHAR(64) NULL, - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),PRIMARY KEY(id), - UNIQUE KEY uk_customs_completion_version(completion_document_id,document_version), - CONSTRAINT fk_customs_completion_version FOREIGN KEY(completion_document_id) REFERENCES fl_customs_completion_document(id) -)ENGINE=InnoDB COMMENT='报关完成文件不可变覆盖版本'; -CREATE TABLE IF NOT EXISTS fl_customs_container_history( - id BIGINT UNSIGNED NOT NULL,customs_container_id BIGINT UNSIGNED NOT NULL,action VARCHAR(40) NOT NULL, - from_status VARCHAR(40) NULL,to_status VARCHAR(40) NULL,remark VARCHAR(500) NULL,operator_id BIGINT UNSIGNED NOT NULL, - operator_name VARCHAR(64) NULL,created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),PRIMARY KEY(id), - KEY idx_customs_container_history(customs_container_id,created_at,id), - CONSTRAINT fk_customs_container_history FOREIGN KEY(customs_container_id) REFERENCES fl_customs_container(id) -)ENGINE=InnoDB COMMENT='整柜报关状态与系统触发历史'; -CREATE TABLE IF NOT EXISTS fl_customs_command( - id BIGINT UNSIGNED NOT NULL,actor_id BIGINT UNSIGNED NOT NULL,action VARCHAR(80) NOT NULL,idempotency_key VARCHAR(128) NOT NULL, - request_hash CHAR(64) NOT NULL,status VARCHAR(16) NOT NULL,result_id BIGINT UNSIGNED NULL, - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),completed_at DATETIME(3) NULL,PRIMARY KEY(id), - UNIQUE KEY uk_customs_command(actor_id,action,idempotency_key),CONSTRAINT chk_customs_command_status CHECK(status IN('PROCESSING','SUCCESS')) -)ENGINE=InnoDB COMMENT='整柜报关写命令幂等事实'; -- 同柜且不可拆组:跨客户但同国内仓、渠道和目的仓港,整组锁定/解锁/消费。 USE baiyue_firstleg; CREATE TABLE IF NOT EXISTS fl_coload_group ( diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java index 97b020c93..cf016a6b8 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java @@ -78,7 +78,7 @@ class Phase3FinalUnifiedSqlContractTest { for (int id = 5102160; id <= 5102164; id++) assertThat(sql).contains(Integer.toString(id)); for (int id : java.util.List.of(5102170,5102172)) assertThat(sql).contains(Integer.toString(id)); - assertThat(sql).contains("DELETE FROM iam_permission WHERE id IN (5102171,5102173,5102174,5102175)") + assertThat(sql).contains("DELETE FROM iam_permission WHERE id IN(5102171,5102173,5102174,5102175)") .doesNotContain("(5102171,0,'PLATFORM','BUTTON'","(5102173,0,'PLATFORM','BUTTON'", "(5102174,0,'PLATFORM','BUTTON'","(5102175,0,'PLATFORM','BUTTON'"); for (int id = 5102180; id <= 5102183; id++) assertThat(sql).contains(Integer.toString(id)); @@ -111,6 +111,35 @@ class Phase3FinalUnifiedSqlContractTest { "iam/src/main/resources/db/phase3-warehouse-roles.sql"); } + @Test + void customsBaselineIsUniqueSynchronisedAndRunsBeforeFinalSchemaAssertion() throws Exception { + String unified = normalise(Files.readString(resolveUnified())); + String marker = "-- 来源:firstleg/src/main/resources/db/phase3-container-customs-schema.sql"; + String finalAssertion = "CREATE PROCEDURE assert_phase3_final_schema()"; + List tables = List.of( + "fl_customs_container", + "fl_customs_customer_document", + "fl_customs_customer_document_inbound", + "fl_customs_customer_document_version", + "fl_customs_frozen_document_inbound", + "fl_customs_completion_document", + "fl_customs_completion_document_version", + "fl_customs_container_history", + "fl_customs_command" + ); + + assertBlock(unified, marker, "/* Phase 3: link inbound preload plans", + "firstleg/src/main/resources/db/phase3-container-customs-schema.sql"); + assertThat(count(unified, marker)).isEqualTo(1); + assertThat(unified.indexOf(marker)).isLessThan(unified.indexOf(finalAssertion)); + for (String table : tables) { + String create = "CREATE TABLE IF NOT EXISTS " + table + " ("; + assertThat(count(unified, create)).as(table).isEqualTo(1); + assertThat(unified.indexOf(create)).as(table + " before final assertion") + .isLessThan(unified.indexOf(finalAssertion)); + } + } + @Test void financeFeeSourceMigrationsAreContiguousAndSynchronised() throws Exception { String unified = normalise(Files.readString(resolveUnified())); From 94395faeefd7526fcdef9f112fdf8b80792b3e6d Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 1 Sep 2026 15:00:38 +0800 Subject: [PATCH 36/37] fix: make phase 5 permission validation mysql compatible --- deploy/sql/头程数据库统一增量-MySQL8.sql | 11 +++++++++-- .../db/phase5-delivery-permissions.sql | 11 +++++++++-- ...Phase5DeliveryPermissionSqlContractTest.java | 17 +++++++++++++++++ 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 2d3c8eff9..71410dfd3 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -16633,6 +16633,9 @@ DROP PROCEDURE IF EXISTS assert_phase5_delivery_permissions; DELIMITER $$ CREATE PROCEDURE assert_phase5_delivery_permissions() BEGIN + DECLARE v_warehouse_operator_permission_count INT DEFAULT 0; + DECLARE v_warehouse_tally_permission_count INT DEFAULT 0; + IF (SELECT COUNT(*) FROM iam_permission WHERE tenant_id=0 AND status='ACTIVE' AND deleted=0 AND permission_code IN ( @@ -16661,8 +16664,12 @@ BEGIN 'platform:overseas-warehouse:delivery:list')) <> 7 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:平台管理员授权不完整'; END IF; - IF (SELECT COUNT(*) FROM tmp_phase5_delivery_role_permission WHERE role_code='WAREHOUSE_OPERATOR') <> 7 - OR (SELECT COUNT(*) FROM tmp_phase5_delivery_role_permission WHERE role_code='WAREHOUSE_TALLY') <> 2 THEN + SELECT COALESCE(SUM(role_code='WAREHOUSE_OPERATOR'),0), + COALESCE(SUM(role_code='WAREHOUSE_TALLY'),0) + INTO v_warehouse_operator_permission_count, v_warehouse_tally_permission_count + FROM tmp_phase5_delivery_role_permission; + IF v_warehouse_operator_permission_count <> 7 + OR v_warehouse_tally_permission_count <> 2 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:仓库岗位最小权限矩阵异常'; END IF; IF EXISTS (SELECT 1 FROM iam_role_permission grant_row diff --git a/iam/src/main/resources/db/phase5-delivery-permissions.sql b/iam/src/main/resources/db/phase5-delivery-permissions.sql index 3dd23f7b6..f60b4a4b5 100644 --- a/iam/src/main/resources/db/phase5-delivery-permissions.sql +++ b/iam/src/main/resources/db/phase5-delivery-permissions.sql @@ -133,6 +133,9 @@ DROP PROCEDURE IF EXISTS assert_phase5_delivery_permissions; DELIMITER $$ CREATE PROCEDURE assert_phase5_delivery_permissions() BEGIN + DECLARE v_warehouse_operator_permission_count INT DEFAULT 0; + DECLARE v_warehouse_tally_permission_count INT DEFAULT 0; + IF (SELECT COUNT(*) FROM iam_permission WHERE tenant_id=0 AND status='ACTIVE' AND deleted=0 AND permission_code IN ( @@ -161,8 +164,12 @@ BEGIN 'platform:overseas-warehouse:delivery:list')) <> 7 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:平台管理员授权不完整'; END IF; - IF (SELECT COUNT(*) FROM tmp_phase5_delivery_role_permission WHERE role_code='WAREHOUSE_OPERATOR') <> 7 - OR (SELECT COUNT(*) FROM tmp_phase5_delivery_role_permission WHERE role_code='WAREHOUSE_TALLY') <> 2 THEN + SELECT COALESCE(SUM(role_code='WAREHOUSE_OPERATOR'),0), + COALESCE(SUM(role_code='WAREHOUSE_TALLY'),0) + INTO v_warehouse_operator_permission_count, v_warehouse_tally_permission_count + FROM tmp_phase5_delivery_role_permission; + IF v_warehouse_operator_permission_count <> 7 + OR v_warehouse_tally_permission_count <> 2 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:仓库岗位最小权限矩阵异常'; END IF; IF EXISTS (SELECT 1 FROM iam_role_permission grant_row diff --git a/iam/src/test/java/com/baiyue/iam/repository/Phase5DeliveryPermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/Phase5DeliveryPermissionSqlContractTest.java index 00d195e31..1112018dd 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/Phase5DeliveryPermissionSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/Phase5DeliveryPermissionSqlContractTest.java @@ -58,6 +58,23 @@ class Phase5DeliveryPermissionSqlContractTest { "('CN_FIRSTLEG_PDA_OPERATOR','platform:overseas-warehouse:"); } + @Test + void validatesTheWarehouseRoleMatrixWithOneTemporaryTableReadPerStatement() throws Exception { + String sql = sql(); + + assertThat(sql).contains( + "DECLARE v_warehouse_operator_permission_count INT DEFAULT 0", + "DECLARE v_warehouse_tally_permission_count INT DEFAULT 0", + "SELECT COALESCE(SUM(role_code='WAREHOUSE_OPERATOR'),0)", + "COALESCE(SUM(role_code='WAREHOUSE_TALLY'),0)", + "INTO v_warehouse_operator_permission_count, v_warehouse_tally_permission_count", + "IF v_warehouse_operator_permission_count <> 7", + "OR v_warehouse_tally_permission_count <> 2 THEN"); + assertThat(sql).doesNotContain( + "IF (SELECT COUNT(*) FROM tmp_phase5_delivery_role_permission", + "OR (SELECT COUNT(*) FROM tmp_phase5_delivery_role_permission"); + } + @Test void grantsOnlyTheDefaultCustomerAdministratorAutomatically() throws Exception { String sql = sql(); From 1e8f504f159b7473e4240026eb76dcc3e33bdd3d Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 1 Sep 2026 15:06:03 +0800 Subject: [PATCH 37/37] fix(firstleg): allow booking link before container pickup --- deploy/sql/头程数据库统一增量-MySQL8.sql | 2 +- .../baiyue/firstleg/dto/ContainerDtos.java | 4 +- .../mapper/ContainerBookingMapper.java | 14 +++++ .../repository/ContainerRepository.java | 4 ++ .../firstleg/service/ContainerService.java | 31 ++++++++++- .../db/phase3-smart-loading-schema.sql | 2 +- .../main/resources/openapi/firstleg-v1.yaml | 8 +-- .../ContainerControllerContractTest.java | 9 ++-- .../ContainerBookingMapperContractTest.java | 13 ++++- .../service/ContainerServiceTest.java | 52 +++++++++++++++++++ 10 files changed, 124 insertions(+), 15 deletions(-) diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 7deed37fe..a2b6591b7 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -4869,7 +4869,7 @@ CREATE TABLE IF NOT EXISTS fl_document_sequence ( CREATE TABLE IF NOT EXISTS fl_container_booking ( id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID(雪花ID)', booking_no VARCHAR(40) NOT NULL COMMENT '平台唯一货柜预订单号', - container_no VARCHAR(64) NULL COMMENT '国内唯一权威真实集装箱号;ISO 6346标准格式;仅订舱关联首次写入、预装纠错命令可修改', + container_no VARCHAR(64) NULL COMMENT '国内唯一权威真实集装箱号;ISO 6346标准格式;订舱关联可选写入、提柜后可原子补录、预装纠错命令可修改', cabinet_no VARCHAR(64) NOT NULL COMMENT '平台唯一内部柜号', source_type VARCHAR(24) NOT NULL DEFAULT 'SIMULATION' COMMENT '记录来源:SIMULATION内部模拟柜、OCEAN_BOOKING订舱真实柜', record_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '记录状态:ACTIVE可操作、MERGED已合并历史', diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java index 600024f29..4a1cbb272 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java @@ -85,9 +85,9 @@ public final class ContainerDtos { @NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal capacityCbm, @NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal maxWeightKg) {} - @Schema(name = "ContainerOceanBookingLinkRequest", description = "将现有模拟柜原地关联到已发布订舱箱型需求并补录真实柜号") + @Schema(name = "ContainerOceanBookingLinkRequest", description = "将现有模拟柜原地关联到已发布订舱箱型需求;真实柜号可在提柜后补录") public record OceanBookingLinkRequest(@NotNull @Positive Long oceanBookingId, - @NotNull @Positive Long requirementId, @NotBlank @Size(max = 64) String containerNo, + @NotNull @Positive Long requirementId, @Size(max = 64) String containerNo, @NotNull @PositiveOrZero Integer expectedVersion) {} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java index 469ade09a..138e5e138 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java @@ -13,6 +13,20 @@ import org.apache.ibatis.annotations.Update; public interface ContainerBookingMapper extends BaseMapper { @Update("UPDATE fl_container_booking SET container_no=#{containerNo},ocean_booking_id=#{oceanBookingId},ocean_booking_requirement_id=#{requirementId},record_status='ACTIVE',bill_of_lading_no=#{billOfLadingNo},vessel_name=#{vesselName},voyage_no=#{voyageNo},shipping_company_id=#{shippingCompanyId},shipping_company_name=#{shippingCompanyName},departure_port_code=#{departurePortCode},departure_port_name=#{departurePortName},destination_port_code=#{destinationPortCode},destination_port_name=#{destinationPortName},channel_code=#{channelCode},channel_name=#{channelName},customer_id=#{customerId},customer_name=#{customerName},estimated_sailing_at=#{estimatedSailingAt},estimated_port_arrival_at=#{estimatedPortArrivalAt},updated_by=#{operatorId},updated_by_name=#{operatorName},updated_at=NOW(),version=version+1 WHERE id=#{id} AND version=#{version} AND source_type='SIMULATION' AND record_status='ACTIVE' AND ocean_booking_id IS NULL") int linkReleasedBooking(@Param("id") long id, @Param("oceanBookingId") long oceanBookingId, @Param("requirementId") long requirementId, @Param("containerNo") String containerNo, @Param("billOfLadingNo") String billOfLadingNo, @Param("vesselName") String vesselName, @Param("voyageNo") String voyageNo, @Param("shippingCompanyId") Long shippingCompanyId, @Param("shippingCompanyName") String shippingCompanyName, @Param("departurePortCode") String departurePortCode, @Param("departurePortName") String departurePortName, @Param("destinationPortCode") String destinationPortCode, @Param("destinationPortName") String destinationPortName, @Param("channelCode") String channelCode, @Param("channelName") String channelName, @Param("customerId") Long customerId, @Param("customerName") String customerName, @Param("estimatedSailingAt") java.time.LocalDateTime estimatedSailingAt, @Param("estimatedPortArrivalAt") java.time.LocalDateTime estimatedPortArrivalAt, @Param("version") int version, @Param("operatorId") long operatorId, @Param("operatorName") String operatorName); + @Update(""" + UPDATE fl_container_booking SET container_no=#{containerNo},updated_by=#{operatorId}, + updated_by_name=#{operatorName},updated_at=CURRENT_TIMESTAMP(3),version=version+1 + WHERE id=#{id} AND version=#{version} AND source_type='SIMULATION' AND record_status='ACTIVE' + AND container_no IS NULL AND ocean_booking_id IS NOT NULL AND ocean_booking_requirement_id IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM fl_loading_plan p WHERE p.container_booking_id=fl_container_booking.id + AND p.plan_status NOT IN ('EDITING')) + AND NOT EXISTS (SELECT 1 FROM fl_loading_pick_task t WHERE t.container_booking_id=fl_container_booking.id) + AND NOT EXISTS (SELECT 1 FROM fl_loading_cabinet_evidence e WHERE e.container_booking_id=fl_container_booking.id) + AND NOT EXISTS (SELECT 1 FROM fl_container_document d WHERE d.container_booking_id=fl_container_booking.id) + """) + int assignRealContainerNo(@Param("id") long id, @Param("containerNo") String containerNo, + @Param("version") int version, @Param("operatorId") long operatorId, + @Param("operatorName") String operatorName); @Select("""