diff --git a/scripts/README.md b/scripts/README.md index 7b35824b0..b9ad0727f 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,5 +1,11 @@ # 后端脚本管理与全量 HTTP 验收索引 +`fba-remaining-nodes-acceptance-transfer.mjs` 是 F33 原已有库存转运的内部验收助手,导入不执行。`freezeTransfer` 使用显式 TEST 配置建立独立会话,只读原票和来源库存后退出;`prepareOriginalTransferPlan` / `prepareTransferStage` 只准备本票真实关联换标的逐阶段命令,不能预设关联号。`runPreparedTransferStage` 默认零网络,只有总控明确 TEST、原票、客户、阶段和稳定批次并注入已核身份的单请求流 transport 后才执行;照片以原字节摘要校验,客户确认使用本票 MERCHANT 身份,不调用被禁止的 PDA 独立复核批准。报告与写入 journal 在 `reports/fba-remaining-nodes-20261004/transfer/`,写前持久化意图;只有已明确成功的原键原载荷可显式重放一次,上传不可重放,失败或结果未知先回查,不能新建替代票、解冻其他业务或重新收货。调用者须逐阶段独立核对库存、其他 HOLD、追加流水和主体归属,HTTP 成功不自动标记业务通过;退出自身会话,不执行 Firstleg 写入、SQL、部署或删除。直接测试 `fba-remaining-nodes-acceptance-transfer.test.mjs`。 + +`fba-remaining-nodes-acceptance.mjs` 是 FBA 剩余节点内部证据助手,显式调用 `auditRemainingNodes`,导入不运行;读取指定原报告并按 JSON pointer/SHA-256 保留证据。`live=true` 仅使用明确 TEST 配置的独立 Auth 会话和串行 GET,结束退出;不执行业务写命令、SQL、故障注入、部署或页面操作。报告位于 `reports/fba-remaining-nodes-20261004/`,拒绝覆盖既有报告,失败保留且不自动重试。对应离线测试为 `fba-remaining-nodes-acceptance.test.mjs`,只读核验不等于原业务已完成。 + +`fba-remaining-nodes-acceptance-pda-operator.mjs` 是 F33 海外 PDA 的一次性 TEST 账号准备助手,导入不运行。显式调用 `createF33PdaOperator({env, credentialFile, reportFile})`,凭据来自已授权 TEST 环境;仅通过正式 IAM API 创建独立洛杉矶仓作业员、授予已存在的海外 PDA 角色并验证登录/仓绑定,不改角色权限或旧账号,不操作库存。随机凭据仅写入调用方指定的仓库外环境文件,报告建议保存 `reports/fba-remaining-nodes-20261004/pda-operator/`,不包含密码或 Token;已有文件、请求未知结果或首次强制改密均停止,禁止自动重造。退出自有管理员/PDA会话,账号保留供页面验收,报告给出精确停用接口,清理由总控在验收结束后执行。 + `fleet-same-route-cost-test-env.mjs` 是同路线自有/外接实运成本分摊的串行TEST入口,默认`--plan`零网络;执行参数为`--execute --environment TEST --run-id <新批次> --warehouse-id <实际海外仓ID> --destination-warehouse-id `,凭据仅通过显式TEST/Jenkins环境文件及`FBA_CORE_JENKINS_AGENT`提供。复用两新客户普通来货/PDA实收/客户确认,各一托一箱,再复用真实外接预订、来货、PDA实收与关联,三托统一目的站;逐单拣货、实际装载和审核后整车发车。登记USD1.00合成成本,以不可变DEPART实测托位、三票来源及业务号顺序独立核对最大余数分币、原币守恒、创建重放与唯一历史,再逐票签收、正式结案并分别释放车辆/司机。自有两票分别分摊,不能先合并自有托位后假设汇总一定为0.67;余分按现行逐业务号规则分配。不写业务SQL、不部署、不操作财务付款。报告沿`reports/fleet-cross-<新批次>/`,增加`same-route-source.json`和`createdResources.fleetSameRouteCost`;入口要求验收函数显式返回本轮车次VERIFIED才能结案,不借另一证据对象的状态。失败保留原请求/样本、停止且不自动重跑,finally停用自有PDA并退出会话。`fleet-same-route-cost-test-env.test.mjs`验证错误来源/目的地/冻结依据/分币及零网络计划;真实HTTP与页面结论分别记录。 `fleet-booking-quotes-test-env.mjs` 是外接预订选用销售报价的独立 TEST HTTP 验收入口,使用显式 `node --env-file=<用户指定目录的test-acceptance.env>` 和 `--ready-commit=a6fd0ba3`,执行前须由总控核对实际部署版本与健康。只读公共仓城市/时区,创建自身全新隔离客户、销售报价及未分配资源的车次/询价草稿;验证候选日期/币种/计价、零价与缺价、手填价格、陈旧版本拒绝、刷新重选及来源快照,不创建库存、不发车、不证明承运成功或实收完成。请求前持久留证、敏感字段脱敏、未知结果停止且禁止自动重跑,独立登录并退出;保留自己的计划/报价/草稿,不删除资源。当前固定R2标识,独占报告 `reports/fleet-booking-quotes-live-20261003-r2/`。离线合同 `fleet-booking-quotes-test-env-contract.test.mjs`,运行前仍须总控放行对应健康版本。 diff --git a/scripts/fba-remaining-nodes-acceptance-pda-operator.mjs b/scripts/fba-remaining-nodes-acceptance-pda-operator.mjs new file mode 100644 index 000000000..b6d675547 --- /dev/null +++ b/scripts/fba-remaining-nodes-acceptance-pda-operator.mjs @@ -0,0 +1,66 @@ +import fs from 'node:fs' +import path from 'node:path' +import crypto from 'node:crypto' + +// Explicit one-time TEST setup. No inventory operations, existing-user changes or role changes. +export async function createF33PdaOperator({ env, credentialFile, reportFile }) { + if (String(env.CHONGCHONG_ENV).toUpperCase() !== 'TEST') throw Error('EXPLICIT_TEST_REQUIRED') + if (fs.existsSync(credentialFile) || fs.existsSync(reportFile)) throw Error('EXISTING_RESULT_RECONCILE_NO_RECREATE') + const warehouseId = '193000000000200003', roleCode = 'OVERSEAS_WAREHOUSE_PDA_OPERATOR' + const report = { startedAt: new Date().toISOString(), warehouseId, roleCode, requests: [], success: false, + existingAccountsChanged: false, rolePermissionsChanged: false, businessOperationsIssued: 0 } + const save = () => { fs.mkdirSync(path.dirname(reportFile), {recursive:true}); fs.writeFileSync(reportFile, JSON.stringify(report,null,2)) } + let admin, pda + const call = async(method, route, body, token=admin?.accessToken) => { + const entry={method,path:route.split('?')[0],phase:'INTENT'};report.requests.push(entry);save() + let response + try { response=await fetch(env.CHONGCHONG_ADMIN_API_BASE_URL.replace(/\/$/,'')+route,{method,redirect:'error',signal:AbortSignal.timeout(20000),headers:{Accept:'application/json',...(token?{Authorization:`Bearer ${token}`} : {}),...(body?{'Content-Type':'application/json'}:{})},...(body?{body:JSON.stringify(body)}:{})}) } + catch {entry.phase='OUTCOME_UNKNOWN';save();throw Error('OUTCOME_UNKNOWN_RECONCILE_NO_RETRY')} + const value=JSON.parse(await response.text());Object.assign(entry,{phase:'ACKNOWLEDGED',http:response.status,code:String(value.code),requestId:response.headers.get('x-request-id'),traceId:response.headers.get('x-trace-id')});save() + if(response.status!==200||String(value.code)!=='200')throw Error(`FORMAL_API_REJECTED_${response.status}_${value.code}`) + return value.data + } + try { + admin=await call('POST','/auth/login',{loginId:env.CHONGCHONG_ADMIN_USERNAME,password:env.CHONGCHONG_ADMIN_PASSWORD},'') + const roles=await call('GET','/platform/v1/iam/roles') + const permissions=await call('GET','/platform/v1/iam/permissions') + const role=roles.find(r=>r.roleCode===roleCode&&r.status==='ACTIVE') + const operate=permissions.find(p=>p.permissionCode==='lastleg:pda:operate') + if(!role||!operate||!(role.permissionIds||[]).some(id=>String(id)===String(operate.id)))throw Error('EXISTING_ROLE_OPERATE_PERMISSION_REQUIRED') + let phone + for(let i=0;i<10;i++){ + const candidate='18'+String(crypto.randomInt(0,1000000000)).padStart(9,'0') + if(await call('GET',`/platform/v1/iam/tenants/administrator-phone-availability?phone=${candidate}`)){phone=candidate;break} + } + if(!phone)throw Error('NO_AVAILABLE_SYNTHETIC_PHONE') + const username='pda_f33_20261004_'+crypto.randomBytes(5).toString('hex') + const password='Pda1!'+crypto.randomBytes(18).toString('hex') + const writeCredentials = membershipId => fs.writeFileSync(credentialFile, + `PDA_USERNAME=${username}\nPDA_PASSWORD=${password}\nPDA_MEMBERSHIP_ID=${membershipId}\n`,{mode:0o600}) + fs.mkdirSync(path.dirname(credentialFile),{recursive:true}) + fs.writeFileSync(credentialFile,`PDA_USERNAME=${username}\nPDA_PASSWORD=${password}\nPDA_MEMBERSHIP_ID=\n`,{flag:'wx',mode:0o600}) + const created=await call('POST','/platform/v1/iam/users',{username,password,phone, + displayName:'F33海外PDA独立验收-20261004',email:username+'@example.com',tenantId:'0',roleIds:[String(role.id)], + overseasWarehouseId:warehouseId,domesticWarehouseId:null,status:'ACTIVE'}) + report.subject={membershipId:String(created.membershipId),userId:String(created.userId),authUserId:String(created.authUserId), + tenantId:String(created.tenantId),roleIds:(created.roleIds||[]).map(String),overseasWarehouseId:String(created.overseasWarehouseId), + domesticWarehouseId:created.domesticWarehouseId??null,status:created.status,protectedAccount:created.protectedAccount} + writeCredentials(report.subject.membershipId);save() + if(report.subject.tenantId!=='0'||report.subject.overseasWarehouseId!==warehouseId||report.subject.status!=='ACTIVE' + ||report.subject.domesticWarehouseId!==null||report.subject.roleIds.length!==1||report.subject.roleIds[0]!==String(role.id))throw Error('CREATED_SUBJECT_SCOPE_MISMATCH') + pda=await call('POST','/auth/login',{loginId:username,password},'') + if(pda.passwordChangeRequired||pda.mustChangePassword||pda.firstLogin||!pda.accessToken)throw Error('FIRST_PASSWORD_CHANGE_OR_NORMAL_LOGIN_BLOCKED') + if(!(pda.authorities||[]).includes('lastleg:pda:operate')||!(pda.roles||[]).includes(roleCode)||(pda.roles||[]).length!==1)throw Error('NORMAL_LOGIN_ROLE_OR_PERMISSION_MISMATCH') + const profile=await call('GET','/pda/v1/last-leg/receiving-containers/warehouse-profile',undefined,pda.accessToken) + report.profile={warehouseId:String(profile.warehouseId),warehouseCode:profile.warehouseCode,warehouseName:profile.warehouseName} + if(report.profile.warehouseId!==warehouseId||report.profile.warehouseCode!=='US-LAX-01')throw Error('FORMAL_WAREHOUSE_PROFILE_MISMATCH') + report.cleanup={method:'PUT',path:`/platform/v1/iam/users/${report.subject.membershipId}/status`,body:{status:'DISABLED'},executeNow:false} + report.status='ACTIVE_FOR_COORDINATOR_UI';report.loginVerified=true;report.warehouseProfileVerified=true;report.success=true + }catch(error){report.failure=String(error.message).replace(/https?:\/\/\S+/g,'[redacted]')} + finally{ + if(pda?.refreshToken){try{await call('POST','/auth/logout',{refreshToken:pda.refreshToken},pda.accessToken);report.pdaSessionClosed=true}catch{report.pdaSessionClosed=false;report.success=false}}else report.pdaSessionClosed=!pda + if(admin?.refreshToken){try{await call('POST','/auth/logout',{refreshToken:admin.refreshToken},admin.accessToken);report.adminSessionClosed=true}catch{report.adminSessionClosed=false;report.success=false}}else report.adminSessionClosed=!admin + save() + } + return report +} diff --git a/scripts/fba-remaining-nodes-acceptance-transfer.mjs b/scripts/fba-remaining-nodes-acceptance-transfer.mjs new file mode 100644 index 000000000..de0178192 --- /dev/null +++ b/scripts/fba-remaining-nodes-acceptance-transfer.mjs @@ -0,0 +1,272 @@ +import fs from 'node:fs' +import path from 'node:path' +import crypto from 'node:crypto' +import { digest } from './fba-remaining-nodes-acceptance.mjs' +export const TRANSFER = 'WT-WH-20261002-430594' +export const CUSTOMER = '2105875471489142785' +export const BASE = `/platform/v1/last-leg/warehouse-transfers/${TRANSFER}?customerId=${CUSTOMER}` +export function relabelPrerequisite(detail, order) { + if (detail.needRelabel !== true) return { ready: true, scope: 'NOT_REQUIRED' } + const linked = Boolean(detail.relabelNo && (order.relabelApplications || []).some(app => + app.relabelNo === detail.relabelNo && String(app.lastlegOrderId) === String(detail.lastlegOrderId))) + const confirmed = linked && order.relabelStatus === 'COMPLETED' + return { ready: confirmed, scope: 'ORIGINAL_TRANSFER_RELABEL', linked, + relabelNo: detail.relabelNo || null, relabelStatus: order.relabelStatus || null, + bindingCount: detail.relabelBindings?.length || 0, + reason: confirmed ? null : 'Required relabel is not linked/completed for this original transfer; available source quantity does not waive it.' } +} +export function validateDestination(detail, warehouse, pallet, location) { + if (!warehouse || !pallet || !location) throw Error('EXPLICIT_DESTINATION_RECEIVING_RESOURCES_REQUIRED') + if (warehouse.warehouseType !== 'OVERSEAS' || warehouse.status !== 'ACTIVE') throw Error('DESTINATION_MUST_BE_ACTIVE_OVERSEAS_RECEIVING_WAREHOUSE') + if (String(pallet.warehouseId) !== String(warehouse.id) || String(location.warehouseId) !== String(warehouse.id) + || String(pallet.locationId) !== String(location.id)) throw Error('DESTINATION_RESOURCE_OWNERSHIP_MISMATCH') + if (pallet.status !== 'IDLE' || String(pallet.customerId) !== '0' || pallet.fbaWarehouseCode !== detail.destinationWarehouseCode) throw Error('DESTINATION_RESOURCE_UNAVAILABLE_OR_WRONG_FBA') + return true +} +export function validateSource(detail, balance, position) { + if (detail.transferNo !== TRANSFER || detail.status !== 'APPROVED' || detail.packageCount !== 1 || detail.allocations.length) throw Error('ORIGINAL_APPROVED_UNALLOCATED_TICKET_REQUIRED') + if (!detail.sourceOrders.some(s => String(s.inboundOrderId) === String(balance.inboundOrderId))) throw Error('SOURCE_NOT_BOUND_TO_ORIGINAL_TICKET') + if (String(balance.customerId) !== CUSTOMER || String(position.customerId) !== CUSTOMER || String(position.balanceId) !== String(balance.id)) throw Error('CUSTOMER_OR_BALANCE_MISMATCH') + if (String(balance.overseasWarehouseId) !== String(detail.operatingWarehouseId) || !position.palletId || Number(position.availablePackages) < 1) throw Error('SOURCE_UNAVAILABLE') + if (Number(position.holdPackages || 0) || Number(position.frozenPackages || 0) || Number(position.allocatedPackages || 0) || Number(position.pickedPackages || 0)) throw Error('SOURCE_HAS_OTHER_OCCUPANCY') + return { inventoryPositionId: String(position.id), palletId: String(position.palletId), packageCount: 1 } +} +export async function withTransferSession({env, output}, work) { + if (fs.existsSync(output)) throw Error('REPORT_EXISTS_NO_OVERWRITE') + if (String(env.CHONGCHONG_ENV).toUpperCase() !== 'TEST') throw Error('EXPLICIT_TEST_REQUIRED') + const report = { transferNo: TRANSFER, customerId: CUSTOMER, startedAt: new Date().toISOString(), requests: [], success: false } + const save = () => { fs.mkdirSync(path.dirname(output), {recursive:true}); fs.writeFileSync(output, JSON.stringify(report,null,2)) } + let session + async function call(method, route, body) { + const auth = route.startsWith('/auth/') + if (!auth && !route.startsWith('/platform/v1/last-leg/') && !route.startsWith('/platform/v1/overseas-warehouse-resources/') && !route.startsWith('/platform/v1/warehouse-admin')) throw Error('FIRSTLEG_OR_UNSCOPED_ROUTE_FORBIDDEN') + const entry = { method, path: route, phase: 'INTENT', ...(!auth && body ? {body, bodyHash:digest(body)} : {}) } + report.requests.push(entry); save() + let response + try { response = await fetch(env.CHONGCHONG_ADMIN_API_BASE_URL.replace(/\/$/,'')+route, {method,redirect:'error',signal:AbortSignal.timeout(20000),headers:{Accept:'application/json',...(session?{Authorization:`Bearer ${session.accessToken}`} : {}),...(body?{'Content-Type':'application/json'}:{})},...(body?{body:JSON.stringify(body)}:{})}) } + catch { entry.phase='OUTCOME_UNKNOWN'; save(); throw Error('OUTCOME_UNKNOWN_RECONCILE_ONLY_NO_RETRY') } + const json=JSON.parse(await response.text()); Object.assign(entry,{phase:'ACKNOWLEDGED',http:response.status,code:String(json.code),requestId:response.headers.get('x-request-id'),traceId:response.headers.get('x-trace-id')});save() + if(response.status!==200||String(json.code)!=='200')throw Error(`API_FAILURE_${response.status}_${json.code}`) + return json.data + } + async function pages(route) { + const records=[]; let total + for(let page=1;page<=100;page++) {const value=await call('GET',`${route}${route.includes('?')?'&':'?'}page=${page}&size=200`);if(total!==undefined&&total!==Number(value.total))throw Error('PAGINATION_TOTAL_CHANGED');total=Number(value.total);records.push(...value.records);if(records.length>=total){if(records.length!==total)throw Error('PAGINATION_COUNT_INVALID');return records}} + throw Error('PAGINATION_BOUND_EXCEEDED') + } + try {session=await call('POST','/auth/login',{loginId:env.CHONGCHONG_ADMIN_USERNAME,password:env.CHONGCHONG_ADMIN_PASSWORD});await work({call,pages,report,save});report.success=true} + catch(error){report.failure=String(error.message).replace(/https?:\/\/\S+/g,'[redacted]')} + finally { if(session){try{await call('POST','/auth/logout',{refreshToken:session.refreshToken});report.sessionClosed=true}catch{report.sessionClosed=false;report.success=false}}else report.sessionClosed=true;save() } + return report +} +export async function freezeTransfer({env,output}) { + return withTransferSession({env,output},async({call,pages,report,save})=>{ + const detail=await call('GET',BASE) + const balances=await pages(`/platform/v1/last-leg/inventory/balances?customerId=${CUSTOMER}`) + const ledger=await pages(`/platform/v1/last-leg/inventory/ledger?customerId=${CUSTOMER}`) + const sourceCandidates=[] + for(const b of balances.filter(b=>detail.items.some(i=>i.skuCode===b.skuCode)))sourceCandidates.push(await call('GET',`/platform/v1/last-leg/inventory/balances/${b.id}?customerId=${CUSTOMER}`)) + const sources=sourceCandidates.filter(b=>detail.sourceOrders.some(s=>String(s.inboundOrderId)===String(b.inboundOrderId))) + const positions=[] + for(const b of sources)positions.push(...await call('GET',`/platform/v1/last-leg/inventory/balances/${b.id}/positions?customerId=${CUSTOMER}`)) + Object.assign(report,{detail,balances,ledger,sources,positions,snapshotHash:digest({detail,balances,ledger,positions})});save() + report.scopeReady=sources.length>0&&positions.some(p=>Number(p.availablePackages)>=1) + }) +} + +export const LA = '193000000000200003' +export const NY = '193000000000200004' +const PDA_MEMBER = '2106727769572478980' +export const TRANSFER_STAGES = Object.freeze({ + approve: {actor:'platform',permission:'platform:overseas-warehouse:relabel-order:status',replay:'SAME_STATUS_NOOP'}, + claim: {actor:'pda',permission:'lastleg:pda:operate',replay:'KEYED'}, + 'create-work-pallet': {actor:'pda',permission:'lastleg:pda:operate',replay:'KEYED'}, + bind: {actor:'pda',permission:'lastleg:pda:operate',replay:'KEYED'}, + unit: {actor:'pda',permission:'lastleg:pda:operate',replay:'KEYED'}, + photo: {actor:'pda',permission:'lastleg:pda:operate',replay:'NONE'}, + complete: {actor:'pda',permission:'lastleg:pda:operate',replay:'KEYED'}, + 'review-read': {actor:'platform',permission:'platform:overseas-warehouse:relabel-order:list',replay:'READ_ONLY'}, + 'customer-confirm': {actor:'merchant',permission:'merchant:overseas-warehouse:relabel:confirm',replay:'KEYED'}, + outbound: {actor:'platform',permission:'lastleg:warehouse-transfer:manage',replay:'KEYED'}, + receipt: {actor:'platform',permission:'lastleg:warehouse-transfer:manage',replay:'KEYED'}, +}) +const demand = (value, message) => { if (!value) throw Error(message) } +const version = value => { demand(Number.isInteger(value)&&value>=0,'FORMAL_CURRENT_VERSION_REQUIRED');return value } +export function prepareOriginalTransferPlan(detail, runId) { + demand(/^[A-Za-z0-9_-]{1,48}$/.test(runId),'STABLE_RUN_ID_REQUIRED') + demand(detail?.transferNo===TRANSFER&&detail.needRelabel===true&&Number(detail.packageCount)===1 + &&String(detail.operatingWarehouseId)===LA&&detail.destinationWarehouseCode==='ABE4','ORIGINAL_TICKET_SCOPE_REQUIRED') + return {runId,transfer:structuredClone(detail),customerId:CUSTOMER,networkIssued:false, + linked:Boolean(detail.relabelNo&&detail.relabelApplicationId), + reconcile:{method:'GET',path:BASE}, + phases:Object.entries(TRANSFER_STAGES).map(([stage,rule])=>({stage,...rule})), + reviewBoundary:'PDA /review is intentionally rejected; platform review-task is read-only. Customer confirm-completion atomically completes review.', + receiptWarehouseId:NY,businessDestination:'ABE4',pdaMembershipId:PDA_MEMBER} +} +function ownRelabel(plan, linked) { + demand(plan.linked,'WAIT_FOR_ORIGINAL_ALLOCATION_NO_ASSUMED_RELABEL') + const app=linked?.application + demand(app&&app.relabelNo===plan.transfer.relabelNo&&String(app.id)===String(plan.transfer.relabelApplicationId) + &&String(app.lastlegOrderId)===String(plan.transfer.lastlegOrderId)&&app.customerOrderNo===TRANSFER + &&String(app.overseasWarehouseId)===LA,'EXACT_ORIGINAL_RELABEL_RELATION_REQUIRED') + return encodeURIComponent(app.relabelNo) +} +function commandHash(command) { + const {preparedHash,...value}=command + return digest(value) +} +export function prepareTransferStage(plan, stage, context={}) { + const rule=TRANSFER_STAGES[stage];demand(rule,'UNSUPPORTED_STAGE_NO_PDA_REVIEW_APPROVE') + const no=ownRelabel(plan,context.linked) + const key=`F33-WT-430594-${plan.runId}-${stage}` + const command={stage,runId:plan.runId,transferNo:TRANSFER,customerId:CUSTOMER,...rule,method:'POST',path:'',body:null} + const pdaBase=`/pda/v1/last-leg/relabel-tasks/${no}` + const platformBase=`/platform/v1/last-leg/relabel-applications/${no}` + const task=context.task + if(['claim','unit','complete'].includes(stage))demand(task?.relabelNo===plan.transfer.relabelNo&&task.customerOrderNo===TRANSFER,'OWN_CURRENT_PDA_TASK_REQUIRED') + if(stage==='approve'){command.method='PUT';command.path=platformBase+'/status';command.body={status:'PENDING_PROCESSING',remark:key}} + if(stage==='claim'){command.path=pdaBase+'/claim';command.body={idempotencyKey:key}} + if(stage==='create-work-pallet'){command.path='/pda/v1/last-leg/pallet-operations/relabel-pallet-label';command.body={relabelNo:plan.transfer.relabelNo,idempotencyKey:key}} + if(stage==='bind'){ + const {source,target,observedCartonMarks}=context + demand(source&&target&&String(source.warehouseId)===LA&&String(target.warehouseId)===LA + &&target.destinationWarehouseCode==='ABE4'&&source.palletCode!==target.palletCode,'FORMAL_LA_WORK_PALLETS_REQUIRED') + const marks=new Set((context.linked.items||[]).map(i=>i.cartonMark)) + demand(Array.isArray(observedCartonMarks)&&observedCartonMarks.length===1&&observedCartonMarks.every(m=>marks.has(m)),'OBSERVED_OWN_CARTON_REQUIRED') + demand(plan.transfer.allocations?.some(a=>String(a.inventoryPositionId)===String(source.positionId) + &&String(a.palletId)===String(source.palletId)&&Number(a.packageCount)===1),'ORIGINAL_SELECTED_POSITION_REQUIRED') + command.path='/pda/v1/last-leg/pallet-operations/relabel-disassemble' + command.body={relabelNo:plan.transfer.relabelNo,idempotencyKey:key, + source:{palletCode:source.palletCode,expectedVersion:version(source.palletVersion)}, + target:{palletCode:target.palletCode,expectedVersion:version(target.palletVersion)},cartonMarks:observedCartonMarks, + actualSource:{positionId:source.positionId,expectedPositionVersion:version(source.positionVersion),packages:1},reason:key} + } + if(stage==='unit'){ + const item=task.items?.find(i=>String(i.itemId)===String(context.itemId)) + demand(item&&item.operationType==='CARTON_AND_SKU','OWN_COMBINED_ITEM_REQUIRED') + demand(context.observedOldLabel===item.cartonMark&&context.observedNewLabel===item.fbaCartonMark,'OBSERVED_LABELS_MUST_MATCH_OWN_ITEM') + demand(Array.isArray(context.observedSkus)&&digest(context.observedSkus)===digest((item.skuItems||[]).map(s=>({skuCode:s.skuCode,newSkuCode:s.newSkuCode,quantity:s.quantity}))), 'OBSERVED_SKU_QUANTITIES_REQUIRED') + command.path=pdaBase+'/units';command.body={itemId:item.itemId,oldLabel:context.observedOldLabel,newLabel:context.observedNewLabel, + expectedVersion:version(task.version),idempotencyKey:key,skuVerifications:context.observedSkus} + } + if(stage==='photo'){ + const photo=context.photo + demand(photo&&/^[a-f0-9]{64}$/.test(photo.sha256)&&Number.isSafeInteger(photo.sizeBytes)&&photo.sizeBytes>0 + &&photo.contentType?.startsWith('image/')&&photo.fileName,'EXPLICIT_PHOTO_CONTENT_REQUIRED') + command.path=pdaBase+'/photos/files';command.body=null + command.multipart={field:'file',fileName:photo.fileName,contentType:photo.contentType,sizeBytes:photo.sizeBytes,sha256:photo.sha256} + } + if(stage==='complete'){ + demand(task.taskStatus==='PROCESSING'&&Number(task.expectedPackages)===1&&Number(task.relabelledPackages)===1 + &&Number(task.skippedPackages)===0&&Number(task.exceptionPackages)===0,'FULL_NORMAL_WORK_FACTS_REQUIRED') + const ownMarks=new Set(task.items.map(i=>i.cartonMark)) + const bound=(task.history||[]).some(h=>{ + if(h.action!=='PALLET_BIND')return false + try{const proof=JSON.parse(h.detail);return proof.round===(task.reviewedAt||'INITIAL') + &&proof.cartonMarks?.some(m=>ownMarks.has(m))&&proof.targetPallets?.length>0}catch{return false} + }) + demand(bound&&Array.isArray(context.photoFileIds)&&context.photoFileIds.length>0 + &&context.photoFileIds.every(id=>/^[1-9]\d*$/.test(String(id))) + &&new Set(context.photoFileIds.map(String)).size===context.photoFileIds.length,'ACTUAL_BINDING_AND_OWN_PHOTOS_REQUIRED') + command.path=pdaBase+'/complete';command.body={photoFileIds:context.photoFileIds,expectedVersion:version(task.version),idempotencyKey:key,remark:key,manualCompletion:false} + } + if(stage==='review-read'){command.method='GET';command.path=platformBase+'/review-task';command.body=null} + if(stage==='customer-confirm'){ + demand(task?.taskStatus==='WAIT_REVIEW'&&task.relabelNo===plan.transfer.relabelNo&&Number(task.relabelledPackages)===1 + &&task.completionPhotoFileIds?.length>0,'SUBMITTED_WORK_AND_PHOTOS_REQUIRED') + command.path=`/merchant/v1/last-leg/relabel-applications/${no}/confirm-completion`;command.body={idempotencyKey:key,remark:key} + } + if(stage==='outbound'){ + demand(plan.transfer.status==='ALLOCATED'&&relabelPrerequisite(plan.transfer,context.order).ready,'OWN_RELABEL_CUSTOMER_CONFIRMATION_REQUIRED') + demand(plan.transfer.allocations?.length===1&&plan.transfer.allocations[0].status==='RESERVED' + &&plan.transfer.allocations[0].skuCode===plan.transfer.relabelBindings[0].newSku,'CURRENT_NEW_SKU_ALLOCATION_REQUIRED') + command.path=`/platform/v1/last-leg/warehouse-transfers/${TRANSFER}/outbound?customerId=${CUSTOMER}`;command.body={idempotencyKey:key,remark:key} + } + if(stage==='receipt'){ + const a=plan.transfer.allocations?.[0],t=context.receiptTarget + demand(plan.transfer.status==='OUTBOUNDED'&&plan.transfer.allocations.length===1&&a.status==='SHIPPED' + &&Number(a.packageCount)===1&&a.skuCode===plan.transfer.relabelBindings[0].newSku,'CURRENT_SHIPPED_NEW_SKU_ALLOCATION_REQUIRED') + demand(t&&String(t.warehouse?.id)===NY,'EXPLICIT_NEW_YORK_RECEIVING_TARGET_REQUIRED') + validateDestination(plan.transfer,t.warehouse,t.pallet,t.location) + command.path=`/platform/v1/last-leg/warehouse-transfers/${TRANSFER}/receipts?customerId=${CUSTOMER}` + command.body={sourceInventoryPositionId:a.inventoryPositionId,destinationOverseasWarehouseId:NY, + destinationOverseasWarehouseCode:t.warehouse.warehouseCode,destinationOverseasWarehouseName:t.warehouse.warehouseName, + destinationWarehouseId:'193000000000000003',destinationWarehouseCode:'ABE4',destinationWarehouseName:'ABE4', + destinationPalletId:t.pallet.id,destinationPalletNo:t.pallet.palletCode,warehouseLocationId:t.location.id, + warehouseLocationCode:t.location.locationCode,batchNo:TRANSFER,idempotencyKey:key,remark:key} + } + command.preparedHash=commandHash(command) + return command +} +export async function runPreparedTransferStage({command,release,actor,transport,journalDirectory,binary,replay=false}) { + if(!release?.execute)return {mode:'PLAN',networkIssued:false,command} + demand(release.environment==='TEST'&&release.transferNo===TRANSFER&&release.customerId===CUSTOMER + &&release.stage===command.stage&&release.runId===command.runId,'EXACT_STAGE_RELEASE_REQUIRED') + demand(command.preparedHash===commandHash(command),'PREPARED_COMMAND_CHANGED') + const policy=TRANSFER_STAGES[command.stage];demand(policy,'UNSUPPORTED_STAGE') + demand(command.transferNo===TRANSFER&&command.customerId===CUSTOMER + &&/^[A-Za-z0-9_-]{1,48}$/.test(command.runId),'ORIGINAL_COMMAND_SCOPE_REQUIRED') + const commandFields=new Set(['stage','runId','transferNo','customerId','actor','permission','replay','method','path','body','multipart','preparedHash']) + demand(Object.keys(command).every(field=>commandFields.has(field)),'NO_CREDENTIALS_OR_EXTRA_TRANSPORT_FIELDS_IN_JOURNAL') + demand(actor?.kind===policy.actor&&Array.isArray(actor.authorities)&&actor.authorities.includes(policy.permission),'ACTOR_PERMISSION_REQUIRED') + if(policy.actor==='pda')demand(Array.isArray(actor.roles)&&actor.roles.includes('OVERSEAS_WAREHOUSE_PDA_OPERATOR') + &&actor.appCode==='PLATFORM'&&String(actor.tenantId)==='0' + &&String(actor.membershipId)===PDA_MEMBER&&String(actor.warehouseId)===LA,'OWN_PDA_IDENTITY_REQUIRED') + else demand(actor.appCode===(policy.actor==='merchant'?'MERCHANT':'PLATFORM') + &&String(actor.tenantId)===(policy.actor==='merchant'?CUSTOMER:'0'),'ACTOR_SCOPE_REQUIRED') + demand(typeof transport==='function'&&journalDirectory,'EXPLICIT_TRANSPORT_AND_JOURNAL_REQUIRED') + if(command.multipart)demand(binary&&binary.length===command.multipart.sizeBytes + &&crypto.createHash('sha256').update(binary).digest('hex')===command.multipart.sha256,'PHOTO_BYTES_CHANGED') + fs.mkdirSync(journalDirectory,{recursive:true}) + const file=path.join(journalDirectory,`${command.runId}-${command.stage}.json`) + let journal + if(fs.existsSync(file)){ + journal=JSON.parse(fs.readFileSync(file,'utf8')) + demand(replay&&['KEYED','SAME_STATUS_NOOP','READ_ONLY'].includes(policy.replay) + &&journal.success===true&&journal.command.preparedHash===command.preparedHash + &&!journal.replay,'ONLY_ACKNOWLEDGED_EXACT_ORIGINAL_REPLAY_ALLOWED') + journal.replay={phase:'INTENT'};fs.writeFileSync(file,JSON.stringify(journal,null,2)) + }else{ + demand(!replay,'ORIGINAL_ACKNOWLEDGED_COMMAND_REQUIRED') + journal={command,actor:{kind:actor.kind,membershipId:actor.membershipId,warehouseId:actor.warehouseId},phase:'INTENT',success:false,businessVerified:false} + fs.writeFileSync(file,JSON.stringify(journal,null,2),{flag:'wx'}) + } + const entry=replay?journal.replay:journal + try{ + const response=await transport(command,binary) + Object.assign(entry,{phase:'ACKNOWLEDGED',http:response.http,code:String(response.code),requestId:response.requestId,traceId:response.traceId,resultDigest:digest(response.data??null)}) + entry.success=response.http===200&&String(response.code)==='200' + fs.writeFileSync(file,JSON.stringify(journal,null,2)) + demand(entry.success,'STAGE_REJECTED_STOP_NO_RECREATE') + return {mode:'EXECUTED',journalFile:file,response} + }catch(error){ + if(entry.phase==='INTENT'){entry.phase='OUTCOME_UNKNOWN';entry.success=false;fs.writeFileSync(file,JSON.stringify(journal,null,2))} + throw Error(entry.phase==='OUTCOME_UNKNOWN'?'OUTCOME_UNKNOWN_READBACK_ONLY_NO_RETRY':'STAGE_REJECTED_STOP_NO_RECREATE') + } +} + +export function verifyTransferMilestone(stage, facts) { + demand(['selection','bind','unit','customer-confirm','outbound','receipt'].includes(stage),'UNKNOWN_STOCK_MILESTONE') + const count=value=>{demand((typeof value==='number'||typeof value==='string'&&/^\d+$/.test(value)) + &&Number.isSafeInteger(Number(value))&&Number(value)>=0,'EXPLICIT_INTEGER_STOCK_FACT_REQUIRED');return Number(value)} + const beforeSource=count(facts.baselineSourceTotal),beforeTarget=count(facts.baselineTargetTotal) + const source=count(facts.sourceTotal),target=count(facts.targetTotal) + const departed=['outbound','receipt'].includes(stage),received=stage==='receipt' + demand(source===beforeSource-(departed?1:0)&&target===beforeTarget+(received?1:0),'ORIGINAL_ONE_CARTON_STOCK_DELTA_REQUIRED') + demand(facts.protectedBefore&&facts.protectedAfter&&digest(facts.protectedBefore)===digest(facts.protectedAfter),'OTHER_HOLD_AND_STOCK_MUST_REMAIN_IDENTICAL') + const past=new Map((facts.ledgerAfter||[]).map(row=>[String(row.id),row])) + demand(Array.isArray(facts.ledgerBefore)&&facts.ledgerBefore.every(row=>past.has(String(row.id))&&digest(past.get(String(row.id)))===digest(row)), 'PRIOR_LEDGER_FACTS_MUST_NOT_CHANGE') + demand(past.size===(facts.ledgerAfter||[]).length,'DUPLICATE_LEDGER_IDS_NOT_COMPLETE_EVIDENCE') + if(departed){ + const ship=facts.ledgerAfter.filter(row=>row.businessType==='SHIP'&&row.businessOrderNo===TRANSFER) + demand(facts.expectedNewSku&&ship.length===1&&Number(ship[0].deltaPackages)===-1 + &&ship[0].skuCode===facts.expectedNewSku,'UNIQUE_ORIGINAL_NEW_SKU_SHIPMENT_REQUIRED') + } + if(received){ + const receipts=facts.ledgerAfter.filter(row=>row.businessType==='STOCK_IN'&&row.businessOrderNo===TRANSFER + &&String(row.overseasWarehouseId)===NY) + demand(receipts.length===1&&Number(receipts[0].deltaPackages)===1 + &&receipts[0].skuCode===facts.expectedNewSku,'UNIQUE_NEW_YORK_NEW_SKU_RECEIPT_REQUIRED') + } + return {verified:true,stage,sourceTotal:source,targetTotal:target,inTransitCartons:departed&&!received?1:0} +} diff --git a/scripts/fba-remaining-nodes-acceptance-transfer.test.mjs b/scripts/fba-remaining-nodes-acceptance-transfer.test.mjs new file mode 100644 index 000000000..789a259df --- /dev/null +++ b/scripts/fba-remaining-nodes-acceptance-transfer.test.mjs @@ -0,0 +1,149 @@ +import {test} from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import os from 'node:os' +import crypto from 'node:crypto' +import {validateSource,validateDestination,relabelPrerequisite,TRANSFER,CUSTOMER} from './fba-remaining-nodes-acceptance-transfer.mjs' +import {LA,NY,prepareOriginalTransferPlan,prepareTransferStage,runPreparedTransferStage,verifyTransferMilestone} from './fba-remaining-nodes-acceptance-transfer.mjs' +const detail={transferNo:TRANSFER,status:'APPROVED',packageCount:1,allocations:[],operatingWarehouseId:'w',sourceOrders:[{inboundOrderId:'o'}]} +const balance={id:'b',customerId:CUSTOMER,inboundOrderId:'o',overseasWarehouseId:'w'} +const position={id:'p',balanceId:'b',customerId:CUSTOMER,palletId:'t',availablePackages:1,frozenPackages:0,pickedPackages:0} +test('original approved one-carton source passes exact ownership',()=>assert.deepEqual(validateSource(detail,balance,position),{inventoryPositionId:'p',palletId:'t',packageCount:1})) +test('other source, customer, warehouse and unavailable or occupied cartons stop before commands',()=>{ + for(const b of [{...balance,inboundOrderId:'other'},{...balance,customerId:'other'},{...balance,overseasWarehouseId:'other'}])assert.throws(()=>validateSource(detail,b,position)) + for(const p of [{...position,availablePackages:0},{...position,frozenPackages:1},{...position,holdPackages:1},{...position,allocatedPackages:1},{...position,pickedPackages:1},{...position,palletId:null}])assert.throws(()=>validateSource(detail,balance,p)) + assert.throws(()=>validateSource({...detail,allocations:[{}]},balance,position)) +}) +test('missing destination or FBA master cannot silently become a receiving overseas resource',()=>{ + assert.throws(()=>validateDestination(detail)) + assert.throws(()=>validateDestination(detail,{warehouseType:'FBA',status:'ACTIVE'},{},{})) + const d={destinationWarehouseCode:'ABE4'},w={id:'ny',warehouseType:'OVERSEAS',status:'ACTIVE'} + const p={warehouseId:'ny',locationId:'loc',status:'IDLE',customerId:'0',fbaWarehouseCode:'ABE4'} + const l={id:'loc',warehouseId:'ny'} + assert.equal(validateDestination(d,w,p,l),true) + assert.throws(()=>validateDestination(d,w,{...p,warehouseId:'la'},l)) + assert.throws(()=>validateDestination(d,w,{...p,status:'OCCUPIED'},l)) +}) +test('available original-source cartons cannot waive original ticket relabel binding',()=>{ + const d={needRelabel:true,lastlegOrderId:'13',relabelBindings:[{quantity:1}]} + assert.equal(relabelPrerequisite(d,{relabelStatus:'PENDING_BINDING'}).ready,false) + assert.equal(relabelPrerequisite({...d,relabelNo:'original'},{relabelApplications:[{relabelNo:'other',lastlegOrderId:'13'}],relabelStatus:'COMPLETED'}).ready,false) + assert.equal(relabelPrerequisite({...d,relabelNo:'original'},{relabelApplications:[{relabelNo:'original',lastlegOrderId:'13'}],relabelStatus:'PENDING_PROCESSING'}).ready,false) + assert.equal(relabelPrerequisite({...d,relabelNo:'original'},{relabelApplications:[{relabelNo:'original',lastlegOrderId:'other'}],relabelStatus:'COMPLETED'}).ready,false) + assert.equal(relabelPrerequisite({...d,relabelNo:'original'},{relabelApplications:[{relabelNo:'original',lastlegOrderId:'13'}],relabelStatus:'COMPLETED'}).ready,true) +}) + +function linkedFixture(status='WAIT_RELABEL'){ + const transfer={transferNo:TRANSFER,needRelabel:true,packageCount:1,operatingWarehouseId:LA,destinationWarehouseCode:'ABE4', + relabelNo:'RL-SYNTHETIC-ONLY',relabelApplicationId:'app',lastlegOrderId:'tail',status, + relabelBindings:[{newSku:'NEW'}],allocations:[{inventoryPositionId:'selected-old',palletId:'source-pallet',packageCount:1,status:'RESERVED',skuCode:'OLD'}]} + const linked={application:{id:'app',relabelNo:transfer.relabelNo,lastlegOrderId:'tail',customerOrderNo:TRANSFER,overseasWarehouseId:LA},items:[{cartonMark:'BOX-1'}]} + const task={relabelNo:transfer.relabelNo,customerOrderNo:TRANSFER,taskStatus:'PROCESSING',version:2, + expectedPackages:1,relabelledPackages:1,skippedPackages:0,exceptionPackages:0, + items:[{itemId:'item',cartonMark:'BOX-1',fbaCartonMark:'FBA-BOX-1',operationType:'CARTON_AND_SKU',skuItems:[{skuCode:'OLD',newSkuCode:'NEW',quantity:1}]}], + history:[{action:'PALLET_BIND',detail:JSON.stringify({round:'INITIAL',cartonMarks:['BOX-1'],targetPallets:['WORK-1']})}]} + return {transfer,linked,task,plan:prepareOriginalTransferPlan(transfer,'offline-contract')} +} +const platform={kind:'platform',appCode:'PLATFORM',tenantId:'0',authorities:['platform:overseas-warehouse:relabel-order:status']} +const release=stage=>({execute:true,environment:'TEST',transferNo:TRANSFER,customerId:CUSTOMER,stage,runId:'offline-contract'}) +function temporaryJournal(){const directory=fs.mkdtempSync(path.join(os.tmpdir(),'f33-stage-'));return {directory,cleanup(){for(const file of fs.readdirSync(directory))fs.unlinkSync(path.join(directory,file));fs.rmdirSync(directory)}}} +test('unallocated original ticket remains a zero-network plan without inventing a relabel number',async()=>{ + const f=linkedFixture();delete f.transfer.relabelNo;delete f.transfer.relabelApplicationId + const plan=prepareOriginalTransferPlan(f.transfer,'unallocated') + assert.equal(plan.linked,false);assert.equal(plan.networkIssued,false) + assert.throws(()=>prepareTransferStage(plan,'approve',{linked:f.linked}),/WAIT_FOR_ORIGINAL_ALLOCATION/) + assert.throws(()=>prepareTransferStage(f.plan,'review-approve',{linked:f.linked}),/UNSUPPORTED_STAGE/) + const command=prepareTransferStage(f.plan,'approve',{linked:f.linked});let calls=0 + assert.equal((await runPreparedTransferStage({command,transport:async()=>{calls++}})).mode,'PLAN');assert.equal(calls,0) +}) +test('different relabel parent IDs or customer-order linkage cannot enter the original pipeline',()=>{ + const f=linkedFixture() + for(const patch of [{id:'other'},{lastlegOrderId:'other'},{customerOrderNo:'OTHER-TICKET'},{overseasWarehouseId:NY}]){ + assert.throws(()=>prepareTransferStage(f.plan,'approve',{linked:{...f.linked,application:{...f.linked.application,...patch}}}),/EXACT_ORIGINAL_RELABEL/) + } +}) +test('actual labels and approved SKU piece counts are required for one combined unit fact',()=>{ + const f=linkedFixture(),context={linked:f.linked,task:f.task,itemId:'item',observedOldLabel:'BOX-1',observedNewLabel:'FBA-BOX-1',observedSkus:[{skuCode:'OLD',newSkuCode:'NEW',quantity:1}]} + const command=prepareTransferStage(f.plan,'unit',context);assert.equal(command.body.expectedVersion,2);assert.match(command.path,/\/units$/) + assert.throws(()=>prepareTransferStage(f.plan,'unit',{...context,observedOldLabel:'OTHER'})) + assert.throws(()=>prepareTransferStage(f.plan,'unit',{...context,observedSkus:[{skuCode:'OLD',newSkuCode:'NEW',quantity:2}]})) +}) +test('normal completion requires current-round physical binding photos and no skipped cartons',()=>{ + const f=linkedFixture(),context={linked:f.linked,task:f.task,photoFileIds:['77']} + assert.equal(prepareTransferStage(f.plan,'complete',context).body.manualCompletion,false) + assert.throws(()=>prepareTransferStage(f.plan,'complete',{...context,task:{...f.task,reviewedAt:'NEW-ROUND'}})) + assert.throws(()=>prepareTransferStage(f.plan,'complete',{...context,task:{...f.task,skippedPackages:1}})) + assert.throws(()=>prepareTransferStage(f.plan,'complete',{...context,photoFileIds:[]})) +}) +test('receipt consumes the current shipped output position and keeps New York separate from ABE4',()=>{ + const f=linkedFixture('OUTBOUNDED');f.transfer.allocations=[{inventoryPositionId:'new-output',palletId:'work-pallet',packageCount:1,status:'SHIPPED',skuCode:'NEW'}] + const plan=prepareOriginalTransferPlan(f.transfer,'receipt') + const receiptTarget={warehouse:{id:NY,warehouseType:'OVERSEAS',status:'ACTIVE',warehouseCode:'US-NYC-01',warehouseName:'纽约仓'}, + pallet:{id:'ny-pallet',warehouseId:NY,locationId:'ny-location',palletCode:'F33-NY',status:'IDLE',customerId:'0',fbaWarehouseCode:'ABE4'},location:{id:'ny-location',warehouseId:NY,locationCode:'F33-NY-LOC'}} + const command=prepareTransferStage(plan,'receipt',{linked:f.linked,receiptTarget}) + assert.equal(command.body.sourceInventoryPositionId,'new-output');assert.equal(command.body.destinationOverseasWarehouseId,NY) + assert.equal(command.body.destinationWarehouseCode,'ABE4');assert.equal(command.body.destinationWarehouseId,'193000000000000003') + assert.throws(()=>prepareTransferStage(plan,'receipt',{linked:f.linked,receiptTarget:{...receiptTarget,warehouse:{...receiptTarget.warehouse,id:LA}}})) +}) +test('write intent is durable before a single transport call; only known exact original replay is allowed',async()=>{ + const f=linkedFixture(),command=prepareTransferStage(f.plan,'approve',{linked:f.linked}),temp=temporaryJournal();let count=0 + try{ + const transport=async c=>{count++;const j=JSON.parse(fs.readFileSync(path.join(temp.directory,`${c.runId}-${c.stage}.json`)));assert.equal(count===1?j.phase:j.replay.phase,'INTENT');return {http:200,code:'200',data:{approved:true}}} + const options={command,release:release('approve'),actor:platform,transport,journalDirectory:temp.directory} + await runPreparedTransferStage(options);await assert.rejects(runPreparedTransferStage(options));await runPreparedTransferStage({...options,replay:true}) + assert.equal(count,2);const journal=JSON.parse(fs.readFileSync(path.join(temp.directory,`${command.runId}-${command.stage}.json`)));assert.equal(journal.businessVerified,false) + await assert.rejects(runPreparedTransferStage({...options,replay:true}));assert.equal(count,2) + }finally{temp.cleanup()} +}) +test('unknown result or explicit rejection preserves evidence and blocks retry even with the original key',async()=>{ + for(const unknown of [true,false]){ + const f=linkedFixture(),command=prepareTransferStage(f.plan,'approve',{linked:f.linked}),temp=temporaryJournal();let calls=0 + try{ + const transport=async()=>{calls++;if(unknown)throw Error('secret diagnostics must not be recorded');return {http:409,code:'A0409',requestId:'req',traceId:'trace'}} + const options={command,release:release('approve'),actor:platform,transport,journalDirectory:temp.directory} + await assert.rejects(runPreparedTransferStage(options));await assert.rejects(runPreparedTransferStage({...options,replay:true}));assert.equal(calls,1) + const raw=fs.readFileSync(path.join(temp.directory,`${command.runId}-${command.stage}.json`),'utf8');assert.doesNotMatch(raw,/secret diagnostics/) + const j=JSON.parse(raw);assert.equal(j.phase,unknown?'OUTCOME_UNKNOWN':'ACKNOWLEDGED');if(!unknown)assert.equal(j.requestId,'req') + }finally{temp.cleanup()} + } +}) +test('stage release actor or prepared-payload mismatch cannot send a request',async()=>{ + const f=linkedFixture(),command=prepareTransferStage(f.plan,'approve',{linked:f.linked});let calls=0 + const options={command,release:release('approve'),actor:platform,journalDirectory:'not-created',transport:async()=>{calls++}} + await assert.rejects(runPreparedTransferStage({...options,release:release('receipt')})) + await assert.rejects(runPreparedTransferStage({...options,actor:{...platform,tenantId:CUSTOMER}})) + await assert.rejects(runPreparedTransferStage({...options,command:{...command,body:{status:'REJECTED'}}})) + assert.equal(calls,0) +}) +test('photo upload bytes are frozen and an acknowledged no-key upload cannot be repeated',async()=>{ + const f=linkedFixture(),binary=Buffer.from('synthetic offline transport fixture') + const command=prepareTransferStage(f.plan,'photo',{linked:f.linked,photo:{fileName:'fixture.png',contentType:'image/png',sizeBytes:binary.length,sha256:crypto.createHash('sha256').update(binary).digest('hex')}}) + const actor={kind:'pda',appCode:'PLATFORM',tenantId:'0',membershipId:'2106727769572478980',warehouseId:LA,roles:['OVERSEAS_WAREHOUSE_PDA_OPERATOR'],authorities:['lastleg:pda:operate']} + const temp=temporaryJournal();let calls=0 + try{ + const options={command,release:release('photo'),actor,binary,journalDirectory:temp.directory,transport:async()=>{calls++;return {http:200,code:'200',data:{fileId:'77'}}}} + await assert.rejects(runPreparedTransferStage({...options,binary:Buffer.from('changed')}));assert.equal(calls,0) + await runPreparedTransferStage(options);await assert.rejects(runPreparedTransferStage({...options,replay:true}));assert.equal(calls,1) + }finally{temp.cleanup()} +}) +test('physical binding uses selected source and current versions without client customer/warehouse scope in PDA body',()=>{ + const f=linkedFixture(),context={linked:f.linked,source:{warehouseId:LA,positionId:'selected-old',palletId:'source-pallet',palletCode:'SOURCE',palletVersion:3,positionVersion:4},target:{warehouseId:LA,palletCode:'WORK',palletVersion:0,destinationWarehouseCode:'ABE4'},observedCartonMarks:['BOX-1']} + const c=prepareTransferStage(f.plan,'bind',context) + assert.equal(c.body.actualSource.positionId,'selected-old');assert.equal(c.body.actualSource.packages,1);assert.equal(c.body.target.expectedVersion,0) + assert.equal(c.body.customerId,undefined);assert.equal(c.body.warehouseId,undefined) + assert.throws(()=>prepareTransferStage(f.plan,'bind',{...context,target:{...context.target,warehouseId:NY}})) +}) +test('one original carton stays conserved and existing HOLD and append-only facts remain untouched',()=>{ + const unchanged={positionId:'protected',availablePackages:0,holdPackages:1} + const ship={id:'ship',businessType:'SHIP',businessOrderNo:TRANSFER,deltaPackages:-1,skuCode:'NEW'} + const receipt={id:'receipt',businessType:'STOCK_IN',businessOrderNo:TRANSFER,overseasWarehouseId:NY,deltaPackages:1,skuCode:'NEW'} + const base={baselineSourceTotal:4,baselineTargetTotal:0,expectedNewSku:'NEW',protectedBefore:unchanged,protectedAfter:unchanged,ledgerBefore:[{id:'old',packages:1}],ledgerAfter:[ship,receipt,{id:'old',packages:1}]} + assert.equal(verifyTransferMilestone('customer-confirm',{...base,sourceTotal:4,targetTotal:0}).verified,true) + assert.equal(verifyTransferMilestone('outbound',{...base,sourceTotal:3,targetTotal:0}).inTransitCartons,1) + assert.equal(verifyTransferMilestone('receipt',{...base,sourceTotal:3,targetTotal:1}).inTransitCartons,0) + assert.throws(()=>verifyTransferMilestone('receipt',{...base,sourceTotal:2,targetTotal:1})) + assert.throws(()=>verifyTransferMilestone('receipt',{...base,sourceTotal:3,targetTotal:1,protectedAfter:{...unchanged,holdPackages:0}})) + assert.throws(()=>verifyTransferMilestone('receipt',{...base,sourceTotal:3,targetTotal:1,ledgerAfter:[{id:'old',packages:2}]})) + assert.throws(()=>verifyTransferMilestone('receipt',{...base,sourceTotal:3,targetTotal:1,ledgerAfter:[ship,{...receipt,skuCode:'OLD'},{id:'old',packages:1}]})) +}) diff --git a/scripts/fba-remaining-nodes-acceptance.mjs b/scripts/fba-remaining-nodes-acceptance.mjs new file mode 100644 index 000000000..083b26dde --- /dev/null +++ b/scripts/fba-remaining-nodes-acceptance.mjs @@ -0,0 +1,122 @@ +// Internal evidence helper; invoked explicitly by the assigned audit, never runs on import. +import fs from 'node:fs' +import path from 'node:path' +import crypto from 'node:crypto' + +export const digest = value => crypto.createHash('sha256').update(typeof value === 'string' ? value : JSON.stringify(value)).digest('hex') +export function selectSteps(report, predicate) { + return (report.steps || []).flatMap((step, index) => predicate(step) ? [{ pointer: `/steps/${index}`, ...step }] : []) +} +export function requirePassed(steps) { + return steps.length > 0 && steps.every(s => s.passed === true) +} +export function transferConclusion(detail) { + const shipped = Boolean(detail.actualShipAt) + const terminal = detail.status === 'RECEIVED' + const received = (detail.allocations || []).length > 0 && detail.allocations.every(a => a.status === 'RECEIVED' && a.destinationInventoryPositionId) + return { status: detail.status, orderStatus: detail.orderStatus, transferNo: detail.transferNo, + allocationCount: detail.allocations?.length || 0, packageCount: detail.packageCount, + sourceCount: detail.sourceOrders?.length || 0, actualShipAt: detail.actualShipAt || null, + completed: terminal && shipped && received, requiresRealOperation: !(terminal && shipped && received) } +} +export async function auditRemainingNodes({ root, output, env, live = false }) { + if (fs.existsSync(output)) throw Error('REPORT_EXISTS_NO_OVERWRITE') + const read = relative => JSON.parse(fs.readFileSync(path.join(root, relative), 'utf8')) + const files = ['reports/node-completion-audit-root-20261004-r1.json', + 'reports/admin-full-current-20261002-r4.json', 'reports/overseas-receiving-current-20261002-r1.json', + 'reports/fba-core-f40-tax-fleet-20261003-r1/root_first.json', + 'reports/f26-loaded-transit-verification-20261004-r2.json', + 'reports/discard-review-entry-root-20261004-r1/final-evidence.json'] + const reports = Object.fromEntries(files.map(f => [f, read(f)])) + const full = reports[files[1]] + const evidence = (file, predicate) => ({ file, sha256: digest(fs.readFileSync(path.join(root, file), 'utf8')), + steps: selectSteps(reports[file], predicate) }) + const result = { checkedAt: new Date().toISOString(), goalComplete: false, + scope: 'Existing TEST evidence only; no business commands, SQL, fault injection, deployment or UI operation', + sourceFiles: files.map(file => ({ file, sha256: digest(fs.readFileSync(path.join(root, file), 'utf8')) })), + integrationRequired: { file: 'scripts/README.md', owner: '总控', + reason: 'Current write boundary excludes shared script index; documentation contract identifies this new helper as undocumented.', + text: '`scripts/fba-remaining-nodes-acceptance.mjs`:本轮FBA剩余节点内部证据助手,仅显式调用auditRemainingNodes;读取指定原报告并按JSON pointer/SHA-256保留证据。live=true仅使用明确TEST环境文件的独立Auth会话与串行GET,正常退出;不执行业务写命令、SQL、故障注入、部署或页面操作。报告reports/fba-remaining-nodes-20261004/,既有报告拒绝覆盖,失败保留且不自动重试。对应离线测试fba-remaining-nodes-acceptance.test.mjs。' }, + nodes: [], requests: [], sessionClosed: !live } + result.nodes.push({ node: 'F23.2', conclusion: 'HTTP actual AT_ORIGIN_PORT supported; Admin save and two-end projection remain unverified', + category: '需真实操作', evidence: [evidence(files[3], s => /AT_ORIGIN_PORT/.test(s.name))], + next: 'Use a current not-yet-at-origin-port sample; freeze query version/history and customer projection, record actual node once through Admin, capture POST and both readbacks; preserve customs/container identity and do not rewrite a later actual node.' }) + result.nodes.push({ node: 'F25', conclusion: 'Clearance/DO upload and replay, fleet/appointment/empty-return supported; PU, distinct actual pickup and mail lifecycle unverified', + category: '需真实操作', evidence: [evidence(files[1], s => s.module === 'phase5-terminal-drayage'), + evidence(files[1], s => s.module === 'phase3-container-documents' && /DELIVERY_ORDER|DESTINATION_CLEARANCE/.test(s.name))], + boundaries: { mailRequestsInOriginalReport: full.steps.filter(s => /terminal-drayages.*\/emails?|terminal-drayages.*pickup-email/.test(s.request?.path || '')).length, + pickupAssertion: 'Original drayage assertion compares actualPickupAt to unloadingCompletedAt; this does not establish a distinct pickup command/time.' }, + next: 'Freeze pre-pickup container and actual node history; inspect current DO/PU contract, confirm distinct PICKED_UP before unloading; capture explicit email queue/recipient snapshot, SMTP acceptance separately, fleet-change ordered cancellation/request and failed-mail retry without assuming delivery.' }) + result.nodes.push({ node: 'F27', conclusion: 'Normal conversion/resources/replay supported; resource-failure retry UI unverified', + category: '需真实操作', evidence: [evidence(files[1], s => s.module === 'phase4-tail-order-controls')], + explicitRetryEvidence: selectSteps(full, s => /资源.*重试|计划.*重试|重试.*资源/.test(s.name)), + next: 'First locate a real resourcePendingPlanCount>0 container; freeze plans, reservations, resource identities and original command key. Use original container conversion UI retry once, verify only failed resources finish and no new plans/reservations; healthy completed samples cannot prove recovery. Failure injection requires controller window.' }) + result.nodes.push({ node: 'F33-transfer', conclusion: 'Original stock transfer approved; outbound/target receipt terminal not established', + category: '需真实操作', originalTransferNo: 'WT-WH-20261002-430594', + evidence: [evidence(files[1], s => (s.request?.path || '').includes('WT-WH-20261002-430594'))], + originalFixture: full.createdResources.phase4OverseasClientServices, + next: 'Continue this exact original transfer only after current source stock, holds, destination and allocations are frozen. Verify allocation, actual outbound and target receipt with per-position balances/unique movements and same-command replay. Incoming transfer fixtures are separate sources and cannot replace this ticket.' }) + result.nodes.push({ node: 'F33-discard', conclusion: 'Rejection release supported; approval/decrement decision pending', + category: '缺用户输入', evidenceFiles: [files[5]], next: 'Await approval-time versus PDA actual-quantity decrement decision; no approval or decrement issued.' }) + result.nodes.push({ node: 'F26', conclusion: 'Existing 68-check source/transit scope is already evidenced; no repeat run', + category: '可立即关闭指定分支', evidenceFiles: [files[4]], boundary: 'Only 11 FBA sources/13 actual cartons and pre-receipt mapping; not all event faults or UI.' }) + result.nodes.push({ node: 'F24-external', conclusion: 'External carrier lifecycle lacks valid input', + category: '缺用户输入', next: 'Provide valid carrier TEST input; synthetic fixture or manual nodes cannot close supplier lifecycle.' }) + const save = () => { fs.mkdirSync(path.dirname(output), { recursive: true }); fs.writeFileSync(output, JSON.stringify(result, null, 2)) } + save() + if (!live) return result + let session + const call = async (method, route, body, token = session?.accessToken) => { + if (method !== 'GET' && !['/auth/login', '/auth/logout'].includes(route)) throw Error('BUSINESS_WRITE_FORBIDDEN') + const entry = { method, path: route, phase: 'INTENT' }; result.requests.push(entry); save() + const response = await fetch(env.CHONGCHONG_ADMIN_API_BASE_URL.replace(/\/$/, '') + route, { + method, redirect: 'error', signal: AbortSignal.timeout(20000), + headers: { Accept: 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }) }) + const json = JSON.parse(await response.text()) + Object.assign(entry, { phase: 'ACKNOWLEDGED', http: response.status, code: String(json.code), + requestId: response.headers.get('x-request-id'), traceId: response.headers.get('x-trace-id') }); save() + if (response.status !== 200 || String(json.code) !== '200') throw Error(`READ_FAILED_${response.status}_${json.code}`) + return json.data + } + try { + if (String(env.CHONGCHONG_ENV).toUpperCase() !== 'TEST' || !env.CHONGCHONG_ADMIN_API_BASE_URL || !env.CHONGCHONG_ADMIN_USERNAME || !env.CHONGCHONG_ADMIN_PASSWORD) throw Error('EXPLICIT_TEST_ENV_REQUIRED') + session = await call('POST', '/auth/login', { loginId: env.CHONGCHONG_ADMIN_USERNAME, password: env.CHONGCHONG_ADMIN_PASSWORD }, '') + const transferRoute = '/platform/v1/last-leg/warehouse-transfers/WT-WH-20261002-430594?customerId=2105875471489142785' + const before = await call('GET', transferRoute) + if (before.transferNo !== 'WT-WH-20261002-430594') throw Error('TRANSFER_SCOPE_MISMATCH') + result.nodes.find(n => n.node === 'F33-transfer').current = transferConclusion(before) + const container = full.createdResources.phase5TerminalDrayage.containerBookingId + const drayage = await call('GET', `/platform/v1/terminal-drayages/${container}`) + const history = await call('GET', `/platform/v1/terminal-drayages/${container}/history`) + const mailbox = await call('GET', `/platform/v1/terminal-drayages/${container}/emails`) + result.nodes.find(n => n.node === 'F25').current = { + containerNo: drayage.containerNo, status: drayage.status, actualPickupAt: drayage.actualPickupAt, + unloadingCompletedAt: drayage.unloadingCompletedAt, actualEmptyReturnAt: drayage.actualEmptyReturnAt, + pickupEqualsUnloading: drayage.actualPickupAt === drayage.unloadingCompletedAt, + historyActions: history.map(h => h.action), mailboxCount: mailbox.records?.length || 0, + mailStatuses: (mailbox.records || []).map(m => ({ status: m.status, kind: m.kind })) } + const overview = await call('GET', `/platform/v1/containers/overseas/${container}/overview`) + Object.assign(result.nodes.find(n => n.node === 'F25').current, { + pickupCodePresent: Boolean(overview.container?.pickupCode), + documents: (overview.documents || []).map(d => ({ documentType: d.documentType, version: d.version, + filePresent: Boolean(d.fileId), completed: d.completed === true })) }) + const preview = await call('GET', `/platform/v1/last-leg/inbound-plans/container-preview?sourceContainerBookingId=${container}`) + result.nodes.find(n => n.node === 'F27').current = { containerNo: preview.containerNo, + totalOrderCount: preview.totalOrderCount, pendingOrderCount: preview.pendingOrderCount, + blockedOrderCount: preview.blockedOrderCount, totalPlannedPalletCount: preview.totalPlannedPalletCount, + plans: (preview.groups || []).flatMap(g => (g.orders || []).map(o => ({ planNo: o.existingPlanNo, planStatus: o.planStatus }))), + recoveryProven: false } + const after = await call('GET', transferRoute) + result.transferReadStable = digest(before) === digest(after) + result.liveReadSuccess = result.transferReadStable + } catch (error) { result.liveReadSuccess = false; result.failure = String(error.message).replace(/https?:\/\/\S+/g, '[redacted]') } + finally { + if (!session) result.sessionClosed = true + if (session) { try { await call('POST', '/auth/logout', { refreshToken: session.refreshToken }); result.sessionClosed = true } + catch { result.sessionClosed = false; result.liveReadSuccess = false } } + save() + } + return result +} diff --git a/scripts/fba-remaining-nodes-acceptance.test.mjs b/scripts/fba-remaining-nodes-acceptance.test.mjs new file mode 100644 index 000000000..4fbffa3c1 --- /dev/null +++ b/scripts/fba-remaining-nodes-acceptance.test.mjs @@ -0,0 +1,20 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { requirePassed, selectSteps, transferConclusion } from './fba-remaining-nodes-acceptance.mjs' + +test('missing and partially failed evidence cannot close a branch', () => { + assert.equal(requirePassed([]), false) + assert.equal(requirePassed([{ passed: true }, { passed: false }]), false) + assert.equal(requirePassed([{ passed: true }, {}]), false) + assert.equal(requirePassed([{ passed: true }]), true) +}) +test('approved stock transfer and a signed unrelated order do not prove transfer receipt', () => { + assert.equal(transferConclusion({ status: 'APPROVED', orderStatus: 'DELIVERED' }).completed, false) + assert.equal(transferConclusion({ status: 'RECEIVED' }).completed, false) + assert.equal(transferConclusion({ status: 'OUTBOUNDED', actualShipAt: '2026-10-04' }).completed, false) + assert.equal(transferConclusion({ status: 'RECEIVED', actualShipAt: '2026-10-04', allocations: [{ status: 'RECEIVED', destinationInventoryPositionId: '42' }] }).completed, true) +}) +test('selection keeps exact original JSON pointer and failure evidence', () => { + const rows = selectSteps({ steps: [{ name: 'other', passed: true }, { name: 'original', passed: false }] }, s => s.name === 'original') + assert.deepEqual(rows, [{ pointer: '/steps/1', name: 'original', passed: false }]) +})