diff --git a/deploy/sql/README.md b/deploy/sql/README.md index 784182525..1a7498180 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -4,7 +4,7 @@ 自有业务运营视图的结构在 Lastleg 源基线及海外统一空库基线新增四张附加表:`ll_fleet_owned_business_ownership/record/command/history`,分别保存原排车装车完成时的车辆归属证据、运营信息、命令与不可变历史;不新增排车主单或第二套运输状态。权限为 IAM `lastleg-permissions.sql` 中的自有业务 list/edit/upload,并同步国内统一空库基线。业务唯一规则见工作区 `docs/海外仓业务/自有业务首版开发契约.md`。本次仅准备源码基线,无 SQL 执行、存量回填或部署;旧已装车 TEST 数据不倒推历史归属,匹配版本按新原生样本验收。 -客户计费重独立确认新增 `fl_chargeable_weight_confirmation`,源表与国内空库基线同步;存量 TEST 使用 `migrations/cn/baiyue_firstleg/V8__chargeable_weight_confirmation.sql`,不回填费用确认。须随 Firstleg、Order、Lastleg、Admin 和 Client 匹配版本核对;本次未执行 SQL、未部署。 +客户“确认计费重”统一使用 `fl_inbound_fee` 最新已推送费用版本及其客户确认记录,不再维护独立重量确认表或接口。源建表定义、旧创建脚本与空库基线已移除;已有 TEST 先发布移除旧引用的 Firstleg 版本,再执行 `migrations/cn/baiyue_firstleg/V9__drop_chargeable_weight_confirmation.sql` 删除 `fl_chargeable_weight_confirmation`,不修改费用、订单或测量数据。脚本存在不表示已执行或部署。 费用项目默认价格结构:Firstleg 源基线 `phase2-fee-item-and-inbound-detail.sql` 与国内统一空库基线均增加可空的 `fl_fee_item.default_price DECIMAL(12,2)`。字段业务规则见工作区 `docs/入库测量与计费/README.md`;需与匹配的 Firstleg 和 Admin 一起发布。本次仅修改基线,不表示 SQL 已执行,也不能向已有测试库重放空库脚本。 diff --git a/deploy/sql/migrations/cn/baiyue_firstleg/V8__chargeable_weight_confirmation.sql b/deploy/sql/migrations/cn/baiyue_firstleg/V8__chargeable_weight_confirmation.sql deleted file mode 100644 index 481e45442..000000000 --- a/deploy/sql/migrations/cn/baiyue_firstleg/V8__chargeable_weight_confirmation.sql +++ /dev/null @@ -1,15 +0,0 @@ --- 仅为已初始化的头程库新增独立确认历史;不回填费用确认,不修改订单事实。 --- 由单 schema 执行器选择 baiyue_firstleg,不在迁移内部切库。 --- 客户独立确认计费重,不回填既有费用确认。 -CREATE TABLE IF NOT EXISTS fl_chargeable_weight_confirmation ( - id BIGINT NOT NULL COMMENT '确认记录ID', - tenant_id BIGINT NOT NULL COMMENT '客户隔离ID', - inbound_id BIGINT NOT NULL COMMENT '独立来源入仓单ID', - operator_id BIGINT NOT NULL COMMENT '确认客户成员ID', - command_key VARCHAR(128) NOT NULL COMMENT '成员确认幂等键', - fingerprint CHAR(64) NOT NULL COMMENT '计费重及测量轮次摘要', - chargeable_weight_kg DECIMAL(18,4) NOT NULL COMMENT '客户确认计费重千克', - confirmed_at DATETIME(3) NOT NULL COMMENT '客户确认时间', - PRIMARY KEY(id), UNIQUE KEY uk_weight_confirmation_command(tenant_id,operator_id,command_key), - KEY idx_weight_confirmation_inbound(tenant_id,inbound_id,id) -) ENGINE=InnoDB COMMENT='客户计费重独立确认历史'; diff --git a/deploy/sql/migrations/cn/baiyue_firstleg/V9__drop_chargeable_weight_confirmation.sql b/deploy/sql/migrations/cn/baiyue_firstleg/V9__drop_chargeable_weight_confirmation.sql new file mode 100644 index 000000000..cf83e363d --- /dev/null +++ b/deploy/sql/migrations/cn/baiyue_firstleg/V9__drop_chargeable_weight_confirmation.sql @@ -0,0 +1,4 @@ +-- 客户“确认计费重”统一确认 fl_inbound_fee 的当前推送费用版本。 +-- 先发布已移除旧重量确认接口及查询的 Firstleg 版本,再执行本脚本。 +-- 仅删除旧独立重量确认记录,不修改费用快照、订单或测量数据。 +DROP TABLE IF EXISTS baiyue_firstleg.fl_chargeable_weight_confirmation; diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index db18e9fdd..8f6843980 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -4437,20 +4437,6 @@ CREATE TABLE fl_inbound_split_item ( SET SESSION foreign_key_checks = 1; --- 客户独立确认计费重,不回填既有费用确认。 -CREATE TABLE fl_chargeable_weight_confirmation ( - id BIGINT NOT NULL COMMENT '确认记录ID', - tenant_id BIGINT NOT NULL COMMENT '客户隔离ID', - inbound_id BIGINT NOT NULL COMMENT '独立来源入仓单ID', - operator_id BIGINT NOT NULL COMMENT '确认客户成员ID', - command_key VARCHAR(128) NOT NULL COMMENT '成员确认幂等键', - fingerprint CHAR(64) NOT NULL COMMENT '计费重及测量轮次摘要', - chargeable_weight_kg DECIMAL(18,4) NOT NULL COMMENT '客户确认计费重千克', - confirmed_at DATETIME(3) NOT NULL COMMENT '客户确认时间', - PRIMARY KEY(id), UNIQUE KEY uk_weight_confirmation_command(tenant_id,operator_id,command_key), - KEY idx_weight_confirmation_inbound(tenant_id,inbound_id,id) -) ENGINE=InnoDB COMMENT='客户计费重独立确认历史'; - -- 服务 schema:baiyue_file CREATE DATABASE IF NOT EXISTS baiyue_file DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; USE baiyue_file; @@ -8391,4 +8377,4 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) 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 (5102213,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:direct-unload:confirm','登记直送目的仓实际卸货',NULL,'POST',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,5102213,0,CURRENT_TIMESTAMP(3)); -SELECT 'PASS' AS initialization_status, 'CN' AS region, 228 AS initialized_tables; +SELECT 'PASS' AS initialization_status, 'CN' AS region, 227 AS initialized_tables; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/MerchantWorkbenchFilterMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/MerchantWorkbenchFilterMapper.java index 12b1223ea..dcec5ed35 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/MerchantWorkbenchFilterMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/MerchantWorkbenchFilterMapper.java @@ -23,6 +23,7 @@ public interface MerchantWorkbenchFilterMapper { FROM fl_inbound_order i JOIN fl_inbound_fee f ON f.tenant_id=i.tenant_id AND f.inbound_id=i.id WHERE i.tenant_id=#{tenantId} AND i.deleted=0 AND i.order_id IS NOT NULL + AND i.process_status != 'CANCELLED' AND i.measure_review_status='APPROVED' AND f.fee_status='SENT' AND f.customer_confirmed_at IS NULL AND NOT EXISTS (SELECT 1 FROM fl_inbound_fee newer WHERE newer.tenant_id=f.tenant_id AND newer.inbound_id=f.inbound_id diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/MerchantWorkbenchFilterRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/MerchantWorkbenchFilterRepository.java new file mode 100644 index 000000000..7da7c0d08 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/MerchantWorkbenchFilterRepository.java @@ -0,0 +1,37 @@ +package com.baiyue.firstleg.repository; + +import com.baiyue.firstleg.dto.MerchantWorkbenchFilters; +import com.baiyue.firstleg.mapper.MerchantWorkbenchFilterMapper; +import org.springframework.stereotype.Repository; +import java.util.List; + +/** 客户工作台筛选事实的持久化入口。 */ +@Repository +public class MerchantWorkbenchFilterRepository { + private final MerchantWorkbenchFilterMapper mapper; + + /** 注入客户隔离的筛选查询。 */ + public MerchantWorkbenchFilterRepository(MerchantWorkbenchFilterMapper mapper) { + this.mapper = mapper; + } + + /** 查询物流主状态对应的订单范围。 */ + public List byProcessStatuses(long tenantId, List statuses) { + return mapper.byProcessStatuses(tenantId, statuses); + } + + /** 只返回最新已推送费用仍待客户确认的订单。 */ + public List pendingFees(long tenantId) { + return mapper.pendingFees(tenantId); + } + + /** 查询待客户补齐报关资料的订单。 */ + public List pendingCustoms(long tenantId) { + return mapper.pendingCustoms(tenantId); + } + + /** 查询客户订单的实际履约片段。 */ + public List progressFragments(long tenantId, List orderIds) { + return mapper.progressFragments(tenantId, orderIds); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantWorkbenchFilterService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantWorkbenchFilterService.java index 3418b78c4..e00315275 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantWorkbenchFilterService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantWorkbenchFilterService.java @@ -1,30 +1,30 @@ package com.baiyue.firstleg.service; import com.baiyue.firstleg.dto.MerchantWorkbenchFilters; -import com.baiyue.firstleg.mapper.MerchantWorkbenchFilterMapper; +import com.baiyue.firstleg.repository.MerchantWorkbenchFilterRepository; import jakarta.annotation.Resource; import org.springframework.stereotype.Service; @Service public class MerchantWorkbenchFilterService { - @Resource private MerchantWorkbenchFilterMapper mapper; + @Resource private MerchantWorkbenchFilterRepository repository; @Resource private UnifiedInboundQueryService unified; - @Resource private com.baiyue.firstleg.workflow.ChargeableWeightService weights; - /** 精确详情只读进度;工作台待确认队列使用计费重事实,金额确认不能代替。 */ + /** 精确详情只读进度;确认计费重待办统一使用最新已推送费用的客户确认状态。 */ @org.springframework.transaction.annotation.Transactional(readOnly=true) public MerchantWorkbenchFilters.Response read(MerchantWorkbenchFilters.Request request) { if (request.orderIds() != null) { return new MerchantWorkbenchFilters.Response(null, java.util.List.of(), java.util.List.of(), - progress(mapper.progressFragments(request.tenantId(), request.orderIds()))); + progress(repository.progressFragments(request.tenantId(), request.orderIds()))); } return new MerchantWorkbenchFilters.Response( - request.mainStatus() == null ? null : mapper.byProcessStatuses(request.tenantId(), + request.mainStatus() == null ? null : repository.byProcessStatuses(request.tenantId(), unified.internalStatuses(request.mainStatus())), - weights.pendingOrders(request.tenantId()), mapper.pendingCustoms(request.tenantId()), - progress(mapper.progressFragments(request.tenantId(), null))); + repository.pendingFees(request.tenantId()), repository.pendingCustoms(request.tenantId()), + progress(repository.progressFragments(request.tenantId(), null))); } + /** 将实装片段聚合为订单级履约进度。 */ static java.util.List progress(java.util.List facts) { var grouped = facts.stream().collect(java.util.stream.Collectors.groupingBy(MerchantWorkbenchFilters.Fragment::orderId)); return grouped.entrySet().stream().sorted(java.util.Map.Entry.comparingByKey()).map(entry -> { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/workflow/ChargeableWeightService.java b/firstleg/src/main/java/com/baiyue/firstleg/workflow/ChargeableWeightService.java deleted file mode 100644 index a2274c93b..000000000 --- a/firstleg/src/main/java/com/baiyue/firstleg/workflow/ChargeableWeightService.java +++ /dev/null @@ -1,57 +0,0 @@ -package com.baiyue.firstleg.workflow; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import com.baiyue.common.core.api.ResultCode; -import com.baiyue.common.core.exception.BizException; -import java.util.*; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.time.LocalDateTime; -/** 客户独立确认计费重,金额确认和主流程不受影响。 */ -@Service -public class ChargeableWeightService { - private final WorkflowFactRepository repository; - /** 注入当前事实和确认历史。 */ - public ChargeableWeightService(WorkflowFactRepository repository) { this.repository=repository; } - /** 客户仅能读取本订单已审核的计费重。 */ - public List read(long tenant,long orderId) { - return repository.facts(repository.orderInbounds(tenant,orderId)).stream().filter(f->f.tenantId()==tenant && eligible(f)).map(ChargeableWeightService::weight).toList(); - } - /** 客户工作台按独立确认事实计算待办,不借用费用确认。 */ - public List pendingOrders(long tenant) { - var ids=repository.weightCandidates(tenant);var orders=new LinkedHashSet(); - for(int offset=0;offset0; } - /** 只认可当前版本的确认。 */ - public static WorkflowFacts.Weight weight(WorkflowFacts.Fact f) { - String hash=fingerprint(f);boolean confirmed=hash.equals(f.confirmationFingerprint()); - return new WorkflowFacts.Weight(f.inboundId(),f.orderId(),f.chargeableWeightKg(),hash,confirmed,confirmed?f.confirmedAt():null); - } - /** 入仓锁、预期版本和幂等键防止确认旧值或异参重放。 */ - @Transactional - public WorkflowFacts.Weight confirm(long tenant,long operator,long id,String key,WorkflowFacts.Confirm request) { - if(key==null || key.isBlank() || key.length()>128) throw new BizException(ResultCode.BAD_REQUEST,"确认幂等键不能为空且最多128字符"); - var inbound=repository.lock(tenant,id); - if(inbound==null || "CANCELLED".equals(inbound.getProcessStatus())) throw new BizException(ResultCode.NOT_FOUND,"入仓单不存在"); - var replay=repository.command(tenant,operator,key); - if(replay!=null && (replay.inboundId()!=id || !replay.fingerprint().equals(request.expectedFingerprint()))) throw new BizException(ResultCode.CONFLICT,"同一幂等键不能确认不同计费重"); - var facts=repository.facts(List.of(id)); - if(facts.isEmpty()) throw new BizException(ResultCode.CONFLICT,"请分别确认分柜后的独立订单计费重"); - var f=facts.get(0); - if(!eligible(f)) throw new BizException(ResultCode.CONFLICT,"计费重尚未审核通过"); - var current=weight(f); - if(!current.fingerprint().equals(request.expectedFingerprint())) throw new BizException(ResultCode.CONFLICT,"计费重已变化,请刷新后重新确认"); - if(replay==null) repository.insert(new WorkflowFacts.Confirmation(com.baomidou.mybatisplus.core.toolkit.IdWorker.getId(),tenant,id,operator,key,current.fingerprint(),current.chargeableWeightKg(),LocalDateTime.now())); - return weight(repository.facts(List.of(id)).get(0)); - } -} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/workflow/MerchantChargeableWeightController.java b/firstleg/src/main/java/com/baiyue/firstleg/workflow/MerchantChargeableWeightController.java deleted file mode 100644 index 7be5a3db9..000000000 --- a/firstleg/src/main/java/com/baiyue/firstleg/workflow/MerchantChargeableWeightController.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.baiyue.firstleg.workflow; -import com.baiyue.common.core.api.ApiResponse; -import com.baiyue.common.security.AuthenticatedUser; -import com.baiyue.firstleg.service.FirstlegScopeGuard; -import org.springframework.web.bind.annotation.*; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import jakarta.validation.Valid; -import java.util.List; -/** 客户计费重确认入口,独立于费用确认。 */ -@RestController -@RequestMapping("/api/merchant/v1/first-leg") -public class MerchantChargeableWeightController { - private final ChargeableWeightService service; - /** 注入独立确认服务。 */ - public MerchantChargeableWeightController(ChargeableWeightService service) { this.service=service; } - /** 客户范围取认证上下文。 */ - @GetMapping("/orders/{orderId}/chargeable-weights") - @PreAuthorize("hasAuthority('merchant:order:view')") - public ApiResponse> read(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable long orderId) { return ApiResponse.success(service.read(FirstlegScopeGuard.merchant(user),orderId)); } - /** 不触发费用确认与金额锁定。 */ - @com.baiyue.common.audit.BusinessAudit(event="CUSTOMER_CHARGEABLE_WEIGHT_CONFIRM",resourceType="INBOUND_ORDER",resourceId="#inboundId",description="客户独立确认计费重") - @PostMapping("/chargeable-weights/{inboundId}/confirm") - @PreAuthorize("hasAuthority('merchant:order:edit')") - public ApiResponse confirm(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable long inboundId,@RequestHeader("Idempotency-Key") String key,@Valid @RequestBody WorkflowFacts.Confirm request) { return ApiResponse.success(service.confirm(FirstlegScopeGuard.merchant(user),user.userId(),inboundId,key,request)); } -} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowFactMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowFactMapper.java index dce644169..3459fd8c6 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowFactMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowFactMapper.java @@ -1,7 +1,7 @@ package com.baiyue.firstleg.workflow; import org.apache.ibatis.annotations.*; import java.util.List; -/** 柜事实和独立确认SQL入口。 */ +/** 柜事实与最新费用确认状态的只读 SQL 入口。 */ @Mapper public interface WorkflowFactMapper { String ACTUAL=""" @@ -13,29 +13,29 @@ public interface WorkflowFactMapper { AND pk.item_status IN ('PICKED','PARTIAL') AND pk.picked_package_count>0 """; /** 按独立实装票核验资料;来源单承接实装时,余量子单不能将来源票排除。 */ - @Select("") List facts(@Param("ids") List ids); - /** 锁定客户自己的入仓单,串行确认与测量写入。 */ - @Select("SELECT * FROM fl_inbound_order WHERE tenant_id=#{tenantId} AND id=#{id} AND deleted=0 FOR UPDATE") - com.baiyue.firstleg.entity.InboundOrderEntity lock(@Param("tenantId") long tenantId,@Param("id") long id); - /** 客户订单下全部独立入仓单。 */ - @Select("SELECT id FROM fl_inbound_order WHERE tenant_id=#{tenantId} AND order_id=#{orderId} AND deleted=0 AND process_status != 'CANCELLED' ORDER BY id") - List orderInbounds(@Param("tenantId") long tenantId,@Param("orderId") long orderId); - /** 客户工作台只读取审核通过且有正计费重的来源。 */ - @Select("SELECT id FROM fl_inbound_order WHERE tenant_id=#{tenantId} AND deleted=0 AND process_status != 'CANCELLED' AND measure_review_status='APPROVED' AND chargeable_weight_kg>0 ORDER BY id") - List weightCandidates(@Param("tenantId") long tenantId); - /** 成员幂等键不允许跨确认对象复用。 */ - @Select("SELECT * FROM fl_chargeable_weight_confirmation WHERE tenant_id=#{tenantId} AND operator_id=#{operatorId} AND command_key=#{key}") - WorkflowFacts.Confirmation command(@Param("tenantId") long tenantId,@Param("operatorId") long operatorId,@Param("key") String key); - /** 独立记录,不修改费用或物流主状态。 */ - @Insert("INSERT INTO fl_chargeable_weight_confirmation(id,tenant_id,inbound_id,operator_id,command_key,fingerprint,chargeable_weight_kg,confirmed_at) VALUES(#{id},#{tenantId},#{inboundId},#{operatorId},#{commandKey},#{fingerprint},#{chargeableWeightKg},#{confirmedAt})") - int insert(WorkflowFacts.Confirmation value); + /** 客户确认页签只读取最新已推送费用版本,旧版本确认不能覆盖新版本。 */ + @Select(""" + + """) + List feeConfirmationRows(@Param("ids") List ids); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowFactRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowFactRepository.java index 567aaba6a..6d1bd9ba9 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowFactRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowFactRepository.java @@ -1,7 +1,7 @@ package com.baiyue.firstleg.workflow; import org.springframework.stereotype.Repository; import java.util.List; -/** 本地柜事实与确认记录的持久化入口。 */ +/** 本地柜事实与费用确认状态的只读持久化入口。 */ @Repository public class WorkflowFactRepository { private final WorkflowFactMapper mapper; @@ -9,14 +9,8 @@ public class WorkflowFactRepository { public WorkflowFactRepository(WorkflowFactMapper mapper) { this.mapper=mapper; } /** 空候选不读取数据。 */ public List facts(List ids) { return ids.isEmpty()?List.of():mapper.facts(ids); } - /** 锁定所属客户来源。 */ - public com.baiyue.firstleg.entity.InboundOrderEntity lock(long tenant,long id) { return mapper.lock(tenant,id); } - /** 查询订单下独立来源。 */ - public List orderInbounds(long tenant,long order) { return mapper.orderInbounds(tenant,order); } - /** 客户计费重待确认候选。 */ - public List weightCandidates(long tenant) { return mapper.weightCandidates(tenant); } - /** 读取成员确认命令。 */ - public WorkflowFacts.Confirmation command(long tenant,long operator,String key) { return mapper.command(tenant,operator,key); } - /** 追加确认历史。 */ - public void insert(WorkflowFacts.Confirmation value) { mapper.insert(value); } + /** 按本轮有效入仓范围读取最新推送费用的客户确认状态。 */ + public List feeConfirmationRows(List ids) { + return ids.isEmpty() ? List.of() : mapper.feeConfirmationRows(ids); + } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowFacts.java b/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowFacts.java index afa08a995..173f66927 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowFacts.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowFacts.java @@ -1,17 +1,12 @@ package com.baiyue.firstleg.workflow; -import java.math.BigDecimal; -import java.time.LocalDateTime; import java.util.List; -/** 页签事实与独立计费重确认契约。 */ +/** 订单业务页签的只读事实契约。 */ public final class WorkflowFacts { - /** 仅承载查询及确认契约,不允许实例化。 */ + /** 仅承载事实查询契约,不允许实例化。 */ private WorkflowFacts() {} - public record Fact(long tenantId,long inboundId,long orderId,String reviewStatus,BigDecimal chargeableWeightKg,LocalDateTime measureCompletedAt, - boolean departed,boolean clearanceUploaded,boolean missingCustoms,boolean exportCustomsReady,String confirmationFingerprint,LocalDateTime confirmedAt) {} + public record Fact(long tenantId,long inboundId,long orderId,String reviewStatus, + boolean departed,boolean clearanceUploaded,boolean missingCustoms,boolean exportCustomsReady) {} public record Source(long tenantId,long inboundId) {} public record Request(List sources) {} public record Overseas(long tenantId,long inboundId,boolean outboundExists,boolean pendingOutbound,boolean delivering,boolean unloaded) {} - public record Weight(long inboundId,long orderId,BigDecimal chargeableWeightKg,String fingerprint,boolean confirmed,LocalDateTime confirmedAt) {} - public record Confirm(@jakarta.validation.constraints.Pattern(regexp="[a-f0-9]{64}") @jakarta.validation.constraints.NotNull String expectedFingerprint) {} - public record Confirmation(long id,long tenantId,long inboundId,long operatorId,String commandKey,String fingerprint,BigDecimal chargeableWeightKg,LocalDateTime confirmedAt) {} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowTabService.java b/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowTabService.java index c513cbf9f..522242750 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowTabService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowTabService.java @@ -22,6 +22,11 @@ public class WorkflowTabService { var batch=inbounds.subList(offset,Math.min(offset+500,inbounds.size())); var expected=new HashMap();batch.forEach(i->expected.put(i.getId(),i)); var facts=repository.facts(batch.stream().map(InboundOrderEntity::getId).toList()); + // 确认页签复用已推送费用版本。 + for(var fee:repository.feeConfirmationRows(facts.stream().map(WorkflowFacts.Fact::inboundId).toList())) { + if(facts.stream().noneMatch(f->f.tenantId()==fee.tenantId() && f.orderId()==fee.orderId())) throw unavailable(); + result.putIfAbsent(fee.orderId()+":"+fee.mainStatus(),fee); + } var sources=facts.stream().filter(WorkflowFacts.Fact::departed).map(f->new WorkflowFacts.Source(f.tenantId(),f.inboundId())).distinct().toList(); Map overseas=new HashMap<>(); if(!sources.isEmpty()) { @@ -46,11 +51,10 @@ public class WorkflowTabService { } return List.copyOf(result.values()); } - /** 分类为可重叠的待办/证据集合,计费重确认独立于费用确认。 */ + /** 分类为可重叠的物流待办/证据集合,客户确认由最新费用版本单独提供。 */ public static Set classify(WorkflowFacts.Fact f,String process,WorkflowFacts.Overseas o) { Set keys=new LinkedHashSet<>(); if("MEASURE_PENDING".equals(process) && "PENDING".equals(f.reviewStatus())) keys.add("MEASUREMENT_REVIEW"); - if(ChargeableWeightService.eligible(f)) keys.add(ChargeableWeightService.weight(f).confirmed()?"WEIGHT_CONFIRMED":"WAIT_WEIGHT_CONFIRM"); boolean signed="DELIVERED".equals(process); // 报关页签按资料与实装事实汇总,不依赖点击开始报关;实际开船后转入清关链路。 if(!f.departed() && !signed) { diff --git a/firstleg/src/main/resources/db/chargeable-weight-confirmation.sql b/firstleg/src/main/resources/db/chargeable-weight-confirmation.sql deleted file mode 100644 index 1dcc7e9c7..000000000 --- a/firstleg/src/main/resources/db/chargeable-weight-confirmation.sql +++ /dev/null @@ -1,13 +0,0 @@ --- 客户独立确认计费重,不回填既有费用确认。 -CREATE TABLE IF NOT EXISTS fl_chargeable_weight_confirmation ( - id BIGINT NOT NULL COMMENT '确认记录ID', - tenant_id BIGINT NOT NULL COMMENT '客户隔离ID', - inbound_id BIGINT NOT NULL COMMENT '独立来源入仓单ID', - operator_id BIGINT NOT NULL COMMENT '确认客户成员ID', - command_key VARCHAR(128) NOT NULL COMMENT '成员确认幂等键', - fingerprint CHAR(64) NOT NULL COMMENT '计费重及测量轮次摘要', - chargeable_weight_kg DECIMAL(18,4) NOT NULL COMMENT '客户确认计费重千克', - confirmed_at DATETIME(3) NOT NULL COMMENT '客户确认时间', - PRIMARY KEY(id), UNIQUE KEY uk_weight_confirmation_command(tenant_id,operator_id,command_key), - KEY idx_weight_confirmation_inbound(tenant_id,inbound_id,id) -) ENGINE=InnoDB COMMENT='客户计费重独立确认历史'; diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 6e2607eea..59219443d 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -355,7 +355,7 @@ paths: /internal/firstleg/v1/inbounds/unified-orders/workbench-filters: post: summary: 查询单一客户工作台履约筛选标识(Order内部) - description: 只读返回命中状态、待独立确认计费重及待上传报关资料的订单标识。feeOrderIds沿用既有字段名,值为尚未确认当前计费重的订单,不代表金额确认。客户必须明确且为正数;不创建报关聚合,不跨Schema读取。传入orderIds为精确进度模式,仅计算progress,matchingStatusOrderIds为null,feeOrderIds和customsOrderIds为空;mainStatus在该模式不参与查询。 + description: 只读返回命中状态、待确认已推送费用及待上传报关资料的订单标识。feeOrderIds为最新已推送费用版本尚未由客户确认的订单,对应客户端“确认计费重”入口及费用明细弹窗。客户必须明确且为正数;不创建报关聚合,不跨Schema读取。传入orderIds为精确进度模式,仅计算progress,matchingStatusOrderIds为null,feeOrderIds和customsOrderIds为空;mainStatus在该模式不参与查询。 security: [{internalServiceToken: []}] requestBody: required: true @@ -388,7 +388,7 @@ paths: required: [feeOrderIds, customsOrderIds, progress] properties: matchingStatusOrderIds: {type: array, nullable: true, items: {type: integer, format: int64}} - feeOrderIds: {type: array, description: 待客户独立确认当前计费重的订单ID, items: {type: integer, format: int64}} + feeOrderIds: {type: array, description: 待客户确认最新已推送费用版本的订单ID, items: {type: integer, format: int64}} customsOrderIds: {type: array, items: {type: integer, format: int64}} progress: type: array diff --git a/firstleg/src/main/resources/openapi/order-workflow-v1.yaml b/firstleg/src/main/resources/openapi/order-workflow-v1.yaml deleted file mode 100644 index 9c9abf1e4..000000000 --- a/firstleg/src/main/resources/openapi/order-workflow-v1.yaml +++ /dev/null @@ -1,64 +0,0 @@ -openapi: 3.0.3 -info: {title: 客户独立计费重确认, version: 1.0.0} -security: [{bearerAuth: []}] -paths: - /api/merchant/v1/first-leg/orders/{orderId}/chargeable-weights: - get: - summary: 客户读取本票全部独立入仓计费重 - description: 认证客户隔离,要求merchant:order:view;仅返回测量审核通过且计费重为正的分片。 - parameters: - - {name: orderId, in: path, required: true, schema: {type: integer, format: int64}} - responses: - '200': - description: 标准ApiResponse,认证客户无可确认来源时返回空数组 - content: - application/json: - schema: - type: object - properties: - code: {type: string, example: '200'} - message: {type: string} - data: - type: array - items: {$ref: '#/components/schemas/Weight'} - /api/merchant/v1/first-leg/chargeable-weights/{inboundId}/confirm: - post: - summary: 客户独立确认当前计费重 - description: 要求merchant:order:edit。与费用金额确认独立;值或测量轮次变化后旧确认失效。客户范围取认证主体。 - parameters: - - {name: inboundId, in: path, required: true, schema: {type: integer, format: int64}} - - {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}} - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [expectedFingerprint] - properties: - expectedFingerprint: {type: string, pattern: '^[a-f0-9]{64}$'} - responses: - '200': - description: 标准ApiResponse;版本或异参冲突返回业务冲突码,不改变费用状态 - content: - application/json: - schema: - type: object - properties: - code: {type: string, example: '200'} - message: {type: string} - data: {$ref: '#/components/schemas/Weight'} -components: - securitySchemes: - bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT} - schemas: - Weight: - type: object - required: [inboundId, orderId, chargeableWeightKg, fingerprint, confirmed] - properties: - inboundId: {type: string, description: 独立来源入仓单ID} - orderId: {type: string, description: 独立订单ID} - chargeableWeightKg: {type: number} - fingerprint: {type: string} - confirmed: {type: boolean} - confirmedAt: {type: string, format: date-time, nullable: true} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/MerchantWorkbenchFilterMapperTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/MerchantWorkbenchFilterMapperTest.java index 00b029a6d..ab435876e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/MerchantWorkbenchFilterMapperTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/MerchantWorkbenchFilterMapperTest.java @@ -19,6 +19,7 @@ class MerchantWorkbenchFilterMapperTest { var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:workbench-facts-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1"); jdbc=new JdbcTemplate(ds); jdbc.execute("CREATE TABLE fl_inbound_order(id BIGINT PRIMARY KEY,tenant_id BIGINT,order_id BIGINT,deleted INT DEFAULT 0,tax_mode VARCHAR(32),process_status VARCHAR(32))"); + jdbc.execute("ALTER TABLE fl_inbound_order ADD measure_review_status VARCHAR(32) DEFAULT 'APPROVED'"); jdbc.execute("CREATE TABLE fl_inbound_fee(id BIGINT PRIMARY KEY,tenant_id BIGINT,inbound_id BIGINT,fee_version INT,fee_status VARCHAR(32),customer_confirmed_at TIMESTAMP)"); jdbc.execute("CREATE TABLE fl_loading_plan(id BIGINT PRIMARY KEY,container_booking_id BIGINT,plan_status VARCHAR(32),confirmed_at TIMESTAMP,updated_at TIMESTAMP)"); jdbc.execute("CREATE TABLE fl_loading_plan_item(loading_plan_id BIGINT,inbound_id BIGINT,account_id BIGINT,deleted INT DEFAULT 0)"); @@ -33,7 +34,7 @@ class MerchantWorkbenchFilterMapperTest { var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(MerchantWorkbenchFilterMapper.class); var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config); mapper=new SqlSessionTemplate(factory.getObject()).getMapper(MerchantWorkbenchFilterMapper.class); - jdbc.update("INSERT INTO fl_inbound_order VALUES(1,11,101,0,'TAX_REFUND','LOADED'),(2,11,102,0,'TAX_REFUND','DELIVERED'),(3,22,103,0,'TAX_REFUND','LOADED'),(4,11,104,1,'TAX_REFUND','LOADED'),(5,11,105,0,'BUY_DECLARATION','LOADED')"); + jdbc.update("INSERT INTO fl_inbound_order(id,tenant_id,order_id,deleted,tax_mode,process_status) VALUES(1,11,101,0,'TAX_REFUND','LOADED'),(2,11,102,0,'TAX_REFUND','DELIVERED'),(3,22,103,0,'TAX_REFUND','LOADED'),(4,11,104,1,'TAX_REFUND','LOADED'),(5,11,105,0,'BUY_DECLARATION','LOADED')"); jdbc.update("INSERT INTO fl_container_booking VALUES(100,'ACTIVE')"); jdbc.update("INSERT INTO fl_loading_plan VALUES(100,100,'COMPLETED',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)"); jdbc.update("INSERT INTO fl_loading_plan_item(loading_plan_id,inbound_id,account_id,deleted) VALUES(100,1,11,0),(100,1,11,0),(100,2,11,0),(100,3,22,0),(100,4,11,0),(100,5,11,0)"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantWorkbenchFilterServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantWorkbenchFilterServiceTest.java index f9d21c3b5..f8a0117a4 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantWorkbenchFilterServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantWorkbenchFilterServiceTest.java @@ -1,7 +1,7 @@ package com.baiyue.firstleg.service; import com.baiyue.firstleg.dto.MerchantWorkbenchFilters; -import com.baiyue.firstleg.mapper.MerchantWorkbenchFilterMapper; +import com.baiyue.firstleg.repository.MerchantWorkbenchFilterRepository; import org.junit.jupiter.api.Test; import org.springframework.test.util.ReflectionTestUtils; import java.util.List; @@ -9,25 +9,22 @@ import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; class MerchantWorkbenchFilterServiceTest { - /** 金额待确认与计费重待确认不同,客户工作台只能使用后者。 */ - @Test void customerQueueUsesIndependentWeightConfirmation() { - var mapper=mock(MerchantWorkbenchFilterMapper.class); - var weights=mock(com.baiyue.firstleg.workflow.ChargeableWeightService.class); + /** 确认计费重待办使用费用版本的确认结果,与费用弹窗保持一致。 */ + @Test void customerQueueUsesPublishedFeeConfirmation() { + var repository=mock(MerchantWorkbenchFilterRepository.class); var service=new MerchantWorkbenchFilterService(); - ReflectionTestUtils.setField(service,"mapper",mapper); - ReflectionTestUtils.setField(service,"weights",weights); - when(weights.pendingOrders(11)).thenReturn(List.of(101L)); + ReflectionTestUtils.setField(service,"repository",repository); + when(repository.pendingFees(11)).thenReturn(List.of(101L)); var response=service.read(new MerchantWorkbenchFilters.Request(11L,null,null)); assertThat(response.feeOrderIds()).containsExactly(101L); - verify(weights).pendingOrders(11); - verify(mapper,never()).pendingFees(anyLong()); + verify(repository).pendingFees(11); } @Test void exactDetailProgressSkipsUnrelatedCustomerQueues() { - var mapper=mock(MerchantWorkbenchFilterMapper.class); + var mapper=mock(MerchantWorkbenchFilterRepository.class); var unified=mock(UnifiedInboundQueryService.class); var service=new MerchantWorkbenchFilterService(); - ReflectionTestUtils.setField(service,"mapper",mapper); + ReflectionTestUtils.setField(service,"repository",mapper); ReflectionTestUtils.setField(service,"unified",unified); when(mapper.progressFragments(11,List.of(101L))).thenReturn(List.of( new MerchantWorkbenchFilters.Fragment(101,3),new MerchantWorkbenchFilters.Fragment(101,1))); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBoundaryTest.java b/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBoundaryTest.java index 409acb688..d8db7cf4d 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBoundaryTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBoundaryTest.java @@ -4,8 +4,6 @@ import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.entity.InboundOrderEntity; import org.junit.jupiter.api.Test; -import java.math.BigDecimal; -import java.time.LocalDateTime; import java.util.List; import static org.assertj.core.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; @@ -15,8 +13,7 @@ import static org.mockito.Mockito.*; class WorkflowBoundaryTest { /** 真实开船来源的测试事实。 */ private WorkflowFacts.Fact fact() { - return new WorkflowFacts.Fact(11,1,101,"APPROVED",BigDecimal.TEN, - LocalDateTime.of(2026,10,19,10,0),true,false,false,false,null,null); + return new WorkflowFacts.Fact(11,1,101,"APPROVED",true,false,false,false); } /** 下游漏来源或返回其他客户均整体失败。 */ @@ -36,28 +33,4 @@ class WorkflowBoundaryTest { assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class); } - /** 重测同值也要求重新确认,金额没有参与摘要。 */ - @Test void repeatedMeasurementRequiresNewConfirmation() { - var original=fact(); - var measuredAgain=new WorkflowFacts.Fact(11,1,101,"APPROVED",BigDecimal.TEN, - original.measureCompletedAt().plusSeconds(1),true,false,false,false, - ChargeableWeightService.fingerprint(original),LocalDateTime.now()); - assertThat(ChargeableWeightService.weight(measuredAgain).confirmed()).isFalse(); - } - - /** 平台主体不得冒充客户,客户和操作者只取认证上下文。 */ - @Test void confirmationUsesAuthenticatedCustomerAndOperator() { - var service=mock(ChargeableWeightService.class); - var controller=new MerchantChargeableWeightController(service); - var platform=new com.baiyue.common.security.AuthenticatedUser(7L,0L,0L,9L,"platform","PLATFORM","WEB",java.util.Set.of()); - var request=new WorkflowFacts.Confirm("a".repeat(64)); - assertThatThrownBy(()->controller.read(platform,101)).isInstanceOf(BizException.class); - assertThatThrownBy(()->controller.confirm(platform,1,"key",request)).isInstanceOf(BizException.class); - verifyNoInteractions(service); - var merchant=new com.baiyue.common.security.AuthenticatedUser(7L,11L,0L,9L,"member","MERCHANT","WEB",java.util.Set.of()); - controller.read(merchant,101); - controller.confirm(merchant,1,"key",request); - verify(service).read(11,101); - verify(service).confirm(11,7,1,"key",request); - } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBusinessTest.java b/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBusinessTest.java index 6772b8ab7..4106e0665 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBusinessTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBusinessTest.java @@ -1,18 +1,15 @@ package com.baiyue.firstleg.workflow; import org.junit.jupiter.api.Test; -import java.math.BigDecimal; -import java.time.LocalDateTime; import static org.assertj.core.api.Assertions.*; -/** 取消回显、分柜隔离和独立计费重版本回归。 */ +/** 取消回显、分柜隔离和资料页签回归。 */ class WorkflowBusinessTest { /** 报关资料齐备进入汇总,实际开船及清关资料分别驱动后续页签。 */ @Test void customsDocumentsDepartureAndClearanceDriveTabs() { - var time=LocalDateTime.of(2026,10,19,10,0); - var missing=new WorkflowFacts.Fact(11,1,101,"APPROVED",BigDecimal.TEN,time,false,false,true,false,null,null); - var uploaded=new WorkflowFacts.Fact(11,1,101,"APPROVED",BigDecimal.TEN,time,false,false,false,true,null,null); - var departed=new WorkflowFacts.Fact(11,1,101,"APPROVED",BigDecimal.TEN,time,true,false,false,true,null,null); - var cleared=new WorkflowFacts.Fact(11,1,101,"APPROVED",BigDecimal.TEN,time,true,true,false,true,null,null); - var uploadedBeforeDeparture=new WorkflowFacts.Fact(11,1,101,"APPROVED",BigDecimal.TEN,time,false,true,false,true,null,null); + var missing=new WorkflowFacts.Fact(11,1,101,"APPROVED",false,false,true,false); + var uploaded=new WorkflowFacts.Fact(11,1,101,"APPROVED",false,false,false,true); + var departed=new WorkflowFacts.Fact(11,1,101,"APPROVED",true,false,false,true); + var cleared=new WorkflowFacts.Fact(11,1,101,"APPROVED",true,true,false,true); + var uploadedBeforeDeparture=new WorkflowFacts.Fact(11,1,101,"APPROVED",false,true,false,true); var pendingUnload=new WorkflowFacts.Overseas(11,1,false,false,false,false); var unloaded=new WorkflowFacts.Overseas(11,1,false,false,false,true); assertThat(WorkflowTabService.classify(missing,"LOADED",null)) @@ -31,12 +28,12 @@ class WorkflowBusinessTest { .contains("SIGNED").doesNotContain("EXPORT_CUSTOMS","WAIT_CUSTOMS_DOCS"); } /** 构造独立分柜来源。 */ - private WorkflowFacts.Fact fact(long id,String kg,String confirmed,boolean uploaded) { - return new WorkflowFacts.Fact(11,id,100+id,"APPROVED",new BigDecimal(kg),LocalDateTime.of(2026,10,19,10,0),true,uploaded,false,false,confirmed,null); + private WorkflowFacts.Fact fact(long id,boolean uploaded) { + return new WorkflowFacts.Fact(11,id,100+id,"APPROVED",true,uploaded,false,false); } /** 有效建单移出待排车,取消后恢复;实际出库不计未出库单。 */ @Test void cancellationRestoresDispatchQueue() { - var f=fact(1,"100",null,true); + var f=fact(1,true); var waiting=new WorkflowFacts.Overseas(11,1,false,false,false,true); var created=new WorkflowFacts.Overseas(11,1,true,true,false,true); var shipped=new WorkflowFacts.Overseas(11,1,true,false,true,true); @@ -47,17 +44,10 @@ class WorkflowBusinessTest { } /** 一柜资料上传不覆盖另一柜;签收不再进入待排。 */ @Test void independentSlicesAndUploadedClearance() { - assertThat(WorkflowTabService.classify(fact(1,"100",null,false),"LOADED",new WorkflowFacts.Overseas(11,1,false,false,false,false))) + assertThat(WorkflowTabService.classify(fact(1,false),"LOADED",new WorkflowFacts.Overseas(11,1,false,false,false,false))) .contains("SAILING_CLEARANCE","WAIT_DISPATCH").doesNotContain("WAIT_UNLOAD"); - assertThat(WorkflowTabService.classify(fact(2,"200",null,true),"LOADED",new WorkflowFacts.Overseas(11,2,true,true,false,false))) + assertThat(WorkflowTabService.classify(fact(2,true),"LOADED",new WorkflowFacts.Overseas(11,2,true,true,false,false))) .contains("WAIT_UNLOAD","OUTBOUND_DOCUMENT").doesNotContain("SAILING_CLEARANCE","WAIT_DISPATCH"); - assertThat(WorkflowTabService.classify(fact(1,"100",null,false),"DELIVERED",null)).contains("SIGNED").doesNotContain("WAIT_DISPATCH"); - } - /** 数值格式不改变版本,值或来源变化必须重新确认。 */ - @Test void independentWeightVersion() { - var hash=ChargeableWeightService.fingerprint(fact(1,"100.0000",null,false)); - assertThat(ChargeableWeightService.weight(fact(1,"100",hash,false)).confirmed()).isTrue(); - assertThat(ChargeableWeightService.weight(fact(1,"101",hash,false)).confirmed()).isFalse(); - assertThat(ChargeableWeightService.weight(fact(2,"100",hash,false)).confirmed()).isFalse(); + assertThat(WorkflowTabService.classify(fact(1,false),"DELIVERED",null)).contains("SIGNED").doesNotContain("WAIT_DISPATCH"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowFactPersistenceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowFactPersistenceTest.java index 75d5c5565..23b012520 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowFactPersistenceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowFactPersistenceTest.java @@ -5,16 +5,16 @@ import org.springframework.jdbc.core.JdbcTemplate; import org.mybatis.spring.SqlSessionTemplate; import com.baomidou.mybatisplus.core.MybatisConfiguration; import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; -import java.nio.file.*; import java.util.List; import static org.assertj.core.api.Assertions.*; -/** 真实Mapper和隔离内存库验证资料归属、计费重确认和历史失效。 */ +/** 不创建独立重量确认表,验证资料归属和最新费用版本驱动的确认流转。 */ class WorkflowFactPersistenceTest { /** 测试建表不连接业务数据库。 */ - @Test void clearanceAndWeightConfirmationPersistIndependently() throws Exception { + @Test void clearanceAndPublishedFeeConfirmationUseCurrentFacts() throws Exception { var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:weight-"+java.util.UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1"); var db=new JdbcTemplate(ds); db.execute("CREATE TABLE fl_inbound_order(id BIGINT PRIMARY KEY,tenant_id BIGINT,order_id BIGINT,measure_review_status VARCHAR(30),chargeable_weight_kg DECIMAL(18,4),measure_completed_at TIMESTAMP,deleted INT,process_status VARCHAR(30))"); + db.execute("CREATE TABLE fl_inbound_fee(id BIGINT PRIMARY KEY,tenant_id BIGINT,inbound_id BIGINT,fee_version INT,fee_status VARCHAR(32),customer_confirmed_at TIMESTAMP)"); db.execute("CREATE TABLE fl_loading_plan_item(id BIGINT,inbound_id BIGINT,account_id BIGINT,loading_plan_id BIGINT,deleted INT)"); db.execute("CREATE TABLE fl_loading_plan(id BIGINT,plan_status VARCHAR(30),container_booking_id BIGINT)"); db.execute("CREATE TABLE fl_loading_pick_task(id BIGINT,loading_plan_id BIGINT,task_status VARCHAR(30))"); @@ -29,7 +29,6 @@ class WorkflowFactPersistenceTest { db.execute("ALTER TABLE fl_inbound_order ADD parent_inbound_id BIGINT"); db.execute("ALTER TABLE fl_inbound_order ADD inbound_role VARCHAR(30)"); db.execute("ALTER TABLE fl_inbound_order ADD split_purpose VARCHAR(30)"); - db.execute(Files.readString(Path.of("src/main/resources/db/chargeable-weight-confirmation.sql"))); db.update("INSERT INTO fl_inbound_order(id,tenant_id,order_id,measure_review_status,chargeable_weight_kg,measure_completed_at,deleted,process_status,tax_mode) VALUES(1,11,101,'APPROVED',100,CURRENT_TIMESTAMP,0,'LOADED','TAX_REFUND'),(2,22,102,'APPROVED',200,CURRENT_TIMESTAMP,0,'LOADED','BUY_DECLARATION')"); db.update("INSERT INTO fl_loading_plan_item VALUES(3,1,11,4,0)"); db.update("INSERT INTO fl_loading_plan VALUES(4,'COMPLETED',5)"); @@ -39,15 +38,16 @@ class WorkflowFactPersistenceTest { db.update("INSERT INTO fl_carrier_schedule_node_fact VALUES(5,'DEPARTED_ORIGIN_PORT',CURRENT_TIMESTAMP)"); var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(WorkflowFactMapper.class); config.addMapper(com.baiyue.firstleg.mapper.ContainerBookingMapper.class); + config.addMapper(com.baiyue.firstleg.mapper.MerchantWorkbenchFilterMapper.class); var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config); var mapper=new SqlSessionTemplate(factory.getObject()).getMapper(WorkflowFactMapper.class); var containers=new SqlSessionTemplate(factory.getObject()).getMapper(com.baiyue.firstleg.mapper.ContainerBookingMapper.class); + var workbench=new SqlSessionTemplate(factory.getObject()).getMapper(com.baiyue.firstleg.mapper.MerchantWorkbenchFilterMapper.class); assertThat(containers.hasActualVesselDeparture(5)).isOne(); assertThat(containers.hasActualVesselDeparture(6)).isZero(); db.update("INSERT INTO fl_carrier_schedule_node_fact VALUES(6,'DEPARTED_ORIGIN_PORT',NULL),(6,'ARRIVED_DESTINATION_PORT',CURRENT_TIMESTAMP)"); assertThat(containers.hasActualVesselDeparture(6)).isZero(); - var repository=new WorkflowFactRepository(mapper);var service=new ChargeableWeightService(repository); - assertThat(service.read(22,101)).isEmpty(); + var repository=new WorkflowFactRepository(mapper); assertThat(mapper.facts(List.of(1L))).singleElement().satisfies(f->{assertThat(f.departed()).isTrue();assertThat(f.clearanceUploaded()).isFalse();assertThat(f.missingCustoms()).isTrue();}); db.update("INSERT INTO fl_container_document VALUES(5,'DESTINATION_CLEARANCE',99)"); assertThat(mapper.facts(List.of(1L)).get(0).clearanceUploaded()).isTrue(); @@ -92,29 +92,42 @@ class WorkflowFactPersistenceTest { assertThat(mapper.facts(List.of(2L))).singleElement().satisfies(f->{ assertThat(f.missingCustoms()).isFalse();assertThat(f.exportCustomsReady()).isTrue(); }); - var current=service.read(11,101).get(0); - assertThat(service.confirm(11,7,1,"cmd",new WorkflowFacts.Confirm(current.fingerprint())).confirmed()).isTrue(); - assertThat(service.confirm(11,7,1,"cmd",new WorkflowFacts.Confirm(current.fingerprint())).confirmed()).isTrue(); - assertThat(db.queryForObject("SELECT COUNT(*) FROM fl_chargeable_weight_confirmation",Integer.class)).isEqualTo(1); - assertThatThrownBy(()->service.confirm(11,7,1,"cmd",new WorkflowFacts.Confirm("0".repeat(64)))).isInstanceOf(com.baiyue.common.core.exception.BizException.class); - db.update("UPDATE fl_inbound_order SET chargeable_weight_kg=101 WHERE id=1"); - assertThat(service.read(11,101).get(0).confirmed()).isFalse(); - assertThatThrownBy(()->service.confirm(11,7,1,"old",new WorkflowFacts.Confirm(current.fingerprint()))).isInstanceOf(com.baiyue.common.core.exception.BizException.class); - assertThatThrownBy(()->service.confirm(22,7,1,"foreign",new WorkflowFacts.Confirm(current.fingerprint()))).isInstanceOf(com.baiyue.common.core.exception.BizException.class); + // 未推送费用不进入确认页签;所有查询在没有独立重量确认表的库中运行。 + var downstream=org.mockito.Mockito.mock(LastlegWorkflowClient.class); + org.mockito.Mockito.when(downstream.read(org.mockito.ArgumentMatchers.any(),org.mockito.ArgumentMatchers.any())) + .thenReturn(com.baiyue.common.core.api.ApiResponse.success(List.of(new WorkflowFacts.Overseas(11,1,false,false,false,false)))); + var tabs=new WorkflowTabService(repository,downstream); + var inbound=new com.baiyue.firstleg.entity.InboundOrderEntity(); + inbound.setId(1L);inbound.setTenantId(11L);inbound.setOrderId(101L);inbound.setProcessStatus("LOADED"); + assertThat(tabs.rows(List.of(inbound))).noneMatch(r->r.mainStatus().equals("WAIT_WEIGHT_CONFIRM")||r.mainStatus().equals("WEIGHT_CONFIRMED")); + db.update("INSERT INTO fl_inbound_fee VALUES(20,11,1,1,'SENT',NULL),(21,22,2,1,'SENT',NULL)"); + assertThat(tabs.rows(List.of(inbound))).anyMatch(r->r.mainStatus().equals("WAIT_WEIGHT_CONFIRM")) + .noneMatch(r->r.mainStatus().equals("WEIGHT_CONFIRMED")); + assertThat(workbench.pendingFees(11)).containsExactly(101L); + // 客户确认费用后两端同时流转;同重量的新费用版本仍必须重新确认。 + db.update("UPDATE fl_inbound_fee SET customer_confirmed_at=CURRENT_TIMESTAMP WHERE id=20"); + assertThat(tabs.rows(List.of(inbound))).anyMatch(r->r.mainStatus().equals("WEIGHT_CONFIRMED")) + .noneMatch(r->r.mainStatus().equals("WAIT_WEIGHT_CONFIRM")); + assertThat(workbench.pendingFees(11)).isEmpty(); + db.update("INSERT INTO fl_inbound_fee VALUES(22,11,1,2,'CALCULATED',NULL)"); + assertThat(tabs.rows(List.of(inbound))).noneMatch(r->r.mainStatus().equals("WAIT_WEIGHT_CONFIRM")||r.mainStatus().equals("WEIGHT_CONFIRMED")); + assertThat(workbench.pendingFees(11)).isEmpty(); + db.update("UPDATE fl_inbound_fee SET fee_status='SENT' WHERE id=22"); + assertThat(tabs.rows(List.of(inbound))).anyMatch(r->r.mainStatus().equals("WAIT_WEIGHT_CONFIRM")) + .noneMatch(r->r.mainStatus().equals("WEIGHT_CONFIRMED")); + assertThat(workbench.pendingFees(11)).containsExactly(101L); + assertThat(workbench.pendingFees(22)).containsExactly(102L); + db.update("UPDATE fl_inbound_order SET measure_review_status='PENDING' WHERE id=1"); + assertThat(repository.feeConfirmationRows(List.of(1L))).isEmpty(); + assertThat(workbench.pendingFees(11)).isEmpty(); + db.update("UPDATE fl_inbound_order SET measure_review_status='APPROVED' WHERE id=1"); // 现行部分装柜由来源单承接实装,余量子单独立继续分柜,两票均应保留。 db.update("INSERT INTO fl_inbound_order(id,tenant_id,order_id,measure_review_status,chargeable_weight_kg,measure_completed_at,deleted,process_status,tax_mode,parent_inbound_id,inbound_role,split_purpose) VALUES(3,11,103,'APPROVED',50,CURRENT_TIMESTAMP,0,'WAIT_SPLIT','BUY_DECLARATION',1,'CHILD','REMAINDER')"); assertThat(mapper.facts(List.of(1L,3L))).hasSize(2); assertThat(mapper.facts(List.of(1L)).get(0).departed()).isTrue(); assertThat(mapper.facts(List.of(3L)).get(0).departed()).isFalse(); - assertThat(service.read(11,101)).hasSize(1); - assertThat(service.read(11,103)).hasSize(1); - // 旧模型已将实装移至独立子票时,父汇总不能重复进入页签或确认。 + // 父单仅作汇总时,不重复进入业务页签。 db.update("UPDATE fl_inbound_order SET process_status='LOADED',split_purpose='LOADED' WHERE id=3"); - assertThat(service.read(11,101)).isEmpty(); - assertThatThrownBy(()->service.confirm(11,7,1,"parent",new WorkflowFacts.Confirm(current.fingerprint()))).isInstanceOf(com.baiyue.common.core.exception.BizException.class); - var child=service.read(11,103).get(0); - assertThat(child.confirmed()).isFalse(); - assertThatThrownBy(()->service.confirm(11,7,3,"cmd",new WorkflowFacts.Confirm(child.fingerprint()))).isInstanceOf(com.baiyue.common.core.exception.BizException.class); - assertThat(service.confirm(11,7,3,"child",new WorkflowFacts.Confirm(child.fingerprint())).confirmed()).isTrue(); + assertThat(mapper.facts(List.of(1L,3L))).singleElement().satisfies(f->assertThat(f.inboundId()).isEqualTo(3)); } } diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index dafc6ce68..bdb2b14a1 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -562,8 +562,8 @@ Assert-Contains $adminAgents '`totalPackageCount/remainingPackageCount`' 'Admin Assert-Contains $firstlegOpenApi 'required: [id, packageCount, totalPackageCount, remainingPackageCount' '智能装柜计划数量 OpenAPI' Assert-Contains $smartLoadingService 'repository.measuredPackageCount' '智能装柜已审核测量数量' Assert-NotContains $firstlegAgents '| 客户费用查询 |' 'Firstleg 重复客户费用入口' -Assert-Contains $clientAgents '所有订单行均可进入计费重弹框' 'Client 独立计费重入口' -Assert-Contains $clientOrdersView 'dialog-only' 'Client 独立计费重弹框实现' +Assert-Contains $clientAgents '所有订单行均可进入费用确认弹框' 'Client 费用确认入口' +Assert-Contains $clientOrdersView 'dialog-only' 'Client 费用确认弹框实现' Assert-Path 'baiyue-firstleg/warehouse/src/main/java/com/baiyue/warehouse/service/LocationBlockService.java' # 企业联系信息与账号登录标识已经解耦,资质审核不得重新把法人身份证设为必填。