From c0fbe2698b451b64473ccc065d653a0ee971bc94 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 10 Oct 2026 02:42:59 +0800 Subject: [PATCH] fix(procurement): read MySQL fulfillment timestamps with JDBC types --- .../service/ProcurementChargeService.java | 2 +- .../service/ProcurementLifecycleService.java | 12 +++++++++--- .../service/ProcurementPhysicalService.java | 7 +++---- .../procurement/service/ProcurementQueryService.java | 6 +++--- .../service/ProcurementTransportService.java | 5 ++--- .../service/ProcurementPurchasePersistenceTest.java | 2 ++ .../phase4-overseas-business.mjs | 4 ++-- scripts/procurement-sample-test-env.mjs | 10 +++++++--- scripts/procurement-sample-test-env.test.mjs | 9 ++++++++- 9 files changed, 37 insertions(+), 20 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementChargeService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementChargeService.java index 9e105c487..3187f1acd 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementChargeService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementChargeService.java @@ -107,7 +107,7 @@ public class ProcurementChargeService { } private Snapshot snapshot(OverseasProcurementOrder order) { Object at=lifecycle.flow(order.getId()).get("charges_confirmed_at"); - return new Snapshot(order.getOrderNo(),order.getVersion(),order.getStatus(),at==null?null:((java.sql.Timestamp)at).toLocalDateTime(), + return new Snapshot(order.getOrderNo(),order.getVersion(),order.getStatus(),(java.time.LocalDateTime)at, finance.all(order.getId()).stream().map(f->new Fee(f,finance.sourceLinked(f))).toList()); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementLifecycleService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementLifecycleService.java index a9fed57ba..24297efb1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementLifecycleService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementLifecycleService.java @@ -17,7 +17,6 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.web.server.ResponseStatusException; import java.math.BigDecimal; import java.security.MessageDigest; -import java.sql.Timestamp; import java.time.LocalDateTime; import java.util.HexFormat; import java.util.List; @@ -29,6 +28,13 @@ import java.util.Set; @Service @RequiredArgsConstructor public class ProcurementLifecycleService { + // Read SQL DATETIME/TIMESTAMP through JDBC 4.2, independently of each driver's default getObject type. + static final org.springframework.jdbc.core.ColumnMapRowMapper FACT_ROW = new org.springframework.jdbc.core.ColumnMapRowMapper() { + @Override protected Object getColumnValue(java.sql.ResultSet rs,int index) throws java.sql.SQLException { + if(rs.getMetaData().getColumnType(index)==java.sql.Types.TIMESTAMP) return rs.getObject(index,LocalDateTime.class); + return super.getColumnValue(rs,index); + } + }; private final OverseasProcurementOrderMapper orders; private final OverseasProcurementFileMapper files; private final ProcurementWarehouseDirectory warehouses; @@ -130,7 +136,7 @@ public class ProcurementLifecycleService { } var budget=budget(order); if(budget.amount()==null) issue="客户货款预算尚未明确,请取得凭证并完成预算核验"; - var businessDate=((Timestamp)flow.get("purchased_at")).toLocalDateTime().toLocalDate(); + var businessDate=((LocalDateTime)flow.get("purchased_at")).toLocalDate(); if(issue==null) { try { var payment=converter.convert(String.valueOf(flow.get("purchase_currency")),(BigDecimal)flow.get("purchase_amount"),businessDate); @@ -191,7 +197,7 @@ public class ProcurementLifecycleService { return new Outcome(order.getOrderNo(),order.getVersion(),order.getStatus(),purchaseStatus(order)); } Map flow(long orderId) { - var rows=jdbc.queryForList("SELECT * FROM ll_procurement_flow WHERE order_id=?",orderId); + var rows=jdbc.query("SELECT * FROM ll_procurement_flow WHERE order_id=?",FACT_ROW,orderId); return rows.isEmpty()?Map.of():rows.get(0); } void ensureFlow(OverseasProcurementOrder order) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementPhysicalService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementPhysicalService.java index 629272bb2..00b377863 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementPhysicalService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementPhysicalService.java @@ -11,7 +11,6 @@ import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; import java.sql.ResultSet; import java.sql.SQLException; -import java.sql.Timestamp; import java.time.LocalDateTime; import java.util.*; import static com.baiyue.lastleg.procurement.service.ProcurementLifecycleService.*; @@ -112,10 +111,10 @@ public class ProcurementPhysicalService { if(flow.get("actual_warehouse_id")!=null && ((Number)flow.get("actual_warehouse_id")).longValue()!=request.warehouseId()) throw conflict("本单实际收货仓已冻结,不支持转仓"); jdbc.update("UPDATE ll_procurement_flow SET actual_warehouse_id=?,actual_warehouse_code=?,actual_warehouse_name=? WHERE order_id=?", warehouse.warehouseId(),warehouse.warehouseCode(),warehouse.warehouseName(),order.getId()); - originAt=((Timestamp)flow.get("purchased_at")).toLocalDateTime(); + originAt=((LocalDateTime)flow.get("purchased_at")); } else { if(request.warehouseId()!=null) throw bad("国内实收不应指定海外仓"); - originAt=((Timestamp)flow.get("overseas_shipped_at")).toLocalDateTime(); + originAt=((LocalDateTime)flow.get("overseas_shipped_at")); } if(request.receivedAt().isBefore(originAt)) throw bad("实收时间不能早于来源购买或国际交寄时间"); long counted=received(order.getId(),request.leg(),request.sourceCartonId()).stream() @@ -293,7 +292,7 @@ public class ProcurementPhysicalService { return new Snapshot(orderNo,version,status,receipts,dispositions,((Number)manifest.get("version")).intValue(),String.valueOf(manifest.get("status")),cartons); } Map currentManifest(long orderId) { - var rows=jdbc.queryForList("SELECT * FROM ll_procurement_manifest WHERE order_id=? AND status<>'SUPERSEDED' ORDER BY version DESC LIMIT 1",orderId); + var rows=jdbc.query("SELECT * FROM ll_procurement_manifest WHERE order_id=? AND status<>'SUPERSEDED' ORDER BY version DESC LIMIT 1",ProcurementLifecycleService.FACT_ROW,orderId); return rows.isEmpty()?null:rows.get(0); } long goodTotal(long orderId,Leg leg){return received(orderId,leg,null,true).stream().mapToLong(ReceiptRow::goodQuantity).reduce(0,ProcurementPhysicalService::add);} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementQueryService.java index cc722df4b..ce575ab74 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementQueryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementQueryService.java @@ -72,11 +72,11 @@ public class ProcurementQueryService { var manifest=physical.currentManifest(order.getId()); boolean confirmed=manifest!=null && "CONFIRMED".equals(manifest.get("status")); progress.add(4,new Progress("packing_confirmed","装箱确认",confirmed?date(manifest.get("confirmed_at")):null)); - var labelDates=confirmed?jdbc.queryForList("SELECT l.id,l.created_at FROM ll_procurement_label_set l WHERE l.order_id=? AND l.leg='OVERSEAS' AND l.manifest_id=? ORDER BY l.version DESC LIMIT 1",order.getId(),manifest.get("id")):List.>of(); + var labelDates=confirmed?jdbc.query("SELECT l.id,l.created_at FROM ll_procurement_label_set l WHERE l.order_id=? AND l.leg='OVERSEAS' AND l.manifest_id=? ORDER BY l.version DESC LIMIT 1",ProcurementLifecycleService.FACT_ROW,order.getId(),manifest.get("id")):List.>of(); progress.add(5,new Progress("international_labels","国际面单上传",labelDates.isEmpty()?null:date(labelDates.get(0).get("created_at")))); LocalDateTime applied=null; if(!labelDates.isEmpty()) { - var states=jdbc.queryForMap("SELECT COUNT(*) AS total,SUM(CASE WHEN applied_at IS NULL THEN 1 ELSE 0 END) AS missing,MAX(applied_at) AS applied_at FROM ll_procurement_label_item WHERE label_set_id=?",labelDates.get(0).get("id")); + var states=jdbc.query("SELECT COUNT(*) AS total,SUM(CASE WHEN applied_at IS NULL THEN 1 ELSE 0 END) AS missing,MAX(applied_at) AS applied_at FROM ll_procurement_label_item WHERE label_set_id=?",ProcurementLifecycleService.FACT_ROW,labelDates.get(0).get("id")).get(0); if(((Number)states.get("total")).longValue()>0 && ((Number)states.get("missing")).longValue()==0)applied=date(states.get("applied_at")); } progress.add(6,new Progress("labels_applied","全部箱贴单",applied)); @@ -128,5 +128,5 @@ public class ProcurementQueryService { } private static void action(List result,Set permissions,String key,String label,String suffix,String block){String code="platform:overseas-business:purchase:"+suffix;String reason=permissions==null || !permissions.contains(code)?"无此操作权限":block;result.add(new Action(key,label,code,reason==null,reason));} private static String reason(Runnable action){try{action.run();return null;}catch(org.springframework.web.server.ResponseStatusException e){return e.getReason();}} - private static LocalDateTime date(Object value){return value==null?null:((java.sql.Timestamp)value).toLocalDateTime();} + private static LocalDateTime date(Object value){return (LocalDateTime)value;} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementTransportService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementTransportService.java index afe3bdaa8..d1b18ee56 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementTransportService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementTransportService.java @@ -12,7 +12,6 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; import java.security.MessageDigest; -import java.sql.Timestamp; import java.time.LocalDateTime; import java.util.*; import static com.baiyue.lastleg.procurement.service.ProcurementLifecycleService.*; @@ -115,7 +114,7 @@ public class ProcurementTransportService { var labels=validLabels(order,Leg.OVERSEAS,request.labelVersion()); if(request.appliedAt()==null || request.appliedAt().isAfter(LocalDateTime.now()))throw bad("请填写真实贴单时间"); var flow=lifecycle.flow(order.getId()); - if(request.appliedAt().isBefore(((Timestamp)flow.get("overseas_received_at")).toLocalDateTime()))throw bad("贴单时间不能早于海外收货完成"); + if(request.appliedAt().isBefore(((LocalDateTime)flow.get("overseas_received_at"))))throw bad("贴单时间不能早于海外收货完成"); if(request.cartonIds()==null || request.cartonIds().isEmpty() || new HashSet<>(request.cartonIds()).size()!=request.cartonIds().size())throw bad("请选择不重复的实际贴单箱"); for(long carton:request.cartonIds()) { var label=labels.labels().stream().filter(l->l.cartonId()==carton).findFirst().orElseThrow(()->bad("贴单箱不属于当前面单")); @@ -154,7 +153,7 @@ public class ProcurementTransportService { if(request.leg()==Leg.OVERSEAS) { if(labels.labels().stream().anyMatch(l->request.shippedAt().isBefore(l.appliedAt())))throw bad("交寄时间不能早于贴单时间"); } else { - if(request.shippedAt().isBefore(((Timestamp)flow.get("domestic_received_at")).toLocalDateTime()))throw bad("交寄时间不能早于国内收货完成"); + if(request.shippedAt().isBefore(((LocalDateTime)flow.get("domestic_received_at"))))throw bad("交寄时间不能早于国内收货完成"); } long id=IdWorker.getId();String number="PS-"+UUID.randomUUID().toString().replace("-","").substring(0,24).toUpperCase(); jdbc.update("INSERT INTO ll_procurement_shipment(id,order_id,customer_id,shipment_no,leg,manifest_id,shipped_at,address_snapshot,created_by) VALUES(?,?,?,?,?,?,?,CAST(? AS JSON),?)", diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementPurchasePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementPurchasePersistenceTest.java index 8526203c2..4048bab32 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementPurchasePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementPurchasePersistenceTest.java @@ -67,6 +67,8 @@ class ProcurementPurchasePersistenceTest { assertThat(result.purchase().recorded()).isTrue();assertThat(result.purchase().confirmed()).isTrue(); assertThat(result.status()).isEqualTo("WAIT_OVERSEAS_RECEIPT");assertThat(result.version()).isEqualTo(1); assertThat(result.purchase().walletStatus()).isEqualTo("FREEZE_PENDING"); + assertThat(service.flow(11).get("purchased_at")).isInstanceOf(LocalDateTime.class); + assertThat(service.flow(11).get("purchase_confirmed_at")).isInstanceOf(LocalDateTime.class); tx.execute(s->service.recordPurchase("9","PO-1","7",request)); verify(finance).confirm(eq("9"),eq("PO-1"),eq("7"),argThat(r->r.originalAmount().compareTo(new BigDecimal("80"))==0)); assertThat(orders.findNo("9","PO-1").getInboundQuantity()).isZero(); diff --git a/scripts/admin-test-env-modules/phase4-overseas-business.mjs b/scripts/admin-test-env-modules/phase4-overseas-business.mjs index b72a45afb..991566196 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-business.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-business.mjs @@ -54,9 +54,9 @@ function partnerWarehousePayload(context) { const suffix = String(context.marker || '').replace(/[^A-Za-z0-9]/g, '').slice(-20).toUpperCase() return { tenantId: 0, warehouseCode: `P4B-${suffix}`.slice(0, 50), warehouseName: `${context.marker} 采购合作仓`, - warehouseType: 'OVERSEAS_PARTNER', countryCode: 'US', timezone: 'UTC-8', contactName: 'Phase4 QA', + warehouseType: 'OVERSEAS', countryCode: 'US', timezone: 'UTC-8', contactName: 'Phase4 QA', contactPhone: '12135550177', contactEmail: null, status: 'ACTIVE', remark: `${context.marker}-procurement-fixture`, - address: { addressType: 'WAREHOUSE', countryCode: 'US', stateProvince: 'California', city: 'Los Angeles', + address: { addressType: 'OPERATING', countryCode: 'US', stateProvince: 'California', city: 'Los Angeles', district: 'Commerce', postalCode: '90040', addressLine1: '601 Procurement Partner Avenue', addressLine2: null, latitude: null, longitude: null }, capabilities: [{ capabilityCode: 'RECEIVING', capabilityName: 'Receiving', enabled: true, diff --git a/scripts/procurement-sample-test-env.mjs b/scripts/procurement-sample-test-env.mjs index 29b40a9b0..d8e5924c1 100644 --- a/scripts/procurement-sample-test-env.mjs +++ b/scripts/procurement-sample-test-env.mjs @@ -14,6 +14,12 @@ export function options(args) { if (value('--environment') !== 'TEST' || !/^[A-Za-z0-9-]{1,32}$/.test(runId) || !/^[1-9]\d*$/.test(warehouseId)) throw new Error('须显式指定 TEST、唯一 run-id 和已核对的有效收货仓') return { execute: true, runId, warehouseId } } +export function accountForCustomer(data, customerId) { + if (!Array.isArray(data)) throw new Error('Finance账户响应须为正式数组结构') + const account = data.find(row => String(row.customerId) === String(customerId)) + if (!account) throw new Error('账户响应未包含本轮独立客户') + return account +} export async function execute(args = process.argv.slice(2), execArgs = process.execArgv) { const config = options(args) if (!config.execute) return { mode: 'PLAN', writes: ['创建独立客户并审核资质', '仅为本轮客户充值10元', '上传样品凭证和面单', '执行采购两箱完整正常流程'], sql: false, deploy: false, cleanup: '保留样本,退出自身会话;未知结果不自动重跑' } @@ -80,9 +86,7 @@ export async function execute(args = process.argv.slice(2), execArgs = process.e const customerId = String(fixture.tenantId), customerToken = await login(fixture.username,fixture.password,'customer') const readWallet = async () => { const page = requireSuccess(await context.request('GET',`/api/platform/finance/v1/accounts?page=1&pageSize=20&customerId=${customerId}`)) - const account = page.records?.find(row => String(row.customerId) === customerId) - if (!account) throw new Error('本轮独立客户钱包尚未初始化') - return account + return accountForCustomer(page, customerId) } const before = await readWallet() requireSuccess(await context.request('POST',`/api/platform/finance/v1/accounts/${customerId}/adjustments`,{ type:'RECHARGE', amount:'10.00', occurredDate:new Date().toISOString().slice(0,10), paymentMethod:'BANK_TRANSFER', referenceNo:context.marker, remark:context.marker, expectedAccountVersion:before.accountVersion },context.token,{'Idempotency-Key':`${context.marker}-fund`})) diff --git a/scripts/procurement-sample-test-env.test.mjs b/scripts/procurement-sample-test-env.test.mjs index ebf7c5b6b..f2ad8769f 100644 --- a/scripts/procurement-sample-test-env.test.mjs +++ b/scripts/procurement-sample-test-env.test.mjs @@ -1,6 +1,6 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { execute, options } from './procurement-sample-test-env.mjs' +import { execute, options, accountForCustomer } from './procurement-sample-test-env.mjs' test('default preview needs no credentials and performs no execution', async () => { const result = await execute([], []) @@ -15,3 +15,10 @@ test('execution rejects an absent or different environment file before login', a await assert.rejects(execute(args, []), /TEST 环境文件/) await assert.rejects(execute(args, ['--env-file=C:\\private\\production.env']), /TEST 环境文件/) }) + +test('Finance uses a data array and matches the exact customer without numeric coercion', () => { + const row = { customerId:'9007199254740993', accountVersion:0, actualBalance:'0.00' } + assert.equal(accountForCustomer([{ customerId:'9007199254740992' }, row], '9007199254740993'), row) + assert.throws(() => accountForCustomer({ records:[row] }, row.customerId), /正式数组/) + assert.throws(() => accountForCustomer([], row.customerId), /本轮独立客户/) +})