Compare commits

..
6 Commits
Author SHA1 Message Date
liuyihe 0bd25de906 提交 2026-10-29 15:49:18 +08:00
liuyihe 849f8dc5f2 提交 2026-10-29 14:29:02 +08:00
liuyihe 1b27d7e6c2 提交 2026-10-29 13:36:38 +08:00
ouyangxq 353c86f4cf 为船期节点记录并返回操作人快照 2026-10-28 16:56:33 +08:00
liuyihe 71a1026e21 提交 2026-10-28 14:44:59 +08:00
liuyihe f3d57869f2 费用提交 2026-10-28 13:57:34 +08:00
99 changed files with 1807 additions and 164 deletions
+5
View File
@@ -20,6 +20,8 @@
货柜关联已放舱订舱时不再限制 ISO 柜号格式,Firstleg 模块基线与国内统一空库基线已移除 `chk_container_booking_iso6346`,保留同订舱柜号唯一键。既有 TEST 发布前需核对并单独授权移除该约束:`ALTER TABLE baiyue_firstleg.fl_container_booking DROP CHECK chk_container_booking_iso6346;`。配套发布 Firstleg 与 Admin;不重放空库初始化、不修改业务数据。本次仅准备源码与 SQL 定义,未执行环境 SQL 或部署。
船节点日志在 `fl_carrier_schedule_node_fact.operator_id/operator_name` 保存操作时的账号与用户名快照;人工补录和确认出仓写入,船司自动采集为空。两列定义同步于 Firstleg 船节点模块基线与国内统一空库基线,更新结构后配套发布 Firstleg 与 Admin。既有 TEST 的字段变更须单独授权执行,不重放空库初始化,不回填历史节点。
`overseas-storage-rent-zero-fee-repair.sql` 为免费仓租的定向结构修复,固定 `baiyue_lastleg.ll_finance_fee_source`:先检查列类型、既有金额约束及存量行,再以单次原子 ALTER 仅放行自动应收仓租的原币及结算双零金额和空汇率,其余费用仍须正额及有效汇率。不更新费用、余额或库存;禁止重放空库初始化。源码基线与海外统一空库基线同步。脚本待审阅执行,文件存在不代表已执行。
`overseas-fee-remark-add.sql` 为海外人工费用说明的独立结构增量,固定目标 `baiyue_lastleg.ll_finance_fee_source`,仅在缺失时增加允许为空的 `fee_remark VARCHAR(500)`,保留旧费用与审核记录,不回填、不扣款、不改菜单。Lastleg 源基线与海外统一空库基线已同步;已有开发库须逐项授权执行本脚本后才能加载配套 Lastleg 版本。文件准备不表示已执行。
@@ -222,3 +224,6 @@ node --test scripts/admin-test-env-modules/initialization-baseline-contract.test
本次重复的五份版本脚本和两份专项汇总 SQL 已移除,内容清单见 [20261022-海外费用仓租数据库变更汇总](20261022-海外费用仓租数据库变更汇总.md)。两份统一文件仍只用于空库初始化,不能向已有业务库重放;本次不再提供这项功能的独立升级脚本,已有库升级须另按实际结构和明确授权准备、审阅增量。其他独立修复补丁及历史迁移保持各自适用边界。
采购方向扩展:`migrations/overseas/baiyue_lastleg/V34__procurement_direction.sql` 先于匹配 Lastleg 版本执行,为采购单增加 `procurement_direction`;历史订单默认海外采购回国内,不修改既有实物和资金记录。国内采购通过既有国内仓目录选起运收货仓;源基线与统一空库基线同步。本地验证不表示测试库已执行或服务已发布。
2026-10-29 客户采购驳回和下单预算冻结:源结构与海外空库入口同步,已有 Lastleg 库使用 `overseas-order-budget-and-procurement-rejection.sql` 增加两列和预算预占表,不向已有库重放空库入口。需要匹配的 Lastleg、Admin、Client;本轮仅交付脚本,未执行数据库变更。
@@ -0,0 +1,29 @@
-- 2026-10-29:采购驳回照片返工及客户下单预估冻结;仅在Lastleg所属库执行。
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='customer_rejection_reason'), 'SELECT 1', 'ALTER TABLE ll_overseas_procurement_order ADD COLUMN customer_rejection_reason VARCHAR(1000) NULL COMMENT ''客户采购结果驳回原因'''));
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='rejected_photo_ids'), 'SELECT 1', 'ALTER TABLE ll_overseas_procurement_order ADD COLUMN rejected_photo_ids TEXT NULL COMMENT ''本轮客户驳回的收货照片主键快照'''));
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
CREATE TABLE IF NOT EXISTS ll_order_budget_reservation (
id BIGINT NOT NULL COMMENT '预算冻结记录主键',
customer_id VARCHAR(64) NOT NULL COMMENT '客户隔离标识',
kind VARCHAR(32) NOT NULL COMMENT '客户下单业务类型',
request_key VARCHAR(128) NOT NULL COMMENT '下单幂等键',
order_no VARCHAR(64) NULL COMMENT '成功创建的业务单号',
original_amount DECIMAL(18,2) NOT NULL COMMENT '客户预估原币金额',
currency VARCHAR(3) NOT NULL COMMENT '预估金额币种',
amount_cny DECIMAL(18,2) NOT NULL COMMENT '按下单月份汇率折算的人民币冻结金额',
status VARCHAR(32) NOT NULL COMMENT '资金冻结或释放回执状态',
error_message VARCHAR(1000) NULL COMMENT '资金失败原因',
command_event_id VARCHAR(64) NULL COMMENT '当前资金指令事件标识',
command_version BIGINT NOT NULL DEFAULT 0 COMMENT '资金指令单调版本',
release_requested TINYINT NOT NULL DEFAULT 0 COMMENT '是否已请求释放预占',
created_at DATETIME(3) NOT NULL COMMENT '首次预占时间',
updated_at DATETIME(3) NOT NULL COMMENT '最近资金状态更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_order_budget_request(customer_id,kind,request_key),
KEY idx_order_budget_release(status,release_requested,created_at),
KEY idx_order_budget_order(customer_id,kind,order_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='海外客户下单预估资金冻结投影';
@@ -3853,6 +3853,8 @@ CREATE TABLE fl_carrier_schedule_node_fact (
location_name varchar(128) DEFAULT NULL COMMENT '节点地点名称',
source_reference varchar(500) DEFAULT NULL COMMENT '可审计来源引用',
raw_payload_hash char(64) NOT NULL COMMENT '原始响应SHA-256;不保存敏感原文',
operator_id bigint unsigned DEFAULT NULL COMMENT '节点操作人账号ID;自动采集时为空',
operator_name varchar(128) DEFAULT NULL COMMENT '节点操作人用户名快照;自动采集时为空',
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事实入库时间',
PRIMARY KEY (id),
UNIQUE KEY uk_carrier_schedule_node_event (container_booking_id,event_key),
@@ -8465,3 +8467,9 @@ USE baiyue_iam;
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,created_at,updated_at,deleted) VALUES (5117200,0,'PLATFORM','MENU',5105000,'platform:overseas-fee:list','海外费用明细','/overseas-business/fees','GET',60,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at) VALUES (4000001,5117200,0,CURRENT_TIMESTAMP(3));
SELECT 'PASS' AS initialization_status, 'CN' AS region, 229 AS initialized_tables;
USE baiyue_lastleg;
-- 现场扫描清单独立于客户选箱快照;重复执行不改现有业务数据。
SET @discard_actual_marks_ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='actual_carton_marks_json'), 'SELECT 1', 'ALTER TABLE ll_cargo_discard_item ADD COLUMN actual_carton_marks_json JSON NULL COMMENT ''PDA现场实际废弃箱唛清单,与客户选箱快照独立保存''');
PREPARE discard_actual_marks_stmt FROM @discard_actual_marks_ddl;
EXECUTE discard_actual_marks_stmt;
DEALLOCATE PREPARE discard_actual_marks_stmt;
+24 -1
View File
@@ -2105,6 +2105,8 @@ CREATE TABLE ll_overseas_procurement_order (
created_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人ID',
reviewed_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核人ID',
review_remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核备注',
customer_rejection_reason VARCHAR(1000) NULL COMMENT '客户采购结果驳回原因',
rejected_photo_ids TEXT NULL COMMENT '本轮客户驳回的收货照片主键快照',
review_idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核幂等键',
review_request_hash char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核请求内容哈希',
reviewed_at datetime(3) DEFAULT NULL COMMENT '审核时间',
@@ -4897,4 +4899,25 @@ CREATE TABLE IF NOT EXISTS ll_procurement_workflow_command (
PRIMARY KEY (order_id,command_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采购操作幂等及审计';
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 222 AS initialized_tables;
CREATE TABLE ll_order_budget_reservation (
id BIGINT NOT NULL COMMENT '预算冻结记录主键',
customer_id VARCHAR(64) NOT NULL COMMENT '客户隔离标识',
kind VARCHAR(32) NOT NULL COMMENT '客户下单业务类型',
request_key VARCHAR(128) NOT NULL COMMENT '下单幂等键',
order_no VARCHAR(64) NULL COMMENT '成功创建的业务单号',
original_amount DECIMAL(18,2) NOT NULL COMMENT '客户预估原币金额',
currency VARCHAR(3) NOT NULL COMMENT '预估金额币种',
amount_cny DECIMAL(18,2) NOT NULL COMMENT '按下单月份汇率折算的人民币冻结金额',
status VARCHAR(32) NOT NULL COMMENT '资金冻结或释放回执状态',
error_message VARCHAR(1000) NULL COMMENT '资金失败原因',
command_event_id VARCHAR(64) NULL COMMENT '当前资金指令事件标识',
command_version BIGINT NOT NULL DEFAULT 0 COMMENT '资金指令单调版本',
release_requested TINYINT NOT NULL DEFAULT 0 COMMENT '是否已请求释放预占',
created_at DATETIME(3) NOT NULL COMMENT '首次预占时间',
updated_at DATETIME(3) NOT NULL COMMENT '最近资金状态更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_order_budget_request(customer_id,kind,request_key),
KEY idx_order_budget_release(status,release_requested,created_at),
KEY idx_order_budget_order(customer_id,kind,order_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='海外客户下单预估资金冻结投影';
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 223 AS initialized_tables;
View File
@@ -103,7 +103,8 @@ public final class CarrierScheduleQueryDtos {
String providerNodeCode, String standardNode,
int nodeSequence, LocalDateTime observedAt, LocalDateTime estimatedAt,
LocalDateTime actualAt, String locationCode, String locationName,
String sourceReference, String rawPayloadHash, LocalDateTime createdAt) {}
String sourceReference, String rawPayloadHash, LocalDateTime createdAt,
String operatorName) {}
public record ContainerNodeDetail(Long containerBookingId, String currentNode,
LocalDateTime currentNodeAt, List<NodeFact> nodes) {}
@@ -30,5 +30,7 @@ public class CarrierScheduleNodeFactEntity {
private String locationName;
private String sourceReference;
private String rawPayloadHash;
private Long operatorId;
private String operatorName;
@TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt;
}
@@ -11,14 +11,15 @@ import java.util.List;
@Mapper
public interface CarrierScheduleNodeFactMapper extends BaseMapper<CarrierScheduleNodeFactEntity> {
/** 保存不可变节点事实及执行该操作的账号快照。 */
@Insert("""
INSERT INTO fl_carrier_schedule_node_fact
(id,query_id,container_booking_id,event_key,provider_code,provider_event_id,provider_node_code,standard_node,node_sequence,observed_at,
estimated_at,actual_at,location_code,location_name,source_reference,raw_payload_hash,created_at)
estimated_at,actual_at,location_code,location_name,source_reference,raw_payload_hash,operator_id,operator_name,created_at)
VALUES
(#{value.id},#{value.queryId},#{value.containerBookingId},#{value.eventKey},#{value.providerCode},#{value.providerEventId},#{value.providerNodeCode},
#{value.standardNode},#{value.nodeSequence},#{value.observedAt},#{value.estimatedAt},#{value.actualAt},
#{value.locationCode},#{value.locationName},#{value.sourceReference},#{value.rawPayloadHash},
#{value.locationCode},#{value.locationName},#{value.sourceReference},#{value.rawPayloadHash},#{value.operatorId},#{value.operatorName},
CURRENT_TIMESTAMP(3))
""")
int insertStrict(@Param("value") CarrierScheduleNodeFactEntity value);
@@ -111,7 +111,7 @@ public class CarrierScheduleQueryService {
public long customerId(long id) { return required(id).getCustomerId(); }
/** Idempotently starts the canonical tracking task after domestic outbound confirmation. */
/** 国内确认出仓后幂等建立跟踪任务,并保存确认人的账号快照。 */
@Transactional
public CarrierScheduleQueryEntity ensureFromOutboundConfirm(Long customerId, long oceanBookingId,
long containerBookingId,
@@ -161,7 +161,7 @@ public class CarrierScheduleQueryService {
persistenceStage = "NODE_FACT";
CarrierScheduleNodeFactEntity departed = addNode(value, "FIRSTLEG", "OUTBOUND_CONFIRM", "OUTBOUND_CONFIRM",
CarrierScheduleNode.DEPARTED_WAREHOUSE, now, null, now, null, null, "OUTBOUND_CONFIRM",
sha256("OUTBOUND_CONFIRM|" + containerBookingId));
sha256("OUTBOUND_CONFIRM|" + containerBookingId), operatorId, operatorName);
if (departed != null) {
persistenceStage = "CROSS_REGION_OUTBOX";
nodeOutbox.publish(value, departed, requireContainerNo(containerBookingId));
@@ -185,6 +185,7 @@ public class CarrierScheduleQueryService {
+ ";shippingCompanyName=" + Objects.toString(booking.getShippingCompanyName(), "");
}
/** 保存船司自动采集事实,操作人留空以区别人工补录。 */
@Transactional
public Detail applyProviderObservation(long id, ProviderObservationRequest request) {
CarrierScheduleQueryEntity value = requiredLocked(id);
@@ -193,7 +194,7 @@ public class CarrierScheduleQueryService {
CarrierScheduleNodeFactEntity fact = addNode(value, request.providerCode(), request.providerEventId(),
request.providerNodeCode(), node,
request.observedAt(), request.estimatedAt(), request.actualAt(), request.locationCode(),
request.locationName(), request.sourceReference(), request.rawPayloadHash());
request.locationName(), request.sourceReference(), request.rawPayloadHash(), null, null);
if (fact == null) {
return detail(value);
}
@@ -220,6 +221,7 @@ public class CarrierScheduleQueryService {
return detail(value);
}
/** 人工补录节点与操作账号一并留存,重放不覆盖原有事实。 */
@Transactional
public Detail addManualNode(long id, long operatorId, String operatorName, String key,
ManualNodeRequest request) {
@@ -238,7 +240,7 @@ public class CarrierScheduleQueryService {
CarrierScheduleNodeFactEntity fact = addNode(value, "MANUAL", providerEventId, node.name(), node,
request.observedAt(),
request.estimatedAt(), request.actualAt(), request.locationCode(), request.locationName(),
"MANUAL:" + operatorId, hash(operation, id, request));
"MANUAL:" + operatorId, hash(operation, id, request), operatorId, operatorName);
if (fact == null) {
return complete(id, operatorId, key, detail(value));
}
@@ -261,6 +263,7 @@ public class CarrierScheduleQueryService {
return containerNodeDetail(containerBookingId, repository.nodesByContainer(containerBookingId));
}
/** 按货柜补录节点并保存操作账号,未建立查询任务时同样保留署名。 */
@Transactional
public ContainerNodeDetail addContainerManualNode(long containerBookingId, long operatorId,
String operatorName, String key,
@@ -280,7 +283,8 @@ public class CarrierScheduleQueryService {
CarrierScheduleNodeFactEntity fact = addNode(query == null ? null : query.getId(), containerBookingId,
"MANUAL", providerEventId,
node.name(), node, request.observedAt(), request.estimatedAt(), request.actualAt(),
request.locationCode(), request.locationName(), "MANUAL:" + operatorId, payloadHash);
request.locationCode(), request.locationName(), "MANUAL:" + operatorId, payloadHash,
operatorId, operatorName);
if (fact != null && query != null) {
CarrierScheduleQueryEntity before = copy(query);
requireManualActualAdvance(query, node, request.actualAt());
@@ -546,11 +550,12 @@ public class CarrierScheduleQueryService {
repository.histories(value.getId()).stream().map(this::history).toList());
}
/** 返回节点发生时保存的用户名,避免以来源引用代替操作人。 */
private NodeFact node(CarrierScheduleNodeFactEntity value) {
return new NodeFact(value.getId(), value.getProviderCode(), value.getProviderEventId(), value.getProviderNodeCode(),
value.getStandardNode(), value.getNodeSequence(), value.getObservedAt(), value.getEstimatedAt(),
value.getActualAt(), value.getLocationCode(), value.getLocationName(), value.getSourceReference(),
value.getRawPayloadHash(), value.getCreatedAt());
value.getRawPayloadHash(), value.getCreatedAt(), value.getOperatorName());
}
private ExceptionFact exception(CarrierScheduleExceptionFactEntity value) {
@@ -565,21 +570,23 @@ public class CarrierScheduleQueryService {
value.getReason(), value.getOperatorId(), value.getOperatorName(), value.getCreatedAt());
}
/** 为查询任务追加节点事实并透传人工操作账号。 */
private CarrierScheduleNodeFactEntity addNode(CarrierScheduleQueryEntity query, String providerCode, String providerEventId,
String providerNodeCode,
CarrierScheduleNode node, LocalDateTime observedAt, LocalDateTime estimatedAt,
LocalDateTime actualAt, String locationCode, String locationName,
String sourceReference, String rawPayloadHash) {
String sourceReference, String rawPayloadHash, Long operatorId, String operatorName) {
return addNode(query.getId(), query.getContainerBookingId(), providerCode, providerEventId,
providerNodeCode, node, observedAt, estimatedAt, actualAt, locationCode, locationName,
sourceReference, rawPayloadHash);
sourceReference, rawPayloadHash, operatorId, operatorName);
}
/** 节点与操作人快照同次入库,重复事件保留首次记录。 */
private CarrierScheduleNodeFactEntity addNode(Long queryId, long containerBookingId,
String providerCode, String providerEventId, String providerNodeCode,
CarrierScheduleNode node, LocalDateTime observedAt, LocalDateTime estimatedAt,
LocalDateTime actualAt, String locationCode, String locationName,
String sourceReference, String rawPayloadHash) {
String sourceReference, String rawPayloadHash, Long operatorId, String operatorName) {
CarrierScheduleNodeFactEntity fact = new CarrierScheduleNodeFactEntity();
fact.setId(IdWorker.getId()); fact.setQueryId(queryId); fact.setContainerBookingId(containerBookingId);
fact.setEventKey(providerCode.trim().toUpperCase(Locale.ROOT) + ':' + providerEventId.trim());
@@ -589,6 +596,7 @@ public class CarrierScheduleQueryService {
fact.setNodeSequence(node.sequence()); fact.setObservedAt(observedAt); fact.setEstimatedAt(estimatedAt);
fact.setActualAt(actualAt); fact.setLocationCode(trim(locationCode)); fact.setLocationName(trim(locationName));
fact.setSourceReference(trim(sourceReference)); fact.setRawPayloadHash(rawPayloadHash);
fact.setOperatorId(operatorId); fact.setOperatorName(trim(operatorName));
if (repository.insertIfAbsent(fact)) return fact;
CarrierScheduleNodeFactEntity existing = repository.nodeByContainer(containerBookingId, fact.getEventKey());
if (existing != null && !nodeFactHash(existing).equals(nodeFactHash(fact))) {
@@ -129,6 +129,8 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact (
location_name VARCHAR(128) NULL COMMENT '节点地点名称',
source_reference VARCHAR(500) NULL COMMENT '可审计来源引用',
raw_payload_hash CHAR(64) NOT NULL COMMENT '原始响应SHA-256;不保存敏感原文',
operator_id BIGINT UNSIGNED NULL COMMENT '节点操作人账号ID;自动采集时为空',
operator_name VARCHAR(128) NULL COMMENT '节点操作人用户名快照;自动采集时为空',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事实入库时间',
PRIMARY KEY (id),
UNIQUE KEY uk_carrier_schedule_node_event (container_booking_id,event_key),
@@ -234,6 +234,7 @@ components:
locationCode: {type: string, maxLength: 32, nullable: true}
locationName: {type: string, maxLength: 128, nullable: true}
sourceReference: {type: string, maxLength: 500, nullable: true}
operatorName: {type: string, maxLength: 128, nullable: true, description: '节点操作人用户名快照;人工补录和确认出仓时记录,自动采集为空'}
rawPayloadHash: {type: string, pattern: '^[0-9a-f]{64}$'}
createdAt: {type: string, format: date-time}
ExceptionFact:
@@ -8,6 +8,16 @@ import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
class CarrierScheduleQueryOpenApiContractTest {
/** 两个节点读取入口使用相同的操作用户名响应契约。 */
@Test
void nodeFactsExposeTheRecordedOperatorUsername() throws Exception {
String yaml = new ClassPathResource("openapi/carrier-schedule-query-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8);
String node = yaml.substring(yaml.indexOf(" NodeFact:"), yaml.indexOf(" ExceptionFact:"));
assertThat(node).contains("operatorName: {type: string, maxLength: 128, nullable: true")
.contains("节点操作人用户名快照");
}
@Test
void publishesBusinessNodesWithoutSchedulerOrAttemptEndpoints() throws Exception {
String yaml = new ClassPathResource("openapi/carrier-schedule-query-v1.yaml")
@@ -16,6 +16,23 @@ import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class CarrierScheduleQuerySqlContractTest {
/** 模块与部署基线共同保存节点署名,Mapper 同次写入两项快照。 */
@Test
void nodeFactsPersistOperatorSnapshotInBothBaselines() throws Exception {
String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"),
StandardCharsets.UTF_8);
for (String source : List.of(moduleSql(), unified)) {
String lower = source.toLowerCase(java.util.Locale.ROOT);
int start = lower.indexOf("fl_carrier_schedule_node_fact (");
String table = lower.substring(start, lower.indexOf("engine=innodb", start));
assertThat(table).contains("operator_id bigint unsigned", "operator_name varchar(128)");
}
String insert = String.join("\n", com.baiyue.firstleg.portschedule.mapper.CarrierScheduleNodeFactMapper.class
.getMethod("insertStrict", com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity.class)
.getAnnotation(org.apache.ibatis.annotations.Insert.class).value());
assertThat(insert).contains("operator_id,operator_name", "#{value.operatorId},#{value.operatorName}");
}
@Test
void deployedTriggerPredicateMapsPlatformBookingToScopeZeroAndStillRejectsOtherCustomers() throws Exception {
JdbcDataSource database = new JdbcDataSource();
@@ -133,14 +133,21 @@ class CarrierScheduleExceptionPersistenceTest {
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fl_carrier_schedule_exception_fact WHERE source_reference IS NOT NULL AND raw_payload_hash IS NOT NULL", Integer.class)).isEqualTo(8);
}
/** 真实 Mapper 保存并返回操作用户名,账号更名后重放不改写原署名。 */
@Test
void originalContainerManualBodyReplaysWithoutUpdateHistoryOrFactMutationAndRejectsChangedFact() {
var body = new com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerManualNodeRequest(
"ARRIVED_DESTINATION_PORT", TIME.plusDays(2), null, TIME.plusDays(2), "USLAX", "Los Angeles",
"synthetic container manual fact");
service.addContainerManualNode(3,10001,"test-operator","original-body-key",body);
var detail = service.addContainerManualNode(3,10001,"platform_admin","original-body-key",body);
assertThat(detail.nodes().stream().filter(node -> "MANUAL".equals(node.providerCode())).toList())
.singleElement().satisfies(node -> assertThat(node.operatorName()).isEqualTo("platform_admin"));
assertThat(jdbc.queryForObject("SELECT operator_id FROM fl_carrier_schedule_node_fact WHERE provider_code='MANUAL'", Long.class))
.isEqualTo(10001L);
assertThat(jdbc.queryForObject("SELECT operator_name FROM fl_carrier_schedule_node_fact WHERE provider_code='MANUAL'", String.class))
.isEqualTo("platform_admin");
var saved = allSnapshot();
service.addContainerManualNode(3,10001,"test-operator","original-body-key",body);
service.addContainerManualNode(3,10001,"renamed_operator","original-body-key",body);
assertThat(allSnapshot()).isEqualTo(saved);
assertThatThrownBy(() -> service.addContainerManualNode(3,10001,"test-operator","original-body-key",
new com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerManualNodeRequest(
@@ -183,6 +183,7 @@ class CarrierScheduleQueryServiceTest {
.isInstanceOf(BizException.class).hasMessageContaining("柜号").hasMessageContaining("漂移");
}
/** 出仓节点记录确认人的账号,供两个货柜详情展示。 */
@Test
void outboundConfirmationCreatesTaskWithContainerTrackingAndDepartureNode() {
OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L);
@@ -194,7 +195,7 @@ class CarrierScheduleQueryServiceTest {
when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity());
when(repository.insertIfAbsent(any(CarrierScheduleNodeFactEntity.class))).thenReturn(true);
CarrierScheduleQueryEntity result = service.ensureFromOutboundConfirm(10001L, 10L, 20L, 0L, "SYSTEM");
CarrierScheduleQueryEntity result = service.ensureFromOutboundConfirm(10001L, 10L, 20L, 7L, "platform_admin");
assertThat(result.getTriggerSource()).isEqualTo("OUTBOUND_CONFIRM");
assertThat(result.getCarrierCode()).isEqualTo("COSCO");
@@ -204,23 +205,25 @@ class CarrierScheduleQueryServiceTest {
assertThat(result.getTrackingType()).isEqualTo("CONTAINER_NO");
assertThat(result.getLatestStandardNode()).isEqualTo("DEPARTED_WAREHOUSE");
assertThat(result.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30));
assertThat(result.getCreatedBy()).isZero();
assertThat(result.getCreatedByName()).isEqualTo("SYSTEM");
assertThat(result.getUpdatedBy()).isZero();
assertThat(result.getUpdatedByName()).isEqualTo("SYSTEM");
assertThat(result.getCreatedBy()).isEqualTo(7L);
assertThat(result.getCreatedByName()).isEqualTo("platform_admin");
assertThat(result.getUpdatedBy()).isEqualTo(7L);
assertThat(result.getUpdatedByName()).isEqualTo("platform_admin");
verify(repository).insertIfAbsent(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity.class));
verify(nodeOutbox).publish(eq(result), argThat(fact ->
"FIRSTLEG".equals(fact.getProviderCode())
&& "OUTBOUND_CONFIRM".equals(fact.getProviderEventId())
&& "OUTBOUND_CONFIRM".equals(fact.getProviderNodeCode())
&& "DEPARTED_WAREHOUSE".equals(fact.getStandardNode())
&& Long.valueOf(7L).equals(fact.getOperatorId())
&& "platform_admin".equals(fact.getOperatorName())
&& fact.getActualAt() != null), eq("ZCSU7881970"));
verify(repository).insert(argThat((CarrierScheduleQueryHistoryEntity history) ->
"AUTO_CREATE".equals(history.getAction())
&& history.getFromVersion() == null
&& Integer.valueOf(0).equals(history.getToVersion())
&& Long.valueOf(0L).equals(history.getOperatorId())
&& "SYSTEM".equals(history.getOperatorName())
&& Long.valueOf(7L).equals(history.getOperatorId())
&& "platform_admin".equals(history.getOperatorName())
&& "国内确认出仓自动创建".equals(history.getReason())));
}
@@ -360,6 +363,7 @@ class CarrierScheduleQueryServiceTest {
verify(repository, never()).insertIfAbsent(any(CarrierScheduleNodeFactEntity.class));
}
/** 自动采集推进原节点,但不冒用平台操作账号。 */
@Test
void providerActualObservationAdvancesBusinessNodeWithoutChangingTaskStatus() {
CarrierScheduleQueryEntity task = task(0, 0);
@@ -377,6 +381,8 @@ class CarrierScheduleQueryServiceTest {
assertThat(result.query().query().status()).isEqualTo("READY");
assertThat(result.query().query().latestActualNode()).isEqualTo("ARRIVED_DESTINATION_PORT");
assertThat(result.nodes()).isEmpty();
verify(repository).insertIfAbsent(argThat((CarrierScheduleNodeFactEntity fact) ->
fact.getOperatorId() == null && fact.getOperatorName() == null));
}
@Test
@@ -448,6 +454,7 @@ class CarrierScheduleQueryServiceTest {
verify(nodeOutbox).publish(eq(task), any(CarrierScheduleNodeFactEntity.class), eq("ZCSU7881970"));
}
/** 人工实际节点保留操作账号及既有还柜幂等行为。 */
@Test
void manualActualEmptyReturnedUsesExistingPermissionFlowAndStopsIdempotently() {
CarrierScheduleQueryEntity task = task(0, 3);
@@ -462,6 +469,8 @@ class CarrierScheduleQueryServiceTest {
assertThat(result.query().query().status()).isEqualTo("STOPPED");
assertThat(result.query().query().latestActualNode()).isEqualTo("EMPTY_RETURNED");
verify(repository).insertIfAbsent(argThat((CarrierScheduleNodeFactEntity fact) ->
Long.valueOf(7L).equals(fact.getOperatorId()) && "admin".equals(fact.getOperatorName())));
verify(repository).completeOperation(eq(1L), eq(7L), eq("manual-empty-1"), anyString());
}
@@ -488,6 +497,7 @@ class CarrierScheduleQueryServiceTest {
verify(repository).updateState(before, 3, 7L, "admin");
}
/** 柜级补录沿用当前账号,并保持已有跟踪任务和事件投递。 */
@Test
void containerManualNodeJoinsAndAdvancesExistingCarrierTask() {
CarrierScheduleQueryEntity task = task(0, 8);
@@ -509,7 +519,8 @@ class CarrierScheduleQueryServiceTest {
"PICKED_UP", NOW, null, NOW, "USLAX", "Los Angeles", "terminal pickup"));
verify(repository).insertIfAbsent(argThat((CarrierScheduleNodeFactEntity fact) -> Long.valueOf(1L).equals(fact.getQueryId())
&& fact.getContainerBookingId() == 20L && "PICKED_UP".equals(fact.getStandardNode())));
&& fact.getContainerBookingId() == 20L && "PICKED_UP".equals(fact.getStandardNode())
&& Long.valueOf(7L).equals(fact.getOperatorId()) && "admin".equals(fact.getOperatorName())));
verify(repository).updateState(task, 8, 7L, "admin");
verify(repository).insert(argThat((CarrierScheduleQueryHistoryEntity history) ->
"MANUAL_NODE".equals(history.getAction()) && Long.valueOf(1L).equals(history.getQueryId())
+1 -1
View File
@@ -79,7 +79,7 @@
| 外接询价、确认占位及取消 | `fleet/trip/PlatformFleetBookingController.java`、`FleetBookingService.java`、`FleetBookingReservations.java`;客户从`fleet/customer/`读取IAM有效主档与资格,询价不占位,确认后与自有货计划共同校验逐段容量。约定金额不代表应收/实收,`FleetBookingIncomingGateway`由`transfer/fleet/FleetIncomingAdapter`实现来货登记/实收确认,排车关联通过`FleetDispatchGateway.sourceCargo`核对实收来源 |
| 拣货前计划容量与逐段余位 | `FleetTripExecutionService.capacityPlan/confirmCapacityPlan`、`/fleet/trips/{tripNo}/capacity-plan`;保存自有货的计划值,不检查拣货完成、不推进审核或扣库存;`planValid`校验当前货物hash,不把计划值当实测值,资源/计划/关联变更后须重新核对 |
| 实物装载、逐段容量与双端整车发车 | 两端`dispatches/{dispatchNo}/loading-complete`统一调用`dispatch/service/DispatchLoadingDepartureService.java`,实载确认成功进入`AFTER_LOADING`;请求携带唯一`recordVersion`,原键恢复原装车后结果。`dispatch/service/DispatchLoadingDocumentService.java`复用整车附加照片与账本,平台/PDA均经`loading-documents`保存;五框齐全的最后一次绑定与派送准备、发车同事务,成功`DELIVERING`。公开手工安排派送、`/depart`入口仍不开放。真实PDA拣货须核平,不重复库存SHIP,Fleet容量和资源资格仍复核;未知费用不补零、不阻塞发运,真实已明确外接报价沿原费用来源防重 |
| 车次货物结案、车辆和司机分别释放 | `fleet/trip/FleetTripCompletionService.java`、`FleetCompletionModels.java`;平台 `/fleet/trips/{tripNo}/completion`、`/complete`、`/resources/release`,独立complete/release权限。结案核对所有派送终态与实发数量;实际任务结束时间/地点及车辆结束状态单独记录,不以结案或收款自动释放资源 |
| 车次货物结案、车辆和司机自动释放 | `fleet/trip/FleetTripCompletionService.java`、`FleetCompletionModels.java`;平台 `/fleet/trips/{tripNo}/completion`、`/complete`、`/resources/release`,独立complete/release权限。结案核对所有派送终态与实发数量;全车派送完成自动结案释放车辆和司机,提货全部到仓及本土卡车完成同事务释放;不等待二次确认,不改变车辆主档状态,收款不触发释放 |
| 跨服务 HTTP 客户端装配 | `config/LastlegRestClientFactory.java` |
| 数据库基线、表索引与中文注释契约 | `src/main/resources/db/lastleg.sql` |
| 测试 | `src/test/java/com/baiyue/lastleg/` |
+3 -1
View File
@@ -100,7 +100,7 @@
- 排车主流程以工作区 `docs/海外仓业务/排车列表统一作业改造契约.md` 为正式入口:一张排车单代表一趟车;Admin/PDA确认`loading-complete`时,同一事务自动按客户与目的仓生成派送单并发车,成功直接进入`DELIVERING`。公开手工安排派送及`/depart`入口已删除;内部`AFTER_LOADING`仅是同事务中间态。`DispatchPdaWorkflowService` 按公开 `status`、`stage`、`recordVersion` 和 `actions` 返回作业事实与资格,Admin/PDA 共用服务端规则;BOL 与签收资料归拆分后的派送单,费用按其来源去重,不以旧转派送阶段或前端推算代替资格校验。
- 排车主单允许同一起运仓的跨客户货物同车,每条来源保留客户、库存与费用归属;不再要求用户先建独立车次再关联多张客户排车。Fleet 的资源、司机证照、容量、历史与附加能力继续沿 `fleet/trip/` 定位;计划或资源变化对审核、实载确认的影响按正式契约及服务端结果核对,已拣货事实不能因改派或取消被直接抹除,实际发车后不得伪造资源释放。
- 实物装载按仓库托盘去重计量,客户货量与物理占位分别保存;重量/体积由已有实测自动带出,未知保持空值且不阻塞装车,不要求逐托补填,不用计费材积重回填。装车前真实拣货须核平,后台不能补录拣货数量绕过;装车发运编排不重复执行已完成的库存扣减;费用未明确时保持未计费,不要求理货员补费用,也不填零,真实已明确外接报价仍按原来源防重发布。来源、审核、实载与资源版本变化按正式契约重新校验,保留操作历史;双端使用同一版本与原请求键核对结果,任何失败均不得伪造完成事实。
- 车次结案只消费派送事实:实签须signedAt/signedPackages,实退须原来源入账流水/RETURN_COMPLETED;实签加本趟实退等于原实发且余量为零。拒收、待退回、取消不算完成,时间取最后实际终态。结案不改客户状态/费用、不释放占用;车/司机分别记录实际结束时间地点,车辆状态与释放同事务。过期证照不阻止旧任务结束、不恢复新任务资格;原键重放不影响后续车次。
- 车次结案只消费派送事实:实签须signedAt/signedPackages,实退须原来源入账流水/RETURN_COMPLETED;实签加本趟实退等于原实发且余量为零。拒收、待退回、取消不算完成,时间取最后实际终态。全车正常派送完成自动结案,结案不改客户状态/费用;结案与车/司机释放占用同事务,提货全部到仓、本土卡车完成也直接释放,不需二次确认。自动释放记录运输完成时刻及路线终点,保留车辆主档状态。过期证照不阻止旧任务结束、不恢复新任务资格;原键重放不影响后续车次。
- 已签收后另发起退货保留原运输的签收事实,后续退货不重开原车次、不被车次结案改写;未签收货物仍按实际退回完成核对。`DeliveryDocumentService.save` 首次保存签收资料触发完成时要求关联出库已OUTBOUNDED,未出库可以准备BOL/装车资料但不能提前完成派送。
- 排车执行与 `delivery/` Controller 一同注册;平台排车查询和智能预排入口分别沿 `lastleg.dispatch-query-api.enabled`、`lastleg.smart-dispatch-api.enabled` 配置定位,启用现状以配置和实际路由核对。平台查询按全局排车号及来源归属加载跨客户同车明细,客户接口保持本客户隔离;预排创建逐项校验同一起运仓及各来源客户,不能强制整张排车只含一个客户。草稿托盘锁仍按操作人与草稿标识持有,释放、失效及转正式锁由服务端原子处理。
- 仓库 ID、类型、状态、地址和能力唯一权威归国内 `warehouse-service`;海外库位、实物托盘和资源锁归 `warehouse-overseas-service/` 且只引用同一国内 `warehouseId`。Lastleg PDA 通过 Warehouse Overseas 内部资源入口校验该国内共享收货仓,不得要求海外数据库重复建仓;浏览器 API 统一经 `gateway/`。
@@ -171,3 +171,5 @@
- 除仓租外,换标报价、外接约定运费、拆柜打托成本仅保留业务依据,不再自动生成客户应收。旧派送应收参数须改由海外费用登记审核,供应商应付不变。订单详情新增费用统一委托人工费用服务。
- 新换标资金状态固定 NOT_REQUIRED,完成不会发布旧自动应收;旧已冻结申请保留原资金收尾,仅在确认实扣后发布已收来源。已有旧来源只补扣款引用,不重复生成来源;旧财务账单需上线前核对撤销或核销。采购简化流程复用海外人工费用来源,默认余额支付;资金冻结、审核实扣与回执保持原有幂等和客户隔离。
- 同一服务业务的旧自动项目已锁款或已有有效应收时,拦截相同项目的人工重复登记;人工链允许按实际服务合法分次登记。
- 废弃PDA:无箱唛关联的普通库存先扫本单托盘,再扫描现场箱唛自动累计各明细实际箱数,禁止手填数量绕过扫码。现场箱唛独立保存到 actual_carton_marks_json,不改客户选箱快照;提交校验数量一致、同托及同单跨托盘不重复,失败不落盘、可刷新重扫。客户/平台详情返回 actualCartonMarks、palletNo、实际箱数和原备注。数量申请达到目标后释放未用预留。
+12 -3
View File
@@ -14,13 +14,13 @@
拒绝、取消等异常结果独立展示,不增加正常流程节点。采购方向只影响起运仓候选与方向展示,不再增加国内、海外两套作业步骤。
收货批次保存实际数量和绑定本采购单的附件。采购完成后冻结收货结果,客户确认前不得出库。平台不得代客户调用确认接口;客户身份从统一认证上下文取得,不接受请求指定其他客户。
收货批次保存实际数量和绑定本采购单的附件。采购完成后冻结收货结果,客户确认前不得出库。客户可确认或填写1~1000字原因驳回;驳回回到收货中,平台展示原因并逐批重新上传照片。驳回前所有已上传文件均不可复用;仅替换照片,不重复累加数量。全部旧照片替换后方可再次提交确认。平台不得代客户调用确认接口;客户身份从统一认证上下文取得,不接受请求指定其他客户。
出库按批次保存数量、物流跟踪号、派送和签收资料。创建批次时预留可用数量,避免多个批次超出采购库存;签收完成时才扣减该批采购库存。签收请求原键重放不得再次扣减。存在剩余库存时仍可继续创建批次,不能因前一批签收而提前结束整单。采购库存属于采购台账,不写普通尾程 WMS 库存。
主单版本、客户隔离、附件归属与幂等键由服务端复核。失败不能留下部分批次或库存变动;同幂等键不同载荷必须拒绝,过期版本须刷新后重新操作。
费用明细使用其他海外业务共用的费用窗口和费用来源,采购默认选择余额支付。每次创建出库批次前,本采购主单必须至少有一项未作废应收费用,且全部未作废应收费用均须有效并审核通过;缺少费用、待审核或驳回时提示并阻止出库,前后端共同校验。应付与已作废费用不能替代应收费用明细;原出库成功请求的幂等重放不重新创建批次。余额冻结、审核实扣、失败重试和扣款回执沿用海外人工费用链;审核通过与扣款成功分别展示,创建费用或发出资金指令不能显示为已扣款。采购履约不以旧的预算核验、装箱、面单或两段运费冻结作为门禁。
费用明细使用其他海外业务共用的费用窗口和费用来源,采购默认选择余额支付。费用明细缺失或尚未审核时可提示运营补充,但不阻止创建出库批次;出库资格只由采购状态、可用数量、物流跟踪号及操作权限决定。原出库成功请求的幂等重放不重新创建批次。余额冻结、审核实扣、失败重试和扣款回执沿用海外人工费用链;审核通过与扣款成功分别展示,创建费用或发出资金指令不能显示为已扣款。采购履约不以旧的预算核验、装箱、面单或两段运费冻结作为门禁。
客户端保留独立下单、列表和详情路由。详情集中展示商品及收件信息、收货批次、出库批次和费用,待客户确认状态提供确认动作。平台列表突出主状态及数量,详情承载审核、收货、提交确认、出库和费用操作;不重复展示旧节点表单。
@@ -34,7 +34,7 @@
- 分两批出库;超量、并发、过期版本和异载荷重放被拒绝。
- 批次派送后上传文件或图片签收,库存仅扣一次;未完成批次与剩余库存阻止整单提前完成。
- 费用默认余额支付,费用归属本单客户,余额结果以资金回执为准。
- 无费用、仅作废费用、仅应付费用或任一应收尚未审核通过时,不能创建出库;全部有效应收审核通过后允许出库。
- 无费用、费用尚未审核或驳回时均允许创建出库;页面提示不影响出库操作。
自动测试、本地类型检查、真实 HTTP 与页面验收分别记录。现有 `procurement-sample-fulfillment.mjs` 验证旧两段样品链,不能作为本流程验收通过的依据。
@@ -46,3 +46,12 @@
- Gateway路由合同5项通过,Mapper扫描通过。本轮影响文件差异格式检查通过。
- 指引索引检查已尝试,因工作区缺少 `D:/project/docs/业务快速索引.md` 无法执行完成;不代表业务测试失败,也不计为检查通过。
- 未执行环境SQL、部署、真实HTTP或页面截图验收。结构脚本为 `deploy/sql/overseas-procurement-workflow.sql`(后端仓库路径),发布需同时包含Lastleg、Admin和Client。余额冻结及实扣仍须在发布后核对Finance实际回执。
## 2026-10-29 客户下单预算与页面调整
客户采购下单前,以相同幂等键调用 `order-budgets/procurement/{key}`,金额为预算单价乘数量,按有效汇率折人民币;仅Finance冻结成功且usable=true时允许建单。余额不足使用可用授信,合计不足则提示Finance原因,不创建订单。免费样品金额为0时不发资金指令。未绑定预占15分钟后释放,晚到回执仍释放;已绑定订单终态释放,失败定时重试。预算预占独立于现有人工费用冻结/实扣,不计入已扣款汇总。
本土卡车同样校验预算,托数与预算金额须大于0,原件数字段不再作为客户下单数量;预算金额及币种创建后不可通过普通编辑改变。Client采购详情将四项数量卡片置顶,商品及收件信息置底。
结构变化:Lastleg源基线与海外空库基线同步;已有库的独立增量为 `deploy/sql/overseas-order-budget-and-procurement-rejection.sql`。本轮未执行SQL或部署,Finance真实回执与人工截图验收仍待环境联调。
@@ -17,7 +17,10 @@ public final class CargoDiscardRequests {
public record Create(@NotEmpty @Size(max = 200) List<@Valid Item> items,
@NotBlank @Size(max = 255) String idempotencyKey,
@Size(max = 1000) String remark) {}
@Size(max = 1000) String remark, @Positive Integer requestedPackages) {
/** 逐箱申请从明细计数,无箱唛申请另传整单目标箱数。 */
public Create(List<Item> items, String idempotencyKey, String remark) { this(items, idempotencyKey, remark, null); }
}
public record Item(Long orderId, Long returnCartonId, @NotBlank @Size(max = 128) String skuCode,
@Positive int discardPackages, Long inventoryPositionId, @Size(max = 5000) List<@NotNull @Positive Long> cartonIds) {
@@ -42,7 +42,12 @@ public final class CargoDiscardResponses {
public record Item(Long id, Long orderId, String inboundCode, String skuCode,
String productName, int availablePackagesSnapshot, int discardPackages, List<String> cartonMarks,
String quantityUnit, Integer actualDiscardPackages, String pdaStatus) {
String quantityUnit, Integer actualDiscardPackages, String pdaStatus,
List<String> actualCartonMarks, String palletNo) {
/** 保留历史详情调用,未记录的现场箱唛和托盘不补造。 */
public Item(Long id, Long orderId, String inboundCode, String skuCode, String productName, int availablePackagesSnapshot, int discardPackages, List<String> cartonMarks, String quantityUnit, Integer actualDiscardPackages, String pdaStatus) {
this(id, orderId, inboundCode, skuCode, productName, availablePackagesSnapshot, discardPackages, cartonMarks, quantityUnit, actualDiscardPackages, pdaStatus, List.of(), null);
}
/** 无来源事实的调用不猜测数量单位或实际完成数。 */
public Item(Long id, Long orderId, String inboundCode, String skuCode, String productName, int availablePackagesSnapshot, int discardPackages, List<String> cartonMarks) {
this(id, orderId, inboundCode, skuCode, productName, availablePackagesSnapshot, discardPackages, cartonMarks, null, null, null);
@@ -18,6 +18,7 @@ public class CargoDiscardItem {
private Long returnCartonId;
private Long inventoryPositionId;
private String cartonMarksJson;
private String actualCartonMarksJson;
private Boolean cartonScanRequired;
private String inboundCode;
private Long profileId;
@@ -0,0 +1,80 @@
package com.baiyue.lastleg.discard.mapper;
import com.baiyue.lastleg.relabel.entity.RelabelItem;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import org.apache.ibatis.annotations.*;
import java.util.List;
/** 已确认待发换标箱的废弃归属账本;取消及扣减均保留原换标事实。 */
@Mapper
public interface CargoDiscardRelabelMapper {
/** 与排车使用相同原申请行锁,锁顺序由调用方统一排列。 */
@Select("SELECT relabel_no FROM ll_relabel_application WHERE relabel_no=#{no} FOR UPDATE")
String lock(@Param("no") String no);
String ELIGIBLE = "a.status='READY_FOR_OUTBOUND' AND a.fulfillment_status='WAIT_PRE_DISPATCH' "
+ "AND a.customer_confirmed_at IS NOT NULL AND COALESCE(a.outbound_order_no,'')='' "
+ "AND i.return_carton_id IS NULL AND i.notified_package_count=1 "
+ "AND NOT EXISTS(SELECT 1 FROM ll_cargo_discard_carton dc WHERE dc.customer_id=i.customer_id "
+ "AND dc.carton_id=i.id AND dc.source_type IN ('RELABEL','RELABEL_DISCARDED')) ";
/** 已确认箱必须仍在实际冻结仓位;当前轮次绑定由业务端口继续复核。 */
@Select("SELECT i.* FROM ll_relabel_item i JOIN ll_relabel_application a ON a.id=i.application_id AND a.customer_id=i.customer_id "
+ "WHERE i.customer_id=#{customerId} AND i.source_inventory_position_id=#{positionId} AND " + ELIGIBLE + " ORDER BY i.id")
List<RelabelItem> cartons(@Param("customerId") String customerId, @Param("positionId") Long positionId);
/** 通过原入仓单及客户/系统箱唛定位唯一转运实收箱,废弃扫码始终使用系统箱唛。 */
@Select("SELECT DISTINCT c.carton_mark FROM ll_relabel_item i "
+ "JOIN ll_warehouse_transfer_order t ON t.customer_id=i.customer_id AND t.inbound_order_id=i.source_inbound_order_id "
+ "JOIN ll_warehouse_transfer_carton c ON c.customer_id=t.customer_id AND c.transfer_order_id=t.id "
+ "AND (UPPER(TRIM(i.carton_mark))=UPPER(TRIM(c.carton_mark)) OR UPPER(TRIM(i.carton_mark))=UPPER(TRIM(c.customer_carton_mark))) "
+ "WHERE i.customer_id=#{customerId} AND i.id=#{id} ORDER BY c.carton_mark")
List<String> systemCartonMarks(@Param("customerId") String customerId, @Param("id") Long id);
/** 箱级交接使用锁定读复核最新归属,不能复用提交前的一致性读快照。 */
@Select("SELECT i.id FROM ll_relabel_item i JOIN ll_relabel_application a ON a.id=i.application_id AND a.customer_id=i.customer_id "
+ "WHERE i.customer_id=#{customerId} AND i.id=#{id} AND " + ELIGIBLE + " FOR UPDATE")
Long lockEligible(@Param("customerId") String customerId, @Param("id") Long id);
/** 增补仍有已确认换标冻结实箱的候选仓位,不开放其他业务冻结库存。 */
@Select("<script>SELECT p.* FROM ll_inventory_position p JOIN ll_inventory_balance b ON b.id=p.balance_id AND b.customer_id=p.customer_id "
+ "WHERE p.customer_id=#{customerId} AND p.hold_packages&gt;0 AND b.hold_packages&gt;0 "
+ "AND EXISTS(SELECT 1 FROM ll_relabel_item i JOIN ll_relabel_application a ON a.id=i.application_id AND a.customer_id=i.customer_id "
+ "WHERE i.customer_id=p.customer_id AND i.source_inventory_position_id=p.id AND a.overseas_warehouse_id=p.overseas_warehouse_id AND " + ELIGIBLE + ") "
+ "AND NOT EXISTS(SELECT 1 FROM ll_warehouse_transfer_inventory_allocation t WHERE t.customer_id=p.customer_id AND t.inventory_position_id=p.id AND t.status='RESERVED') "
+ "<if test='keyword != null and keyword != &quot;&quot;'>AND b.inbound_code LIKE CONCAT('%',#{keyword},'%')</if> ORDER BY p.id</script>")
List<InventoryPosition> positions(@Param("customerId") String customerId, @Param("keyword") String keyword);
/** 排车与废弃统一先锁原换标申请,再锁库存仓位。 */
@Select("<script>SELECT DISTINCT a.relabel_no FROM ll_relabel_application a JOIN ll_relabel_item i ON i.application_id=a.id AND i.customer_id=a.customer_id "
+ "WHERE i.customer_id=#{customerId} AND i.source_inventory_position_id IN "
+ "<foreach collection='positions' item='id' open='(' separator=',' close=')'>#{id}</foreach> ORDER BY a.relabel_no</script>")
List<String> applications(@Param("customerId") String customerId, @Param("positions") List<Long> positions);
/** 原申请业务号用于交接及取消恢复冻结。 */
@Select("SELECT a.relabel_no FROM ll_relabel_item i JOIN ll_relabel_application a ON a.id=i.application_id AND a.customer_id=i.customer_id "
+ "WHERE i.customer_id=#{customerId} AND i.id=#{id}")
String relabelNo(@Param("customerId") String customerId, @Param("id") Long id);
/** 识别已转交换标的旧转运箱,避免同一箱在原托盘再次出现。 */
@Select("SELECT DISTINCT c.id FROM ll_warehouse_transfer_carton c JOIN ll_warehouse_transfer_order t ON t.id=c.transfer_order_id AND t.customer_id=c.customer_id "
+ "JOIN ll_relabel_item i ON i.customer_id=c.customer_id AND i.source_inbound_order_id=t.inbound_order_id "
+ "AND (UPPER(TRIM(i.carton_mark))=UPPER(TRIM(c.carton_mark)) OR UPPER(TRIM(i.carton_mark))=UPPER(TRIM(c.customer_carton_mark))) "
+ "JOIN ll_relabel_application a ON a.id=i.application_id AND a.customer_id=i.customer_id "
+ "WHERE c.customer_id=#{customerId} AND a.status NOT IN ('CANCELLED','REJECTED')")
List<Long> transferredCartons(@Param("customerId") String customerId);
/** 使用原状态条件将本次箱级占用改为取消或已实际废弃。 */
@Update("UPDATE ll_cargo_discard_carton SET source_type=#{target} WHERE customer_id=#{customerId} AND carton_id=#{id} AND source_type='RELABEL'")
int finish(@Param("customerId") String customerId, @Param("id") Long id, @Param("target") String target);
/** 有待废弃箱时暂停排车,结算后剩余箱恢复;全部实际废弃后终止履约。 */
@Update("UPDATE ll_relabel_application SET fulfillment_status=CASE "
+ "WHEN EXISTS(SELECT 1 FROM ll_relabel_item i JOIN ll_cargo_discard_carton dc ON dc.carton_id=i.id AND dc.customer_id=i.customer_id "
+ "AND dc.source_type='RELABEL' WHERE i.application_id=ll_relabel_application.id) THEN 'DISCARD_PENDING' "
+ "WHEN EXISTS(SELECT 1 FROM ll_relabel_item i WHERE i.application_id=ll_relabel_application.id AND NOT EXISTS "
+ "(SELECT 1 FROM ll_cargo_discard_carton dc WHERE dc.customer_id=i.customer_id AND dc.carton_id=i.id AND dc.source_type='RELABEL_DISCARDED')) "
+ "THEN 'WAIT_PRE_DISPATCH' ELSE 'DISCARDED' END,updated_at=NOW(3) "
+ "WHERE customer_id=#{customerId} AND relabel_no=#{relabelNo} AND status='READY_FOR_OUTBOUND' "
+ "AND fulfillment_status IN ('WAIT_PRE_DISPATCH','DISCARD_PENDING','DISCARDED') AND COALESCE(outbound_order_no,'')=''")
int refresh(@Param("customerId") String customerId, @Param("relabelNo") String relabelNo);
}
@@ -6,6 +6,17 @@ import java.util.List;
/** 废弃单汇总使用物理箱数,退货明细的数量仍保留为 SKU 件数。 */
public final class CargoDiscardQuantities {
/** 明细保留候选托盘预留容量,整单总箱数小于容量时按客户目标跨托盘分配。 */
public static boolean quantityPool(com.baiyue.lastleg.discard.entity.CargoDiscardApplication application, List<CargoDiscardItem> lines) {
return application.getTotalPackages() != null && !lines.isEmpty()
&& lines.stream().allMatch(i -> "INVENTORY".equals(i.getSourceType()) && i.getInventoryPositionId() != null
&& !Boolean.TRUE.equals(i.getCartonScanRequired()) && (i.getCartonMarksJson() == null || "[]".equals(i.getCartonMarksJson())))
&& application.getTotalPackages() < boxes(lines, false);
}
/** 客户申请箱数与候选库存容量分别统计。 */
public static int requested(com.baiyue.lastleg.discard.entity.CargoDiscardApplication application, List<CargoDiscardItem> lines) {
return lines.isEmpty() || quantityPool(application, lines) ? application.getTotalPackages() : boxes(lines, false);
}
/** 数量规则不需要实例化。 */
private CargoDiscardQuantities() { }
@@ -13,7 +13,11 @@ public final class PdaCargoDiscardDtos {
public record Scan(@NotBlank @Size(max = 128) String palletNo, @Size(max = 128) String discardNo) {
public Scan(String palletNo) { this(palletNo, null); }
}
public record ActualLine(@NotNull Long itemId, @PositiveOrZero int actualPackages) { }
public record ActualLine(@NotNull Long itemId, @PositiveOrZero int actualPackages,
List<@NotBlank @Size(max = 128) String> scannedCartonMarks) {
/** 已关联箱唛的旧调用继续使用整托扫码清单。 */
public ActualLine(Long itemId, int actualPackages) { this(itemId, actualPackages, null); }
}
public record Confirm(@NotEmpty List<@Valid ActualLine> lines,
@NotEmpty @Size(max = 20) List<@NotNull @jakarta.validation.constraints.Positive Long> photoFileIds,
List<@NotBlank String> scannedCartonMarks, Boolean confirmAllCartons,
@@ -29,5 +33,6 @@ public final class PdaCargoDiscardDtos {
boolean actualQuantityEditable) { }
public record PalletTask(String discardNo, String customerId, String overseasWarehouseId,
String palletNo, Long profileId, String status, List<Sku> skus, String outboundNo,
String scanMode, List<String> cartonMarks) { }
String scanMode, List<String> cartonMarks, String remark,
Integer requestedPackages, Integer remainingPackages) { }
}
@@ -69,18 +69,34 @@ public class PdaCargoDiscardService {
if (!line.actualQuantityEditable() && actual != line.plannedPackages())
throw conflict("必须完成本次废弃单所选全部箱子,不能漏出");
}
if ("CARTON".equals(task.scanMode()) && !Boolean.TRUE.equals(request.confirmAllCartons())) {
// 有真实箱号必须逐箱核对,兼容字段不能绕过扫码校验。
if ("CARTON".equals(task.scanMode())) {
var scanned = request.scannedCartonMarks() == null ? List.<String>of() : request.scannedCartonMarks();
if (scanned.size() != task.cartonMarks().size() || !new HashSet<>(scanned).equals(new HashSet<>(task.cartonMarks())))
throw conflict("必须扫描全部箱唛,或选择一键全部废弃");
throw conflict("必须扫描本次废弃的全部箱号");
}
List<CargoDiscardItem> palletItems = new ArrayList<>();
// 无箱唛关联的库存按现场逐箱扫码计数,箱唛仅记录实弃事实,不冒充原箱级关联。
var observed = new HashSet<String>();
var otherMarks = repository.items(application.getCustomerId(), application.getId()).stream()
.filter(i -> "INVENTORY".equals(i.getSourceType()))
.filter(i -> !physicalCode(application, i).equals(task.palletNo()))
.flatMap(i -> stockService.actualCartonMarks(i).stream()).collect(Collectors.toSet());
for (PdaCargoDiscardDtos.Sku line : task.skus()) {
CargoDiscardItem item = repository.item(line.itemId());
if (item == null || !application.getCustomerId().equals(item.getCustomerId())
|| !Objects.equals(application.getId(), item.getApplicationId())
|| !physicalCode(application, item).equals(task.palletNo()))
throw conflict("废弃明细不属于当前申请或托盘");
if (line.actualQuantityEditable()) {
var input = request.lines().stream().filter(i -> i.itemId().equals(line.itemId())).findFirst().orElseThrow();
var scanned = input.scannedCartonMarks() == null ? List.<String>of() : input.scannedCartonMarks();
if (scanned.size() != input.actualPackages() || scanned.stream().anyMatch(mark -> mark == null || mark.isBlank()
|| mark.length() > 128 || !mark.equals(mark.trim()) || !observed.add(mark) || otherMarks.contains(mark)))
throw conflict("实际废弃箱数必须与逐箱扫码一致,箱唛不能重复或跨托盘重复废弃");
if ("COMPLETED".equals(item.getPdaStatus()) && !new HashSet<>(stockService.actualCartonMarks(item)).equals(new HashSet<>(scanned)))
throw conflict("已完成托盘的实际废弃箱唛不能修改");
}
if ("COMPLETED".equals(item.getPdaStatus())
&& !Objects.equals(item.getActualDiscardPackages(), actuals.get(item.getId())))
throw conflict("已完成托盘的实际数量不能修改");
@@ -91,6 +107,18 @@ public class PdaCargoDiscardService {
|| !application.getCustomerId().equals(item.getCustomerId())
|| !Objects.equals(application.getId(), item.getApplicationId())))
throw conflict("废弃申请终态存在未完成明细,不能恢复作业结果");
if (terminal && palletItems.stream().allMatch(item -> "COMPLETED".equals(item.getPdaStatus()))) return task;
var allItems = repository.items(application.getCustomerId(), application.getId());
boolean quantityPool = com.baiyue.lastleg.discard.model.CargoDiscardQuantities.quantityPool(application, allItems);
if (quantityPool) {
int done = allItems.stream().filter(i -> "COMPLETED".equals(i.getPdaStatus()))
.mapToInt(i -> Objects.requireNonNullElse(i.getActualDiscardPackages(), 0)).sum();
int current = actuals.values().stream().mapToInt(Integer::intValue).sum();
int availableLater = allItems.stream().filter(i -> !"COMPLETED".equals(i.getPdaStatus()) && !actuals.containsKey(i.getId()))
.mapToInt(CargoDiscardItem::getDiscardPackages).sum();
if ((long)done + current > application.getTotalPackages()) throw conflict("累计废弃箱数不能超过客户申请箱数,请刷新核对剩余数量");
if ((long)done + current + availableLater < application.getTotalPackages()) throw conflict("剩余托盘数量不足以完成申请,请核对本托盘实际废弃箱数");
}
photoService.confirm(application, task.palletNo(), request.photoFileIds(),
palletItems.stream().allMatch(item -> "COMPLETED".equals(item.getPdaStatus())));
// 已完成托盘保持原始实点结果,终态重放不得再次扣减或释放库存。
@@ -113,12 +141,25 @@ public class PdaCargoDiscardService {
adjustments.discardPalletItem(application.getCustomerId(), application, item, links,
actuals.get(item.getId()), identity.operatorKey(), now);
}
var actualMarks = task.skus().stream().filter(s -> s.itemId().equals(item.getId())).findFirst().orElseThrow().actualQuantityEditable()
? request.lines().stream().filter(i -> i.itemId().equals(item.getId())).findFirst().orElseThrow().scannedCartonMarks()
: cartonMarks(application, item);
if ("INVENTORY".equals(item.getSourceType())) stockService.saveActualCartonMarks(item, actualMarks == null ? List.of() : actualMarks);
item.setActualDiscardPackages(actuals.get(item.getId())); item.setPdaStatus("COMPLETED");
item.setPdaOperatorId(identity.operatorKey()); item.setPdaCompletedAt(now); repository.saveItem(item);
}
task.skus().stream().map(PdaCargoDiscardDtos.Sku::itemId).map(repository::item)
.filter(this::isReturnItem).map(CargoDiscardItem::getReturnCartonId).filter(Objects::nonNull).distinct()
.forEach(cartonId -> adjustments.finishReturnCarton(application.getCustomerId(), cartonId));
// 达到客户申请总数后无需逐个空扫其余托盘,仅释放未使用预留,不扣库存、不伪造照片。
allItems = repository.items(application.getCustomerId(), application.getId());
if (quantityPool && com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(allItems, true) == application.getTotalPackages()) {
for (var unused : allItems) if (!"COMPLETED".equals(unused.getPdaStatus())) {
stockService.discard(application.getCustomerId(), identity.operatorKey(), application, unused, 0);
unused.setActualDiscardPackages(0); unused.setPdaStatus("COMPLETED");
unused.setPdaOperatorId(identity.operatorKey()); unused.setPdaCompletedAt(now); repository.saveItem(unused);
}
}
if (repository.items(application.getCustomerId(), application.getId()).stream()
.allMatch(i -> "COMPLETED".equals(i.getPdaStatus()))) {
int actualTotal = com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(
@@ -179,13 +220,16 @@ public class PdaCargoDiscardService {
isReturnItem(i) ? "件" : "箱", "INVENTORY".equals(i.getSourceType()) && stockService.actualQuantityEditable(i))).toList();
String status = skus.stream().allMatch(i -> "COMPLETED".equals(i.status())) ? "COMPLETED" : "PENDING";
var marks = entry.getValue().stream().flatMap(i -> cartonMarks(application, i).stream()).distinct().sorted().toList();
// 退货箱只代表本单选择的箱子,不能因为有托盘号就当成整托废弃。
boolean cartonScan = LOOSE.equals(entry.getKey()) || entry.getValue().stream()
// 整托同样须核对已知箱号;仅无箱号的普通库存按实际箱数作业。
boolean cartonScan = !marks.isEmpty() || entry.getValue().stream()
.anyMatch(i -> isReturnItem(i) || Boolean.TRUE.equals(i.getCartonScanRequired()));
if (cartonScan && marks.isEmpty()) throw conflict("散箱废弃任务缺少箱唛");
return new PdaCargoDiscardDtos.PalletTask(application.getDiscardNo(), application.getCustomerId(), application.getOverseasWarehouseId(),
entry.getKey(), first.getInventoryPositionId() != null ? first.getInventoryPositionId() : isReturnItem(first) ? first.getReturnCartonId() : first.getProfileId(),
status, skus, outboundService.outboundNo(application), cartonScan ? "CARTON" : "PALLET", marks);
status, skus, outboundService.outboundNo(application), cartonScan ? "CARTON" : "PALLET", marks,
application.getRemark(), com.baiyue.lastleg.discard.model.CargoDiscardQuantities.requested(application, allItems),
application.getTotalPackages() == null ? null : Math.max(0, application.getTotalPackages()
- com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(allItems, true)));
}).toList();
}
/** 相关托盘只从客户库存事实读取,不从请求推断。 */
@@ -15,6 +15,6 @@ public class CargoDiscardQuantityRepository {
public int boxes(CargoDiscardApplication application) {
var lines = items.listApplication(application.getCustomerId(), application.getId());
boolean completed = "PDA_COMPLETED".equals(application.getStatus()) || "COMPLETED".equals(application.getStatus());
return lines.isEmpty() ? application.getTotalPackages() : CargoDiscardQuantities.boxes(lines, completed);
return lines.isEmpty() ? application.getTotalPackages() : completed ? CargoDiscardQuantities.boxes(lines, true) : CargoDiscardQuantities.requested(application, lines);
}
}
@@ -0,0 +1,34 @@
package com.baiyue.lastleg.discard.repository;
import com.baiyue.lastleg.discard.mapper.CargoDiscardRelabelMapper;
import com.baiyue.lastleg.relabel.entity.RelabelItem;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 换标废弃事实仅经本库 Mapper 读取和原状态条件更新。 */
@Repository
public class CargoDiscardRelabelRepository {
private final CargoDiscardRelabelMapper mapper;
public CargoDiscardRelabelRepository(CargoDiscardRelabelMapper mapper) { this.mapper = mapper; }
/** 按原申请业务号取得正式行锁。 */
public String lock(String no) { return mapper.lock(no); }
/** 复验最新箱级状态,拒绝并发废弃或已经排车的旧页面。 */
public boolean lockEligible(String customer, Long id) { return mapper.lockEligible(customer, id) != null; }
/** 已确认待发实箱。 */
public List<RelabelItem> cartons(String customer, Long position) { return mapper.cartons(customer, position); }
/** 读取换标箱对应的原系统箱唛,避免以 FBA 箱唛替代仓内扫码身份。 */
public List<String> systemCartonMarks(String customer, Long item) { return mapper.systemCartonMarks(customer, item); }
/** 已确认冻结仓位。 */
public List<InventoryPosition> positions(String customer, String keyword) { return mapper.positions(customer, keyword); }
/** 原申请锁集合。 */
public List<String> applications(String customer, List<Long> positions) { return positions.isEmpty() ? List.of() : mapper.applications(customer, positions); }
/** 换标业务归属。 */
public String relabelNo(String customer, Long item) { return mapper.relabelNo(customer, item); }
/** 原托盘旧箱记录。 */
public List<Long> transferredCartons(String customer) { return mapper.transferredCartons(customer); }
/** 结束箱级独占。 */
public int finish(String customer, Long item, String target) { return mapper.finish(customer, item, target); }
/** 刷新整单剩余履约资格。 */
public int refresh(String customer, String no) { return mapper.refresh(customer, no); }
}
@@ -51,7 +51,7 @@ public class CargoDiscardOutboundService {
outbound.setOutboundNo(orderNoGenerator.next("OUTBOUND"));
outbound.setOverseasWarehouseId(application.getOverseasWarehouseId()); outbound.setOutboundType(TYPE);
outbound.setLxCodes(null); outbound.setRecipientInfo("废弃单 " + application.getDiscardNo());
outbound.setStatus("WAIT_PICK"); outbound.setPackageCount(com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, false));
outbound.setStatus("WAIT_PICK"); outbound.setPackageCount(com.baiyue.lastleg.discard.model.CargoDiscardQuantities.requested(application, lines));
outbound.setIdempotencyKey(key(application)); outbound.setRequestHash(key(application));
outbound.setVolumeWeightKg(BigDecimal.ZERO); outbound.setVolumeCbm(BigDecimal.ZERO);
outbound.setCreatedBy(operatorId); outbound.setCreatedAt(at); outbound.setUpdatedAt(at);
@@ -67,7 +67,8 @@ public class CargoDiscardOutboundService {
var position = stockService.position(application.getCustomerId(), line.getInventoryPositionId());
if (!application.getOverseasWarehouseId().equals(position.getOverseasWarehouseId())) throw conflict("废弃库存仓库不匹配");
palletNo = position.getPalletNo();
cartonMarks = String.join(",", stockService.cartonMarks(line));
cartonMarks = String.join(",", line.getActualCartonMarksJson() == null
? stockService.cartonMarks(line) : stockService.actualCartonMarks(line));
} else if (returned) {
var carton = returnCartons.selectById(line.getReturnCartonId());
if (carton == null || !application.getCustomerId().equals(carton.getCustomerId())
@@ -134,7 +135,8 @@ public class CargoDiscardOutboundService {
};
outbound.setStatus(status);
outbound.setLxCodes(null);
outbound.setPackageCount(com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, "OUTBOUNDED".equals(status)));
outbound.setPackageCount("OUTBOUNDED".equals(status) ? com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, true)
: com.baiyue.lastleg.discard.model.CargoDiscardQuantities.requested(application, lines));
outbound.setUpdatedAt(LocalDateTime.now()); outbounds.updateById(outbound);
}
/** 保留逐 SKU 来源行,已投影过的同一实箱在后续行记零箱。 */
@@ -0,0 +1,123 @@
package com.baiyue.lastleg.discard.service;
import com.baiyue.lastleg.discard.entity.*;
import com.baiyue.lastleg.discard.model.CargoDiscardStockCartonRow;
import com.baiyue.lastleg.discard.repository.CargoDiscardRelabelRepository;
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.relabel.port.RelabelDispatchSourcePort;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.http.HttpStatus;
import java.util.*;
/** 已换标实箱在原换标冻结与废弃冻结之间原子交接,保留原箱历史。 */
@Service
public class CargoDiscardRelabelStockService {
@Resource private CargoDiscardRelabelRepository repository;
@Resource private RelabelDispatchSourcePort sources;
@Resource private InventoryCommandService commands;
@Resource private ObjectMapper json;
/** 仅增补有待发换标库存的位置。 */
public List<InventoryPosition> positions(String customer, String keyword) { return repository.positions(customer, keyword); }
/** 从当前轮次实托绑定核对箱级贡献,换标完成但未绑定的箱不能猜测位置。 */
public List<CargoDiscardStockCartonRow> cartons(String customer, InventoryPosition position) {
var items = repository.cartons(customer, position.getId());
if (items.isEmpty() || position.getPalletId() == null || position.getPalletId() <= 0) return List.of();
var snapshot = sources.snapshot(position.getOverseasWarehouseId(), List.of(position.getPalletId()));
if (!snapshot.complete()) return List.of();
var bound = snapshot.sources().stream().filter(s -> customer.equals(s.customerId())
&& Objects.equals(position.getId(), s.inventoryPositionId()) && s.boundPackageCount() == 1)
.map(RelabelDispatchSourcePort.BoundSource::itemId).collect(java.util.stream.Collectors.toSet());
var result = new ArrayList<CargoDiscardStockCartonRow>();
for (var item : items) {
if (!bound.contains(item.getId())) continue;
var box = new CargoDiscardStockCartonRow(); box.setId(item.getId()); box.setSourceType("RELABEL");
// 系统箱唛是仓内实箱身份;换标仅更新 FBA 箱唛和商品标签,不替换废弃扫码身份。
var systemMarks = repository.systemCartonMarks(customer, item.getId());
if (systemMarks.size() > 1) throw conflict("换标箱对应多个系统箱唛,请先核对原转运箱记录");
box.setCartonMark(systemMarks.isEmpty() ? item.getCartonMark() : systemMarks.get(0));
box.setSkuCode(item.getNewSkuCode() == null || item.getNewSkuCode().isBlank() ? item.getSkuCode() : item.getNewSkuCode());
try {
var values = json.readTree(Objects.toString(item.getSkuRelabelItemsJson(), "[]"));
if (values.isArray() && !values.isEmpty()) {
var contents = json.createArrayNode();
for (var value : values) {
var content = contents.addObject();
String code = value.path("newSkuCode").asText();
content.put("skuCode", code.isBlank() ? value.path("skuCode").asText() : code);
content.put("quantity", value.path("quantity").asLong());
}
box.setSkuItemsJson(json.writeValueAsString(contents));
}
} catch (Exception invalid) { throw conflict("换标箱内 SKU 事实无法读取"); }
result.add(box);
}
return result.size() <= number(position.getHoldPackages()) ? result : List.of();
}
/** 排除已转交换标处理的旧转运箱,不能同时宣称原托盘和新托盘都存有该箱。 */
public Set<Long> transferred(String customer) { return new HashSet<>(repository.transferredCartons(customer)); }
/** 保存废弃选箱账本前以最新锁定读认领实箱。 */
public int claim(String customer, Long item) { return repository.lockEligible(customer, item) ? 1 : 0; }
/** 与排车共用按业务号排序的原申请锁,之后再取得库存正式锁。 */
public void lock(String customer, List<Long> positions) {
var applications = repository.applications(customer, positions);
applications.stream().distinct().sorted().forEach(no -> {
if (repository.lock(no) == null) throw conflict("换标原申请不存在");
});
}
/** 先归还原换标冻结,紧接着由调用方以废弃周期重新冻结,同事务不暴露可用窗口。 */
public void handoff(String customer, String operator, CargoDiscardApplication app, CargoDiscardItem line,
List<CargoDiscardStockCartonRow> selected) {
for (var box : selected) if ("RELABEL".equals(box.getSourceType())) {
String no = requireNo(customer, box.getId());
commands.release(customer, operator, move(line, app, box.getId(), no, "HANDOFF"));
}
}
/** 箱级占用已持久化后暂停原换标出库;同一混标箱多SKU行只需刷新一次。 */
public void synchronize(List<CargoDiscardCarton> links, String customer) {
var ids = links.stream().filter(link -> "RELABEL".equals(link.getSourceType()))
.map(CargoDiscardCarton::getCartonId).distinct().toList();
var no = new TreeSet<String>(); ids.forEach(id -> no.add(requireNo(customer, id)));
for (String relabelNo : no) if (repository.refresh(customer, relabelNo) != 1)
throw conflict("换标单已进入其他出库流程");
}
/** 实际废弃或撤销记录保留箱级结果;整单仅依据剩余箱恢复资格。 */
public int finish(String customer, Long item, boolean discarded) {
String no = requireNo(customer, item);
int changed = repository.finish(customer, item, discarded ? "RELABEL_DISCARDED" : "RELABEL_RELEASED");
if (changed == 1 && repository.refresh(customer, no) != 1) throw conflict("换标废弃归属发生变化");
return changed;
}
/** 撤销后恢复原换标 HOLD,不能把已换标待发箱释放为普通可用箱。 */
public void restore(String customer, CargoDiscardApplication app, CargoDiscardItem line, List<CargoDiscardCarton> links) {
for (var link : links) if (Objects.equals(line.getId(), link.getItemId()) && "RELABEL".equals(link.getSourceType()))
commands.hold(customer, app.getCreatedBy(), move(line, app, link.getCartonId(), requireNo(customer, link.getCartonId()), "RESTORE"));
}
/** 每次废弃申请独立幂等,允许取消后重新申请而不复用旧释放结果。 */
private InventoryCommandRequests.Move move(CargoDiscardItem line, CargoDiscardApplication app, Long item, String no, String action) {
return new InventoryCommandRequests.Move(line.getInventoryPositionId(), 1, no, null, "RELABEL:" + no,
"DISCARD_RELABEL:" + app.getId() + ":" + item + ":" + action, "换标库存废弃交接 " + action);
}
/** 原申请必须可追溯。 */
private String requireNo(String customer, Long item) {
String no = repository.relabelNo(customer, item);
if (no == null) throw conflict("换标箱原申请不存在");
return no;
}
private long number(Long value) { return value == null ? 0 : value; }
private ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
}
@@ -147,6 +147,8 @@ public class CargoDiscardService {
CargoDiscardRequests.Create request) {
String key = request.idempotencyKey().trim();
String requestHash = hash(request);
if (request.requestedPackages() != null && !StringUtils.hasText(request.remark()))
throw badRequest("无箱唛数量废弃必须填写备注");
CargoDiscardApplication replay = applications.findKey(customerId, key);
if (replay != null) {
if (!requestHash.equals(replay.getRequestHash())) {
@@ -219,6 +221,13 @@ public class CargoDiscardService {
}
total = Math.addExact(total, returnCartonCache.size());
total = Math.addExact(total, requestedBoxes.size());
// 客户只指定总箱数,托盘候选预留后由仓库分配实际数量。
if (request.requestedPackages() != null) {
if (request.requestedPackages() <= 0 || request.requestedPackages() > total
|| request.items().stream().anyMatch(i -> !stockService.quantitySelectionAllowed(customerId, i)))
throw badRequest("按数量废弃仅支持有托盘且无箱唛关联的库存,申请箱数不能超过可用库存");
total = request.requestedPackages();
}
LocalDateTime now = LocalDateTime.now();
if (!StringUtils.hasText(warehouseName)) warehouseName = warehouseNames().get(warehouseId);
CargoDiscardApplication application = new CargoDiscardApplication();
@@ -289,7 +298,9 @@ public class CargoDiscardService {
List<CargoDiscardResponses.Item> itemViews = items.listApplication(customerId, application.getId()).stream()
.map(value -> new CargoDiscardResponses.Item(value.getId(), value.getOrderId(), value.getInboundCode(),
value.getSkuCode(), value.getProductName(), value.getAvailablePackagesSnapshot(), value.getDiscardPackages(), selectedMarks(customerId, application, value),
value.getReturnCartonId() != null ? "件" : "箱", value.getActualDiscardPackages(), value.getPdaStatus()))
value.getReturnCartonId() != null ? "件" : "箱", value.getActualDiscardPackages(), value.getPdaStatus(),
stockService.actualCartonMarks(value), value.getInventoryPositionId() == null ? null
: stockService.position(customerId, value.getInventoryPositionId()).getPalletNo()))
.toList();
List<CargoDiscardResponses.History> historyViews = histories.listApplication(customerId, application.getId()).stream()
.map(value -> new CargoDiscardResponses.History(value.getFromStatus(), value.getToStatus(),
@@ -522,7 +533,7 @@ public class CargoDiscardService {
int boxes = quantities.boxes(value);
var lines = items.listApplication(value.getCustomerId(), value.getId());
var skuCodes = lines.stream().map(CargoDiscardItem::getSkuCode).filter(s -> s != null && !s.isBlank()).distinct().sorted().toList();
int requested = lines.isEmpty() ? value.getTotalPackages() : com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, false);
int requested = com.baiyue.lastleg.discard.model.CargoDiscardQuantities.requested(value, lines);
Integer actual = lines.stream().anyMatch(i -> i.getActualDiscardPackages() != null)
? com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, true) : null;
return new CargoDiscardResponses.Summary(value.getId(), value.getCustomerId(), value.getDiscardNo(), value.getInboundCodes(),
@@ -25,7 +25,7 @@ public class CargoDiscardStatisticsService {
boolean piecesKnown = true, actualPiecesKnown = true, actualKnown = false, palletsKnown = true;
for (var application : applications) {
var lines = dimensions.items(application.getCustomerId(), application.getId());
boxes += lines.isEmpty() ? application.getTotalPackages() : CargoDiscardQuantities.boxes(lines, false);
boxes += CargoDiscardQuantities.requested(application, lines);
if (lines.isEmpty()) { piecesKnown = false; palletsKnown = false; }
var confirmed = lines.stream().filter(i -> i.getActualDiscardPackages() != null).toList();
if (!confirmed.isEmpty()) { actualKnown = true; actualBoxes += CargoDiscardQuantities.boxes(confirmed, true); }
@@ -20,14 +20,18 @@ public class CargoDiscardStockService {
@Resource private com.baiyue.lastleg.discard.repository.CargoDiscardStockRepository repository;
@Resource private InventoryCommandService commands;
@Resource private ObjectMapper objectMapper;
@Resource private CargoDiscardRelabelStockService relabelStock;
/** 展示可用库存及相关托盘,箱唛完整时才提供逐箱选择。 */
public List<CargoDiscardResponses.Candidate> candidates(String customer, String keyword) {
return repository.candidates(customer, keyword).stream().map(position -> {
var positions = new LinkedHashMap<Long, InventoryPosition>();
repository.candidates(customer, keyword).forEach(p -> positions.put(p.getId(), p));
relabelStock.positions(customer, keyword).forEach(p -> positions.put(p.getId(), p));
return positions.values().stream().map(position -> {
var balance = repository.balance(position.getBalanceId());
if (balance == null || !customer.equals(balance.getCustomerId())) throw conflict("库存余额不存在");
long available = Math.min(value(position.getAvailablePackages()), value(balance.getAvailablePackages()));
var cartons = repository.cartons(customer, position.getId());
long available = available(customer, position, balance);
var cartons = cartons(customer, position);
boolean complete = completeCartons(cartons, available);
var boxes = complete ? cartons.stream().map(c -> new CargoDiscardResponses.Box(c.getId(), c.getCartonMark(), position.getPalletNo(),
contents(c, balance.getProductNameZh()))).toList() : List.<CargoDiscardResponses.Box>of();
@@ -40,6 +44,15 @@ public class CargoDiscardStockService {
}).filter(c -> c.availablePackages() > 0).toList();
}
/** 按数量选择仅适用于有托盘且没有完整逐箱关联的普通库存。 */
public boolean quantitySelectionAllowed(String customer, CargoDiscardRequests.Item request) {
if (request.inventoryPositionId() == null || request.cartonIds() != null) return false;
var position = position(customer, request.inventoryPositionId());
var balance = validate(customer, request);
return position.getPalletNo() != null && !position.getPalletNo().isBlank()
&& !completeCartons(cartons(customer, position), available(customer, position, balance));
}
/** 校验客户、仓库、SKU、预留上限与具体箱子的归属。 */
public InventoryBalance validate(String customer, CargoDiscardRequests.Item request) {
var position = position(customer, request.inventoryPositionId());
@@ -47,18 +60,19 @@ public class CargoDiscardStockService {
if (balance == null || !customer.equals(balance.getCustomerId())
|| !Objects.equals(position.getOverseasWarehouseId(), balance.getOverseasWarehouseId()))
throw conflict("库存不属于当前客户或仓库");
long available = Math.min(value(position.getAvailablePackages()), value(balance.getAvailablePackages()));
long available = available(customer, position, balance);
if (available <= 0 || request.discardPackages() <= 0 || request.discardPackages() > available) throw conflict("所选废弃库存数量不足");
if (request.cartonIds() != null) {
if (request.cartonIds().size() != request.discardPackages() || new HashSet<>(request.cartonIds()).size() != request.cartonIds().size())
throw conflict("废弃箱子数量与所选箱子不一致或重复");
var cartons = repository.cartons(customer, position.getId());
var cartons = cartons(customer, position);
var ids = cartons.stream().map(c -> c.getId()).collect(java.util.stream.Collectors.toSet());
if (cartons.size() != available || cartons.stream().anyMatch(c -> c.getCartonMark() == null || c.getCartonMark().isBlank()) || cartons.stream().map(c -> c.getCartonMark()).distinct().count() != cartons.size() || !ids.containsAll(request.cartonIds())) throw conflict("箱唛库存已变化或不属于所选托盘,请刷新后重选");
if (cartons.stream().filter(c -> request.cartonIds().contains(c.getId())).anyMatch(c -> contents(c, null).stream().noneMatch(s -> request.skuCode().trim().equals(s.skuCode())))) throw conflict("SKU 不属于所选箱子");
} else {
if (relabelStock.cartons(customer, position).size() > 0) throw conflict("换标库存必须选择具体箱子");
if (!sku(balance).equals(request.skuCode().trim())) throw conflict("SKU 不属于所选库存");
if (request.discardPackages() != available) throw conflict("部分废弃必须选择具体箱子,不能只填写数量");
if (request.discardPackages() != available) throw conflict("无箱唛的候选托盘须完整预留,废弃目标箱数请填写在申请总数中");
}
if (repository.dropshipPosition(customer, position.getId())) throw conflict("一件代发库存必须按真实箱唛选择,不能绕过箱级预留");
return balance;
@@ -66,14 +80,16 @@ public class CargoDiscardStockService {
/** 预留所选实收箱并保存箱号快照,转运箱从已清点状态进入待废弃。 */
public void reserve(String customer, String operator, CargoDiscardApplication application, CargoDiscardRequests.Item request, LocalDateTime at) {
relabelStock.lock(customer, List.of(request.inventoryPositionId()));
if (repository.lockPosition(customer, request.inventoryPositionId()) == null) throw conflict("库存仓位不存在");
var balance = validate(customer, request);
reserveValidated(customer, operator, application, request, at, balance, repository.cartons(customer, request.inventoryPositionId()), new HashSet<>(), new HashSet<>());
reserveValidated(customer, operator, application, request, at, balance, cartons(customer, position(customer, request.inventoryPositionId())), new HashSet<>(), new HashSet<>());
}
/** 整箱混装必须包含全部 SKU 仓位;先锁仓位并校验全部事实,再按实物箱仅锁定一次。 */
public void reserveAll(String customer, String operator, CargoDiscardApplication application, List<CargoDiscardRequests.Item> requests, LocalDateTime at) {
var stocks = requests.stream().filter(r -> r.inventoryPositionId() != null).toList();
relabelStock.lock(customer, stocks.stream().map(CargoDiscardRequests.Item::inventoryPositionId).distinct().sorted().toList());
stocks.stream().map(CargoDiscardRequests.Item::inventoryPositionId).distinct().sorted().forEach(id -> {
if (repository.lockPosition(customer, id) == null) throw conflict("库存仓位不存在");
});
@@ -82,7 +98,7 @@ public class CargoDiscardStockService {
var selectedSkus = new HashMap<String, Set<String>>();
for (var request : stocks) {
balances.put(request.inventoryPositionId(), validate(customer, request));
var facts = repository.cartons(customer, request.inventoryPositionId());
var facts = cartons(customer, position(customer, request.inventoryPositionId()));
cartons.put(request.inventoryPositionId(), facts);
for (var carton : facts) if (request.cartonIds() == null || request.cartonIds().contains(carton.getId()))
selectedSkus.computeIfAbsent(carton.getSourceType() + ":" + carton.getId(), k -> new HashSet<>()).add(request.skuCode().trim());
@@ -124,9 +140,15 @@ public class CargoDiscardStockService {
if (marks.size() != request.discardPackages() || marks.stream().distinct().count() != marks.size()) throw conflict("散箱库存缺少完整箱唛,请先核对实物箱级入库记录");
snapshotMarks(line, marks);
}
int physical = selected.isEmpty() ? request.discardPackages() : (int)selected.stream().filter(c -> heldBoxes.add(position.getId() + ":" + c.getSourceType() + ":" + c.getId())).count();
if (physical > 0) commands.hold(customer, operator, move(application, line, physical, "HOLD"));
var newlyHeld = selected.stream().filter(c -> heldBoxes.add(position.getId() + ":" + c.getSourceType() + ":" + c.getId())).toList();
int physical = selected.isEmpty() ? request.discardPackages() : newlyHeld.size();
if (physical > 0) {
relabelStock.handoff(customer, operator, application, line, newlyHeld);
commands.hold(customer, operator, move(application, line, physical, "HOLD"));
}
repository.saveItem(line);
if (newlyHeld.stream().anyMatch(c -> "RELABEL".equals(c.getSourceType())))
relabelStock.synchronize(repository.selectedCartons(customer, application.getId()), customer);
}
/** 同一仓位可能承载混装箱,完整 SKU 候选来自实箱而不是库存余额的首 SKU。 */
@@ -165,14 +187,18 @@ public class CargoDiscardStockService {
/** 驳回或取消时恢复所选箱状态并释放本次冻结数量。 */
public void release(String customer, CargoDiscardApplication application, CargoDiscardItem line) {
var links = repository.selectedCartons(customer, application.getId());
relabelStock.lock(customer, List.of(line.getInventoryPositionId()));
changeSelectedCartons(customer, application, line, "AVAILABLE");
int physical = physicalPackages(customer, application, line);
if (physical > 0) commands.release(customer, application.getCreatedBy(), move(application, line, physical, "RELEASE"));
relabelStock.restore(customer, application, line, links);
}
/** 无逐箱对应关系时按仓库实点扣冻结库存,未废弃余量在同一作业事务释放。 */
@org.springframework.transaction.annotation.Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
public void discard(String customer, String operator, CargoDiscardApplication application, CargoDiscardItem line, int actual) {
relabelStock.lock(customer, List.of(line.getInventoryPositionId()));
if (actual < 0 || actual > line.getDiscardPackages()) throw conflict("实际废弃箱数不能超过申请预留箱数");
if (!actualQuantityEditable(line) && actual != line.getDiscardPackages())
throw conflict("必须完成本次申请所选全部箱子,不能漏出");
@@ -203,6 +229,7 @@ public class CargoDiscardStockService {
/** 按箱来源执行带原状态条件的变更。 */
private int changeBox(String customer, Long cartonId, String source, String from, String to) {
if ("RELABEL".equals(source)) return "DISCARD_PENDING".equals(to) ? relabelStock.claim(customer, cartonId) : relabelStock.finish(customer, cartonId, "DISCARDED".equals(to));
if ("TRANSFER".equals(source)) return repository.changeTransferCarton(customer, cartonId, from, to);
if (!"INBOUND".equals(source)) throw conflict("箱子库存来源不正确");
return repository.changeInboundCarton(customer, cartonId, from, to);
@@ -237,8 +264,34 @@ public class CargoDiscardStockService {
try { return objectMapper.readValue(line.getCartonMarksJson(), new com.fasterxml.jackson.core.type.TypeReference<List<String>>() {}); }
catch (Exception e) { throw new IllegalStateException("Cannot read carton marks", e); }
}
/** 现场扫码单独保存,不改写客户选箱快照或把无关联库存伪装成已锁定实箱。 */
public void saveActualCartonMarks(CargoDiscardItem line, List<String> marks) {
try { line.setActualCartonMarksJson(objectMapper.writeValueAsString(marks)); }
catch (Exception e) { throw new IllegalStateException("实际废弃箱唛保存失败", e); }
}
/** 废弃详情读取真实提交的现场箱唛,历史未记录时返回空。 */
public List<String> actualCartonMarks(CargoDiscardItem line) {
if (line.getActualCartonMarksJson() == null) return List.of();
try { return objectMapper.readValue(line.getActualCartonMarksJson(), new com.fasterxml.jackson.core.type.TypeReference<List<String>>() {}); }
catch (Exception e) { throw new IllegalStateException("实际废弃箱唛读取失败", e); }
}
/** 无 SKU 来源保留未指定标记,不猜测商品。 */
private static String sku(InventoryBalance balance) { return balance.getSkuCode() == null || balance.getSkuCode().isBlank() ? "UNSPECIFIED" : balance.getSkuCode(); }
/** 当前可废弃数量包括已完成并确认的换标实箱,不包含其他业务冻结。 */
private long available(String customer, InventoryPosition position, InventoryBalance balance) {
long normal = Math.min(value(position.getAvailablePackages()), value(balance.getAvailablePackages()));
long relabel = relabelStock.cartons(customer, position).size();
return normal + Math.min(relabel, value(balance.getHoldPackages()));
}
/** 原转运旧箱与当前换标实箱互斥,避免换托后重复展示。 */
private List<com.baiyue.lastleg.discard.model.CargoDiscardStockCartonRow> cartons(String customer, InventoryPosition position) {
var moved = relabelStock.transferred(customer);
var result = new ArrayList<>(repository.cartons(customer, position.getId()).stream()
.filter(c -> !"TRANSFER".equals(c.getSourceType()) || !moved.contains(c.getId())).toList());
result.addAll(relabelStock.cartons(customer, position));
return result;
}
/** 空库存桶按零处理。 */
private static long value(Long value) { return value == null ? 0 : value; }
/** 返回可恢复的业务冲突。 */
@@ -328,6 +328,8 @@ public interface DispatchCandidateMapper {
"LEFT JOIN ll_warehouse_transfer_pallet wtp ON wtp.transfer_order_id=wto.id AND wtp.customer_id=a.customer_id " +
"AND wtp.warehouse_pallet_id=pos.pallet_id " +
"WHERE a.delivery_method='TRUCK' AND a.status='READY_FOR_OUTBOUND' AND a.fulfillment_status='WAIT_PRE_DISPATCH' " +
// 部分废弃后只统计仍在仓内的换标箱,保留原任务供追溯。
"AND NOT EXISTS(SELECT 1 FROM ll_cargo_discard_carton dc WHERE dc.customer_id=ri.customer_id AND dc.carton_id=ri.id AND dc.source_type='RELABEL_DISCARDED') " +
"<if test='customerId != null and customerId != \"\"'>AND a.customer_id=#{customerId} </if>" +
"<if test='relabelNo != null and relabelNo != \"\"'>AND a.relabel_no=#{relabelNo} </if>" +
"<if test='orderNo != null and orderNo != \"\"'>AND (a.relabel_no LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_code LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_order_no LIKE CONCAT('%',#{orderNo},'%')) </if>" +
@@ -27,10 +27,13 @@ public class DispatchCompletionService implements DispatchCompletionPort {
private final DispatchDeliveryPort delivery;
private final DispatchSignalPort signals;
private final ObjectMapper json;
private final com.baiyue.lastleg.fleet.trip.FleetTripCompletionService completion;
/** 仅依赖共同端口,禁止持签收子锁回调父流程。 */
public DispatchCompletionService(DispatchAggregateRepository repository,DispatchExecutionRepository reads,FleetTripExecutionService fleet,
DispatchExecutionPort execution,DispatchDeliveryPort delivery,DispatchSignalPort signals,ObjectMapper json) {
DispatchExecutionPort execution,DispatchDeliveryPort delivery,DispatchSignalPort signals,ObjectMapper json,
com.baiyue.lastleg.fleet.trip.FleetTripCompletionService completion) {
this.repository=repository;this.reads=reads;this.fleet=fleet;this.execution=execution;this.delivery=delivery;this.signals=signals;this.json=json;
this.completion=completion;
}
/** 新事务先父锁后普通读全部已提交事实,失败回滚后才由worker记录退避。 */
@Override @Transactional(propagation=Propagation.REQUIRES_NEW,isolation=Isolation.READ_COMMITTED)
@@ -50,6 +53,8 @@ public class DispatchCompletionService implements DispatchCompletionPort {
if(repository.history(IdWorker.getId(),row,"NORMAL_DELIVERY_COMPLETED",SYSTEM.operatorId(),null,write(Map.of("dispatch",row,"normalDeliveryFacts",facts)),now)!=1)throw new IllegalStateException("正常签收完成证据未保存");
changed=true;
}
// 全部正常签收后直接结案释放,不再等待人工二次确认。
if (facts.allNormallySigned()) completion.completeDelivered(identity.tripNo(), SYSTEM.operatorId());
// 不完整的合法部分签收也只确认本版本通知,未来签收产生自己的新信号。
signals.acknowledge(signal,Instant.now());
return new CompletionResult(row.getDispatchNo(),Stage.valueOf(row.getStage()),row.getRecordVersion(),changed,facts.allNormallySigned(),
@@ -16,11 +16,14 @@ public final class TransportTaskModels {
@NotNull Instant expectedDriverEndAt,@NotBlank @Size(min=8,max=128) String idempotencyKey) { }
public record Command(@NotNull @Min(0) Long recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey,
@NotBlank @Size(max=1000) String reason,List<@Positive Long> evidenceFileIds) { }
public record Cargo(int palletCount,int packageCount,java.math.BigDecimal totalWeightKg,java.math.BigDecimal totalVolumeCbm,
String origin,String originAddress,String originContactName,String originContactPhone,
String destination,String destinationAddress,String destinationContactName,String destinationContactPhone) { }
public record Detail(String dispatchNo,String taskType,FleetTripModels.Detail execution,
String customerId,String orderNo,List<FleetPickupModels.Task> pickups,
List<com.baiyue.lastleg.fleet.reporting.TripOperationsRecordModels.EvidenceFile> departureEvidence,
List<com.baiyue.lastleg.fleet.reporting.TripOperationsRecordModels.EvidenceFile> completionEvidence,
Long dispatchRecordVersion, com.baiyue.lastleg.dispatch.port.DispatchContracts.Resources resources) { }
Long dispatchRecordVersion, com.baiyue.lastleg.dispatch.port.DispatchContracts.Resources resources,Cargo cargo) { }
public record Assign(@NotNull @Min(0) Long recordVersion, @NotNull @Positive Long vehicleId,
@NotNull @Positive Long driverId, @NotBlank @Size(max=1000) String reason,
@Min(0) Long dispatchRecordVersion,
@@ -101,12 +101,16 @@ public class TransportTaskService {
var departureIds=commandEvidence.isEmpty()?List.<Long>of():evidenceJson(commandEvidence.get(0));
var completionIds=commandEvidence.size()>1?commandEvidence.subList(1,commandEvidence.size()).stream().flatMap(value->evidenceJson(value).stream()).distinct().toList()
: "COMPLETED".equals(trip.getStatus()) ? departureIds : List.<Long>of();
var cargo=link==null?null:trucks.findNo(link.customerId(),link.orderNo());
return new TransportTaskModels.Detail(number,row.getTaskType(),trips.detail(trip.getTripNo()),link==null?null:link.customerId(),link==null?null:link.orderNo(),
"PICKUP".equals(row.getTaskType())?pickups.tasks(trip.getTripNo()):List.of(),
"LOCAL_TRUCK".equals(row.getTaskType()) && "IN_TRANSIT".equals(trip.getStatus()) ? metadata(trip.getId(),departureIds) :
"LOCAL_TRUCK".equals(row.getTaskType()) && "COMPLETED".equals(trip.getStatus()) && commandEvidence.size()>1 ? metadata(trip.getId(),departureIds) : List.<com.baiyue.lastleg.fleet.reporting.TripOperationsRecordModels.EvidenceFile>of(),
"LOCAL_TRUCK".equals(row.getTaskType()) ? metadata(trip.getId(),completionIds) : List.<com.baiyue.lastleg.fleet.reporting.TripOperationsRecordModels.EvidenceFile>of(),row.getRecordVersion(),
reviewPhotos.resources(row));
reviewPhotos.resources(row),cargo==null?null:new TransportTaskModels.Cargo(
cargo.getPalletCount()==null?0:cargo.getPalletCount(),cargo.getPackageCount()==null?0:cargo.getPackageCount(),cargo.getTotalWeightKg(),cargo.getTotalVolumeCbm(),
cargo.getOrigin(),cargo.getOriginAddress(),cargo.getOriginContactName(),cargo.getOriginContactPhone(),
cargo.getDestination(),cargo.getDestinationAddress(),cargo.getDestinationContactName(),cargo.getDestinationContactPhone()));
}
/** 仅允许读取已确认属于本排车的完成凭证,文件元数据再次核对资源归属。 */
public byte[] evidence(String number,long id){var trip=transport(number,false);if(!evidenceIds(number).contains(id))throw missing("当前排车没有该完成凭证");files.metadata(trip.getId(),id);return files.download(trip.getId(),id);}
@@ -15,7 +15,9 @@ public final class DriverModels {
public record Query(@Min(1) Integer page, @Min(1) @Max(200) Integer size,
@Size(max = 128) String keyword, Boolean active, String reviewStatus,
String ownership, String region, String licenseState,
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant expectedEnd) {
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant expectedEnd,
@Size(max = 64) String dispatchNo) {
/** 保留唯一规范构造器供 MVC 绑定,缺省分页使用第一页、每页二十条。 */
public Query { page = page == null ? 1 : page; size = size == null ? 20 : size; }
}
@@ -29,6 +29,7 @@ public class DriverService {
private final ObjectMapper json;
private final Clock clock;
private final FleetWarehouseDirectory warehouses;
@Autowired private com.baiyue.lastleg.fleet.port.DriverAvailability availability;
@Autowired
public DriverService(DriverMapper drivers, DriverHistoryMapper history, DriverFileGateway files, ObjectMapper json, FleetWarehouseDirectory warehouses) {
@@ -55,6 +56,7 @@ public class DriverService {
page.getCurrent(), page.getSize(), page.getTotal(), page.getPages(), summary);
}
/** 先排除其他车次占用,再分页统计,避免空页或错误总数。 */
private LambdaQueryWrapper<DriverEntity> filter(DriverModels.Query query, Instant now) {
String review = optionalChoice(query.reviewStatus(), Set.of("PENDING", "APPROVED", "REJECTED"), "审核状态");
String ownership = optionalChoice(query.ownership(), Set.of("OWNED", "DISPATCHED"), "司机归属");
@@ -65,6 +67,10 @@ public class DriverService {
.eq(review != null, DriverEntity::getReviewStatus, review)
.eq(ownership != null, DriverEntity::getOwnership, ownership)
.eq(region != null, DriverEntity::getRegion, region);
if (query.dispatchNo() != null && !query.dispatchNo().isBlank()) {
var unavailable = availability.unavailable(query.dispatchNo());
if (!unavailable.isEmpty()) filter.notIn(DriverEntity::getId, unavailable);
}
if (keyword != null) filter.and(w -> w.like(DriverEntity::getName, keyword)
.or().like(DriverEntity::getPhone, keyword).or().like(DriverEntity::getDriverNo, keyword));
String state = optionalChoice(query.licenseState(), Set.of("MISSING", "VALID", "EXPIRING", "EXPIRED"), "驾驶证有效期");
@@ -189,7 +189,7 @@ public class FleetPickupService {
trips.recordExecution(trip,request.recordVersion(),"PICKUP_ARRIVED",request.reason(),operator);
repository.command(number,request.idempotencyKey(),hash);return tasks(number);
}
/** 全部送仓后结案运输,车辆和司机仍沿原有入口分别结束任务。 */
/** 全部送仓后结案运输,并在同一事务内自动释放车辆和司机。 */
@Transactional
public FleetTripModels.Detail complete(String number,String operator,FleetPickupModels.Command request) {
var trip=pickupTrip(number,true);String hash=commandHash("COMPLETE",request);
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.fleet.port;
import java.util.List;
/** 司机候选读取权威占用的车队端口。 */
public interface DriverAvailability {
/** 返回其他车次实际占用的司机,保留当前排车资源。 */
List<Long> unavailable(String dispatchNo);
}
@@ -0,0 +1,15 @@
package com.baiyue.lastleg.fleet.trip;
import com.baiyue.lastleg.fleet.port.DriverAvailability;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 司机候选只读取车队权威占用,保留当前排车关联资源。 */
@Repository
public class DriverAvailabilityRepository implements DriverAvailability {
private final FleetResourceMapper resources;
/** 注入资源占用持久层。 */
public DriverAvailabilityRepository(FleetResourceMapper resources) { this.resources = resources; }
/** 返回其他车次占用的司机身份。 */
public List<Long> unavailable(String dispatchNo) { return resources.unavailableDrivers(dispatchNo); }
}
@@ -28,5 +28,12 @@ public interface FleetResourceMapper {
SELECT 1 FROM ll_fleet_trip_dispatch d WHERE d.trip_id=a.trip_id AND d.dispatch_no=#{dispatchNo})
""")
List<Long> unavailableVehicles(@Param("dispatchNo") String dispatchNo);
/** 当前排车的司机可保留,其他车次实际占用的司机不进入候选。 */
@Select("""
SELECT a.resource_id FROM ll_fleet_resource_assignment a
WHERE a.resource_kind='DRIVER' AND NOT EXISTS (
SELECT 1 FROM ll_fleet_trip_dispatch d WHERE d.trip_id=a.trip_id AND d.dispatch_no=#{dispatchNo})
""")
List<Long> unavailableDrivers(@Param("dispatchNo") String dispatchNo);
record Assignment(String resourceKind, long resourceId, long tripId, String tripNo) { }
}
@@ -58,11 +58,26 @@ public class FleetTripCompletionService {
Instant completedAt = inspection.outcomes().stream().map(FleetCompletionModels.Outcome::terminalAt).max(Instant::compareTo).orElseThrow();
trip.setStatus("COMPLETED"); trip.setCompletedAt(LocalDateTime.ofInstant(completedAt, ZoneOffset.UTC));
trip.setCompletionJson(write(new FleetCompletionModels.Completed(request.idempotencyKey(), hash, completedAt, Instant.now(), operator, inspection.outcomes())));
// Completion freezes cargo outcomes. It deliberately does not release a driver still returning.
// 结案保存全部货物事实,并由统一执行入口同时释放车辆和司机。
history.recordExecution(trip, request.recordVersion(), "COMPLETE", request.reason(), operator);
return history.detail(tripNo);
}
/** 正常派送汇总后重核全车终态,未全部履约时继续保留占用。 */
@Transactional
public void completeDelivered(String tripNo, String operator) {
var trip = required(tripNo, true);
if ("COMPLETED".equals(trip.getStatus())) {
if (trip.getVehicleReleaseJson() == null || trip.getDriverReleaseJson() == null)
history.recordExecution(trip, trip.getRecordVersion(), "AUTO_RELEASE", "派送完成自动释放", operator);
return;
}
var inspection = inspect(trip, true);
if (!inspection.canComplete()) return;
complete(tripNo, operator, new FleetCompletionModels.Complete(trip.getRecordVersion(),
"AUTO_COMPLETE:" + tripNo, "全部派送完成自动结案"));
}
@Transactional
public FleetTripModels.Detail release(String tripNo, String operator, FleetCompletionModels.Release request) {
var trip = required(tripNo, true);
@@ -681,8 +681,27 @@ public class FleetTripService {
/** 在调用方已有事务内保存执行事实及对应历史。 */
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
public void recordExecution(FleetTripEntity trip, long version, String action, String reason, String operator) {
// 完成运输与释放占用同事务保存,任一步失败均不得留下半完成状态。
if ("COMPLETED".equals(trip.getStatus())) releaseCompletedResources(trip, operator);
persist(trip, version, action, reason, operator);
}
/** 仅删除本车次占用;保留车辆主档状态及已有释放事实,不影响后续车次。 */
private void releaseCompletedResources(FleetTripEntity trip, String operator) {
if (trip.getCompletedAt() == null || trip.getCompletionJson() == null) throw conflict("缺少运输完成依据");
var stops = read(trip.getStopsJson(), new TypeReference<List<FleetTripModels.Stop>>() {});
String location = stops.isEmpty() ? null : stops.get(stops.size()-1).name();
if (location == null || location.isBlank()) throw conflict("缺少运输终点");
for (String kind : List.of("VEHICLE", "DRIVER")) {
boolean vehicle = "VEHICLE".equals(kind);
if ((vehicle ? trip.getVehicleReleaseJson() : trip.getDriverReleaseJson()) != null) continue;
Long id = vehicle ? trip.getVehicleId() : trip.getDriverId();
if (id != null) repository.release(kind, id, trip.getId());
String key = "AUTO_COMPLETE:" + trip.getTripNo() + ":" + kind;
var released = new FleetCompletionModels.Released(key, hash(key), instant(trip.getCompletedAt()),
location, null, operator, clock.instant());
if (vehicle) trip.setVehicleReleaseJson(write(released)); else trip.setDriverReleaseJson(write(released));
}
}
/** 按指定模型解析历史 JSON,损坏记录明确报错。 */
private <T> T read(String value, Class<T> type) { try { return json.readValue(value, type); } catch (JsonProcessingException e) { throw new IllegalStateException("车次历史读取失败", e); } }
/** 按显式集合类型读取资源站点,禁止损坏数据回退为假路线。 */
@@ -16,6 +16,10 @@ import java.math.BigDecimal;
@Mapper
public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
/** 查询当前客户及海外仓的一件代发入仓码,供贴标、转运来源过滤。 */
@org.apache.ibatis.annotations.Select("SELECT DISTINCT inbound_code FROM ll_order WHERE customer_id=#{customerId} AND overseas_warehouse_id=#{warehouseId} AND order_type='ONE_CLICK_DROPSHIP' AND inbound_code IS NOT NULL")
java.util.List<String> listDropshipInboundCodes(@org.apache.ibatis.annotations.Param("customerId") String customerId,
@org.apache.ibatis.annotations.Param("warehouseId") String warehouseId);
// Correlate both order identity and customer; never infer a business source from remarks.
// CHAR(128) uses the connection character set/collation on both sides. These keys
// have at most 128 characters; table defaults differ between the fleet and order schemas.
@@ -24,6 +24,7 @@ import org.springframework.web.server.ResponseStatusException;
@RequestMapping("/api/merchant/v1/last-leg/overseas-procurements")
public class MerchantOverseasProcurementController {
@Resource private OverseasProcurementService service;
@Resource private com.baiyue.lastleg.wallet.budget.OrderBudgetService budgets;
@Resource private com.baiyue.lastleg.procurement.service.ProcurementQueryService queryService;
@PostMapping
@@ -31,7 +32,8 @@ public class MerchantOverseasProcurementController {
public ApiResponse<com.baiyue.lastleg.procurement.service.ProcurementQueryService.CustomerView> create(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody OverseasProcurementRequests.Create request) {
var created=service.create(customer(user),operator(user),request);
var created=budgets.submit(customer(user),"procurement",request.idempotencyKey(),request.budgetUnitPrice().multiply(java.math.BigDecimal.valueOf(request.purchaseQuantity())),request.currency(),
()->service.create(customer(user),operator(user),request),result->result.summary().orderNo());
return ApiResponse.success(queryService.customer(customer(user),created.summary().orderNo()));
}
@@ -29,6 +29,12 @@ public class MerchantProcurementWorkflowController {
public ApiResponse<Workflow> confirm(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String orderNo,@Valid @RequestBody Command request) {
return ApiResponse.success(workflow.confirm(customer(user),orderNo,user.userId()==null?user.username():String.valueOf(user.userId()),request));
}
/** 客户驳回必须说明原因,由当前认证客户确认归属。 */
@PostMapping("/{orderNo}/reject")
@PreAuthorize("hasAuthority('"+OverseasPermissions.MERCHANT_PROCUREMENT_CREATE+"')")
public ApiResponse<Workflow> reject(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String orderNo,@Valid @RequestBody Rejection request) {
return ApiResponse.success(workflow.reject(customer(user),orderNo,user.userId()==null?user.username():String.valueOf(user.userId()),request));
}
/** 只下载本单收货与出库已关联凭证,其他内部文件不外露。 */
@GetMapping("/{orderNo}/files/{fileId}/content")
@PreAuthorize("hasAuthority('"+OverseasPermissions.MERCHANT_PROCUREMENT_LIST+"')")
@@ -29,6 +29,12 @@ public class ProcurementWorkflowController {
public ApiResponse<Workflow> receive(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody Receipt request) {
return ApiResponse.success(service.receive(customerId,orderNo,platform(user,customerId),request));
}
/** 客户驳回后的照片替换复用正式收货权限。 */
@PostMapping("/{orderNo}/receipt-photos")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_RECEIVE_OVERSEAS+"')")
public ApiResponse<Workflow> replacePhotos(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody ReceiptPhotos request) {
return ApiResponse.success(service.replacePhotos(customerId,orderNo,platform(user,customerId),request));
}
/** 将本单实收交给客户确认。 */
@PostMapping("/{orderNo}/request-confirmation")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_RECEIVE_OVERSEAS+"')")
@@ -47,6 +53,16 @@ public class ProcurementWorkflowController {
public ApiResponse<Workflow> dispatch(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@PathVariable long batchId,@Valid @RequestBody Command request) {
return ApiResponse.success(service.dispatch(customerId,orderNo,batchId,platform(user,customerId),request));
}
@PutMapping("/{orderNo}/outbounds/{batchId}")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_SHIP_CUSTOMER+"')")
public ApiResponse<Workflow> editOutbound(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@PathVariable long batchId,@Valid @RequestBody OutboundEdit request) {
return ApiResponse.success(service.editOutbound(customerId,orderNo,batchId,platform(user,customerId),request));
}
@PostMapping("/{orderNo}/outbounds/{batchId}/cancel")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_SHIP_CUSTOMER+"')")
public ApiResponse<Workflow> cancelOutbound(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@PathVariable long batchId,@Valid @RequestBody Command request) {
return ApiResponse.success(service.cancelOutbound(customerId,orderNo,batchId,platform(user,customerId),request));
}
/** 上传签收凭证并完成本批出库。 */
@PostMapping("/{orderNo}/outbounds/{batchId}/complete")
@PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_SHIP_CUSTOMER+"')")
@@ -8,10 +8,16 @@ import java.util.List;
public final class ProcurementWorkflowDtos {
private ProcurementWorkflowDtos() { }
public record Command(@Min(0) long version,@NotBlank @Size(max=128) String idempotencyKey) { }
public record Rejection(@Min(0) long version,@NotBlank @Size(max=128) String idempotencyKey,
@NotBlank @Size(max=1000) String reason) { }
public record ReceiptPhotos(@Min(0) long version,@NotBlank @Size(max=128) String idempotencyKey,
@Positive long receiptId,@NotEmpty @Size(max=20) List<@Positive Long> fileIds) { }
public record Receipt(@Min(0) long version,@NotBlank @Size(max=128) String idempotencyKey,
@Positive long quantity,@NotEmpty @Size(max=20) List<@Positive Long> fileIds) { }
public record Outbound(@Min(0) long version,@NotBlank @Size(max=128) String idempotencyKey,
@Positive long quantity,@NotBlank @Size(max=255) String trackingNo) { }
public record OutboundEdit(@Min(0) long version,@NotBlank @Size(max=128) String idempotencyKey,
@Positive long quantity,@NotBlank @Size(max=255) String trackingNo) { }
public record Completion(@Min(0) long version,@NotBlank @Size(max=128) String idempotencyKey,
@NotEmpty @Size(max=20) List<@Positive Long> fileIds) { }
public record ReceiptRow(Long id,long quantity,List<Long> fileIds,LocalDateTime receivedAt) { }
@@ -20,5 +26,5 @@ public final class ProcurementWorkflowDtos {
public record FileInfo(Long fileId,String fileName,String contentType) { }
public record Workflow(String orderNo,long version,String status,long receivedQuantity,long stockQuantity,
long reservedQuantity,long availableQuantity,List<ReceiptRow> receipts,
List<OutboundRow> outbounds,List<FileInfo> files) { }
List<OutboundRow> outbounds,List<FileInfo> files,String rejectionReason,boolean photosNeedUpdate) { }
}
@@ -33,6 +33,8 @@ public class OverseasProcurementOrder {
private Long inboundQuantity;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private LocalDateTime estimatedArrivalAt;
private String status;
private String customerRejectionReason;
private String rejectedPhotoIds;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String remark;
private String idempotencyKey;
private String requestHash;
@@ -22,10 +22,16 @@ public interface ProcurementWorkflowMapper {
/** 保存带照片的分批收货。 */
@Insert("INSERT INTO ll_procurement_workflow_receipt(id,order_id,quantity,file_ids,received_at,created_by) VALUES(#{id},#{orderId},#{quantity},#{files},CURRENT_TIMESTAMP(3),#{operator})")
void receipt(@Param("id") long id,@Param("orderId") long orderId,@Param("quantity") long quantity,@Param("files") String files,@Param("operator") String operator);
/** 驳回返工只替换本批照片,不重复增加收货数量。 */
@Update("UPDATE ll_procurement_workflow_receipt SET file_ids=#{files} WHERE id=#{id} AND order_id=#{orderId}")
int replacePhotos(@Param("id") long id,@Param("orderId") long orderId,@Param("files") String files);
/** 创建占用可用采购库存的出库单。 */
@Insert("INSERT INTO ll_procurement_workflow_outbound(id,order_id,outbound_no,quantity,tracking_no,status,file_ids,created_at,created_by) VALUES(#{id},#{orderId},#{no},#{quantity},#{tracking},'IN_TRANSIT','[]',CURRENT_TIMESTAMP(3),#{operator})")
void outbound(@Param("id") long id,@Param("orderId") long orderId,@Param("no") String no,@Param("quantity") long quantity,@Param("tracking") String tracking,@Param("operator") String operator);
/** 派送和签收均要求批次属于已锁定订单。 */
@Update("UPDATE ll_procurement_workflow_outbound SET status=#{status},file_ids=#{files},completed_at=#{completedAt} WHERE id=#{id} AND order_id=#{orderId}")
void updateOutbound(@Param("id") long id,@Param("orderId") long orderId,@Param("status") String status,@Param("files") String files,@Param("completedAt") java.time.LocalDateTime completedAt);
/** 修改尚未开始派送的批次。 */
@Update("UPDATE ll_procurement_workflow_outbound SET quantity=#{quantity},tracking_no=#{tracking} WHERE id=#{id} AND order_id=#{orderId} AND status='IN_TRANSIT'")
void editOutbound(@Param("id") long id,@Param("orderId") long orderId,@Param("quantity") long quantity,@Param("tracking") String tracking);
}
@@ -27,10 +27,13 @@ public class ProcurementWorkflowRepository {
public List<Map<String,Object>> outbounds(long id) { return facts.outbounds(id); }
/** 写入收货事实。 */
public void receipt(long id,long orderId,long quantity,String files,String operator) { facts.receipt(id,orderId,quantity,files,operator); }
/** 照片返工保留原始收货数量和时间。 */
public int replacePhotos(long id,long orderId,String files) { return facts.replacePhotos(id,orderId,files); }
/** 写入出库事实。 */
public void outbound(long id,long orderId,String no,long quantity,String tracking,String operator) { facts.outbound(id,orderId,no,quantity,tracking,operator); }
/** 更新出库批次。 */
public void outbound(long id,long orderId,String status,String files,java.time.LocalDateTime completedAt) { facts.updateOutbound(id,orderId,status,files,completedAt); }
public void editOutbound(long id,long orderId,long quantity,String tracking) { facts.editOutbound(id,orderId,quantity,tracking); }
/** 文件仅按当前订单及客户返回,不能引用其他采购单文件。 */
public List<OverseasProcurementFile> files(OverseasProcurementOrder order) {
return files.selectList(new LambdaQueryWrapper<OverseasProcurementFile>().eq(OverseasProcurementFile::getOrderId,order.getId()).eq(OverseasProcurementFile::getCustomerId,order.getCustomerId()));
@@ -5,7 +5,6 @@ import com.baiyue.lastleg.procurement.dto.ProcurementWorkflowDtos.*;
import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder;
import com.baiyue.lastleg.procurement.repository.ProcurementWorkflowRepository;
import com.baiyue.lastleg.service.LastlegDocumentNumberGenerator;
import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@@ -23,7 +22,6 @@ public class ProcurementWorkflowService {
private final ProcurementWorkflowRepository repository;
private final LastlegDocumentNumberGenerator numbers;
private final ObjectMapper json;
private final OverseasFeeSourceService fees;
/** 客户和平台共享实际批次事实,按客户及单号读取。 */
@Transactional(readOnly=true)
@@ -51,6 +49,7 @@ public class ProcurementWorkflowService {
if(replay(order,request.idempotencyKey(),"REQUEST_CONFIRMATION",request)) return snapshot(order);
version(order,request.version());state(order,"RECEIVING");
if(snapshot(order).receivedQuantity()<=0) throw conflict("请先登记收货照片和数量");
if(snapshot(order).photosNeedUpdate()) throw conflict("客户已驳回,请重新上传各收货批次照片后再提交确认");
order.setStatus("WAIT_CUSTOMER_CONFIRM");
finish(order,operator,request.idempotencyKey(),"REQUEST_CONFIRMATION",request);
return snapshot(order);
@@ -67,13 +66,39 @@ public class ProcurementWorkflowService {
return snapshot(order);
}
/** 客户驳回后回到收货中,冻结本轮照片快照以强制重新上传。 */
@Transactional
public Workflow reject(String customer,String no,String operator,Rejection request) {
var order=order(customer,no,true);
if(replay(order,request.idempotencyKey(),"CUSTOMER_REJECT",request)) return snapshot(order);
version(order,request.version());state(order,"WAIT_CUSTOMER_CONFIRM");
if(request.reason()==null || request.reason().isBlank() || request.reason().length()>1000) throw bad("请填写驳回原因,最多1000字");
order.setCustomerRejectionReason(request.reason().trim());
order.setRejectedPhotoIds(serialize(repository.files(order).stream().map(f->f.getFileId()).distinct().toList()));
order.setStatus("RECEIVING");
finish(order,operator,request.idempotencyKey(),"CUSTOMER_REJECT",request);return snapshot(order);
}
/** 平台逐批重新上传图片,禁止复用本轮被驳回照片或追加虚假收货量。 */
@Transactional
public Workflow replacePhotos(String customer,String no,String operator,ReceiptPhotos request) {
var order=order(customer,no,true);
if(replay(order,request.idempotencyKey(),"REPLACE_PHOTOS",request)) return snapshot(order);
version(order,request.version());state(order,"RECEIVING");
if(order.getCustomerRejectionReason()==null) throw conflict("只有客户驳回后可以重新上传收货照片");
files(order,request.fileIds(),true);
var rejected=ids(order.getRejectedPhotoIds());
if(request.fileIds().stream().anyMatch(rejected::contains)) throw bad("请重新上传照片,不能复用被驳回的图片");
if(repository.replacePhotos(request.receiptId(),order.getId(),serialize(request.fileIds()))!=1) throw bad("收货批次不属于本采购单");
finish(order,operator,request.idempotencyKey(),"REPLACE_PHOTOS",request);return snapshot(order);
}
/** 创建出库先占用库存,避免其他批次重复使用相同数量;完成才实际扣除。 */
@Transactional
public Workflow outbound(String customer,String no,String operator,Outbound request) {
var order=order(customer,no,true);
if(replay(order,request.idempotencyKey(),"OUTBOUND",request)) return snapshot(order);
version(order,request.version());state(order,"CONFIRMED","IN_TRANSIT","DELIVERING");
requireReviewedFees(order);
if(request.quantity()<=0 || request.quantity()>snapshot(order).availableQuantity()) throw conflict("出库数量超过采购可用库存");
if(request.trackingNo()==null || request.trackingNo().isBlank() || request.trackingNo().length()>255) throw bad("请填写物流跟踪号");
repository.outbound(IdWorker.getId(),order.getId(),numbers.next("CK"),request.quantity(),request.trackingNo().trim(),operator);
@@ -82,15 +107,6 @@ public class ProcurementWorkflowService {
return snapshot(order);
}
/** 本单有效应收须先添加并全部审核通过,作废费用不能替代费用明细。 */
private void requireReviewedFees(OverseasProcurementOrder order) {
var current=fees.listForBusiness(order.getCustomerId(),"OVERSEAS_PROCUREMENT",order.getId()).stream()
.filter(f->"RECEIVABLE".equals(f.getDirection()) && !"VOIDED".equals(f.getSourceStatus())).toList();
if(current.isEmpty()) throw conflict("请先添加费用明细并审核通过后再出库");
if(current.stream().anyMatch(f->!"ACTIVE".equals(f.getSourceStatus()) || !"APPROVED".equals(f.getReviewStatus())))
throw conflict("费用明细尚未全部审核通过,请先完成费用审核后再出库");
}
/** 按具体出库批次推进到派送中。 */
@Transactional
public Workflow dispatch(String customer,String no,long batchId,String operator,Command request) {
@@ -103,6 +119,31 @@ public class ProcurementWorkflowService {
return snapshot(order);
}
/** 运输中批次允许调整数量和物流单号,开始派送后立即冻结。 */
@Transactional
public Workflow editOutbound(String customer,String no,long batchId,String operator,OutboundEdit request) {
var order=order(customer,no,true);String action="EDIT_OUTBOUND:"+batchId;
if(replay(order,request.idempotencyKey(),action,request)) return snapshot(order);
version(order,request.version());var batch=batch(order,batchId);
if(!"IN_TRANSIT".equals(batch.status())) throw conflict("开始派送后不能修改出库单");
var current=snapshot(order); long available=current.availableQuantity()+batch.quantity();
if(request.quantity()<=0 || request.quantity()>available) throw conflict("出库数量超过采购可用库存");
if(request.trackingNo()==null || request.trackingNo().isBlank() || request.trackingNo().length()>255) throw bad("请填写物流跟踪号");
repository.editOutbound(batchId,order.getId(),request.quantity(),request.trackingNo().trim());
finish(order,operator,request.idempotencyKey(),action,request); return snapshot(order);
}
/** 运输中批次允许取消;已开始派送的批次不可取消。 */
@Transactional
public Workflow cancelOutbound(String customer,String no,long batchId,String operator,Command request) {
var order=order(customer,no,true);String action="CANCEL_OUTBOUND:"+batchId;
if(replay(order,request.idempotencyKey(),action,request)) return snapshot(order);
version(order,request.version());var batch=batch(order,batchId);
if(!"IN_TRANSIT".equals(batch.status())) throw conflict("开始派送后不能取消出库单");
repository.outbound(batchId,order.getId(),"CANCELLED","[]",null); refreshStatus(order);
finish(order,operator,request.idempotencyKey(),action,request); return snapshot(order);
}
/** 签收凭证关联到本批次;同事务完成批次并扣除采购库存,重试不重复扣减。 */
@Transactional
public Workflow complete(String customer,String no,long batchId,String operator,Completion request) {
@@ -136,10 +177,10 @@ public class ProcurementWorkflowService {
var outbounds=repository.outbounds(order.getId()).stream().map(r->new OutboundRow(number(r,"id"),text(r,"outbound_no"),number(r,"quantity"),text(r,"tracking_no"),text(r,"status"),ids(r.get("file_ids")),date(r.get("created_at")),date(r.get("completed_at")))).toList();
long received=receipts.stream().mapToLong(ReceiptRow::quantity).sum();
long completed=outbounds.stream().filter(b->"COMPLETED".equals(b.status())).mapToLong(OutboundRow::quantity).sum();
long reserved=outbounds.stream().filter(b->!"COMPLETED".equals(b.status())).mapToLong(OutboundRow::quantity).sum();
long reserved=outbounds.stream().filter(b->!Set.of("COMPLETED","CANCELLED").contains(b.status())).mapToLong(OutboundRow::quantity).sum();
Set<Long> visible=new HashSet<>();receipts.forEach(r->visible.addAll(r.fileIds()));outbounds.forEach(b->visible.addAll(b.fileIds()));
var files=visible.isEmpty()?List.<FileInfo>of():repository.files(order).stream().filter(f->visible.contains(f.getFileId())).map(f->new FileInfo(f.getFileId(),f.getFileName(),f.getContentType())).toList();
return new Workflow(order.getOrderNo(),order.getVersion(),order.getStatus(),received,received-completed,reserved,received-completed-reserved,receipts,outbounds,files);
return new Workflow(order.getOrderNo(),order.getVersion(),order.getStatus(),received,received-completed,reserved,received-completed-reserved,receipts,outbounds,files,order.getCustomerRejectionReason(),order.getRejectedPhotoIds()!=null && receipts.stream().flatMap(r->r.fileIds().stream()).anyMatch(ids(order.getRejectedPhotoIds())::contains));
}
/** 文件数量、客户归属、用途和照片类型均由服务端验证。 */
@@ -16,6 +16,10 @@ import java.util.List;
/** 只读取换标及库存事实;不连接排车账本,不取得库存位置锁。 */
@Mapper
public interface RelabelDispatchSourceMapper {
/** 已实际废弃箱不再贡献排车库存,原换标明细及作业事实继续保留追溯。 */
@Select("SELECT DISTINCT i.id FROM ll_relabel_item i JOIN ll_cargo_discard_carton dc ON dc.customer_id=i.customer_id "
+ "AND dc.carton_id=i.id AND dc.source_type='RELABEL_DISCARDED' WHERE i.application_id=#{id}")
List<Long> discardedItems(@Param("id") Long id);
String PALLETS = "<foreach collection='palletIds' item='id' open='(' separator=',' close=')'>#{id}</foreach>";
/** 找出实托当前所有明细关联的申请,不按状态或页面资格筛选。 */
@@ -6,6 +6,10 @@ import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface RelabelItemMapper extends BaseMapper<RelabelItem> {
/** 废弃完成后原明细仅用于追溯,不再参与剩余箱的绑定门禁。 */
@org.apache.ibatis.annotations.Select("SELECT DISTINCT i.id FROM ll_relabel_item i JOIN ll_cargo_discard_carton dc "
+ "ON dc.customer_id=i.customer_id AND dc.carton_id=i.id AND dc.source_type='RELABEL_DISCARDED' WHERE i.application_id=#{id}")
java.util.List<Long> discardedItems(@org.apache.ibatis.annotations.Param("id") Long id);
/** 批量查询退货箱已完成的换标事实,避免逐箱查询。 */
@org.apache.ibatis.annotations.Select("<script>SELECT DISTINCT i.return_carton_id FROM ll_relabel_item i " +
"JOIN ll_relabel_application a ON a.id=i.application_id AND a.customer_id=i.customer_id " +
@@ -58,10 +58,11 @@ public class RelabelDispatchSourceRepository {
}
// 正式取消已释放 HOLD,旧 item 指针仅供追溯;不把它重新解释为当前贡献或沿其他托盘扩组。
// 请求实托的全部真实位置已在上方保留,残余 HOLD 仍交现有消费方完整核对。
if ("CANCELLED".equals(application.getStatus())) continue;
List<RelabelItem> items = mapper.items(applicationId);
if ("CANCELLED".equals(application.getStatus()) || "DISCARDED".equals(application.getFulfillmentStatus())) continue;
var discarded = new java.util.HashSet<>(mapper.discardedItems(applicationId));
List<RelabelItem> items = mapper.items(applicationId).stream().filter(i -> !discarded.contains(i.getId())).toList();
applications.add(new ApplicationRows(application, items, mapper.tasks(applicationId),
mapper.histories(applicationId), mapper.facts(applicationId)));
mapper.histories(applicationId), mapper.facts(applicationId).stream().filter(f -> !discarded.contains(f.getItemId())).toList()));
for (RelabelItem item : items) {
if (item.getSourceInventoryPositionId() == null) continue;
InventoryPosition position = positions.get(item.getSourceInventoryPositionId());
@@ -0,0 +1,21 @@
package com.baiyue.lastleg.relabel.repository;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import org.springframework.stereotype.Repository;
import java.util.Set;
import java.util.stream.Collectors;
/** 查询贴标、转运共用候选中应排除的一件代发来源。 */
@Repository
public class RelabelSourceRepository {
private final LastlegOrderMapper orders;
public RelabelSourceRepository(LastlegOrderMapper orders) { this.orders = orders; }
/** 按客户及操作仓隔离,使用入仓码关联库存,不按订单号前缀猜测业务类型。 */
public Set<String> dropshipInboundCodes(String customerId, String warehouseId) {
return orders.listDropshipInboundCodes(customerId, warehouseId).stream()
.filter(org.springframework.util.StringUtils::hasText).collect(Collectors.toSet());
}
}
@@ -91,6 +91,7 @@ public class RelabelApplicationService implements WalletResultHandler {
@Resource private WalletCommandOutboxService walletCommands;
@Resource private InventoryCommandService inventoryCommands;
@Resource private InventoryBalanceMapper inventoryBalances;
@Resource private com.baiyue.lastleg.relabel.repository.RelabelSourceRepository sourceRepository;
@Resource private InventoryPositionMapper inventoryPositions;
@Resource private ReturnInventoryCartonMapper returnCartons;
@Resource private ReturnInventoryCartonItemMapper returnCartonItems;
@@ -257,6 +258,11 @@ public class RelabelApplicationService implements WalletResultHandler {
.gt(InventoryBalance::getAvailablePackages, 0)
.like(StringUtils.hasText(inboundCode), InventoryBalance::getInboundCode, trim(inboundCode))
.orderByDesc(InventoryBalance::getUpdatedAt));
// 贴标与仓储转运共用本候选接口,一件代发库存不得进入两处来源下拉。
if (!balances.isEmpty()) {
var dropshipCodes = sourceRepository.dropshipInboundCodes(customerId, operationWarehouseId.trim());
balances = balances.stream().filter(balance -> !dropshipCodes.contains(balance.getInboundCode())).toList();
}
Map<Long, List<InventoryPosition>> positionsByBalance = balances.isEmpty() ? Map.of()
: inventoryPositions.listBalances(customerId, balances.stream().map(InventoryBalance::getId).toList())
.stream().filter(position -> value(position.getAvailablePackages()) > 0)
@@ -476,6 +482,11 @@ public class RelabelApplicationService implements WalletResultHandler {
|| !Objects.equals(balance.getInboundOrderId(), item.sourceInboundOrderId())) {
throw conflict("Selected inventory does not belong to this customer, operation warehouse and inbound order");
}
// 旧页面或直接请求也不能用一件代发库存创建贴标申请。
if (sourceRepository.dropshipInboundCodes(customerId, request.operationWarehouseId().trim())
.contains(balance.getInboundCode())) {
throw conflict("一件代发订单不支持贴标申请");
}
if (!inventoryBalances.cartonBelongsToInbound(customerId, request.operationWarehouseId().trim(),
item.sourceInboundOrderId(), item.cartonMark().trim())) {
throw conflict("当前箱子不存在或不属于当前订单");
@@ -27,9 +27,10 @@ public class RelabelBindingGuard {
@Resource private ObjectMapper objectMapper;
public void requireBound(RelabelApplication app) {
var discarded = new HashSet<>(items.discardedItems(app.getId()));
List<RelabelItem> cartons = items.selectList(new QueryWrapper<RelabelItem>()
.eq("application_id", app.getId()).eq("customer_id", app.getCustomerId()))
.stream().filter(item -> (item.getReturnCartonId() != null && "SKU_LABEL".equals(item.getOperationType())) || Set.of("CARTON_LABEL", "CARTON_AND_SKU", "RETURN_BULK_SKU_LABEL")
.stream().filter(item -> !discarded.contains(item.getId())).filter(item -> (item.getReturnCartonId() != null && "SKU_LABEL".equals(item.getOperationType())) || Set.of("CARTON_LABEL", "CARTON_AND_SKU", "RETURN_BULK_SKU_LABEL")
.contains(item.getOperationType())).toList();
if (cartons.isEmpty()) return;
Map<String, Set<String>> bindings = new HashMap<>();
@@ -287,6 +287,10 @@ public class WarehouseTransferService {
if (selectedOrders.isEmpty() || (!selectedIds.isEmpty() && !isReturnOrderSource(inboundCode) && selectedOrders.size() != selectedIds.size())) {
throw notFound("One or more selected customer orders do not exist in the current tenant");
}
// 与来源下拉保持一致,旧页面提交的一件代发订单也不得创建仓储转运。
if (selectedOrders.stream().anyMatch(order -> "ONE_CLICK_DROPSHIP".equals(order.getOrderType()))) {
throw conflict("一件代发订单不支持仓储转运");
}
} else {
inboundCode = transferNo;
inboundOrderId = createInboundSnapshot(customerId, transferId, transferNo, request, requestHash, now);
@@ -25,12 +25,14 @@ import org.springframework.web.server.ResponseStatusException;
@RequestMapping("/api/merchant/v1/last-leg/us-truck-orders")
public class MerchantUsTruckOrderController {
@Resource private UsTruckOrderService service;
@Resource private com.baiyue.lastleg.wallet.budget.OrderBudgetService budgets;
@PostMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRUCKING_CREATE + "')")
public ApiResponse<UsTruckOrderResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody UsTruckOrderRequests.Create request) {
return ApiResponse.success(service.create(customer(user), operator(user), request));
return ApiResponse.success(budgets.submit(customer(user),"trucking",request.idempotencyKey(),request.budgetAmount(),request.currency(),
()->service.create(customer(user),operator(user),request),result->result.summary().orderNo()));
}
@GetMapping
@@ -51,7 +53,8 @@ public class MerchantUsTruckOrderController {
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRUCKING_EDIT + "')")
public ApiResponse<UsTruckOrderResponses.Detail> update(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String orderNo, @Valid @RequestBody UsTruckOrderRequests.Update request) {
return ApiResponse.success(service.update(customer(user), orderNo, operator(user), request));
return ApiResponse.success(budgets.update(customer(user),"trucking",orderNo,request.budgetAmount(),request.currency(),
()->service.update(customer(user),orderNo,operator(user),request)));
}
private String customer(AuthenticatedUser user) {
@@ -27,10 +27,10 @@ public final class UsTruckOrderRequests {
@Size(max = 500) String originAddress,
@NotBlank @Size(max = 255) String destination,
@Size(max = 500) String destinationAddress,
@Min(0) Integer packageCount,
@NotNull @Positive Integer palletCount,
@NotNull @DecimalMin("0.00") BigDecimal totalWeightKg,
@NotNull @DecimalMin("0.00") BigDecimal totalVolumeCbm,
@DecimalMin("0.00") BigDecimal budgetAmount,
@NotNull @DecimalMin(value = "0.00", inclusive = false) BigDecimal budgetAmount,
@Size(min = 3, max = 3) String currency,
@NotBlank @Size(max = 128) String originContactName,
@NotBlank @Size(max = 128) String originContactPhone,
@@ -67,10 +67,10 @@ public final class UsTruckOrderRequests {
@Size(max = 500) String originAddress,
@Size(max = 255) String destination,
@Size(max = 500) String destinationAddress,
@Min(0) Integer packageCount,
@NotNull @Positive Integer palletCount,
@DecimalMin("0.00") BigDecimal totalWeightKg,
@DecimalMin("0.00") BigDecimal totalVolumeCbm,
@DecimalMin("0.00") BigDecimal budgetAmount,
@NotNull @DecimalMin(value = "0.00", inclusive = false) BigDecimal budgetAmount,
@Size(min = 3, max = 3) String currency,
@NotBlank @Size(max = 128) String originContactName,
@NotBlank @Size(max = 128) String originContactPhone,
@@ -77,8 +77,10 @@ public class UsTruckOrderService {
order.setVehicleType(request.vehicleType());
order.setOrigin(trim(request.origin())); order.setOriginAddress(trim(request.originAddress()));
order.setDestination(trim(request.destination())); order.setDestinationAddress(trim(request.destinationAddress()));
// 车型只表达用车需求;实际托数由收货记录,不能按车型虚构货量。
order.setPalletCount(0); order.setPackageCount(value(request.packageCount()));
// 客户申报托数独立保存,件数由后续实际收货提供。
if(request.palletCount()==null || request.palletCount()<=0) throw badRequest("请输入大于0的托数");
if(request.budgetAmount()==null || request.budgetAmount().signum()<=0) throw badRequest("请输入大于0的预算总价");
order.setPalletCount(request.palletCount()); order.setPackageCount(0);
order.setReceivedPalletCount(0); order.setAllocatedPalletCount(0);
order.setTotalWeightKg(request.totalWeightKg()); order.setTotalVolumeCbm(request.totalVolumeCbm());
order.setBudgetAmount(request.budgetAmount()); order.setCurrency(defaultCurrency(request.currency()));
@@ -127,7 +129,7 @@ public class UsTruckOrderService {
order.setVehicleType(request.vehicleType());
setText(request.origin(), order::setOrigin); setPresent(request.originAddress(), order::setOriginAddress);
setText(request.destination(), order::setDestination); setPresent(request.destinationAddress(), order::setDestinationAddress);
if (request.packageCount() != null) order.setPackageCount(request.packageCount());
if (request.palletCount() != null) order.setPalletCount(request.palletCount());
if (request.totalWeightKg() != null) order.setTotalWeightKg(request.totalWeightKg()); if (request.totalVolumeCbm() != null) order.setTotalVolumeCbm(request.totalVolumeCbm());
if (request.budgetAmount() != null) order.setBudgetAmount(request.budgetAmount()); if (request.currency() != null) order.setCurrency(defaultCurrency(request.currency()));
order.setOriginContactName(request.originContactName().trim()); order.setOriginContactPhone(request.originContactPhone().trim());
@@ -0,0 +1,40 @@
package com.baiyue.lastleg.wallet.budget;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
/** 客户下单只使用当前认证客户资金,不接受请求客户ID。 */
@RestController @RequiredArgsConstructor
@RequestMapping("/api/merchant/v1/last-leg/order-budgets")
public class MerchantOrderBudgetController {
private final OrderBudgetService service;
public record Prepare(@NotNull @DecimalMin("0") @Digits(integer=12,fraction=2) BigDecimal amount,
@NotBlank @Pattern(regexp="CNY|USD") String currency) { }
/** 按业务创建权限发起预估冻结。 */
@PostMapping("/{kind}/{key}")
@PreAuthorize("(#kind == 'procurement' and hasAuthority('merchant:cross-border:procurement:create')) or (#kind == 'trucking' and hasAuthority('merchant:cross-border:domestic-trucking:create'))")
public ApiResponse<OrderBudgetService.View> prepare(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String kind,@PathVariable @Size(max=128) String key,@Valid @RequestBody Prepare request) {
return ApiResponse.success(service.prepare(customer(user),kind,key,request.amount(),request.currency()));
}
/** 只读资金回执沿同一业务权限。 */
@GetMapping("/{kind}/{key}")
@PreAuthorize("(#kind == 'procurement' and hasAuthority('merchant:cross-border:procurement:create')) or (#kind == 'trucking' and hasAuthority('merchant:cross-border:domestic-trucking:create'))")
public ApiResponse<OrderBudgetService.View> read(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String kind,@PathVariable String key) {
return ApiResponse.success(service.read(customer(user),kind,key));
}
/** 客户身份只取认证上下文,平台身份不可借用。 */
private String customer(AuthenticatedUser user) {
if(user==null || user.tenantId()==null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if(user.tenantId()<=0 || "PLATFORM".equals(user.appCode())) throw new ResponseStatusException(HttpStatus.FORBIDDEN);
return user.tenantId().toString();
}
}
@@ -0,0 +1,26 @@
package com.baiyue.lastleg.wallet.budget;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 下单预估冻结的本地回执投影,不维护余额或授信账本。 */
@Data @TableName("ll_order_budget_reservation")
public class OrderBudget {
@TableId(type=IdType.INPUT) private Long id;
private String customerId;
private String kind;
private String requestKey;
private String orderNo;
private BigDecimal originalAmount;
private String currency;
private BigDecimal amountCny;
private String status;
private String errorMessage;
private String commandEventId;
private Long commandVersion;
private Boolean releaseRequested;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,29 @@
package com.baiyue.lastleg.wallet.budget;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.*;
import java.util.List;
/** 全部预算冻结查询按客户和业务隔离,订单绑定与资金回执串行更新。 */
@Mapper
public interface OrderBudgetMapper extends BaseMapper<OrderBudget> {
/** 按当前客户、业务与下单键读取并锁定。 */
@Select("SELECT * FROM ll_order_budget_reservation WHERE customer_id=#{customer} AND kind=#{kind} AND request_key=#{key} FOR UPDATE")
OrderBudget lock(@Param("customer") String customer,@Param("kind") String kind,@Param("key") String key);
/** 读取已绑定订单的原始预算。 */
@Select("SELECT * FROM ll_order_budget_reservation WHERE customer_id=#{customer} AND kind=#{kind} AND order_no=#{no} FOR UPDATE")
OrderBudget lockOrder(@Param("customer") String customer,@Param("kind") String kind,@Param("no") String no);
/** 资金结果只更新当前记录,禁止跨客户凭单号写入。 */
@Select("SELECT * FROM ll_order_budget_reservation WHERE id=#{id} AND customer_id=#{customer} FOR UPDATE")
OrderBudget lockId(@Param("id") long id,@Param("customer") String customer);
/** 清理未下单预占及已经取消、拒绝或履约结束的订单预算,实际费用仍沿共用费用链。 */
@Select("""
SELECT b.* FROM ll_order_budget_reservation b
WHERE (b.status='RELEASE_FAILED' AND b.updated_at < TIMESTAMPADD(MINUTE,-1,CURRENT_TIMESTAMP(3))) OR (b.status IN ('FREEZE_PENDING','FROZEN','FAILED') AND b.release_requested=0 AND (
(b.order_no IS NULL AND b.created_at < TIMESTAMPADD(MINUTE,-15,CURRENT_TIMESTAMP(3)))
OR (b.kind='procurement' AND EXISTS (SELECT 1 FROM ll_overseas_procurement_order o WHERE o.customer_id=b.customer_id AND o.order_no=b.order_no AND o.status IN ('REJECTED','CANCELLED','COMPLETED','EXCEPTION_CLOSED')))
OR (b.kind='trucking' AND EXISTS (SELECT 1 FROM ll_us_truck_order o WHERE o.customer_id=b.customer_id AND o.order_no=b.order_no AND o.status IN ('REJECTED','CANCELLED','COMPLETED')))
)) ORDER BY b.id LIMIT 100 FOR UPDATE SKIP LOCKED
""")
List<OrderBudget> releasable();
}
@@ -0,0 +1,23 @@
package com.baiyue.lastleg.wallet.budget;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 下单预算的唯一持久化入口。 */
@Repository @RequiredArgsConstructor
public class OrderBudgetRepository {
private final OrderBudgetMapper mapper;
/** 预算写入同业务事务。 */
public void insert(OrderBudget row) { mapper.insert(row); }
/** 保存当前资金指令与状态。 */
public void save(OrderBudget row) { mapper.updateById(row); }
/** 客户及下单键隔离锁定。 */
public OrderBudget lock(String customer,String kind,String key) { return mapper.lock(customer,kind,key); }
/** 已下单后的预估预算不可通过普通编辑绕过冻结。 */
public OrderBudget lockOrder(String customer,String kind,String no) { return mapper.lockOrder(customer,kind,no); }
/** 资金回执按原始记录及客户锁定。 */
public OrderBudget lockId(long id,String customer) { return mapper.lockId(id,customer); }
/** 有界读取待释放记录。 */
public List<OrderBudget> releasable() { return mapper.releasable(); }
}
@@ -0,0 +1,140 @@
package com.baiyue.lastleg.wallet.budget;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.event.WalletCommandCompletedV1;
import com.baiyue.lastleg.financefee.service.OverseasFeeCurrencyConverter;
import com.baiyue.lastleg.wallet.*;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.http.HttpStatus;
import java.math.BigDecimal;
import java.time.*;
import java.util.Objects;
import java.util.Set;
import java.util.function.*;
/** Finance 冻结回执成功后才能提交客户订单;不足或超时均不能绕过。 */
@Service @RequiredArgsConstructor
public class OrderBudgetService implements WalletResultHandler {
public static final String BUSINESS_TYPE="OVERSEAS_ORDER_BUDGET";
private final OrderBudgetRepository repository;
private final OverseasFeeCurrencyConverter converter;
private final WalletCommandOutboxService wallet;
public record View(String status,BigDecimal amountCny,String message,String orderNo,boolean usable) { }
/** 预估按当月有效汇率折人民币;余额优先及授信拆分由Finance原子完成。 */
@Transactional
public View prepare(String customer,String kind,String key,BigDecimal amount,String currency) {
requireKind(kind);
if(key==null || key.isBlank() || key.length()>128 || amount==null || (amount.signum()<0 || ("trucking".equals(kind) && amount.signum()==0)) || amount.scale()>2 || !Set.of("CNY","USD").contains(currency)) throw bad("请填写有效的预估金额(卡车须大于0),最多两位小数");
var row=repository.lock(customer,kind,key);
if(row!=null) {
requireAmount(row,amount,currency);
if(!Boolean.TRUE.equals(row.getReleaseRequested()) && "FAILED".equals(row.getStatus())) sendFreeze(row);
return view(row);
}
row=new OrderBudget();row.setId(IdWorker.getId());row.setCustomerId(customer);row.setKind(kind);row.setRequestKey(key);
row.setOriginalAmount(amount);row.setCurrency(currency);
row.setAmountCny(amount.signum()==0 ? BigDecimal.ZERO : converter.convert(currency,amount,LocalDate.now(ZoneOffset.UTC)).settlementAmount());
if(amount.signum()>0 && row.getAmountCny().signum()<=0) throw bad("预估折算人民币金额须大于0");
row.setCommandVersion(0L);row.setReleaseRequested(false);row.setCreatedAt(now());row.setUpdatedAt(now());
row.setStatus("FREEZE_PENDING");repository.insert(row);if(amount.signum()==0) { row.setStatus("FROZEN");repository.save(row); } else sendFreeze(row);return view(row);
}
/** 仅返回当前客户的预占状态,不暴露资金账户或内部事件。 */
@Transactional
public View read(String customer,String kind,String key) {
requireKind(kind);var row=repository.lock(customer,kind,key);
if(row==null) throw bad("未找到本次下单的资金校验记录");return view(row);
}
/** 预算锁、创建订单及绑定在同一事务;失败保留预占供相同下单键重试。 */
@Transactional
public <T> T submit(String customer,String kind,String key,BigDecimal amount,String currency,Supplier<T> create,Function<T,String> number) {
var row=repository.lock(customer,kind,key);
if(row==null) throw bad("请先完成预估金额及余额授信校验");
requireAmount(row,amount,currency);
if(row.getOrderNo()!=null) {
var prior=create.get();if(!row.getOrderNo().equals(number.apply(prior))) throw bad("本次冻结已用于其他订单");return prior;
}
if(!"FROZEN".equals(row.getStatus()) || Boolean.TRUE.equals(row.getReleaseRequested())) throw bad(view(row).message());
if(row.getOrderNo()==null && row.getCreatedAt().isBefore(now().minusMinutes(15))) throw bad("本次资金预占已过期,请重新下单");
var result=create.get();String no=number.apply(result);
if(row.getOrderNo()!=null && !row.getOrderNo().equals(no)) throw bad("本次冻结已用于其他订单");
row.setOrderNo(no);row.setUpdatedAt(now());repository.save(row);return result;
}
/** 原订单预算锁与普通编辑同事务,金额变化须重新下单冻结。 */
@Transactional
public <T> T update(String customer,String kind,String no,BigDecimal amount,String currency,Supplier<T> change) {
var row=repository.lockOrder(customer,kind,no);
if(row!=null) requireAmount(row,amount,currency);
return change.get();
}
/** 仅接收本服务独立预算资金身份。 */
@Override public boolean supports(String type) { return BUSINESS_TYPE.equals(type); }
/** 相关指令ID、客户、操作、金额均匹配后才接受最终回执。 */
@Override @Transactional
public void handle(WalletCommandCompletedV1 result) {
var row=repository.lockId(Long.parseLong(result.businessNo()),String.valueOf(result.customerId()));
if(row==null || !Objects.equals(row.getCommandEventId(),result.commandEventId())) return;
String operation="FREEZE_PENDING".equals(row.getStatus())?"FREEZE":"RELEASE_PENDING".equals(row.getStatus())?"RELEASE":null;
if(operation==null) return;
if(!operation.equals(result.operation()) || result.amount()==null || row.getAmountCny().compareTo(result.amount())!=0) throw bad("预算资金回执与原指令不一致");
if("SUCCEEDED".equals(result.result())) {
String expected="FREEZE".equals(operation)?"FROZEN":"RELEASED";
if(!expected.equals(result.holdStatus())) throw bad("预算资金回执状态无效");
row.setStatus(expected);row.setErrorMessage("");
if("FROZEN".equals(expected) && Boolean.TRUE.equals(row.getReleaseRequested())) sendRelease(row);
} else if("FAILED".equals(result.result())) {
row.setStatus("FREEZE".equals(operation)?"FAILED":"RELEASE_FAILED");
row.setErrorMessage(result.errorMessage()==null?"资金处理失败,请联系平台处理":result.errorMessage());
} else throw bad("预算资金结果无效");
row.setUpdatedAt(now());repository.save(row);
}
/** 未提交预占到期释放;终态订单不再占用预算;冻结在途时先登记释放意图。 */
@Scheduled(fixedDelay=30000) @Transactional
public void releaseFinished() {
for(var row:repository.releasable()) {
row.setReleaseRequested(true);
if(Set.of("FROZEN","RELEASE_FAILED").contains(row.getStatus())) { if(row.getAmountCny().signum()==0) row.setStatus("RELEASED"); else sendRelease(row); }
else if("FAILED".equals(row.getStatus())) row.setStatus("RELEASED");
row.setUpdatedAt(now());repository.save(row);
}
}
/** 生成单调版本指令,失败重试不能冒充旧回执成功。 */
private void sendFreeze(OrderBudget row) {
row.setCommandVersion(row.getCommandVersion()+1);row.setStatus("FREEZE_PENDING");row.setErrorMessage("");
row.setCommandEventId(wallet.publish("FREEZE",Long.parseLong(row.getCustomerId()),BUSINESS_TYPE,row.getId().toString(),row.getAmountCny(),"海外订单预估金额冻结",row.getCommandVersion()));
row.setUpdatedAt(now());repository.save(row);
}
/** 释放仍由Finance处理,发出指令不能当作已解冻。 */
private void sendRelease(OrderBudget row) {
row.setCommandVersion(row.getCommandVersion()+1);row.setStatus("RELEASE_PENDING");
row.setCommandEventId(wallet.publish("RELEASE",Long.parseLong(row.getCustomerId()),BUSINESS_TYPE,row.getId().toString(),row.getAmountCny(),"海外订单预算预占释放",row.getCommandVersion()));
}
/** 预算、币种和真实下单值必须一致,禁止以小额冻结提交大额订单。 */
private void requireAmount(OrderBudget row,BigDecimal amount,String currency) {
if(amount==null || row.getOriginalAmount().compareTo(amount)!=0 || !row.getCurrency().equals(currency)) throw bad("本次预估金额已变化,请重新校验资金");
}
/** 资金状态使用确定性文案,失败透传Finance不足原因。 */
private View view(OrderBudget row) {
String message=switch(row.getStatus()) {
case "FROZEN" -> (Boolean.TRUE.equals(row.getReleaseRequested()) || (row.getOrderNo()==null && row.getCreatedAt().isBefore(now().minusMinutes(15))))?"本次资金预占已过期,请重新下单":"预估金额冻结成功";
case "FAILED","RELEASE_FAILED" -> row.getErrorMessage();
case "RELEASED","RELEASE_PENDING" -> "本次资金预占已释放,请重新下单";
default -> "余额及授信校验处理中,请稍后再次提交";
};return new View(row.getStatus(),row.getAmountCny(),message,row.getOrderNo(),"FROZEN".equals(row.getStatus()) && !Boolean.TRUE.equals(row.getReleaseRequested()) && (row.getOrderNo()!=null || !row.getCreatedAt().isBefore(now().minusMinutes(15))));
}
/** 仅开放本轮客户下单业务。 */
private void requireKind(String kind) { if(!Set.of("procurement","trucking").contains(kind)) throw bad("不支持的预算业务"); }
/** 时间按UTC保存以匹配资金事件。 */
private LocalDateTime now() { return LocalDateTime.now(ZoneOffset.UTC); }
/** 返回可恢复的业务校验失败。 */
private ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST,message); }
}
@@ -2898,6 +2898,7 @@ CREATE TABLE IF NOT EXISTS ll_cargo_discard_item (
inventory_position_id BIGINT NULL COMMENT '废弃来源库存仓位ID',
carton_marks_json TEXT NULL COMMENT '本次废弃所选真实箱唛快照JSON数组',
carton_scan_required TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否需要逐箱核对本次所选箱子',
actual_carton_marks_json JSON NULL COMMENT 'PDA现场实际废弃箱唛清单,与客户选箱快照独立保存',
completion_photo_file_ids TEXT NULL COMMENT 'PDA完工照片文件ID JSON数组',
actual_discard_packages INT NULL COMMENT 'PDA确认实际废弃箱数', pda_status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT 'PDA处理状态:待处理PENDING、已完成COMPLETED',
pda_operator_id VARCHAR(128) NULL COMMENT 'PDA操作人', pda_completed_at DATETIME(3) NULL COMMENT 'PDA完成时间',
@@ -2913,7 +2914,7 @@ CREATE TABLE IF NOT EXISTS ll_cargo_discard_item (
CREATE TABLE IF NOT EXISTS ll_cargo_discard_carton (
id BIGINT NOT NULL COMMENT '主键ID', application_id BIGINT NOT NULL COMMENT '申请单ID', item_id BIGINT NOT NULL COMMENT '明细ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', carton_id BIGINT NOT NULL COMMENT '箱唛记录ID', carton_mark VARCHAR(128) NOT NULL COMMENT '箱唛',
source_type VARCHAR(32) NOT NULL DEFAULT 'DROPSHIP' COMMENT '箱子来源:一件代发DROPSHIP、入仓箱INBOUND、转运箱TRANSFER',
source_type VARCHAR(32) NOT NULL DEFAULT 'DROPSHIP' COMMENT '箱子来源:一件代发DROPSHIP、入仓箱INBOUND、转运箱TRANSFER、换标箱RELABEL及结案标记',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
UNIQUE KEY uk_ll_cargo_discard_carton(application_id,carton_id),
KEY idx_ll_cargo_discard_carton_application(customer_id,application_id,item_id),
@@ -2945,6 +2946,8 @@ CREATE TABLE IF NOT EXISTS ll_overseas_procurement_order (
reviewed_at DATETIME(3) NULL COMMENT '审核时间', version BIGINT NOT NULL DEFAULT 0 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 '更新时间',
customer_rejection_reason VARCHAR(1000) NULL COMMENT '客户采购结果驳回原因',
rejected_photo_ids TEXT NULL COMMENT '本轮客户驳回的收货照片主键快照',
PRIMARY KEY(id), UNIQUE KEY uk_ll_overseas_procurement_order_no(customer_id,order_no),
UNIQUE KEY uk_ll_overseas_procurement_order_key(customer_id,idempotency_key),
KEY idx_ll_overseas_procurement_list(customer_id,status,created_at),
@@ -4589,3 +4592,24 @@ CREATE TABLE IF NOT EXISTS ll_procurement_workflow_command (
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间',
PRIMARY KEY (order_id,command_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采购操作幂等及审计';
CREATE TABLE IF NOT EXISTS ll_order_budget_reservation (
id BIGINT NOT NULL COMMENT '预算冻结记录主键',
customer_id VARCHAR(64) NOT NULL COMMENT '客户隔离标识',
kind VARCHAR(32) NOT NULL COMMENT '客户下单业务类型',
request_key VARCHAR(128) NOT NULL COMMENT '下单幂等键',
order_no VARCHAR(64) NULL COMMENT '成功创建的业务单号',
original_amount DECIMAL(18,2) NOT NULL COMMENT '客户预估原币金额',
currency VARCHAR(3) NOT NULL COMMENT '预估金额币种',
amount_cny DECIMAL(18,2) NOT NULL COMMENT '按下单月份汇率折算的人民币冻结金额',
status VARCHAR(32) NOT NULL COMMENT '资金冻结或释放回执状态',
error_message VARCHAR(1000) NULL COMMENT '资金失败原因',
command_event_id VARCHAR(64) NULL COMMENT '当前资金指令事件标识',
command_version BIGINT NOT NULL DEFAULT 0 COMMENT '资金指令单调版本',
release_requested TINYINT NOT NULL DEFAULT 0 COMMENT '是否已请求释放预占',
created_at DATETIME(3) NOT NULL COMMENT '首次预占时间',
updated_at DATETIME(3) NOT NULL COMMENT '最近资金状态更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_order_budget_request(customer_id,kind,request_key),
KEY idx_order_budget_release(status,release_requested,created_at),
KEY idx_order_budget_order(customer_id,kind,order_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='海外客户下单预估资金冻结投影';
@@ -20,9 +20,10 @@ info:
RETURN_CARTON selections consume every SKU in the selected physical carton.
Candidates return cartons, cartonSelectionSupported and cartonSelectionBlockReason.
Stock without complete carton/pallet ownership remains available for full disposal only.
PDA tasks return scanMode PALLET or CARTON and the real cartonMarks for loose stock.
Bound pallets require their pallet number; loose stock requires every distinct carton mark scanned,
or confirmAllCartons=true. The loose group uses 散箱 as its API path key, never as a scan code.
PDA tasks with real cartonMarks use CARTON scanMode, including full bound-pallet tasks.
Every distinct selected carton mark must be scanned; confirmAllCartons cannot bypass this check.
Ordinary stock without carton marks uses PALLET mode and actual carton quantities.
The loose group uses 散箱 as its API path key, never as a scan code.
The application may select a subset of available cartons. PDA must finish every selected carton;
short actual quantities are rejected. At least one photo remains required.
Partial bound-pallet tasks use CARTON scanMode with the real palletNo and selected cartonMarks.
@@ -86,7 +87,7 @@ paths:
- $ref: '#/components/parameters/PalletNo'
post:
summary: Confirm actual disposal with owned pallet photos
description: PalletTask.skus返回actualQuantityEditable布尔值。仅普通库存未保存具体箱唛时为true,PDA展示相关托盘并要求填写实际废弃箱数;库存和费用按实际数结算,未废弃预留释放。已完成任务相同数量重放不重复扣减,修改数量拒绝。
description: PalletTask.skus返回actualQuantityEditable布尔值。仅普通库存未保存具体箱唛时为true(表示现场扫码计数,不能手填数量),PDA展示备注和相关托盘,扫描现场箱唛自动累计实际废弃箱数;库存和费用按实际数结算,未废弃预留释放。已完成任务相同数量重放不重复扣减,修改数量拒绝。
x-permissions: [lastleg:pda:operate, ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR]
requestBody:
required: true
@@ -104,16 +105,18 @@ paths:
required: [itemId, actualPackages]
properties:
itemId: { type: string, pattern: '^[1-9][0-9]*$' }
scannedCartonMarks: { type: array, uniqueItems: true, items: { type: string, minLength: 1, maxLength: 128 }, description: 无关联库存现场箱唛清单,数量须与actualPackages一致,整单跨托盘不重复,详情以actualCartonMarks回显 }
actualPackages: { type: integer, minimum: 0, description: 无精确箱唛对应关系的普通库存允许0至plannedPackages的实际箱数,剩余预留释放;有具体选箱的明细必须等于plannedPackages }
scannedCartonMarks:
type: array
uniqueItems: true
items: { type: string }
description: All task cartonMarks must be scanned unless confirmAllCartons is true.
description: All task cartonMarks must be scanned exactly once, regardless of confirmAllCartons.
confirmAllCartons:
type: boolean
default: false
description: Explicitly confirm all loose cartons without individual scans; photos remain mandatory.
deprecated: true
description: Compatibility field only; does not bypass individual carton scans. Photos remain mandatory.
photoFileIds:
type: array
minItems: 1
@@ -42,7 +42,7 @@ paths:
- { name: number, in: path, required: true, schema: { type: string }, description: 真实PC排车业务号 }
get:
summary: 排车运输详情(list)
description: data包含dispatchNo、taskType(PICKUP/LOCAL_TRUCK)、execution(现有资源与执行详情)、customerId、orderNo、pickups、completionEvidence(已确认凭证)、dispatchRecordVersion(排车审核版本)、resources(取货车辆司机照片快照;其他类型为null)。resources.driver.evidence.masterLicenseFileId仅表示司机主档驾驶证来源,包含在fileIds内;其他fileIds属于本车补传。
description: data包含dispatchNo、taskType(PICKUP/LOCAL_TRUCK)、execution(现有资源与执行详情)、customerId、orderNo、pickups、completionEvidence(已确认凭证)、dispatchRecordVersion(排车审核版本)、resources(车辆司机照片快照)、cargo(本土卡车原始托数、箱数、重量kg、体积m³、起讫地地址及双方联系人电话;非本土卡车为null)。resources.driver.evidence.masterLicenseFileId仅表示司机主档驾驶证来源,包含在fileIds内;其他fileIds属于本车补传。
responses: *response
/{number}/{action}:
parameters:
@@ -28,6 +28,7 @@ paths:
- {name: region, in: query, schema: {type: string, enum: [DOMESTIC, OVERSEAS]}}
- {name: licenseState, in: query, schema: {type: string, enum: [MISSING, VALID, EXPIRING, EXPIRED]}, description: 仅驾驶证日期状态,与审核和附件状态分别展示}
- {name: expectedEnd, in: query, schema: {type: string, format: date-time}}
- {name: dispatchNo, in: query, schema: {type: string, maxLength: 64}, description: 排车候选上下文;在分页和统计前过滤其他车次占用,保留当前排车绑定司机}
responses:
'200':
description: 业务响应,分页数据
@@ -11,7 +11,7 @@ info:
车辆和司机资格、容量与实际占用由服务端复验;候选可用不表示已预留,临时外配证据依排车主单规则核验。
调度审核后经理货审核才可真实扫码;PDA拣货核平后,平台和PDA均可确认装车完成及实际发车。
发车前整车派送安排须完整,正常签收汇总推进排车派送完成;既有异常业务和附加资源能力保留。
货物结案不自动释放车辆或司机;各资源任务分别提交实际结束时间、地点和原因,车辆另确认结束状态。
全车派送完成自动结案并释放车辆和司机,不需要二次确认;提货全部到仓及本土卡车完成同事务释放,保留车辆主档状态。
版本、幂等、客户隔离及事务要求以各实际端点及排车公共契约为准;历史数量不授权当前分配。
security:
- bearerAuth: []
@@ -290,14 +290,14 @@ paths:
parameters: [{$ref: '#/components/parameters/TripNo'}]
post:
summary: 核对全部货物并结案车次
description: '权限:platform:fleet:trip:complete;仅IN_TRANSIT,实发数量由全部已签收或已真实退回派送单覆盖;拒收、待退回、取消不算终态完成。不改写客户单、不新增收费、不释放资源;同键同内容重放,旧版本或不同内容拒绝。'
description: '权限:platform:fleet:trip:complete;仅IN_TRANSIT,实发数量由全部已签收或已真实退回派送单覆盖;拒收、待退回、取消不算终态完成。不改写客户单、不新增收费、同事务自动释放车辆和司机;同键同内容重放,旧版本或不同内容拒绝。'
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/Complete'}}}}
responses: {'200': {$ref: '#/components/responses/Detail'}}
/api/platform/v1/last-leg/fleet/trips/{tripNo}/resources/release:
parameters: [{$ref: '#/components/parameters/TripNo'}]
post:
summary: 单独结束车辆或司机本次任务并释放占用
description: '权限:platform:fleet:trip:release;货物结案后才允许;实际结束时间不早于货物完成且不晚于当前时间,地点及原因必填。车辆状态与释放同事务更新;司机可继续返程而保持占用。原请求重放不释放后来另一个车次的占用。'
description: '权限:platform:fleet:trip:release;货物结案后才允许;实际结束时间不早于货物完成且不晚于当前时间,地点及原因必填。该入口仅保留处理未释放记录,当前完成流程自动释放,不要求二次确认。原请求重放不释放后来另一个车次的占用。'
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/Release'}}}}
responses: {'200': {$ref: '#/components/responses/Detail'}}
components:
@@ -0,0 +1,56 @@
openapi: 3.0.3
info:
title: 海外客户下单预估资金校验
version: 1.0.0
description: Finance异步冻结成功后才能创建客户采购/本土卡车订单。余额优先、授信补足及不足判断由Finance原子执行;本服务不自行修改钱包。创建订单须使用相同幂等键、金额及币种。未绑定预占15分钟过期;订单终态释放,释放失败重试。预占独立于最终人工费用结算。
security:
- bearerAuth: []
paths:
/api/merchant/v1/last-leg/order-budgets/{kind}/{key}:
parameters:
- {name: kind, in: path, required: true, schema: {type: string, enum: [procurement, trucking]}}
- {name: key, in: path, required: true, schema: {type: string, minLength: 1, maxLength: 128}}
post:
summary: 发起冻结或读取原请求,失败后可用原键重试
description: 客户身份仅来自认证。procurement需要merchant:cross-border:procurement:create,trucking需要merchant:cross-border:domestic-trucking:create;权限不能交叉使用。采购金额是预算单价乘采购数量,免费样品零金额不发资金指令;卡车金额必须大于0。重试禁止更改金额或币种。
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [amount, currency]
properties:
amount: {type: number, minimum: 0, multipleOf: 0.01}
currency: {type: string, enum: [CNY, USD]}
responses:
'200': {$ref: '#/components/responses/Result'}
'403': {description: 身份或业务创建权限不足}
get:
summary: 读取当前客户本次下单的权威资金回执
description: 与POST相同创建权限;只有usable=true才允许继续下单。FREEZE_PENDING处理期间应轮询,超时保留原键重试;失败显示Finance返回原因。
responses:
'200': {$ref: '#/components/responses/Result'}
'403': {description: 身份或业务创建权限不足}
components:
securitySchemes:
bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT}
responses:
Result:
description: 统一业务响应;code为字符串200表示查询或发起成功,不等于资金冻结成功。
content:
application/json:
schema:
type: object
properties:
code: {type: string}
message: {type: string}
data:
type: object
required: [status, amountCny, message, usable]
properties:
status: {type: string, enum: [FREEZE_PENDING, FROZEN, FAILED, RELEASE_PENDING, RELEASE_FAILED, RELEASED]}
amountCny: {type: number, minimum: 0}
message: {type: string}
orderNo: {type: string, nullable: true}
usable: {type: boolean}
@@ -5172,17 +5172,17 @@ components:
remark: { type: string, nullable: true, maxLength: 1000 }
TruckCreate:
type: object
required: [vehicleType, origin, destination, totalWeightKg, totalVolumeCbm, originContactName, originContactPhone, destinationContactName, destinationContactPhone, pickupAt, deliveryAt, idempotencyKey]
required: [vehicleType, origin, destination, totalWeightKg, totalVolumeCbm, originContactName, originContactPhone, destinationContactName, destinationContactPhone, pickupAt, deliveryAt, palletCount, budgetAmount, idempotencyKey]
properties:
vehicleType: { type: string, enum: [PALLET_15, PALLET_30], description: '15拖或30拖;拼车来源未知时为空' }
origin: { type: string, maxLength: 255 }
originAddress: { type: string, nullable: true, maxLength: 500 }
destination: { type: string, maxLength: 255 }
destinationAddress: { type: string, nullable: true, maxLength: 500 }
packageCount: { type: integer, nullable: true, minimum: 0 }
palletCount: { type: integer, minimum: 1, description: '下单预估托数' }
totalWeightKg: { type: number, minimum: 0 }
totalVolumeCbm: { type: number, minimum: 0 }
budgetAmount: { type: number, nullable: true, minimum: 0 }
budgetAmount: { type: number, minimum: 0, exclusiveMinimum: true }
currency: { type: string, minLength: 3, maxLength: 3, default: USD }
originContactName: { type: string, maxLength: 128 }
originContactPhone: { type: string, maxLength: 128 }
@@ -5194,7 +5194,7 @@ components:
idempotencyKey: { type: string, maxLength: 255 }
TruckUpdate:
type: object
required: [version, vehicleType, originContactName, originContactPhone, destinationContactName, destinationContactPhone, pickupAt, deliveryAt]
required: [version, vehicleType, originContactName, originContactPhone, destinationContactName, destinationContactPhone, pickupAt, deliveryAt, palletCount, budgetAmount]
properties:
version: { type: integer, format: int64, minimum: 0 }
vehicleType: { type: string, enum: [PALLET_15, PALLET_30], description: '15拖或30拖;拼车来源未知时为空' }
@@ -5202,10 +5202,10 @@ components:
originAddress: { type: string, nullable: true, maxLength: 500 }
destination: { type: string, maxLength: 255 }
destinationAddress: { type: string, nullable: true, maxLength: 500 }
packageCount: { type: integer, minimum: 0 }
palletCount: { type: integer, minimum: 1, description: '下单预估托数' }
totalWeightKg: { type: number, minimum: 0 }
totalVolumeCbm: { type: number, minimum: 0 }
budgetAmount: { type: number, nullable: true, minimum: 0 }
budgetAmount: { type: number, minimum: 0, exclusiveMinimum: true }
currency: { type: string, minLength: 3, maxLength: 3 }
originContactName: { type: string, maxLength: 128 }
originContactPhone: { type: string, maxLength: 128 }
@@ -6,6 +6,33 @@ info:
security:
- bearerAuth: []
paths:
/api/merchant/v1/last-leg/overseas-procurements/{orderNo}/reject:
post:
summary: 客户驳回采购结果并填写原因
description: 仅当前认证客户,权限 merchant:cross-border:procurement:create。仅 WAIT_CUSTOMER_CONFIRM,成功回到 RECEIVING;保存原因及当前已上传照片快照,平台须重新上传各批收货照片才能再次提交确认。
parameters:
- $ref: '#/components/parameters/OrderNo'
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/RejectionCommand' }
responses:
'200': { $ref: '#/components/responses/WorkflowResponse' }
/api/platform/v1/last-leg/overseas-procurements/{orderNo}/receipt-photos:
post:
summary: 驳回后替换指定批次的收货照片
description: 权限 platform:overseas-business:purchase:receive-overseas。仅客户驳回后的 RECEIVING;文件须为本单新上传图片,不接受驳回前的文件;仅替换照片,不改变实收数量及时间。
parameters:
- $ref: '#/components/parameters/OrderNo'
- $ref: '#/components/parameters/CustomerId'
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/ReceiptPhotosCommand' }
responses:
'200': { $ref: '#/components/responses/WorkflowResponse' }
/api/platform/v1/last-leg/overseas-procurements/{orderNo}/workflow:
get:
summary: 读取采购实收、库存和出库批次
@@ -43,7 +70,7 @@ paths:
/api/platform/v1/last-leg/overseas-procurements/{orderNo}/request-confirmation:
post:
summary: 采购完成并请求客户确认
description: 权限 platform:overseas-business:purchase:receive-overseas;仅平台主体,必须指定目标客户 customerId。仅 RECEIVING 且已有实收;提交后不再追加收货。
description: 权限 platform:overseas-business:purchase:receive-overseas;仅平台主体,必须指定目标客户 customerId。仅 RECEIVING 且已有实收;客户驳回后必须替换各批旧照片。提交后不再追加收货。
parameters:
- $ref: '#/components/parameters/OrderNo'
- $ref: '#/components/parameters/CustomerId'
@@ -63,7 +90,7 @@ paths:
/api/platform/v1/last-leg/overseas-procurements/{orderNo}/outbounds:
post:
summary: 从采购库存创建出库单
description: 权限 platform:overseas-business:purchase:ship-customer;仅平台主体,必须指定目标客户 customerId。仅 CONFIRMED、IN_TRANSIT 或 DELIVERING;数量不得超过 availableQuantity,物流跟踪号必填。必须存在本采购单未作废应收费用且全部有效并审核通过;缺少费用、待审核或驳回时拒绝出库。创建后占用数量增加,不实际扣库存。
description: 权限 platform:overseas-business:purchase:ship-customer;仅平台主体,必须指定目标客户 customerId。仅 CONFIRMED、IN_TRANSIT 或 DELIVERING;数量不得超过 availableQuantity,物流跟踪号必填。费用明细缺失或尚未审核仅作提示,不阻止创建出库单。创建后占用数量增加,不实际扣库存。
parameters:
- $ref: '#/components/parameters/OrderNo'
- $ref: '#/components/parameters/CustomerId'
@@ -101,6 +128,36 @@ paths:
description: 未认证
'403':
description: 身份或操作权限不允许
/api/platform/v1/last-leg/overseas-procurements/{orderNo}/outbounds/{batchId}:
put:
summary: 修改尚未开始派送的出库单
description: 仅 IN_TRANSIT 批次可修改数量和物流跟踪号;开始派送后拒绝修改。
parameters:
- $ref: '#/components/parameters/OrderNo'
- $ref: '#/components/parameters/CustomerId'
- $ref: '#/components/parameters/BatchId'
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/OutboundCommand' }
responses:
'200': { $ref: '#/components/responses/WorkflowResponse' }
/api/platform/v1/last-leg/overseas-procurements/{orderNo}/outbounds/{batchId}/cancel:
post:
summary: 取消尚未开始派送的出库单
description: 仅 IN_TRANSIT 批次可取消;取消后释放采购库存占用,开始派送后拒绝取消。
parameters:
- $ref: '#/components/parameters/OrderNo'
- $ref: '#/components/parameters/CustomerId'
- $ref: '#/components/parameters/BatchId'
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/Command' }
responses:
'200': { $ref: '#/components/responses/WorkflowResponse' }
/api/platform/v1/last-leg/overseas-procurements/{orderNo}/outbounds/{batchId}/complete:
post:
summary: 上传凭证并完成本批出库
@@ -235,6 +292,21 @@ components:
uniqueItems: true
items:
$ref: '#/components/schemas/Id'
RejectionCommand:
allOf:
- $ref: '#/components/schemas/Command'
- type: object
required: [reason]
properties:
reason: {type: string, minLength: 1, maxLength: 1000}
ReceiptPhotosCommand:
allOf:
- $ref: '#/components/schemas/Command'
- type: object
required: [receiptId, fileIds]
properties:
receiptId: { $ref: '#/components/schemas/Id' }
fileIds: { $ref: '#/components/schemas/FileIds' }
ReceiptCommand:
allOf:
- $ref: '#/components/schemas/Command'
@@ -301,6 +373,8 @@ components:
required: [orderNo, version, status, receivedQuantity, stockQuantity, reservedQuantity, availableQuantity, receipts, outbounds, files]
description: stockQuantity = receivedQuantity - 已完成出库数量;reservedQuantity 为未完成出库占用;availableQuantity = stockQuantity - reservedQuantity。主状态优先显示运输中批次,其次派送中;全部批次完成但仍有库存时回到已确认,无剩余库存才签收完成。
properties:
rejectionReason: {type: string, nullable: true, description: 客户驳回原因}
photosNeedUpdate: {type: boolean, description: 存在尚未替换的被驳回照片}
orderNo: {type: string}
version: {type: integer, format: int64, minimum: 0}
status:
@@ -211,7 +211,7 @@ components:
actualPackages: {type: integer, minimum: 0}
photoFileIds: {type: array, items: {type: string}}
scannedCartonMarks: {type: array, items: {type: string}}
confirmAllCartons: {type: boolean}
confirmAllCartons: {type: boolean, deprecated: true, description: 仅兼容旧请求,不能绕过逐箱扫码校验}
Preparation:
type: object
properties:
@@ -0,0 +1,57 @@
package com.baiyue.lastleg.discard;
import com.baiyue.lastleg.discard.mapper.CargoDiscardRelabelMapper;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.session.Configuration;
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
/** 执行真实 MyBatis SQL,验证仅已确认待发换标箱进入可选来源。 */
class CargoDiscardRelabelMapperTest {
@Test
void selectsConfirmedHeldBoxesAndExcludesClaimedOrShippedSources() {
var source = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
var db = new JdbcTemplate(source);
db.execute("CREATE TABLE ll_inventory_position(id BIGINT,customer_id VARCHAR(20),balance_id BIGINT,overseas_warehouse_id VARCHAR(20),hold_packages BIGINT,pallet_id BIGINT)");
db.execute("CREATE TABLE ll_inventory_balance(id BIGINT,customer_id VARCHAR(20),hold_packages BIGINT,inbound_code VARCHAR(80))");
db.execute("CREATE TABLE ll_relabel_application(id BIGINT,customer_id VARCHAR(20),relabel_no VARCHAR(80),overseas_warehouse_id VARCHAR(20),status VARCHAR(40),fulfillment_status VARCHAR(40),customer_confirmed_at TIMESTAMP,outbound_order_no VARCHAR(80),updated_at TIMESTAMP)");
db.execute("CREATE TABLE ll_relabel_item(id BIGINT,application_id BIGINT,customer_id VARCHAR(20),source_inventory_position_id BIGINT,return_carton_id BIGINT,notified_package_count INT)");
db.execute("CREATE TABLE ll_cargo_discard_carton(customer_id VARCHAR(20),carton_id BIGINT,source_type VARCHAR(32))");
db.execute("CREATE TABLE ll_warehouse_transfer_inventory_allocation(customer_id VARCHAR(20),inventory_position_id BIGINT,status VARCHAR(30))");
db.execute("CREATE TABLE ll_warehouse_transfer_carton(id BIGINT,customer_id VARCHAR(20),transfer_order_id BIGINT,carton_mark VARCHAR(80),customer_carton_mark VARCHAR(80))");
db.execute("CREATE TABLE ll_warehouse_transfer_order(id BIGINT,customer_id VARCHAR(20),inbound_order_id BIGINT)");
db.execute("INSERT INTO ll_inventory_position VALUES(10,'C',20,'W',2,30)");
db.execute("INSERT INTO ll_inventory_balance VALUES(20,'C',2,'ZY-1')");
db.execute("INSERT INTO ll_relabel_application VALUES(1,'C','TB-1','W','READY_FOR_OUTBOUND','WAIT_PRE_DISPATCH',CURRENT_TIMESTAMP,NULL,NULL),(2,'C','TB-2','W','READY_FOR_OUTBOUND','PRE_DISPATCH_CREATED',CURRENT_TIMESTAMP,NULL,NULL)");
db.execute("INSERT INTO ll_relabel_item VALUES(101,1,'C',10,NULL,1),(102,1,'C',10,NULL,1),(201,2,'C',10,NULL,1)");
db.execute("INSERT INTO ll_cargo_discard_carton VALUES('C',101,'RELABEL_DISCARDED')");
db.execute("ALTER TABLE ll_relabel_item ADD COLUMN source_inbound_order_id BIGINT");
db.execute("ALTER TABLE ll_relabel_item ADD COLUMN carton_mark VARCHAR(80)");
db.execute("UPDATE ll_relabel_item SET source_inbound_order_id=50,carton_mark='CUSTOMER-BOX-1' WHERE id=102");
db.execute("INSERT INTO ll_warehouse_transfer_order VALUES(60,'C',50),(61,'OTHER',50),(62,'C',51)");
db.execute("INSERT INTO ll_warehouse_transfer_carton VALUES(70,'C',60,'BX-SYSTEM-1','CUSTOMER-BOX-1'),"
+ "(71,'OTHER',61,'BX-OTHER-CUSTOMER','CUSTOMER-BOX-1'),(72,'C',62,'BX-OTHER-INBOUND','CUSTOMER-BOX-1')");
var configuration = new Configuration(new Environment("test", new JdbcTransactionFactory(), source));
configuration.setMapUnderscoreToCamelCase(true); configuration.addMapper(CargoDiscardRelabelMapper.class);
try (var session = new SqlSessionFactoryBuilder().build(configuration).openSession(true)) {
var mapper = session.getMapper(CargoDiscardRelabelMapper.class);
assertEquals(java.util.List.of(102L), mapper.cartons("C", 10L).stream().map(i -> i.getId()).toList());
assertEquals(java.util.List.of(10L), mapper.positions("C", "ZY-1").stream().map(p -> p.getId()).toList());
assertTrue(mapper.lockEligible("C", 102L) != null);
assertNull(mapper.lockEligible("C", 101L));
assertTrue(mapper.cartons("OTHER", 10L).isEmpty());
assertTrue(mapper.cartons("C", 999L).isEmpty());
assertEquals(java.util.List.of("BX-SYSTEM-1"), mapper.systemCartonMarks("C", 102L));
assertTrue(mapper.systemCartonMarks("OTHER", 102L).isEmpty());
db.update("UPDATE ll_relabel_item SET carton_mark='BX-SYSTEM-1' WHERE id=102");
session.clearCache();
assertEquals(java.util.List.of("BX-SYSTEM-1"), mapper.systemCartonMarks("C", 102L));
assertEquals(1, mapper.refresh("C", "TB-1"));
}
}
}
@@ -38,7 +38,7 @@ class PdaCargoDiscardServiceTest {
private CargoDiscardItem item;
private final PdaWarehouseIdentity identity = new PdaWarehouseIdentity("OP", "operator", 99L);
private final com.baiyue.lastleg.discard.service.CargoDiscardPhotoService photoService = mock(com.baiyue.lastleg.discard.service.CargoDiscardPhotoService.class);
private PdaCargoDiscardDtos.Confirm request(int actual) { return new PdaCargoDiscardDtos.Confirm(List.of(new PdaCargoDiscardDtos.ActualLine(11L, actual)), List.of(101L)); }
private PdaCargoDiscardDtos.Confirm request(int actual) { return new PdaCargoDiscardDtos.Confirm(List.of(new PdaCargoDiscardDtos.ActualLine(11L, actual, marks(11L, actual))), List.of(101L)); }
@BeforeEach void setup() {
service = new PdaCargoDiscardService();
@@ -162,9 +162,55 @@ class PdaCargoDiscardServiceTest {
assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(actual)));
noWrites(); verifyNoInteractions(photoService);
}
/** 数量申请可仅使用一个托盘,也可跨托盘完成,剩余预留只释放一次。 */
@ParameterizedTest @ValueSource(ints = {0, 2})
void quantityTargetUsesChosenPalletsAndReleasesUnusedReservations(int firstActual) {
var stock = mock(com.baiyue.lastleg.discard.service.CargoDiscardStockService.class);
ReflectionTestUtils.setField(service, "stockService", stock); scannedStock(stock);
application.setTotalPackages(5); application.setRemark("只废弃破损外箱"); application.setStatus(CargoDiscardService.APPROVED);
item.setSourceType("INVENTORY"); item.setInventoryPositionId(42L); item.setDiscardPackages(12);
var second = new CargoDiscardItem(); second.setId(12L); second.setCustomerId("CUSTOMER"); second.setApplicationId(1L);
second.setSourceType("INVENTORY"); second.setInventoryPositionId(43L); second.setSkuCode("SKU"); second.setDiscardPackages(10); second.setPdaStatus("PENDING");
when(items.listApplication("CUSTOMER", 1L)).thenAnswer(call -> List.of(item, second));
when(items.selectById(12L)).thenReturn(second);
for (var line : List.of(item, second)) {
var position = new com.baiyue.lastleg.inventory.entity.InventoryPosition(); position.setOverseasWarehouseId("99");
position.setPalletNo(line == item ? "PALLET-1" : "PALLET-2");
when(stock.position("CUSTOMER", line.getInventoryPositionId())).thenReturn(position);
when(stock.actualQuantityEditable(line)).thenReturn(true);
}
when(applications.selectList(any())).thenReturn(List.of(application));
var task = service.resolve(identity, "PALLET-2", "CD-1");
assertEquals("只废弃破损外箱", task.remark()); assertEquals(5, task.requestedPackages()); assertEquals(5, task.remainingPackages());
assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(6)));
verifyNoInteractions(photoService);
if (firstActual > 0) {
service.confirm(identity, "CD-1", "PALLET-1", request(firstActual));
assertEquals(CargoDiscardService.APPROVED, application.getStatus());
assertEquals(3, service.resolve(identity, "PALLET-2", "CD-1").remainingPackages());
}
var secondRequest = new PdaCargoDiscardDtos.Confirm(List.of(new PdaCargoDiscardDtos.ActualLine(12L, 5-firstActual, marks(12L, 5-firstActual))), List.of(101L));
if (firstActual > 0) assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-2",
new PdaCargoDiscardDtos.Confirm(List.of(new PdaCargoDiscardDtos.ActualLine(12L, 3, List.of("FIELD-11-0", "SECOND-1", "SECOND-2"))), List.of(101L))));
service.confirm(identity, "CD-1", "PALLET-2", secondRequest);
assertEquals(CargoDiscardService.PDA_COMPLETED, application.getStatus()); assertEquals(5, application.getTotalPackages());
verify(stock).discard("CUSTOMER", "OP", application, second, 5-firstActual);
verify(stock).discard("CUSTOMER", "OP", application, item, firstActual);
service.confirm(identity, "CD-1", "PALLET-2", secondRequest);
verify(stock, times(1)).discard("CUSTOMER", "OP", application, second, 5-firstActual);
verifyNoInteractions(adjustments);
}
/** 生成现场扫码样本,各托盘箱唛独立。 */
private List<String> marks(long itemId, int count) { return java.util.stream.IntStream.range(0, count).mapToObj(i -> "FIELD-" + itemId + "-" + i).toList(); }
/** Mock仍执行真实JSON序列化,验证重放时箱唛不可篡改。 */
private void scannedStock(com.baiyue.lastleg.discard.service.CargoDiscardStockService stock) {
ReflectionTestUtils.setField(stock, "objectMapper", new com.fasterxml.jackson.databind.ObjectMapper());
doCallRealMethod().when(stock).saveActualCartonMarks(any(), anyList());
when(stock.actualCartonMarks(any())).thenCallRealMethod();
}
private void looseTask() {
var stockService = mock(com.baiyue.lastleg.discard.service.CargoDiscardStockService.class);
ReflectionTestUtils.setField(service, "stockService", stockService);
ReflectionTestUtils.setField(service, "stockService", stockService); scannedStock(stockService);
item.setSourceType("INVENTORY"); item.setInventoryPositionId(42L); item.setDiscardPackages(2);
var position = new com.baiyue.lastleg.inventory.entity.InventoryPosition();
position.setId(42L); position.setCustomerId("CUSTOMER"); position.setOverseasWarehouseId("99");
@@ -180,6 +226,7 @@ class PdaCargoDiscardServiceTest {
void unboundPalletUsesActualQuantityForInventoryFeeAndReplay(int actual) {
var stockService = mock(com.baiyue.lastleg.discard.service.CargoDiscardStockService.class);
ReflectionTestUtils.setField(service, "stockService", stockService);
scannedStock(stockService);
item.setSourceType("INVENTORY"); item.setInventoryPositionId(42L); item.setDiscardPackages(3);
var position = new com.baiyue.lastleg.inventory.entity.InventoryPosition();
position.setOverseasWarehouseId("99"); position.setPalletNo("PALLET-1");
@@ -190,13 +237,21 @@ class PdaCargoDiscardServiceTest {
var task = service.resolve(identity, "PALLET-1", "CD-1");
assertTrue(task.skus().get(0).actualQuantityEditable()); assertEquals("PALLET", task.scanMode());
assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(4)));
assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1",
new PdaCargoDiscardDtos.Confirm(List.of(new PdaCargoDiscardDtos.ActualLine(11L, 1)), List.of(101L))));
assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1",
new PdaCargoDiscardDtos.Confirm(List.of(new PdaCargoDiscardDtos.ActualLine(11L, 2, List.of("SAME", "SAME"))), List.of(101L))));
verifyNoInteractions(photoService);
var completed = service.confirm(identity, "CD-1", "PALLET-1", request(actual));
assertEquals(actual, completed.skus().get(0).actualPackages());
assertEquals(actual, application.getTotalPackages());
assertEquals(new BigDecimal("5.00").multiply(BigDecimal.valueOf(actual)), application.getTotalFee());
assertEquals(marks(11L, actual), stockService.actualCartonMarks(item));
service.confirm(identity, "CD-1", "PALLET-1", request(actual));
verify(stockService, times(1)).discard("CUSTOMER", "OP", application, item, actual);
if (actual > 0) assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1",
new PdaCargoDiscardDtos.Confirm(List.of(new PdaCargoDiscardDtos.ActualLine(11L, actual,
java.util.stream.IntStream.range(0, actual).mapToObj(i -> "CHANGED-" + i).toList())), List.of(101L))));
verifyNoInteractions(adjustments);
assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(3)));
verify(items, times(1)).updateById(item);
@@ -210,8 +265,11 @@ class PdaCargoDiscardServiceTest {
noWrites(); verifyNoInteractions(photoService);
}
@ParameterizedTest @ValueSource(booleans = {false, true})
void looseTaskCompletesAfterAllScansOrExplicitBulkAndReplays(boolean bulk) {
looseTask(); var request = looseRequest(bulk ? List.of() : List.of("BOX-2", "BOX-1"), bulk);
void looseTaskRequiresAllScansEvenWithBulkFlagAndReplays(boolean bulk) {
looseTask();
assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "散箱", looseRequest(List.of(), bulk)));
noWrites(); verifyNoInteractions(photoService);
var request = looseRequest(List.of("BOX-2", "BOX-1"), bulk);
assertEquals("COMPLETED", service.confirm(identity, "CD-1", "散箱", request).status());
assertEquals(CargoDiscardService.PDA_COMPLETED, application.getStatus());
service.confirm(identity, "CD-1", "散箱", request);
@@ -242,8 +300,9 @@ class PdaCargoDiscardServiceTest {
verify(adjustments).discardPalletItem(eq("CUSTOMER"), same(application), same(item), anyList(), eq(5), eq("OP"), any());
}
@Test void partialBoundPalletOnlyExposesSelectedBoxesAndRequiresTheirScan() {
looseTask(); item.setCartonScanRequired(true);
@ParameterizedTest @ValueSource(booleans = {false, true})
void boundPalletExposesSelectedBoxesAndRequiresTheirScan(boolean partial) {
looseTask(); item.setCartonScanRequired(partial);
var stockService = (com.baiyue.lastleg.discard.service.CargoDiscardStockService) ReflectionTestUtils.getField(service, "stockService");
var position = new com.baiyue.lastleg.inventory.entity.InventoryPosition(); position.setOverseasWarehouseId("99"); position.setPalletNo("P-42");
when(stockService.position("CUSTOMER", 42L)).thenReturn(position);
@@ -0,0 +1,47 @@
package com.baiyue.lastleg.discard.service;
import com.baiyue.lastleg.discard.repository.CargoDiscardRelabelRepository;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.relabel.entity.RelabelItem;
import com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/** 废弃实箱扫码身份保持系统箱唛,商品仍使用换标后的 SKU。 */
class CargoDiscardRelabelStockServiceTest {
@Test
void keepsSystemCartonMarkInsteadOfCustomerOrFbaLabel() {
var repository = mock(CargoDiscardRelabelRepository.class);
var sources = mock(RelabelDispatchSourcePort.class);
var service = new CargoDiscardRelabelStockService();
ReflectionTestUtils.setField(service, "repository", repository);
ReflectionTestUtils.setField(service, "sources", sources);
ReflectionTestUtils.setField(service, "json", new ObjectMapper());
var position = new InventoryPosition(); position.setId(10L); position.setPalletId(30L);
position.setOverseasWarehouseId("W"); position.setHoldPackages(1L);
var item = new RelabelItem(); item.setId(102L); item.setCartonMark("CUSTOMER-BOX-1");
item.setFbaCartonMark("FBA-BOX-1"); item.setSkuCode("OLD-SKU"); item.setNewSkuCode("NEW-SKU");
item.setSkuRelabelItemsJson("[{\"skuCode\":\"OLD-SKU\",\"newSkuCode\":\"NEW-SKU\",\"quantity\":5}]");
when(repository.cartons("C", 10L)).thenReturn(List.of(item));
var bound = new RelabelDispatchSourcePort.BoundSource(1L, "TB-1", 102L, "C", "W", 30L,
10L, 20L, 20L, 50L, "ZY-1", "ZY-1", 1, 1, 5L, "hash", List.of());
when(sources.snapshot("W", List.of(30L))).thenReturn(new RelabelDispatchSourcePort.Snapshot(
List.of(), List.of(bound), List.of(), "version", true, List.of()));
when(repository.systemCartonMarks("C", 102L)).thenReturn(List.of("BX-SYSTEM-1"));
var box = service.cartons("C", position).get(0);
assertEquals("BX-SYSTEM-1", box.getCartonMark());
assertEquals("NEW-SKU", box.getSkuCode());
assertTrue(box.getSkuItemsJson().contains("NEW-SKU"));
when(repository.systemCartonMarks("C", 102L)).thenReturn(List.of("BX-SYSTEM-1", "BX-SYSTEM-2"));
assertThrows(ResponseStatusException.class, () -> service.cartons("C", position));
// 普通非转运来源沿用原我方箱唛,也不采用 FBA 标签。
item.setCartonMark("BX-NORMAL");
when(repository.systemCartonMarks("C", 102L)).thenReturn(List.of());
assertEquals("BX-NORMAL", service.cartons("C", position).get(0).getCartonMark());
}
}
@@ -32,6 +32,7 @@ class CargoDiscardStockServiceTest {
final CargoDiscardApplication application = new CargoDiscardApplication();
CargoDiscardRequests.Item request(int count) { return new CargoDiscardRequests.Item(null, null, "SKU", count, 42L); }
@BeforeEach void setup() {
ReflectionTestUtils.setField(service, "relabelStock", mock(CargoDiscardRelabelStockService.class));
var repository = new com.baiyue.lastleg.discard.repository.CargoDiscardStockRepository();
ReflectionTestUtils.setField(service, "repository", repository);
ReflectionTestUtils.setField(repository, "stock", stock);
@@ -28,6 +28,7 @@ class DispatchCompletionPersistenceTest {
assertThat(changed.changed()).isTrue();assertThat(changed.stage()).isEqualTo(Stage.DELIVERED);
assertThat(repeated.changed()).isFalse();assertThat(repeated.recordVersion()).isEqualTo(changed.recordVersion());
assertThat(fixture.completedHistory()).isOne();
verify(fixture.completion,atLeastOnce()).completeDelivered(anyString(),eq("SYSTEM_DELIVERY_RECONCILE"));
assertThat(fixture.f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("PLANNED");
}
/** 部分签收只确认本信号,不把异常或返回数凑作全签收;后续版本独立触发完成。 */
@@ -44,6 +45,7 @@ class DispatchCompletionPersistenceTest {
private static final class CompletionFixture {
final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture();
final DispatchDeliveryPort deliveries=mock(DispatchDeliveryPort.class);final DispatchSignalPort signals=mock(DispatchSignalPort.class);
final com.baiyue.lastleg.fleet.trip.FleetTripCompletionService completion=mock(com.baiyue.lastleg.fleet.trip.FleetTripCompletionService.class);
final DispatchCompletionPort port;final String number;final Signal signal;final Instant signedAt=Instant.now().minusSeconds(5);
/** 使用真实Spring事务代理,REQUIRES_NEW与回滚均按生产注解执行。 */
CompletionFixture() throws Exception {
@@ -51,7 +53,7 @@ class DispatchCompletionPersistenceTest {
f.picked(1,"101",501,3);f.db.update("UPDATE ll_dispatch_order SET stage='DELIVERING',picked_package_count=3,shipped_package_count=3,departed_at=?",LocalDateTime.ofInstant(signedAt.minusSeconds(60),ZoneOffset.UTC));
f.db.execute("CREATE TABLE completion_test_signal(id BIGINT PRIMARY KEY,state VARCHAR(32))");f.db.update("INSERT INTO completion_test_signal VALUES(1,'PENDING')");
signal=new Signal(1L,number,"DELIVERY-A",1,0,Instant.now());
var target=new DispatchCompletionService(f.repository,f.executionReads,f.fleet,f.service,deliveries,signals,f.json);
var target=new DispatchCompletionService(f.repository,f.executionReads,f.fleet,f.service,deliveries,signals,f.json,completion);
var proxy=new ProxyFactory(target);proxy.addAdvice(new TransactionInterceptor(f.transactions,new AnnotationTransactionAttributeSource()));port=(DispatchCompletionPort)proxy.getProxy();
}
/** 父事务中的信号SQL参与同一个DataSource事务。 */
@@ -229,7 +229,7 @@ class DispatchMergedRelabelPersistenceTest {
var schema=Files.readString(Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql"));
for(var table:List.of("ll_order","ll_inventory_position","ll_inventory_balance","ll_inventory_ledger","ll_inventory_operation","ll_physical_pallet_guard",
"ll_relabel_application","ll_relabel_item","ll_relabel_pda_task","ll_relabel_pda_history","ll_relabel_pda_unit_fact","ll_inbound_plan","ll_inbound_plan_pallet",
"ll_inbound_stock_receipt","ll_dispatch_item_source","ll_dispatch_physical_pallet_lock","ll_dispatch_pallet_lock","ll_dispatch_pda_task","ll_dispatch_pda_pick","ll_dispatch_pda_pick_order","ll_warehouse_transfer_inventory_allocation","ll_warehouse_transfer_order","ll_warehouse_transfer_pallet",
"ll_inbound_stock_receipt","ll_dispatch_item_source","ll_dispatch_physical_pallet_lock","ll_dispatch_pallet_lock","ll_dispatch_pda_task","ll_dispatch_pda_pick","ll_dispatch_pda_pick_order","ll_cargo_discard_carton","ll_warehouse_transfer_inventory_allocation","ll_warehouse_transfer_order","ll_warehouse_transfer_pallet",
"ll_inbound_plan_line","ll_inbound_plan_pallet_allocation","ll_inbound_plan_carton","ll_order_detail","ll_inbound_order","ll_inbound_fba_appointment","ll_inbound_fba_appointment_item","ll_order_manual_node","ll_container_shipping_gate",
"ll_return_inventory_carton","ll_return_receipt","ll_return_stock_receipt","ll_exception_order","ll_delivery_order")) {
f.db.execute("DROP TABLE IF EXISTS "+table);int start=schema.indexOf("CREATE TABLE "+table+" (");int end=schema.indexOf(") ENGINE=",start);
@@ -55,6 +55,7 @@ class DriverAuthorizationTest {
@Configuration
@EnableMethodSecurity
static class Config {
@Bean com.baiyue.lastleg.fleet.port.DriverAvailability availability() { return mock(com.baiyue.lastleg.fleet.port.DriverAvailability.class); }
@Bean DriverService service() { return mock(DriverService.class); }
@Bean PlatformDriverController controller(DriverService service) { return new PlatformDriverController(service); }
}
@@ -46,6 +46,9 @@ class DriverPersistenceTest {
configuration.addInterceptor(pagination);
configuration.addMapper(DriverMapper.class);
configuration.addMapper(DriverHistoryMapper.class);
configuration.addMapper(com.baiyue.lastleg.fleet.trip.FleetResourceMapper.class);
jdbc.execute("CREATE TABLE ll_fleet_resource_assignment(resource_kind VARCHAR(16),resource_id BIGINT,trip_id BIGINT,trip_no VARCHAR(64))");
jdbc.execute("CREATE TABLE ll_fleet_trip_dispatch(trip_id BIGINT,dispatch_no VARCHAR(64))");
var factory = new MybatisSqlSessionFactoryBean();
factory.setDataSource(ds);
factory.setConfiguration(configuration);
@@ -53,12 +56,25 @@ class DriverPersistenceTest {
var target = new DriverService(session.getMapper(DriverMapper.class), session.getMapper(DriverHistoryMapper.class),
mock(DriverFileGateway.class), new ObjectMapper().registerModule(new JavaTimeModule()),
Clock.fixed(Instant.parse("2026-10-02T12:00:00Z"), ZoneOffset.UTC), mock(FleetWarehouseDirectory.class));
org.springframework.test.util.ReflectionTestUtils.setField(target, "availability", new com.baiyue.lastleg.fleet.trip.DriverAvailabilityRepository(session.getMapper(com.baiyue.lastleg.fleet.trip.FleetResourceMapper.class)));
var proxy = new ProxyFactory(target);
proxy.setProxyTargetClass(true);
proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(ds), new AnnotationTransactionAttributeSource()));
service = (DriverService) proxy.getProxy();
}
@Test void dispatchCandidatesExcludeOtherTripsBeforePagingButKeepOwnDriver() {
var own = service.create("operator", draft("本车司机"));
var occupied = service.create("operator", draft("其他车司机"));
jdbc.update("INSERT INTO ll_fleet_resource_assignment VALUES('DRIVER',?,1,'TR1')", Long.parseLong(own.id()));
jdbc.update("INSERT INTO ll_fleet_resource_assignment VALUES('DRIVER',?,2,'TR2')", Long.parseLong(occupied.id()));
jdbc.update("INSERT INTO ll_fleet_trip_dispatch VALUES(1,'PC1')");
var result = service.page(new DriverModels.Query(1,1,null,null,null,null,null,null,null,"PC1"));
assertThat(result.total()).isEqualTo(1);
assertThat(result.records()).extracting(DriverModels.Response::id).containsExactly(own.id());
assertThat(service.page(new DriverModels.Query(1,20,null,null,null,null,null,null,null, null)).total()).isEqualTo(2);
}
@Test void historyFailureRollsBackDriverStatusAndVersion() {
var driver = service.create("operator", draft("初始备注"));
jdbc.execute("ALTER TABLE ll_fleet_driver_history ADD CONSTRAINT reject_deactivate CHECK(action <> 'DEACTIVATE')");
@@ -110,16 +126,16 @@ class DriverPersistenceTest {
service.create("operator", eastern);
service.create("operator", western);
service.create("operator", draft(null));
var all = service.page(new DriverModels.Query(1, 1, null, null, null, null, null, null, null));
var all = service.page(new DriverModels.Query(1, 1, null, null, null, null, null, null, null, null));
assertThat(all.total()).isEqualTo(3);
assertThat(all.records()).hasSize(1);
assertThat(all.summary()).isEqualTo(new DriverModels.Summary(3, 2, 1, 1, 1));
var expired = service.page(new DriverModels.Query(1, 20, null, null, null, null, null, "EXPIRED", null));
var expired = service.page(new DriverModels.Query(1, 20, null, null, null, null, null, "EXPIRED", null, null));
assertThat(expired.records()).extracting(row -> row.details().name()).containsExactly("东侧日期");
assertThat(expired.summary().expired()).isEqualTo(1);
var expiring = service.page(new DriverModels.Query(1, 20, null, null, null, null, null, "EXPIRING", null));
var expiring = service.page(new DriverModels.Query(1, 20, null, null, null, null, null, "EXPIRING", null, null));
assertThat(expiring.records()).extracting(DriverModels.Response::licenseState).containsExactly("EXPIRING");
var missing = service.page(new DriverModels.Query(1, 20, null, null, null, null, null, "MISSING", null));
var missing = service.page(new DriverModels.Query(1, 20, null, null, null, null, null, "MISSING", null, null));
assertThat(missing.total()).isEqualTo(1);
assertThat(missing.summary().expiringSoon()).isZero();
}
@@ -0,0 +1,45 @@
package com.baiyue.lastleg.fleet.driver;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/** 经真实 MVC 参数绑定验证列表入口,不能用直接调用 Service 代替。 */
class DriverQueryBindingTest {
@Test void emptyListQueryBindsDefaults() throws Exception {
var service = mock(DriverService.class);
var mvc = MockMvcBuilders.standaloneSetup(new PlatformDriverController(service)).build();
mvc.perform(get("/api/platform/v1/last-leg/fleet/drivers")).andExpect(status().isOk());
var argument = ArgumentCaptor.forClass(DriverModels.Query.class);
verify(service).page(argument.capture());
assertThat(argument.getValue().page()).isEqualTo(1);
assertThat(argument.getValue().size()).isEqualTo(20);
assertThat(argument.getValue().dispatchNo()).isNull();
}
@Test void dispatchCandidatesBindFiltersAndExpectedEnd() throws Exception {
var service = mock(DriverService.class);
var mvc = MockMvcBuilders.standaloneSetup(new PlatformDriverController(service)).build();
mvc.perform(get("/api/platform/v1/last-leg/fleet/drivers")
.param("page", "2").param("size", "100").param("keyword", "司机")
.param("active", "true").param("reviewStatus", "APPROVED")
.param("region", "OVERSEAS").param("dispatchNo", "PC1")
.param("expectedEnd", "2026-10-30T12:00:00Z")).andExpect(status().isOk());
var argument = ArgumentCaptor.forClass(DriverModels.Query.class);
verify(service).page(argument.capture());
var query = argument.getValue();
assertThat(query.page()).isEqualTo(2);
assertThat(query.size()).isEqualTo(100);
assertThat(query.keyword()).isEqualTo("司机");
assertThat(query.active()).isTrue();
assertThat(query.reviewStatus()).isEqualTo("APPROVED");
assertThat(query.region()).isEqualTo("OVERSEAS");
assertThat(query.dispatchNo()).isEqualTo("PC1");
assertThat(query.expectedEnd()).isEqualTo(Instant.parse("2026-10-30T12:00:00Z"));
}
}
@@ -48,21 +48,34 @@ class FleetTripPersistenceTest {
assertThatThrownBy(()->f.tx.execute(s->f.nativeFleetService.updatePlan(trip(f),"operator",new FleetTripModels.Plan(detail.trip().recordVersion(),Instant.now().plusSeconds(60),Instant.now().plusSeconds(120),List.of(new FleetTripModels.Stop("起点",null,null),new FleetTripModels.Stop("终点",null,"FBA:ONT8")),null,"改计划")))).hasMessageContaining("排车主单");
assertThat(f.db.queryForObject("SELECT record_version FROM ll_dispatch_order",Long.class)).isZero();
}
@Test void externalCargoCompletionAndTwoTaskEndsAreIndependentAndReplayable() throws Exception {
@Test void completionReleasesBothResourcesAndReplayKeepsLaterOccupancy() throws Exception {
var scenario=new CompletedCargo();var f=scenario.f;var inspection=scenario.completion.inspection(trip(f));assertThat(inspection.canComplete()).isTrue();
var request=new FleetCompletionModels.Complete(inspection.recordVersion(),"complete-original","全部货物实际履约");
var complete=f.tx.execute(s->scenario.completion.complete(trip(f),"operator",request));assertThat(complete.trip().status()).isEqualTo("COMPLETED");
FleetTripModels.Detail recovered=f.tx.execute(s->scenario.completion.complete(trip(f),"operator",request));assertThat(recovered).isEqualTo(complete);
assertThat(complete.trip().vehicleRelease()).isNull();assertThat(complete.trip().driverRelease()).isNull();
var end=Instant.now().minusSeconds(10);var vehicle=new FleetCompletionModels.Release(complete.trip().recordVersion(),"VEHICLE",end,"实际交车点","RETURNED","vehicle-end-original","外配车实际结束");
var released=f.tx.execute(s->scenario.completion.release(trip(f),"operator",vehicle));assertThat(released.trip().vehicleRelease()).isNotNull();assertThat(released.trip().driverRelease()).isNull();
var driver=new FleetCompletionModels.Release(released.trip().recordVersion(),"DRIVER",end,"实际任务终点",null,"driver-end-original","司机实际结束");
var finished=f.tx.execute(s->scenario.completion.release(trip(f),"operator",driver));assertThat(finished.trip().driverRelease()).isNotNull();
assertThat(f.tx.execute(s->scenario.completion.release(trip(f),"operator",vehicle)).trip().recordVersion()).isEqualTo(finished.trip().recordVersion());
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment",Long.class)).isZero();verifyNoInteractions(scenario.vehicleService);
assertThat(complete.trip().vehicleRelease()).isNotNull();assertThat(complete.trip().driverRelease()).isNotNull();
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment",Long.class)).isZero();
f.db.update("INSERT INTO ll_fleet_resource_assignment(resource_kind,resource_id,trip_id,trip_no) VALUES('DRIVER',2,999,'NEXT-TRIP')");
f.tx.execute(s->{scenario.completion.completeDelivered(trip(f),"SYSTEM");return null;});
assertThat(f.db.queryForObject("SELECT trip_no FROM ll_fleet_resource_assignment WHERE resource_kind='DRIVER' AND resource_id=2",String.class)).isEqualTo("NEXT-TRIP");
verifyNoInteractions(scenario.vehicleService);
}
/** 自动完成与资源占用、历史共同回滚,重试才真正释放。 */
@Test void automaticDeliveryCompletionRollsBackWithItsCallerAndRetries() throws Exception {
var scenario=new CompletedCargo();var f=scenario.f;
assertThatThrownBy(()->f.tx.execute(s->{scenario.completion.completeDelivered(trip(f),"SYSTEM");throw new IllegalStateException("模拟后续失败");})).isInstanceOf(IllegalStateException.class);
assertThat(f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("IN_TRANSIT");
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment",Long.class)).isEqualTo(2);
f.tx.execute(s->{scenario.completion.completeDelivered(trip(f),"SYSTEM");return null;});
var result=f.nativeFleetService.detail(trip(f));
assertThat(result.trip().status()).isEqualTo("COMPLETED");
assertThat(result.trip().vehicleRelease()).isNotNull();assertThat(result.trip().driverRelease()).isNotNull();
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment",Long.class)).isZero();
}
@Test void partialSignatureCannotCloseCargoOrEndExternalTasks() throws Exception {
var scenario=new CompletedCargo();var f=scenario.f;f.db.update("UPDATE ll_delivery_order SET signed_at=NULL,signed_packages=NULL,status='DELIVERING'");
f.tx.execute(s->{scenario.completion.completeDelivered(trip(f),"SYSTEM");return null;});
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment",Long.class)).isEqualTo(2);
var inspection=scenario.completion.inspection(trip(f));assertThat(inspection.canComplete()).isFalse();
assertThatThrownBy(()->f.tx.execute(s->scenario.completion.complete(trip(f),"operator",new FleetCompletionModels.Complete(inspection.recordVersion(),"partial-cannot-close","未全签")))).isInstanceOf(RuntimeException.class);
assertThatThrownBy(()->f.tx.execute(s->scenario.completion.release(trip(f),"operator",new FleetCompletionModels.Release(inspection.recordVersion(),"DRIVER",Instant.now().minusSeconds(1),"未结束",null,"cannot-end-driver","尚未履约")))).isInstanceOf(RuntimeException.class);
@@ -81,6 +94,12 @@ class FleetTripPersistenceTest {
f=new Fixture();var created=f.tx.execute(s->f.service.create(f.actor,f.configured("fleet-completion",List.of(f.source(1,"101",501,"ONT8",3)))));f.approve(created);f.picked(1,"101",501,3);
f.db.update("UPDATE ll_dispatch_order SET stage='DELIVERING',picked_package_count=3,shipped_package_count=3,departed_at=?",LocalDateTime.now(ZoneOffset.UTC).minusMinutes(10));
f.db.update("UPDATE ll_fleet_trip SET status='IN_TRANSIT',departed_at=?",LocalDateTime.now(ZoneOffset.UTC).minusMinutes(10));
// 为自动释放建立真实占用行,避免只验证没有资源的外配车次。
var drivers=(com.baiyue.lastleg.fleet.driver.DriverService)org.springframework.test.util.ReflectionTestUtils.getField(f.nativeFleetService,"drivers");
when(drivers.detail(org.mockito.ArgumentMatchers.anyLong(),org.mockito.ArgumentMatchers.any())).thenReturn(mock(com.baiyue.lastleg.fleet.driver.DriverModels.Response.class));
f.db.update("UPDATE ll_fleet_trip SET vehicle_id=1,driver_id=2");
f.db.update("INSERT INTO ll_fleet_resource_assignment SELECT 'VEHICLE',1,id,trip_no FROM ll_fleet_trip");
f.db.update("INSERT INTO ll_fleet_resource_assignment SELECT 'DRIVER',2,id,trip_no FROM ll_fleet_trip");
var root=f.repository.find(created.dispatchNo());var outbound=new OutboundOrder();outbound.setId(600L);outbound.setCustomerId("101");outbound.setOutboundNo("OB-REAL");outbound.setDispatchOrderId(root.getId());outbound.setOverseasWarehouseId("9");outbound.setStatus("OUTBOUNDED");outbound.setPackageCount(3);f.session.getMapper(OutboundOrderMapper.class).insert(outbound);
var delivery=new DeliveryOrder();delivery.setId(700L);delivery.setCustomerId("101");delivery.setDeliveryNo("DL-REAL");delivery.setDispatchOrderId(root.getId());delivery.setOutboundOrderId(600L);delivery.setOverseasWarehouseId("9");delivery.setStatus("DELIVERED");delivery.setPackageCount(3);delivery.setSignedPackages(3);delivery.setSignedAt(LocalDateTime.now().minusMinutes(5));delivery.setVersion(0L);delivery.setDestinationType("FBA");delivery.setDestinationWarehouseCode("ONT8");f.session.getMapper(DeliveryOrderMapper.class).insert(delivery);
completion=new FleetTripCompletionService(f.session.getMapper(FleetTripMapper.class),f.session.getMapper(FleetTripDispatchMapper.class),f.session.getMapper(FleetResourceMapper.class),(FleetDispatchGateway)org.springframework.test.util.ReflectionTestUtils.getField(f.nativeFleetService,"dispatches"),f.nativeFleetService,mock(VehicleRepository.class),vehicleService,f.json);
@@ -17,7 +17,8 @@ class OverseasProcurementOpenApiContractTest {
}
String platform = "/api/platform/v1/last-leg/overseas-procurements/{orderNo}";
for (String suffix : new String[]{"/workflow:", "/receipts:", "/request-confirmation:",
"/outbounds:", "/outbounds/{batchId}/dispatch:", "/outbounds/{batchId}/complete:"}) {
"/outbounds:", "/outbounds/{batchId}:", "/outbounds/{batchId}/cancel:",
"/outbounds/{batchId}/dispatch:", "/outbounds/{batchId}/complete:"}) {
assertTrue(yaml.contains(platform + suffix), suffix);
}
String merchant = "/api/merchant/v1/last-leg/overseas-procurements/{orderNo}";
@@ -35,6 +35,8 @@ class ProcurementWorkflowAuthorizationTest {
controller.read(user,"9","CG-1");verify(service).read("9","CG-1");
assertThatThrownBy(()->controller.receive(user,"9","CG-1",new Receipt(0,"r",1,List.of(1L)))).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(()->controller.outbound(user,"9","CG-1",new Outbound(0,"o",1,"TN"))).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(()->controller.editOutbound(user,"9","CG-1",1,new OutboundEdit(0,"edit",1,"TN"))).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(()->controller.cancelOutbound(user,"9","CG-1",1,new Command(0,"cancel"))).isInstanceOf(AccessDeniedException.class);
}
@Test void receiverCanSubmitConfirmationButCannotShip() {
var controller=context.getBean(ProcurementWorkflowController.class);
@@ -56,6 +58,7 @@ class ProcurementWorkflowAuthorizationTest {
var user=login(9,"MERCHANT","merchant:cross-border:procurement:list");
merchant.read(user,"CG-1");verify(service).read("9","CG-1");
assertThatThrownBy(()->merchant.confirm(user,"CG-1",new Command(0,"c"))).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(()->merchant.reject(user,"CG-1",new Rejection(0,"r","照片不符合要求"))).isInstanceOf(AccessDeniedException.class);
}
private AuthenticatedUser login(long customer,String app,String permission) {
var user=new AuthenticatedUser(7L,customer,customer,8L,"operator",app,"WEB",Set.of(permission));
@@ -7,8 +7,6 @@ import com.baiyue.lastleg.procurement.entity.*;
import com.baiyue.lastleg.procurement.mapper.*;
import com.baiyue.lastleg.procurement.repository.ProcurementWorkflowRepository;
import com.baiyue.lastleg.service.LastlegDocumentNumberGenerator;
import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService;
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.*;
import org.mybatis.spring.SqlSessionTemplate;
@@ -33,7 +31,6 @@ class ProcurementWorkflowPersistenceTest {
OverseasProcurementFileMapper files;
OverseasProcurementStatusHistoryMapper histories;
ProcurementWorkflowRepository repository;
OverseasFeeSourceService fees;
@BeforeEach void setup() throws Exception {
var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1","sa","");
@@ -49,13 +46,32 @@ class ProcurementWorkflowPersistenceTest {
histories=session.getMapper(OverseasProcurementStatusHistoryMapper.class);
repository=new ProcurementWorkflowRepository(session.getMapper(ProcurementWorkflowMapper.class),orders,files);
var numbers=mock(LastlegDocumentNumberGenerator.class);var sequence=new AtomicInteger();when(numbers.next("CK")).thenAnswer(i->"CK-"+sequence.incrementAndGet());
fees=mock(OverseasFeeSourceService.class);
when(fees.listForBusiness("9","OVERSEAS_PROCUREMENT",11L)).thenReturn(List.of(fee("ACTIVE","APPROVED")));
service=new ProcurementWorkflowService(repository,numbers,new ObjectMapper().findAndRegisterModules(),fees);
service=new ProcurementWorkflowService(repository,numbers,new ObjectMapper().findAndRegisterModules());
var order=new OverseasProcurementOrder();order.setId(11L);order.setCustomerId("9");order.setOrderNo("CG-1");order.setProductName("采购商品");order.setPurchaseCategory("");order.setPurchaseQuantity(10L);order.setCurrency("USD");order.setStatus("RECEIVING");order.setVersion(0L);order.setInboundQuantity(0L);order.setContactName("buyer");order.setContactPhone("123456789");order.setIdempotencyKey("create");order.setRequestHash("0".repeat(64));orders.insert(order);
file(101L,11L,"9","image/jpeg");file(102L,11L,"9","application/pdf");file(103L,12L,"10","image/jpeg");file(104L,11L,"9","image/png");
}
/** 驳回后必须全批换新照片,返工不能重复增加库存。 */
@Test void rejectionRequiresNewPhotosWithoutChangingStock() {
tx.execute(t->service.receive("9","CG-1","7",new Receipt(0,"receipt",10,List.of(101L))));
tx.execute(t->service.requestConfirmation("9","CG-1","7",new Command(1,"ask")));
assertThatThrownBy(()->tx.execute(t->service.reject("9","CG-1","8",new Rejection(2,"empty"," ")))).hasMessageContaining("原因");
assertThatThrownBy(()->tx.execute(t->service.reject("10","CG-1","8",new Rejection(2,"other","破损")))).hasMessageContaining("不属于");
var request=new Rejection(2,"reject","照片不清晰");
var rejected=tx.execute(t->service.reject("9","CG-1","8",request));
assertThat(rejected.status()).isEqualTo("RECEIVING");assertThat(rejected.rejectionReason()).isEqualTo("照片不清晰");assertThat(rejected.photosNeedUpdate()).isTrue();
assertThat(tx.execute(t->service.reject("9","CG-1","8",request)).version()).isEqualTo(3);
assertThatThrownBy(()->tx.execute(t->service.requestConfirmation("9","CG-1","7",new Command(3,"early")))).hasMessageContaining("重新上传");
long receipt=rejected.receipts().get(0).id();
assertThatThrownBy(()->tx.execute(t->service.replacePhotos("9","CG-1","7",new ReceiptPhotos(3,"old",receipt,List.of(101L))))).hasMessageContaining("不能复用");
assertThatThrownBy(()->tx.execute(t->service.replacePhotos("9","CG-1","7",new ReceiptPhotos(3,"old-upload",receipt,List.of(104L))))).hasMessageContaining("重新上传");
file(105L,11L,"9","image/png");
var current=tx.execute(t->service.replacePhotos("9","CG-1","7",new ReceiptPhotos(3,"new",receipt,List.of(105L))));
assertThat(current.photosNeedUpdate()).isFalse();assertThat(current.stockQuantity()).isEqualTo(10);assertThat(current.receipts()).hasSize(1);
tx.execute(t->service.requestConfirmation("9","CG-1","7",new Command(4,"again")));
assertThat(tx.execute(t->service.confirm("9","CG-1","8",new Command(5,"confirm"))).status()).isEqualTo("CONFIRMED");
}
@Test void twoReceiptsCustomerConfirmationAndTwoOutboundsDeductOnlyAtCompletion() {
var first=new Receipt(0,"receive1",4,List.of(101L));
var current=tx.execute(s->service.receive("9","CG-1","7",first));
@@ -131,34 +147,29 @@ class ProcurementWorkflowPersistenceTest {
assertThat(service.read("9","CG-1").stockQuantity()).isEqualTo(10);
}
@Test void outboundRequiresCurrentReviewedReceivablesAndRejectedAttemptsLeaveNoInventoryChanges() {
@Test void outboundCanBeEditedOrCancelledBeforeDispatchButNotAfter() {
tx.execute(s->service.receive("9","CG-1","7",new Receipt(0,"r",10,List.of(101L))));
tx.execute(s->service.requestConfirmation("9","CG-1","7",new Command(1,"ask")));
tx.execute(s->service.confirm("9","CG-1","8",new Command(2,"ok")));
var request=new Outbound(3,"out",3,"TRACK");
for(var rows:List.of(List.<OverseasFeeSource>of(),List.of(fee("VOIDED","APPROVED")))) {
when(fees.listForBusiness("9","OVERSEAS_PROCUREMENT",11L)).thenReturn(rows);
assertThatThrownBy(()->tx.execute(s->service.outbound("9","CG-1","7",request))).hasMessageContaining("请先添加费用明细");
}
var payable=fee("ACTIVE","APPROVED");payable.setDirection("PAYABLE");
when(fees.listForBusiness("9","OVERSEAS_PROCUREMENT",11L)).thenReturn(List.of(payable));
assertThatThrownBy(()->tx.execute(s->service.outbound("9","CG-1","7",request))).hasMessageContaining("请先添加费用明细");
for(String review:List.of("PENDING","REJECTED")) {
when(fees.listForBusiness("9","OVERSEAS_PROCUREMENT",11L)).thenReturn(List.of(fee("ACTIVE","APPROVED"),fee("ACTIVE",review)));
assertThatThrownBy(()->tx.execute(s->service.outbound("9","CG-1","7",request))).hasMessageContaining("尚未全部审核通过");
}
assertThat(service.read("9","CG-1").version()).isEqualTo(3);
assertThat(service.read("9","CG-1").reservedQuantity()).isZero();
assertThat(service.read("9","CG-1").outbounds()).isEmpty();
when(fees.listForBusiness("9","OVERSEAS_PROCUREMENT",11L)).thenReturn(List.of(fee("ACTIVE","APPROVED"),fee("VOIDED","REJECTED")));
var result=tx.execute(s->service.outbound("9","CG-1","7",request));
assertThat(result.reservedQuantity()).isEqualTo(3);
when(fees.listForBusiness("9","OVERSEAS_PROCUREMENT",11L)).thenReturn(List.of());
assertThat(tx.execute(s->service.outbound("9","CG-1","7",request)).version()).isEqualTo(4);
}
private OverseasFeeSource fee(String status,String review) {
var fee=new OverseasFeeSource();fee.setDirection("RECEIVABLE");fee.setSourceStatus(status);fee.setReviewStatus(review);return fee;
assertThat(result.outbounds()).hasSize(1);
long batch=result.outbounds().get(0).id();
var edited=tx.execute(s->service.editOutbound("9","CG-1",batch,"7",new OutboundEdit(4,"edit",4,"TRACK-EDIT")));
assertThat(edited.outbounds().get(0).quantity()).isEqualTo(4);
assertThat(edited.outbounds().get(0).trackingNo()).isEqualTo("TRACK-EDIT");
var cancelled=tx.execute(s->service.cancelOutbound("9","CG-1",batch,"7",new Command(5,"cancel")));
assertThat(cancelled.outbounds().get(0).status()).isEqualTo("CANCELLED");
assertThat(cancelled.reservedQuantity()).isZero();
assertThat(cancelled.availableQuantity()).isEqualTo(10);
var next=tx.execute(s->service.outbound("9","CG-1","7",new Outbound(6,"out2",3,"TRACK-2")));
long activeBatch=next.outbounds().stream().filter(row->"IN_TRANSIT".equals(row.status())).findFirst().orElseThrow().id();
tx.execute(s->service.dispatch("9","CG-1",activeBatch,"7",new Command(7,"dispatch")));
assertThatThrownBy(()->tx.execute(s->service.editOutbound("9","CG-1",activeBatch,"7",new OutboundEdit(8,"edit-late",2,"TRACK-LATE"))))
.hasMessageContaining("开始派送后");
assertThatThrownBy(()->tx.execute(s->service.cancelOutbound("9","CG-1",activeBatch,"7",new Command(8,"cancel-late"))))
.hasMessageContaining("开始派送后");
}
private void file(long id,long orderId,String customer,String type) {
@@ -142,6 +142,31 @@ class RelabelApplicationServiceTest {
assertEquals(0, occupied.cartonAvailablePackages());
assertEquals(2, occupied.skuItems().size(), "处理中库存保留占用信息,但不可再次选择");
}
@Test void sharedCandidatesExcludeDropshipButKeepOrdinaryInbound() {
ReflectionTestUtils.setField(service, "returnCartons", org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper.class));
when(orders.listDropshipInboundCodes("10001", "WH-1")).thenReturn(List.of("DROP-1"));
var excluded = new InventoryBalance(); excluded.setId(1L); excluded.setInboundCode("DROP-1");
var ordinary = new InventoryBalance(); ordinary.setId(2L); ordinary.setInboundCode("NORMAL-1");
when(inventoryBalances.selectList(any())).thenReturn(List.of(excluded, ordinary));
var position = new InventoryPosition(); position.setId(3L); position.setBalanceId(2L); position.setAvailablePackages(4L);
when(inventoryPositions.listBalances("10001", List.of(2L))).thenReturn(List.of(position));
var result = service.candidates("10001", "WH-1", null);
assertEquals(List.of("NORMAL-1"), result.stream().map(RelabelResponses.InventoryCandidate::inboundCode).toList());
}
@Test void staleDropshipSelectionCannotCreateRelabelApplication() {
when(orders.listDropshipInboundCodes("10001", "WH-1")).thenReturn(List.of("CODE-1"));
var error = assertThrows(ResponseStatusException.class,
() -> service.create("10001", "operator-1", request("dropship", 1, 1)));
assertEquals("一件代发订单不支持贴标申请", error.getReason());
verify(applications, never()).insert(any(RelabelApplication.class));
verifyNoInteractions(inventoryCommands);
}
@Test void convertedSkuRemainsAvailableForCartonLabelOnly() {
var cartons = org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper.class);
var lines = org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper.class);
@@ -245,6 +270,7 @@ class RelabelApplicationServiceTest {
ReflectionTestUtils.setField(service, "walletCommands", walletCommands);
ReflectionTestUtils.setField(service, "inventoryCommands", inventoryCommands);
ReflectionTestUtils.setField(service, "inventoryBalances", inventoryBalances);
ReflectionTestUtils.setField(service, "sourceRepository", new com.baiyue.lastleg.relabel.repository.RelabelSourceRepository(orders));
ReflectionTestUtils.setField(service, "inventoryPositions", inventoryPositions);
ReflectionTestUtils.setField(service, "outboundOrders", outboundOrders);
ReflectionTestUtils.setField(service, "outboundItems", outboundItems);
@@ -60,6 +60,7 @@ class RelabelDispatchSourcePersistenceTest {
source_inventory_balance_id BIGINT, source_inventory_position_id BIGINT,
source_inbound_order_id BIGINT, source_inbound_order_no VARCHAR(80), source_inbound_code VARCHAR(80))
""");
jdbc.execute("CREATE TABLE ll_cargo_discard_carton(customer_id VARCHAR(40),carton_id BIGINT,source_type VARCHAR(32))");
jdbc.execute("""
CREATE TABLE ll_inventory_position (
id BIGINT PRIMARY KEY, customer_id VARCHAR(40), balance_id BIGINT,
@@ -0,0 +1,31 @@
package com.baiyue.lastleg.relabel.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.relabel.repository.RelabelSourceRepository;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import static org.assertj.core.api.Assertions.assertThat;
/** 用真实 Mapper 验证业务类型、客户和仓库隔离,避免跨来源误隐藏。 */
class RelabelSourceRepositoryTest {
@Test void onlyDropshipSourcesInCurrentCustomerAndWarehouseAreExcluded() throws Exception {
var ds = new DriverManagerDataSource("jdbc:h2:mem:relabel_sources;MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
var jdbc = new JdbcTemplate(ds);
jdbc.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(64),overseas_warehouse_id VARCHAR(64),order_type VARCHAR(64),inbound_code VARCHAR(64))");
jdbc.execute("INSERT INTO ll_order VALUES (1,'C1','W1','ONE_CLICK_DROPSHIP','DROP'),(2,'C1','W1','WAREHOUSE_TRANSFER','NORMAL'),(3,'C2','W1','ONE_CLICK_DROPSHIP','FOREIGN'),(4,'C1','W2','ONE_CLICK_DROPSHIP','OTHER-WAREHOUSE')");
var configuration = new MybatisConfiguration();
configuration.addMapper(LastlegOrderMapper.class);
var factory = new MybatisSqlSessionFactoryBean();
factory.setDataSource(ds); factory.setConfiguration(configuration);
var repository = new RelabelSourceRepository(new SqlSessionTemplate(factory.getObject()).getMapper(LastlegOrderMapper.class));
assertThat(repository.dropshipInboundCodes("C1", "W1")).containsExactly("DROP");
assertThat(repository.dropshipInboundCodes("C2", "W1")).containsExactly("FOREIGN");
assertThat(repository.dropshipInboundCodes("C1", "W2")).containsExactly("OTHER-WAREHOUSE");
}
}
@@ -1283,6 +1283,20 @@ class WarehouseTransferServiceTest {
verify(orders, never()).insert(any(LastlegOrder.class));
}
@Test
void staleDropshipSelectionCannotCreateWarehouseTransfer() {
when(inboundSnapshots.findId("TENANT-A", "IN-1")).thenReturn(101L);
var dropship = historicalOrder(); dropship.setOrderType("ONE_CLICK_DROPSHIP");
when(orders.selectList(any())).thenReturn(List.of(dropship));
var error = assertThrows(ResponseStatusException.class,
() -> service.create("TENANT-A", "USER-1", request(false)));
assertEquals("一件代发订单不支持仓储转运", error.getReason());
verify(transfers, never()).insert(any(WarehouseTransferOrder.class));
verifyNoInteractions(inventory);
}
@Test
void missingOrForeignCustomerNameStopsCreationBeforeAnyBusinessWrites() {
for (var customer : List.of(
@@ -16,7 +16,7 @@ class UsTruckOrderRequestsTest {
var request = new UsTruckOrderRequests.Create(null, "LA", null, "ONT", null, 0,
BigDecimal.ZERO, BigDecimal.ZERO, null, "USD", " ", null, null, "", null, null, null, "required");
var fields = factory.getValidator().validate(request).stream().map(v -> v.getPropertyPath().toString()).collect(Collectors.toSet());
assertEquals(java.util.Set.of("vehicleType", "originContactName", "originContactPhone", "destinationContactName", "destinationContactPhone", "pickupAt", "deliveryAt"), fields);
assertEquals(java.util.Set.of("vehicleType", "originContactName", "originContactPhone", "destinationContactName", "destinationContactPhone", "pickupAt", "deliveryAt", "palletCount", "budgetAmount"), fields);
}
}
@@ -25,8 +25,8 @@ class UsTruckOrderRequestsTest {
void onlyCurrentVehicleTypesAreAccepted() {
try (var factory = Validation.buildDefaultValidatorFactory()) {
for (String vehicle : java.util.List.of("PALLET_15", "PALLET_30", "OTHER")) {
var request = new UsTruckOrderRequests.Create(vehicle, "LA", null, "ONT", null, 0,
BigDecimal.ZERO, BigDecimal.ZERO, null, "USD", "A", "+1 (555) 0100", "B", "+1-555-0101",
var request = new UsTruckOrderRequests.Create(vehicle, "LA", null, "ONT", null, 1,
BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ONE, "USD", "A", "+1 (555) 0100", "B", "+1-555-0101",
LocalDateTime.of(2026, 10, 20, 9, 30), LocalDateTime.of(2026, 10, 20, 15, 45), null, "vehicle");
assertEquals(!vehicle.equals("OTHER"), factory.getValidator().validate(request).isEmpty());
}
@@ -91,7 +91,7 @@ class UsTruckOrderServiceTest {
assertEquals("+1-555-0101", detail.summary().destinationContactPhone());
assertEquals(request.pickupAt(), detail.summary().pickupAt());
assertEquals(request.deliveryAt(), detail.summary().deliveryAt());
assertEquals(0, detail.summary().palletCount());
assertEquals(3, detail.summary().palletCount());
assertTrue(detail.pallets().isEmpty());
assertThrows(ResponseStatusException.class, () -> service.create("100", "operator", create("OTHER", "invalid")));
}
@@ -108,7 +108,7 @@ class UsTruckOrderServiceTest {
@Test
void reversedTimesAreRejectedBeforeCreatingAnOrder() {
var request = create("PALLET_30", "reversed");
var invalid = new UsTruckOrderRequests.Create(request.vehicleType(), request.origin(), request.originAddress(), request.destination(), request.destinationAddress(), request.packageCount(), request.totalWeightKg(), request.totalVolumeCbm(), request.budgetAmount(), request.currency(), request.originContactName(), request.originContactPhone(), request.destinationContactName(), request.destinationContactPhone(), request.deliveryAt(), request.pickupAt(), request.remark(), request.idempotencyKey());
var invalid = new UsTruckOrderRequests.Create(request.vehicleType(), request.origin(), request.originAddress(), request.destination(), request.destinationAddress(), request.palletCount(), request.totalWeightKg(), request.totalVolumeCbm(), request.budgetAmount(), request.currency(), request.originContactName(), request.originContactPhone(), request.destinationContactName(), request.destinationContactPhone(), request.deliveryAt(), request.pickupAt(), request.remark(), request.idempotencyKey());
assertThrows(ResponseStatusException.class, () -> service.create("100", "operator", invalid));
}
@@ -169,7 +169,7 @@ class UsTruckOrderServiceTest {
private UsTruckOrderRequests.Create create(String vehicleType, String key) {
return new UsTruckOrderRequests.Create(vehicleType, "Los Angeles", null, "Ontario", null,
0, BigDecimal.ZERO, BigDecimal.ZERO, null, "USD", "Alice", "+1-555-0100", "Bob", "+1-555-0101",
3, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.TEN, "USD", "Alice", "+1-555-0100", "Bob", "+1-555-0101",
LocalDateTime.of(2026, 10, 20, 9, 30), LocalDateTime.of(2026, 10, 20, 15, 45), null, key);
}
@@ -0,0 +1,46 @@
package com.baiyue.lastleg.wallet.budget;
import com.baiyue.common.security.AuthenticatedUser;
import org.junit.jupiter.api.*;
import org.springframework.context.annotation.*;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.util.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
/** 预算入口不允许跨业务借用权限或由平台身份指定客户资金。 */
class OrderBudgetAuthorizationTest {
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(Config.class);
@Configuration @EnableMethodSecurity(proxyTargetClass=true)
static class Config {
@Bean OrderBudgetService service() { return mock(OrderBudgetService.class); }
@Bean MerchantOrderBudgetController controller(OrderBudgetService service) { return new MerchantOrderBudgetController(service); }
}
@AfterEach void cleanup() { SecurityContextHolder.clearContext();context.close(); }
@Test void procurementPermissionCannotFreezeTruckAndOnlyUsesAuthenticatedCustomer() {
var controller=context.getBean(MerchantOrderBudgetController.class);
var user=login(9,"MERCHANT","merchant:cross-border:procurement:create");
var request=new MerchantOrderBudgetController.Prepare(BigDecimal.TEN,"USD");
controller.prepare(user,"procurement","key",request);
verify(context.getBean(OrderBudgetService.class)).prepare("9","procurement","key",BigDecimal.TEN,"USD");
assertThatThrownBy(()->controller.prepare(user,"trucking","key",request)).isInstanceOf(AccessDeniedException.class);
}
@Test void readPermissionCannotReserveAndPlatformCannotImpersonateCustomer() {
var controller=context.getBean(MerchantOrderBudgetController.class);
var reader=login(9,"MERCHANT","merchant:cross-border:procurement:list");
assertThatThrownBy(()->controller.read(reader,"procurement","key")).isInstanceOf(AccessDeniedException.class);
var platform=login(9,"PLATFORM","merchant:cross-border:procurement:create");
assertThatThrownBy(()->controller.read(platform,"procurement","key")).isInstanceOf(ResponseStatusException.class);
verifyNoInteractions(context.getBean(OrderBudgetService.class));
}
private AuthenticatedUser login(long customer,String app,String permission) {
var user=new AuthenticatedUser(7L,customer,customer,8L,"operator",app,"WEB",Set.of(permission));
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user,"",List.of(new SimpleGrantedAuthority(permission))));return user;
}
}
@@ -0,0 +1,55 @@
package com.baiyue.lastleg.wallet.budget;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import org.junit.jupiter.api.*;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import java.math.BigDecimal;
import java.nio.file.*;
import java.time.LocalDateTime;
import java.util.UUID;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.*;
/** 用真实 Mapper 验证客户隔离、持久化及到期/终态释放候选。 */
class OrderBudgetPersistenceTest {
OrderBudgetRepository repository;
JdbcTemplate jdbc;
@BeforeEach void setup() throws Exception {
var ds = new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
jdbc = new JdbcTemplate(ds);
String sql = Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
var match = Pattern.compile("(?s)CREATE TABLE IF NOT EXISTS ll_order_budget_reservation \\(.*?;").matcher(sql);
assertThat(match.find()).isTrue();jdbc.execute(match.group());
jdbc.execute("CREATE TABLE ll_overseas_procurement_order(customer_id VARCHAR(64),order_no VARCHAR(64),status VARCHAR(32))");
jdbc.execute("CREATE TABLE ll_us_truck_order(customer_id VARCHAR(64),order_no VARCHAR(64),status VARCHAR(32))");
var config = new MybatisConfiguration();config.addMapper(OrderBudgetMapper.class);
var factory = new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
repository = new OrderBudgetRepository(new SqlSessionTemplate(factory.getObject()).getMapper(OrderBudgetMapper.class));
}
@Test void isolatesCustomerAndKeepsOriginalMoneyOnBinding() {
var row = row(1,"FROZEN");repository.insert(row);
assertThat(repository.lock("10","procurement","key1")).isNull();
assertThat(repository.lockId(1,"10")).isNull();
var loaded = repository.lock("9","procurement","key1");
loaded.setOrderNo("CG1");repository.save(loaded);
assertThat(repository.lockOrder("9","procurement","CG1").getOriginalAmount()).isEqualByComparingTo("100");
assertThat(repository.lockOrder("10","procurement","CG1")).isNull();
}
@Test void selectsExpiredTerminalAndFailedReleaseButKeepsLiveOrders() {
var expired = row(1,"FREEZE_PENDING");expired.setCreatedAt(LocalDateTime.now().minusMinutes(16));repository.insert(expired);
var terminal = row(2,"FROZEN");terminal.setOrderNo("CG2");repository.insert(terminal);
jdbc.update("INSERT INTO ll_overseas_procurement_order VALUES('9','CG2','COMPLETED')");
var failed = row(3,"RELEASE_FAILED");failed.setReleaseRequested(true);failed.setUpdatedAt(LocalDateTime.now().minusMinutes(2));repository.insert(failed);
repository.insert(row(4,"FROZEN"));
assertThat(repository.releasable()).extracting(OrderBudget::getId).containsExactly(1L,2L,3L);
}
private OrderBudget row(long id,String status) {
var row=new OrderBudget();row.setId(id);row.setCustomerId("9");row.setKind("procurement");row.setRequestKey("key"+id);
row.setOriginalAmount(new BigDecimal("100"));row.setCurrency("USD");row.setAmountCny(new BigDecimal("800"));
row.setStatus(status);row.setCommandVersion(1L);row.setReleaseRequested(false);
row.setCreatedAt(LocalDateTime.now());row.setUpdatedAt(LocalDateTime.now());return row;
}
}
@@ -0,0 +1,60 @@
package com.baiyue.lastleg.wallet.budget;
import com.baiyue.common.event.WalletCommandCompletedV1;
import com.baiyue.lastleg.financefee.service.OverseasFeeCurrencyConverter;
import com.baiyue.lastleg.wallet.WalletCommandOutboxService;
import org.junit.jupiter.api.*;
import java.math.BigDecimal;
import java.time.*;
import java.util.*;
import static org.mockito.Mockito.*;
import static org.assertj.core.api.Assertions.*;
/** 验证资金不足、金额篡改、过期在途回执及客户隔离不能绕过建单门禁。 */
class OrderBudgetServiceTest {
OrderBudgetRepository repository=mock(OrderBudgetRepository.class);
WalletCommandOutboxService wallet=mock(WalletCommandOutboxService.class);
OrderBudgetService service=new OrderBudgetService(repository,mock(OverseasFeeCurrencyConverter.class),wallet);
OrderBudget row;
@BeforeEach void setup() {
row=new OrderBudget();row.setId(11L);row.setCustomerId("9");row.setKind("procurement");row.setRequestKey("key");row.setOriginalAmount(new BigDecimal("100"));row.setCurrency("USD");row.setAmountCny(new BigDecimal("800"));row.setStatus("FREEZE_PENDING");row.setCommandEventId("freeze");row.setCommandVersion(1L);row.setReleaseRequested(false);row.setCreatedAt(LocalDateTime.now(ZoneOffset.UTC));
when(repository.lock("9","procurement","key")).thenReturn(row);when(repository.lockId(11L,"9")).thenReturn(row);
}
@Test void insufficientNeverCreatesOrderAndExplicitRetryUsesNewCommand() {
assertThatThrownBy(()->service.submit("9","procurement","key",new BigDecimal("100"),"USD",()->{throw new AssertionError("不得建单");},v->"CG1")).hasMessageContaining("处理中");
service.handle(result("freeze","FREEZE","FAILED",null,"可用余额和授信额度不足"));
assertThat(service.read("9","procurement","key").message()).contains("授信额度不足");
assertThatThrownBy(()->service.submit("9","procurement","key",new BigDecimal("100"),"USD",()->"CG1",v->v)).hasMessageContaining("授信额度不足");
when(wallet.publish(eq("FREEZE"),eq(9L),anyString(),eq("11"),any(),anyString(),eq(2L))).thenReturn("retry");
assertThat(service.prepare("9","procurement","key",new BigDecimal("100"),"USD").status()).isEqualTo("FREEZE_PENDING");assertThat(row.getCommandEventId()).isEqualTo("retry");
}
@Test void latestSuccessfulReceiptAndExactCustomerAmountCurrencyAreRequired() {
service.handle(result("old","FREEZE","SUCCEEDED","FROZEN",null));assertThat(row.getStatus()).isEqualTo("FREEZE_PENDING");
service.handle(result("freeze","FREEZE","SUCCEEDED","FROZEN",null));
assertThatThrownBy(()->service.submit("10","procurement","key",new BigDecimal("100"),"USD",()->"CG1",v->v)).hasMessageContaining("校验");
assertThatThrownBy(()->service.submit("9","procurement","key",new BigDecimal("200"),"USD",()->"CG1",v->v)).hasMessageContaining("变化");
assertThatThrownBy(()->service.submit("9","procurement","key",new BigDecimal("100"),"CNY",()->"CG1",v->v)).hasMessageContaining("变化");
assertThat(service.submit("9","procurement","key",new BigDecimal("100"),"USD",()->"CG1",v->v)).isEqualTo("CG1");assertThat(row.getOrderNo()).isEqualTo("CG1");
}
@Test void expiryWaitsForLateFreezeThenReleases() {
when(repository.releasable()).thenReturn(List.of(row));service.releaseFinished();assertThat(row.getReleaseRequested()).isTrue();
when(wallet.publish(eq("RELEASE"),eq(9L),anyString(),eq("11"),any(),anyString(),eq(2L))).thenReturn("release");
service.handle(result("freeze","FREEZE","SUCCEEDED","FROZEN",null));assertThat(row.getStatus()).isEqualTo("RELEASE_PENDING");
service.handle(result("release","RELEASE","SUCCEEDED","RELEASED",null));assertThat(row.getStatus()).isEqualTo("RELEASED");
}
@Test void failedReleaseRetriesWithANewVersionAndBlocksSubmission() {
row.setStatus("RELEASE_FAILED");row.setReleaseRequested(true);
when(repository.releasable()).thenReturn(List.of(row));
when(wallet.publish(eq("RELEASE"),eq(9L),anyString(),eq("11"),any(),anyString(),eq(2L))).thenReturn("release-retry");
service.releaseFinished();
assertThat(row.getStatus()).isEqualTo("RELEASE_PENDING");
assertThat(row.getCommandEventId()).isEqualTo("release-retry");
assertThat(service.read("9","procurement","key").usable()).isFalse();
}
@Test void expiredFrozenBudgetIsNeverReportedUsable() {
row.setStatus("FROZEN");row.setCreatedAt(LocalDateTime.now(ZoneOffset.UTC).minusMinutes(16));
assertThat(service.read("9","procurement","key").usable()).isFalse();
assertThat(service.read("9","procurement","key").message()).contains("过期");
}
private WalletCommandCompletedV1 result(String event,String operation,String outcome,String status,String error) {
return new WalletCommandCompletedV1(event,operation,9L,OrderBudgetService.BUSINESS_TYPE,"11",outcome,status,new BigDecimal("800"),null,error,Instant.now(),"hold",null);
}
}