fix(procurement): read MySQL fulfillment timestamps with JDBC types

This commit is contained in:
2026-10-10 02:42:59 +08:00
parent a973be2103
commit c0fbe2698b
9 changed files with 37 additions and 20 deletions
@@ -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());
}
}
@@ -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<String,Object> 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) {
@@ -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<String,Object> 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);}
@@ -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.<Map<String,Object>>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.<Map<String,Object>>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<Action> result,Set<String> 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;}
}
@@ -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),?)",
@@ -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();
@@ -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,
+7 -3
View File
@@ -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`}))
+8 -1
View File
@@ -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), /本轮独立客户/)
})