This commit is contained in:
2026-10-20 11:30:34 +08:00
parent c13175bf77
commit 16ac817524
92 changed files with 1916 additions and 190 deletions
@@ -157,6 +157,24 @@ public class InternalInboundOrderController {
return ApiResponse.success(workbenchFilters.read(request));
}
/** 当前页摘要继续应用原筛选,防止父子或分片入库投影错配。 */
@PostMapping("/unified-orders/status-summaries")
public ApiResponse<List<UnifiedInboundSummary>> unifiedStatusSummaries(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@Valid @RequestBody UnifiedInboundQuery request) {
requireToken(token);
return ApiResponse.success(unifiedQueryService.statusSummaries(request));
}
/** 服务令牌保护的轻量状态投影,供 Order 统计和分页校正。 */
@PostMapping("/unified-orders/status-rows")
public ApiResponse<List<UnifiedInboundQueryService.StatusRow>> unifiedStatusRows(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@Valid @RequestBody UnifiedInboundQuery request) {
requireToken(token);
return ApiResponse.success(unifiedQueryService.statusRows(request));
}
@PostMapping("/unified-orders/query")
public ApiResponse<PageResult<UnifiedInboundSummary>> unifiedPage(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@@ -140,7 +140,7 @@ public class PlatformContainerController {
public ApiResponse<BusinessDocument> clearance(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long id) {
FirstlegScopeGuard.platform(user);
service.requireOverseas(id);
service.requireClearanceAccess(id);
return ApiResponse.success(loadingLists.containerClearance(id));
}
@@ -151,7 +151,7 @@ public class PlatformContainerController {
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id,
@PathVariable @Positive long fileId) {
FirstlegScopeGuard.platform(user);
service.requireOverseas(id);
service.requireClearanceAccess(id);
var download = loadingLists.downloadContainerClearance(id, fileId);
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.header(HttpHeaders.CONTENT_DISPOSITION, org.springframework.http.ContentDisposition.attachment()
@@ -168,7 +168,7 @@ public class PlatformContainerController {
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody DocumentAttachRequest request) {
FirstlegScopeGuard.platform(user);
service.requireOverseas(id);
service.requireClearanceAccess(id);
return ApiResponse.success(loadingLists.attachContainerClearance(
id, user.userId(), user.username(), key, request));
}
@@ -183,7 +183,7 @@ public class PlatformContainerController {
@RequestParam("expectedVersion") @Min(0) int expectedVersion,
@RequestParam(value = "operationReason", required = false) @Size(max = 500) String operationReason) {
FirstlegScopeGuard.platform(user);
service.requireOverseas(id);
service.requireClearanceAccess(id);
return ApiResponse.success(loadingLists.uploadContainerClearance(
id, user.userId(), user.username(), key, expectedVersion, operationReason, file));
}
@@ -196,7 +196,7 @@ public class PlatformContainerController {
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody DocumentCompleteRequest request) {
FirstlegScopeGuard.platform(user);
service.requireOverseas(id);
service.requireClearanceAccess(id);
return ApiResponse.success(loadingLists.completeContainerClearance(
id, user.userId(), user.username(), key, request));
}
@@ -15,7 +15,7 @@ public record UnifiedInboundQuery(
@Size(max = 64) String keyword,
@Size(max = 64) String orderNo,
@Schema(allowableValues = {"PENDING_REVIEW", "WAIT_MEASURE", "WAIT_PALLETIZE", "WAIT_SPLIT",
"LOADING", "LOADED", "DELIVERED"})
"LOADING", "LOADED", "DELIVERED", "WAIT_WEIGHT_CONFIRM", "WEIGHT_CONFIRMED", "WAIT_CUSTOMS_DOCS", "EXPORT_CUSTOMS", "SAILING_CLEARANCE", "WAIT_UNLOAD", "WAIT_DISPATCH", "OUTBOUND_DOCUMENT", "DELIVERING", "SIGNED", "MEASUREMENT_REVIEW"})
@Size(max = 32) String mainStatus,
Long customerId,
Long receivingWarehouseId,
@@ -158,6 +158,14 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
""")
int hasActiveOutboundDeparture(@Param("id") long id);
/** 手动或拉取的实际开船节点均可作为清关资料入口依据。 */
@Select("""
SELECT COUNT(1) FROM fl_carrier_schedule_node_fact
WHERE container_booking_id=#{id} AND standard_node='DEPARTED_ORIGIN_PORT'
AND actual_at IS NOT NULL
""")
int hasActualVesselDeparture(@Param("id") long id);
@Update("""
UPDATE fl_container_booking SET pickup_code=#{pickupCode},pickup_code_updated_by=#{operatorId},
pickup_code_updated_by_name=#{operatorName},pickup_code_updated_at=CURRENT_TIMESTAMP(3),
@@ -25,6 +25,8 @@ public class ContainerRepository {
public ContainerBookingEntity findForUpdate(long id) { return mapper.selectBookingForUpdate(id); }
public ContainerBookingEntity findActiveForUpdate(long id) { return mapper.selectActiveBookingForUpdate(id); }
public boolean departed(long id) { return mapper.hasActiveOutboundDeparture(id) > 0; }
/** 读取本服务维护的实际开船事实,不依赖第三方查询任务是否建立。 */
public boolean sailed(long id) { return mapper.hasActualVesselDeparture(id) > 0; }
public boolean containerNoExists(long oceanBookingId, String no, Long excludedId) {
if (no == null) return false;
return mapper.selectCount(new LambdaQueryWrapper<ContainerBookingEntity>()
@@ -114,9 +114,27 @@ public class InboundOrderRepository {
.last("LIMIT 1"));
}
/** Internal unified-query accessors. They always retain the tenant predicate when one is supplied. */
/** 统一订单列表按共享筛选分页。 */
public Page<InboundOrderEntity> pageUnifiedEntities(UnifiedInboundQuery query, List<String> processStatuses) {
LambdaQueryWrapper<InboundOrderEntity> wrapper = new LambdaQueryWrapper<InboundOrderEntity>()
return orders.selectPage(new Page<>(query.page(), query.size()), unifiedFilter(query, processStatuses));
}
/** 只读取身份和流程状态,顺序与列表一致;空候选不能扩大查询范围。 */
public List<InboundOrderEntity> unifiedStatusEntities(UnifiedInboundQuery query) {
if (query.candidateOrderIds() != null && query.candidateOrderIds().isEmpty()) return List.of();
return orders.selectList(unifiedFilter(query, List.of()).select(InboundOrderEntity::getId,
InboundOrderEntity::getTenantId, InboundOrderEntity::getOrderId, InboundOrderEntity::getProcessStatus));
}
/** 当前页候选订单沿原筛选读取匹配的入库记录,避免读到另一条不匹配投影。 */
public List<InboundOrderEntity> unifiedMatchedEntities(UnifiedInboundQuery query, List<String> processStatuses) {
if (query.candidateOrderIds() == null || query.candidateOrderIds().isEmpty()) return List.of();
return orders.selectList(unifiedFilter(query, processStatuses));
}
/** 列表与统计共享客户隔离、费用、测量及船期等筛选语义。 */
private LambdaQueryWrapper<InboundOrderEntity> unifiedFilter(UnifiedInboundQuery query, List<String> processStatuses) {
return new LambdaQueryWrapper<InboundOrderEntity>()
.eq(query.tenantId() != null, InboundOrderEntity::getTenantId, query.tenantId())
.eq(query.taxMode() != null, InboundOrderEntity::getTaxMode, query.taxMode())
.in(processStatuses != null && !processStatuses.isEmpty(), InboundOrderEntity::getProcessStatus, processStatuses)
@@ -187,7 +205,6 @@ public class InboundOrderRepository {
.ge(query.createdFrom() != null, InboundOrderEntity::getCreatedAt, query.createdFrom())
.le(query.createdTo() != null, InboundOrderEntity::getCreatedAt, query.createdTo())
.orderByDesc(InboundOrderEntity::getCreatedAt).orderByDesc(InboundOrderEntity::getId);
return orders.selectPage(new Page<>(query.page(), query.size()), wrapper);
}
public List<InboundOrderEntity> findByOrderIds(Long tenantId, List<Long> orderIds) {
@@ -45,6 +45,13 @@ public class ContainerService {
if (!repository.departed(id)) throw new BizException(ResultCode.NOT_FOUND, "国外工作台货柜不存在");
}
/** 目的港清关资料允许已离仓或已实际开船货柜维护,不改写国内出仓事实。 */
public void requireClearanceAccess(long id) {
required(id);
if (!repository.departed(id) && !repository.sailed(id))
throw new BizException(ResultCode.NOT_FOUND, "货柜尚未离仓或实际开船,不能维护目的港清关资料");
}
public void requireDomestic(long id) {
required(id);
if (repository.departed(id)) throw new BizException(ResultCode.NOT_FOUND, "国内工作台货柜不存在");
@@ -9,7 +9,9 @@ import org.springframework.stereotype.Service;
public class MerchantWorkbenchFilterService {
@Resource private MerchantWorkbenchFilterMapper mapper;
@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) {
@@ -19,7 +21,7 @@ public class MerchantWorkbenchFilterService {
return new MerchantWorkbenchFilters.Response(
request.mainStatus() == null ? null : mapper.byProcessStatuses(request.tenantId(),
unified.internalStatuses(request.mainStatus())),
mapper.pendingFees(request.tenantId()), mapper.pendingCustoms(request.tenantId()),
weights.pendingOrders(request.tenantId()), mapper.pendingCustoms(request.tenantId()),
progress(mapper.progressFragments(request.tenantId(), null)));
}
@@ -36,6 +36,38 @@ public class UnifiedInboundQueryService {
@Resource private FbaPricingRepository pricing;
@Resource private UnifiedInboundReadRepository operations;
@Resource private ObjectMapper json;
@Resource private com.baiyue.firstleg.workflow.WorkflowTabService workflowTabs;
/** 以稳定列表顺序返回去重履约状态,不触发逐票摘要及费用查询。 */
public List<StatusRow> statusRows(UnifiedInboundQuery query) {
validate(query);
Map<String, StatusRow> byOrder = new LinkedHashMap<>();
List<InboundOrderEntity> matched = inbounds.unifiedStatusEntities(query);
for (InboundOrderEntity inbound : matched) {
String status = mainStatus(inbound.getProcessStatus());
// 分片订单可同时命中不同履约页签;同一页签内每个订单只统计一次。
byOrder.putIfAbsent(inbound.getOrderId() + ":" + status, new StatusRow(inbound.getTenantId(),
inbound.getOrderId(), status));
}
workflowTabs.rows(matched).forEach(row -> byOrder.putIfAbsent(row.orderId() + ":" + row.mainStatus(), row));
return List.copyOf(byOrder.values());
}
/** 只对当前页最多200个订单读取匹配摘要,选取规则与状态投影一致。 */
public List<UnifiedInboundSummary> statusSummaries(UnifiedInboundQuery query) {
validate(query);
if (query.candidateOrderIds() == null || query.candidateOrderIds().size() > 200) {
throw new com.baiyue.common.core.exception.BizException(
com.baiyue.common.core.api.ResultCode.BAD_REQUEST, "状态摘要必须提供最多200个候选订单");
}
Map<Long, InboundOrderEntity> byOrder = new LinkedHashMap<>();
for (InboundOrderEntity inbound : inbounds.unifiedMatchedEntities(query, internalStatuses(query.mainStatus()))) {
byOrder.putIfAbsent(inbound.getOrderId(), inbound);
}
return summaries(List.copyOf(byOrder.values()));
}
public record StatusRow(Long tenantId, Long orderId, String mainStatus) {}
public PageResult<UnifiedInboundSummary> page(UnifiedInboundQuery query) {
validate(query);
@@ -43,11 +75,45 @@ public class UnifiedInboundQueryService {
|| query.candidateOrderIds() != null && query.candidateOrderIds().isEmpty()) {
return new PageResult<>(List.of(), query.page(), query.size(), 0, 0);
}
if (StringUtils.hasText(query.mainStatus())
&& com.baiyue.firstleg.workflow.WorkflowTabService.KEYS.contains(query.mainStatus())) {
return workflowPage(query);
}
Page<InboundOrderEntity> source = inbounds.pageUnifiedEntities(query, internalStatuses(query.mainStatus()));
List<UnifiedInboundSummary> records = summaries(source.getRecords());
return new PageResult<>(records, source.getCurrent(), source.getSize(), source.getTotal(), source.getPages());
}
/** 独立事实页签先筛选完整来源再分页,内部查询也不能把业务页签解释成全部状态。 */
private PageResult<UnifiedInboundSummary> workflowPage(UnifiedInboundQuery query) {
List<InboundOrderEntity> matched = inbounds.unifiedStatusEntities(query);
Set<Long> matchingOrders = workflowTabs.rows(matched).stream()
.filter(row -> query.mainStatus().equals(row.mainStatus())).map(StatusRow::orderId)
.collect(Collectors.toSet());
Map<Long, InboundOrderEntity> unique = new LinkedHashMap<>();
// 轻量事实不能用于摘要,重新读取当前条件下的完整来源。
for (InboundOrderEntity inbound : inbounds.unifiedMatchedEntities(
withCandidates(query, matchingOrders.stream().toList()), List.of())) {
if (matchingOrders.contains(inbound.getOrderId())) unique.putIfAbsent(inbound.getOrderId(), inbound);
}
List<InboundOrderEntity> records = List.copyOf(unique.values());
long total = records.size();
long start = query.page() - 1 > Long.MAX_VALUE / query.size()
? total : Math.min((query.page() - 1) * query.size(), total);
var current = records.subList((int) start, (int) Math.min(start + query.size(), total));
return new PageResult<>(summaries(current), query.page(), query.size(), total,
total / query.size() + (total % query.size() == 0 ? 0 : 1));
}
/** 保留全部过滤条件,只收窄为业务事实已命中的订单。 */
private UnifiedInboundQuery withCandidates(UnifiedInboundQuery q, List<Long> candidates) {
return new UnifiedInboundQuery(q.tenantId(), q.keyword(), q.orderNo(), q.mainStatus(), q.customerId(),
q.receivingWarehouseId(), q.destinationWarehouseId(), q.inboundCode(), q.internalOrderStatus(),
q.expectedArrivalDate(), q.feeReviewStatus(), q.channelName(), q.containerNo(), q.cabinetNo(),
q.sailingDate(), q.loaded(), q.fullContainer(), q.measurementReviewStatus(), candidates,
q.createdFrom(), q.createdTo(), q.page(), q.size(), q.taxMode());
}
public List<UnifiedInboundSummary> summaries(Long tenantId, List<Long> orderIds) {
Map<Long, UnifiedInboundSummary> byOrder = new LinkedHashMap<>();
for (UnifiedInboundSummary item : summaries(inbounds.findByOrderIds(tenantId, orderIds)))
@@ -203,7 +269,7 @@ public class UnifiedInboundQueryService {
}
List<String> internalStatuses(String mainStatus) {
if (!StringUtils.hasText(mainStatus)) return List.of();
if (!StringUtils.hasText(mainStatus) || com.baiyue.firstleg.workflow.WorkflowTabService.KEYS.contains(mainStatus)) return List.of();
return switch (mainStatus) {
case "WAIT_MEASURE" -> List.of("WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED",
"WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING");
@@ -231,6 +297,7 @@ public class UnifiedInboundQueryService {
}
if (StringUtils.hasText(query.mainStatus())
&& !MAIN_STATUSES.contains(query.mainStatus())
&& !com.baiyue.firstleg.workflow.WorkflowTabService.KEYS.contains(query.mainStatus())
) {
throw new com.baiyue.common.core.exception.BizException(
com.baiyue.common.core.api.ResultCode.BAD_REQUEST, "统一订单主状态不合法");
@@ -0,0 +1,57 @@
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<WorkflowFacts.Weight> 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<Long> pendingOrders(long tenant) {
var ids=repository.weightCandidates(tenant);var orders=new LinkedHashSet<Long>();
for(int offset=0;offset<ids.size();offset+=500) for(var f:repository.facts(ids.subList(offset,Math.min(offset+500,ids.size()))))
if(f.tenantId()==tenant && eligible(f) && !weight(f).confirmed()) orders.add(f.orderId());
return List.copyOf(orders);
}
/** 值和测量轮次变化使旧确认失效,金额变化不影响计费重。 */
public static String fingerprint(WorkflowFacts.Fact f) {
try { String value=f.inboundId()+"|"+f.chargeableWeightKg().stripTrailingZeros().toPlainString()+"|"+f.measureCompletedAt();
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)));
} catch(java.security.NoSuchAlgorithmException e) { throw new IllegalStateException(e); }
}
/** 未审核或非正数不允许确认。 */
public static boolean eligible(WorkflowFacts.Fact f) { return "APPROVED".equals(f.reviewStatus()) && f.chargeableWeightKg()!=null && f.chargeableWeightKg().signum()>0; }
/** 只认可当前版本的确认。 */
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));
}
}
@@ -0,0 +1,12 @@
package com.baiyue.firstleg.workflow;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/** 经内部接口读取尾程,禁止跨库查询。 */
@FeignClient(name="lastleg-service",contextId="firstlegWorkflowFacts",url="${LASTLEG_SERVICE_URL:${baiyue.clients.lastleg.url:}}")
public interface LastlegWorkflowClient {
/** 最多500个精确来源,不转发客户JWT。 */
@PostMapping("/internal/lastleg/v1/order-workflow-facts")
ApiResponse<List<WorkflowFacts.Overseas>> read(@RequestHeader("X-Internal-Service-Token") String token,@RequestBody WorkflowFacts.Request request);
}
@@ -0,0 +1,26 @@
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<List<WorkflowFacts.Weight>> 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<WorkflowFacts.Weight> 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)); }
}
@@ -0,0 +1,41 @@
package com.baiyue.firstleg.workflow;
import org.apache.ibatis.annotations.*;
import java.util.List;
/** 柜事实和独立确认SQL入口。 */
@Mapper
public interface WorkflowFactMapper {
String ACTUAL="""
FROM fl_loading_plan_item li JOIN fl_loading_plan p ON p.id=li.loading_plan_id AND p.plan_status='COMPLETED'
JOIN fl_loading_pick_task t ON t.loading_plan_id=p.id AND t.task_status='COMPLETED'
JOIN fl_loading_pick_item pk ON pk.pick_task_id=t.id AND pk.loading_plan_item_id=li.id
JOIN fl_container_booking b ON b.id=p.container_booking_id AND b.record_status='ACTIVE'
WHERE li.inbound_id=i.id AND li.account_id=i.tenant_id AND li.deleted=0
AND pk.item_status IN ('PICKED','PARTIAL') AND pk.picked_package_count>0
""";
/** 按独立实装票核验资料;来源单承接实装时,余量子单不能将来源票排除。 */
@Select("<script>SELECT i.tenant_id,i.id inbound_id,i.order_id,i.measure_review_status review_status,i.chargeable_weight_kg,i.measure_completed_at,"
+"EXISTS(SELECT 1 "+ACTUAL+" AND EXISTS(SELECT 1 FROM fl_carrier_schedule_node_fact n WHERE n.container_booking_id=b.id AND n.standard_node='DEPARTED_ORIGIN_PORT' AND n.actual_at IS NOT NULL)) departed,"
+"EXISTS(SELECT 1 "+ACTUAL+" AND EXISTS(SELECT 1 FROM fl_container_document d WHERE d.container_booking_id=b.id AND d.document_type='DESTINATION_CLEARANCE' AND d.file_id IS NOT NULL)) clearance_uploaded,"
+"EXISTS(SELECT 1 "+ACTUAL+" AND i.tax_mode='TAX_REFUND' AND NOT EXISTS(SELECT 1 FROM fl_customs_customer_document_inbound s JOIN fl_customs_customer_document d ON d.id=s.document_id AND d.tenant_id=i.tenant_id AND d.status='ACTIVE' JOIN fl_customs_container c ON c.id=d.customs_container_id AND c.loading_plan_id=p.id WHERE s.inbound_id=i.id AND s.tenant_id=i.tenant_id)) missing_customs,"
+"EXISTS(SELECT 1 "+ACTUAL+" AND (i.tax_mode='BUY_DECLARATION' OR EXISTS(SELECT 1 FROM fl_customs_customer_document_inbound s JOIN fl_customs_customer_document d ON d.id=s.document_id AND d.tenant_id=i.tenant_id AND d.status='ACTIVE' JOIN fl_customs_container c ON c.id=d.customs_container_id AND c.loading_plan_id=p.id WHERE s.inbound_id=i.id AND s.tenant_id=i.tenant_id))) export_customs_ready,"
+"(SELECT w.fingerprint FROM fl_chargeable_weight_confirmation w WHERE w.tenant_id=i.tenant_id AND w.inbound_id=i.id ORDER BY w.id DESC LIMIT 1) confirmation_fingerprint,"
+"(SELECT w.confirmed_at FROM fl_chargeable_weight_confirmation w WHERE w.tenant_id=i.tenant_id AND w.inbound_id=i.id ORDER BY w.id DESC LIMIT 1) confirmed_at "
+"FROM fl_inbound_order i WHERE i.deleted=0 AND NOT EXISTS(SELECT 1 FROM fl_inbound_order child WHERE child.tenant_id=i.tenant_id AND child.parent_inbound_id=i.id AND child.inbound_role='CHILD' AND (child.split_purpose IS NULL OR child.split_purpose != 'REMAINDER') AND child.deleted=0) AND i.process_status != 'CANCELLED' AND i.id IN "
+"<foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach></script>")
List<WorkflowFacts.Fact> facts(@Param("ids") List<Long> 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<Long> 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<Long> 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);
}
@@ -0,0 +1,22 @@
package com.baiyue.firstleg.workflow;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 本地柜事实与确认记录的持久化入口。 */
@Repository
public class WorkflowFactRepository {
private final WorkflowFactMapper mapper;
/** 注入SQL提供者。 */
public WorkflowFactRepository(WorkflowFactMapper mapper) { this.mapper=mapper; }
/** 空候选不读取数据。 */
public List<WorkflowFacts.Fact> facts(List<Long> 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<Long> orderInbounds(long tenant,long order) { return mapper.orderInbounds(tenant,order); }
/** 客户计费重待确认候选。 */
public List<Long> 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); }
}
@@ -0,0 +1,17 @@
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 Source(long tenantId,long inboundId) {}
public record Request(List<Source> 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) {}
}
@@ -0,0 +1,75 @@
package com.baiyue.firstleg.workflow;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Value;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.service.UnifiedInboundQueryService.StatusRow;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import java.util.*;
/** 将已确认的独立业务维度映射为页签,不改物流主状态。 */
@Service
public class WorkflowTabService {
public static final Set<String> KEYS=Set.of("WAIT_WEIGHT_CONFIRM","WEIGHT_CONFIRMED","WAIT_CUSTOMS_DOCS","EXPORT_CUSTOMS","SAILING_CLEARANCE","WAIT_UNLOAD","WAIT_DISPATCH","OUTBOUND_DOCUMENT","DELIVERING","SIGNED","MEASUREMENT_REVIEW");
private final WorkflowFactRepository repository;
private final LastlegWorkflowClient lastleg;
@Value("${baiyue.security.internal-service-token}") private String token;
/** 注入本地事实与尾程内部契约。 */
public WorkflowTabService(WorkflowFactRepository repository,LastlegWorkflowClient lastleg) { this.repository=repository;this.lastleg=lastleg; }
/** 分批取事实,失败整体关闭,不能把服务异常伪装成零。 */
public List<StatusRow> rows(List<InboundOrderEntity> inbounds) {
var result=new LinkedHashMap<String,StatusRow>();
for(int offset=0;offset<inbounds.size();offset+=500) {
var batch=inbounds.subList(offset,Math.min(offset+500,inbounds.size()));
var expected=new HashMap<Long,InboundOrderEntity>();batch.forEach(i->expected.put(i.getId(),i));
var facts=repository.facts(batch.stream().map(InboundOrderEntity::getId).toList());
var sources=facts.stream().filter(WorkflowFacts.Fact::departed).map(f->new WorkflowFacts.Source(f.tenantId(),f.inboundId())).distinct().toList();
Map<String,WorkflowFacts.Overseas> overseas=new HashMap<>();
if(!sources.isEmpty()) {
try {
var response=lastleg.read(token,new WorkflowFacts.Request(sources));
if(response==null || !response.successful() || response.data()==null) throw unavailable();
var allowed=new HashSet<>(sources);
for(var fact:response.data()) {
if(fact==null || !allowed.contains(new WorkflowFacts.Source(fact.tenantId(),fact.inboundId()))) throw unavailable();
overseas.merge(fact.tenantId()+":"+fact.inboundId(),fact,(a,b)->new WorkflowFacts.Overseas(a.tenantId(),a.inboundId(),a.outboundExists()||b.outboundExists(),a.pendingOutbound()||b.pendingOutbound(),a.delivering()||b.delivering(),a.unloaded()&&b.unloaded()));
}
if(overseas.size()!=sources.size()) throw unavailable();
} catch(BizException e) { throw e; } catch(Exception e) { throw unavailable(); }
}
// SQL排除仅作汇总的父票;保留承接实际装柜数量的来源票及独立余量票。
for(var fact:facts) {
var inbound=expected.get(fact.inboundId());
if(inbound==null || !Objects.equals(inbound.getTenantId(),fact.tenantId()) || !Objects.equals(inbound.getOrderId(),fact.orderId())) throw unavailable();
var o=overseas.get(fact.tenantId()+":"+fact.inboundId());
for(String key:classify(fact,inbound.getProcessStatus(),o)) result.putIfAbsent(fact.orderId()+":"+key,new StatusRow(fact.tenantId(),fact.orderId(),key));
}
}
return List.copyOf(result.values());
}
/** 分类为可重叠的待办/证据集合,计费重确认独立于费用确认。 */
public static Set<String> classify(WorkflowFacts.Fact f,String process,WorkflowFacts.Overseas o) {
Set<String> 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) {
if(f.missingCustoms()) keys.add("WAIT_CUSTOMS_DOCS");
if(f.exportCustomsReady()) keys.add("EXPORT_CUSTOMS");
}
if(signed) keys.add("SIGNED");
if(f.departed() && !signed) {
if(!f.clearanceUploaded()) keys.add("SAILING_CLEARANCE");
if(o!=null) {
if(f.clearanceUploaded() && !o.unloaded()) keys.add("WAIT_UNLOAD");
// 取消的出库不构成有效覆盖,读取事实后自动回显,不能保存永久隐藏标志。
if(!o.outboundExists()) keys.add("WAIT_DISPATCH");
if(o.pendingOutbound()) keys.add("OUTBOUND_DOCUMENT");
if(o.delivering()) keys.add("DELIVERING");
}
}
return keys;
}
/** 下游缺失或归属异常不能生成部分数量。 */
private static BizException unavailable() { return new BizException(ResultCode.SERVICE_UNAVAILABLE,"订单业务页签事实暂不可用,请稍后重试"); }
}