diff --git a/.gitignore b/.gitignore index b3add6d9..679f73ec 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,5 @@ Thumbs.db /.codex-work/ .serena/ .workbuddy/ +/data/ +/output/ diff --git a/blade-auth/src/main/java/org/springblade/auth/handler/BladeAuthorizationHandler.java b/blade-auth/src/main/java/org/springblade/auth/handler/BladeAuthorizationHandler.java index 2c12576a..eba9dc15 100644 --- a/blade-auth/src/main/java/org/springblade/auth/handler/BladeAuthorizationHandler.java +++ b/blade-auth/src/main/java/org/springblade/auth/handler/BladeAuthorizationHandler.java @@ -84,16 +84,11 @@ public class BladeAuthorizationHandler extends AbstractAuthorizationHandler { if (bladeProperties.isProd() && isWeakPassword(request.getPassword())) { return buildValidationFailure(ExceptionCode.INVALID_USER_PASSWORD); } - // 判断账号是否锁定 + // 仅按账号锁定;不做 IP 锁定,避免同出口/网关 IP 下一人失败牵连全部未登录用户 OAuth2Validation accountValidation = lockHandler.validateAccountLock(request.getTenantId(), request.getUsername()); if (!accountValidation.isSuccess()) { return accountValidation; } - // 判断IP是否锁定 - OAuth2Validation ipValidation = lockHandler.validateIpLock(request.getTenantId()); - if (!ipValidation.isSuccess()) { - return ipValidation; - } } return super.preValidation(request); } @@ -105,7 +100,13 @@ public class BladeAuthorizationHandler extends AbstractAuthorizationHandler { */ @Override public void preFailure(OAuth2Request request, OAuth2Validation validation) { - // 处理认证失败,增加错误次数 + // 已因锁定失败时不再累加失败次数,避免其他账号重试时被误计并连带锁定 + if (validation != null && !validation.isSuccess() + && ExceptionCode.USER_TOO_MANY_FAILS.getCode() == validation.getCode()) { + log.error("用户:{},认证失败,失败原因:{}", request.getUsername(), validation.getMessage()); + return; + } + // 处理认证失败,增加错误次数(仅账号维度) lockHandler.handleAuthFailure(request.getTenantId(), request.getUsername()); log.error("用户:{},认证失败,失败原因:{}", request.getUsername(), validation.getMessage()); diff --git a/blade-auth/src/main/java/org/springblade/auth/handler/BladeLockHandler.java b/blade-auth/src/main/java/org/springblade/auth/handler/BladeLockHandler.java index ad90ea7d..af423cf4 100644 --- a/blade-auth/src/main/java/org/springblade/auth/handler/BladeLockHandler.java +++ b/blade-auth/src/main/java/org/springblade/auth/handler/BladeLockHandler.java @@ -90,7 +90,7 @@ public class BladeLockHandler { /** * 处理认证失败 - * 同时增加账号和IP错误次数 + * 仅增加账号错误次数;不再累计 IP 失败次数,避免共享出口 IP 误伤其他用户。 * * @param tenantId 租户id * @param account 账号 @@ -102,7 +102,6 @@ public class BladeLockHandler { CompletableFuture.runAsync(() -> { try { authLockClient.addAccountFailCount(tenantId, account, userId, ip, userAgent); - authLockClient.addIpFailCount(tenantId, ip, userAgent); } catch (Exception e) { log.warn("认证失败计数异常: {}", e.getMessage()); } diff --git a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/feign/ISysClient.java b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/feign/ISysClient.java index c1ebf5b5..b55af34f 100644 --- a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/feign/ISysClient.java +++ b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/feign/ISysClient.java @@ -309,12 +309,13 @@ public interface ISysClient { R getRegion(@RequestParam("code") String code); /** - * 获取启用的费用项 + * 获取费用项。默认仅返回启用档案;includeDisabled 为 true 时同时返回停用档案。 * + * @param includeDisabled 是否包含停用费用项 * @return 费用项集合 */ @GetMapping(FEE_ITEMS) - R> getFeeItems(); + R> getFeeItems(@RequestParam(value = "includeDisabled", required = false) Boolean includeDisabled); @GetMapping(CARGO_TYPES) R> getCargoTypes(); diff --git a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/feign/ISysClientFallback.java b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/feign/ISysClientFallback.java index b7704b68..bfb7d123 100644 --- a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/feign/ISysClientFallback.java +++ b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/feign/ISysClientFallback.java @@ -165,7 +165,7 @@ public class ISysClientFallback implements ISysClient { } @Override - public R> getFeeItems() { + public R> getFeeItems(Boolean includeDisabled) { return R.fail("获取数据失败"); } diff --git a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/entity/Dept.java b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/entity/Dept.java index 15a3e94a..e5958c76 100644 --- a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/entity/Dept.java +++ b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/entity/Dept.java @@ -150,6 +150,13 @@ public class Dept extends TenantEntity { @Schema(description = "合同编码前缀") private String contractCodePrefix; + /** + * 合同初始流水号(该部门当年首个合同时从此号起编,范围1-999) + */ + @Schema(description = "合同初始流水号") + @TableField(updateStrategy = FieldStrategy.ALWAYS) + private Integer contractInitialSerial; + /** * 组织简称 */ diff --git a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/OaOrgSyncPageVO.java b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/OaOrgSyncPageVO.java index d8390cf5..b58f8e73 100644 --- a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/OaOrgSyncPageVO.java +++ b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/OaOrgSyncPageVO.java @@ -69,4 +69,22 @@ public class OaOrgSyncPageVO implements Serializable { @Schema(description = "增量同步使用的 OA modified 起始时间,后续分页需原样回传") private String modifiedSince; + + @Schema(description = "全量同步结束后原始数据层级 JSON 文件路径") + private String dumpFilePath; + + @Schema(description = "当前同步的公司 OA 主键(部门阶段按公司轮询时使用)") + private String companyOaId; + + @Schema(description = "当前公司序号,从 1 开始") + private Integer companyIndex; + + @Schema(description = "待同步公司总数") + private Integer companyTotal; + + @Schema(description = "当前公司的部门是否已同步完") + private Boolean companyFinished; + + @Schema(description = "下一公司 OA 主键;当前公司完成后回传,前端据此切换并重置页码") + private String nextCompanyOaId; } diff --git a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/OaPersonSyncPageVO.java b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/OaPersonSyncPageVO.java index b57811da..d92f413b 100644 --- a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/OaPersonSyncPageVO.java +++ b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/OaPersonSyncPageVO.java @@ -66,4 +66,7 @@ public class OaPersonSyncPageVO implements Serializable { @Schema(description = "增量同步使用的 OA modified 起始时间,后续分页需原样回传") private String modifiedSince; + + @Schema(description = "全量同步结束后原始数据层级 JSON 文件路径") + private String dumpFilePath; } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/FormalSettlementSaveRequest.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/FormalSettlementSaveRequest.java index 73ba9991..d001adb4 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/FormalSettlementSaveRequest.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/FormalSettlementSaveRequest.java @@ -32,6 +32,8 @@ public class FormalSettlementSaveRequest implements Serializable { private String remark; private List summaryFees; private List invoices; + /** 项目预付核销 */ + private List projectAdvanceWriteOffs; @Data public static class DetailAdjustment implements Serializable { diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PaymentApplicationSaveRequest.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PaymentApplicationSaveRequest.java index bb453e14..d69690f8 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PaymentApplicationSaveRequest.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PaymentApplicationSaveRequest.java @@ -35,6 +35,9 @@ public class PaymentApplicationSaveRequest implements Serializable { private Long settlementId; private List settlementIds; private Long preSettlementId; + private List preSettlementIds; + /** 预结算单号。驳回重提时用于在主键失真后找回预结算单 */ + private String preSettlementNo; private Long projectId; private String projectName; private Long deptId; @@ -60,4 +63,6 @@ public class PaymentApplicationSaveRequest implements Serializable { private String remark; private List invoices; private List paymentRecords; + /** 多张结算单时按行提交的付款金额 */ + private List settlementLines; } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PaymentApplicationSettlementLineRequest.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PaymentApplicationSettlementLineRequest.java new file mode 100644 index 00000000..78f28a11 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PaymentApplicationSettlementLineRequest.java @@ -0,0 +1,43 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.pojo.dto; + +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.math.BigDecimal; + +/** 付款申请多结算单付款明细。 @author Chill */ +@Data +public class PaymentApplicationSettlementLineRequest implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + private Long formalSettlementId; + private Long preSettlementId; + private BigDecimal paymentRatio; + private BigDecimal appliedAmount; +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PersonCertSubmitRequest.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PersonCertSubmitRequest.java index c33d030f..01cc4f53 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PersonCertSubmitRequest.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PersonCertSubmitRequest.java @@ -51,6 +51,12 @@ public class PersonCertSubmitRequest implements Serializable { private String realName; @Schema(description = "身份证号") private String idCardNo; + @Schema(description = "出生年月") + private LocalDate birthday; + @Schema(description = "住址-区划:省市区名称拼接串") + private String addressRegion; + @Schema(description = "住址-详细地址") + private String address; @Schema(description = "身份证正面照") private String idCardFrontUrl; @Schema(description = "身份证反面照") diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PreSettlementDetailAdjustRequest.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PreSettlementDetailAdjustRequest.java index bbfddec7..8d2383a9 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PreSettlementDetailAdjustRequest.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PreSettlementDetailAdjustRequest.java @@ -33,7 +33,7 @@ public class PreSettlementDetailAdjustRequest implements Serializable { @Schema(description = "预结算明细ID") private Long detailId; - @Schema(description = "调整原因") + @Schema(description = "备注(写入结算明细备注,非必填)") private String changeReason; @Schema(description = "明细费用行") diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PreSettlementSaveRequest.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PreSettlementSaveRequest.java index dbe0667d..b2c670b7 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PreSettlementSaveRequest.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PreSettlementSaveRequest.java @@ -76,6 +76,9 @@ public class PreSettlementSaveRequest implements Serializable { @Schema(description = "结算合计费用") private List summaryFees; + @Schema(description = "项目预付核销") + private List projectAdvanceWriteOffs; + @Data @Schema(description = "结算合计费用") public static class SummaryFee implements Serializable { diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ReceivablePayableFeeCalculateRequest.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ReceivablePayableFeeCalculateRequest.java index 640ac839..04f1cd42 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ReceivablePayableFeeCalculateRequest.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ReceivablePayableFeeCalculateRequest.java @@ -63,4 +63,10 @@ public class ReceivablePayableFeeCalculateRequest implements Serializable { @Schema(description = "动态费用项目") private Map feeItems; + @Schema(description = "运输单价") + private BigDecimal unitPrice; + + @Schema(description = "本次调整字段:transportQuantity、mileage、unitPrice 或费用项名称") + private String changedField; + } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/SettlementImportBatchRequest.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/SettlementImportBatchRequest.java new file mode 100644 index 00000000..2834e4cb --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/SettlementImportBatchRequest.java @@ -0,0 +1,107 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.pojo.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; +import java.util.Map; + +/** + * 导入结算单请求(列表筛选 + 确认/草稿入参) + * + * @author Chill + */ +@Data +@Schema(description = "导入结算单请求") +public class SettlementImportBatchRequest implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "主键") + private Long id; + + @Schema(description = "批次号") + private String batchNo; + + @Schema(description = "项目ID") + private Long projectId; + + @Schema(description = "项目名称") + private String projectName; + + @Schema(description = "结算类型 receivable/payable") + private String settlementType; + + @Schema(description = "结算单类型 formal/pre,应收仅 formal") + private String settlementBillType; + + @Schema(description = "客户合同ID") + private Long customerContractId; + + @Schema(description = "客户合同名称") + private String customerContractName; + + @Schema(description = "客户名称") + private String customerName; + + @Schema(description = "承运商合同ID") + private Long carrierContractId; + + @Schema(description = "承运商合同名称") + private String carrierContractName; + + @Schema(description = "承运商名称") + private String carrierName; + + @Schema(description = "运单模式:new=新运单,history=历史运单") + private String waybillMode; + + @Schema(description = "导入状态") + private String importStatus; + + @Schema(description = "备注") + private String remark; + + @Schema(description = "动态费用列名") + private List feeItemNames; + + @Schema(description = "导入明细行") + private List> rows; + + @Schema(description = "分批追加") + private Boolean append; + + @Schema(description = "列表筛选:项目名称") + private String projectNameLike; + + @Schema(description = "列表筛选:创建人") + private Long createUser; +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/SettlementImportVoidRequest.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/SettlementImportVoidRequest.java new file mode 100644 index 00000000..bb330c14 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/SettlementImportVoidRequest.java @@ -0,0 +1,51 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.pojo.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 导入结算单作废请求 + * + * @author Chill + */ +@Data +@Schema(description = "导入结算单作废请求") +public class SettlementImportVoidRequest implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "批次主键") + private Long id; + + @Schema(description = "作废原因") + private String reason; +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/SettlementProjectAdvanceWriteOffRequest.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/SettlementProjectAdvanceWriteOffRequest.java new file mode 100644 index 00000000..be281de0 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/SettlementProjectAdvanceWriteOffRequest.java @@ -0,0 +1,53 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.pojo.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.math.BigDecimal; + +/** + * 结算单项目预付核销保存行 + * + * @author Chill + */ +@Data +@Schema(description = "结算单项目预付核销保存行") +public class SettlementProjectAdvanceWriteOffRequest implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "项目预付付款申请ID") + private Long paymentApplicationId; + + @Schema(description = "本次核销金额") + private BigDecimal writeOffAmount; + +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/FormalSettlement.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/FormalSettlement.java index 0a03304b..cc4ddd1a 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/FormalSettlement.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/FormalSettlement.java @@ -172,4 +172,12 @@ public class FormalSettlement extends TenantEntity { * 作废原因,驳回时记录驳回原因 */ private String voidReason; + /** + * 来源原正式结算单号:结算调整重推生成的新单,指向被调整的原单 + */ + private String originFormalSettlementNo; + /** + * 调整状态:adjusted-已被结算调整(原单标记) + */ + private String adjustStatus; } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/FormalSettlementChangeRecord.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/FormalSettlementChangeRecord.java index b3834bc4..8d95679f 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/FormalSettlementChangeRecord.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/FormalSettlementChangeRecord.java @@ -25,6 +25,10 @@ public class FormalSettlementChangeRecord extends TenantEntity { private Integer lineNo; private String operationType; private String changeContent; + /** 变更前数据JSON */ + private String beforeData; + /** 变更后数据JSON */ + private String afterData; private String changeReason; private String operatorName; private LocalDateTime changeTime; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/FormalSettlementDetail.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/FormalSettlementDetail.java index 899dffe1..96f273cd 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/FormalSettlementDetail.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/FormalSettlementDetail.java @@ -8,7 +8,9 @@ */ package org.springblade.transport.pojo.entity; +import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableName; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; import org.springblade.core.tenant.mp.TenantEntity; @@ -123,6 +125,12 @@ public class FormalSettlementDetail extends TenantEntity { * 批次号 */ private String batchNo; + /** + * 计费方式(来源应收应付货物费用计费类型,非本表字段) + */ + @TableField(exist = false) + @Schema(description = "计费方式") + private String billingType; /** * 运输单价 */ diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/FormalSettlementDetailFee.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/FormalSettlementDetailFee.java index c4e62e26..0dcae4f5 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/FormalSettlementDetailFee.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/FormalSettlementDetailFee.java @@ -8,7 +8,9 @@ */ package org.springblade.transport.pojo.entity; +import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableName; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; import org.springblade.core.tenant.mp.TenantEntity; @@ -70,6 +72,12 @@ public class FormalSettlementDetailFee extends TenantEntity { * 费用项JSON */ private String feeItemsJson; + /** + * 命中计费规则JSON(来源费用带回,非本表字段) + */ + @TableField(exist = false) + @Schema(description = "命中计费规则JSON") + private String billingRulesJson; /** * 原金额 */ diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/PaymentApplicationSettlement.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/PaymentApplicationSettlement.java index 0f4dd8ad..08f967b8 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/PaymentApplicationSettlement.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/PaymentApplicationSettlement.java @@ -1,5 +1,6 @@ package org.springblade.transport.pojo.entity; +import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import lombok.EqualsAndHashCode; @@ -17,7 +18,12 @@ public class PaymentApplicationSettlement extends TenantEntity { private Long paymentApplicationId; private Long formalSettlementId; private String formalSettlementNo; + private Long preSettlementId; + private String preSettlementNo; private BigDecimal settlementAmount; private BigDecimal appliedAmount; private BigDecimal paidAmount; + /** 查看时回填的可付款金额,不落库 */ + @TableField(exist = false) + private BigDecimal payableAmount; } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/PreSettlementDetail.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/PreSettlementDetail.java index 2539ca07..76c5653c 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/PreSettlementDetail.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/PreSettlementDetail.java @@ -9,6 +9,7 @@ */ package org.springblade.transport.pojo.entity; +import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableName; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; @@ -99,6 +100,10 @@ public class PreSettlementDetail extends TenantEntity { @Schema(description = "批次号") private String batchNo; + @TableField(exist = false) + @Schema(description = "计费方式") + private String billingType; + @Schema(description = "运输单价") private BigDecimal unitPrice; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/SettlementAdjustment.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/SettlementAdjustment.java index 2b7834af..888fcce8 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/SettlementAdjustment.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/SettlementAdjustment.java @@ -30,6 +30,10 @@ public class SettlementAdjustment extends TenantEntity { private String currentNode; private String currentProcessor; private String kingdeeSyncStatus; + /** + * 金蝶单据号(未推送场景回填原结算单金蝶单号;重推场景回填新单金蝶单号) + */ + private String kingdeeBillNo; private String attachmentsJson; private String remark; private LocalDateTime approvedTime; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/SettlementImportBatch.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/SettlementImportBatch.java new file mode 100644 index 00000000..9cecf4af --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/SettlementImportBatch.java @@ -0,0 +1,118 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.pojo.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springblade.core.tenant.mp.TenantEntity; + +import java.io.Serial; + +/** + * 导入结算单批次 + * + * @author Chill + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("blade_settlement_import_batch") +@Schema(description = "导入结算单批次") +public class SettlementImportBatch extends TenantEntity { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "批次号") + private String batchNo; + + @Schema(description = "项目ID") + private Long projectId; + + @Schema(description = "项目名称") + private String projectName; + + @Schema(description = "结算类型 receivable/payable") + private String settlementType; + + @Schema(description = "结算单类型 formal/pre") + private String settlementBillType; + + @Schema(description = "客户合同ID") + private Long customerContractId; + + @Schema(description = "客户合同名称") + private String customerContractName; + + @Schema(description = "客户名称") + private String customerName; + + @Schema(description = "承运商合同ID") + private Long carrierContractId; + + @Schema(description = "承运商合同名称") + private String carrierContractName; + + @Schema(description = "承运商名称") + private String carrierName; + + @Schema(description = "运单模式 new") + private String waybillMode; + + @Schema(description = "导入状态 draft/completed/voided") + private String importStatus; + + @Schema(description = "运单数量") + private Integer waybillCount; + + @Schema(description = "明细数量") + private Integer detailCount; + + @Schema(description = "正式结算单ID") + private Long formalSettlementId; + + @Schema(description = "正式结算单号") + private String formalSettlementNo; + + @Schema(description = "预结算单ID") + private Long preSettlementId; + + @Schema(description = "预结算单号") + private String preSettlementNo; + + @Schema(description = "动态费用列名JSON") + private String feeItemNamesJson; + + @Schema(description = "导入明细行JSON") + private String rowsJson; + + @Schema(description = "备注") + private String remark; + + @Schema(description = "关联运单导入批次ID") + private Long waybillImportBatchId; +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/SettlementProjectAdvanceWriteOff.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/SettlementProjectAdvanceWriteOff.java new file mode 100644 index 00000000..57be9c22 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/SettlementProjectAdvanceWriteOff.java @@ -0,0 +1,90 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.pojo.entity; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springblade.core.tenant.mp.TenantEntity; + +import java.io.Serial; +import java.math.BigDecimal; +import java.util.Date; + +/** + * 结算单项目预付核销实体类 + * + * @author Chill + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("blade_settlement_project_advance_write_off") +@Schema(description = "结算单项目预付核销") +public class SettlementProjectAdvanceWriteOff extends TenantEntity { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "结算单类型:pre/formal") + private String settlementType; + + @JsonSerialize(using = ToStringSerializer.class) + @Schema(description = "预结算/正式结算单ID") + private Long settlementId; + + @JsonSerialize(using = ToStringSerializer.class) + @Schema(description = "项目预付付款申请ID") + private Long paymentApplicationId; + + @Schema(description = "预付单号") + private String paymentNo; + + @Schema(description = "项目预付金额") + private BigDecimal projectAdvanceAmount; + + @Schema(description = "本次核销金额") + private BigDecimal writeOffAmount; + + @Schema(description = "金蝶预付单号") + private String kingdeeBillNo; + + @TableField(exist = false) + @Schema(description = "可核销金额(动态计算)") + private BigDecimal availableWriteOffAmount; + + @TableField(exist = false) + @Schema(description = "预付单创建人姓名") + private String createUserName; + + @TableField(exist = false) + @Schema(description = "预付单创建时间(展示用)") + private Date paymentCreateTime; + +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/TransportReconciliationExternal.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/TransportReconciliationExternal.java index 353febe3..7cc632d4 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/TransportReconciliationExternal.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/TransportReconciliationExternal.java @@ -6,6 +6,8 @@ */ package org.springblade.transport.pojo.entity; +import com.baomidou.mybatisplus.annotation.FieldStrategy; +import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import lombok.EqualsAndHashCode; @@ -41,6 +43,7 @@ public class TransportReconciliationExternal extends TenantEntity { private BigDecimal freightAmount; private String feeItemsJson; private BigDecimal settlementAmount; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private Boolean suspectedDuplicate; private String matchStatus; private Long matchedInternalId; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/Waybill.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/Waybill.java index d79f1502..a3157095 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/Waybill.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/Waybill.java @@ -249,6 +249,9 @@ public class Waybill extends TenantEntity { @Schema(description = "导入批次ID") private Long importBatchId; + @Schema(description = "导入结算单批次ID") + private Long settlementImportBatchId; + @Schema(description = "关联单号") private String relationNo; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminWaybillCardVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminWaybillCardVO.java index 9e63df31..b0dca1a4 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminWaybillCardVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminWaybillCardVO.java @@ -92,4 +92,30 @@ public class AdminWaybillCardVO implements Serializable { @Schema(description = "采购方是否已付款(预留)") private Boolean buyerPaid; + /* ===== 运次(一趟车)字段:列表按运次下发,卡片的展示单号与路线取这些 ===== */ + + @Schema(description = "运次主键:配载单主键或运单主键,按字符串下发(19 位雪花号按数字下发会被前端取整)") + private String tripId; + + @Schema(description = "配载单号,未配载为空") + private String loadingNo; + + @Schema(description = "展示单号:有配载取配载单号,否则取运单号") + private String tripNo; + + @Schema(description = "途经站点:整趟车中间各站按顺序以「 → 」连接,无中间站为空") + private String viaName; + + @Schema(description = "站点数(整趟车,含起终点)") + private Integer stationCount; + + @Schema(description = "货物汇总(整趟车,同名货物合并且单位一致时取数量合计)") + private String cargoSummary; + + @Schema(description = "合计重量(整趟车同单位时才有值,单位不一致为空)") + private String totalWeight; + + @Schema(description = "子运单数(可见口径;配载运次即整趟车的子单数)") + private Integer waybillCount; + } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminWaybillDetailVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminWaybillDetailVO.java index ebbd9cce..ff601f14 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminWaybillDetailVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminWaybillDetailVO.java @@ -66,9 +66,12 @@ public class AdminWaybillDetailVO implements Serializable { @Schema(description = "卸货地址") private String unloadAddress; - @Schema(description = "货物名称") + @Schema(description = "货物名称:运单上录入的全部货名以「、」连接") private String cargoName; + @Schema(description = "货物名称清单:运单上录入的全部货物,同名的只留一次") + private List cargoNames; + @Schema(description = "货物数量(带单位)") private String cargoQuantity; @@ -135,6 +138,29 @@ public class AdminWaybillDetailVO implements Serializable { @Schema(description = "途打卡记录") private List enrouteRecords; + /* ===== 运次(一趟车)字段:详情按运次下发,与司机端接单页同一口径 ===== */ + + @Schema(description = "运次主键:配载单主键或运单主键,按字符串下发(19 位雪花号按数字下发会被前端取整)") + private String tripId; + + @Schema(description = "配载单号,未配载为空") + private String loadingNo; + + @Schema(description = "展示单号:有配载取配载单号,否则取运单号") + private String tripNo; + + @Schema(description = "途经站点:整趟车中间各站按顺序以「 → 」连接,无中间站为空") + private String viaName; + + @Schema(description = "站点数(整趟车,含起终点)") + private Integer stationCount; + + @Schema(description = "货物汇总(整趟车,同名货物合并且单位一致时取数量合计)") + private String cargoSummary; + + @Schema(description = "站点任务安排(整趟车按实际途经顺序,第 1 站即 ①;配载运次含每站装卸与货物条目)") + private List stations; + @Data @Schema(description = "装卸点") public static class AdminRoutePointVO implements Serializable { diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/CargoLineVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/CargoLineVO.java new file mode 100644 index 00000000..831e6313 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/CargoLineVO.java @@ -0,0 +1,48 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.pojo.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 货物明细行:一条货物一行 + *

+ * 详情页逐行展示「货物名称 + 数量单位」,名称与数量分开给,前端不需要再从汇总串里拆。 + */ +@Data +@Schema(description = "货物明细行") +public class CargoLineVO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "货物名称") + private String cargoName; + + @Schema(description = "数量(含单位,如 1000吨);录入时只填了名称则为空") + private String quantity; +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverPunchNodeVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverPunchNodeVO.java index 03f85510..eaeec543 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverPunchNodeVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverPunchNodeVO.java @@ -67,9 +67,15 @@ public class DriverPunchNodeVO implements Serializable { @Schema(description = "不可打卡的原因,供司机端展示") private String lockReason; + @Schema(description = "运单已结束(完成/取消):卡片仅展示、不可提交") + private Boolean ended; + @Schema(description = "是否展示该卡(在途可能因频次/时段隐藏)") private Boolean visible; + @Schema(description = "打这张卡即完成运单:该运单的末位可打卡节点,且过程配置不要求业务员确认回单") + private Boolean finishOnPunch; + @Schema(description = "是否默认展开(仅第一个可打卡节点)") private Boolean defaultExpanded; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverWaybillCardVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverWaybillCardVO.java index 57e394d1..23432c6a 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverWaybillCardVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverWaybillCardVO.java @@ -67,6 +67,9 @@ public class DriverWaybillCardVO implements Serializable { @Schema(description = "货物类别") private String cargoCategory; + @Schema(description = "货物明细(详情返回):一条一行,同名同单位的货物已合并数量") + private List cargos; + @Schema(description = "重量(带单位)") private String weight; @@ -150,6 +153,29 @@ public class DriverWaybillCardVO implements Serializable { @Schema(description = "运输方式文案:公路运输等") private String transportType; + @Schema(description = "运输组织:common普通 / load配载") + private String transportOrgType; + + /* ===== 运次(一趟车)字段:运次接口返回,旧运单接口不填 ===== */ + + @Schema(description = "运次主键:配载单主键或运单主键,按字符串下发(19 位雪花号按数字下发会被前端取整)") + private String tripId; + + @Schema(description = "配载单号,未配载为空") + private String loadingNo; + + @Schema(description = "展示单号:有配载取配载单号,否则取运单号") + private String tripNo; + + @Schema(description = "途经站点:整趟车中间各站按顺序以「 → 」连接") + private String viaName; + + @Schema(description = "站点数(整趟车,含起终点)") + private Integer stationCount; + + @Schema(description = "货物汇总(整趟车,同名货物合并且单位一致时取数量合计)") + private String cargoSummary; + @Schema(description = "计划发货时间") private String planShipTime; @@ -165,6 +191,9 @@ public class DriverWaybillCardVO implements Serializable { @Schema(description = "装卸点(详情返回)") private List routePoints; + @Schema(description = "站点任务安排(详情返回,按实际途经顺序,第 1 站即 ①;有配载时含每站装卸与货物条目)") + private List stations; + @Schema(description = "过程配置 JSON(详情返回,供前端兜底推导打卡节点)") private String processJson; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/FormalSettlementVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/FormalSettlementVO.java index 5929669a..1c68aef6 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/FormalSettlementVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/FormalSettlementVO.java @@ -20,10 +20,12 @@ import org.springblade.transport.pojo.entity.FormalSettlementSource; import org.springblade.transport.pojo.entity.FormalSettlementPayment; import org.springblade.transport.pojo.entity.FormalSettlementSummaryFee; import org.springblade.transport.pojo.entity.InvoiceApplicationInvoice; +import org.springblade.transport.pojo.entity.SettlementProjectAdvanceWriteOff; import org.springframework.format.annotation.DateTimeFormat; import java.io.Serial; import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.List; /** @@ -33,7 +35,7 @@ import java.util.List; */ @Data @EqualsAndHashCode(callSuper = true) -public class FormalSettlementVO extends FormalSettlement { +public class FormalSettlementVO extends FormalSettlement implements KingdeeUnlockMark { @Serial private static final long serialVersionUID = 1L; @TableField(exist = false) @DateTimeFormat(pattern = "yyyy-MM-dd") private LocalDate createStartDate; @TableField(exist = false) @DateTimeFormat(pattern = "yyyy-MM-dd") private LocalDate createEndDate; @@ -50,6 +52,14 @@ public class FormalSettlementVO extends FormalSettlement { private String kingdeeLockFailReason; @TableField(exist = false) @Schema(description = "金蝶锁重试次数(达上限5后不再自动补偿,需重推同步金蝶)") private Integer kingdeeTryCount; + @TableField(exist = false) @Schema(description = "金蝶解锁时间(非空代表该单曾被金蝶解锁/删单)") + private LocalDateTime kingdeeUnlockTime; + @TableField(exist = false) @Schema(description = "金蝶侧解锁操作人(回调报文 operator)") + private String kingdeeUnlockOperator; + @TableField(exist = false) @Schema(description = "解锁动作名称:金蝶删单/金蝶锁单") + private String kingdeeUnlockOperationName; + @TableField(exist = false) @Schema(description = "调整状态名称:adjusted-已调整") + private String adjustStatusName; @TableField(exist = false) private String preSettlementNos; @TableField(exist = false) private String preSettlementNo; @TableField(exist = false) private List sources; @@ -57,9 +67,12 @@ public class FormalSettlementVO extends FormalSettlement { @TableField(exist = false) private List summaryFees; @TableField(exist = false) private List payments; @TableField(exist = false) private List invoices; + @TableField(exist = false) @Schema(description = "项目预付核销") + private List projectAdvanceWriteOffs; @TableField(exist = false) @Schema(description = "开票申请经金蝶回写的发票结果(含文件地址,只读展示)") private List issuedInvoices; @TableField(exist = false) private List paymentApplications; + @TableField(exist = false) private List advancePayments; @TableField(exist = false) private List adjustments; @TableField(exist = false) private List changeRecords; } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/InvoiceApplicationVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/InvoiceApplicationVO.java index c54945aa..d195bbc5 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/InvoiceApplicationVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/InvoiceApplicationVO.java @@ -35,6 +35,7 @@ import org.springblade.transport.pojo.entity.InvoiceApplicationRecord; import org.springblade.transport.pojo.entity.InvoiceApplicationSettlement; import java.io.Serial; +import java.time.LocalDateTime; import java.util.List; /** @@ -44,7 +45,7 @@ import java.util.List; */ @Data @EqualsAndHashCode(callSuper = true) -public class InvoiceApplicationVO extends InvoiceApplication { +public class InvoiceApplicationVO extends InvoiceApplication implements KingdeeUnlockMark { @Serial private static final long serialVersionUID = 1L; @TableField(exist = false) private String settlementNos; @TableField(exist = false) private String approvalStatusName; @@ -55,6 +56,12 @@ public class InvoiceApplicationVO extends InvoiceApplication { @TableField(exist = false) private String kingdeeLockStatus; @TableField(exist = false) private String kingdeeLockFailReason; @TableField(exist = false) private Integer kingdeeTryCount; + /** 金蝶解锁终态标识(unlocked 锁记录回填):解锁时间非空即代表该单曾被金蝶解锁/删单 */ + @TableField(exist = false) private LocalDateTime kingdeeUnlockTime; + /** 金蝶侧解锁操作人(回调报文 operator) */ + @TableField(exist = false) private String kingdeeUnlockOperator; + /** 解锁动作名称:金蝶删单/金蝶锁单 */ + @TableField(exist = false) private String kingdeeUnlockOperationName; @TableField(exist = false) private String createUserName; @TableField(exist = false) private List settlements; @TableField(exist = false) private List sheets; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/KingdeeUnlockMark.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/KingdeeUnlockMark.java new file mode 100644 index 00000000..ca15b000 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/KingdeeUnlockMark.java @@ -0,0 +1,36 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.pojo.vo; + +import java.time.LocalDateTime; + +/** + * 金蝶解锁终态标识展示契约:需在页面展示「金蝶删单/解锁」标识的 VO 实现之, + * 由 {@code KingdeeBillLockService#fillUnlockMarks} 统一回填(列表/详情共用) + * + * @author Chill + */ +public interface KingdeeUnlockMark { + + /** + * 金蝶解锁时间(unlocked 锁记录回填),非空即代表该单曾被金蝶解锁/删单 + */ + void setKingdeeUnlockTime(LocalDateTime kingdeeUnlockTime); + + /** + * 金蝶侧解锁操作人(回调报文 operator) + */ + void setKingdeeUnlockOperator(String kingdeeUnlockOperator); + + /** + * 解锁动作名称:金蝶删单/金蝶锁单 + */ + void setKingdeeUnlockOperationName(String kingdeeUnlockOperationName); +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/PaymentApplicationReferenceAmountVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/PaymentApplicationReferenceAmountVO.java index 367ae478..d750370b 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/PaymentApplicationReferenceAmountVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/PaymentApplicationReferenceAmountVO.java @@ -27,4 +27,6 @@ public class PaymentApplicationReferenceAmountVO implements Serializable { @Schema(description = "结算金额") private BigDecimal settlementAmount; @Schema(description = "累计申请付款金额") private BigDecimal cumulativeAppliedAmount; @Schema(description = "可付款金额") private BigDecimal payableAmount; + /** 该结算单当前为第几次付款(含本次,已作废不计) */ + @Schema(description = "付款次序") private Integer paymentSequence; } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/PaymentApplicationVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/PaymentApplicationVO.java index d8bc998f..8c079128 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/PaymentApplicationVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/PaymentApplicationVO.java @@ -29,12 +29,13 @@ import org.springblade.transport.pojo.entity.PaymentApplicationSettlement; import java.io.Serial; import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.List; /** 付款申请视图。 @author Chill */ @Data @EqualsAndHashCode(callSuper = true) -public class PaymentApplicationVO extends PaymentApplication { +public class PaymentApplicationVO extends PaymentApplication implements KingdeeUnlockMark { @Serial private static final long serialVersionUID = 1L; @TableField(exist = false) private LocalDate applyStartDate; @TableField(exist = false) private LocalDate applyEndDate; @@ -49,6 +50,12 @@ public class PaymentApplicationVO extends PaymentApplication { @TableField(exist = false) private String kingdeeLockStatus; @TableField(exist = false) private String kingdeeLockFailReason; @TableField(exist = false) private Integer kingdeeTryCount; + /** 金蝶解锁终态标识(unlocked 锁记录回填):解锁时间非空即代表该单曾被金蝶解锁/删单,供页面展示明显标识 */ + @TableField(exist = false) private LocalDateTime kingdeeUnlockTime; + /** 金蝶侧解锁操作人(回调报文 operator) */ + @TableField(exist = false) private String kingdeeUnlockOperator; + /** 解锁动作名称:金蝶删单/金蝶锁单 */ + @TableField(exist = false) private String kingdeeUnlockOperationName; @TableField(exist = false) private List invoices; @TableField(exist = false) private List paymentRecords; @TableField(exist = false) private List settlements; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/PreSettlementVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/PreSettlementVO.java index f4573a63..36955339 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/PreSettlementVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/PreSettlementVO.java @@ -35,6 +35,7 @@ import org.springblade.transport.pojo.entity.PreSettlementChangeRecord; import org.springblade.transport.pojo.entity.PreSettlementDetail; import org.springblade.transport.pojo.entity.PreSettlementDetailFee; import org.springblade.transport.pojo.entity.PreSettlementSummaryFee; +import org.springblade.transport.pojo.entity.SettlementProjectAdvanceWriteOff; import org.springframework.format.annotation.DateTimeFormat; import java.io.Serial; @@ -100,6 +101,10 @@ public class PreSettlementVO extends PreSettlement { @Schema(description = "预付信息") private List advances; + @TableField(exist = false) + @Schema(description = "项目预付核销") + private List projectAdvanceWriteOffs; + @TableField(exist = false) @Schema(description = "变更记录") private List changeRecords; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ProjectAdvanceWriteOffOptionVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ProjectAdvanceWriteOffOptionVO.java new file mode 100644 index 00000000..42d85b13 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ProjectAdvanceWriteOffOptionVO.java @@ -0,0 +1,72 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.pojo.vo; + +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.Date; + +/** + * 可选项目预付核销选项 + * + * @author Chill + */ +@Data +@Schema(description = "可选项目预付核销选项") +public class ProjectAdvanceWriteOffOptionVO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @JsonSerialize(using = ToStringSerializer.class) + @Schema(description = "付款申请ID") + private Long paymentApplicationId; + + @Schema(description = "预付单号") + private String paymentNo; + + @Schema(description = "项目预付金额") + private BigDecimal projectAdvanceAmount; + + @Schema(description = "可核销金额") + private BigDecimal availableWriteOffAmount; + + @Schema(description = "金蝶预付单号") + private String kingdeeBillNo; + + @Schema(description = "创建人姓名") + private String createUserName; + + @Schema(description = "创建时间") + private Date createTime; + +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ReceiptFlowVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ReceiptFlowVO.java index f32a9491..3f105412 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ReceiptFlowVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ReceiptFlowVO.java @@ -39,6 +39,7 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; +import java.util.Map; /** * 收款流水视图实体类 @@ -91,4 +92,9 @@ public class ReceiptFlowVO extends KingdeeReceiptFlow { private BigDecimal receiptAmountEnd; @TableField(exist = false) private List claimRecords = new ArrayList<>(); + /** + * 本流水历史关联且仍有剩余可认领金额的应收正式结算单(继续认领时预填) + */ + @TableField(exist = false) + private List> relatedSettlements = new ArrayList<>(); } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ReceivablePayableDetailVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ReceivablePayableDetailVO.java index 44857626..5fe0ab5d 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ReceivablePayableDetailVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ReceivablePayableDetailVO.java @@ -73,6 +73,10 @@ public class ReceivablePayableDetailVO extends ReceivablePayableDetail { @Schema(description = "状态名称") private String settlementStatusName; + @TableField(exist = false) + @Schema(description = "计费方式") + private String billingType; + @TableField(exist = false) @Schema(description = "费用项目") private Map feeItems; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/SettlementAdjustmentVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/SettlementAdjustmentVO.java index ccbb245e..3f00b3a4 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/SettlementAdjustmentVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/SettlementAdjustmentVO.java @@ -17,7 +17,6 @@ public class SettlementAdjustmentVO extends SettlementAdjustment { private String approvalStatusName; private String settlementTypeName; private String createUserName; - private String kingdeeBillNo; private List details; private List formalDetails; } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/SettlementAdvancePaymentVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/SettlementAdvancePaymentVO.java new file mode 100644 index 00000000..9ec5616e --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/SettlementAdvancePaymentVO.java @@ -0,0 +1,72 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.pojo.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.Date; + +/** + * 结算单统一预付付款记录(进度预付 / 项目预付核销,供付款记录合并展示) + * + * @author Chill + */ +@Data +@Schema(description = "结算单统一预付付款记录") +public class SettlementAdvancePaymentVO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "记录类型:progress_advance 进度预付 / project_advance 项目预付核销") + private String recordType; + + @Schema(description = "付款单号") + private String paymentNo; + + @Schema(description = "申请金额(进度预付为申请预付金额,项目预付为本次核销金额)") + private BigDecimal appliedAmount; + + @Schema(description = "已付款金额(底层付款申请实付)") + private BigDecimal paidAmount; + + @Schema(description = "单据状态") + private String billStatus; + + @Schema(description = "金蝶单据号") + private String kingdeeBillNo; + + @Schema(description = "创建人姓名") + private String createUserName; + + @Schema(description = "创建时间(统一取底层付款申请创建时间)") + private Date createTime; + +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/SettlementImportBatchVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/SettlementImportBatchVO.java new file mode 100644 index 00000000..6887da79 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/SettlementImportBatchVO.java @@ -0,0 +1,73 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.pojo.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springblade.transport.pojo.entity.SettlementImportBatch; + +import java.io.Serial; +import java.util.List; +import java.util.Map; + +/** + * 导入结算单批次视图 + * + * @author Chill + */ +@Data +@EqualsAndHashCode(callSuper = true) +@Schema(description = "导入结算单批次视图") +public class SettlementImportBatchVO extends SettlementImportBatch { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "创建人姓名") + private String createUserName; + + @Schema(description = "更新人姓名") + private String updateUserName; + + @Schema(description = "导入状态显示名") + private String importStatusName; + + @Schema(description = "结算类型显示名") + private String settlementTypeName; + + @Schema(description = "结算单类型显示名") + private String settlementBillTypeName; + + @Schema(description = "运单模式显示名") + private String waybillModeName; + + @Schema(description = "动态费用列名") + private List feeItemNames; + + @Schema(description = "导入明细行") + private List> rows; +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TripQueryVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TripQueryVO.java new file mode 100644 index 00000000..5027ada9 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TripQueryVO.java @@ -0,0 +1,104 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.pojo.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; +import java.util.List; +import java.util.Set; + +/** + * 运次查询条件 + *

+ * 司机端用 {@link #forDriver(String)} 按司机手机号收口;管理端用 {@link #forAdmin()} 不按司机收口。 + * 两者都不给时收口为空集——收口一旦缺省就等于把别人的运次发出去,故缺省不放行。 + */ +@Data +@Schema(description = "运次查询条件") +public class TripQueryVO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "运次主键(详情按它取单个运次)") + private String tripId; + + @Schema(description = "司机手机号") + private String driverPhone; + + @Schema(description = "是否不按司机收口(管理端)") + private boolean allDrivers; + + @Schema(description = "可见组织 ID 集合:非空时只出这些组织的运单行,空集合表示不按组织过滤") + private List deptIds; + + @Schema(description = "状态:0待接单/1进行中/2已完成/3已取消,不传为全部(不含已取消)") + private Integer status; + + @Schema(description = "多档状态:同上取值,非空时优先于 status(待处理列表要待接单与进行中两档)") + private List statuses; + + @Schema(description = "关键字:运单号 / 配载单号 / 起终点 / 车牌") + private String keyword; + + @Schema(description = "运输组织类型:load 公路配载 / common 普通运输,不传为全部(按子单行筛选)") + private String transportOrgType; + + @Schema(description = "异常筛选:exception 只看命中异常的子单 / normal 只看未命中的,不传为全部(按子单行筛选)") + private String exception; + + @Schema(description = "有未完成异常的子运单主键集合,配合 exception 使用(按子单行筛选)") + private Set exceptionWaybillIds; + + @Schema(description = "创建时间下界,按子单行筛选") + private Date createdFrom; + + @Schema(description = "创建时间上界,按子单行筛选") + private Date createdTo; + + @Schema(description = "是否排除「有子单被拒且这趟车还没结束」的运次(待处理列表用)") + private boolean excludeRejectedTrips; + + @Schema(description = "当前页,从 1 开始") + private Integer current; + + @Schema(description = "每页条数(按运次,不是运单)") + private Integer size; + + public static TripQueryVO forDriver(String driverPhone) { + TripQueryVO query = new TripQueryVO(); + query.setDriverPhone(driverPhone); + return query; + } + + public static TripQueryVO forAdmin() { + TripQueryVO query = new TripQueryVO(); + query.setAllDrivers(true); + return query; + } + +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TripStationVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TripStationVO.java new file mode 100644 index 00000000..7b795898 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TripStationVO.java @@ -0,0 +1,105 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.pojo.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.List; + +/** + * 运次站点(任务安排) + *

+ * 有配载单时按配载单的路线信息给出实际途经顺序,第 1 站即 ①;站点上的「装N / 卸N」指向配载单运单集合里的第 N 张子运单, + * 同一站可以既卸又装(装卸)。没有路线信息时回退到子运单的起讫点去重成站。 + *

+ * 站点上的货物条目取子运单,不取配载单的货物信息:配载单的货物条数与子运单不一定同数,行序也对不上站点编号。 + */ +@Data +@Schema(description = "运次站点") +public class TripStationVO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "站序,从 1 开始(展示为 ①②③)") + private Integer index; + + @Schema(description = "站名:展示地址优先,回退详细地址") + private String name; + + @Schema(description = "详细地址") + private String address; + + @Schema(description = "站点标记原文:装N / 卸N") + private List tags; + + @Schema(description = "操作类型:load 装货 / unload 卸货 / load_unload 装卸,无从判定时为空") + private String operationType; + + @Schema(description = "操作类型文案:装货 / 卸货 / 装卸") + private String operationTypeLabel; + + @Schema(description = "该站涉及的货物条目") + private List cargos; + + /** + * 站点货物条目 + */ + @Data + @Schema(description = "运次站点货物条目") + public static class TripCargoVO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "所属子运单主键,按字符串下发") + private String waybillId; + + @Schema(description = "所属运单号") + private String waybillNo; + + @Schema(description = "货物名称") + private String cargoName; + + @Schema(description = "货主名称") + private String customerName; + + @Schema(description = "数量") + private BigDecimal quantity; + + @Schema(description = "数量单位") + private String quantityUnit; + + @Schema(description = "该站的操作:load 装货 / unload 卸货") + private String operation; + + @Schema(description = "项目名称") + private String projectName; + + } + +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TripTabCountsVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TripTabCountsVO.java new file mode 100644 index 00000000..944c4ded --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TripTabCountsVO.java @@ -0,0 +1,55 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.pojo.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 运次 Tab 统计 + *

+ * 与司机端运单统计字段同名同义,但计数单位是运次(一卡一趟车),不是运单。 + */ +@Data +@Schema(description = "运次 Tab 统计") +public class TripTabCountsVO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "全部(待接单+进行中+已完成,不含已取消)") + private long all; + + @Schema(description = "待接单") + private long pending; + + @Schema(description = "进行中") + private long doing; + + @Schema(description = "已完成") + private long done; + +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TripVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TripVO.java new file mode 100644 index 00000000..a6523dfb --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TripVO.java @@ -0,0 +1,236 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.pojo.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.Date; +import java.util.List; + +/** + * 运次:一趟车的展示与操作单位 + *

+ * 有配载单时就是那张配载单连同它的全部子运单,没配载时就是一张运单;它没有自己的表。 + * 状态与接单需求都由子运单读时聚合,配载单上不落这两个字段。 + */ +@Data +@Schema(description = "运次") +public class TripVO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "运次主键:配载单主键或运单主键,按字符串下发(19 位雪花号按数字下发会被前端取整)") + private String id; + + @Schema(description = "展示单号:有配载取配载单号,否则取运单号") + private String tripNo; + + @Schema(description = "配载单号,未配载为空") + private String loadingNo; + + @Schema(description = "运输组织类型:load 公路配载 / common 普通运输") + private String transportOrgType; + + @Schema(description = "状态:0待接单/1运输中/2已完成/3已取消,子单集合为空时为空") + private Integer status; + + @Schema(description = "排序键:运次内子运单 create_time 的最大值") + private Date sortTime; + + @Schema(description = "是否需要接单:任一子运单的项目有接单节点即为是") + private Boolean requireAccept; + + @Schema(description = "接单态:accepted 已接单 / pending 待接单,无从判定时为空") + private String acceptStatus; + + @Schema(description = "子运单数(可见口径:已排除本司机拒单的子单)") + private int waybillCount; + + @Schema(description = "司机姓名") + private String driverName; + + @Schema(description = "司机手机号") + private String driverPhone; + + @Schema(description = "车牌号") + private String vehicleNo; + + @Schema(description = "挂车车牌号") + private String trailerVehicleNo; + + @Schema(description = "项目名称") + private String projectName; + + @Schema(description = "客户名称") + private String customerName; + + @Schema(description = "运输类型") + private String transportType; + + @Schema(description = "货物名称:整趟车全部货名以「、」连接,一格多名的历史写法已摊平") + private String cargoName; + + @Schema(description = "货物名称清单:整趟车全部货物,同名的只留一次,保持子单与录入顺序") + private List cargoNames; + + @Schema(description = "货物类型:整趟车全部类型以「、」连接") + private String cargoType; + + @Schema(description = "货物类型清单:整趟车全部货物类型,同类型的只留一次") + private List cargoTypes; + + @Schema(description = "发货地址") + private String departureAddress; + + @Schema(description = "途经地") + private String transitAddress; + + @Schema(description = "到货地") + private String arrivalAddress; + + @Schema(description = "起点名称:整趟车首站,展示地址优先、回退详细地址") + private String departureName; + + @Schema(description = "途经站点:中间各站按顺序以「 → 」连接,无中间站为空") + private String transitName; + + @Schema(description = "终点名称:整趟车末站,展示地址优先、回退详细地址") + private String arrivalName; + + @Schema(description = "站点数(整趟车,含起终点)") + private int stationCount; + + @Schema(description = "货物汇总:按货物名合并数量,同名同单位取合计") + private String cargoSummary; + + @Schema(description = "货物明细:一条货物一行,同名同单位的货物已合并数量") + private List cargos; + + @Schema(description = "合计重量:全部子运单同单位时为数量合计,单位不一致时为空") + private BigDecimal totalQuantity; + + @Schema(description = "合计重量的单位,与 totalQuantity 同时为空或同时有值") + private String quantityUnit; + + @Schema(description = "计重合计:全部货物同单位时取该单位合计,单位不一致时只合计「吨」项(其余单位不参与);无可用合计时为空") + private BigDecimal weighingQuantity; + + @Schema(description = "计重合计的单位,与 weighingQuantity 同时为空或同时有值") + private String weighingUnit; + + @Schema(description = "计划发货日期(配载单,未配载为空)") + private LocalDate estimatedStartDate; + + @Schema(description = "计划完成日期(配载单,未配载为空)") + private LocalDate estimatedEndDate; + + @Schema(description = "任务备注(配载单的运输说明)") + private String taskRemark; + + @Schema(description = "路线信息,仅单个运次详情返回(列表不带,省流量)") + private String routeJson; + + @Schema(description = "货物信息,仅单个运次详情返回(列表不带,省流量)") + private String goodsJson; + + @Schema(description = "站点(任务安排,按实际途经顺序),仅单个运次详情返回;列表用起/经/终与站点数") + private List stations; + + @Schema(description = "子运单(可见子单,按创建时间升序)") + private List waybills; + + /** + * 运次下的子运单 + *

+ * 接单事实仍落在每张子运单上,运次只是把它们的需要接单与接单态聚合起来。 + */ + @Data + @Schema(description = "运次下的子运单") + public static class TripWaybillVO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "运单主键,按字符串下发") + private String id; + + @Schema(description = "运单号") + private String waybillNo; + + @Schema(description = "业务状态") + private String businessStatus; + + @Schema(description = "接单状态:pending待接单/accepted已接单/rejected已拒绝") + private String acceptStatus; + + @Schema(description = "该运单是否需要接单确认") + private Boolean requireAccept; + + @Schema(description = "项目ID") + private Long projectId; + + @Schema(description = "项目名称") + private String projectName; + + @Schema(description = "货物名称:运单上录入的全部货名以「、」连接,一格多名的历史写法已摊平") + private String cargoName; + + @Schema(description = "货物名称清单:运单上录入的全部货物,同名的只留一次") + private List cargoNames; + + @Schema(description = "货物类型:主表首行的类型,完整清单见 cargoTypes") + private String cargoType; + + @Schema(description = "货物类型清单:运单上录入的全部货物类型,同类型的只留一次") + private List cargoTypes; + + @Schema(description = "数量") + private BigDecimal quantity; + + @Schema(description = "数量单位") + private String quantityUnit; + + @Schema(description = "发货地") + private String departureName; + + @Schema(description = "发货地址") + private String departureAddress; + + @Schema(description = "收货地") + private String arrivalName; + + @Schema(description = "收货地址") + private String arrivalAddress; + + @Schema(description = "创建时间") + private Date createTime; + + } + +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/WaybillImportBatchVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/WaybillImportBatchVO.java index 841236aa..b30c1973 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/WaybillImportBatchVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/WaybillImportBatchVO.java @@ -20,6 +20,12 @@ public class WaybillImportBatchVO extends WaybillImportBatch { private static final long serialVersionUID = 1L; private String importTypeName; private String statusName; + /** 结算状态:unsettled/partial/settled */ + @Schema(description = "结算状态") + private String settlementStatus; + /** 结算状态名称:未结算/部分结算/已结算 */ + @Schema(description = "结算状态名称") + private String settlementStatusName; private String createUserName; private String updateUserName; } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/WaybillVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/WaybillVO.java index ebba5343..a2ecf024 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/WaybillVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/WaybillVO.java @@ -29,6 +29,7 @@ import lombok.EqualsAndHashCode; import org.springblade.transport.pojo.entity.Waybill; import java.io.Serial; +import java.util.List; /** * 运单管理视图实体类 @@ -78,4 +79,12 @@ public class WaybillVO extends Waybill { @Schema(description = "是否仅查询未配载运单") private Integer onlyUnassignedLoading; + @TableField(exist = false) + @Schema(description = "是否仅查询可配载运单") + private Integer loadableOnly; + + @TableField(exist = false) + @Schema(description = "运单主键集合") + private List ids; + } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/service/ISettlementAdjustmentService.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/service/ISettlementAdjustmentService.java index aa4daff4..ec68737b 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/service/ISettlementAdjustmentService.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/service/ISettlementAdjustmentService.java @@ -17,7 +17,40 @@ public interface ISettlementAdjustmentService { Long saveDraft(SettlementAdjustmentSaveRequest request); void removeDraft(Long id); void submit(SettlementAdjustmentStatusRequest request); - void approve(SettlementAdjustmentStatusRequest request); + /** + * 审批通过:未推送场景回写原单并发布金蝶自动推送事件;已推送场景原单零改动仅追加变更记录。 + * + * @param request 审批请求 + * @return 已推送场景的手工冲销提示文案,未推送场景返回 null + */ + String approve(SettlementAdjustmentStatusRequest request); void returnBill(SettlementAdjustmentStatusRequest request); String repush(Long adjustmentId); + + /** + * 选单后金蝶冲销预检(软提示,不阻断建单):未推送 / 已推送已冲销 / 已推送未冲销; + * 金蝶查询异常降级返回「未校验」,由审批分流与重推闸门兜底 + * + * @param formalSettlementId 选定正式结算单id + * @return syncStatus / writtenOff / message + */ + Map kingdeeWriteOffPrecheck(Long formalSettlementId); + + /** + * 推送金蝶(未推送/生成失败场景):推送关联正式结算单,成功后由正式结算单回填调整单金蝶单号与同步状态 + * + * @param adjustmentId 结算调整单id + * @return 金蝶单据编号 + */ + String syncKingdee(Long adjustmentId); + + /** + * 重推克隆+应用(独立事务,由 repush 经自代理调用):克隆原单为新正式结算单并应用调整, + * 供推送失败重试时幂等复用 + * + * @param originalSettlementId 原正式结算单id + * @param adjustmentId 结算调整单id + * @return 新正式结算单id + */ + Long cloneAndApplyForRepush(Long originalSettlementId, Long adjustmentId); } diff --git a/blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/entity/User.java b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/entity/User.java index 398ba356..d20b5bb8 100644 --- a/blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/entity/User.java +++ b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/entity/User.java @@ -132,6 +132,10 @@ public class User extends TenantEntity implements IPhone { * 是否是oa的账号 */ private Integer isOa; + /** + * OA登录名 + */ + private String oaLoginId; /** * OA上次更新时间(OA 的 modified 字段),仅同步比对使用 */ diff --git a/blade-service/blade-system/src/main/java/org/springblade/process/controller/BusinessProcessController.java b/blade-service/blade-system/src/main/java/org/springblade/process/controller/BusinessProcessController.java index f65cc45e..2cf6ad79 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/process/controller/BusinessProcessController.java +++ b/blade-service/blade-system/src/main/java/org/springblade/process/controller/BusinessProcessController.java @@ -11,7 +11,6 @@ import lombok.AllArgsConstructor; import org.springblade.core.boot.ctrl.BladeController; import org.springblade.core.mp.support.Condition; import org.springblade.core.mp.support.Query; -import org.springblade.core.secure.utils.AuthUtil; import org.springblade.core.tool.api.R; import org.springblade.core.tool.utils.StringUtil; import org.springblade.process.pojo.dto.ApprovalDTO; @@ -49,7 +48,6 @@ public class BusinessProcessController extends BladeController { if (param == null) { param = new ApprovalDTO(); } - param.setLoginName(AuthUtil.getUserAccount()); IPage pages = businessProcessService.queryMkApprovalList(Condition.getPage(query), param); return R.data(pages); } diff --git a/blade-service/blade-system/src/main/java/org/springblade/process/service/IBusinessProcessService.java b/blade-service/blade-system/src/main/java/org/springblade/process/service/IBusinessProcessService.java index 36b15ce0..ae11c22d 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/process/service/IBusinessProcessService.java +++ b/blade-service/blade-system/src/main/java/org/springblade/process/service/IBusinessProcessService.java @@ -71,7 +71,7 @@ public interface IBusinessProcessService extends IService { * 获取流程当前节点详情 * * @param processInstanceId 流程实例id - * @param loginName MK登录名(手机号),可为空,为空时按流程发起人解析 + * @param loginName MK登录名(OA登录名),可为空,为空时按流程发起人解析 * @return 当前节点列表 */ List getCurrentNodes(String processInstanceId, String loginName); @@ -80,7 +80,7 @@ public interface IBusinessProcessService extends IService { * 获取流程实例详情 * * @param processInstanceId 流程实例id - * @param loginName MK登录名(手机号),可为空,为空时按流程发起人解析 + * @param loginName MK登录名(OA登录名),可为空,为空时按流程发起人解析 * @return 流程实例详情 */ Object getProcessInfo(String processInstanceId, String loginName); diff --git a/blade-service/blade-system/src/main/java/org/springblade/process/service/impl/BusinessProcessServiceImpl.java b/blade-service/blade-system/src/main/java/org/springblade/process/service/impl/BusinessProcessServiceImpl.java index fed83a70..42326194 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/process/service/impl/BusinessProcessServiceImpl.java +++ b/blade-service/blade-system/src/main/java/org/springblade/process/service/impl/BusinessProcessServiceImpl.java @@ -132,9 +132,9 @@ public class BusinessProcessServiceImpl extends ServiceImpl queryMkApprovalList(IPage page, ApprovalDTO param) { + String loginName = resolveCurrentUserOaLoginId(); + AssertUtils.notBlank(loginName, "当前用户OA登录名为空,无法查询审核列表"); + param.setLoginName(loginName); if (MKDoc.RELATED.getCode().equals(param.getDocType())) { // 我参与的,调用流程列表接口 MKProcessDTO processParam = approvalConvert.dto2mk(param); diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/controller/DeptController.java b/blade-service/blade-system/src/main/java/org/springblade/system/controller/DeptController.java index 39d60f72..47ef9a82 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/controller/DeptController.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/controller/DeptController.java @@ -204,7 +204,7 @@ public class DeptController extends BladeController { } /** - * 从OA按页同步部门(需先完成公司同步) + * 从OA按页同步部门(需先完成公司同步;按公司 oaId 轮询查询) */ @IsAdmin @PostMapping("/sync-oa-department") @@ -214,8 +214,9 @@ public class DeptController extends BladeController { @RequestParam(defaultValue = "1") Integer current, @RequestParam(defaultValue = "20") Integer size, @RequestParam(defaultValue = "false") Boolean incremental, - @RequestParam(required = false) String modifiedSince) { - return R.data(oaSyncService.syncDepartmentPage(current, size, Boolean.TRUE.equals(incremental), modifiedSince)); + @RequestParam(required = false) String modifiedSince, + @RequestParam(required = false) String companyOaId) { + return R.data(oaSyncService.syncDepartmentPage(current, size, Boolean.TRUE.equals(incremental), modifiedSince, companyOaId)); } /** diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/convert/DeptConvert.java b/blade-service/blade-system/src/main/java/org/springblade/system/convert/DeptConvert.java index ba5bdf43..dc045868 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/convert/DeptConvert.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/convert/DeptConvert.java @@ -49,8 +49,8 @@ public interface DeptConvert { @Mapping(source = "modified", target = "oaModified") Dept baseConvert(OACompanyResponse company); - @Mapping(source = "departmentname", target = "deptName") - @Mapping(source = "departmentmark", target = "fullName") + @Mapping(source = "departmentmark", target = "deptName") + @Mapping(source = "departmentname", target = "fullName") @Mapping(source = "supdepid", target = "parentCode") @Mapping(source = "subcompanyid1", target = "belongCompanyCode") @Mapping(source = "created", target = "createTime", dateFormat = "yyyy-MM-dd HH:mm:ss") diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/convert/UserConvert.java b/blade-service/blade-system/src/main/java/org/springblade/system/convert/UserConvert.java index 00b2e576..2dabbf04 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/convert/UserConvert.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/convert/UserConvert.java @@ -70,6 +70,7 @@ public interface UserConvert { @Mapping(source = "lastname", target = "realName") @Mapping(source = "mobile", target = "phone") @Mapping(source = "email", target = "email") + @Mapping(source = "loginid", target = "oaLoginId") @Mapping(source = "modified", target = "oaModified") User baseConvert(OAPersonResponse person); @@ -147,7 +148,7 @@ public interface UserConvert { /** * oa人员转本系统用户部门 * @param person - * @param userMap 所有用户手机号与id map + * @param userMap OA登录名与用户id * @return */ default UserDept person2userDept(OAPersonResponse person, Map userMap) { @@ -161,8 +162,9 @@ public interface UserConvert { userDept.setStatus(status); // 排序 userDept.setSort(OAUtils.parseInt(person.getDsporder())); - // 用户id - userDept.setUserId(userMap.get(resolveAccount(person))); + // 用户id,按 OA 登录名关联本批已新增或更新的用户 + String oaLoginId = person == null ? null : StringUtils.trimToNull(person.getLoginid()); + userDept.setUserId(oaLoginId == null ? null : userMap.get(oaLoginId)); userDept.setSyncTime(new Date()); return userDept; } diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/feign/SysClient.java b/blade-service/blade-system/src/main/java/org/springblade/system/feign/SysClient.java index edacf506..a4cf9718 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/feign/SysClient.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/feign/SysClient.java @@ -32,6 +32,7 @@ import org.springblade.core.tool.api.R; import org.springblade.system.pojo.entity.*; import org.springblade.system.service.*; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import io.swagger.v3.oas.annotations.Hidden; @@ -218,9 +219,9 @@ public class SysClient implements ISysClient { @Override @GetMapping(FEE_ITEMS) - public R> getFeeItems() { + public R> getFeeItems(@RequestParam(value = "includeDisabled", required = false) Boolean includeDisabled) { return R.data(feeItemService.list(Wrappers.lambdaQuery() - .eq(FeeItem::getStatus, 1) + .eq(!Boolean.TRUE.equals(includeDisabled), FeeItem::getStatus, 1) .eq(FeeItem::getIsDeleted, 0) .orderByAsc(FeeItem::getFeeCategory, FeeItem::getName))); } diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/mapper/DeptMapper.xml b/blade-service/blade-system/src/main/java/org/springblade/system/mapper/DeptMapper.xml index f45626dd..bfbaf61f 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/mapper/DeptMapper.xml +++ b/blade-service/blade-system/src/main/java/org/springblade/system/mapper/DeptMapper.xml @@ -14,6 +14,7 @@ + @@ -36,6 +37,7 @@ + diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/mapper/MeasurementUnitMapper.java b/blade-service/blade-system/src/main/java/org/springblade/system/mapper/MeasurementUnitMapper.java index e4b98971..940bb5bb 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/mapper/MeasurementUnitMapper.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/mapper/MeasurementUnitMapper.java @@ -34,6 +34,26 @@ import java.util.List; */ public interface MeasurementUnitMapper extends BaseMapper { + /** + * 按编码查询计量单位(包含逻辑删除记录,用于新增恢复)。 + */ + MeasurementUnit selectByUnitCodeIncludingDeleted(@Param("unitCode") String unitCode); + + /** + * 按名称查询计量单位(包含逻辑删除记录,用于新增恢复)。 + */ + MeasurementUnit selectByUnitNameIncludingDeleted(@Param("unitName") String unitName); + + /** + * 恢复逻辑删除计量单位。 + */ + int restoreById(@Param("id") Long id); + + /** + * 释放已软删除记录占用的单位名称,避免唯一索引冲突。 + */ + int releaseUnitName(@Param("id") Long id, @Param("unitName") String unitName); + /** * 自定义分页 * diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/mapper/MeasurementUnitMapper.xml b/blade-service/blade-system/src/main/java/org/springblade/system/mapper/MeasurementUnitMapper.xml index 10fbb903..4f0bd97d 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/mapper/MeasurementUnitMapper.xml +++ b/blade-service/blade-system/src/main/java/org/springblade/system/mapper/MeasurementUnitMapper.xml @@ -19,6 +19,34 @@ + + + + + + UPDATE blade_measurement_unit + SET is_deleted = 0 + WHERE id = #{id} + AND is_deleted = 1 + + + + UPDATE blade_measurement_unit + SET unit_name = #{unitName} + WHERE id = #{id} + AND is_deleted = 1 + + SELECT FROM blade_kingdee_invoice_pool p WHERE p.is_deleted = 0 AND p.status = 1 AND p.tenant_id = #{tenantId} + + + AND p.invoice_no LIKE CONCAT('%', #{query.invoiceNo}, '%') diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/PaymentApplicationMapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/PaymentApplicationMapper.java index 770dc202..f1507f68 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/PaymentApplicationMapper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/PaymentApplicationMapper.java @@ -21,9 +21,20 @@ package org.springblade.transport.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; import org.springblade.transport.pojo.entity.PaymentApplication; /** 付款申请 Mapper。 @author Chill */ @Mapper public interface PaymentApplicationMapper extends BaseMapper { + + /** + * 查询指定租户、指定日期前缀下已使用的最大付款申请流水号。 + * 不过滤逻辑删除数据,避免唯一索引仍占用历史编号时重复生成。 + */ + @Select("SELECT COALESCE(MAX(CAST(SUBSTRING(payment_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) " + + "FROM blade_payment_application WHERE tenant_id = #{tenantId} " + + "AND payment_no LIKE CONCAT(#{prefix}, '%')") + Long selectMaxDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/PreSettlementAdvanceMapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/PreSettlementAdvanceMapper.java index c8bb1558..734364da 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/PreSettlementAdvanceMapper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/PreSettlementAdvanceMapper.java @@ -10,6 +10,8 @@ package org.springblade.transport.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; import org.springblade.transport.pojo.entity.PreSettlementAdvance; /** @@ -19,4 +21,12 @@ import org.springblade.transport.pojo.entity.PreSettlementAdvance; */ @Mapper public interface PreSettlementAdvanceMapper extends BaseMapper { + + /** + * 查询预付单号最大流水(含逻辑删除)。 + */ + @Select("SELECT COALESCE(MAX(CAST(SUBSTRING(advance_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) " + + "FROM blade_pre_settlement_advance WHERE tenant_id = #{tenantId} " + + "AND advance_no LIKE CONCAT(#{prefix}, '%')") + Long selectMaxDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/PreSettlementMapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/PreSettlementMapper.java index c2b86484..2fbe0db4 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/PreSettlementMapper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/PreSettlementMapper.java @@ -10,6 +10,8 @@ package org.springblade.transport.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; import org.springblade.transport.pojo.entity.PreSettlement; /** @@ -19,4 +21,20 @@ import org.springblade.transport.pojo.entity.PreSettlement; */ @Mapper public interface PreSettlementMapper extends BaseMapper { + + /** + * 查询预结算单号最大流水(含逻辑删除)。 + */ + @Select("SELECT COALESCE(MAX(CAST(SUBSTRING(pre_settlement_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) " + + "FROM blade_pre_settlement WHERE tenant_id = #{tenantId} " + + "AND pre_settlement_no LIKE CONCAT(#{prefix}, '%')") + Long selectMaxPreSettlementDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix); + + /** + * 查询预结算转正式结算单号最大流水(含逻辑删除)。 + */ + @Select("SELECT COALESCE(MAX(CAST(SUBSTRING(formal_settlement_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) " + + "FROM blade_pre_settlement WHERE tenant_id = #{tenantId} " + + "AND formal_settlement_no LIKE CONCAT(#{prefix}, '%')") + Long selectMaxFormalSettlementDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/ReceiptClaimMapper.xml b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/ReceiptClaimMapper.xml index b1337bbe..5dbc2be7 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/ReceiptClaimMapper.xml +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/ReceiptClaimMapper.xml @@ -102,7 +102,8 @@ FROM blade_receipt_claim c INNER JOIN blade_kingdee_receipt_flow f ON f.id = c.receipt_flow_id WHERE - ORDER BY c.create_time DESC + + ORDER BY c.claim_date DESC, c.id DESC + SELECT COALESCE(SUM(w.write_off_amount), 0) + FROM blade_settlement_project_advance_write_off w + LEFT JOIN blade_pre_settlement p + ON w.settlement_type = 'pre' + AND w.settlement_id = p.id + AND p.is_deleted = 0 + LEFT JOIN blade_formal_settlement f + ON w.settlement_type = 'formal' + AND w.settlement_id = f.id + AND f.is_deleted = 0 + WHERE w.is_deleted = 0 + AND w.payment_application_id = #{paymentApplicationId} + AND ( + (w.settlement_type = 'pre' AND p.id IS NOT NULL AND IFNULL(p.approval_status, '') <> 'voided') + OR (w.settlement_type = 'formal' AND f.id IS NOT NULL AND IFNULL(f.approval_status, '') <> 'voided') + ) + + AND NOT (w.settlement_type = #{excludeSettlementType} AND w.settlement_id = #{excludeSettlementId}) + + + + + + + + diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/TransportReconciliationMapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/TransportReconciliationMapper.java index e808e682..db46b800 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/TransportReconciliationMapper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/TransportReconciliationMapper.java @@ -22,4 +22,13 @@ public interface TransportReconciliationMapper extends BaseMapper formValues) { + String loginName, Long bizId, Map formValues) { Map formValuesCopy = new HashMap<>(formValues == null ? Map.of() : formValues); - log.info("公开页将在{}秒后查询流程实例详情 bizType={} processId={}", PROCESS_INFO_DELAY_SECONDS, bizType, processId); + log.info("公开页将在{}秒后查询流程实例详情 bizType={} processId={} bizId={}", + PROCESS_INFO_DELAY_SECONDS, bizType, processId, bizId); + // 延迟任务跑在独立线程,请求态 @TenantIgnore 已丢失,必须显式 ignore,否则 getById 可能因租户条件查空 scheduler.schedule( - () -> queryProcessInfo(handler, bizType, processId, loginName, bizIdText, formValuesCopy), + () -> TenantUtil.ignore(() -> queryProcessInfo(handler, bizType, processId, loginName, bizId, formValuesCopy)), PROCESS_INFO_DELAY_SECONDS, TimeUnit.SECONDS ); } private void queryProcessInfo(IMkProcessBizHandler handler, String bizType, String processId, - String loginName, String bizIdText, Map formValues) { + String loginName, Long bizId, Map formValues) { try { FR processInfoResult = businessProcessClient.getProcessInfo(processId, loginName); Object processInfo = processInfoResult == null ? null : processInfoResult.getData(); - log.info("公开页流程消息流程实例详情 bizType={} processId={} loginName={} result={}", - bizType, processId, loginName, JSON.toJSONString(processInfo)); + log.info("公开页流程消息流程实例详情 bizType={} processId={} loginName={} bizId={} result={}", + bizType, processId, loginName, bizId, JSON.toJSONString(processInfo)); if (isProcessFinished(processInfo)) { - applyProcessResult(handler, bizType, bizIdText, processId, formValues, true); + applyProcessResult(handler, bizType, bizId, processId, formValues, true); } else if (MkProcessNodeHelper.isProcessRejected(processInfo)) { - applyProcessResult(handler, bizType, bizIdText, processId, formValues, false); - } else if (StringUtil.isNotBlank(bizIdText)) { - try { - handler.syncFromProcessInfo(Long.valueOf(bizIdText), processInfo); - } catch (NumberFormatException e) { - log.warn("公开页流程消息业务id格式不正确 bizType={} id={}", bizType, bizIdText); - } + applyProcessResult(handler, bizType, bizId, processId, formValues, false); + } else if (bizId != null) { + handler.syncFromProcessInfo(bizId, processInfo); } } catch (Exception e) { - log.error("公开页查询流程实例详情失败 bizType={} processId={} loginName={}", bizType, processId, loginName, e); + log.error("公开页查询流程实例详情失败 bizType={} processId={} loginName={} bizId={}", + bizType, processId, loginName, bizId, e); } } @@ -123,21 +124,24 @@ public class MkProcessMessageService { return "30".equals(firstText(asMap(processInfo), "fdProcessStatus")); } - private void applyProcessResult(IMkProcessBizHandler handler, String bizType, String bizIdText, + private void applyProcessResult(IMkProcessBizHandler handler, String bizType, Long bizId, String processId, Map formValues, boolean approved) { String action = approved ? "审核通过" : "审核驳回"; - if (StringUtil.isBlank(bizIdText)) { - log.warn("流程{}但未找到业务id,跳过同步 bizType={} processId={}", action, bizType, processId); + if (bizId == null) { + log.warn("流程{}但未解析到业务主键,跳过同步 bizType={} processId={}", action, bizType, processId); return; } try { String processorName = firstText(formValues, "mkUserName", "mkLoginName"); - Long bizId = Long.valueOf(bizIdText); - if (approved) { - handler.approveFromProcess(bizId, processorName); - } else { - handler.rejectFromProcess(bizId, processorName); + boolean synced = approved + ? handler.approveFromProcess(bizId, processorName) + : handler.rejectFromProcess(bizId, processorName); + if (!synced) { + log.error("流程{}同步业务状态失败(业务单据不存在或未更新) bizType={} bizId={} processId={}", + action, bizType, bizId, processId); + return; } + log.info("流程{}同步业务状态成功 bizType={} bizId={} processId={}", action, bizType, bizId, processId); try { FR markResult = businessProcessClient.markProcessFinished(processId, approved); log.info("公开页标记业务流程完结 bizType={} processId={} approved={} result={}", @@ -146,13 +150,56 @@ public class MkProcessMessageService { log.error("公开页标记业务流程完结失败 bizType={} processId={} approved={}", bizType, processId, approved, markException); } - } catch (NumberFormatException e) { - log.warn("流程{}但业务id格式不正确 bizType={} id={}", action, bizType, bizIdText); } catch (Exception e) { - log.error("流程{}同步业务状态失败 bizType={} id={} processId={}", action, bizType, bizIdText, processId, e); + log.error("流程{}同步业务状态失败 bizType={} bizId={} processId={}", action, bizType, bizId, processId, e); } } + /** + * 公开页 formData 里的 id / formInstanceId 可能是表单实例 id(与业务主键不同), + * 须先按 MkFormBind 反查业务主键,与 detail 接口保持一致。 + */ + private Long resolveBizId(Map formData) { + String formInstanceIdText = firstText(formData, "formInstanceId"); + String idText = firstText(formData, "id"); + Long resolved = resolveByFormInstanceId(formInstanceIdText); + if (resolved != null) { + return resolved; + } + resolved = resolveByFormInstanceId(idText); + if (resolved != null) { + return resolved; + } + if (StringUtil.isNotBlank(idText)) { + try { + return Long.valueOf(idText); + } catch (NumberFormatException e) { + log.warn("公开页流程消息业务id格式不正确 id={}", idText); + } + } + return null; + } + + private Long resolveByFormInstanceId(String formInstanceIdText) { + if (StringUtil.isBlank(formInstanceIdText)) { + return null; + } + try { + Long formInstanceId = Long.valueOf(formInstanceIdText); + FR result = businessProcessClient.resolveByFormInstanceId(formInstanceId); + if (result != null && result.isSuccess() && result.getData() != null && result.getData().getBizId() != null) { + Long bizId = result.getData().getBizId(); + log.info("公开页按表单实例id解析业务主键 formInstanceId={} bizId={}", formInstanceId, bizId); + return bizId; + } + } catch (NumberFormatException e) { + log.warn("公开页表单实例id格式不正确 formInstanceId={}", formInstanceIdText); + } catch (Exception e) { + log.warn("公开页按表单实例id解析业务主键失败 formInstanceId={}", formInstanceIdText, e); + } + return null; + } + private static Map asMap(Object value) { if (!(value instanceof Map map)) { return Collections.emptyMap(); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IBillLedgerService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IBillLedgerService.java index abd6e5d2..c46522e3 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IBillLedgerService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IBillLedgerService.java @@ -18,8 +18,9 @@ public interface IBillLedgerService extends BaseService { IPage selectPage(IPage page, BillLedgerVO query); BillLedgerVO detail(Long id); Map expiryCounts(); - List availableOptions(String keyword, Long deptId, Long selectedId); - IPage availablePage(IPage page, String keyword, Long deptId, Long selectedId); + List availableOptions(String keyword, Long deptId, Long selectedId, String payerName, String payeeName); + IPage availablePage(IPage page, String keyword, Long deptId, Long selectedId, + String payerName, String payeeName); Long submit(BillLedgerSaveRequest request); void removeLedger(Long id); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverWaybillService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverWaybillService.java index 0d9d3f15..9ad5f543 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverWaybillService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverWaybillService.java @@ -71,6 +71,53 @@ public interface IDriverWaybillService { */ DriverWaybillCardVO detail(Long id); + /** + * 当前登录司机的运输中运次(一趟车,最多一条) + */ + DriverWaybillCardVO tripCurrentTask(); + + /** + * 当前登录司机的待接运次预览 + * + * @param size 预览条数,默认 2 + */ + DriverWaybillPreviewVO tripPendingPreview(Integer size); + + /** + * 运次 Tab 统计:全部 / 待接单 / 进行中 / 已完成(全部 = 前三者之和,不含已取消) + */ + DriverWaybillTabCountsVO tripTabCounts(); + + /** + * 司机运次分页:一页即若干个运次(有配载单的一趟车算一个) + * + * @param current 当前页,从 1 开始 + * @param size 每页条数(按运次,默认 10) + * @param status 小程序状态:空=全部,0待接单,1运输中,2已完成 + * @param keyword 关键字(运单号 / 配载单号 / 起终点 / 车牌,可选) + */ + IPage tripPage(Integer current, Integer size, Integer status, String keyword); + + /** + * 司机运次详情:按运次主键(字符串)取整趟车,有配载单时下发站点 ①②③ + */ + DriverWaybillCardVO tripDetail(String tripId); + + /** + * 运次确认接单:一次把该运次全部子运单置为已接单,司机字段不动 + * + * @param tripId 运次主键(配载单主键或运单主键) + */ + boolean tripAccept(String tripId); + + /** + * 运次拒绝接单:整单拒绝,该运次此后对本人不可见,等调度重新派单 + * + * @param tripId 运次主键(配载单主键或运单主键) + * @param reason 拒绝原因,可空,最长 200 字 + */ + boolean tripReject(String tripId, String reason); + /** * 司机端运单行驶轨迹:按打卡记录 / 计划时间自动推导起止日期后查询 LBS 轨迹。 *

@@ -130,9 +177,9 @@ public interface IDriverWaybillService { boolean isNodePunched(Long waybillId, String nodeCode); /** - * 断言当前登录司机对该运单有归属:运单车牌须落在司机档案自述绑定的车牌内。 + * 断言当前登录司机对该运单有归属:运单司机手机号须等于当前用户手机号。 *

- * 无司机档案、未绑定车辆、车牌不匹配都会抛异常。管理端与调度员账号没有司机档案, + * 无司机档案、无手机号、手机号不匹配都会抛异常。管理端与调度员账号没有司机档案, * 会停在「未找到当前登录司机档案」——这是刻意的,不提供代报。 */ void assertCurrentDriverOwnsWaybill(Long waybillId); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IFormalSettlementService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IFormalSettlementService.java index 3393983e..daf1d429 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IFormalSettlementService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IFormalSettlementService.java @@ -24,6 +24,8 @@ import org.springblade.transport.pojo.dto.FormalSettlementPaymentRequest; import org.springblade.transport.pojo.dto.FormalSettlementBatchPaymentRequest; import org.springblade.transport.pojo.dto.FormalSettlementInvoiceClaimRequest; +import java.util.Map; + /** * 正式结算单服务 * @@ -93,4 +95,29 @@ public interface IFormalSettlementService extends BaseService String applyPayment(FormalSettlementPaymentRequest request); List applyPayments(FormalSettlementBatchPaymentRequest request); void claimInvoices(FormalSettlementInvoiceClaimRequest request); + + /** + * 结算调整重推:克隆原正式结算单为新单(含明细、明细费项、汇总费项、来源预结算、附件、汇率), + * 新单置审批通过、未同步金蝶,来源原单号指向原单;下游关联(开票申请/收款认领/对账)不迁移。 + * 不推送金蝶,由调用方在事务外调用 syncKingdee 推送 + * + * @param sourceSettlementId 原正式结算单id + * @return 新单id与「原明细/原费项 -> 新明细/新费项」id映射(供调整明细应用到新单) + */ + CloneResult cloneFormalSettlement(Long sourceSettlementId); + + /** + * 结算调整重推收尾:原单标记「已调整」、旧锁解锁、追加重推变更记录(不改原单业务数据) + * + * @param settlementId 原正式结算单id + * @param adjustmentNo 结算调整单号 + * @param newSettlementNo 重推生成的新单号 + */ + void markSettlementAdjusted(Long settlementId, String adjustmentNo, String newSettlementNo); + + /** + * 克隆结果:新单id + 原明细id->新明细id + 原费项id->新费项id + */ + record CloneResult(Long settlementId, Map detailIdMap, Map feeIdMap) { + } } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IInvoiceApplicationService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IInvoiceApplicationService.java index aa9faf18..3277eef2 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IInvoiceApplicationService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IInvoiceApplicationService.java @@ -45,7 +45,7 @@ public interface IInvoiceApplicationService extends BaseService selectPage(IPage page, InvoiceApplicationVO query); InvoiceApplicationVO detail(Long id); IPage> settlementCandidates(IPage page, String keyword, String contractCategory, - String settlementType, String invoiceStatus); + String settlementType); List settlementDetails(String settlementIds); Map receiverInformation(String settlementIds); Long saveDraft(InvoiceApplicationSaveRequest request); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IKingdeeBillLockService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IKingdeeBillLockService.java index 7af9adfc..755e8c45 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IKingdeeBillLockService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IKingdeeBillLockService.java @@ -9,8 +9,10 @@ package org.springblade.transport.service; import com.baomidou.mybatisplus.extension.service.IService; +import org.springblade.core.mp.base.BaseEntity; import org.springblade.thirdparty.bfi.pojo.dto.BfiBillLockDTO; import org.springblade.transport.pojo.entity.KingdeeBillLock; +import org.springblade.transport.pojo.vo.KingdeeUnlockMark; import java.util.List; import java.util.Map; @@ -39,8 +41,10 @@ public interface IKingdeeBillLockService extends IService { String lockAfterPush(String sourceBillType, Long sourceBillId, String sourceBillNo, String targetBillType, List targetBillNos); /** - * 金蝶解锁回调(unLockNotifyBusiData):校验来源/目标系统与 lockAction, - * targetBillNo 支持逗号拼接批量,逐张按 targetBillType+targetBillNo 定位最新 locked 锁记录; + * 金蝶解锁回调(unLockNotifyBusiData):校验来源/目标系统与 lockAction。 + * 注意回调方向与锁定相反:回调 sourceBillNo=金蝶单号(即锁记录 targetBillNo,逗号拼接批量)、 + * targetBillNo=WLTMS 单号(即锁记录 sourceBillNo)、targetBillType 可能为空, + * 故按金蝶单号+我方单号双条件反查最新 locked 锁记录; * operationType=DELETE 时按单据类型分发废除源单并还原关联数据 * * @param param 金蝶回调参数 @@ -56,6 +60,17 @@ public interface IKingdeeBillLockService extends IService { */ void checkEditable(String sourceBillType, Long sourceBillId); + /** + * 结算调整重推解锁:把源单当前「锁定中」的锁记录标记为 unlocked(非金蝶回调,仅本地解锁留痕), + * 供原结算单被调整重推生成新单后解除旧锁使用;新单另行落新锁记录 + * + * @param sourceBillType WLTMS 单据类型 + * @param sourceBillId WLTMS 业务主键 + * @param operator 操作人 + * @return 解锁的记录数 + */ + int unlockByAdjustRepush(String sourceBillType, Long sourceBillId, String operator); + /** * 批量查源单当前锁状态(供列表/详情页填充展示标识): * 每源单取最新一条非终态锁记录(locked/failed),unlocked 为终态不返回(前端视为无锁/已解锁) @@ -66,6 +81,29 @@ public interface IKingdeeBillLockService extends IService { */ Map mapActiveLocks(String sourceBillType, List sourceBillIds); + /** + * 批量查源单解锁终态留痕(供列表/详情页填充解锁标识): + * 每源单取最新一条 unlocked 锁记录(付款申请解锁后不作废,页面据此区分「金蝶删单/解锁」与普通推送失败) + * + * @param sourceBillType WLTMS 单据类型 + * @param sourceBillIds 源单主键集合 + * @return sourceBillId -> 最新 unlocked 锁记录,无解锁留痕的源单不在 Map 中 + */ + Map mapUnlockedLocks(String sourceBillType, List sourceBillIds); + + /** + * 批量填充金蝶解锁终态标识(通用):对无活跃锁的行反查最新 unlocked 锁记录并回填解锁标识 + * (解锁时间/操作人/动作名称),供前端展示明显解锁标识;有活跃锁的单必非解锁终态,跳过。 + * 与 mapActiveLocks 配对在各单据 fillKingdeeLockStatus 中调用,列表/详情共用; + * 后续单据(如收票单)接入锁单时,VO 实现 KingdeeUnlockMark 即可复用 + * + * @param sourceBillType WLTMS 单据类型 + * @param rows 已填充活跃锁标识的 VO 行 + * @param activeLockMap 活跃锁 Map(mapActiveLocks 返回值) + * @param <T> 实现 KingdeeUnlockMark 的 VO 类型(须继承 BaseEntity 以取主键) + */ + void fillUnlockMarks(String sourceBillType, List rows, Map activeLockMap); + /** * 锁定补偿:扫 failed 且 try_count 未达重试上限(RETRY_MAX_COUNT)的锁记录,逐张重调 lockBusiData * (接口 1对1:单次一个源单号对一个金蝶单号,单张成败互不影响;定时任务定时触发,亦可人工触发); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IManageWaybillService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IManageWaybillService.java index 08bd411c..64a3f9ee 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IManageWaybillService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IManageWaybillService.java @@ -63,9 +63,12 @@ public interface IManageWaybillService { String exception, String transportType, String startDate, String endDate); /** - * 调度端运单详情(不校验司机归属) + * 调度端运次详情(不校验司机归属) + *

+ * 传运次主键:配载单主键或未配载运单主键,都按字符串。已拒单的子运单按运次取不到(运次口径 + * 不含已拒子单),此时按运单主键回落成单票视图,供重新派单等从运单进入的场景使用。 */ - AdminWaybillDetailVO detail(Long id); + AdminWaybillDetailVO detail(String tripId); /** * 待处理运单(待接单 / 运输中;可筛需重新派单) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IPaymentApplicationService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IPaymentApplicationService.java index 6d4807af..e68ec0c0 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IPaymentApplicationService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IPaymentApplicationService.java @@ -28,6 +28,7 @@ import org.springblade.transport.pojo.dto.PaymentApplicationStatusRequest; import org.springblade.transport.pojo.entity.PaymentApplication; import org.springblade.transport.pojo.vo.PaymentApplicationReferenceAmountVO; import org.springblade.transport.pojo.vo.PaymentApplicationVO; +import org.springblade.transport.pojo.vo.ProjectAdvanceWriteOffOptionVO; import java.util.List; @@ -36,6 +37,11 @@ public interface IPaymentApplicationService extends BaseService selectPage(IPage page, PaymentApplicationVO query); PaymentApplicationVO detail(Long id); PaymentApplicationReferenceAmountVO referenceAmount(String paymentType, Long referenceId, Long excludeId); + + /** 按合同查询可选项目预付核销列表。 */ + List projectAdvanceWriteOffOptions(Long contractId, + String excludeSettlementType, + Long excludeSettlementId); Long saveDraft(PaymentApplicationSaveRequest request); void removeDraft(Long id); void submit(PaymentApplicationStatusRequest request); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IPreSettlementService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IPreSettlementService.java index 28af8ada..c24b7240 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IPreSettlementService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IPreSettlementService.java @@ -33,6 +33,11 @@ public interface IPreSettlementService extends BaseService { PreSettlementVO detail(Long id); + /** + * 预览下一条预结算单号。仅供新增页面展示,真正落库的单号仍由保存时生成。 + */ + String nextNo(); + default List> contractOptions(String keyword) { return contractOptions(keyword, null); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IReceivablePayableDetailService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IReceivablePayableDetailService.java index 196df17c..82592347 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IReceivablePayableDetailService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IReceivablePayableDetailService.java @@ -101,4 +101,19 @@ public interface IReceivablePayableDetailService extends BaseService + * 历史运单若已有系统生成明细,不覆盖原明细,另建结算导入明细;运单已进入结算则拒绝重复导入。 + * feeRows 每项含 waybillId、transportFee、unitPrice、feeItems(Map)、remark。 + *

+ * + * @param settlementType 结算类型 receivable/payable + * @param contractId 结算合同ID + * @param feeRows 费用行 + * @return 明细主键列表(新建或刷新后的结算导入明细) + */ + List createDetailsFromSettlementImport(String settlementType, Long contractId, + List> feeRows); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ISettlementImportBatchService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ISettlementImportBatchService.java new file mode 100644 index 00000000..5d81be02 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ISettlementImportBatchService.java @@ -0,0 +1,77 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.service; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import jakarta.servlet.http.HttpServletResponse; +import org.springblade.core.mp.base.BaseService; +import org.springblade.transport.pojo.dto.SettlementImportBatchRequest; +import org.springblade.transport.pojo.dto.SettlementImportVoidRequest; +import org.springblade.transport.pojo.entity.SettlementImportBatch; +import org.springblade.transport.pojo.vo.SettlementImportBatchVO; +import org.springblade.transport.pojo.vo.WaybillImportValidateResultVO; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; +import java.util.Map; + +/** + * 导入结算单批次服务 + * + * @author Chill + */ +public interface ISettlementImportBatchService extends BaseService { + + Map>> options(); + + String nextBatchNo(); + + IPage page(IPage page, SettlementImportBatchRequest request); + + SettlementImportBatchVO detail(Long id); + + void exportTemplate(HttpServletResponse response); + + Map parse(MultipartFile file); + + WaybillImportValidateResultVO validate(SettlementImportBatchRequest request); + + void exportFailures(SettlementImportBatchRequest request, MultipartFile file, HttpServletResponse response); + + SettlementImportBatchVO saveDraft(SettlementImportBatchRequest request); + + SettlementImportBatchVO confirm(SettlementImportBatchRequest request); + + void voidBatch(SettlementImportVoidRequest request); + + /** 删除草稿批次(仅 draft 可删)。 */ + void removeDraft(Long id); + + /** + * 历史运单可选列表:相同项目、客户合同、承运商合同下,尚未生成对应应收/应付明细的运单。 + */ + List> historyWaybills(SettlementImportBatchRequest request); +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ISettlementProjectAdvanceWriteOffService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ISettlementProjectAdvanceWriteOffService.java new file mode 100644 index 00000000..3c8886d8 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ISettlementProjectAdvanceWriteOffService.java @@ -0,0 +1,70 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.service; + +import org.springblade.transport.pojo.dto.SettlementProjectAdvanceWriteOffRequest; +import org.springblade.transport.pojo.entity.SettlementProjectAdvanceWriteOff; +import org.springblade.transport.pojo.vo.ProjectAdvanceWriteOffOptionVO; + +import java.math.BigDecimal; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +/** + * 结算单项目预付核销服务 + * + * @author Chill + */ +public interface ISettlementProjectAdvanceWriteOffService { + + /** + * 按合同查询可选项目预付(审批通过、金蝶已付款且仍有可核销余额)。 + */ + List listOptions(Long contractId, String excludeSettlementType, + Long excludeSettlementId); + + /** + * 查询结算单已保存的核销明细(含动态可核销金额)。 + */ + List listBySettlement(String settlementType, Long settlementId); + + /** + * 结算单核销金额合计。 + */ + BigDecimal sumBySettlement(String settlementType, Long settlementId); + + /** + * 按结算单集合分组统计核销合计(一次 group-by,供批量刷新与列表页使用)。 + */ + Map sumBySettlementIds(String settlementType, Collection settlementIds); + + /** + * 覆盖保存核销明细,并校验不超过可核销金额与结算单金额。 + */ + void replaceWriteOffs(String settlementType, Long settlementId, Long contractId, + BigDecimal settlementAmount, List writeOffs); + + /** + * 物理清理结算单核销明细(草稿删除时)。 + */ + void deleteBySettlement(String settlementType, Long settlementId); + + /** + * 删除指定结算单集合上的核销明细(核销行转移/结算单作废联动),返回删除行数。 + */ + int deleteBySettlementIds(String settlementType, Collection settlementIds); + + /** + * 按付款申请删除其核销明细(付款申请作废联动),返回受影响的正式结算单id。 + */ + List deleteByPaymentApplicationIds(Collection paymentApplicationIds); + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ISettlementRuleAutoGenerateService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ISettlementRuleAutoGenerateService.java new file mode 100644 index 00000000..4542b599 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ISettlementRuleAutoGenerateService.java @@ -0,0 +1,42 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.service; + +/** + * 按合同结算规则自动生成预结算单、正式结算单 + * + * @author Chill + */ +public interface ISettlementRuleAutoGenerateService { + + /** + * 扫描已开启自动生成的合同,为截至昨日已关闭的账期生成结算草稿。 + * + * @return 本次生成结果摘要 + */ + String generateDueSettlements(); + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ITripQueryService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ITripQueryService.java new file mode 100644 index 00000000..dbc87bde --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ITripQueryService.java @@ -0,0 +1,65 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.service; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import org.springblade.transport.pojo.vo.TripQueryVO; +import org.springblade.transport.pojo.vo.TripTabCountsVO; +import org.springblade.transport.pojo.vo.TripVO; + +/** + * 运次读服务 + *

+ * 「一卡一运次」的取数、状态聚合、接单需求合并与分页只在这里实现一次,司机端与管理端列表都调它, + * 各自只做字段裁剪。聚合全程只读,不在读路径上写业务状态——分档只按过程配置与接单态推导, + * 不按库里的业务状态,也不借读路径做惰性校正。 + */ +public interface ITripQueryService { + + /** + * 运次分页:一页就是若干个运次(有配载单的一趟车算一个)。 + *

+ * 库层只取回收口与行级筛选命中的运单行,分组、分档、筛选、排序与分页都在服务层算完再做。 + * + * @param query 查询条件,收口由 driverPhone 或 allDrivers 给出 + * @return 运次分页 + */ + IPage page(TripQueryVO query); + + /** + * 运次 Tab 统计:全部 / 待接单 / 进行中 / 已完成,分档口径与 page 完全一致 + * + * @param query 查询条件,口径同 page + * @return 运次计数 + */ + TripTabCountsVO tabCounts(TripQueryVO query); + + /** + * 按运次主键取单个运次(含子运单),主键为字符串 + * + * @param query 查询条件,tripId 必填 + * @return 运次 + */ + TripVO detail(TripQueryVO query); + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IWaybillService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IWaybillService.java index 8a5abe66..d43242fd 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IWaybillService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IWaybillService.java @@ -113,6 +113,14 @@ public interface IWaybillService extends BaseService { */ boolean completeWithoutDeptCheck(Long id); + /** + * 确认回单:回单节点配成「需业务员确认」时,内部人员确认后把运单置为已完成。 + *

+ * 比 {@link #complete(Long)} 多两道校验:该运单的司机打卡节点(在途除外)必须全部已打卡, + * 且没有未了结异常——不挡住就等于借这个入口绕过打卡门禁与异常门禁。 + */ + boolean confirmReturnReceipt(Long id); + BusinessRemoveResultVO batchComplete(String ids); LoadingManageVO roadLoading(String ids); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/KingdeeSyncContext.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/KingdeeSyncContext.java index aeac5967..b34f733c 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/KingdeeSyncContext.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/KingdeeSyncContext.java @@ -46,11 +46,11 @@ public record KingdeeSyncContext(Map contractCache, Map> settlementDetailCache, Map settlementCache, Map receiptAccountCache, - Map feeCategoryByName) { + Map feeCategoryByName, Map paymentNoCache) { /** 空上下文:缓存可写但初始为空,首条记录命中后全批复用 */ public static KingdeeSyncContext empty() { return new KingdeeSyncContext(new HashMap<>(), new HashMap<>(), new HashMap<>(), - new HashMap<>(), new HashMap<>(), new HashMap<>(), new HashMap<>(), new HashMap<>()); + new HashMap<>(), new HashMap<>(), new HashMap<>(), new HashMap<>(), new HashMap<>(), new HashMap<>()); } } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/BillLedgerServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/BillLedgerServiceImpl.java index 0e08c8a2..a20a4e0e 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/BillLedgerServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/BillLedgerServiceImpl.java @@ -86,14 +86,15 @@ public class BillLedgerServiceImpl extends BaseServiceImpl availableOptions(String keyword, Long deptId, Long selectedId) { - return availableList(keyword, deptId, selectedId); + public List availableOptions(String keyword, Long deptId, Long selectedId, String payerName, + String payeeName) { + return availableList(keyword, deptId, selectedId, payerName, payeeName); } @Override public IPage availablePage(IPage page, String keyword, Long deptId, - Long selectedId) { - List available = availableList(keyword, deptId, selectedId); + Long selectedId, String payerName, String payeeName) { + List available = availableList(keyword, deptId, selectedId, payerName, payeeName); long current = Math.max(page.getCurrent(), 1); long size = Math.max(page.getSize(), 1); long from = Math.min((current - 1) * size, available.size()); @@ -103,22 +104,50 @@ public class BillLedgerServiceImpl extends BaseServiceImpl availableList(String keyword, Long deptId, Long selectedId) { + private List availableList(String keyword, Long deptId, Long selectedId, String payerName, + String payeeName) { + LocalDate today = LocalDate.now(); + // 未到期且有余额;收票且部门可用,或出票/收票单位与结算收付款方一致(开票、收票都可) return list(Wrappers.lambdaQuery() .and(Func.isNotEmpty(keyword), wrapper -> wrapper.like(BillLedger::getBillNo, keyword) .or().like(BillLedger::getIssuerName, keyword) .or().like(BillLedger::getReceiverName, keyword)) .and(wrapper -> wrapper - .gt(BillLedger::getAvailableBalance, BigDecimal.ZERO) + .and(available -> available + .ge(BillLedger::getMaturityDate, today) + .gt(BillLedger::getAvailableBalance, BigDecimal.ZERO)) .or(selectedId != null, child -> child.eq(BillLedger::getId, selectedId))) .orderByAsc(BillLedger::getMaturityDate) .orderByDesc(BillLedger::getCreateTime)).stream() .filter(item -> selectedId != null && Objects.equals(item.getId(), selectedId) - || departmentAvailable(item, deptId)) + || selectableBill(item, deptId, payerName, payeeName)) .map(item -> BillLedgerWrapper.build().entityVO(item)) .toList(); } + /** + * 收票且当前部门在可用范围内,或出票单位、收票单位与结算付款方、收款方一致(顺序不限)。 + */ + private boolean selectableBill(BillLedger ledger, Long deptId, String payerName, String payeeName) { + boolean receivedAndDept = "received".equals(ledger.getBillType()) && departmentAvailable(ledger, deptId); + return receivedAndDept || partiesMatch(ledger.getIssuerName(), ledger.getReceiverName(), payerName, payeeName); + } + + private boolean partiesMatch(String issuerName, String receiverName, String payerName, String payeeName) { + String issuer = trimName(issuerName); + String receiver = trimName(receiverName); + String payer = trimName(payerName); + String payee = trimName(payeeName); + if (issuer.isEmpty() || receiver.isEmpty() || payer.isEmpty() || payee.isEmpty()) { + return false; + } + return (issuer.equals(payer) && receiver.equals(payee)) || (issuer.equals(payee) && receiver.equals(payer)); + } + + private String trimName(String value) { + return value == null ? "" : value.trim(); + } + @Override @Transactional(rollbackFor = Exception.class) public Long submit(BillLedgerSaveRequest request) { @@ -136,6 +165,11 @@ public class BillLedgerServiceImpl extends BaseServiceImpllambdaQuery() - .likeRight(BillPayment::getPaymentNo, prefix)) + 1); + Long maxSequence = baseMapper.selectMaxDailySequence(AuthUtil.getTenantId(), prefix); + return prefix + String.format("%05d", (maxSequence == null ? 0L : maxSequence) + 1); } private BigDecimal positive(BigDecimal value, String name) { diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CertAuditServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CertAuditServiceImpl.java index 09a6ece6..247239d0 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CertAuditServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CertAuditServiceImpl.java @@ -354,6 +354,10 @@ public class CertAuditServiceImpl implements ICertAuditService { requireIdCardNotTaken(snapshot.getIdCardNo(), driver.getId()); driver.setDriverName(snapshot.getRealName()); driver.setIdCardNo(snapshot.getIdCardNo()); + driver.setBirthday(snapshot.getBirthday()); + // 空串不是「没填」:框架的 not_null 策略只跳过 null,写空串会把管理端填过的住址清掉 + driver.setAddressRegion(blankToNull(snapshot.getAddressRegion())); + driver.setAddress(blankToNull(snapshot.getAddress())); driver.setIdCardFront(snapshot.getIdCardFrontUrl()); driver.setIdCardBack(snapshot.getIdCardBackUrl()); driver.setIdCardValidStart(snapshot.getIdCardValidStart()); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CommonRouteServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CommonRouteServiceImpl.java index b2b2da65..2c8ec740 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CommonRouteServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CommonRouteServiceImpl.java @@ -30,6 +30,7 @@ import org.springblade.core.log.exception.ServiceException; import org.springblade.core.mp.base.BaseServiceImpl; import org.springblade.core.tool.utils.BeanUtil; import org.springblade.core.tool.utils.Func; +import org.springblade.system.cache.SysCache; import org.springblade.system.cache.UserCache; import org.springblade.system.pojo.entity.Dept; import org.springblade.transport.excel.CommonRouteExportExcel; @@ -217,12 +218,32 @@ public class CommonRouteServiceImpl extends BaseServiceImpl beforeData = buildContractChangeSnapshot(source); + applyContractNo(source, request.getContractNo(), false); source.setContractName(request.getContractName()); source.setPartyB(request.getPartyB()); source.setStartDate(request.getStartDate()); source.setEndDate(request.getEndDate()); source.setContractFormat(request.getContractFormat()); source.setSettlementMode(request.getSettlementMode()); source.setLegalSealFlag(request.getLegalSealFlag()); source.setCopyCount(request.getCopyCount()); @@ -467,9 +468,11 @@ public class ContractManageServiceImpl extends BaseServiceImpllambdaQuery() + .eq(ContractManage::getIsDeleted, 0) + .eq(ContractManage::getContractNo, normalizedContractNo) + .ne(Func.isNotEmpty(contractManage.getId()), ContractManage::getId, contractManage.getId())); + if (duplicatedCount != null && duplicatedCount > 0) { + throw new ServiceException("合同编号已存在"); + } + contractManage.setContractNo(normalizedContractNo); + } + @Override public synchronized String nextContractNo(Long organizationId) { if (Func.isEmpty(organizationId)) { @@ -606,7 +630,8 @@ public class ContractManageServiceImpl extends BaseServiceImpl 999) { + throw new ServiceException("组织机构中配置的合同初始流水号不能超过999"); + } + return serial; + } + private void refreshArchiveStatusOnApprove(ContractManage contractManage) { if (hasSealArchiveFile(contractManage.getContractFileJson())) { contractManage.setArchiveStatus(ARCHIVE_STATUS_ARCHIVED); @@ -1063,6 +1099,7 @@ public class ContractManageServiceImpl extends BaseServiceImpl buildContractChangeSnapshot(ContractManage contractManage) { Map data = new LinkedHashMap<>(); + data.put("contractNo", contractManage.getContractNo()); data.put("contractName", contractManage.getContractName()); data.put("partyB", contractManage.getPartyB()); data.put("startDate", contractManage.getStartDate()); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CustomerArchiveServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CustomerArchiveServiceImpl.java index af8f84c2..bb762f25 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CustomerArchiveServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CustomerArchiveServiceImpl.java @@ -168,7 +168,7 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpllambdaQuery() + .eq(CustomerArchive::getCustomerCode, kingdeeNumber) + .ne(CustomerArchive::getId, customer.getId())); + if (occupied > 0) { + log.warn("金蝶客商编码 {} 已被其他客商占用,跳过采纳客商编号,id={},customerCode={}", + kingdeeNumber, customer.getId(), customer.getCustomerCode()); + return ",金蝶客商编码 " + kingdeeNumber + " 已被占用未同步"; + } + after.setCustomerCode(kingdeeNumber); + return ",客商编号已同步为金蝶客商编码:" + kingdeeNumber; + } + private BfiBillSaveResultItemVO firstSaveItem(BfiBillSaveResultVO result) { if (result == null || result.getResult() == null || result.getResult().isEmpty()) { return null; @@ -739,11 +765,11 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl imp Driver driver = new Driver(); driver.setId(id); driver.setStatus(status); + // 只改 status 也必须带上 driving_vehicle 原值,理由见 carryDrivingVehicle + carryDrivingVehicle(id, driver); return updateById(driver); } @@ -446,9 +448,26 @@ public class DriverServiceImpl extends BaseServiceImpl imp Driver patch = new Driver(); patch.setId(driver.getId()); patch.setUserId(userId); + // 同上:这次只改 user_id,不带 driving_vehicle 原值同样会把车辆绑定清掉 + carryDrivingVehicle(driver.getId(), patch); updateById(patch); } + /** + * 把库里的 driving_vehicle 原值带进 patch。 + *

+ * 该列标了 {@code FieldStrategy.ALWAYS},更新时忽略 null 直接写:patch 里不带这一列, + * 一次只改别的列的更新就会把司机的车辆绑定清成 NULL,而司机端车辆列表正是按这一列 + * 的车牌匹配出来的(清空即「暂无绑定车辆」)。读不到该司机时保持原样, + * 让更新自己去失败。 + */ + private void carryDrivingVehicle(Long driverId, Driver patch) { + Driver current = getById(driverId); + if (current != null) { + patch.setDrivingVehicle(current.getDrivingVehicle()); + } + } + private Long defaultZero(Long value) { return value == null ? 0L : value; } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java index 9158ce0d..ae825828 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java @@ -28,6 +28,7 @@ import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import lombok.RequiredArgsConstructor; import org.springblade.core.log.exception.ServiceException; +import org.springblade.core.secure.BladeUser; import org.springblade.core.secure.utils.AuthUtil; import org.springblade.core.tool.jackson.JsonUtil; import org.springblade.core.tool.utils.DateUtil; @@ -45,24 +46,31 @@ import org.springblade.transport.pojo.dto.PunchPhotoDTO; import org.springblade.transport.pojo.entity.Driver; import org.springblade.transport.pojo.entity.ExceptionDisposal; import org.springblade.transport.pojo.entity.TransportVehicle; -import org.springblade.transport.service.ITransportVehicleService; -import org.springblade.transport.support.CertificationGate; -import org.springblade.transport.support.VehiclePlateSupport; -import org.springblade.transport.pojo.entity.ProcessConfig; import org.springblade.transport.pojo.entity.Waybill; import org.springblade.transport.pojo.entity.WaybillEnroutePunch; import org.springblade.transport.pojo.entity.WaybillNodePunch; +import org.springblade.transport.service.ITransportVehicleService; +import org.springblade.transport.support.CertificationGate; +import org.springblade.transport.support.LiveProcessConfigSupport; +import org.springblade.transport.support.VehiclePlateSupport; +import org.springblade.transport.support.WaybillCargoSupport; import org.springblade.transport.pojo.vo.DriverEnrouteRecordVO; import org.springblade.transport.pojo.vo.DriverNodePunchVO; import org.springblade.transport.pojo.vo.DriverPunchNodeVO; import org.springblade.transport.pojo.vo.DriverPunchPhotoVO; +import org.springblade.transport.pojo.vo.CargoLineVO; import org.springblade.transport.pojo.vo.DriverWaybillCardVO; import org.springblade.transport.pojo.vo.DriverWaybillPreviewVO; import org.springblade.transport.pojo.vo.DriverWaybillTabCountsVO; +import org.springblade.transport.pojo.vo.TripQueryVO; +import org.springblade.transport.pojo.vo.TripStationVO; +import org.springblade.transport.pojo.vo.TripTabCountsVO; +import org.springblade.transport.pojo.vo.TripVO; import org.springblade.transport.pojo.vo.WaybillTrackVO; import org.springblade.transport.service.IDriverService; import org.springblade.transport.service.IDriverWaybillService; import org.springblade.transport.service.IProcessConfigService; +import org.springblade.transport.service.ITripQueryService; import org.springblade.transport.service.IWaybillService; import org.springblade.transport.support.WaybillProcessSupport; import org.springframework.stereotype.Service; @@ -102,6 +110,10 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { private static final String STATUS_COMPLETED = "completed"; private static final String STATUS_CANCELLED = "cancelled"; + /** 运输组织类型:运单有配载单号即配载(load),否则普通(common) */ + private static final String TRANSPORT_COMMON = "common"; + private static final String TRANSPORT_LOAD = "load"; + /** 小程序「待接单」对应的后端业务状态 */ private static final List PENDING_STATUSES = Arrays.asList( STATUS_PENDING, STATUS_WAITING_DISPATCH, STATUS_DISPATCHING @@ -110,6 +122,10 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { private static final List TAB_ALL_STATUSES = Arrays.asList( STATUS_PENDING, STATUS_WAITING_DISPATCH, STATUS_DISPATCHING, STATUS_RUNNING, STATUS_COMPLETED ); + /** 允许完成运单的业务状态 */ + private static final List COMPLETABLE_STATUSES = Arrays.asList( + STATUS_RUNNING, STATUS_PENDING, STATUS_WAITING_DISPATCH, STATUS_DISPATCHING + ); private static final int DEFAULT_PREVIEW_SIZE = 2; private static final int MAX_PREVIEW_SIZE = 20; @@ -122,6 +138,10 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { /** 被异常阻断时的不可打卡原因(司机端展示) */ private static final String LOCK_REASON_EXCEPTION = "该运单存在未处理异常,处理完成后才能打卡"; + /** 运次状态码:0 待接单 / 1 运输中 / 2 已完成 / 3 已取消,与运单侧同一套 */ + private static final int TRIP_STATUS_PENDING = 0; + private static final int TRIP_STATUS_RUNNING = 1; + private final IWaybillService waybillService; private final IDriverService driverService; private final IMpUserClient mpUserClient; @@ -131,15 +151,16 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { private final WaybillEnroutePunchMapper enroutePunchMapper; private final WaybillNodePunchMapper nodePunchMapper; private final ExceptionDisposalMapper exceptionDisposalMapper; + private final ITripQueryService tripQueryService; @Override public DriverWaybillCardVO currentTask() { - List plates = currentBoundPlates(); - if (plates.isEmpty()) { + String phone = currentUserPhone(); + if (Func.isEmpty(phone)) { return null; } // 进行中:running,或无需确认接单但仍为 pending 的历史数据 - List candidates = waybillService.list(scopedQuery(plates) + List candidates = waybillService.list(scopedQuery(phone) .in(Waybill::getBusinessStatus, List.of(STATUS_RUNNING, STATUS_PENDING)) .orderByDesc(Waybill::getUpdateTime) .last("LIMIT 20")); @@ -155,12 +176,12 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { @Override public DriverWaybillPreviewVO pendingPreview(Integer size) { DriverWaybillPreviewVO preview = new DriverWaybillPreviewVO(); - List plates = currentBoundPlates(); - if (plates.isEmpty()) { + String phone = currentUserPhone(); + if (Func.isEmpty(phone)) { return preview; } int limit = normalizePreviewSize(size); - List pendingList = waybillService.list(scopedQuery(plates) + List pendingList = waybillService.list(scopedQuery(phone) .in(Waybill::getBusinessStatus, PENDING_STATUSES) .orderByDesc(Waybill::getCreateTime)); List needAccept = pendingList.stream() @@ -175,11 +196,11 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { @Override public DriverWaybillTabCountsVO tabCounts() { DriverWaybillTabCountsVO vo = new DriverWaybillTabCountsVO(); - List plates = currentBoundPlates(); - if (plates.isEmpty()) { + String phone = currentUserPhone(); + if (Func.isEmpty(phone)) { return vo; } - List waybills = waybillService.list(scopedQuery(plates) + List waybills = waybillService.list(scopedQuery(phone) .in(Waybill::getBusinessStatus, TAB_ALL_STATUSES)); long pending = 0; long doing = 0; @@ -211,12 +232,12 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { int pageSize = size == null || size < 1 ? DEFAULT_PAGE_SIZE : Math.min(size, MAX_PAGE_SIZE); Page empty = new Page<>(pageNo, pageSize); - List plates = currentBoundPlates(); - if (plates.isEmpty()) { + String phone = currentUserPhone(); + if (Func.isEmpty(phone)) { return empty; } - LambdaQueryWrapper wrapper = scopedQuery(plates); + LambdaQueryWrapper wrapper = scopedQuery(phone); // 先按 Tab 口径拉候选,再按过程配置校正 pending→running 后内存分页 applyStatusFilterForQuery(wrapper, status); applyKeyword(wrapper, keyword); @@ -239,24 +260,104 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { @Override public DriverWaybillCardVO detail(Long id) { - Driver driver = requireCurrentDriver(); - Waybill waybill = loadDriverWaybill(id, currentBoundPlates(driver)); + requireCurrentDriver(); + Waybill waybill = loadDriverWaybill(id); return toCard(normalizeAcceptStatus(waybill), true); } + @Override + public DriverWaybillCardVO tripCurrentTask() { + String phone = currentUserPhone(); + if (Func.isEmpty(phone)) { + return null; + } + TripQueryVO query = TripQueryVO.forDriver(phone); + query.setStatus(TRIP_STATUS_RUNNING); + query.setCurrent(1); + query.setSize(1); + IPage page = tripQueryService.page(query); + return page.getRecords().isEmpty() ? null : toTripCard(page.getRecords().get(0), false); + } + + @Override + public DriverWaybillPreviewVO tripPendingPreview(Integer size) { + DriverWaybillPreviewVO preview = new DriverWaybillPreviewVO(); + String phone = currentUserPhone(); + if (Func.isEmpty(phone)) { + return preview; + } + TripQueryVO query = TripQueryVO.forDriver(phone); + query.setStatus(TRIP_STATUS_PENDING); + query.setCurrent(1); + query.setSize(normalizePreviewSize(size)); + IPage page = tripQueryService.page(query); + preview.setTotal(page.getTotal()); + preview.setRecords(page.getRecords().stream() + .map(trip -> toTripCard(trip, false)) + .collect(Collectors.toList())); + return preview; + } + + @Override + public DriverWaybillTabCountsVO tripTabCounts() { + DriverWaybillTabCountsVO counts = new DriverWaybillTabCountsVO(); + String phone = currentUserPhone(); + if (Func.isEmpty(phone)) { + return counts; + } + TripTabCountsVO tripCounts = tripQueryService.tabCounts(TripQueryVO.forDriver(phone)); + counts.setPending(tripCounts.getPending()); + counts.setDoing(tripCounts.getDoing()); + counts.setDone(tripCounts.getDone()); + counts.setAll(tripCounts.getAll()); + return counts; + } + + @Override + public IPage tripPage(Integer current, Integer size, Integer status, String keyword) { + String phone = currentUserPhone(); + if (Func.isEmpty(phone)) { + return new Page<>(normalizePageNo(current), normalizePageSize(size)); + } + TripQueryVO query = TripQueryVO.forDriver(phone); + query.setStatus(status); + query.setKeyword(keyword); + query.setCurrent(current); + query.setSize(size); + IPage trips = tripQueryService.page(query); + Page page = new Page<>(trips.getCurrent(), trips.getSize(), trips.getTotal()); + page.setRecords(trips.getRecords().stream() + .map(trip -> toTripCard(trip, false)) + .collect(Collectors.toList())); + return page; + } + + @Override + public DriverWaybillCardVO tripDetail(String tripId) { + requireCurrentDriver(); + TripQueryVO query = TripQueryVO.forDriver(currentUserPhone()); + query.setTripId(tripId); + return toTripCard(tripQueryService.detail(query), true); + } + @Override public WaybillTrackVO track(Long id) { - Driver driver = requireCurrentDriver(); - Waybill waybill = loadDriverWaybill(id, currentBoundPlates(driver)); + requireCurrentDriver(); + Waybill waybill = loadDriverWaybill(id); LocalDate[] range = resolveTrackDateRange(waybill); return waybillService.trackVehicle(waybill.getId(), range[0].toString(), range[1].toString()); } /** * 按打卡记录 / 计划时间推导轨迹查询起止日期 + *

+ * TODO(临时):当前仅查当天,便于联调 LBS;恢复正式逻辑时删掉本段 early-return。 */ private LocalDate[] resolveTrackDateRange(Waybill waybill) { LocalDate today = LocalDate.now(); + // TEMP: 行驶轨迹时间段临时改为仅当天 + return new LocalDate[]{today, today}; + /* List punchDates = new ArrayList<>(); nodePunchMapper.selectList(Wrappers.lambdaQuery() .eq(WaybillNodePunch::getWaybillId, waybill.getId()) @@ -288,6 +389,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { return new LocalDate[]{waybill.getStartDate(), today}; } return new LocalDate[]{today.minusDays(3), today}; + */ } @Override @@ -309,17 +411,22 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { throw new ServiceException("运单ID不能为空"); } Driver driver = requireCurrentDriver(); - Waybill waybill = loadDriverWaybill(dto.getWaybillId(), currentBoundPlates(driver)); + // 在途打卡单独保留原取单判据,不排除已拒单(见 scopedQuery 的重载说明) + // 加行锁:重复打卡是先查后插,并发请求读不到对方未提交的流水,不锁会各插一条 + Waybill waybill = loadDriverWaybill(dto.getWaybillId(), true, true); waybill = normalizeAcceptStatus(waybill); + assertPunchNotEnded(waybill.getBusinessStatus()); + assertPunchStarted(waybill.getBusinessStatus()); String processJson = resolveProcessJson(waybill); - Date lastPunchAt = findLastPunchTime(waybill.getId()); + Date lastPunchAt = findLastPunchTime(waybill.getId(), true); WaybillProcessSupport.TransitCheckinDecision decision = WaybillProcessSupport.evaluateTransitCheckin( processJson, waybill.getBusinessStatus(), lastPunchAt, LocalDateTime.now()); if (!decision.punchEnabled()) { throw new ServiceException("该运单未启用在途打卡"); } - if (decision.doneToday()) { + // 重复校验按「自然日是否打过」独立判:decision.doneToday() 只在进行中分支计算 + if (WaybillProcessSupport.isPunchedOn(lastPunchAt, null)) { throw new ServiceException("今日已完成在途打卡,不可重复打卡"); } // 在途也是过程节点之一,占过程配置顺序里的位置,同样受异常门禁约束 @@ -358,8 +465,10 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { throw new ServiceException("节点编码不能为空"); } Driver driver = requireCurrentDriver(); - Waybill waybill = loadDriverWaybill(dto.getWaybillId(), currentBoundPlates(driver)); + Waybill waybill = loadDriverWaybill(dto.getWaybillId()); waybill = normalizeAcceptStatus(waybill); + assertPunchNotEnded(waybill.getBusinessStatus()); + assertPunchStarted(waybill.getBusinessStatus()); String processJson = resolveProcessJson(waybill); Map nodeCfg = findPunchNodeConfig(processJson, nodeCode); @@ -411,7 +520,20 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { nodePunchMapper.insert(punch); advanceCurrentProcessNode(waybill, nodeCfg); - return toNodePunchVO(punch); + // 末位打卡节点是到货/卸货/签收、且回单无需确认完成时,本次打卡一并把运单置为完成; + // isLastDriverPunchNode 与 returnNeedsNoConfirmation 传的就是上面那份 processJson,未再调 resolveProcessJson 取过程配置。 + String nodeKey = WaybillProcessSupport.nodeKey(nodeCfg); + String nodeName = WaybillProcessSupport.nodeName(nodeCfg); + boolean completed = false; + if (WaybillProcessSupport.isLastDriverPunchNode(processJson, nodeKey) + && WaybillProcessSupport.isDeliveryNode(nodeKey, nodeName) + && WaybillProcessSupport.returnNeedsNoConfirmation(processJson) + && canAutoCompleteWaybill(waybill)) { + completed = waybillService.completeWithoutDeptCheck(waybill.getId()); + } + DriverNodePunchVO vo = toNodePunchVO(punch); + vo.setWaybillCompleted(completed); + return vo; } @Override @@ -421,7 +543,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { // 认证门槛:司机本人须已认证,且承运车辆须已认证。 // 判据是各自持有的生效快照指针,故「已认证的车重新提交认证」不会拦下接单。 assertCertified(driver); - Waybill waybill = loadDriverWaybill(id, currentBoundPlates(driver)); + Waybill waybill = loadDriverWaybill(id); waybillService.syncDriverAcceptState(waybill); assertAcceptable(waybill); Date now = new Date(); @@ -471,8 +593,8 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { @Override @Transactional(rollbackFor = Exception.class) public boolean reject(Long id, String reason) { - Driver driver = requireCurrentDriver(); - Waybill waybill = loadDriverWaybill(id, currentBoundPlates(driver)); + requireCurrentDriver(); + Waybill waybill = loadDriverWaybill(id); waybillService.syncDriverAcceptState(waybill); assertRejectable(waybill); String rejectReason = Func.isEmpty(reason) ? null : reason.trim(); @@ -495,13 +617,10 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { if (id == null) { throw new ServiceException("运单ID不能为空"); } - Driver driver = requireCurrentDriver(); - Waybill waybill = loadDriverWaybill(id, currentBoundPlates(driver)); + requireCurrentDriver(); + Waybill waybill = loadDriverWaybill(id); waybill = normalizeAcceptStatus(waybill); - if (!STATUS_RUNNING.equals(waybill.getBusinessStatus()) - && !STATUS_PENDING.equals(waybill.getBusinessStatus()) - && !STATUS_WAITING_DISPATCH.equals(waybill.getBusinessStatus()) - && !STATUS_DISPATCHING.equals(waybill.getBusinessStatus())) { + if (!COMPLETABLE_STATUSES.contains(waybill.getBusinessStatus())) { throw new ServiceException("当前运单状态不允许完成"); } // 有未了结异常时不允许完成——完成接口不要求节点全部打卡,不挡住就能绕过打卡门禁 @@ -520,6 +639,10 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { if (WaybillProcessSupport.isAccepted(waybill.getDriverAcceptStatus())) { throw new ServiceException("该运单已接单"); } + // 拒单后运单已对本人脱离,不给自己接回来的入口;要改主意只能由调度重新派单 + if (WaybillProcessSupport.isRejected(waybill.getDriverAcceptStatus())) { + throw new ServiceException("该运单已拒单,请联系调度重新派单"); + } } private void assertRejectable(Waybill waybill) { @@ -534,14 +657,161 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { } } - private Waybill loadDriverWaybill(Long id, List plates) { + @Override + @Transactional(rollbackFor = Exception.class) + public boolean tripAccept(String tripId) { + Driver driver = requireCurrentDriver(); + // 认证门槛按整车一次过:一次接单只校验一次司机与车辆 + assertCertified(driver); + List children = tripChildren(tripId); + assertTripAcceptable(children); + List pendingIds = writeCandidateIds(children, "当前运单状态不允许接单"); + // 更新痕迹与单票接单一致:单票走 updateById,写前会把 update_time 刷成提交时刻、 + // 把 update_user 填成当前用户(无登录态则不填);这里一次写完,显式补上同样两列, + // 时刻取与接单时刻同一个 now,操作人取同一个登录用户 + Date now = new Date(); + Long operatorId = currentOperatorId(); + return waybillService.update(Wrappers.lambdaUpdate() + .set(Waybill::getDriverAcceptStatus, WaybillProcessSupport.ACCEPT_ACCEPTED) + .set(Waybill::getDriverAcceptTime, now) + .set(Waybill::getDriverAcceptDriverId, driver.getId()) + .set(Waybill::getDriverRejectTime, null) + .set(Waybill::getDriverRejectReason, null) + .set(Waybill::getBusinessStatus, STATUS_RUNNING) + .set(Waybill::getUpdateTime, now) + .set(operatorId != null, Waybill::getUpdateUser, operatorId) + .in(Waybill::getId, pendingIds)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean tripReject(String tripId, String reason) { + requireCurrentDriver(); + List children = tripChildren(tripId); + assertTripRejectable(children); + String rejectReason = Func.isEmpty(reason) ? null : reason.trim(); + if (Func.isNotEmpty(rejectReason) && rejectReason.length() > 200) { + throw new ServiceException("拒绝原因不能超过200字"); + } + List pendingIds = writeCandidateIds(children, "当前运单状态不允许拒绝接单"); + // 整单拒绝落在每张未终态子运单上,一次一条语句写完,拒单时刻与更新痕迹全趟车一致 + Date now = new Date(); + Long operatorId = currentOperatorId(); + return waybillService.update(Wrappers.lambdaUpdate() + .set(Waybill::getDriverAcceptStatus, WaybillProcessSupport.ACCEPT_REJECTED) + .set(Waybill::getDriverAcceptTime, null) + .set(Waybill::getDriverAcceptDriverId, null) + .set(Waybill::getDriverRejectTime, now) + .set(Waybill::getDriverRejectReason, rejectReason) + .set(Waybill::getBusinessStatus, STATUS_PENDING) + .set(Waybill::getUpdateTime, now) + .set(operatorId != null, Waybill::getUpdateUser, operatorId) + .in(Waybill::getId, pendingIds)); + } + + /** + * 当前登录用户主键:与框架写入时填充更新人的口径一致——没有登录态就不填 + */ + private Long currentOperatorId() { + BladeUser operator = AuthUtil.getUser(); + return operator == null ? null : operator.getUserId(); + } + + /** + * 运次 → 子运单集合:配载的运次走运次读服务解析(配载单主键命中后按配载单号取子运单), + * 未配载的就是那张运单本身。收口与列表一致——别人的运次在这里已经被挡住。 + *

+ * 子单集合是司机可见的那一批:已拒单的子单不在其中,否则接单会把本人拒掉的单重新拉回来。 + */ + private List tripChildren(String tripId) { + TripQueryVO query = TripQueryVO.forDriver(currentUserPhone()); + query.setTripId(tripId); + TripVO trip = tripQueryService.detail(query); + List children = trip.getWaybills() == null ? List.of() : trip.getWaybills(); + if (children.isEmpty()) { + throw new ServiceException("运次不存在或无权操作"); + } + return children; + } + + /** + * 接单校验按整车:有子单没走完才可接单,任一子单的项目要接单整趟车就要接单,任一子单已接单即整单已接单 + */ + private void assertTripAcceptable(List children) { + if (children.stream().allMatch(this::isTerminalChild)) { + throw new ServiceException("当前运单状态不允许接单"); + } + if (children.stream().noneMatch(child -> Boolean.TRUE.equals(child.getRequireAccept()))) { + throw new ServiceException("该运单无需确认接单"); + } + if (children.stream().anyMatch(child -> WaybillProcessSupport.isAccepted(child.getAcceptStatus()))) { + throw new ServiceException("该运单已接单"); + } + if (children.stream().anyMatch(child -> WaybillProcessSupport.isRejected(child.getAcceptStatus()))) { + throw new ServiceException("该运单已拒单,请联系调度重新派单"); + } + } + + /** + * 拒单校验按整车:有子单没走完才可拒绝,任一子单已接单则整单不能拒绝 + */ + private void assertTripRejectable(List children) { + if (children.stream().allMatch(this::isTerminalChild)) { + throw new ServiceException("当前运单状态不允许拒绝接单"); + } + if (children.stream().noneMatch(child -> Boolean.TRUE.equals(child.getRequireAccept()))) { + throw new ServiceException("该运单无需确认接单"); + } + if (children.stream().anyMatch(child -> WaybillProcessSupport.isAccepted(child.getAcceptStatus()))) { + throw new ServiceException("该运单已接单,无法拒绝"); + } + } + + /** + * 可写入的子运单:已走完的子单不动——把已完成/已取消的子单改回运输中或待执行等于回退历史状态 + */ + private List writeCandidateIds(List children, String emptyMessage) { + List ids = children.stream() + .filter(child -> !isTerminalChild(child)) + .map(child -> parseId(child.getId())) + .filter(Objects::nonNull) + .toList(); + if (ids.isEmpty()) { + throw new ServiceException(emptyMessage); + } + return ids; + } + + private boolean isTerminalChild(TripVO.TripWaybillVO child) { + return WaybillProcessSupport.isTerminalBusinessStatus(child.getBusinessStatus()); + } + + private Waybill loadDriverWaybill(Long id) { + return loadDriverWaybill(id, false); + } + + /** @param includeRejected 仅 submitEnroute 传 true,见 scopedQuery 的重载说明 */ + private Waybill loadDriverWaybill(Long id, boolean includeRejected) { + return loadDriverWaybill(id, includeRejected, false); + } + + /** + * @param forUpdate 加行锁,仅 submitEnroute 使用:「同一天是否打过」是查完再插, + * 不加锁时并发的两个请求都读不到对方未提交的流水,会各插一条。 + * 锁的是运单行,同一运单的打卡提交因此串行。 + */ + private Waybill loadDriverWaybill(Long id, boolean includeRejected, boolean forUpdate) { if (id == null) { throw new ServiceException("运单ID不能为空"); } - if (plates.isEmpty()) { - throw new ServiceException("当前司机未绑定车辆"); + String phone = currentUserPhone(); + if (Func.isEmpty(phone)) { + throw new ServiceException("当前用户手机号为空,无法查找运单"); } - Waybill waybill = waybillService.getOne(scopedQuery(plates).eq(Waybill::getId, id).last("LIMIT 1")); + // MySQL 的 FOR UPDATE 必须跟在 LIMIT 之后 + String tail = forUpdate ? "LIMIT 1 FOR UPDATE" : "LIMIT 1"; + Waybill waybill = waybillService.getOne(scopedQuery(phone, includeRejected) + .eq(Waybill::getId, id).last(tail)); if (waybill == null) { throw new ServiceException("运单不存在或无权操作"); } @@ -573,8 +843,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { } switch (status) { case 0 -> wrapper.in(Waybill::getBusinessStatus, PENDING_STATUSES); - case 1 -> wrapper.in(Waybill::getBusinessStatus, List.of(STATUS_RUNNING, STATUS_PENDING, - STATUS_WAITING_DISPATCH, STATUS_DISPATCHING)); + case 1 -> wrapper.in(Waybill::getBusinessStatus, COMPLETABLE_STATUSES); case 2 -> wrapper.eq(Waybill::getBusinessStatus, STATUS_COMPLETED); case 3 -> wrapper.eq(Waybill::getBusinessStatus, STATUS_CANCELLED); default -> wrapper.in(Waybill::getBusinessStatus, TAB_ALL_STATUSES); @@ -598,24 +867,31 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { } /** - * 司机可见运单范围:运单车牌(主车/挂车)落在当前司机绑定车牌内。 - * 绑定来源:blade_transport_driver.driving_vehicle + * 司机可见运单范围:运单司机手机号(driver_phone)等于当前用户手机号, + * 且该运单未被这个司机拒单。 + *

+ * 拒单后运单对司机彻底脱离——列表、计数、详情、打卡、接单一律不可达,等管理员重新派单。 + * 运单上的司机姓名/手机/车牌不清空,调度重新派单时要知道原来派给谁。 */ - private LambdaQueryWrapper scopedQuery(List plates) { - return Wrappers.lambdaQuery().and(w -> { - w.in(Waybill::getVehicleNo, plates) - .or().in(Waybill::getTrailerVehicleNo, plates); - for (String plate : plates) { - w.or().apply( - "REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(UPPER(IFNULL(vehicle_no,'')),'·',''),'•',''),'.',''),'-',''),' ','') = {0}", - plate - ); - w.or().apply( - "REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(UPPER(IFNULL(trailer_vehicle_no,'')),'·',''),'•',''),'.',''),'-',''),' ','') = {0}", - plate - ); - } - }); + private LambdaQueryWrapper scopedQuery(String phone) { + return scopedQuery(phone, false); + } + + /** + * @param includeRejected true 时不排除已拒单的运单,仅限在途打卡使用: + * 拒单的运单不可能处于运输中,收口拦不到合法操作, + * 而保留原判据可避免「已拒单 + 进行中」的历史脏数据被误伤 + */ + private LambdaQueryWrapper scopedQuery(String phone, boolean includeRejected) { + String normalizedPhone = Func.toStr(phone, "").trim(); + LambdaQueryWrapper wrapper = Wrappers.lambdaQuery().and(w -> w + .eq(Waybill::getDriverPhone, normalizedPhone) + .or().apply("REPLACE(IFNULL(driver_phone,''),' ','') = {0}", normalizedPhone)); + if (!includeRejected) { + wrapper.and(w -> w.isNull(Waybill::getDriverAcceptStatus) + .or().ne(Waybill::getDriverAcceptStatus, WaybillProcessSupport.ACCEPT_REJECTED)); + } + return wrapper; } private void applyKeyword(LambdaQueryWrapper wrapper, String keyword) { @@ -631,13 +907,26 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { .or().like(Waybill::getVehicleNo, kw)); } - /** 当前登录司机绑定的规范化车牌列表;无司机或无绑定车牌则空 */ - private List currentBoundPlates() { + /** + * 当前用户手机号:司机档案 mobile 优先,其次小程序用户账号。 + *

+ * 派单时运单 driver_phone 取自司机档案 mobile,故档案有手机号就以它为准。 + */ + private String currentUserPhone() { Driver driver = currentDriver(); - if (driver == null || Func.isEmpty(driver.getDrivingVehicle())) { - return List.of(); + if (driver != null && isMobileLike(driver.getMobile())) { + return driver.getMobile().trim(); } - return splitPlates(driver.getDrivingVehicle()); + Long userId = AuthUtil.getUserId(); + if (userId == null || userId <= 0) { + return null; + } + R result = mpUserClient.userById(userId); + if (result != null && R.isSuccess(result) && result.getData() != null + && isMobileLike(result.getData().getAccount())) { + return result.getData().getAccount().trim(); + } + return null; } /** @@ -699,17 +988,28 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { return toCard(waybill, false); } - private DriverWaybillCardVO toCard(Waybill waybill, boolean withEnrouteRecords) { + private DriverWaybillCardVO toCard(Waybill waybill, boolean withDetail) { DriverWaybillCardVO card = new DriverWaybillCardVO(); card.setId(waybill.getId()); card.setWaybillNo(waybill.getWaybillNo()); - card.setFromName(Func.toStr(waybill.getDepartureName(), "")); - card.setToName(Func.toStr(waybill.getArrivalName(), "")); - card.setFromAddress(Func.toStr(waybill.getDepartureAddress(), card.getFromName())); - card.setToAddress(Func.toStr(waybill.getArrivalAddress(), card.getToName())); - card.setCargoNames(splitCargoNames(waybill.getCargoName())); - card.setCargoCategory(Func.toStr(waybill.getCargoType(), "")); - card.setWeight(formatWeight(waybill.getQuantity(), waybill.getQuantityUnit())); + // 批量导入的运单只有发货/到货地址、没有地点名称,名称为空时回退到地址, + // 否则司机端首页、运单列表与打卡页的地点位置都是空的 + card.setFromName(Func.toStrWithEmpty(waybill.getDepartureName(), Func.toStr(waybill.getDepartureAddress(), ""))); + card.setToName(Func.toStrWithEmpty(waybill.getArrivalName(), Func.toStr(waybill.getArrivalAddress(), ""))); + card.setFromAddress(Func.toStrWithEmpty(waybill.getDepartureAddress(), card.getFromName())); + card.setToAddress(Func.toStrWithEmpty(waybill.getArrivalAddress(), card.getToName())); + // 货名取运单上录入的全部货物:主表 cargo_name 只落了第一行,直接拿它会漏掉这张票的其余货物 + List cargos = WaybillCargoSupport.of(waybill); + List cargoNames = WaybillCargoSupport.cargoNames(cargos); + card.setCargoNames(cargoNames); + card.setCargoCategory(cargos.stream() + .map(WaybillCargoSupport.Cargo::cargoType) + .filter(Func::isNotEmpty) + .distinct() + .collect(Collectors.joining("、"))); + // 计重合计:全部货物同单位时就是该单位合计,单位不一致时只有吨项参与(口径见 WaybillCargoSupport#weighing) + WaybillCargoSupport.Quantity weighing = WaybillCargoSupport.weighing(cargos); + card.setWeight(weighing == null ? "" : formatWeight(weighing.value(), weighing.unit())); card.setStatus(toAppStatus(waybill.getBusinessStatus())); String createTime = formatDateTime(waybill.getCreateTime()); card.setCreateTime(createTime); @@ -726,19 +1026,21 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { card.setRejectReason(waybill.getDriverRejectReason()); // 详情页字段(列表也可带上,体积很小) - String cargoName = Func.toStr(waybill.getCargoName(), ""); + String cargoName = String.join("、", cargoNames); String weightText = card.getWeight(); card.setCargoName(cargoName); card.setPickupAddress(card.getFromAddress()); card.setUnloadAddress(card.getToAddress()); card.setCargoQuantity(weightText); card.setTotalWeight(weightText); + card.setCargos(withDetail ? cargoLines(cargos) : null); card.setTransportType(toTransportTypeLabel(waybill.getTransportType())); + card.setTransportOrgType(Func.isNotBlank(waybill.getLoadingNo()) ? TRANSPORT_LOAD : TRANSPORT_COMMON); card.setPlanShipTime(formatLocalDateYmd(waybill.getEstimatedStartTime())); card.setPlanFinishTime(formatLocalDateYmd(waybill.getEstimatedEndTime())); card.setRemark(Func.toStr(waybill.getRemark(), "")); - if (withEnrouteRecords) { + if (withDetail) { Date lastPunchAt = findLastPunchTime(waybill.getId()); WaybillProcessSupport.TransitCheckinDecision transit = WaybillProcessSupport.evaluateTransitCheckin( processJson, waybill.getBusinessStatus(), lastPunchAt, LocalDateTime.now()); @@ -776,6 +1078,143 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { return List.of(load, unload); } + /** + * 运次卡片 + *

+ * 未配载的运次就是一张运单:详情照运单详情下发(打卡节点、在途打卡、过程配置都还在), + * 只是状态按运次口径只读推导,不走会落库的接单态归一化。 + * 配载的运次按配载单下发整趟车的信息,运单级字段(运费、当前节点、打卡节点)留空—— + * 一趟车里每张子单各有各的项目配置与打卡节点。 + */ + private DriverWaybillCardVO toTripCard(TripVO trip, boolean withDetail) { + DriverWaybillCardVO card = Func.isEmpty(trip.getLoadingNo()) && withDetail + ? unloadedTripDetail(trip) + : new DriverWaybillCardVO(); + fillTripFields(card, trip, withDetail); + return card; + } + + /** + * 未配载运次的详情照运单详情拼,只读:不调用 normalizeAcceptStatus + */ + private DriverWaybillCardVO unloadedTripDetail(TripVO trip) { + Long waybillId = parseId(trip.getId()); + Waybill waybill = waybillId == null ? null : waybillService.getById(waybillId); + return waybill == null ? new DriverWaybillCardVO() : toCard(waybill, true); + } + + private void fillTripFields(DriverWaybillCardVO card, TripVO trip, boolean withDetail) { + List children = trip.getWaybills() == null ? List.of() : trip.getWaybills(); + card.setTripId(trip.getId()); + card.setTripNo(Func.toStr(trip.getTripNo(), "")); + card.setLoadingNo(Func.toStr(trip.getLoadingNo(), "")); + // 旧字段 id / waybillNo 保留原语义:未配载就是这张运单,配载取下第一张可见子运单(运次操作请用 tripId) + card.setId(parseId(children.isEmpty() ? trip.getId() : children.get(0).getId())); + card.setWaybillNo(children.isEmpty() ? card.getTripNo() : Func.toStr(children.get(0).getWaybillNo(), "")); + card.setStatus(trip.getStatus()); + card.setRequireAccept(trip.getRequireAccept()); + card.setAcceptStatus(trip.getAcceptStatus()); + card.setTransportOrgType(trip.getTransportOrgType()); + card.setTransportType(toTransportTypeLabel(trip.getTransportType())); + card.setDriverName(Func.toStr(trip.getDriverName(), "")); + card.setDriverPhone(Func.toStr(trip.getDriverPhone(), "")); + card.setVehicleNo(Func.toStr(trip.getVehicleNo(), "")); + card.setCreateTime(formatDateTime(trip.getSortTime())); + card.setPublishTime(card.getCreateTime()); + // 计划时间两条路同源:配载运次取配载单,未配载运次取那张运单自己的。 + // 未配载详情进来时卡片已照运单填过,运次级为空就保留它,不覆盖成空 + if (trip.getEstimatedStartDate() != null) { + card.setPlanShipTime(formatLocalDateYmd(trip.getEstimatedStartDate())); + } + if (trip.getEstimatedEndDate() != null) { + card.setPlanFinishTime(formatLocalDateYmd(trip.getEstimatedEndDate())); + } + // 备注只有配载运次有运次级的值,未配载的保留运单卡片自己带来的备注 + if (Func.isNotEmpty(card.getLoadingNo())) { + card.setRemark(Func.toStr(trip.getTaskRemark(), "")); + } + card.setFromName(Func.toStrWithEmpty(trip.getDepartureName(), Func.toStr(trip.getDepartureAddress(), ""))); + card.setFromAddress(Func.toStrWithEmpty(trip.getDepartureAddress(), card.getFromName())); + card.setToName(Func.toStrWithEmpty(trip.getArrivalName(), Func.toStr(trip.getArrivalAddress(), ""))); + card.setToAddress(Func.toStrWithEmpty(trip.getArrivalAddress(), card.getToName())); + card.setViaName(Func.toStr(trip.getTransitName(), "")); + card.setStationCount(trip.getStationCount()); + card.setPickupAddress(card.getFromAddress()); + card.setUnloadAddress(card.getToAddress()); + // 货名取运次上算好的整趟车清单:它按子单录入的货物明细摊平去重,主表 cargo_name 只是首行。 + // 运次清单为空时按子单卡片自己的货名兜底,不让卡片上的货物行整个消失 + List cargoNames = trip.getCargoNames() == null ? List.of() : trip.getCargoNames(); + if (cargoNames.isEmpty()) { + cargoNames = children.stream() + .flatMap(child -> splitCargoNames(child.getCargoName()).stream()) + .distinct() + .collect(Collectors.toList()); + } + card.setCargoNames(cargoNames); + card.setCargoName(String.join("、", cargoNames)); + List cargoTypes = trip.getCargoTypes() == null ? List.of() : trip.getCargoTypes(); + if (cargoTypes.isEmpty()) { + cargoTypes = children.stream() + .map(TripVO.TripWaybillVO::getCargoType) + .filter(Func::isNotEmpty) + .distinct() + .collect(Collectors.toList()); + } + card.setCargoCategory(String.join("、", cargoTypes)); + card.setCargoSummary(Func.toStr(trip.getCargoSummary(), "")); + // 计重合计:同单位时就是该单位合计,单位不一致时只有吨项参与(见 TripQueryServiceImpl#fillWeighing); + // 都没有就留空,卡片上只剩货名,不拿汇总串冒充重量 + String weight = trip.getWeighingQuantity() == null + ? "" + : formatWeight(trip.getWeighingQuantity(), trip.getWeighingUnit()); + card.setWeight(weight); + card.setCargoQuantity(weight); + card.setTotalWeight(weight); + if (withDetail) { + card.setStations(trip.getStations()); + card.setRoutePoints(toTripRoutePoints(trip.getStations())); + card.setCargos(trip.getCargos()); + } + } + + /** + * 详情里的点位列:整趟车的全部站点,站序即途经顺序 + */ + private List toTripRoutePoints(List stations) { + if (Func.isEmpty(stations)) { + return List.of(); + } + return stations.stream().map(station -> { + DriverWaybillCardVO.DriverRoutePointVO point = new DriverWaybillCardVO.DriverRoutePointVO(); + point.setName(Func.toStr(station.getName(), "")); + point.setAddress(Func.toStrWithEmpty(station.getAddress(), point.getName())); + point.setStatus("pending"); + return point; + }).collect(Collectors.toList()); + } + + private Long parseId(String value) { + if (Func.isEmpty(value)) { + return null; + } + try { + return Long.valueOf(value.trim()); + } catch (NumberFormatException ignored) { + return null; + } + } + + private int normalizePageNo(Integer current) { + return current == null || current < 1 ? 1 : current; + } + + private int normalizePageSize(Integer size) { + if (size == null || size < 1) { + return DEFAULT_PAGE_SIZE; + } + return Math.min(size, MAX_PAGE_SIZE); + } + private String toTransportTypeLabel(String transportType) { if (Func.isBlank(transportType)) { return ""; @@ -798,55 +1237,16 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { } /** - * 优先用项目启用中的过程配置;若动态配置无打卡节点,回退运单快照 processJson, - * 避免项目配置改坏后司机端打卡页空白。 + * 优先用项目启用中的过程配置;若动态配置无打卡节点,回退运单快照, + * 避免项目配置改坏后司机端打卡页空白。取法与规则都在 {@link LiveProcessConfigSupport}。 */ private String resolveProcessJson(Waybill waybill) { if (waybill == null) { return null; } - String snapshot = waybill.getProcessJson(); - String live = loadLiveProcessConfigJson(waybill.getProjectId()); - if (Func.isNotEmpty(live)) { - if (!WaybillProcessSupport.listDriverPunchNodes(live).isEmpty()) { - return live; - } - // 动态配置存在但无可打卡节点:仍回退快照 - if (Func.isNotEmpty(snapshot) - && !WaybillProcessSupport.listDriverPunchNodes(snapshot).isEmpty()) { - return snapshot; - } - return live; - } - return snapshot; - } - - private String loadLiveProcessConfigJson(Long projectId) { - if (projectId == null) { - return null; - } - String projectIdStr = String.valueOf(projectId); - return processConfigService.list(Wrappers.lambdaQuery() - .eq(ProcessConfig::getStatus, 1) - .eq(ProcessConfig::getIsDeleted, 0) - .like(ProcessConfig::getProjectIds, projectIdStr) - .orderByDesc(ProcessConfig::getUpdateTime) - .orderByDesc(ProcessConfig::getCreateTime)) - .stream() - .filter(cfg -> containsProjectId(cfg.getProjectIds(), projectIdStr)) - .map(ProcessConfig::getNodeConfigJson) - .filter(Func::isNotEmpty) - .findFirst() - .orElse(null); - } - - private boolean containsProjectId(String projectIds, String projectId) { - if (Func.isEmpty(projectIds) || Func.isEmpty(projectId)) { - return false; - } - return Arrays.stream(projectIds.split(",")) - .map(String::trim) - .anyMatch(projectId::equals); + return LiveProcessConfigSupport.resolveForPunch( + LiveProcessConfigSupport.byProject(processConfigService, waybill.getProjectId()), + waybill.getProcessJson()); } /** @@ -880,9 +1280,9 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { @Override public void assertCurrentDriverOwnsWaybill(Long waybillId) { - // 归属口径与司机打卡、司机接单一致:都按司机档案自述绑定的车牌判定 - Driver driver = requireCurrentDriver(); - loadDriverWaybill(waybillId, currentBoundPlates(driver)); + // 归属口径与司机打卡、司机接单一致:按当前用户手机号匹配运单司机手机号 + requireCurrentDriver(); + loadDriverWaybill(waybillId); } /** @@ -946,6 +1346,32 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { .orElse(null); } + /** + * 断言运单未结束。 + *

+ * 运单已完成/已取消后不再接受司机打卡:在途与节点都拦,且先于其它门禁。 + * 结束不可逆,没有解除途径——处理完异常、等到第二天都还是打不了。 + */ + private void assertPunchNotEnded(String businessStatus) { + String reason = WaybillProcessSupport.punchEndedReason(businessStatus); + if (reason != null) { + throw new ServiceException(reason); + } + } + + /** + * 断言运单已开始运输。 + *

+ * 只有进行中的运单能打:草稿、待执行(待接单)、待调度、调度中都还没上路。 + * 与「运单已结束」分开判:结束没有解除途径,未开始会随接单/调度自行变为进行中。 + */ + private void assertPunchStarted(String businessStatus) { + String reason = WaybillProcessSupport.punchNotStartedReason(businessStatus); + if (reason != null) { + throw new ServiceException(reason); + } + } + /** * 断言指定节点未被异常门禁阻断。 *

@@ -978,7 +1404,8 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { Set unfinished = WaybillProcessSupport.unfinishedPrerequisiteKeys( processJson, punched, WaybillProcessSupport.nodeKey(nodeCfg)); if (!unfinished.isEmpty()) { - throw new ServiceException("请先完成「" + prerequisiteNames(processJson, unfinished) + "」打卡"); + throw new ServiceException("请先完成「" + + WaybillProcessSupport.firstUnfinishedPrerequisiteName(processJson, unfinished) + "」打卡"); } } @@ -1091,6 +1518,16 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { return countByType; } + /** + * 自动完成的前置:与司机端「完成运单」同一套——业务状态允许完成,且没有未了结异常。 + *

+ * 不满足返回 false,由调用方静默跳过,打卡不受影响。 + */ + private boolean canAutoCompleteWaybill(Waybill waybill) { + return COMPLETABLE_STATUSES.contains(waybill.getBusinessStatus()) + && unresolvedExceptionNodeCodeList(waybill.getId()).isEmpty(); + } + /** * 断言该运单没有未了结的异常。 *

@@ -1129,6 +1566,8 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { latestEnroute = enroutes.get(enroutes.size() - 1); } Map latestNodePunchByCode = latestNodePunchMap(waybill.getId()); + // 未开始运输的运单在途卡仍下发,但不可提交:司机要看得到这张卡,才知道为什么不能打 + String notStartedReason = WaybillProcessSupport.punchNotStartedReason(waybill.getBusinessStatus()); for (Map cfg : punchConfigs) { boolean isTransit = WaybillProcessSupport.isTransitNodePublic(cfg); @@ -1156,7 +1595,10 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { vo.setCargoTypes(Collections.emptyList()); vo.setVisible(true); vo.setDone(done); - vo.setActionable(!done); + vo.setActionable(!done && notStartedReason == null); + if (notStartedReason != null) { + vo.setLockReason(notStartedReason); + } if (done && latestEnroute != null) { vo.setCheckinTime(latestEnroute.getTime()); vo.setCheckinPlace(latestEnroute.getAddress()); @@ -1177,7 +1619,16 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { boolean done = punched != null; vo.setVisible(true); vo.setDone(done); - vo.setActionable(!done); + vo.setActionable(!done && notStartedReason == null); + if (notStartedReason != null && !done) { + vo.setLockReason(notStartedReason); + } + // 末位卡上打一下就完成这张运单:判据与下方 submitNode 的自动完成同一份 + // (末位可打卡节点 + 到货/卸货/签收 + 回单不要求业务员确认),客户端只按它把按钮文案改成「完成运单」。 + // 两处判据必须同源,否则会出现「按钮写着完成运单、打下去这张运单没完成」。 + vo.setFinishOnPunch(WaybillProcessSupport.isLastDriverPunchNode(processJson, vo.getKey()) + && WaybillProcessSupport.isDeliveryNode(vo.getKey(), vo.getName()) + && WaybillProcessSupport.returnNeedsNoConfirmation(processJson)); if (done) { if (punched.getPunchTime() != null) { LocalDateTime ldt = punched.getPunchTime().toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime(); @@ -1206,6 +1657,11 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { if (Boolean.TRUE.equals(vo.getDone()) || !blockedKeys.contains(vo.getKey())) { continue; } + // 未开始运输是更根本的拒绝:运单状态不变,处理完异常也打不了。 + // 这里不能再改写成异常文案,否则会暗示「处理完就能打」。 + if (notStartedReason != null) { + continue; + } vo.setBlocked(true); vo.setActionable(false); if (blocker != null) { @@ -1234,14 +1690,36 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { } vo.setBlockedByOrder(true); vo.setActionable(false); - vo.setOrderLockReason("请先完成「" + prerequisiteNames(processJson, unfinished) + "」打卡"); + vo.setOrderLockReason("请先完成「" + + WaybillProcessSupport.firstUnfinishedPrerequisiteName(processJson, unfinished) + "」打卡"); + } + + // 已结束的运单不再接受打卡:卡片留着给司机回看,未打卡的节点一律不可提交。 + // 文案压过异常与顺序门禁——结束没有解除途径,说「处理完就能打」是错的。 + // 已打卡的节点只置 ended:它们要继续可展开回看照片与时间,原因文案也不再适用。 + String endedReason = WaybillProcessSupport.punchEndedReason(waybill.getBusinessStatus()); + if (endedReason != null) { + for (DriverPunchNodeVO vo : nodes) { + vo.setEnded(true); + if (!Boolean.TRUE.equals(vo.getDone())) { + vo.setActionable(false); + vo.setLockReason(endedReason); + } + } } // 是否允许展开:顺序门禁不允许展开(还没轮到,展开也没用); // 异常门禁允许展开——那里出过事,司机需要看到回填内容与「异常详情」。 + // 已结束的运单:在途卡一律不可展开(结束之后没有在途打卡可做,展开也无内容); + // 未打卡的节点展开同样一无所有;已打卡的普通节点保留展开,供司机回看时间与照片。 for (DriverPunchNodeVO vo : nodes) { boolean blockedByOrder = Boolean.TRUE.equals(vo.getBlockedByOrder()); - vo.setExpandable(!blockedByOrder); + boolean ended = Boolean.TRUE.equals(vo.getEnded()); + boolean done = Boolean.TRUE.equals(vo.getDone()); + boolean transitCard = Boolean.TRUE.equals(vo.getTransit()); + // 未开始运输的未打卡卡片:打不了也展不开——展开是空表单,状态由锁文案说明 + boolean notStartedLocked = !done && notStartedReason != null; + vo.setExpandable(!blockedByOrder && !notStartedLocked && (!ended || (done && !transitCard))); } // 「异常」标签:只挂**存在未处理完成异常**的节点(与异常门禁同一口径,结案即消失)。 @@ -1274,6 +1752,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { for (DriverPunchNodeVO vo : nodes) { if (!Boolean.TRUE.equals(vo.getDone()) + && !Boolean.TRUE.equals(vo.getEnded()) && !Boolean.TRUE.equals(vo.getBlocked()) && !Boolean.TRUE.equals(vo.getBlockedByOrder())) { vo.setDefaultExpanded(true); @@ -1283,18 +1762,6 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { return nodes; } - /** 把前置节点 key 拼成给人看的名称列表,如「装货、发货」 */ - private String prerequisiteNames(String processJson, Set nodeKeys) { - List names = new ArrayList<>(); - for (Map node : WaybillProcessSupport.listDriverPunchNodes(processJson)) { - String key = WaybillProcessSupport.nodeKey(node); - if (nodeKeys.contains(key)) { - names.add(WaybillProcessSupport.nodeName(node)); - } - } - return names.isEmpty() ? "" : String.join("、", names); - } - private Map findPunchNodeConfig(String processJson, String nodeCode) { List> punchConfigs = WaybillProcessSupport.listDriverPunchNodes(processJson); for (Map cfg : punchConfigs) { @@ -1486,13 +1953,21 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { } private Date findLastPunchTime(Long waybillId) { + return findLastPunchTime(waybillId, false); + } + + /** + * @param forUpdate 仅 submitEnroute:与运单行锁配对。不加锁读走的是事务快照, + * 并发提交的第一笔已提交后,第二笔可能仍读到旧快照而看不到它。 + */ + private Date findLastPunchTime(Long waybillId, boolean forUpdate) { if (waybillId == null) { return null; } WaybillEnroutePunch latest = enroutePunchMapper.selectOne(Wrappers.lambdaQuery() .eq(WaybillEnroutePunch::getWaybillId, waybillId) .orderByDesc(WaybillEnroutePunch::getPunchTime) - .last("LIMIT 1")); + .last(forUpdate ? "LIMIT 1 FOR UPDATE" : "LIMIT 1")); return latest == null ? null : latest.getPunchTime(); } @@ -1535,16 +2010,26 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { }; } + /** + * 一格多名的历史写法(顿号、逗号分隔)摊平成货名清单,与货物明细同一个拆分口径 + */ private List splitCargoNames(String cargoName) { - if (Func.isEmpty(cargoName)) { - return Collections.emptyList(); + List names = WaybillCargoSupport.splitCargoNames(cargoName); + return names.isEmpty() ? List.of() : names; + } + + /** + * 货物明细行:一条货物一行,数量带单位 + */ + private List cargoLines(List cargos) { + List lines = new ArrayList<>(); + for (WaybillCargoSupport.Line line : WaybillCargoSupport.lines(cargos)) { + CargoLineVO vo = new CargoLineVO(); + vo.setCargoName(line.name()); + vo.setQuantity(line.quantity() == null ? "" : formatWeight(line.quantity(), line.unit())); + lines.add(vo); } - String normalized = cargoName.replace(",", ",").replace("、", ","); - List names = Func.toStrList(",", normalized).stream() - .map(String::trim) - .filter(Func::isNotEmpty) - .collect(Collectors.toList()); - return names.isEmpty() ? List.of(cargoName.trim()) : names; + return lines; } private String formatWeight(BigDecimal quantity, String unit) { diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java index d86e008c..575ce831 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java @@ -144,6 +144,17 @@ public class ExceptionDisposalServiceImpl // 整单异常同样受这条约束:没有节点不等于没有上报人。 driverWaybillService.assertCurrentDriverOwnsWaybill(disposal.getWaybillId()); + // 上报门禁之零:运单得先在路上,且不能已经结束。 + // 判据与司机打卡同一来源;归属校验会顺手把「无需接单」的待执行运单校正为进行中, + // 所以状态要在它之后重新读,不能用 fillFromWaybill 里校正前的那份对象。 + Waybill reportWaybill = waybillService.getById(disposal.getWaybillId()); + String blockedReason = reportWaybill == null + ? null + : WaybillProcessSupport.reportBlockedReason(reportWaybill.getBusinessStatus()); + if (blockedReason != null) { + throw new ServiceException(blockedReason); + } + // 节点可以为空:全局入口(首页快捷格、打卡页底部按钮)上报的异常不指定环节, // 它无从定位阻断区间,由门禁按「阻断该运单全部未打卡节点」处理。 // 节点非空时才要求它确属该运单;节点名取自过程配置而非请求体,避免落进一个该节点并不叫的名字。 diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/FormalSettlementServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/FormalSettlementServiceImpl.java index d3f7a922..6872c102 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/FormalSettlementServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/FormalSettlementServiceImpl.java @@ -33,12 +33,14 @@ import org.springblade.transport.mapper.InvoiceApplicationMapper; import org.springblade.transport.mapper.InvoiceApplicationSettlementMapper; import org.springblade.transport.mapper.PreSettlementDetailMapper; import org.springblade.transport.mapper.PreSettlementDetailFeeMapper; +import org.springblade.transport.mapper.PreSettlementAdvanceMapper; import org.springblade.transport.mapper.PreSettlementMapper; import org.springblade.transport.mapper.PreSettlementSummaryFeeMapper; import org.springblade.transport.mapper.PaymentApplicationMapper; import org.springblade.transport.mapper.PaymentApplicationInvoiceMapper; import org.springblade.transport.mapper.PaymentApplicationSettlementMapper; import org.springblade.transport.mapper.ReceiptClaimSettlementMapper; +import org.springblade.transport.mapper.SettlementProjectAdvanceWriteOffMapper; import org.springblade.transport.constant.KingdeeAttachmentConstant; import org.springblade.transport.config.BfiProperties; import org.springblade.transport.constant.KingdeeBfiConstant; @@ -69,6 +71,7 @@ import org.springblade.transport.pojo.entity.InvoiceApplicationInvoice; import org.springblade.transport.pojo.entity.InvoiceApplicationSettlement; import org.springblade.transport.pojo.entity.ContractManage; import org.springblade.transport.pojo.entity.PreSettlement; +import org.springblade.transport.pojo.entity.PreSettlementAdvance; import org.springblade.transport.pojo.entity.PreSettlementDetail; import org.springblade.transport.pojo.entity.PreSettlementDetailFee; import org.springblade.transport.pojo.entity.PreSettlementSummaryFee; @@ -79,15 +82,19 @@ import org.springblade.transport.pojo.entity.ReceiptClaimSettlement; import org.springblade.transport.pojo.entity.ReceivablePayableDetail; import org.springblade.transport.pojo.entity.ReceivablePayableCargoFee; import org.springblade.transport.pojo.entity.SettlementAdjustment; +import org.springblade.transport.pojo.entity.SettlementProjectAdvanceWriteOff; import org.springblade.transport.pojo.entity.Waybill; import org.springblade.transport.pojo.vo.FormalSettlementVO; +import org.springblade.transport.pojo.vo.PaymentApplicationVO; import org.springblade.transport.pojo.vo.PreSettlementVO; +import org.springblade.transport.pojo.vo.SettlementAdvancePaymentVO; import org.springblade.transport.pojo.vo.SettlementAdjustmentVO; import org.springblade.transport.service.IFormalSettlementService; import org.springblade.transport.service.IInvoiceReceiptService; import org.springblade.transport.service.IKingdeeBillLockService; import org.springblade.transport.service.IContractManageService; import org.springblade.transport.service.IPreSettlementService; +import org.springblade.transport.service.ISettlementProjectAdvanceWriteOffService; import org.springblade.transport.service.IWaybillService; import org.springblade.transport.service.KingdeeSyncContext; import org.springblade.transport.support.KingdeeApBillSyncSupport; @@ -112,6 +119,8 @@ import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Collection; +import java.util.Comparator; +import java.util.Date; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.LinkedHashSet; @@ -161,6 +170,8 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl result = page(page, wrapper.orderByDesc(FormalSettlement::getCreateTime)); - IPage voPage = result.convert(this::toVO); + // 本页应付结算单的项目预付核销合计一次查询,避免逐行 toVO 重算汇总时的 N+1 + Map writeOffAmounts = this.projectAdvanceWriteOffService.sumBySettlementIds( + TransportBusiConstant.SETTLEMENT_BILL_FORMAL, + result.getRecords().stream() + .filter(item -> "payable".equals(item.getSettlementType())) + .map(FormalSettlement::getId).toList()); + IPage voPage = result.convert(item -> this.toVO(item, writeOffAmounts)); fillAttachmentSyncStatus(voPage.getRecords()); fillKingdeeLockStatus(voPage.getRecords()); return voPage; @@ -233,6 +251,8 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl throw new ServiceException("结算类型不正确"); }; String prefix = code + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); - List numbers = list(Wrappers.lambdaQuery() - .select(FormalSettlement::getFormalSettlementNo) - .likeRight(FormalSettlement::getFormalSettlementNo, prefix)) - .stream().map(FormalSettlement::getFormalSettlementNo).toList(); - int sequence = 1; - if (!numbers.isEmpty()) { - String latest = numbers.stream().max(String::compareTo).orElse(prefix); - try { - sequence = Integer.parseInt(latest.substring(prefix.length())) + 1; - } catch (RuntimeException ignored) { - sequence = Math.toIntExact(count(Wrappers.lambdaQuery() - .likeRight(FormalSettlement::getFormalSettlementNo, prefix))) + 1; - } - } - return prefix + String.format("%05d", sequence); + Long maxSequence = baseMapper.selectMaxDailySequence(AuthUtil.getTenantId(), prefix); + return prefix + String.format("%05d", (maxSequence == null ? 0L : maxSequence) + 1); } @Override @@ -310,19 +317,25 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl details = detailMapper.selectList(Wrappers.lambdaQuery() .eq(FormalSettlementDetail::getFormalSettlementId, id).orderByAsc(FormalSettlementDetail::getLineNo)); fillDetailWaybillAddresses(details); + fillDetailCargoNames(details); + fillDetailBillingTypes(details); vo.setDetails(details); vo.setSummaryFees(listSummaryFees(id)); vo.setPayments(paymentMapper.selectList(Wrappers.lambdaQuery() .eq(FormalSettlementPayment::getFormalSettlementId, id).orderByDesc(FormalSettlementPayment::getCreateTime))); vo.setInvoices(listInvoices(settlement)); vo.setIssuedInvoices(listIssuedInvoices(id)); - Set relationApplicationIds = paymentApplicationSettlementMapper.selectList( + vo.setProjectAdvanceWriteOffs(projectAdvanceWriteOffService.listBySettlement( + TransportBusiConstant.SETTLEMENT_BILL_FORMAL, id)); + vo.setAdvancePayments(this.listAdvancePayments(sources, vo.getProjectAdvanceWriteOffs())); + Map relationByApplicationId = paymentApplicationSettlementMapper.selectList( Wrappers.lambdaQuery() - .select(PaymentApplicationSettlement::getPaymentApplicationId) .eq(PaymentApplicationSettlement::getFormalSettlementId, id) .eq(PaymentApplicationSettlement::getIsDeleted, 0)).stream() - .map(PaymentApplicationSettlement::getPaymentApplicationId).filter(Objects::nonNull) - .collect(Collectors.toSet()); + .filter(relation -> relation.getPaymentApplicationId() != null) + .collect(Collectors.toMap(PaymentApplicationSettlement::getPaymentApplicationId, relation -> relation, + (first, second) -> first)); + Set relationApplicationIds = relationByApplicationId.keySet(); LambdaQueryWrapper paymentApplicationQuery = Wrappers.lambdaQuery(); paymentApplicationQuery.and(query -> { query.eq(PaymentApplication::getSettlementId, id); @@ -334,17 +347,56 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl PaymentApplicationWrapper.build().entityVO(item)).toList()); + .map(item -> { + PaymentApplicationVO application = PaymentApplicationWrapper.build().entityVO(item); + // 多结算单合并付款时,付款申请金额为整单合计;详情按当前结算单关联行展示分摊金额 + PaymentApplicationSettlement relation = relationByApplicationId.get(item.getId()); + if (relation != null) { + application.setAppliedAmount(money(relation.getAppliedAmount())); + application.setPaidAmount(money(relation.getPaidAmount())); + } + return application; + }).toList()); vo.setAdjustments(settlementAdjustmentMapper.selectList(Wrappers.lambdaQuery() .eq(SettlementAdjustment::getFormalSettlementId, id) .orderByDesc(SettlementAdjustment::getCreateTime)).stream() - .map(item -> toAdjustmentVO(item, settlement.getKingdeeBillNo())).toList()); + .map(this::toAdjustmentVO).toList()); vo.setChangeRecords(changeRecordMapper.selectList(Wrappers.lambdaQuery() .eq(FormalSettlementChangeRecord::getFormalSettlementId, id) .orderByDesc(FormalSettlementChangeRecord::getChangeTime))); return vo; } + /** + * 明细展示计费方式:按来源应收应付货物费用的计费类型汇总(去重后顿号拼接)。 + */ + private void fillDetailBillingTypes(List details) { + if (details == null || details.isEmpty()) { + return; + } + List sourceDetailIds = details.stream() + .map(FormalSettlementDetail::getSourceDetailId) + .filter(Objects::nonNull) + .distinct() + .toList(); + if (sourceDetailIds.isEmpty()) { + return; + } + Map billingTypeMap = receivablePayableCargoFeeMapper.selectList( + Wrappers.lambdaQuery() + .in(ReceivablePayableCargoFee::getDetailId, sourceDetailIds) + .orderByAsc(ReceivablePayableCargoFee::getLineNo)) + .stream() + .filter(fee -> Func.isNotEmpty(fee.getBillingType()) && !"-".equals(fee.getBillingType())) + .collect(Collectors.groupingBy(ReceivablePayableCargoFee::getDetailId, + Collectors.mapping(ReceivablePayableCargoFee::getBillingType, + Collectors.collectingAndThen(Collectors.toCollection(LinkedHashSet::new), + values -> String.join("、", values))))); + for (FormalSettlementDetail detail : details) { + detail.setBillingType(billingTypeMap.getOrDefault(detail.getSourceDetailId(), "")); + } + } + /** * 为历史正式结算明细补充运单收发货信息,保留明细中已有的快照值。 */ @@ -539,6 +591,17 @@ public class FormalSettlementServiceImpl extends BaseServiceImpllambdaQuery() .eq(FormalSettlementInvoice::getFormalSettlementId, id)); + projectAdvanceWriteOffService.deleteBySettlement(TransportBusiConstant.SETTLEMENT_BILL_FORMAL, id); changeRecordMapper.delete(Wrappers.lambdaQuery() .eq(FormalSettlementChangeRecord::getFormalSettlementId, id)); removeById(id); @@ -592,6 +656,9 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl新id 映射(调整明细应用到新单时按映射定位) + Map detailIdMap = new HashMap<>(); + Map feeIdMap = new HashMap<>(); + List details = detailMapper.selectList(Wrappers.lambdaQuery() + .eq(FormalSettlementDetail::getFormalSettlementId, sourceSettlementId) + .eq(FormalSettlementDetail::getIsDeleted, 0) + .orderByAsc(FormalSettlementDetail::getLineNo)); + for (FormalSettlementDetail detail : details) { + FormalSettlementDetail cloneDetail = Objects.requireNonNull(BeanUtil.copyProperties(detail, FormalSettlementDetail.class)); + resetAuditFields(cloneDetail); + cloneDetail.setFormalSettlementId(clone.getId()); + detailMapper.insert(cloneDetail); + detailIdMap.put(detail.getId(), cloneDetail.getId()); + for (FormalSettlementDetailFee fee : detailFeeMapper.selectList(Wrappers.lambdaQuery() + .eq(FormalSettlementDetailFee::getFormalSettlementDetailId, detail.getId()) + .orderByAsc(FormalSettlementDetailFee::getLineNo))) { + FormalSettlementDetailFee cloneFee = Objects.requireNonNull(BeanUtil.copyProperties(fee, FormalSettlementDetailFee.class)); + resetAuditFields(cloneFee); + cloneFee.setFormalSettlementDetailId(cloneDetail.getId()); + detailFeeMapper.insert(cloneFee); + feeIdMap.put(fee.getId(), cloneFee.getId()); + } + } + // 汇总费用项克隆 + for (FormalSettlementSummaryFee summaryFee : listSummaryFees(sourceSettlementId)) { + FormalSettlementSummaryFee cloneFee = Objects.requireNonNull(BeanUtil.copyProperties(summaryFee, FormalSettlementSummaryFee.class)); + resetAuditFields(cloneFee); + cloneFee.setFormalSettlementId(clone.getId()); + summaryFeeMapper.insert(cloneFee); + } + // 来源预结算重建(接续原单来源,不重复锁定 PreSettlement.formalSettlementNo) + for (FormalSettlementSource relation : sourceMapper.selectList(Wrappers.lambdaQuery() + .eq(FormalSettlementSource::getFormalSettlementId, sourceSettlementId))) { + FormalSettlementSource cloneRelation = Objects.requireNonNull(BeanUtil.copyProperties(relation, FormalSettlementSource.class)); + resetAuditFields(cloneRelation); + cloneRelation.setFormalSettlementId(clone.getId()); + sourceMapper.insert(cloneRelation); + } + // 核销行迁移:克隆到新单并删除原单行,避免预付可核销额度双占(原单为调整存档,收付口径以新单为准) + List writeOffRows = this.projectAdvanceWriteOffMapper.selectList( + Wrappers.lambdaQuery() + .eq(SettlementProjectAdvanceWriteOff::getSettlementType, TransportBusiConstant.SETTLEMENT_BILL_FORMAL) + .eq(SettlementProjectAdvanceWriteOff::getSettlementId, sourceSettlementId) + .eq(SettlementProjectAdvanceWriteOff::getIsDeleted, 0)); + for (SettlementProjectAdvanceWriteOff writeOffRow : writeOffRows) { + SettlementProjectAdvanceWriteOff cloneWriteOff = Objects.requireNonNull( + BeanUtil.copyProperties(writeOffRow, SettlementProjectAdvanceWriteOff.class)); + resetAuditFields(cloneWriteOff); + cloneWriteOff.setSettlementId(clone.getId()); + this.projectAdvanceWriteOffMapper.insert(cloneWriteOff); + } + this.projectAdvanceWriteOffService.deleteBySettlementIds( + TransportBusiConstant.SETTLEMENT_BILL_FORMAL, List.of(sourceSettlementId)); + this.refreshPaymentSummaries(List.of(sourceSettlementId, clone.getId())); + log.info("结算调整重推克隆正式结算单:原单[{}] -> 新单[{}](明细{}条,核销迁移{}条)", + source.getFormalSettlementNo(), clone.getFormalSettlementNo(), details.size(), writeOffRows.size()); + return new CloneResult(clone.getId(), detailIdMap, feeIdMap); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void markSettlementAdjusted(Long settlementId, String adjustmentNo, String newSettlementNo) { + existing(settlementId); + // 原单只加标记,业务字段不动 + update(Wrappers.lambdaUpdate() + .eq(FormalSettlement::getId, settlementId) + .set(FormalSettlement::getAdjustStatus, "adjusted")); + kingdeeBillLockService.unlockByAdjustRepush(KingdeeLockConstant.SOURCE_SETTLEMENT, settlementId, AuthUtil.getUserName()); + saveChange(settlementId, "结算单基本信息", null, "调整", + "结算调整单" + Func.toStr(adjustmentNo) + "重推生成新单" + Func.toStr(newSettlementNo) + ",原单标记已调整", null); + } + + /** 重推/克隆复用:清空审计与主键字段,交由 MP 自动填充 */ + private void resetAuditFields(org.springblade.core.mp.base.BaseEntity entity) { + entity.setId(null); + entity.setCreateUser(null); + entity.setCreateDept(null); + entity.setCreateTime(null); + entity.setUpdateUser(null); + entity.setUpdateTime(null); + entity.setStatus(null); + entity.setIsDeleted(null); + } + @Override public String syncKingdee(Long id) { String billNo; @@ -687,6 +869,11 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl 0) { + // 结算调整单回填:未推送场景审批通过后自动推送失败,调整单置 failed 供「推送金蝶」重试 + settlementAdjustmentMapper.update(null, Wrappers.lambdaUpdate() + .eq(SettlementAdjustment::getFormalSettlementId, id) + .eq(SettlementAdjustment::getApprovalStatus, TransportBusiConstant.APPROVED) + .set(SettlementAdjustment::getKingdeeSyncStatus, KingdeeBfiConstant.KINGDEE_STATUS_FAILED)); // 失败原因写变更记录留档(推送事务已回滚,此处独立事务);原因过长截断留档,不抛错 String failReason = Func.toStrWithEmpty(reason, "未知异常"); saveChange(id, "结算单基本信息", null, "调整", @@ -792,7 +979,7 @@ public class FormalSettlementServiceImpl extends BaseServiceImpllambdaUpdate() + .eq(SettlementAdjustment::getFormalSettlementId, settlement.getId()) + .eq(SettlementAdjustment::getApprovalStatus, TransportBusiConstant.APPROVED) + .set(SettlementAdjustment::getKingdeeSyncStatus, KingdeeBfiConstant.KINGDEE_STATUS_SYNCED) + .set(SettlementAdjustment::getKingdeeBillNo, billNo)); log.info("正式结算单[{}]同步金蝶完成:settlementType={},billNo={},billPkId={}", settlement.getFormalSettlementNo(), settlement.getSettlementType(), billNo, billPkId); return billNo; @@ -1076,9 +1269,23 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl detailFees(Long detailId) { FormalSettlementDetail detail = detailMapper.selectById(detailId); if (detail == null || Objects.equals(detail.getIsDeleted(), 1)) throw new ServiceException("正式结算明细不存在"); - return detailFeeMapper.selectList(Wrappers.lambdaQuery() + List fees = detailFeeMapper.selectList(Wrappers.lambdaQuery() .eq(FormalSettlementDetailFee::getFormalSettlementDetailId, detailId) .orderByAsc(FormalSettlementDetailFee::getLineNo)); + List sourceFeeIds = fees.stream().map(FormalSettlementDetailFee::getSourceFeeId) + .filter(Objects::nonNull).toList(); + if (sourceFeeIds.isEmpty()) { + return fees; + } + Map sourceFeeMap = receivablePayableCargoFeeMapper.selectBatchIds(sourceFeeIds).stream() + .collect(Collectors.toMap(ReceivablePayableCargoFee::getId, Function.identity(), (left, right) -> left)); + fees.forEach(fee -> { + ReceivablePayableCargoFee sourceFee = sourceFeeMap.get(fee.getSourceFeeId()); + if (sourceFee != null) { + fee.setBillingRulesJson(sourceFee.getBillingRulesJson()); + } + }); + return fees; } @Override @@ -1088,11 +1295,15 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl existing = detailFees(detail.getId()).stream() .collect(Collectors.toMap(FormalSettlementDetailFee::getId, item -> item)); if (existing.size() != request.getRows().size()) throw new ServiceException("费用调整行数据不完整"); List changes = new java.util.ArrayList<>(); + Map beforeData = new LinkedHashMap<>(); + Map afterData = new LinkedHashMap<>(); for (PreSettlementDetailAdjustRequest.FeeRow row : request.getRows()) { FormalSettlementDetailFee fee = existing.get(row.getId()); if (fee == null) throw new ServiceException("存在无效的货物费用行"); @@ -1105,7 +1316,6 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl rows = detailFees(detail.getId()); detail.setTransportQuantity(rows.stream().map(FormalSettlementDetailFee::getTransportQuantity).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add)); + detail.setMileage(rows.stream().map(FormalSettlementDetailFee::getMileage).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add)); + // 运输单价无法按行简单求和,与前端暂存逻辑一致:取首行费用单价。 + detail.setUnitPrice(rows.stream().map(FormalSettlementDetailFee::getUnitPrice).filter(Objects::nonNull).findFirst().orElse(null)); detail.setFreightAmount(rows.stream().map(FormalSettlementDetailFee::getFreightAmount).map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add)); detail.setOriginalAmount(rows.stream().map(FormalSettlementDetailFee::getOriginalAmount).map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add)); detail.setSettlementAmountTax(rows.stream().map(FormalSettlementDetailFee::getSettlementAmountTax).map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add)); - detail.setSettlementAmountNoTax(rows.stream().map(FormalSettlementDetailFee::getSettlementAmountNoTax).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add)); + boolean hasNoTax = rows.stream().anyMatch(fee -> fee.getSettlementAmountNoTax() != null); + detail.setSettlementAmountNoTax(hasNoTax + ? rows.stream().map(FormalSettlementDetailFee::getSettlementAmountNoTax).filter(Objects::nonNull).map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add) + : null); detail.setAdjustAmount(detail.getSettlementAmountTax().subtract(detail.getOriginalAmount())); - detail.setRemark(reason); + Map feeItems = new LinkedHashMap<>(); + rows.forEach(fee -> parseFeeItems(fee.getFeeItemsJson()).forEach((name, amount) -> + feeItems.merge(name, money(amount), BigDecimal::add))); + detail.setFeeItemsJson(JsonUtil.toJson(feeItems)); + detail.setRemark(detailRemark); detailMapper.updateById(detail); + appendTextChange(changes, beforeData, afterData, "备注", beforeDetailRemark, detailRemark); + if (changes.isEmpty()) { + throw new ServiceException("未修改任何结算明细费用"); + } rebuildSummaryFees(settlement.getId()); refreshSettlementAmount(settlement); saveChange(settlement.getId(), "结算明细项", detail.getLineNo(), "调整", - changes.isEmpty() ? "调整结算明细费用" : String.join(";", changes), reason); + String.join(";", changes), detailRemark, beforeData, afterData); } private void rebuildSummaryFees(Long settlementId) { @@ -1136,6 +1360,9 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl existingManualRows = existingRows.stream() .filter(row -> Integer.valueOf(1).equals(row.getManualFlag())).toList(); Map aggregates = new LinkedHashMap<>(); + // 预结算手工费用行不并入自动汇总:金蝶分录只送手工行(manualFlag=1),并入自动汇总 + // 会被当作明细再汇总跳过,推送金蝶时金额缺失(如预付单手工水费转正式后丢失) + Map sourceManualAmounts = new LinkedHashMap<>(); for (FormalSettlementSource source : sourceMapper.selectList(Wrappers.lambdaQuery() .eq(FormalSettlementSource::getFormalSettlementId, settlementId))) { List sourceSummary = preSummaryFeeMapper.selectList( @@ -1143,9 +1370,12 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl { - BigDecimal additionalAmount = Integer.valueOf(1).equals(row.getManualFlag()) - ? money(row.getSettlementAmount()) : money(row.getAdjustAmount()); - aggregates.merge(summaryKey(row.getFeeType(), row.getFeeItem()), additionalAmount, BigDecimal::add); + if (Integer.valueOf(1).equals(row.getManualFlag())) { + sourceManualAmounts.merge(summaryKey(row.getFeeType(), row.getFeeItem()), + money(row.getSettlementAmount()), BigDecimal::add); + } else { + aggregates.merge(summaryKey(row.getFeeType(), row.getFeeItem()), money(row.getAdjustAmount()), BigDecimal::add); + } }); } Map feeTypeMap = feeTypeMap(); @@ -1180,6 +1410,25 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl entry : sourceManualAmounts.entrySet()) { + boolean existed = existingManualRows.stream().anyMatch(row -> summaryKey(row).equals(entry.getKey())); + if (existed) { + continue; + } + String[] keyParts = entry.getKey().split("@@@", 2); + FormalSettlementSummaryFee manualRow = new FormalSettlementSummaryFee(); + manualRow.setFormalSettlementId(settlementId); + manualRow.setFeeType(keyParts[0]); + manualRow.setFeeItem(keyParts.length > 1 ? keyParts[1] : ""); + manualRow.setOriginalAmount(BigDecimal.ZERO.setScale(2)); + manualRow.setAdjustAmount(money(entry.getValue())); + manualRow.setSettlementAmount(money(entry.getValue())); + manualRow.setRemark(""); + manualRow.setManualFlag(1); + manualRow.setLineNo(lineNo++); + summaryFeeMapper.insert(manualRow); + } renumberSummaryFees(settlementId); } @@ -1276,7 +1525,14 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl requestRows) { - if (requestRows == null) return; + if (requestRows == null) { + return; + } + // 空列表表示沿用 rebuildSummaryFees 结果(如预结算转正式结转的手工合计行),不得按空请求删光手工行 + if (requestRows.isEmpty()) { + renumberSummaryFees(settlementId); + return; + } Map> allowedManualFees = new LinkedHashMap<>(); for (Map option : preSettlementService.feeOptions()) { Set feeItems = new LinkedHashSet<>(); @@ -1300,10 +1556,18 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl changes = new java.util.ArrayList<>(); + Map beforeData = new LinkedHashMap<>(); + Map afterData = new LinkedHashMap<>(); + appendChange(changes, beforeData, afterData, "调整金额", beforeAdjustAmount, row.getAdjustAmount()); + appendChange(changes, beforeData, afterData, "结算金额", beforeSettlementAmount, row.getSettlementAmount()); + appendTextChange(changes, beforeData, afterData, "备注", beforeRemark, row.getRemark()); + if (!changes.isEmpty()) { + saveChange(settlementId, "合计费用项", row.getLineNo(), "调整", String.join(";", changes), "", + beforeData, afterData); + } } for (FormalSettlementSummaryFee manualRow : existingManualRows) { - if (!retainedManualIds.contains(manualRow.getId())) summaryFeeMapper.deleteById(manualRow.getId()); + if (!retainedManualIds.contains(manualRow.getId())) { + summaryFeeMapper.deleteById(manualRow.getId()); + Map beforeData = new LinkedHashMap<>(); + Map afterData = new LinkedHashMap<>(); + beforeData.put("费用类型", manualRow.getFeeType()); + beforeData.put("费用项", manualRow.getFeeItem()); + beforeData.put("调整金额", money(manualRow.getAdjustAmount())); + beforeData.put("结算金额", money(manualRow.getSettlementAmount())); + beforeData.put("备注", manualRow.getRemark()); + afterData.put("费用类型", ""); + afterData.put("费用项", ""); + afterData.put("调整金额", BigDecimal.ZERO.setScale(2)); + afterData.put("结算金额", BigDecimal.ZERO.setScale(2)); + afterData.put("备注", ""); + saveChange(settlementId, "合计费用项", manualRow.getLineNo(), "删除", + "删除合计费用项【" + manualRow.getFeeItem() + "】金额【" + money(manualRow.getSettlementAmount()) + "】", + "", beforeData, afterData); + } } renumberSummaryFees(settlementId); } @@ -1476,11 +1772,17 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl receiptClaimPaidBySettlement = hasReceivable ? receiptClaimSettlementMapper.selectList(Wrappers.lambdaQuery() .in(ReceiptClaimSettlement::getFormalSettlementId, settledIds) - .eq(ReceiptClaimSettlement::getStatus, 1)).stream() + .eq(ReceiptClaimSettlement::getStatus, 1) + .eq(ReceiptClaimSettlement::getIsDeleted, 0)).stream() .collect(Collectors.groupingBy(ReceiptClaimSettlement::getFormalSettlementId, Collectors.reducing(BigDecimal.ZERO, item -> money(item.getAllocatedReceiptAmount()), BigDecimal::add))) : Map.of(); + // 项目预付核销合计(仅应付结算单需要,一次 group-by 避免逐单查询) + Map writeOffAmountsBySettlement = hasPayable + ? this.projectAdvanceWriteOffService.sumBySettlementIds( + TransportBusiConstant.SETTLEMENT_BILL_FORMAL, settledIds) + : Map.of(); for (FormalSettlement settlement : settlements) { List relations = relationsBySettlement @@ -1496,7 +1798,8 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl details) { + if (details == null || details.isEmpty()) { + return; + } + List waybillIds = details.stream().map(FormalSettlementDetail::getWaybillId) + .filter(Objects::nonNull).distinct().toList(); + Map waybillMap = waybillIds.isEmpty() ? Map.of() : waybillService.listByIds(waybillIds).stream() + .collect(Collectors.toMap(Waybill::getId, Function.identity(), (left, right) -> left)); + List detailIds = details.stream().map(FormalSettlementDetail::getId).filter(Objects::nonNull).toList(); + Map> feesByDetail = detailIds.isEmpty() ? Map.of() + : detailFeeMapper.selectList(Wrappers.lambdaQuery() + .in(FormalSettlementDetailFee::getFormalSettlementDetailId, detailIds) + .orderByAsc(FormalSettlementDetailFee::getLineNo)).stream() + .collect(Collectors.groupingBy(FormalSettlementDetailFee::getFormalSettlementDetailId, LinkedHashMap::new, Collectors.toList())); + for (FormalSettlementDetail detail : details) { + Waybill waybill = waybillMap.get(detail.getWaybillId()); + LinkedHashSet goodsNames = new LinkedHashSet<>(); + LinkedHashSet goodsTypes = new LinkedHashSet<>(); + if (waybill != null) { + collectGoodsCargo(waybill.getGoodsJson(), goodsNames, goodsTypes); + } + LinkedHashSet feeNames = new LinkedHashSet<>(); + LinkedHashSet feeTypes = new LinkedHashSet<>(); + for (FormalSettlementDetailFee fee : feesByDetail.getOrDefault(detail.getId(), List.of())) { + addCargoToken(feeNames, fee.getCargoName(), true); + addCargoToken(feeTypes, fee.getCargoType(), true); + } + LinkedHashSet names = goodsNames.size() > 1 ? goodsNames : feeNames.size() > 1 ? feeNames : goodsNames; + LinkedHashSet types = goodsTypes.size() > 1 ? goodsTypes : feeTypes.size() > 1 ? feeTypes : goodsTypes; + if (names.isEmpty()) { + addCargoToken(names, detail.getCargoName(), true); + } + if (types.isEmpty()) { + addCargoToken(types, detail.getCargoType(), true); + } + if (names.size() > 1) { + detail.setCargoName(joinCargo(names)); + } + if (types.size() > 1) { + detail.setCargoType(joinCargo(types)); + } + } + } + + private void persistDetailCargoNames(Long settlementId) { + List details = detailMapper.selectList(Wrappers.lambdaQuery() + .eq(FormalSettlementDetail::getFormalSettlementId, settlementId) + .orderByAsc(FormalSettlementDetail::getLineNo)); + Map cargoNames = new LinkedHashMap<>(); + Map cargoTypes = new LinkedHashMap<>(); + for (FormalSettlementDetail detail : details) { + cargoNames.put(detail.getId(), detail.getCargoName()); + cargoTypes.put(detail.getId(), detail.getCargoType()); + } + fillDetailCargoNames(details); + for (FormalSettlementDetail detail : details) { + if (Objects.equals(cargoNames.get(detail.getId()), detail.getCargoName()) + && Objects.equals(cargoTypes.get(detail.getId()), detail.getCargoType())) { + continue; + } + detailMapper.update(null, Wrappers.lambdaUpdate() + .eq(FormalSettlementDetail::getId, detail.getId()) + .set(FormalSettlementDetail::getCargoName, detail.getCargoName()) + .set(FormalSettlementDetail::getCargoType, detail.getCargoType())); + } + } + + private void collectGoodsCargo(String goodsJson, Set names, Set types) { + if (Func.isEmpty(goodsJson)) { + return; + } + try { + Object parsed = JsonUtil.parse(goodsJson, List.class); + if (!(parsed instanceof List rows)) { + return; + } + for (Object row : rows) { + if (!(row instanceof Map goods)) { + continue; + } + addCargoToken(names, goods.get("cargoName") == null ? null : String.valueOf(goods.get("cargoName")), false); + addCargoToken(types, goods.get("cargoType") == null ? null : String.valueOf(goods.get("cargoType")), false); + } + } catch (RuntimeException ignored) { + // 货物清单异常时保留明细上已有的货物名称。 + } + } + + private void addCargoToken(Set target, String raw, boolean splitJoined) { + if (Func.isEmpty(raw)) { + return; + } + String[] parts = splitJoined ? raw.split("[,,]") : new String[]{raw}; + for (String part : parts) { + String token = part.trim(); + if (token.isEmpty() || "手工调差".equals(token)) { + continue; + } + target.add(token); + } + } + + private String joinCargo(Set values) { + String joined = String.join(",", values); + return joined.length() > 500 ? joined.substring(0, 500) : joined; } private void copyPreDetailFees(PreSettlementDetail source, FormalSettlementDetail target) { @@ -1705,9 +2119,16 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl writeOffAmounts) { FormalSettlementVO vo = FormalSettlementWrapper.build().entityVO(entity); vo.setInvoiceStatus(invoiceStatus(entity.getInvoiceAmount(), entity.getSettlementAmount())); - PaymentSummary summary = calculatePaymentSummary(entity); + PaymentSummary summary = this.calculatePaymentSummary(entity, writeOffAmounts); vo.setAppliedPaymentAmount(summary.appliedAmount()); vo.setPaidAmount(summary.paidAmount()); vo.setRemainingPayableAmount(money(entity.getSettlementAmount()).subtract(summary.paidAmount()) @@ -1717,6 +2138,10 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl) null); + } + + private PaymentSummary calculatePaymentSummary(FormalSettlement settlement, Map writeOffAmounts) { List relations = paymentApplicationSettlementMapper.selectList( Wrappers.lambdaQuery() .eq(PaymentApplicationSettlement::getFormalSettlementId, settlement.getId()) @@ -1734,7 +2159,7 @@ public class FormalSettlementServiceImpl extends BaseServiceImpllambdaQuery() .eq(ReceiptClaimSettlement::getFormalSettlementId, settlement.getId()) - .eq(ReceiptClaimSettlement::getStatus, 1)) + .eq(ReceiptClaimSettlement::getStatus, 1) + .eq(ReceiptClaimSettlement::getIsDeleted, 0)) .stream().map(ReceiptClaimSettlement::getAllocatedReceiptAmount).map(this::money) .reduce(BigDecimal.ZERO, BigDecimal::add); } @@ -1802,22 +2235,91 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl relations, List directApplications, Map relationStatuses, - List sources, BigDecimal receiptClaimPaid) { + List sources, BigDecimal receiptClaimPaid, + Map writeOffAmounts) { } - private SettlementAdjustmentVO toAdjustmentVO(SettlementAdjustment entity, String kingdeeBillNo) { + private SettlementAdjustmentVO toAdjustmentVO(SettlementAdjustment entity) { SettlementAdjustmentVO vo = Objects.requireNonNull( BeanUtil.copyProperties(entity, SettlementAdjustmentVO.class)); vo.setCreateUserName(UserCache.getUserRealName(entity.getCreateUser())); vo.setApprovalStatusName(approvalStatusName(entity.getApprovalStatus())); vo.setSettlementTypeName("receivable".equals(entity.getSettlementType()) ? "应收" : "应付"); - vo.setKingdeeBillNo(kingdeeBillNo); return vo; } + /** + * 统一预付付款记录:进度预付(来源预结算台账)+ 项目预付核销(回填底层付款申请实付)。 + * createTime 统一取底层付款申请创建时间(项目预付核销行已回填 paymentCreateTime,进度预付按单号反查)。 + */ + private List listAdvancePayments(List sources, + List writeOffs) { + List records = new ArrayList<>(); + List preSettlementIds = sources.stream().map(FormalSettlementSource::getPreSettlementId) + .filter(Objects::nonNull).distinct().toList(); + if (!preSettlementIds.isEmpty()) { + List advances = this.preSettlementAdvanceMapper.selectList( + Wrappers.lambdaQuery() + .eq(PreSettlementAdvance::getIsDeleted, 0) + .ne(PreSettlementAdvance::getBillStatus, TransportBusiConstant.VOIDED) + .in(PreSettlementAdvance::getPreSettlementId, preSettlementIds)); + // 进度预付行的展示时间取底层付款申请创建时间(advanceNo 即付款申请单号) + List advanceNos = advances.stream().map(PreSettlementAdvance::getAdvanceNo) + .filter(Func::isNotEmpty).distinct().toList(); + Map applicationTimes = advanceNos.isEmpty() ? Map.of() + : this.paymentApplicationMapper.selectList(Wrappers.lambdaQuery() + .select(PaymentApplication::getPaymentNo, PaymentApplication::getCreateTime) + .eq(PaymentApplication::getIsDeleted, 0) + .in(PaymentApplication::getPaymentNo, advanceNos)) + .stream().filter(item -> item.getCreateTime() != null) + .collect(Collectors.toMap(PaymentApplication::getPaymentNo, + PaymentApplication::getCreateTime, (first, second) -> first)); + for (PreSettlementAdvance advance : advances) { + SettlementAdvancePaymentVO record = new SettlementAdvancePaymentVO(); + record.setRecordType(TransportBusiConstant.PAYMENT_TYPE_PROGRESS_ADVANCE); + record.setPaymentNo(advance.getAdvanceNo()); + record.setAppliedAmount(advance.getAppliedAmount()); + record.setPaidAmount(advance.getPaidAmount()); + record.setBillStatus(advance.getBillStatus()); + record.setKingdeeBillNo(advance.getKingdeeAdvanceNo()); + record.setCreateUserName(UserCache.getUserRealName(advance.getCreateUser())); + record.setCreateTime(applicationTimes.getOrDefault(advance.getAdvanceNo(), advance.getCreateTime())); + records.add(record); + } + } + if (Func.isNotEmpty(writeOffs)) { + List applicationIds = writeOffs.stream() + .map(SettlementProjectAdvanceWriteOff::getPaymentApplicationId) + .filter(Objects::nonNull).distinct().toList(); + Map applications = applicationIds.isEmpty() ? Map.of() + : this.paymentApplicationMapper.selectBatchIds(applicationIds).stream() + .collect(Collectors.toMap(PaymentApplication::getId, Function.identity(), + (first, second) -> first)); + for (SettlementProjectAdvanceWriteOff writeOff : writeOffs) { + PaymentApplication application = applications.get(writeOff.getPaymentApplicationId()); + SettlementAdvancePaymentVO record = new SettlementAdvancePaymentVO(); + record.setRecordType(TransportBusiConstant.PAYMENT_TYPE_PROJECT_ADVANCE); + record.setPaymentNo(writeOff.getPaymentNo()); + record.setAppliedAmount(writeOff.getWriteOffAmount()); + record.setPaidAmount(application == null ? null : application.getPaidAmount()); + record.setBillStatus(application == null ? null : application.getApprovalStatus()); + record.setKingdeeBillNo(writeOff.getKingdeeBillNo()); + record.setCreateUserName(UserCache.getUserRealName(application == null + ? writeOff.getCreateUser() : application.getCreateUser())); + record.setCreateTime(writeOff.getPaymentCreateTime() != null + ? writeOff.getPaymentCreateTime() : writeOff.getCreateTime()); + records.add(record); + } + } + records.sort(Comparator.comparing(SettlementAdvancePaymentVO::getCreateTime, + Comparator.nullsLast(Comparator.reverseOrder()))); + return records; + } + private String approvalStatusName(String value) { if (value == null) { return ""; @@ -1855,6 +2357,14 @@ public class FormalSettlementServiceImpl extends BaseServiceImpllambdaQuery() + .eq(FormalSettlement::getId, id).last("FOR UPDATE")); + if (entity == null || Objects.equals(entity.getIsDeleted(), 1)) throw new ServiceException("正式结算单不存在"); + return entity; + } + private FormalSettlement editable(Long id) { FormalSettlement entity = existing(id); if (!TransportBusiConstant.DRAFT.equals(entity.getApprovalStatus()) && !TransportBusiConstant.RETURNED.equals(entity.getApprovalStatus())) throw new ServiceException("仅草稿或已驳回的正式结算单允许编辑"); @@ -1870,14 +2380,77 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl changes = new java.util.ArrayList<>(); + Map beforeData = new LinkedHashMap<>(); + Map afterData = new LinkedHashMap<>(); + if (creating) { + changes.add("新增合计费用项【" + row.getFeeItem() + "】金额【" + money(row.getSettlementAmount()) + "】"); + beforeData.put("费用类型", ""); + beforeData.put("费用项", ""); + beforeData.put("调整金额", BigDecimal.ZERO.setScale(2)); + beforeData.put("结算金额", BigDecimal.ZERO.setScale(2)); + beforeData.put("备注", ""); + afterData.put("费用类型", row.getFeeType()); + afterData.put("费用项", row.getFeeItem()); + afterData.put("调整金额", money(row.getAdjustAmount())); + afterData.put("结算金额", money(row.getSettlementAmount())); + afterData.put("备注", row.getRemark()); + saveChange(settlementId, "合计费用项", row.getLineNo(), "新增", String.join(";", changes), "", + beforeData, afterData); + return; + } + appendTextChange(changes, beforeData, afterData, "费用类型", beforeFeeType, row.getFeeType()); + appendTextChange(changes, beforeData, afterData, "费用项", beforeFeeItem, row.getFeeItem()); + appendChange(changes, beforeData, afterData, "调整金额", beforeAdjustAmount, row.getAdjustAmount()); + appendChange(changes, beforeData, afterData, "结算金额", beforeSettlementAmount, row.getSettlementAmount()); + appendTextChange(changes, beforeData, afterData, "备注", beforeRemark, row.getRemark()); + if (changes.isEmpty()) { + return; + } + saveChange(settlementId, "合计费用项", row.getLineNo(), "调整", String.join(";", changes), "", + beforeData, afterData); + } + + private void appendChange(List changes, Map beforeData, Map afterData, + String fieldName, BigDecimal before, BigDecimal after) { + BigDecimal oldValue = money(before); + BigDecimal newValue = money(after); + if (oldValue.compareTo(newValue) != 0) { + changes.add("【" + fieldName + "】从【" + oldValue + "】调整为【" + newValue + "】"); + beforeData.put(fieldName, oldValue); + afterData.put(fieldName, newValue); + } + } + + private void appendTextChange(List changes, Map beforeData, Map afterData, + String fieldName, String before, String after) { + String oldValue = before == null ? "" : before; + String newValue = after == null ? "" : after; + if (!Objects.equals(oldValue, newValue)) { + changes.add("【" + fieldName + "】从【" + oldValue + "】调整为【" + newValue + "】"); + beforeData.put(fieldName, oldValue); + afterData.put(fieldName, newValue); + } + } + private void saveChange(Long settlementId, String changeType, Integer lineNo, String operationType, String content, String reason) { + saveChange(settlementId, changeType, lineNo, operationType, content, reason, null, null); + } + + private void saveChange(Long settlementId, String changeType, Integer lineNo, String operationType, + String content, String reason, Map beforeData, Map afterData) { FormalSettlementChangeRecord record = new FormalSettlementChangeRecord(); record.setFormalSettlementId(settlementId); record.setChangeType(changeType); record.setLineNo(lineNo); record.setOperationType(operationType); record.setChangeContent(content); + record.setBeforeData(beforeData == null ? null : JsonUtil.toJson(beforeData)); + record.setAfterData(afterData == null ? null : JsonUtil.toJson(afterData)); record.setChangeReason(reason); record.setOperatorName(Func.isEmpty(AuthUtil.getUserName()) ? "system" : AuthUtil.getUserName()); record.setChangeTime(LocalDateTime.now()); @@ -1900,11 +2473,11 @@ public class FormalSettlementServiceImpl extends BaseServiceImpllambdaQuery() - .likeRight(FormalSettlementPayment::getPaymentNo, prefix)); - return prefix + String.format("%04d", count + 1); + Long maxSequence = paymentMapper.selectMaxDailySequence(AuthUtil.getTenantId(), prefix); + return prefix + String.format("%04d", (maxSequence == null ? 0L : maxSequence) + 1); } private BigDecimal money(BigDecimal value) { return (value == null ? BigDecimal.ZERO : value).setScale(2, RoundingMode.HALF_UP); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceApplicationServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceApplicationServiceImpl.java index 2bd7d3ac..f14f2570 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceApplicationServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceApplicationServiceImpl.java @@ -38,8 +38,6 @@ import org.springblade.core.secure.utils.AuthUtil; import org.springblade.core.tool.jackson.JsonUtil; import org.springblade.core.tool.utils.BeanUtil; import org.springblade.core.tool.utils.Func; -import org.springblade.system.cache.SysCache; -import org.springblade.system.pojo.entity.Dept; import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceApplyOpenApiQueryDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceApplyQueryDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceApplySaveDTO; @@ -98,6 +96,7 @@ import org.springblade.transport.pojo.vo.InvoiceApplicationSheetVO; import org.springblade.transport.pojo.vo.InvoiceApplicationVO; import org.springblade.transport.service.IInvoiceApplicationService; import org.springblade.transport.service.IKingdeeBillLockService; +import org.springblade.transport.service.KingdeeSyncContext; import org.springblade.transport.support.KingdeeApBillSyncSupport; import org.springblade.transport.support.KingdeeArBillSyncSupport; import org.springblade.transport.support.KingdeeSyncLockSupport; @@ -203,6 +202,8 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpllambdaQuery() - .eq(InvoiceApplicationSettlement::getInvoiceApplicationId, id) - .orderByAsc(InvoiceApplicationSettlement::getCreateTime))); + // 剩余可开票金额按当前占用实时重算(排除本申请),避免编辑时展示保存时点的过期额度 + List relations = settlementRelationMapper.selectList( + Wrappers.lambdaQuery() + .eq(InvoiceApplicationSettlement::getInvoiceApplicationId, id) + .orderByAsc(InvoiceApplicationSettlement::getCreateTime)); + if (!relations.isEmpty()) { + Map settlementMap = formalSettlementMapper.selectBatchIds( + relations.stream().map(InvoiceApplicationSettlement::getFormalSettlementId).distinct().toList()) + .stream().collect(Collectors.toMap(FormalSettlement::getId, Function.identity(), (first, duplicate) -> first)); + relations.forEach(relation -> { + FormalSettlement settlement = settlementMap.get(relation.getFormalSettlementId()); + if (settlement != null) { + relation.setAvailableInvoiceAmount(availableAmount(settlement, id)); + } + }); + } + vo.setSettlements(relations); List sheets = sheetMapper.selectList(Wrappers.lambdaQuery() .eq(InvoiceApplicationSheet::getInvoiceApplicationId, id).orderByAsc(InvoiceApplicationSheet::getSheetNo)); vo.setSheets(sheets.stream().map(sheet -> { @@ -255,9 +270,8 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl> settlementCandidates(IPage page, String keyword, - String contractCategory, String settlementType, String invoiceStatus) { + String contractCategory, String settlementType) { String candidateSettlementType = Func.isEmpty(settlementType) ? "receivable" : settlementType; - String candidateInvoiceStatus = Func.isEmpty(invoiceStatus) ? "unreceived" : invoiceStatus; List contractIds = Func.isEmpty(contractCategory) ? List.of() : contractManageMapper.selectList(Wrappers.lambdaQuery() .select(ContractManage::getId) @@ -269,7 +283,6 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl settlements = formalSettlementMapper.selectList(Wrappers.lambdaQuery() .eq(FormalSettlement::getSettlementType, candidateSettlementType) - .eq(FormalSettlement::getInvoiceStatus, candidateInvoiceStatus) .eq(FormalSettlement::getApprovalStatus, TransportBusiConstant.APPROVED) .eq(FormalSettlement::getStatus, 1) .in(Func.isNotEmpty(contractCategory), FormalSettlement::getContractId, contractIds) @@ -337,6 +350,7 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl first, LinkedHashMap::new)).values().stream().toList(); List settlements = requestedRows.stream().map(row -> availableSettlement(row.getSettlementId())).toList(); - if (settlements.stream().anyMatch(settlement -> !"receivable".equals(settlement.getSettlementType()) - || (creating && !"unreceived".equals(settlement.getInvoiceStatus())))) { - throw new ServiceException("只能选择审批通过、未作废、未收票的应收正式结算单"); + if (settlements.stream().anyMatch(settlement -> !"receivable".equals(settlement.getSettlementType()))) { + throw new ServiceException("只能选择审批通过、未作废的应收正式结算单"); } assertCompatible(settlements); FormalSettlement first = settlements.get(0); Map settlementMap = settlements.stream() .collect(Collectors.toMap(FormalSettlement::getId, Function.identity())); - BigDecimal totalAvailable = BigDecimal.ZERO; - for (InvoiceApplicationSaveRequest.SettlementRow row : requestedRows) { - FormalSettlement settlement = settlementMap.get(row.getSettlementId()); - BigDecimal available = availableAmount(settlement, entity.getId()); - BigDecimal allocated = nonNegative(row.getAllocatedInvoiceAmount(), "分摊发票金额"); - if (allocated.compareTo(available) > 0) { - throw new ServiceException("结算单" + settlement.getFormalSettlementNo() + "的分摊金额超过剩余可开票金额"); - } - totalAvailable = totalAvailable.add(available); - } BigDecimal lineTotal = validateSheets(request.getSheets()); if (lineTotal.compareTo(BigDecimal.ZERO) <= 0) { throw new ServiceException("本次开票金额必须大于0"); } + // 本次开票金额按选择顺序自动分摊到各结算单并登记累计占用,摊不完即超过可开票金额 + BigDecimal totalAvailable = BigDecimal.ZERO; + BigDecimal remaining = lineTotal; + for (InvoiceApplicationSaveRequest.SettlementRow row : requestedRows) { + FormalSettlement settlement = settlementMap.get(row.getSettlementId()); + BigDecimal available = availableAmount(settlement, entity.getId()); + totalAvailable = totalAvailable.add(available); + BigDecimal allocated = available.min(remaining); + row.setAllocatedInvoiceAmount(allocated); + remaining = remaining.subtract(allocated); + } + if (remaining.compareTo(BigDecimal.ZERO) > 0) { + throw new ServiceException("本次开票金额不能超过可开票金额"); + } CustomerArchive customer = findCustomer(first.getPayerName()); if (customer == null) throw new ServiceException("未找到正式结算单受票方对应的客商档案"); List invoiceInfos = activeInvoiceInfoVOs(customer.getId()); @@ -699,7 +716,7 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl items) { BfiInvoiceApplySaveDTO param = new BfiInvoiceApplySaveDTO(); // ==================== 以下为【必填】字段(BfiInvoiceApplySaveDTO 标注),非必填字段先注释保留 ==================== - // 【必填】组织.编码:申请部门 deptId → 金蝶组织编码 - param.setOrgidNumber(dept.getKingdeeDeptCode()); - // 【必填】申请部门.编码:同上 - param.setAwrpOrgfieldNumber(dept.getKingdeeDeptCode()); + // 【必填】组织.编码:项目业务部门反查唯一平台公司 → 平台公司金蝶组织编码 + param.setOrgidNumber(orgNumber); + // 【必填】申请部门.编码:项目业务部门金蝶编码(与付款申请单 awrp_department_number 同源) + param.setAwrpOrgfieldNumber(deptNumber); // 【必填】业务单据编号(候选键):单张发票候选 billno(多张发票时开票申请单号-张次序号) param.setBillno(billNo); // 【必填】单据日期:申请日期 yyyy-MM-dd HH:mm:ss @@ -1670,6 +1688,14 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpllambdaQuery() + .eq(InvoiceApplication::getId, id).last("FOR UPDATE")); + if (entity == null || Objects.equals(entity.getIsDeleted(), 1)) throw new ServiceException("开票申请不存在"); + return entity; + } + private InvoiceApplication editable(Long id) { InvoiceApplication entity = existing(id); if (!List.of(TransportBusiConstant.DRAFT, TransportBusiConstant.RETURNED).contains(entity.getApprovalStatus())) throw new ServiceException("当前状态不可编辑"); @@ -1680,10 +1706,11 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpllambdaQuery() - .likeRight(InvoiceApplication::getApplicationNo, prefix)) + 1); + Long maxSequence = baseMapper.selectMaxDailySequence(AuthUtil.getTenantId(), prefix); + return prefix + String.format("%05d", (maxSequence == null ? 0L : maxSequence) + 1); } private String normalizeDepartmentEmails(String value, List invoiceInfos) { diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceReceiptServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceReceiptServiceImpl.java index 30837ee2..33a64fbd 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceReceiptServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceReceiptServiceImpl.java @@ -64,6 +64,7 @@ import org.springblade.transport.mapper.FormalSettlementMapper; import org.springblade.transport.mapper.InvoiceReceiptMapper; import org.springblade.transport.mapper.InvoiceReceiptRecordMapper; import org.springblade.transport.mapper.InvoiceReceiptSettlementMapper; +import org.springblade.transport.mapper.CustomerInvoiceInfoMapper; import org.springblade.transport.mapper.KingdeeInvoicePoolMapper; import org.springblade.transport.pojo.dto.InvoiceReceiptSaveRequest; import org.springblade.transport.pojo.dto.InvoiceReceiptStatusRequest; @@ -71,6 +72,7 @@ import org.springblade.transport.pojo.dto.KingdeeInvoicePoolQueryDTO; import org.springblade.transport.pojo.entity.ContractManage; import org.springblade.transport.pojo.entity.CustomerArchive; import org.springblade.transport.pojo.entity.CustomerContact; +import org.springblade.transport.pojo.entity.CustomerInvoiceInfo; import org.springblade.transport.pojo.entity.FormalSettlement; import org.springblade.transport.pojo.entity.FormalSettlementDetail; import org.springblade.transport.pojo.entity.InvoiceReceipt; @@ -91,6 +93,7 @@ import org.springframework.cloud.context.config.annotation.RefreshScope; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; import java.io.IOException; import java.io.InputStream; @@ -150,8 +153,11 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl selectLocalPoolPage(KingdeeInvoicePoolQueryDTO query, long current, long size) { - return invoicePoolMapper.selectInvoicePoolPage(new Page<>(current, size), query, AuthUtil.getTenantId()); + return invoicePoolMapper.selectInvoicePoolPage(new Page<>(current, size), query, AuthUtil.getTenantId(), + bfiProperties.getInvoiceReceipt().isCustomerUnitsOnly()); } /** @@ -608,12 +615,16 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl failures = new ArrayList<>(); for (InvoiceReceipt receipt : receipts) { try { - pushOneReceivedInvoice(planByReceipt.get(receipt.getId()), kingdeeRowByInvoiceNo, apBillNoBySrcCode, context, autoSync); + // 逐单单据级事务:推送方法首行 FOR UPDATE 串行化同一登记的并发推送,单笔失败只回滚自身不中断整批 + transactionTemplate.executeWithoutResult(status -> pushOneReceivedInvoice( + planByReceipt.get(receipt.getId()), kingdeeRowByInvoiceNo, apBillNoBySrcCode, context, autoSync)); success++; } catch (RuntimeException pushException) { failures.add("发票" + Func.toStrWithEmpty(receipt.getInvoiceNo(), String.valueOf(receipt.getId())) + ":" + pushException.getMessage()); log.warn("推送金蝶收票单失败,收票登记id={}", receipt.getId(), pushException); + // 单笔事务已回滚,事务外独立补落"推送失败"状态,纳入定时重推(与付款/开票失败口径统一) + markReceivedInvoicePushFailed(receipt, planByReceipt.get(receipt.getId()), pushException, autoSync); } } String summary = "推送金蝶收票单完成:本批 " + receipts.size() + " 笔,成功 " + success + " 笔"; @@ -674,7 +685,21 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl kingdeeRows, Map apBillNoBySrcCode, KingdeeSyncContext context, boolean autoSync) { - InvoiceReceipt receipt = plan.receipt(); + // 行锁读:同一登记并发推送串行化(锁随本单事务提交释放,事务由批量编排的 TransactionTemplate 开启) + InvoiceReceipt receipt = baseMapper.selectOne(Wrappers.lambdaQuery() + .eq(InvoiceReceipt::getId, plan.receipt().getId()).last("FOR UPDATE")); + if (receipt == null || Objects.equals(receipt.getIsDeleted(), 1)) { + throw new ServiceException("收票登记不存在"); + } + // 锁后重查推送状态:并发场景另一事务已完成推送则幂等跳过,不再重复调金蝶 + if (KingdeeBfiConstant.RECEIVED_PUSH_PUSHED.equals(receipt.getKingdeePushStatus()) + || KingdeeBfiConstant.RECEIVED_PUSH_ASSIGNED.equals(receipt.getKingdeePushStatus())) { + log.info("收票登记[{}]金蝶收票单已推送(状态[{}]),幂等跳过", receipt.getInvoiceNo(), receipt.getKingdeePushStatus()); + return "发票" + Func.toStrWithEmpty(receipt.getInvoiceNo(), String.valueOf(receipt.getId())) + "已推送金蝶,幂等跳过"; + } + // 计划内登记实体以行锁后最新数据替换(并发下推送状态可能已被其他事务改写) + plan = new ReceivedInvoicePushPlan(receipt, plan.mirror(), plan.settlement(), plan.details(), + plan.apBillSrcCode(), plan.blockedReason()); log.info("收票登记[{}]推送金蝶收票单开始:id={},推送状态={},应付单防重键={}", receipt.getInvoiceNo(), receipt.getId(), receipt.getKingdeePushStatus(), plan.apBillSrcCode()); if (Func.isNotBlank(plan.blockedReason())) { @@ -765,6 +790,25 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl invoiceTypes = this.poolInvoiceTypeCodes(); + // 客商单位过滤集:开关开启时一次性载入有效客商发票信息税号(销/购方税号都须命中才入镜像),整轮共享避免逐页查询 + Set customerTaxNos = bfiProperties.getInvoiceReceipt().isCustomerUnitsOnly() ? this.customerInvoiceTaxNos() : null; SyncStats stats = new SyncStats(); - log.info("金蝶全票池同步开始,增量窗口 {} ~ {},发票种类:{},单页 {} 条最多 {} 页(触顶自动二分切片)", + log.info("金蝶全票池同步开始,增量窗口 {} ~ {},发票种类:{},客商单位过滤:{},单页 {} 条最多 {} 页(触顶自动二分切片)", windowStart.format(POOL_TIME_FORMATTER), windowEnd.format(POOL_TIME_FORMATTER), - invoiceTypes.isEmpty() ? "不限制" : invoiceTypes, POOL_PAGE_SIZE, MAX_POOL_PAGES); + invoiceTypes.isEmpty() ? "不限制" : invoiceTypes, customerTaxNos != null ? "开启" : "关闭", + POOL_PAGE_SIZE, MAX_POOL_PAGES); if (invoiceTypes.isEmpty()) { - this.syncWindowSlice(windowStart, windowEnd, null, stats); + this.syncWindowSlice(windowStart, windowEnd, null, customerTaxNos, stats); } else { // 单值参数拆轮:每个票种编码独立完整切片续扫,互不挤占翻页额度 for (String invoiceType : invoiceTypes) { - this.syncWindowSlice(windowStart, windowEnd, invoiceType, stats); + this.syncWindowSlice(windowStart, windowEnd, invoiceType, customerTaxNos, stats); } } String summary = "金蝶全票池同步:翻页" + stats.pageCount + "页切片" + stats.sliceCount + "片拉取" - + stats.pulledCount + "张发票,镜像新增" + stats.created + "更新" + stats.updated + "无变化" + + stats.pulledCount + "张发票" + + (stats.customerFiltered > 0 ? "(客商单位过滤" + stats.customerFiltered + "张)" : "") + + ",镜像新增" + stats.created + "更新" + stats.updated + "无变化" + stats.mirrorUnchanged + ",补绑" + stats.bound + ",登记无变化跳过" + stats.unchanged + ",失败" + stats.failed + ",耗时" + (System.currentTimeMillis() - startMillis) + "ms"; log.info("金蝶全票池同步结束,{}", summary); @@ -1288,6 +1337,8 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl2.5 万,从中点二分为两片递归续扫(递归深度受时间粒度自然收敛, * 单片 500 条以内 1 页即短页终止) * - * @param sliceStart 切片起点(含) - * @param sliceEnd 切片终点(不含,避免与下一片边界重复拉取) - * @param stats 整轮统计累加器 + * @param sliceStart 切片起点(含) + * @param sliceEnd 切片终点(不含,避免与下一片边界重复拉取) + * @param customerTaxNos 客商发票信息税号集,null 表示客商单位过滤关闭(整轮共享) + * @param stats 整轮统计累加器 */ - private void syncWindowSlice(LocalDateTime sliceStart, LocalDateTime sliceEnd, String invoiceType, SyncStats stats) { + private void syncWindowSlice(LocalDateTime sliceStart, LocalDateTime sliceEnd, String invoiceType, + Set customerTaxNos, SyncStats stats) { // 二分收敛保护:切片粒度细到 1 秒仍触顶说明远端异常(同秒 >2.5 万条不可能),不再二分直接记漏 if (!sliceStart.isBefore(sliceEnd)) { log.error("金蝶全票池同步切片区间为空({} ~ {}),远端 updateTime 粒度异常,放弃该切片", sliceStart, sliceEnd); @@ -1329,6 +1382,13 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl this.bothUnitsRegistered(row, customerTaxNos)).toList(); + stats.customerFiltered += rawPageSize - rows.size(); + } BatchUpsertResult upsertResult = this.batchUpsertInvoicePool(rows); stats.created += upsertResult.created(); stats.updated += upsertResult.updated(); @@ -1340,8 +1400,8 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl customerInvoiceTaxNos() { + return customerInvoiceInfoMapper.selectList(Wrappers.lambdaQuery() + .eq(CustomerInvoiceInfo::getStatus, 1) + .eq(CustomerInvoiceInfo::getIsDeleted, 0)).stream() + .map(CustomerInvoiceInfo::getTaxNo) + .filter(StringUtil::isNotBlank) + .collect(Collectors.toSet()); + } + + /** + * 开票单位(销方)与受票单位(购方)税号是否均已登记在客商发票信息中;税号缺失视为未登记(开关开启时不入镜像) + */ + private boolean bothUnitsRegistered(BfiInvoicePoolVO row, Set customerTaxNos) { + return StringUtil.isNotBlank(row.getSalerTaxNo()) && StringUtil.isNotBlank(row.getBuyerTaxNo()) + && customerTaxNos.contains(row.getSalerTaxNo()) && customerTaxNos.contains(row.getBuyerTaxNo()); + } + /** 已登记票跟随刷新结果统计(绑定/刷新/无变化/失败) */ private record RefreshResult(int bound, int unchanged, int failed) { } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/KingdeeBillLockServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/KingdeeBillLockServiceImpl.java index 97ee28db..02aefab7 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/KingdeeBillLockServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/KingdeeBillLockServiceImpl.java @@ -13,6 +13,7 @@ import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springblade.core.log.exception.ServiceException; +import org.springblade.core.mp.base.BaseEntity; import org.springblade.core.secure.utils.AuthUtil; import org.springblade.core.tool.utils.Func; import org.springblade.core.tool.utils.SpringUtil; @@ -29,6 +30,7 @@ import org.springblade.transport.pojo.entity.FormalSettlement; import org.springblade.transport.pojo.entity.InvoiceApplication; import org.springblade.transport.pojo.entity.KingdeeBillLock; import org.springblade.transport.pojo.entity.PaymentApplication; +import org.springblade.transport.pojo.vo.KingdeeUnlockMark; import org.springblade.transport.service.IFormalSettlementService; import org.springblade.transport.service.IInvoiceApplicationService; import org.springblade.transport.service.IKingdeeBillLockService; @@ -152,8 +154,8 @@ public class KingdeeBillLockServiceImpl extends ServiceImpllambdaQuery() - .eq(KingdeeBillLock::getTargetBillType, param.getTargetBillType()) - .eq(KingdeeBillLock::getTargetBillNo, targetBillNo) - .orderByDesc(KingdeeBillLock::getId) - .last("limit 1"), false); + // 回调方向与锁定相反:回调 sourceBillNo=金蝶单号(即锁记录 targetBillNo)、targetBillNo=WLTMS 单号(即锁记录 sourceBillNo)、 + // targetBillType 可能为空,故按金蝶单号+我方单号双条件反查;未匹配锁定中记录的幂等跳过(重复通知防金蝶重试风暴) + // 一次 IN 查询取回候选记录、内存取各单号最新一条,批量更新解锁状态,避免逐张查询/更新的 N+1 + List kingdeeBillNos = Func.toStrList(param.getSourceBillNo()); + Map latestLockMap = this.list(Wrappers.lambdaQuery() + .in(KingdeeBillLock::getTargetBillNo, kingdeeBillNos) + .eq(KingdeeBillLock::getSourceBillNo, param.getTargetBillNo()) + .orderByDesc(KingdeeBillLock::getId)) + .stream() + .collect(Collectors.toMap(KingdeeBillLock::getTargetBillNo, Function.identity(), (first, second) -> first)); + List unlockRecords = new ArrayList<>(); + for (String kingdeeBillNo : kingdeeBillNos) { + KingdeeBillLock lockRecord = latestLockMap.get(kingdeeBillNo); if (lockRecord == null || !KingdeeLockConstant.STATUS_LOCKED.equals(lockRecord.getLockStatus())) { - log.warn("金蝶解锁回调未匹配锁定中的锁记录(幂等跳过):targetBillType={},targetBillNo={}", - param.getTargetBillType(), targetBillNo); + log.warn("金蝶解锁回调未匹配锁定中的锁记录(幂等跳过):金蝶单号={},回调targetBillNo={}", + kingdeeBillNo, param.getTargetBillNo()); continue; } lockRecord.setLockStatus(KingdeeLockConstant.STATUS_UNLOCKED); lockRecord.setOperationType(param.getOperationType()); lockRecord.setUnlockTime(LocalDateTime.now()); lockRecord.setNotifyOperator(param.getOperator()); - this.updateById(lockRecord); - processed++; + unlockRecords.add(lockRecord); log.info("金蝶单据[{}:{}]解锁回调落库:operationType={},操作人={}", lockRecord.getSourceBillType(), lockRecord.getSourceBillNo(), param.getOperationType(), param.getOperator()); - if (KingdeeLockConstant.OPERATION_DELETE.equals(param.getOperationType())) { - this.restoreOnKingdeeDelete(lockRecord, param.getOperator()); - } } - return processed; + this.updateBatchById(unlockRecords); + // DELETE 分发废除还原:按源单逐张执行(各单据类型还原动作为独立业务事务,无法合并) + if (KingdeeLockConstant.OPERATION_DELETE.equals(param.getOperationType())) { + unlockRecords.forEach(lockRecord -> this.restoreOnKingdeeDelete(lockRecord, param.getOperator())); + } + return unlockRecords.size(); } /** @@ -243,6 +251,28 @@ public class KingdeeBillLockServiceImpl extends ServiceImpl lockedRecords = this.list(Wrappers.lambdaQuery() + .eq(KingdeeBillLock::getSourceBillType, sourceBillType) + .eq(KingdeeBillLock::getSourceBillId, sourceBillId) + .eq(KingdeeBillLock::getLockStatus, KingdeeLockConstant.STATUS_LOCKED)); + if (Func.isEmpty(lockedRecords)) { + return 0; + } + LocalDateTime unlockTime = LocalDateTime.now(); + for (KingdeeBillLock lockRecord : lockedRecords) { + lockRecord.setLockStatus(KingdeeLockConstant.STATUS_UNLOCKED); + lockRecord.setOperationType(KingdeeLockConstant.OPERATION_ADJUST_REPUSH); + lockRecord.setUnlockTime(unlockTime); + lockRecord.setNotifyOperator(Func.isEmpty(operator) ? "system" : operator); + } + this.updateBatchById(lockedRecords); + log.info("结算调整重推解锁旧锁:源单[{}:{}],解锁{}张,操作人={}", sourceBillType, sourceBillId, lockedRecords.size(), operator); + return lockedRecords.size(); + } + @Override public void checkEditable(String sourceBillType, Long sourceBillId) { long lockedCount = this.count(Wrappers.lambdaQuery() @@ -268,6 +298,47 @@ public class KingdeeBillLockServiceImpl extends ServiceImpl second)); } + @Override + public Map mapUnlockedLocks(String sourceBillType, List sourceBillIds) { + if (Func.isEmpty(sourceBillIds)) { + return Map.of(); + } + List lockRecords = this.list(Wrappers.lambdaQuery() + .eq(KingdeeBillLock::getSourceBillType, sourceBillType) + .in(KingdeeBillLock::getSourceBillId, sourceBillIds) + .eq(KingdeeBillLock::getLockStatus, KingdeeLockConstant.STATUS_UNLOCKED) + .orderByDesc(KingdeeBillLock::getId)); + // 降序取回后首个生效:每源单保留最新一条解锁留痕 + return lockRecords.stream().collect(Collectors.toMap(KingdeeBillLock::getSourceBillId, Function.identity(), (first, second) -> first)); + } + + @Override + public void fillUnlockMarks(String sourceBillType, List rows, Map activeLockMap) { + if (Func.isEmpty(rows)) { + return; + } + List unlockedIds = rows.stream() + .filter(row -> !activeLockMap.containsKey(row.getId())) + .map(BaseEntity::getId).toList(); + if (Func.isEmpty(unlockedIds)) { + return; + } + Map unlockedMap = this.mapUnlockedLocks(sourceBillType, unlockedIds); + for (T row : rows) { + KingdeeBillLock unlockRecord = unlockedMap.get(row.getId()); + if (unlockRecord == null) { + continue; + } + row.setKingdeeUnlockTime(unlockRecord.getUnlockTime()); + row.setKingdeeUnlockOperator(unlockRecord.getNotifyOperator()); + row.setKingdeeUnlockOperationName(switch (Func.toStr(unlockRecord.getOperationType())) { + case KingdeeLockConstant.OPERATION_DELETE -> "金蝶删单"; + case KingdeeLockConstant.OPERATION_ADJUST_REPUSH -> "结算调整重推"; + default -> "金蝶锁单"; + }); + } + } + /** * 锁定补偿:扫 failed 且 try_count 未达重试上限(RETRY_MAX_COUNT)的锁记录,逐张重调 lockBusiData * (接口 1对1:单次一个源单号对一个金蝶单号,单张成败互不影响,由定时任务定时触发,亦可人工触发)。 diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/LoadingManageServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/LoadingManageServiceImpl.java index 7393aa55..ed88df34 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/LoadingManageServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/LoadingManageServiceImpl.java @@ -28,7 +28,10 @@ import org.springblade.transport.pojo.vo.LoadingCarrierContractVO; import org.springblade.transport.pojo.vo.LoadingManageVO; import org.springblade.transport.service.IContractManageService; import org.springblade.transport.service.ILoadingManageService; +import org.springblade.transport.service.IProcessConfigService; import org.springblade.transport.service.IReceivablePayableDetailService; +import org.springblade.transport.support.LiveProcessConfigSupport; +import org.springblade.transport.support.LoadableAdmissionSupport; import org.springblade.transport.support.TransportBusinessSupport; import org.springblade.transport.support.WaybillProcessSupport; import org.springblade.transport.wrapper.LoadingManageWrapper; @@ -43,8 +46,10 @@ import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; @@ -76,6 +81,9 @@ public class LoadingManageServiceImpl extends BaseServiceImpl selectLoadingManagePage(IPage page, LoadingManageVO loadingManage) { IPage entityPage = page(page, buildQuery(loadingManage)); @@ -147,9 +155,12 @@ public class LoadingManageServiceImpl extends BaseServiceImpl oldWaybillIds = new ArrayList<>(); + boolean ownChildrenAdmitted = false; if (Func.isNotEmpty(loadingManage.getId())) { LoadingManage oldRecord = loadEditable(loadingManage.getId(), true); oldWaybillIds = waybillIds(oldRecord.getWaybillIdsJson()); + // 草稿只预占运单号、未过判据,草稿转正式不得沿用本单豁免 + ownChildrenAdmitted = !STATUS_DRAFT.equals(oldRecord.getBusinessStatus()); loadingManage.setLoadingNo(oldRecord.getLoadingNo()); loadingManage.setDeptId(oldRecord.getDeptId()); loadingManage.setDeptName(oldRecord.getDeptName()); @@ -160,10 +171,10 @@ public class LoadingManageServiceImpl extends BaseServiceImpl waybills = validateWaybillsAvailable(loadingManage, waybillIds(loadingManage.getWaybillIdsJson()), ownChildrenAdmitted); + validateCarrierContract(loadingManage, true, waybills); boolean result = saveOrUpdate(loadingManage); - syncAssociatedWaybills(loadingManage, STATUS_PENDING, oldWaybillIds); + writeWaybillsForDriver(loadingManage, oldWaybillIds, waybills); return result; } @@ -409,11 +420,10 @@ public class LoadingManageServiceImpl extends BaseServiceImpl waybills = validateWaybillsAvailable(loadingManage, waybillIds(loadingManage.getWaybillIdsJson()), true); + validateCarrierContract(loadingManage, true, waybills); boolean result = updateById(loadingManage); - syncAssociatedWaybills(loadingManage, STATUS_PENDING, oldWaybillIds); - clearAssociatedWaybillAcceptRecords(loadingManage); + writeWaybillsForDriver(loadingManage, oldWaybillIds, waybills); return result; } @@ -735,29 +745,64 @@ public class LoadingManageServiceImpl extends BaseServiceImpl waybillIds) { + private List validateWaybillsAvailable(LoadingManage loadingManage, List waybillIds, boolean ownChildrenAdmitted) { if (Func.isEmpty(waybillIds)) { throw new ServiceException("请选择待配载运单"); } - List waybills = waybillMapper.selectList(Wrappers.lambdaQuery() - .eq(Waybill::getIsDeleted, 0) - .in(Waybill::getId, waybillIds)); + List waybills = loadWaybills(waybillIds); if (waybills.size() != waybillIds.size()) { throw new ServiceException("待配载运单不存在或已删除"); } + String ownLoadingNo = loadingManage.getLoadingNo(); for (Waybill waybill : waybills) { TransportBusinessSupport.assertCurrentDept(waybill.getDeptId(), "运单管理"); - if (Func.isNotEmpty(waybill.getLoadingNo()) && !Objects.equals(waybill.getLoadingNo(), loadingManage.getLoadingNo())) { - throw new ServiceException("运单" + waybill.getWaybillNo() + "已关联其他配载单"); + if (ownChildrenAdmitted && LoadableAdmissionSupport.isOwnChild(waybill, ownLoadingNo)) { + continue; } - if ((Objects.equals(waybill.getBusinessStatus(), STATUS_COMPLETED) || Objects.equals(waybill.getBusinessStatus(), STATUS_CANCELLED)) - && !Objects.equals(waybill.getLoadingNo(), loadingManage.getLoadingNo())) { - throw new ServiceException("运单" + waybill.getWaybillNo() + "当前状态不允许配载"); + LoadableAdmissionSupport.UnloadableReason reason = + LoadableAdmissionSupport.unloadableReason(waybill, ownLoadingNo); + if (reason != null) { + throw new ServiceException(unloadableMessage(waybill, reason)); } } + validateSameProject(waybills); + return waybills; + } + + /** + * 逐张准入失败的可读原因:判据只给原因枚举,运单号与文案在这里拼。 + */ + private String unloadableMessage(Waybill waybill, LoadableAdmissionSupport.UnloadableReason reason) { + String prefix = "运单" + waybill.getWaybillNo(); + return prefix + switch (reason) { + case NOT_ROAD -> "运输方式不是公路,不可配载"; + case NOT_PENDING -> "当前状态不是待执行,不可配载"; + case ON_OTHER_MANIFEST -> "已关联其他配载单"; + }; + } + + /** + * 一张配载单的全部子运单必须属于同一个项目,否则点名与其它子运单项目不同的运单。 + *

+ * 查询条件表达不了跨行约束,只能整批取回后在内存里比。 + */ + private void validateSameProject(List waybills) { + Long projectId = waybills.get(0).getProjectId(); + List others = waybills.stream() + .filter(waybill -> !Objects.equals(waybill.getProjectId(), projectId)) + .map(Waybill::getWaybillNo) + .toList(); + if (Func.isNotEmpty(others)) { + throw new ServiceException("配载单的子运单必须属于同一项目,运单" + + String.join("、", others) + "与其它子运单不属于同一项目"); + } } private void validateCarrierContract(LoadingManage loadingManage, boolean required) { + validateCarrierContract(loadingManage, required, null); + } + + private void validateCarrierContract(LoadingManage loadingManage, boolean required, List waybills) { if (!Objects.equals(loadingManage.getCarrierType(), "承运商")) { loadingManage.setCarrierContractId(null); if (!Objects.equals(loadingManage.getCarrierType(), CARRIER_SELF)) { @@ -772,10 +817,24 @@ public class LoadingManageServiceImpl extends BaseServiceImpl Objects.equals(contract.getId(), loadingManage.getCarrierContractId())) - .findFirst() - .orElseThrow(() -> new ServiceException("所选承运商合同不存在、未审核通过或已失效")); + ContractManage carrierContract = availableCarrierContractById(loadingManage.getCarrierContractId()); + if (carrierContract == null) { + throw new ServiceException("所选承运商合同不存在、未审核通过或已失效"); + } + List childWaybills = waybills == null + ? loadWaybills(waybillIds(loadingManage.getWaybillIdsJson())) + : waybills; + // 合同自带项目与乙方:合同的项目不属于本单子运单的项目,等于换了项目却没换乙方 + if (!projectIdsOf(childWaybills).contains(carrierContract.getProjectId())) { + String waybillNos = childWaybills.stream() + .filter(waybill -> !Objects.equals(waybill.getProjectId(), carrierContract.getProjectId())) + .map(Waybill::getWaybillNo) + .filter(Func::isNotEmpty) + .collect(Collectors.joining("、")); + throw new ServiceException(Func.isEmpty(waybillNos) + ? "所选承运商合同不属于本项目" + : "所选承运商合同不属于运单" + waybillNos + "的项目"); + } loadingManage.setCarrierName(carrierContract.getPartyB()); } @@ -792,34 +851,98 @@ public class LoadingManageServiceImpl extends BaseServiceImpl projectIdsByWaybills(LoadingManage loadingManage) { - List waybillIds = waybillIds(loadingManage.getWaybillIdsJson()); + private List loadWaybills(List waybillIds) { if (Func.isEmpty(waybillIds)) { return List.of(); } return waybillMapper.selectList(Wrappers.lambdaQuery() - .select(Waybill::getProjectId) .eq(Waybill::getIsDeleted, 0) - .in(Waybill::getId, waybillIds)) - .stream().map(Waybill::getProjectId).filter(Objects::nonNull).distinct().toList(); + .in(Waybill::getId, waybillIds)); + } + + private List projectIdsOf(List waybills) { + return waybills.stream().map(Waybill::getProjectId).filter(Objects::nonNull).distinct().toList(); } private List availableCarrierContracts(List projectIds) { if (Func.isEmpty(projectIds)) { return List.of(); } - return contractManageService.list(Wrappers.lambdaQuery() - .eq(ContractManage::getIsDeleted, 0) - .eq(ContractManage::getStatus, 1) - .eq(ContractManage::getContractCategory, "承运商合同") + return contractManageService.list(availableCarrierContractQuery() .in(ContractManage::getProjectId, projectIds) - .in(ContractManage::getApprovalStatus, "approved", "change_approved") - .and(wrapper -> wrapper.isNull(ContractManage::getContractStage) - .or().ne(ContractManage::getContractStage, "terminated")) .orderByDesc(ContractManage::getCreateTime)); } + /** + * 按主键取一张可用的承运商合同。 + *

+ * 不按项目过滤,才能把「合同不可用」与「合同不属于本项目」分成两种提示。 + * 取不到可用的合同返回 null。 + */ + private ContractManage availableCarrierContractById(Long contractId) { + if (Func.isEmpty(contractId)) { + return null; + } + return contractManageService.list(availableCarrierContractQuery() + .eq(ContractManage::getId, contractId) + .orderByDesc(ContractManage::getCreateTime)) + .stream().findFirst().orElse(null); + } + + private LambdaQueryWrapper availableCarrierContractQuery() { + return Wrappers.lambdaQuery() + .eq(ContractManage::getIsDeleted, 0) + .eq(ContractManage::getStatus, 1) + .eq(ContractManage::getContractCategory, "承运商合同") + .in(ContractManage::getApprovalStatus, "approved", "change_approved") + .and(wrapper -> wrapper.isNull(ContractManage::getContractStage) + .or().ne(ContractManage::getContractStage, "terminated")); + } + + /** + * 配载提交与重新派单共用:子运单状态逐张按该票自己项目的实时过程配置推导后落库,再清除接单事实。 + *

+ * 推导按「接单事实已清、尚未接单」的口径取值,故清接单事实放在状态写入之后,两者口径一致。 + */ + private void writeWaybillsForDriver(LoadingManage loadingManage, List oldWaybillIds, List waybills) { + syncAssociatedWaybills(loadingManage, deriveWaybillBusinessStatuses(waybills), oldWaybillIds); + // 接单事实不清,被拒的票对新司机不可见——司机端一切入口都排除已拒绝的运单 + clearAssociatedWaybillAcceptRecords(loadingManage); + } + + /** + * 子运单业务状态逐张按各自项目的实时过程配置推导:需要接单 → 待执行,不需要 → 进行中。 + *

+ * 配置取法与运单读路径同一处:项目生效配置优先,取不到时才回退该运单的过程配置快照; + * 快照只作兜底、永不优先,否则项目改配置后写入值与读时的归一化结果会分叉。 + * 项目为空且快照也为空 → 视为不需要接单 → 进行中。 + */ + private Map deriveWaybillBusinessStatuses(List waybills) { + Map businessStatusByWaybillId = new LinkedHashMap<>(); + if (Func.isEmpty(waybills)) { + return businessStatusByWaybillId; + } + Map liveConfigByProject = LiveProcessConfigSupport.byProjects(processConfigService, + waybills.stream().map(Waybill::getProjectId).toList()); + for (Waybill waybill : waybills) { + String processJson = LiveProcessConfigSupport.resolve( + liveConfigByProject.get(waybill.getProjectId()), waybill.getProcessJson()); + // 以「待执行 + 待接单」为输入走读路径同一个归一化:需要接单留下待执行,不需要接单提升为进行中 + businessStatusByWaybillId.put(waybill.getId(), WaybillProcessSupport.normalizeBusinessStatus( + STATUS_PENDING, processJson, WaybillProcessSupport.ACCEPT_PENDING)); + } + return businessStatusByWaybillId; + } + private void syncAssociatedWaybills(LoadingManage loadingManage, String status, List oldWaybillIds) { + Map statusByWaybillId = new LinkedHashMap<>(); + for (Long waybillId : waybillIds(loadingManage.getWaybillIdsJson())) { + statusByWaybillId.put(waybillId, status); + } + syncAssociatedWaybills(loadingManage, statusByWaybillId, oldWaybillIds); + } + + private void syncAssociatedWaybills(LoadingManage loadingManage, Map statusByWaybillId, List oldWaybillIds) { List newWaybillIds = waybillIds(loadingManage.getWaybillIdsJson()); if (Func.isNotEmpty(oldWaybillIds)) { List removedIds = new ArrayList<>(oldWaybillIds); @@ -829,8 +952,18 @@ public class LoadingManageServiceImpl extends BaseServiceImpl> waybillIdsByStatus = new LinkedHashMap<>(); + for (Long waybillId : newWaybillIds) { + waybillIdsByStatus + .computeIfAbsent(statusByWaybillId.getOrDefault(waybillId, STATUS_PENDING), key -> new ArrayList<>()) + .add(waybillId); + } + waybillIdsByStatus.forEach((status, ids) -> updateAssociatedWaybillFields(loadingManage, ids, status)); + } + + private void updateAssociatedWaybillFields(LoadingManage loadingManage, List waybillIds, String status) { waybillMapper.update(null, Wrappers.lambdaUpdate() - .in(Waybill::getId, newWaybillIds) + .in(Waybill::getId, waybillIds) .set(Waybill::getLoadingNo, loadingManage.getLoadingNo()) .set(Waybill::getBusinessStatus, status) .set(Waybill::getCarrierType, loadingManage.getCarrierType()) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ManageWaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ManageWaybillServiceImpl.java index 71327729..4b1e5c93 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ManageWaybillServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ManageWaybillServiceImpl.java @@ -23,16 +23,19 @@ package org.springblade.transport.service.impl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import lombok.RequiredArgsConstructor; +import org.springblade.core.log.exception.ServiceException; import org.springblade.core.secure.utils.AuthUtil; import org.springblade.core.tool.utils.DateUtil; import org.springblade.core.tool.utils.Func; import org.springblade.system.cache.UserDisplayCache; import org.springblade.transport.pojo.entity.Driver; import org.springblade.transport.pojo.entity.ExceptionDisposal; +import org.springblade.transport.pojo.entity.LoadingManage; import org.springblade.transport.pojo.entity.RiskDisposal; import org.springblade.transport.pojo.entity.Waybill; import org.springblade.transport.pojo.vo.AdminDriverOptionVO; @@ -44,12 +47,21 @@ import org.springblade.transport.pojo.vo.AdminVehicleOptionVO; import org.springblade.transport.pojo.vo.AdminWaybillCardVO; import org.springblade.transport.pojo.vo.AdminWaybillDetailVO; import org.springblade.transport.pojo.vo.DriverWaybillCardVO; +import org.springblade.transport.pojo.vo.TripQueryVO; +import org.springblade.transport.pojo.vo.TripStationVO; +import org.springblade.transport.pojo.vo.TripTabCountsVO; +import org.springblade.transport.pojo.vo.TripVO; import org.springblade.transport.service.IDriverService; import org.springblade.transport.service.IDriverWaybillService; import org.springblade.transport.service.IExceptionDisposalService; +import org.springblade.transport.service.ILoadingManageService; import org.springblade.transport.service.IManageWaybillService; import org.springblade.transport.service.IRiskDisposalService; +import org.springblade.transport.service.ITripQueryService; import org.springblade.transport.service.IWaybillService; +import org.springblade.transport.support.TransportBusinessSupport; +import org.springblade.transport.support.WaybillCargoSupport; +import org.springblade.transport.support.WaybillProcessSupport; import org.springframework.stereotype.Service; import java.math.BigDecimal; @@ -58,16 +70,24 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; +import java.util.Collection; import java.util.Collections; import java.util.Date; import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.function.Function; import java.util.stream.Collectors; /** - * 调度端:运单状态统计 + 异常/风险角标 + 待处理事项 + 运单列表 + * 调度端:运次状态统计 + 异常/风险角标 + 待处理事项 + 运次列表 + *

+ * 列表与详情与司机端同粒度:取数、状态聚合、接单需求合并都调共享运次读服务, + * 本类只做字段裁剪与调度端独有的筛选(组织类型 / 异常 / 创建时间 / 待重新派单)。 *

* 小程序调度账号(如「小程序管理」)组织常与运单业务组织不一致,故不做 dept 过滤, * 仅依赖租户隔离,口径接近后台 {@code /waybill-manage/list?allDept=1}。 @@ -102,6 +122,11 @@ public class ManageWaybillServiceImpl implements IManageWaybillService { private static final int DEFAULT_PAGE_SIZE = 10; private static final int MAX_PAGE_SIZE = 50; + /** 待重新派单的排序键:运次内子单被拒的时刻取最大值 */ + private static final String SQL_MAX_REJECT_TIME = "MAX(driver_reject_time)"; + /** 待重新派单的排序键:运次内子单创建时刻取最大值(与运次列表同一排序口径) */ + private static final String SQL_MAX_CREATE_TIME = "MAX(create_time)"; + private static final DateTimeFormatter DATE_MD = DateTimeFormatter.ofPattern("MM-dd"); private static final DateTimeFormatter DATE_YMD = DateTimeFormatter.ofPattern("yyyy-MM-dd"); @@ -110,13 +135,19 @@ public class ManageWaybillServiceImpl implements IManageWaybillService { private final IExceptionDisposalService exceptionDisposalService; private final IRiskDisposalService riskDisposalService; private final IDriverWaybillService driverWaybillService; + private final ITripQueryService tripQueryService; + private final ILoadingManageService loadingManageService; @Override public AdminHomeStatsVO stats() { AdminHomeStatsVO vo = new AdminHomeStatsVO(); - vo.setPendingAccept(countWaybillByStatus(STATUS_PENDING)); - vo.setTransporting(countWaybillByStatus(STATUS_RUNNING)); - vo.setCompleted(countWaybillByStatus(STATUS_COMPLETED)); + // 三档状态按运次计:有配载单的一趟车算一个,与列表和司机端同一份聚合 + TripQueryVO query = TripQueryVO.forAdmin(); + query.setDeptIds(manageableDeptIds()); + TripTabCountsVO tripCounts = tripQueryService.tabCounts(query); + vo.setPendingAccept(tripCounts.getPending()); + vo.setTransporting(tripCounts.getDoing()); + vo.setCompleted(tripCounts.getDone()); vo.setException(countIncompleteExceptions()); return vo; } @@ -144,57 +175,46 @@ public class ManageWaybillServiceImpl implements IManageWaybillService { Set exceptionWaybillIds = loadIncompleteExceptionWaybillIds(); if (EXCEPTION_YES.equals(exception) && exceptionWaybillIds.isEmpty()) { - Page emptyVo = new Page<>(pageNo, pageSize, 0); - emptyVo.setRecords(List.of()); - return emptyVo; + return emptyCardPage(pageNo, pageSize); } - LambdaQueryWrapper wrapper = scopedWaybillQuery(); - applyStatusFilter(wrapper, status); - applyExceptionFilter(wrapper, exception, exceptionWaybillIds); - applyTransportTypeFilter(wrapper, transportType); - applyKeywordFilter(wrapper, keyword); - applyCreateTimeFilter(wrapper, startDate, endDate); - wrapper.orderByDesc(Waybill::getCreateTime); - - IPage entityPage = waybillService.page(new Page<>(pageNo, pageSize), wrapper); - List pageIds = entityPage.getRecords().stream() - .map(Waybill::getId) - .filter(Objects::nonNull) - .toList(); - Set pageExceptionIds = pageIds.isEmpty() - ? Collections.emptySet() - : exceptionWaybillIds.stream().filter(pageIds::contains).collect(Collectors.toSet()); - - Page voPage = new Page<>(entityPage.getCurrent(), entityPage.getSize(), entityPage.getTotal()); - voPage.setRecords(entityPage.getRecords().stream() - .map(w -> toCard(w, pageExceptionIds.contains(w.getId()))) - .toList()); - return voPage; + // 组织类型 / 异常 / 创建时间都是子单行级条件,与状态、关键字、分页一起交给运次读服务, + // 一次取数取完整趟车:分组、分档、筛选、排序与分页都在它那边算完 + TripQueryVO query = TripQueryVO.forAdmin(); + query.setDeptIds(manageableDeptIds()); + query.setStatus(toTripStatus(status)); + query.setKeyword(keyword); + query.setTransportOrgType(transportType); + query.setException(exception); + query.setExceptionWaybillIds(exceptionWaybillIds); + query.setCreatedFrom(parseDayStart(startDate)); + query.setCreatedTo(parseDayEnd(endDate)); + query.setCurrent(pageNo); + query.setSize(pageSize); + IPage trips = tripQueryService.page(query); + return tripCardPage(trips.getRecords(), pageNo, pageSize, trips.getTotal(), exceptionWaybillIds); } @Override - public AdminWaybillDetailVO detail(Long id) { + public AdminWaybillDetailVO detail(String tripId) { + Long id = parseId(tripId); if (id == null) { - throw new org.springblade.core.log.exception.ServiceException("运单ID不能为空"); + throw new ServiceException("运次不存在或无权操作"); } + TripVO trip = visibleTrip(tripId); + if (trip != null) { + return tripDetail(trip); + } + // 已拒单的子运单按运次取不到(运次口径不含已拒子单),此时按运单主键回落,供重新派单等从运单进入的场景使用 Waybill waybill = waybillService.getById(id); - if (waybill == null || Objects.equals(waybill.getIsDeleted(), 1)) { - throw new org.springblade.core.log.exception.ServiceException("运单不存在"); + if (waybill != null && !Objects.equals(waybill.getIsDeleted(), 1)) { + return waybillDetail(waybill); } - boolean hasException = false; - Long exceptionId = null; - ExceptionDisposal latest = exceptionDisposalService.getOne(Wrappers.lambdaQuery() - .eq(ExceptionDisposal::getWaybillId, id) - .in(ExceptionDisposal::getDisposalStatus, DISPOSAL_PENDING, DISPOSAL_PROCESSING) - .orderByDesc(ExceptionDisposal::getReportTime) - .orderByDesc(ExceptionDisposal::getCreateTime) - .last("LIMIT 1")); - if (latest != null) { - hasException = true; - exceptionId = latest.getId(); + LoadingManage loading = loadingManageService.getById(id); + if (loading == null) { + throw new ServiceException("运次不存在或无权操作"); } - return toDetail(waybill, hasException, exceptionId); + return loadingDetail(loading, visibleChildren(loading.getLoadingNo())); } @Override @@ -202,32 +222,521 @@ public class ManageWaybillServiceImpl implements IManageWaybillService { int pageNo = current == null || current < 1 ? 1 : current; int pageSize = size == null || size < 1 ? DEFAULT_PAGE_SIZE : Math.min(size, MAX_PAGE_SIZE); - LambdaQueryWrapper wrapper = scopedWaybillQuery() - .in(Waybill::getBusinessStatus, STATUS_PENDING, STATUS_RUNNING); if (Boolean.TRUE.equals(needReassign)) { - wrapper.eq(Waybill::getDriverAcceptStatus, ACCEPT_REJECTED); - } else if (Boolean.FALSE.equals(needReassign)) { - wrapper.and(w -> w.isNull(Waybill::getDriverAcceptStatus) - .or().ne(Waybill::getDriverAcceptStatus, ACCEPT_REJECTED)); + // 待重新派单必须按「子单被拒」找:运次口径把已拒子单排除在外,整单拒绝后这趟车在运次里已经不存在 + return reassignPendingPage(pageNo, pageSize, keyword); } - applyKeywordFilter(wrapper, keyword); - wrapper.orderByDesc(Waybill::getUpdateTime).orderByDesc(Waybill::getCreateTime); - IPage entityPage = waybillService.page(new Page<>(pageNo, pageSize), wrapper); Set exceptionWaybillIds = loadIncompleteExceptionWaybillIds(); - List pageIds = entityPage.getRecords().stream() - .map(Waybill::getId) - .filter(Objects::nonNull) - .toList(); - Set pageExceptionIds = pageIds.isEmpty() - ? Collections.emptySet() - : exceptionWaybillIds.stream().filter(pageIds::contains).collect(Collectors.toSet()); + TripQueryVO query = TripQueryVO.forAdmin(); + query.setDeptIds(manageableDeptIds()); + query.setKeyword(keyword); + // 待处理只要待接单与进行中两档;「未拒单」也是交给运次读服务按子单行筛 + query.setStatuses(List.of(0, 1)); + query.setExcludeRejectedTrips(Boolean.FALSE.equals(needReassign)); + query.setCurrent(pageNo); + query.setSize(pageSize); + IPage trips = tripQueryService.page(query); + return tripCardPage(trips.getRecords(), pageNo, pageSize, trips.getTotal(), exceptionWaybillIds); + } - Page voPage = new Page<>(entityPage.getCurrent(), entityPage.getSize(), entityPage.getTotal()); - voPage.setRecords(entityPage.getRecords().stream() - .map(w -> toCard(w, pageExceptionIds.contains(w.getId()))) - .toList()); - return voPage; + private Page emptyCardPage(int pageNo, int pageSize) { + Page page = new Page<>(pageNo, pageSize, 0); + page.setRecords(List.of()); + return page; + } + + /** + * 运次卡片页:一次算清这一页的重派标记与异常,再逐条拼卡片 + */ + private Page tripCardPage(List trips, int pageNo, int pageSize, long total, + Set exceptionWaybillIds) { + List cards = new ArrayList<>(trips.size()); + if (!trips.isEmpty()) { + Set reassignKeys = loadReassignTripKeys( + trips.stream().map(this::tripKeyOf).collect(Collectors.toCollection(LinkedHashSet::new))); + for (TripVO trip : trips) { + cards.add(toTripCard(trip, exceptionWaybillIds, reassignKeys)); + } + } + Page page = new Page<>(pageNo, pageSize, total); + page.setRecords(cards); + return page; + } + + /** + * 运次卡片:展示单号、整趟车起/经/终与站点数、货物汇总、计划时间都取运次字段。 + * 旧字段 id / waybillNo 保留运单语义(未配载就是这张运单,配载取第一张可见子运单), + * 打卡订正、重新派单等按运单进入的入口仍靠它们。 + */ + private AdminWaybillCardVO toTripCard(TripVO trip, Set exceptionWaybillIds, + Set reassignTripIds) { + AdminWaybillCardVO card = new AdminWaybillCardVO(); + List children = trip.getWaybills() == null ? List.of() : trip.getWaybills(); + Long firstChildId = children.isEmpty() ? null : parseId(children.get(0).getId()); + boolean loaded = Func.isNotEmpty(trip.getLoadingNo()); + + card.setTripId(trip.getId()); + card.setTripNo(Func.toStr(trip.getTripNo(), "")); + card.setLoadingNo(Func.toStr(trip.getLoadingNo(), "")); + card.setId(firstChildId == null ? parseId(trip.getId()) : firstChildId); + card.setWaybillNo(children.isEmpty() ? card.getTripNo() : Func.toStr(children.get(0).getWaybillNo(), "")); + card.setWaybillCount(trip.getWaybillCount()); + card.setTransportMode(Func.toStr(trip.getTransportType(), "")); + card.setTransportType(loaded ? TRANSPORT_LOAD : TRANSPORT_COMMON); + card.setFromName(Func.toStrWithEmpty(trip.getDepartureName(), Func.toStr(trip.getDepartureAddress(), ""))); + card.setToName(Func.toStrWithEmpty(trip.getArrivalName(), Func.toStr(trip.getArrivalAddress(), ""))); + card.setViaName(Func.toStr(trip.getTransitName(), "")); + card.setStationCount(trip.getStationCount()); + card.setCargo(Func.toStr(trip.getCargoSummary(), "")); + card.setCargoSummary(Func.toStr(trip.getCargoSummary(), "")); + String weight = formatWeight(trip.getTotalQuantity(), trip.getQuantityUnit()); + card.setWeight(weight); + card.setTotalWeight(weight); + // 计划时间两条路同源:配载运次取配载单,未配载运次取那张运单自己的 + card.setPlanTime(formatLocalDate(trip.getEstimatedStartDate())); + card.setPlanTimeEnd(formatLocalDate(trip.getEstimatedEndDate())); + card.setStatus(trip.getStatus()); + card.setDriverName(Func.toStr(trip.getDriverName(), "")); + card.setVehicleNo(Func.toStr(trip.getVehicleNo(), "")); + card.setCreateTime(formatDateTime(trip.getSortTime())); + card.setNeedReassign(reassignTripIds.contains(tripKeyOf(trip))); + card.setHasException(hasChildException(children.stream().map(TripVO.TripWaybillVO::getId).toList(), exceptionWaybillIds)); + card.setBuyerPaid(false); + return card; + } + + /** 这一页里带「子单被拒」标记的运次主键:判据与重派待办一致(已拒单 + 未取消未完成) */ + private Set loadReassignTripKeys(Set tripIds) { + if (Func.isEmpty(tripIds)) { + return Set.of(); + } + // 被拒子单一次取回后在服务层换算运次键 + return waybillService.list(pendingReassignQuery().select(Waybill::getId, Waybill::getLoadingNo)) + .stream() + .map(this::tripKeyOf) + .filter(tripIds::contains) + .collect(Collectors.toSet()); + } + + private boolean hasRejectedChild(String tripId) { + return Func.isNotEmpty(tripId) && !loadReassignTripKeys(Set.of(tripId)).isEmpty(); + } + + /** 运次里有没有未完成的异常:异常仍是运单级的事实,落到运次上按「任一子单有」算 */ + private boolean hasChildException(Collection childIds, Set exceptionWaybillIds) { + if (exceptionWaybillIds.isEmpty() || childIds.isEmpty()) { + return false; + } + return childIds.stream().map(this::parseId).filter(Objects::nonNull).anyMatch(exceptionWaybillIds::contains); + } + + /** 小程序 status → 运次状态:0待接单 / 1进行中 / 2已完成 */ + private Integer toTripStatus(String status) { + if (Func.isBlank(status)) { + return null; + } + return switch (status.trim()) { + case "0", STATUS_PENDING -> 0; + case "1", STATUS_RUNNING, "transporting", "doing" -> 1; + case "2", STATUS_COMPLETED, "done" -> 2; + case "3", STATUS_CANCELLED -> 3; + default -> null; + }; + } + + private Date parseDayStart(String date) { + if (Func.isBlank(date)) { + return null; + } + return DateUtil.parse(date.trim() + " 00:00:00", DateUtil.PATTERN_DATETIME); + } + + private Date parseDayEnd(String date) { + if (Func.isBlank(date)) { + return null; + } + return DateUtil.parse(date.trim() + " 23:59:59", DateUtil.PATTERN_DATETIME); + } + + private Long parseId(String value) { + if (Func.isEmpty(value)) { + return null; + } + try { + return Long.valueOf(value.trim()); + } catch (NumberFormatException ignored) { + return null; + } + } + + /** 运次的键:配载取配载单号,未配载取 W + 运单主键,与运次读服务同一个口径 */ + private String tripKeyOf(Waybill waybill) { + return waybill == null ? null : TripAggregation.keyOf(waybill.getLoadingNo(), waybill.getId()); + } + + /** 运次的键:同上,运次卡片取运次主键换算 */ + private String tripKeyOf(TripVO trip) { + return trip == null ? null : TripAggregation.keyOf(trip.getLoadingNo(), parseId(trip.getId())); + } + + /* =========================================================================== + * 详情:按运次展示;已拒单等取不到运次的场景回落成运单视图 + * =========================================================================*/ + + /** 按运次主键取可见运次;取不到(已拒子单、不是运次主键)返回空,交给运单级回落 */ + private TripVO visibleTrip(String tripId) { + try { + TripQueryVO query = TripQueryVO.forAdmin(); + query.setTripId(tripId); + return tripQueryService.detail(query); + } catch (ServiceException ignored) { + return null; + } + } + + /** + * 运次详情:与司机端接单页同一口径(单号取配载单号,站点 ①②③ 与每站货物条目都带运单号)。 + * 打卡节点取第一张可见子运单:管理端打卡订正仍按运单走,逐子单的打卡视图不在运次这一层。 + */ + private AdminWaybillDetailVO tripDetail(TripVO trip) { + List children = trip.getWaybills() == null ? List.of() : trip.getWaybills(); + Long firstChildId = children.isEmpty() ? null : parseId(children.get(0).getId()); + List stations = trip.getStations() == null ? List.of() : trip.getStations(); + List scopeIds = new ArrayList<>(); + children.forEach(child -> { + Long childId = parseId(child.getId()); + if (childId != null) { + scopeIds.add(childId); + } + }); + if (scopeIds.isEmpty() && parseId(trip.getId()) != null) { + scopeIds.add(parseId(trip.getId())); + } + + AdminWaybillDetailVO detail = new AdminWaybillDetailVO(); + detail.setId(firstChildId == null ? parseId(trip.getId()) : firstChildId); + detail.setWaybillNo(children.isEmpty() ? Func.toStr(trip.getTripNo(), "") : Func.toStr(children.get(0).getWaybillNo(), "")); + detail.setStatus(trip.getStatus()); + detail.setTransportMode(Func.toStr(trip.getTransportType(), "")); + detail.setTransportType(toTransportTypeLabel(Func.toStr(trip.getTransportType(), ""))); + detail.setTransportOrgType(Func.isEmpty(trip.getLoadingNo()) ? TRANSPORT_COMMON : TRANSPORT_LOAD); + detail.setTripId(trip.getId()); + detail.setLoadingNo(Func.toStr(trip.getLoadingNo(), "")); + detail.setTripNo(Func.toStr(trip.getTripNo(), "")); + detail.setViaName(Func.toStr(trip.getTransitName(), "")); + detail.setStationCount(trip.getStationCount()); + detail.setCargoSummary(Func.toStr(trip.getCargoSummary(), "")); + String weight = formatWeight(trip.getTotalQuantity(), trip.getQuantityUnit()); + detail.setCargoName(Func.toStr(trip.getCargoName(), "")); + detail.setCargoNames(trip.getCargoNames() == null ? List.of() : trip.getCargoNames()); + detail.setCargoQuantity(weight); + detail.setWeight(weight); + detail.setTotalWeight(weight); + String fromAddr = Func.toStr(trip.getDepartureAddress(), ""); + String toAddr = Func.toStr(trip.getArrivalAddress(), ""); + detail.setFromName(Func.toStrWithEmpty(trip.getDepartureName(), fromAddr)); + detail.setToName(Func.toStrWithEmpty(trip.getArrivalName(), toAddr)); + detail.setFromAddress(Func.toStrWithEmpty(fromAddr, detail.getFromName())); + detail.setToAddress(Func.toStrWithEmpty(toAddr, detail.getToName())); + detail.setPickupAddress(detail.getFromAddress()); + detail.setUnloadAddress(detail.getToAddress()); + detail.setPlanShipTime(formatLocalDate(trip.getEstimatedStartDate())); + detail.setPlanFinishTime(formatLocalDate(trip.getEstimatedEndDate())); + detail.setCarrierName(""); + detail.setDriverName(Func.toStr(trip.getDriverName(), "")); + detail.setDriverPhone(Func.toStr(trip.getDriverPhone(), "")); + detail.setVehicleNo(Func.toStr(trip.getVehicleNo(), "")); + detail.setRemark(Func.toStr(trip.getTaskRemark(), "")); + detail.setNeedReassign(hasRejectedChild(tripKeyOf(trip))); + detail.setAcceptStatus(Func.toStr(trip.getAcceptStatus(), "")); + detail.setRejectReason(""); + detail.setStations(stations); + detail.setRoutePoints(toRoutePoints(stations)); + fillException(detail, scopeIds); + fillPunchSnapshot(detail, firstChildId); + return detail; + } + + /** + * 运单主键回落:重新派单、异常处置等按运单进入的场景仍按运单展示; + * 它属于配载单时补上整趟车的展示字段(配载单号、站点 ①②③、计划时间),与列表和司机端同一口径。 + */ + private AdminWaybillDetailVO waybillDetail(Waybill waybill) { + Set scopeIds = new LinkedHashSet<>(); + scopeIds.add(waybill.getId()); + AdminWaybillDetailVO detail = toDetail(waybill, false, null); + if (Func.isEmpty(waybill.getLoadingNo())) { + fillException(detail, scopeIds); + return detail; + } + List children = visibleChildren(waybill.getLoadingNo()); + children.forEach(child -> scopeIds.add(child.getId())); + LoadingManage loading = findLoading(waybill.getLoadingNo()); + if (loading != null) { + // 整趟车的子单都被拒时组里为空,站点退回配载单自己的发货/途经/到货 + List stations = TripStationAssembler.assemble(loading, children); + detail.setTripId(String.valueOf(loading.getId())); + detail.setLoadingNo(loading.getLoadingNo()); + detail.setTripNo(loading.getLoadingNo()); + detail.setTransportOrgType(TRANSPORT_LOAD); + detail.setViaName(TripStationAssembler.transitName(stations)); + detail.setStationCount(stations.size()); + detail.setStations(stations); + detail.setPlanShipTime(formatLocalDate(loading.getEstimatedStartDate())); + detail.setPlanFinishTime(formatLocalDate(loading.getEstimatedEndDate())); + detail.setFromName(Func.toStrWithEmpty(TripStationAssembler.departureName(stations), detail.getFromName())); + detail.setToName(Func.toStrWithEmpty(TripStationAssembler.arrivalName(stations), detail.getToName())); + if (Func.isEmpty(detail.getDriverName())) { + detail.setDriverName(Func.toStr(loading.getDriverName(), "")); + } + if (Func.isEmpty(detail.getVehicleNo())) { + detail.setVehicleNo(Func.toStr(loading.getVehicleNo(), "")); + } + } + fillException(detail, scopeIds); + return detail; + } + + /** + * 按配载单主键取整趟车:整单被拒时子单为空,站点与计划时间仍按配载单展示 + */ + private AdminWaybillDetailVO loadingDetail(LoadingManage loading, List children) { + List stations = TripStationAssembler.assemble(loading, children); + List scopeIds = children.stream().map(Waybill::getId).filter(Objects::nonNull).toList(); + Waybill head = children.isEmpty() ? null : children.get(0); + + AdminWaybillDetailVO detail = new AdminWaybillDetailVO(); + detail.setId(head == null ? loading.getId() : head.getId()); + detail.setWaybillNo(head == null ? loading.getLoadingNo() : Func.toStr(head.getWaybillNo(), loading.getLoadingNo())); + detail.setStatus(TripAggregation.appStatus(tripBusinessStatus(children))); + detail.setTransportMode(Func.toStr(loading.getTransportType(), "")); + detail.setTransportType(toTransportTypeLabel(Func.toStr(loading.getTransportType(), ""))); + detail.setTransportOrgType(TRANSPORT_LOAD); + detail.setTripId(String.valueOf(loading.getId())); + detail.setLoadingNo(loading.getLoadingNo()); + detail.setTripNo(loading.getLoadingNo()); + detail.setViaName(TripStationAssembler.transitName(stations)); + detail.setStationCount(stations.size()); + detail.setCargoName(Func.toStrWithEmpty(loading.getCargoName(), head == null ? "" : Func.toStr(head.getCargoName(), ""))); + String weight = head == null ? "" : formatWeight(head.getQuantity(), head.getQuantityUnit()); + detail.setCargoQuantity(weight); + detail.setWeight(weight); + detail.setTotalWeight(weight); + detail.setFromName(TripStationAssembler.departureName(stations)); + detail.setToName(TripStationAssembler.arrivalName(stations)); + detail.setFromAddress(Func.toStr(loading.getDepartureAddress(), "")); + detail.setToAddress(Func.toStr(loading.getArrivalAddress(), "")); + detail.setPickupAddress(detail.getFromAddress()); + detail.setUnloadAddress(detail.getToAddress()); + detail.setPlanShipTime(formatLocalDate(loading.getEstimatedStartDate())); + detail.setPlanFinishTime(formatLocalDate(loading.getEstimatedEndDate())); + detail.setCarrierName(""); + detail.setDriverName(Func.toStr(loading.getDriverName(), "")); + detail.setDriverPhone(Func.toStr(loading.getDriverPhone(), "")); + detail.setVehicleNo(Func.toStr(loading.getVehicleNo(), "")); + detail.setRemark(Func.toStr(loading.getTaskRemark(), "")); + detail.setNeedReassign(true); + detail.setAcceptStatus(Func.toStr(TripAggregation.acceptStatus(children), "")); + detail.setRejectReason(""); + detail.setStations(stations); + detail.setRoutePoints(toRoutePoints(stations)); + fillException(detail, scopeIds); + fillPunchSnapshot(detail, head == null ? null : head.getId()); + return detail; + } + + /** 一组子运单的业务状态:全部终态取已完成/已取消,否则有待执行取待执行、其余进行中 */ + private String tripBusinessStatus(List children) { + if (children.isEmpty()) { + return null; + } + boolean requireAccept = TripAggregation.requiresAccept(children); + boolean accepted = WaybillProcessSupport.ACCEPT_ACCEPTED.equals(TripAggregation.acceptStatus(children)); + return TripAggregation.statusOf(children, requireAccept, accepted); + } + + private List visibleChildren(String loadingNo) { + if (Func.isEmpty(loadingNo)) { + return List.of(); + } + return waybillService.list(Wrappers.lambdaQuery() + .eq(Waybill::getLoadingNo, loadingNo) + .in(Waybill::getBusinessStatus, TripAggregation.CHILD_STATUSES) + .and(visible -> visible.isNull(Waybill::getDriverAcceptStatus) + .or().ne(Waybill::getDriverAcceptStatus, ACCEPT_REJECTED)) + .orderByAsc(Waybill::getCreateTime)); + } + + private LoadingManage findLoading(String loadingNo) { + if (Func.isEmpty(loadingNo)) { + return null; + } + return loadingManageService.getOne(Wrappers.lambdaQuery() + .eq(LoadingManage::getLoadingNo, loadingNo) + .last("LIMIT 1")); + } + + /** 未完成的异常处置:整趟车取最新一条,与旧单票详情同口径 */ + private void fillException(AdminWaybillDetailVO detail, Collection waybillIds) { + ExceptionDisposal latest = latestException(waybillIds); + detail.setHasException(latest != null); + detail.setExceptionId(latest == null ? null : latest.getId()); + } + + private ExceptionDisposal latestException(Collection waybillIds) { + if (Func.isEmpty(waybillIds)) { + return null; + } + return exceptionDisposalService.getOne(Wrappers.lambdaQuery() + .in(ExceptionDisposal::getWaybillId, waybillIds) + .in(ExceptionDisposal::getDisposalStatus, DISPOSAL_PENDING, DISPOSAL_PROCESSING) + .orderByDesc(ExceptionDisposal::getReportTime) + .orderByDesc(ExceptionDisposal::getCreateTime) + .last("LIMIT 1")); + } + + /** 打卡节点与途打卡记录复用司机端组装;管理端打卡订正按运单走,故传第一张可见子运单 */ + private void fillPunchSnapshot(AdminWaybillDetailVO detail, Long waybillId) { + if (waybillId == null) { + return; + } + DriverWaybillCardVO punch = driverWaybillService.detailPunchSnapshot(waybillId); + if (punch == null) { + return; + } + detail.setPunchNodes(punch.getPunchNodes()); + detail.setEnrouteRecords(punch.getEnrouteRecords()); + if (punch.getRoutePoints() != null && !punch.getRoutePoints().isEmpty()) { + detail.setRoutePoints(punch.getRoutePoints().stream().map(p -> { + AdminWaybillDetailVO.AdminRoutePointVO rp = new AdminWaybillDetailVO.AdminRoutePointVO(); + rp.setName(p.getName()); + rp.setAddress(p.getAddress()); + rp.setStatus(p.getStatus()); + return rp; + }).toList()); + } + } + + /** 点位列:整趟车的全部站点,站序即途经顺序 */ + private List toRoutePoints(List stations) { + if (Func.isEmpty(stations)) { + return List.of(); + } + return stations.stream().map(station -> { + AdminWaybillDetailVO.AdminRoutePointVO point = new AdminWaybillDetailVO.AdminRoutePointVO(); + point.setName(Func.toStr(station.getName(), "")); + point.setAddress(Func.toStrWithEmpty(station.getAddress(), point.getName())); + point.setStatus("pending"); + return point; + }).toList(); + } + + /** + * 待重新派单列表:按库层分组把「同一配载单被拒的多张子单」收成一条,未配载运单各自一条。 + * 分页与总数都在库层按运次完成,避免整单拒绝后同一趟车重复出现。 + */ + private IPage reassignPendingPage(int pageNo, int pageSize, String keyword) { + QueryWrapper wrapper = reassignGroupQuery(); + applyGroupKeyword(wrapper, keyword); + wrapper.orderByDesc(SQL_MAX_REJECT_TIME); + + Page rows = new Page<>(pageNo, pageSize); + waybillService.page(rows, wrapper); + + Set exceptionWaybillIds = loadIncompleteExceptionWaybillIds(); + Page page = new Page<>(pageNo, pageSize, rows.getTotal()); + page.setRecords(toReassignCards(rows.getRecords(), exceptionWaybillIds)); + return page; + } + + /** + * 库层按运次分组的「待重新派单」查询:一条即一个运次,段二不用再回查子单。 + * 展示用字段一律包聚合函数取组内代表值,既满足 ONLY_FULL_GROUP_BY,也让一条待办自带它要显示的信息。 + */ + private QueryWrapper reassignGroupQuery() { + // 分组键只在 TripAggregation 声明一次:库层分组与服务层分组的「运次」必须是同一个东西 + QueryWrapper wrapper = Wrappers.query(); + wrapper.select("MIN(id) AS id", "MIN(NULLIF(loading_no, '')) AS loading_no", "MIN(waybill_no) AS waybill_no", + "MIN(transport_type) AS transport_type", "MIN(business_status) AS business_status", + "MIN(departure_name) AS departure_name", "MIN(arrival_name) AS arrival_name", + "MIN(cargo_name) AS cargo_name", "MIN(quantity) AS quantity", "MIN(quantity_unit) AS quantity_unit", + "MIN(estimated_start_time) AS estimated_start_time", "MIN(estimated_end_time) AS estimated_end_time", + "MIN(driver_name) AS driver_name", "MIN(vehicle_no) AS vehicle_no", + "MIN(driver_reject_reason) AS driver_reject_reason", + SQL_MAX_REJECT_TIME + " AS driver_reject_time", SQL_MAX_CREATE_TIME + " AS create_time"); + wrapper.eq("driver_accept_status", ACCEPT_REJECTED); + wrapper.in("business_status", STATUS_PENDING, STATUS_RUNNING); + // 这条查询不经运次读服务,组织收口得自己带上,否则待重派列表与首页重派角标会出别的组织的单 + List deptIds = manageableDeptIds(); + if (Func.isNotEmpty(deptIds)) { + wrapper.in("dept_id", deptIds); + } + wrapper.groupBy(TripAggregation.SQL_TRIP_KEY); + return wrapper; + } + + /** 一条待办 = 一个运次:整趟车取配载单的单号与站点,未配载就是这张运单本身 */ + private List toReassignCards(List rows, Set exceptionWaybillIds) { + if (Func.isEmpty(rows)) { + return List.of(); + } + Set loadingNos = rows.stream().map(Waybill::getLoadingNo).filter(Func::isNotEmpty) + .collect(Collectors.toCollection(LinkedHashSet::new)); + Map loadings = loadingsByNo(loadingNos); + + List cards = new ArrayList<>(rows.size()); + for (Waybill row : rows) { + String loadingNo = Func.toStr(row.getLoadingNo(), ""); + AdminWaybillCardVO card = toCard(row, hasExceptionIds(row, exceptionWaybillIds)); + card.setTripId(String.valueOf(Func.isEmpty(loadingNo) ? row.getId() : loadingId(loadings.get(loadingNo), row.getId()))); + card.setTripNo(Func.isEmpty(loadingNo) ? Func.toStr(row.getWaybillNo(), "") : loadingNo); + card.setLoadingNo(loadingNo); + card.setWaybillCount(1); + card.setCargoSummary(Func.toStr(row.getCargoName(), "")); + card.setTotalWeight(formatWeight(row.getQuantity(), row.getQuantityUnit())); + card.setNeedReassign(true); + if (Func.isNotEmpty(loadingNo)) { + List stations = TripStationAssembler.assemble(loadings.get(loadingNo), List.of()); + card.setTransportType(TRANSPORT_LOAD); + card.setFromName(TripStationAssembler.departureName(stations)); + card.setToName(TripStationAssembler.arrivalName(stations)); + card.setViaName(TripStationAssembler.transitName(stations)); + card.setStationCount(stations.size()); + } else { + card.setTransportType(TRANSPORT_COMMON); + card.setStationCount(2); + } + card.setCreateTime(formatDateTime(row.getCreateTime())); + cards.add(card); + } + return cards; + } + + private Map loadingsByNo(Set loadingNos) { + if (loadingNos.isEmpty()) { + return Map.of(); + } + return loadingManageService.list(Wrappers.lambdaQuery().in(LoadingManage::getLoadingNo, loadingNos)) + .stream() + .collect(Collectors.toMap(LoadingManage::getLoadingNo, Function.identity(), (first, ignored) -> first)); + } + + private Long loadingId(LoadingManage loading, Long fallback) { + return loading == null || loading.getId() == null ? fallback : loading.getId(); + } + + /** 分组查询的关键字:运单号 / 司机 / 车牌,与列表关键字口径一致 */ + private void applyGroupKeyword(QueryWrapper wrapper, String keyword) { + if (Func.isBlank(keyword)) { + return; + } + String key = keyword.trim(); + wrapper.and(w -> w.like("waybill_no", key).or().like("driver_name", key).or().like("vehicle_no", key)); + } + + private boolean hasExceptionIds(Waybill waybill, Set exceptionWaybillIds) { + return waybill != null && waybill.getId() != null && exceptionWaybillIds.contains(waybill.getId()); } private AdminWaybillDetailVO toDetail(Waybill waybill, boolean hasException, Long exceptionId) { @@ -250,9 +759,12 @@ public class ManageWaybillServiceImpl implements IManageWaybillService { detail.setPickupAddress(fromAddr); detail.setUnloadAddress(toAddr); - String cargo = Func.toStr(waybill.getCargoName(), ""); - String weight = formatWeight(waybill.getQuantity(), waybill.getQuantityUnit()); - detail.setCargoName(cargo); + // 货名与合计重量取运单上录入的全部货物:主表 cargo_name 只是首行、quantity 是各行合计而单位只是首行的 + List cargos = WaybillCargoSupport.of(waybill); + List cargoNames = WaybillCargoSupport.cargoNames(cargos); + detail.setCargoName(String.join("、", cargoNames)); + detail.setCargoNames(cargoNames); + String weight = cargoWeight(cargos, formatWeight(waybill.getQuantity(), waybill.getQuantityUnit())); detail.setCargoQuantity(weight); detail.setWeight(weight); detail.setTotalWeight(weight); @@ -371,82 +883,33 @@ public class ManageWaybillServiceImpl implements IManageWaybillService { }; } - private long countWaybillByStatus(String status) { - return waybillService.count(Wrappers.lambdaQuery() - .eq(Waybill::getBusinessStatus, status)); + /** + * 调度端可见组织:普通用户(含各级角色)只出自己所属组织,超级管理员不过滤。 + *

+ * 小程序不校验「所属组织不能为空」:账号没有组织时不按组织收口,避免首页、待处理等接口直接报错。 + * 详情与重新派单本身不走这里。 + */ + private List manageableDeptIds() { + if (TransportBusinessSupport.isAdministrator()) { + return List.of(); + } + Long deptId = Func.firstLong(AuthUtil.getDeptId()); + if (Func.isEmpty(deptId) || deptId <= 0) { + return List.of(); + } + return List.of(deptId); } /** - * 小程序调度端不做组织过滤。 - * 「小程序管理」等账号 JWT/档案 dept 常与运单业务组织不一致,按 dept 过滤会导致统计全 0; - * 与后台 allDept=1 一致,仅依赖租户隔离(MyBatis-Plus TenantLine)。 + * 已拒子单的查询底:只有租户与逻辑删除(MyBatis-Plus 自动补),不带组织。 + *

+ * 它服务的是「这个运次里有没有被拒的子单」,可见范围由调用方先定好——列表传的运次已经收过组织, + * 详情按主键进入不收组织。这里再按组织收一次,详情里别的组织的运次会丢掉重派标记。 */ - private LambdaQueryWrapper scopedWaybillQuery() { + private LambdaQueryWrapper rejectedChildQuery() { return Wrappers.lambdaQuery(); } - private void applyStatusFilter(LambdaQueryWrapper wrapper, String status) { - String businessStatus = toBusinessStatus(status); - if (Func.isNotBlank(businessStatus)) { - wrapper.eq(Waybill::getBusinessStatus, businessStatus); - } - } - - private void applyExceptionFilter(LambdaQueryWrapper wrapper, String exception, Set exceptionWaybillIds) { - if (EXCEPTION_YES.equals(exception)) { - wrapper.in(Waybill::getId, exceptionWaybillIds); - } else if (EXCEPTION_NO.equals(exception) && !exceptionWaybillIds.isEmpty()) { - wrapper.notIn(Waybill::getId, exceptionWaybillIds); - } - } - - private void applyTransportTypeFilter(LambdaQueryWrapper wrapper, String transportType) { - if (TRANSPORT_LOAD.equals(transportType)) { - wrapper.isNotNull(Waybill::getLoadingNo).ne(Waybill::getLoadingNo, ""); - } else if (TRANSPORT_COMMON.equals(transportType)) { - wrapper.and(w -> w.isNull(Waybill::getLoadingNo).or().eq(Waybill::getLoadingNo, "")); - } - } - - private void applyKeywordFilter(LambdaQueryWrapper wrapper, String keyword) { - if (Func.isBlank(keyword)) { - return; - } - String key = keyword.trim(); - wrapper.and(w -> w.like(Waybill::getWaybillNo, key) - .or().like(Waybill::getDriverName, key) - .or().like(Waybill::getVehicleNo, key)); - } - - private void applyCreateTimeFilter(LambdaQueryWrapper wrapper, String startDate, String endDate) { - if (Func.isNotBlank(startDate)) { - Date start = DateUtil.parse(startDate.trim() + " 00:00:00", DateUtil.PATTERN_DATETIME); - if (start != null) { - wrapper.ge(Waybill::getCreateTime, start); - } - } - if (Func.isNotBlank(endDate)) { - Date end = DateUtil.parse(endDate.trim() + " 23:59:59", DateUtil.PATTERN_DATETIME); - if (end != null) { - wrapper.le(Waybill::getCreateTime, end); - } - } - } - - /** 小程序 status → 后端 businessStatus */ - private String toBusinessStatus(String status) { - if (Func.isBlank(status)) { - return null; - } - return switch (status.trim()) { - case "0", STATUS_PENDING -> STATUS_PENDING; - case "1", STATUS_RUNNING, "transporting", "doing" -> STATUS_RUNNING; - case "2", STATUS_COMPLETED, "done" -> STATUS_COMPLETED; - case "3", STATUS_CANCELLED -> STATUS_CANCELLED; - default -> null; - }; - } - private Set loadIncompleteExceptionWaybillIds() { List list = exceptionDisposalService.list(Wrappers.lambdaQuery() .select(ExceptionDisposal::getWaybillId) @@ -546,6 +1009,33 @@ public class ManageWaybillServiceImpl implements IManageWaybillService { return Func.isBlank(unit) ? qty : qty + unit; } + /** + * 重量文案:全部货物同单位时按货物行相加,单位不一致时不出合计 + *

+ * 主表 quantity 是各行数量的合计、quantity_unit 只是首行的单位,「20堆 + 50方」会变成「70堆」。 + * 拿明细算不出合计(有货物没填数量或单位)时退回主表那份汇总值,不让重量凭空消失。 + */ + private String cargoWeight(List cargos, String fallback) { + if (cargos.isEmpty()) { + return fallback; + } + String unit = null; + BigDecimal total = BigDecimal.ZERO; + for (WaybillCargoSupport.Cargo cargo : cargos) { + String cargoUnit = Func.toStr(cargo.quantityUnit(), ""); + if (cargo.quantity() == null || Func.isEmpty(cargoUnit)) { + return fallback; + } + if (unit == null) { + unit = cargoUnit; + } else if (!unit.equals(cargoUnit)) { + return ""; + } + total = total.add(cargo.quantity()); + } + return formatWeight(total, unit); + } + private String formatLocalDate(LocalDate date) { if (date == null) { return ""; @@ -570,13 +1060,20 @@ public class ManageWaybillServiceImpl implements IManageWaybillService { .eq(RiskDisposal::getDisposalStatus, RISK_PENDING)); } - /** 待重新派单:司机已拒单,且运单尚未取消/完成 */ + /** + * 待重新派单的运次数:整单拒绝会把一张配载单的多张子运单一起写成已拒绝,按运次键去重后只算一个。 + *

+ * 这里不走运次读服务:它的可见口径已排除被拒子单,整单拒绝的配载单在运次里根本不存在; + * 分组键用与运次读服务同一个表达式,两处的运次才是同一个东西。 + */ private long countPendingReassignments() { - return waybillService.count(pendingReassignQuery()); + Page rows = new Page<>(1, 1); + waybillService.page(rows, reassignGroupQuery()); + return rows.getTotal(); } private LambdaQueryWrapper pendingReassignQuery() { - return Wrappers.lambdaQuery() + return rejectedChildQuery() .eq(Waybill::getDriverAcceptStatus, ACCEPT_REJECTED) .in(Waybill::getBusinessStatus, STATUS_PENDING, STATUS_RUNNING); } @@ -611,11 +1108,17 @@ public class ManageWaybillServiceImpl implements IManageWaybillService { .collect(Collectors.toList()); } + /** + * 待重新派单的待办:一条 = 一个运次。 + *

+ * 整单拒绝会把一张配载单的多张子运单一起写成已拒绝,按运次键分组后同一趟车只出 1 条; + * 未配载运单自成运次,各自一条。排序与取值都在库层按运次完成,与待处理列表、角标同一口径。 + */ private List buildReassignFeed() { - List list = waybillService.list(pendingReassignQuery() - .orderByDesc(Waybill::getDriverRejectTime) + List rows = waybillService.list(reassignGroupQuery() + .orderByDesc(SQL_MAX_REJECT_TIME) .last("LIMIT " + FEED_LIMIT)); - return list.stream() + return rows.stream() .map(waybill -> new TimedTodo(toReassignTodoItem(waybill), rejectTimeOf(waybill))) .collect(Collectors.toList()); } @@ -649,7 +1152,8 @@ public class ManageWaybillServiceImpl implements IManageWaybillService { item.setTitle("重新派单待处理"); item.setTimeAgo(formatTimeAgo(rejectTimeOf(waybill))); item.setDesc(buildReassignDesc(waybill)); - item.setWaybillNo(Func.toStr(waybill.getWaybillNo(), "")); + // 一条待办即一个运次:有配载单的展示配载单号;跳转参数仍是运单主键,该页据此回显并直接重派 + item.setWaybillNo(Func.toStrWithEmpty(waybill.getLoadingNo(), Func.toStr(waybill.getWaybillNo(), ""))); item.setActionLabel("重新派单"); item.setTargetUrl(String.format(REASSIGN_TARGET_URL, waybill.getId())); return item; diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java index ba289e59..f9d256ef 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java @@ -73,6 +73,7 @@ import org.springblade.transport.mapper.PreSettlementMapper; import org.springblade.transport.mapper.ProjectApplyMapper; import org.springblade.transport.mapper.TemporaryCreditLimitMapper; import org.springblade.transport.pojo.dto.PaymentApplicationInvoiceRequest; +import org.springblade.transport.pojo.dto.PaymentApplicationSettlementLineRequest; import org.springblade.transport.pojo.dto.PaymentApplicationRecordRequest; import org.springblade.transport.pojo.dto.PaymentApplicationSaveRequest; import org.springblade.transport.pojo.dto.PaymentApplicationStatusRequest; @@ -99,7 +100,9 @@ import org.springblade.transport.pojo.vo.PaymentApplicationVO; import org.springblade.transport.service.IFormalSettlementService; import org.springblade.transport.service.IKingdeeBillLockService; import org.springblade.transport.service.IPaymentApplicationService; +import org.springblade.transport.service.ISettlementProjectAdvanceWriteOffService; import org.springblade.transport.service.KingdeeSyncContext; +import org.springblade.transport.pojo.vo.ProjectAdvanceWriteOffOptionVO; import org.springblade.transport.support.KingdeeApBillSyncSupport; import org.springblade.transport.support.KingdeeAttachmentSyncSupport; import org.springblade.transport.support.KingdeeSyncLockSupport; @@ -116,10 +119,13 @@ import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.HashMap; +import java.util.HashSet; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Set; import java.util.concurrent.TimeUnit; import java.util.function.Consumer; import java.util.stream.Collectors; @@ -161,6 +167,7 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpllambdaQuery() .eq(org.springblade.transport.pojo.entity.PaymentApplicationRecord::getPaymentApplicationId, id) .orderByDesc(org.springblade.transport.pojo.entity.PaymentApplicationRecord::getCreateTime))); - vo.setSettlements(settlementRelationMapper.selectList(Wrappers.lambdaQuery() + List settlements = settlementRelationMapper.selectList(Wrappers.lambdaQuery() .eq(PaymentApplicationSettlement::getPaymentApplicationId, id) .eq(PaymentApplicationSettlement::getIsDeleted, 0) - .orderByAsc(PaymentApplicationSettlement::getCreateTime))); + .orderByAsc(PaymentApplicationSettlement::getCreateTime)); + for (PaymentApplicationSettlement relation : settlements) { + if (relation.getPreSettlementId() != null) { + relation.setPayableAmount(payableOfPreSettlement(relation.getPreSettlementId(), relation.getSettlementAmount(), id) + .setScale(2, RoundingMode.HALF_UP)); + } else if (relation.getFormalSettlementId() != null) { + relation.setPayableAmount(payableOfFormalSettlement(relation.getFormalSettlementId(), relation.getSettlementAmount(), id) + .setScale(2, RoundingMode.HALF_UP)); + } + } + vo.setSettlements(settlements); return vo; } @@ -302,13 +321,29 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl projectAdvanceWriteOffOptions(Long contractId, + String excludeSettlementType, + Long excludeSettlementId) { + return projectAdvanceWriteOffService.listOptions(contractId, excludeSettlementType, excludeSettlementId); + } + @Override @Transactional(rollbackFor = Exception.class) public Long saveDraft(PaymentApplicationSaveRequest request) { @@ -342,16 +377,28 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl preSettlementIds = resolvePreSettlementIds(request); + if (request.getPreSettlementId() == null && !preSettlementIds.isEmpty()) { + request.setPreSettlementId(preSettlementIds.get(0)); + } fillReference(entity, request); List formalSettlements = resolveFormalSettlements(request, entity); if (formalSettlements.size() > 1) fillFormalAggregateReference(entity, formalSettlements); + List preSettlements = resolvePreSettlements(preSettlementIds); + if (preSettlements.size() > 1) fillPreAggregateReference(entity, preSettlements); fillBillLedger(entity, request); validateQuota(entity); saveOrUpdate(entity); + // not_null 更新策略不会把 settlementId 的 null 写回库,进度预付需显式清掉误存的正式结算单 ID + clearDetachedFormalReference(entity); + // 汇票付款在生成申请时即占用可用余额;编辑时按差额调整,避免重复扣减 + syncBillOccupation(entity); invoiceMapper.delete(Wrappers.lambdaQuery() .eq(PaymentApplicationInvoice::getPaymentApplicationId, entity.getId())); int lineNo = 1; @@ -382,12 +429,22 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl 0 ? "matched" : "unmatched"); entity.setPaidAmount(paidAmount); updateById(entity); - saveSettlementRelations(entity, formalSettlements); + saveSettlementRelations(entity, formalSettlements, request.getSettlementLines()); + savePreSettlementRelations(entity, preSettlements, request.getSettlementLines()); return entity.getId(); } @Override @Transactional(rollbackFor = Exception.class) - public void removeDraft(Long id) { PaymentApplication entity = editable(id); invoiceMapper.delete(Wrappers.lambdaQuery().eq(PaymentApplicationInvoice::getPaymentApplicationId, id)); recordMapper.delete(Wrappers.lambdaQuery().eq(PaymentApplicationRecord::getPaymentApplicationId, id)); settlementRelationMapper.delete(Wrappers.lambdaQuery().eq(PaymentApplicationSettlement::getPaymentApplicationId, id)); removeById(entity); } + public void removeDraft(Long id) { + PaymentApplication entity = editable(id); + if (isBillPayment(entity.getPaymentMethod())) { + releaseBillBalanceIfOccupied(entity); + } + invoiceMapper.delete(Wrappers.lambdaQuery().eq(PaymentApplicationInvoice::getPaymentApplicationId, id)); + recordMapper.delete(Wrappers.lambdaQuery().eq(PaymentApplicationRecord::getPaymentApplicationId, id)); + settlementRelationMapper.delete(Wrappers.lambdaQuery().eq(PaymentApplicationSettlement::getPaymentApplicationId, id)); + removeById(entity); + } @Override @Transactional(rollbackFor = Exception.class) public void submit(PaymentApplicationStatusRequest request) { @@ -402,6 +459,10 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl writeOffSettlementIds = this.projectAdvanceWriteOffService.deleteByPaymentApplicationIds(List.of(entity.getId())); + if (!writeOffSettlementIds.isEmpty()) { + formalSettlementService.refreshPaymentSummaries(writeOffSettlementIds); + } refreshFormalSettlement(entity); } @@ -432,7 +516,10 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl relations = settlementRelationMapper.selectList( + Wrappers.lambdaQuery() + .eq(PaymentApplicationSettlement::getPaymentApplicationId, entity.getId()) + .isNotNull(PaymentApplicationSettlement::getPreSettlementId) + .eq(PaymentApplicationSettlement::getIsDeleted, 0)); + if (relations.isEmpty()) { + PaymentApplicationSettlement single = new PaymentApplicationSettlement(); + single.setPreSettlementId(entity.getPreSettlementId()); + single.setAppliedAmount(entity.getAppliedAmount()); + relations = List.of(single); + } + BigDecimal remainingPaid = money(paidAmount); + for (int index = 0; index < relations.size(); index++) { + PaymentApplicationSettlement relation = relations.get(index); + BigDecimal paid = index == relations.size() - 1 ? remainingPaid + : money(relation.getAppliedAmount()).min(remainingPaid); + remainingPaid = remainingPaid.subtract(paid); + relation.setPaidAmount(paid); + syncOnePreSettlementAdvance(entity, relation, billStatus); + } + } + + /** + * 按预结算单逐单建档/更新预付记录:金蝶付款申请单生成成功(synced)即回显预付信息,付款完成回写(paid)更新为已付款 + * + * @param relation 预结算关联行,携带预结算单id、申请金额与本次分摊的已付金额 + */ + private void syncOnePreSettlementAdvance(PaymentApplication entity, PaymentApplicationSettlement relation, String billStatus) { + Long preSettlementId = relation.getPreSettlementId(); PreSettlementAdvance advance = preSettlementAdvanceMapper.selectList( Wrappers.lambdaQuery() - .eq(PreSettlementAdvance::getPreSettlementId, entity.getPreSettlementId()) + .eq(PreSettlementAdvance::getPreSettlementId, preSettlementId) .eq(PreSettlementAdvance::getIsDeleted, 0) .ne(PreSettlementAdvance::getBillStatus, TransportBusiConstant.VOIDED) .eq(PreSettlementAdvance::getAdvanceNo, entity.getPaymentNo()) .last("limit 1")).stream().findFirst().orElse(null); if (advance == null) { advance = preSettlementAdvanceMapper.selectList(Wrappers.lambdaQuery() - .eq(PreSettlementAdvance::getPreSettlementId, entity.getPreSettlementId()) + .eq(PreSettlementAdvance::getPreSettlementId, preSettlementId) .eq(PreSettlementAdvance::getIsDeleted, 0) .ne(PreSettlementAdvance::getBillStatus, TransportBusiConstant.VOIDED) .eq(PreSettlementAdvance::getPaidAmount, BigDecimal.ZERO) - .eq(PreSettlementAdvance::getAppliedAmount, money(entity.getAppliedAmount())) + .eq(PreSettlementAdvance::getAppliedAmount, money(relation.getAppliedAmount())) .orderByDesc(PreSettlementAdvance::getCreateTime).last("limit 1")).stream().findFirst().orElse(null); } if (advance == null) { advance = new PreSettlementAdvance(); - advance.setPreSettlementId(entity.getPreSettlementId()); + advance.setPreSettlementId(preSettlementId); advance.setAdvanceNo(entity.getPaymentNo()); - advance.setAppliedAmount(money(entity.getAppliedAmount())); + advance.setAppliedAmount(money(relation.getAppliedAmount())); } - advance.setPaidAmount(money(entity.getAppliedAmount())); - advance.setBillStatus("paid"); + advance.setPaidAmount(money(relation.getPaidAmount())); + advance.setBillStatus(billStatus); advance.setKingdeeAdvanceNo(entity.getKingdeeBillNo()); if (advance.getId() == null) preSettlementAdvanceMapper.insert(advance); else preSettlementAdvanceMapper.updateById(advance); - refreshPreSettlementAdvanceSummary(entity.getPreSettlementId()); + refreshPreSettlementAdvanceSummary(preSettlementId); formalSettlementSourceMapper.selectList(Wrappers.lambdaQuery() - .eq(FormalSettlementSource::getPreSettlementId, entity.getPreSettlementId()) + .eq(FormalSettlementSource::getPreSettlementId, preSettlementId) .eq(FormalSettlementSource::getIsDeleted, 0)).forEach(source -> { - source.setAdvanceAppliedAmount(findPreSettlementApplied(entity.getPreSettlementId())); - source.setAdvancePaidAmount(findPreSettlementPaid(entity.getPreSettlementId())); + source.setAdvanceAppliedAmount(findPreSettlementApplied(preSettlementId)); + source.setAdvancePaidAmount(findPreSettlementPaid(preSettlementId)); formalSettlementSourceMapper.updateById(source); }); - formalSettlementService.refreshPaymentSummariesForPreSettlement(entity.getPreSettlementId()); + formalSettlementService.refreshPaymentSummariesForPreSettlement(preSettlementId); + } + + /** + * 联动作废付款申请关联的预结算预付记录(付款申请作废、金蝶反审核解锁场景): + * 仅清理提前建档的未付记录,已付款记录不联动(付款完成的事实不回退),避免残留记录阻断预结算作废 + */ + private void voidPreSettlementAdvance(PaymentApplication entity) { + if (entity.getPreSettlementId() == null) return; + if (!List.of("progress_advance", "settlement_payment").contains(entity.getPaymentType())) return; + // 多选预结算单的预付记录按关联行逐单作废;单选回退单据上的预结算 + List preSettlementIds = new ArrayList<>(List.of(entity.getPreSettlementId())); + settlementRelationMapper.selectList(Wrappers.lambdaQuery() + .eq(PaymentApplicationSettlement::getPaymentApplicationId, entity.getId()) + .isNotNull(PaymentApplicationSettlement::getPreSettlementId) + .eq(PaymentApplicationSettlement::getIsDeleted, 0)) + .stream().map(PaymentApplicationSettlement::getPreSettlementId).filter(Objects::nonNull).distinct() + .forEach(preSettlementIds::add); + preSettlementAdvanceMapper.update(null, Wrappers.lambdaUpdate() + .in(PreSettlementAdvance::getPreSettlementId, preSettlementIds) + .eq(PreSettlementAdvance::getAdvanceNo, entity.getPaymentNo()) + .eq(PreSettlementAdvance::getIsDeleted, 0) + .ne(PreSettlementAdvance::getBillStatus, "paid") + .set(PreSettlementAdvance::getBillStatus, TransportBusiConstant.VOIDED)); + preSettlementIds.forEach(this::refreshPreSettlementAdvanceSummary); } private BigDecimal findPreSettlementPaid(Long preSettlementId) { @@ -1684,11 +1847,14 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl 0)) throw new ServiceException("付款比例必须在0-100之间"); if (request.getAppliedAmount() == null || request.getAppliedAmount().compareTo(BigDecimal.ZERO) <= 0) throw new ServiceException("申请付款金额必须大于0"); if (!"project_advance".equals(request.getPaymentType()) && request.getSettlementId() == null - && request.getPreSettlementId() == null && Func.isEmpty(request.getSettlementIds())) throw new ServiceException("非项目预付必须关联结算单"); + && request.getPreSettlementId() == null && Func.isEmpty(request.getSettlementIds()) + && Func.isEmpty(request.getPreSettlementIds())) throw new ServiceException("非项目预付必须关联结算单"); if ("project_advance".equals(request.getPaymentType()) && request.getProjectId() == null) throw new ServiceException("项目预付必须选择所属项目"); - if ("progress_advance".equals(request.getPaymentType()) && request.getPreSettlementId() == null) throw new ServiceException("进度预付必须关联预结算单"); + if ("progress_advance".equals(request.getPaymentType()) && request.getPreSettlementId() == null + && Func.isEmpty(request.getPreSettlementIds())) throw new ServiceException("进度预付必须关联预结算单"); if (TransportBusiConstant.PAYMENT_TYPE_SETTLEMENT.equals(request.getPaymentType()) && request.getSettlementId() == null - && Func.isEmpty(request.getSettlementIds()) && request.getPreSettlementId() == null) { + && Func.isEmpty(request.getSettlementIds()) && request.getPreSettlementId() == null + && Func.isEmpty(request.getPreSettlementIds())) { throw new ServiceException("尾款付款必须关联正式结算单或预结算单"); } } @@ -1745,31 +1911,201 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpllambdaUpdate() + .eq(PaymentApplication::getId, entity.getId()) + .setSql("settlement_id = null, settlement_no = null")); + entity.setSettlementId(null); + entity.setSettlementNo(null); + } + + /** + * 进度预付驳回重提时,请求里的结算主键可能是失效正式结算单或精度失真后的 ID。 + * 依次用预结算主键、正式结算位上的预结算主键、单据已保存的预结算主键和预结算单号找回。 + */ + private Long resolveProgressPreSettlementId(PaymentApplicationSaveRequest request, PaymentApplication entity) { + List candidates = new java.util.ArrayList<>(); + if (request.getPreSettlementId() != null) { + candidates.add(request.getPreSettlementId()); + } + if (request.getPreSettlementIds() != null) { + request.getPreSettlementIds().stream().filter(Objects::nonNull).forEach(candidates::add); + } + if (request.getSettlementId() != null) { + candidates.add(request.getSettlementId()); + } + if (request.getSettlementIds() != null) { + request.getSettlementIds().stream().filter(Objects::nonNull).forEach(candidates::add); + } + if (entity != null && entity.getPreSettlementId() != null) { + candidates.add(entity.getPreSettlementId()); + } + for (Long candidate : candidates) { + PreSettlement preSettlement = preSettlementMapper.selectById(candidate); + if (preSettlement != null && !Objects.equals(preSettlement.getIsDeleted(), 1)) { + return preSettlement.getId(); + } + } + String preSettlementNo = request.getPreSettlementNo(); + if (Func.isEmpty(preSettlementNo) && entity != null) { + preSettlementNo = entity.getPreSettlementNo(); + } + if (Func.isNotEmpty(preSettlementNo)) { + String settlementNo = preSettlementNo.split("、")[0].trim(); + PreSettlement preSettlement = preSettlementMapper.selectOne(Wrappers.lambdaQuery() + .eq(PreSettlement::getPreSettlementNo, settlementNo) + .eq(PreSettlement::getIsDeleted, 0) + .last("limit 1")); + if (preSettlement != null) { + return preSettlement.getId(); + } + } + return null; + } + private void fillReference(PaymentApplication entity, PaymentApplicationSaveRequest request) { - if ("project_advance".equals(request.getPaymentType())) { + if ("progress_advance".equals(request.getPaymentType())) { + Long preSettlementId = resolveProgressPreSettlementId(request, entity); + request.setPreSettlementId(preSettlementId); + if (preSettlementId == null) { + throw new ServiceException("进度预付必须关联预结算单"); + } + request.setSettlementId(null); + request.setSettlementIds(null); + fillPreSettlementReference(entity, requireApprovedPreSettlement(request.getPreSettlementId())); + } else if ("project_advance".equals(request.getPaymentType())) { ProjectApply project = projectApplyMapper.selectById(request.getProjectId()); if (project == null || Objects.equals(project.getIsDeleted(), 1)) throw new ServiceException("所属项目不存在"); entity.setSettlementId(null); entity.setSettlementNo(null); entity.setPreSettlementId(null); entity.setPreSettlementNo(null); entity.setProjectId(request.getProjectId()); entity.setProjectName(request.getProjectName()); entity.setDeptId(request.getDeptId()); entity.setDeptName(request.getDeptName()); entity.setContractId(request.getContractId()); entity.setContractNo(request.getContractNo()); entity.setContractName(request.getContractName()); entity.setPayerName(request.getPayerName()); entity.setPayeeName(request.getPayeeName()); entity.setSettlementAmount(request.getSettlementAmount()); entity.setPayableAmount(money(project.getFundDemand()).multiply(BigDecimal.valueOf(10000))); entity.setBillType(Func.isEmpty(project.getSettlementMode()) ? null : "项目预付"); } else if (request.getSettlementId() != null) { FormalSettlement source = formalSettlementMapper.selectById(request.getSettlementId()); - if (source == null) throw new ServiceException("正式结算单不存在"); - if (!TransportBusiConstant.APPROVED.equals(source.getApprovalStatus())) throw new ServiceException("只能选择审批通过、未作废的正式结算单"); - entity.setSettlementId(source.getId()); entity.setSettlementNo(source.getFormalSettlementNo()); entity.setPreSettlementId(null); entity.setPreSettlementNo(null); - entity.setProjectId(source.getProjectId()); entity.setProjectName(source.getProjectName()); entity.setDeptId(source.getDeptId()); entity.setDeptName(source.getDeptName()); entity.setContractId(source.getContractId()); entity.setContractNo(source.getContractNo()); entity.setContractName(source.getContractName()); entity.setPayerName(source.getPayerName()); entity.setPayeeName(source.getPayeeName()); entity.setSettlementAmount(source.getSettlementAmount()); entity.setPayableAmount(money(source.getSettlementAmount()).subtract(cumulativeFormalSettlementApplied(source.getId(), entity.getId()))); entity.setBillType("正式结算单"); + if (source == null || Objects.equals(source.getIsDeleted(), 1)) { + // 驳回重提等场景:settlementId 可能误存为预结算单 ID,降级走预结算分支 + PreSettlement preSettlement = preSettlementMapper.selectById(request.getSettlementId()); + if (preSettlement != null && !Objects.equals(preSettlement.getIsDeleted(), 1)) { + request.setPreSettlementId(preSettlement.getId()); + request.setSettlementId(null); + fillPreSettlementReference(entity, preSettlement); + } else if (request.getPreSettlementId() != null) { + request.setSettlementId(null); + fillPreSettlementReference(entity, requireApprovedPreSettlement(request.getPreSettlementId())); + } else if (entity.getPreSettlementId() != null) { + request.setSettlementId(null); + request.setPreSettlementId(entity.getPreSettlementId()); + fillPreSettlementReference(entity, requireApprovedPreSettlement(entity.getPreSettlementId())); + } else { + throw new ServiceException("正式结算单不存在"); + } + } else { + if (!TransportBusiConstant.APPROVED.equals(source.getApprovalStatus())) throw new ServiceException("只能选择审批通过、未作废的正式结算单"); + entity.setSettlementId(source.getId()); entity.setSettlementNo(source.getFormalSettlementNo()); entity.setPreSettlementId(null); entity.setPreSettlementNo(null); + entity.setProjectId(source.getProjectId()); entity.setProjectName(source.getProjectName()); entity.setDeptId(source.getDeptId()); entity.setDeptName(source.getDeptName()); entity.setContractId(source.getContractId()); entity.setContractNo(source.getContractNo()); entity.setContractName(source.getContractName()); entity.setPayerName(source.getPayerName()); entity.setPayeeName(source.getPayeeName()); entity.setSettlementAmount(source.getSettlementAmount()); entity.setPayableAmount(payableOfFormalSettlement(source.getId(), source.getSettlementAmount(), entity.getId())); entity.setBillType("正式结算单"); + } } else if (request.getPreSettlementId() != null) { - PreSettlement source = preSettlementMapper.selectById(request.getPreSettlementId()); - if (source == null) throw new ServiceException("预结算单不存在"); - if (!TransportBusiConstant.APPROVED.equals(source.getApprovalStatus())) throw new ServiceException("只能选择审批通过、未作废的预结算单"); - entity.setPreSettlementId(source.getId()); entity.setPreSettlementNo(source.getPreSettlementNo()); entity.setSettlementId(null); entity.setSettlementNo(null); entity.setProjectId(source.getProjectId()); entity.setProjectName(source.getProjectName()); entity.setDeptId(source.getDeptId()); entity.setDeptName(source.getDeptName()); entity.setContractId(source.getContractId()); entity.setContractNo(source.getContractNo()); entity.setContractName(source.getContractName()); entity.setPayerName(source.getPayerName()); entity.setPayeeName(source.getPayeeName()); entity.setSettlementAmount(source.getSettlementAmount()); entity.setPayableAmount(money(source.getSettlementAmount()).subtract(cumulativePreSettlementApplied(source.getId(), entity.getId()))); entity.setBillType("预结算单"); + fillPreSettlementReference(entity, requireApprovedPreSettlement(request.getPreSettlementId())); } else { entity.setProjectId(request.getProjectId()); entity.setProjectName(request.getProjectName()); entity.setDeptId(request.getDeptId()); entity.setDeptName(request.getDeptName()); entity.setContractId(request.getContractId()); entity.setContractNo(request.getContractNo()); entity.setContractName(request.getContractName()); entity.setPayerName(request.getPayerName()); entity.setPayeeName(request.getPayeeName()); entity.setSettlementAmount(request.getSettlementAmount()); entity.setPayableAmount(request.getPayableAmount()); entity.setBillType(request.getBillType()); } - if (!hasMultipleFormalReferences(request) + if (!hasMultipleFormalReferences(request) && !hasMultiplePreReferences(request) && money(entity.getAppliedAmount()).compareTo(money(entity.getPayableAmount())) > 0 && money(entity.getPayableAmount()).compareTo(BigDecimal.ZERO) > 0) throw new ServiceException("申请付款金额不能超过可付款金额"); } + private PreSettlement requireApprovedPreSettlement(Long preSettlementId) { + PreSettlement source = preSettlementMapper.selectById(preSettlementId); + if (source == null || Objects.equals(source.getIsDeleted(), 1)) { + throw new ServiceException("预结算单不存在"); + } + if (!TransportBusiConstant.APPROVED.equals(source.getApprovalStatus())) { + throw new ServiceException("只能选择审批通过、未作废的预结算单"); + } + return source; + } + + private void fillPreSettlementReference(PaymentApplication entity, PreSettlement source) { + entity.setPreSettlementId(source.getId()); + entity.setPreSettlementNo(source.getPreSettlementNo()); + entity.setSettlementId(null); + entity.setSettlementNo(null); + entity.setProjectId(source.getProjectId()); + entity.setProjectName(source.getProjectName()); + entity.setDeptId(source.getDeptId()); + entity.setDeptName(source.getDeptName()); + entity.setContractId(source.getContractId()); + entity.setContractNo(source.getContractNo()); + entity.setContractName(source.getContractName()); + entity.setPayerName(source.getPayerName()); + entity.setPayeeName(source.getPayeeName()); + entity.setSettlementAmount(source.getSettlementAmount()); + entity.setPayableAmount(payableOfPreSettlement(source.getId(), source.getSettlementAmount(), entity.getId())); + entity.setBillType("预结算单"); + } + private List resolveFormalSettlements(PaymentApplicationSaveRequest request, PaymentApplication entity) { if (!TransportBusiConstant.PAYMENT_TYPE_SETTLEMENT.equals(request.getPaymentType())) return List.of(); List ids = request.getSettlementIds() == null ? List.of() : request.getSettlementIds(); @@ -1797,7 +2133,7 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl money(item.getSettlementAmount()).subtract(cumulativeFormalSettlementApplied(item.getId(), entity.getId())).max(BigDecimal.ZERO)) + .map(item -> payableOfFormalSettlement(item.getId(), item.getSettlementAmount(), entity.getId())) .reduce(BigDecimal.ZERO, BigDecimal::add); if (money(entity.getAppliedAmount()).compareTo(payableAmount) > 0) throw new ServiceException("申请付款金额不能超过所选结算单可付款金额合计"); FormalSettlement first = settlements.get(0); @@ -1807,20 +2143,33 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl settlements) { + private void saveSettlementRelations(PaymentApplication entity, List settlements, + List lines) { if (settlements.isEmpty()) return; // 关系唯一索引不含 is_deleted,逻辑删除会残留旧行导致重存冲突,此处必须物理删除 settlementRelationMapper.deleteByPaymentApplicationId(entity.getId()); - BigDecimal totalBase = settlements.stream().map(item -> money(item.getSettlementAmount()).subtract( - cumulativeFormalSettlementApplied(item.getId(), entity.getId())).max(BigDecimal.ZERO)) + boolean customAmounts = settlements.size() > 1 && lines != null && !lines.isEmpty(); + BigDecimal totalBase = settlements.stream() + .map(item -> payableOfFormalSettlement(item.getId(), item.getSettlementAmount(), entity.getId())) .reduce(BigDecimal.ZERO, BigDecimal::add); BigDecimal remainingApplied = money(entity.getAppliedAmount()); BigDecimal remainingPaid = money(entity.getPaidAmount()); + if (customAmounts) { + BigDecimal lineTotal = BigDecimal.ZERO; + for (FormalSettlement settlement : settlements) { + BigDecimal payable = payableOfFormalSettlement(settlement.getId(), settlement.getSettlementAmount(), entity.getId()); + BigDecimal applied = this.specifiedAppliedAmount(lines, settlement.getId(), null); + this.assertAppliedWithinPayable(settlement.getFormalSettlementNo(), applied, payable); + lineTotal = lineTotal.add(applied); + } + this.assertLineTotalMatches(entity, lineTotal); + } for (int index = 0; index < settlements.size(); index++) { FormalSettlement settlement = settlements.get(index); - BigDecimal base = money(settlement.getSettlementAmount()).subtract( - cumulativeFormalSettlementApplied(settlement.getId(), entity.getId())).max(BigDecimal.ZERO); - BigDecimal applied = index == settlements.size() - 1 ? remainingApplied + BigDecimal base = payableOfFormalSettlement(settlement.getId(), settlement.getSettlementAmount(), entity.getId()); + BigDecimal applied = customAmounts + ? this.specifiedAppliedAmount(lines, settlement.getId(), null) + : index == settlements.size() - 1 || totalBase.compareTo(BigDecimal.ZERO) == 0 ? remainingApplied : money(entity.getAppliedAmount()).multiply(base).divide(totalBase, 2, RoundingMode.DOWN); BigDecimal paid = index == settlements.size() - 1 ? remainingPaid : money(entity.getPaidAmount()).multiply(applied).divide(money(entity.getAppliedAmount()).max(BigDecimal.ONE), 2, RoundingMode.DOWN); @@ -1832,6 +2181,156 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl resolvePreSettlementIds(PaymentApplicationSaveRequest request) { + if (!List.of("progress_advance", TransportBusiConstant.PAYMENT_TYPE_SETTLEMENT).contains(request.getPaymentType())) { + return List.of(); + } + boolean formalSelected = TransportBusiConstant.PAYMENT_TYPE_SETTLEMENT.equals(request.getPaymentType()) + && ((request.getSettlementIds() != null && request.getSettlementIds().stream().anyMatch(Objects::nonNull)) + || request.getSettlementId() != null) + && Func.isEmpty(request.getPreSettlementIds()); + if (formalSelected) { + return List.of(); + } + List ids = request.getPreSettlementIds() == null ? List.of() : request.getPreSettlementIds(); + ids = ids.stream().filter(Objects::nonNull).distinct().toList(); + if (ids.isEmpty() && request.getPreSettlementId() != null) { + return List.of(request.getPreSettlementId()); + } + return ids; + } + + private boolean hasMultiplePreReferences(PaymentApplicationSaveRequest request) { + return request.getPreSettlementIds() != null + && request.getPreSettlementIds().stream().filter(Objects::nonNull).distinct().count() > 1; + } + + private List resolvePreSettlements(List ids) { + if (ids.isEmpty()) { + return List.of(); + } + List settlements = ids.stream().map(preSettlementMapper::selectById).toList(); + if (settlements.stream().anyMatch(item -> item == null || Objects.equals(item.getIsDeleted(), 1) + || !TransportBusiConstant.APPROVED.equals(item.getApprovalStatus()))) { + throw new ServiceException("只能选择审批通过、未作废的预结算单"); + } + if (settlements.size() > 1) { + Long contractId = settlements.get(0).getContractId(); + if (contractId == null || settlements.stream().anyMatch(item -> !Objects.equals(contractId, item.getContractId()))) { + throw new ServiceException("多选预结算单必须属于同一合同"); + } + } + return settlements; + } + + private void fillPreAggregateReference(PaymentApplication entity, List settlements) { + BigDecimal settlementAmount = settlements.stream().map(PreSettlement::getSettlementAmount).map(this::money) + .reduce(BigDecimal.ZERO, BigDecimal::add).setScale(2, RoundingMode.HALF_UP); + BigDecimal payableAmount = settlements.stream() + .map(item -> payableOfPreSettlement(item.getId(), item.getSettlementAmount(), entity.getId())) + .reduce(BigDecimal.ZERO, BigDecimal::add).setScale(2, RoundingMode.HALF_UP); + if (money(entity.getAppliedAmount()).compareTo(payableAmount) > 0) { + throw new ServiceException("申请付款金额不能超过所选预结算单可付款金额合计"); + } + PreSettlement first = settlements.get(0); + String preSettlementNo = settlements.stream().map(PreSettlement::getPreSettlementNo).filter(Objects::nonNull) + .collect(Collectors.joining("、")); + entity.setPreSettlementId(first.getId()); + entity.setPreSettlementNo(preSettlementNo.length() > 500 ? preSettlementNo.substring(0, 500) : preSettlementNo); + entity.setSettlementId(null); + entity.setSettlementNo(null); + entity.setSettlementAmount(settlementAmount); + entity.setPayableAmount(payableAmount); + entity.setBillType("预结算单"); + } + + private void savePreSettlementRelations(PaymentApplication entity, List settlements, + List lines) { + settlementRelationMapper.delete(Wrappers.lambdaQuery() + .eq(PaymentApplicationSettlement::getPaymentApplicationId, entity.getId()) + .isNotNull(PaymentApplicationSettlement::getPreSettlementId)); + List targetSettlements = settlements; + if (targetSettlements.isEmpty() && entity.getPreSettlementId() != null) { + PreSettlement single = preSettlementMapper.selectById(entity.getPreSettlementId()); + if (single != null && !Objects.equals(single.getIsDeleted(), 1)) { + targetSettlements = List.of(single); + } + } + if (targetSettlements.isEmpty()) { + return; + } + // 单选/多选均落关联行,生成即占用可付款额度(删除草稿或审核不通过后释放) + boolean customAmounts = targetSettlements.size() > 1 && lines != null && !lines.isEmpty(); + BigDecimal totalBase = targetSettlements.stream() + .map(item -> payableOfPreSettlement(item.getId(), item.getSettlementAmount(), entity.getId())) + .reduce(BigDecimal.ZERO, BigDecimal::add); + BigDecimal remainingApplied = money(entity.getAppliedAmount()); + if (customAmounts) { + BigDecimal lineTotal = BigDecimal.ZERO; + for (PreSettlement settlement : targetSettlements) { + BigDecimal payable = payableOfPreSettlement(settlement.getId(), settlement.getSettlementAmount(), entity.getId()); + BigDecimal applied = this.specifiedAppliedAmount(lines, null, settlement.getId()); + this.assertAppliedWithinPayable(settlement.getPreSettlementNo(), applied, payable); + lineTotal = lineTotal.add(applied); + } + this.assertLineTotalMatches(entity, lineTotal); + } + for (int index = 0; index < targetSettlements.size(); index++) { + PreSettlement settlement = targetSettlements.get(index); + BigDecimal base = payableOfPreSettlement(settlement.getId(), settlement.getSettlementAmount(), entity.getId()); + BigDecimal applied = customAmounts + ? this.specifiedAppliedAmount(lines, null, settlement.getId()) + : index == targetSettlements.size() - 1 || totalBase.compareTo(BigDecimal.ZERO) == 0 + ? remainingApplied + : money(entity.getAppliedAmount()).multiply(base).divide(totalBase, 2, RoundingMode.DOWN); + PaymentApplicationSettlement relation = new PaymentApplicationSettlement(); + relation.setPaymentApplicationId(entity.getId()); + relation.setPreSettlementId(settlement.getId()); + relation.setPreSettlementNo(settlement.getPreSettlementNo()); + relation.setSettlementAmount(money(settlement.getSettlementAmount()).setScale(2, RoundingMode.HALF_UP)); + relation.setAppliedAmount(applied.setScale(2, RoundingMode.HALF_UP)); + relation.setPaidAmount(BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP)); + settlementRelationMapper.insert(relation); + remainingApplied = remainingApplied.subtract(applied); + } + } + + private BigDecimal specifiedAppliedAmount(List lines, + Long formalSettlementId, Long preSettlementId) { + if (lines == null) { + throw new ServiceException("请填写每张结算单的付款金额"); + } + for (PaymentApplicationSettlementLineRequest line : lines) { + if (line == null) { + continue; + } + boolean matched = formalSettlementId != null + ? Objects.equals(formalSettlementId, line.getFormalSettlementId()) + : Objects.equals(preSettlementId, line.getPreSettlementId()); + if (matched && line.getAppliedAmount() != null) { + return money(line.getAppliedAmount()).setScale(2, RoundingMode.HALF_UP); + } + } + throw new ServiceException("请填写每张结算单的付款金额"); + } + + private void assertAppliedWithinPayable(String settlementNo, BigDecimal appliedAmount, BigDecimal payableAmount) { + if (money(appliedAmount).compareTo(BigDecimal.ZERO) < 0) { + throw new ServiceException("付款金额不能小于0"); + } + if (money(appliedAmount).compareTo(money(payableAmount)) > 0) { + String label = Func.isEmpty(settlementNo) ? "结算单" : settlementNo; + throw new ServiceException(label + "付款金额不能超过可付款金额"); + } + } + + private void assertLineTotalMatches(PaymentApplication entity, BigDecimal lineTotal) { + BigDecimal appliedAmount = money(entity.getAppliedAmount()).setScale(2, RoundingMode.HALF_UP); + if (lineTotal.setScale(2, RoundingMode.HALF_UP).compareTo(appliedAmount) != 0) { + throw new ServiceException("申请付款金额须等于各结算单付款金额合计"); + } + } + private void validateQuota(PaymentApplication entity) { if (!"project_advance".equals(entity.getPaymentType())) return; BigDecimal appliedAmount = money(entity.getAppliedAmount()); @@ -1869,10 +2368,24 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl wrapper) { - return list(wrapper.ne(PaymentApplication::getApprovalStatus, TransportBusiConstant.VOIDED)).stream() + return list(wrapper).stream() + .filter(item -> !releasedOccupiedPayment(item)) .map(PaymentApplication::getAppliedAmount).map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add); } + /** + * 已释放占用的付款申请:作废、审核不通过(驳回)不再占用可付款金额; + * 删除草稿由逻辑删除自然释放。生成草稿/审批中/审批通过均占用。 + */ + private boolean releasedOccupiedPayment(PaymentApplication payment) { + if (payment == null || Objects.equals(payment.getIsDeleted(), 1)) { + return true; + } + String status = payment.getApprovalStatus(); + return TransportBusiConstant.VOIDED.equals(status) + || TransportBusiConstant.RETURNED.equals(status); + } + private BigDecimal cumulativeAppliedAmount(String paymentType, Long referenceId, Long excludeId) { if ("progress_advance".equals(paymentType)) { return cumulativePreSettlementApplied(referenceId, excludeId); @@ -1883,17 +2396,165 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl paymentIds = new LinkedHashSet<>(); + if (preSettlementReference) { + List relations = settlementRelationMapper.selectList( + Wrappers.lambdaQuery() + .eq(PaymentApplicationSettlement::getPreSettlementId, referenceId) + .eq(PaymentApplicationSettlement::getIsDeleted, 0)); + for (PaymentApplicationSettlement relation : relations) { + if (relation.getPaymentApplicationId() != null) { + paymentIds.add(relation.getPaymentApplicationId()); + } + } + list(Wrappers.lambdaQuery() + .eq(PaymentApplication::getPreSettlementId, referenceId) + .ne(PaymentApplication::getApprovalStatus, TransportBusiConstant.VOIDED)) + .forEach(payment -> paymentIds.add(payment.getId())); + } else { + List relations = settlementRelationMapper.selectList( + Wrappers.lambdaQuery() + .eq(PaymentApplicationSettlement::getFormalSettlementId, referenceId) + .eq(PaymentApplicationSettlement::getIsDeleted, 0)); + for (PaymentApplicationSettlement relation : relations) { + if (relation.getPaymentApplicationId() != null) { + paymentIds.add(relation.getPaymentApplicationId()); + } + } + list(Wrappers.lambdaQuery() + .eq(PaymentApplication::getSettlementId, referenceId) + .ne(PaymentApplication::getApprovalStatus, TransportBusiConstant.VOIDED)) + .forEach(payment -> paymentIds.add(payment.getId())); + } + if (paymentIds.isEmpty()) { + return 1; + } + long priorCount = listByIds(paymentIds.stream().toList()).stream() + .filter(payment -> !releasedOccupiedPayment(payment)) + .filter(payment -> !Objects.equals(payment.getId(), excludeId)) + .count(); + return (int) priorCount + 1; + } + private BigDecimal cumulativeFormalSettlementApplied(Long settlementId, Long excludeId) { - return sumApplied(Wrappers.lambdaQuery() + // 多选正式结算时按关联行累计占用;单选兼容主表 settlementId + List relations = settlementRelationMapper.selectList( + Wrappers.lambdaQuery() + .eq(PaymentApplicationSettlement::getFormalSettlementId, settlementId) + .eq(PaymentApplicationSettlement::getIsDeleted, 0)); + Set coveredPaymentIds = new HashSet<>(); + BigDecimal relationAmount = BigDecimal.ZERO; + if (!relations.isEmpty()) { + Map payments = listByIds(relations.stream() + .map(PaymentApplicationSettlement::getPaymentApplicationId).filter(Objects::nonNull).distinct().toList()) + .stream().collect(Collectors.toMap(PaymentApplication::getId, item -> item, (left, right) -> left)); + for (PaymentApplicationSettlement relation : relations) { + PaymentApplication payment = payments.get(relation.getPaymentApplicationId()); + if (releasedOccupiedPayment(payment) || Objects.equals(payment.getId(), excludeId)) { + if (payment != null) { + coveredPaymentIds.add(payment.getId()); + } + continue; + } + coveredPaymentIds.add(payment.getId()); + relationAmount = relationAmount.add(money(relation.getAppliedAmount())); + } + } + BigDecimal legacyAmount = sumApplied(Wrappers.lambdaQuery() .eq(PaymentApplication::getSettlementId, settlementId) - .ne(excludeId != null, PaymentApplication::getId, excludeId)); + .ne(excludeId != null, PaymentApplication::getId, excludeId) + .notIn(!coveredPaymentIds.isEmpty(), PaymentApplication::getId, coveredPaymentIds)); + return relationAmount.add(legacyAmount); } private BigDecimal cumulativePreSettlementApplied(Long preSettlementId, Long excludeId) { - // 进度预付、尾款付款关联同一预结算单时累计占用可付款额度 - return sumApplied(Wrappers.lambdaQuery() + // 进度预付、尾款付款关联同一预结算单时累计占用可付款额度;按关联行申请金额累计,兼容历史仅写主表的单据 + List relations = settlementRelationMapper.selectList( + Wrappers.lambdaQuery() + .eq(PaymentApplicationSettlement::getPreSettlementId, preSettlementId) + .eq(PaymentApplicationSettlement::getIsDeleted, 0)); + Set coveredPaymentIds = new HashSet<>(); + BigDecimal relationAmount = BigDecimal.ZERO; + if (!relations.isEmpty()) { + Map payments = listByIds(relations.stream() + .map(PaymentApplicationSettlement::getPaymentApplicationId).filter(Objects::nonNull).distinct().toList()) + .stream().collect(Collectors.toMap(PaymentApplication::getId, item -> item, (left, right) -> left)); + for (PaymentApplicationSettlement relation : relations) { + PaymentApplication payment = payments.get(relation.getPaymentApplicationId()); + if (releasedOccupiedPayment(payment) || Objects.equals(payment.getId(), excludeId)) { + if (payment != null) { + coveredPaymentIds.add(payment.getId()); + } + continue; + } + coveredPaymentIds.add(payment.getId()); + relationAmount = relationAmount.add(money(relation.getAppliedAmount())); + } + } + BigDecimal legacyAmount = list(Wrappers.lambdaQuery() .eq(PaymentApplication::getPreSettlementId, preSettlementId) - .ne(excludeId != null, PaymentApplication::getId, excludeId)); + .ne(excludeId != null, PaymentApplication::getId, excludeId) + .notIn(!coveredPaymentIds.isEmpty(), PaymentApplication::getId, coveredPaymentIds)).stream() + .filter(item -> !releasedOccupiedPayment(item)) + .map(PaymentApplication::getAppliedAmount).map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add); + return relationAmount.add(legacyAmount); + } + + /** + * 正式结算可付款 = 结算金额 − 已申请付款 − 项目预付核销 − 来源预结算进度预付占用。 + * 进度预付占用与正式结算台账 remainingPayable 口径对齐,避免转正式后仍可按全额申请尾款。 + */ + private BigDecimal payableOfFormalSettlement(Long settlementId, BigDecimal settlementAmount, Long excludeId) { + return money(settlementAmount) + .subtract(cumulativeFormalSettlementApplied(settlementId, excludeId)) + .subtract(projectAdvanceWriteOffService.sumBySettlement( + TransportBusiConstant.SETTLEMENT_BILL_FORMAL, settlementId)) + .subtract(formalSettlementAdvanceOccupied(settlementId)) + .max(BigDecimal.ZERO); + } + + /** + * 正式结算来源预结算的进度预付占用合计:优先取来源行快照,快照为空时回退预结算实时已申请/已付。 + */ + private BigDecimal formalSettlementAdvanceOccupied(Long settlementId) { + if (settlementId == null) { + return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP); + } + List sources = formalSettlementSourceMapper.selectList( + Wrappers.lambdaQuery() + .eq(FormalSettlementSource::getFormalSettlementId, settlementId) + .eq(FormalSettlementSource::getIsDeleted, 0)); + if (sources.isEmpty()) { + return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP); + } + BigDecimal total = BigDecimal.ZERO; + for (FormalSettlementSource source : sources) { + BigDecimal occupied = money(source.getAdvanceAppliedAmount()).max(money(source.getAdvancePaidAmount())); + if (occupied.compareTo(BigDecimal.ZERO) <= 0 && source.getPreSettlementId() != null) { + occupied = findPreSettlementApplied(source.getPreSettlementId()) + .max(findPreSettlementPaid(source.getPreSettlementId())); + } + total = total.add(occupied); + } + return total; + } + + /** 预结算可付款 = 结算金额 − 已申请付款 − 项目预付核销 */ + private BigDecimal payableOfPreSettlement(Long preSettlementId, BigDecimal settlementAmount, Long excludeId) { + return money(settlementAmount) + .subtract(cumulativePreSettlementApplied(preSettlementId, excludeId)) + .subtract(projectAdvanceWriteOffService.sumBySettlement( + TransportBusiConstant.SETTLEMENT_BILL_PRE, preSettlementId)) + .max(BigDecimal.ZERO); } private void fillBillLedger(PaymentApplication entity, PaymentApplicationSaveRequest request) { @@ -1903,7 +2564,8 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl 0) throw new ServiceException("申请付款金额超过汇票可用余额"); - if (!departmentAvailable(ledger, deptId)) throw new ServiceException("当前使用部门不在汇票可用部门范围内"); + BigDecimal available = money(ledger.getAvailableBalance()).add(occupiedAmount(paymentApplicationId, ledger.getId())); + if (money(amount).compareTo(available) > 0) throw new ServiceException("申请付款金额超过汇票可用余额"); + if (!partyMatched && !departmentAvailable(ledger, deptId)) throw new ServiceException("当前使用部门不在汇票可用部门范围内"); + } + + /** 出票单位、收票单位与结算付款方、收款方一致(顺序不限) */ + private boolean settlementPartiesMatch(BillLedger ledger, Long settlementId, Long preSettlementId) { + String payerName = null; + String payeeName = null; + if (settlementId != null) { + FormalSettlement settlement = formalSettlementMapper.selectById(settlementId); + if (settlement != null && !Objects.equals(settlement.getIsDeleted(), 1)) { + payerName = settlement.getPayerName(); + payeeName = settlement.getPayeeName(); + } + } + if ((payerName == null || payeeName == null) && preSettlementId != null) { + PreSettlement settlement = preSettlementMapper.selectById(preSettlementId); + if (settlement != null && !Objects.equals(settlement.getIsDeleted(), 1)) { + payerName = settlement.getPayerName(); + payeeName = settlement.getPayeeName(); + } + } + String issuer = ledger.getIssuerName() == null ? "" : ledger.getIssuerName().trim(); + String receiver = ledger.getReceiverName() == null ? "" : ledger.getReceiverName().trim(); + String payer = payerName == null ? "" : payerName.trim(); + String payee = payeeName == null ? "" : payeeName.trim(); + if (issuer.isEmpty() || receiver.isEmpty() || payer.isEmpty() || payee.isEmpty()) { + return false; + } + return (issuer.equals(payer) && receiver.equals(payee)) || (issuer.equals(payee) && receiver.equals(payer)); } private void useBillBalance(PaymentApplication entity) { BillLedger ledger = lockedBill(entity.getBillLedgerId()); - validateBill(ledger, entity.getDeptId(), entity.getAppliedAmount(), true); - BillLedgerUsage exists = billLedgerUsageMapper.selectOne(Wrappers.lambdaQuery() - .eq(BillLedgerUsage::getPaymentApplicationId, entity.getId()).last("FOR UPDATE")); - if (exists != null) throw new ServiceException("该付款申请已生成汇票使用记录"); + validateBill(ledger, entity.getDeptId(), entity.getAppliedAmount(), true, null, + entity.getSettlementId(), entity.getPreSettlementId()); + BillLedgerUsage exists = lockedBillUsage(entity.getId()); + if (exists != null && TransportBusiConstant.APPROVED.equals(exists.getUsageStatus())) { + throw new ServiceException("该付款申请已生成汇票使用记录"); + } BigDecimal amount = money(entity.getAppliedAmount()).setScale(2, RoundingMode.HALF_UP); - ledger.setAvailableBalance(money(ledger.getAvailableBalance()).subtract(amount)); + ledger.setAvailableBalance(money(ledger.getAvailableBalance()).subtract(amount).setScale(2, RoundingMode.HALF_UP)); + billLedgerMapper.updateById(ledger); + if (exists == null) { + BillLedgerUsage usage = new BillLedgerUsage(); + usage.setBillLedgerId(ledger.getId()); + usage.setPaymentApplicationId(entity.getId()); + usage.setApplicationNo(entity.getPaymentNo()); + usage.setUsedAmount(amount); + usage.setUseDeptId(entity.getDeptId()); + usage.setUseDeptName(entity.getDeptName()); + usage.setUsageStatus(TransportBusiConstant.APPROVED); + usage.setStatus(1); + billLedgerUsageMapper.insert(usage); + return; + } + exists.setBillLedgerId(ledger.getId()); + exists.setApplicationNo(entity.getPaymentNo()); + exists.setUsedAmount(amount); + exists.setUseDeptId(entity.getDeptId()); + exists.setUseDeptName(entity.getDeptName()); + exists.setUsageStatus(TransportBusiConstant.APPROVED); + billLedgerUsageMapper.updateById(exists); + } + + /** + * 汇票付款申请生成后占用汇票可用余额。同一申请重复保存时只调整差额; + * 更换汇票或改为非汇票付款时,先退回原占用。 + */ + private void syncBillOccupation(PaymentApplication entity) { + BillLedgerUsage usage = lockedBillUsage(entity.getId()); + boolean occupy = isBillPayment(entity.getPaymentMethod()) && entity.getBillLedgerId() != null; + if (!occupy) { + if (usage != null && TransportBusiConstant.APPROVED.equals(usage.getUsageStatus())) { + releaseUsage(usage); + } + return; + } + BigDecimal amount = money(entity.getAppliedAmount()).setScale(2, RoundingMode.HALF_UP); + if (usage != null && TransportBusiConstant.APPROVED.equals(usage.getUsageStatus()) + && Objects.equals(usage.getBillLedgerId(), entity.getBillLedgerId())) { + adjustOccupiedAmount(usage, entity, amount); + return; + } + if (usage != null && TransportBusiConstant.APPROVED.equals(usage.getUsageStatus())) { + releaseUsage(usage); + } + useBillBalance(entity); + } + + private void adjustOccupiedAmount(BillLedgerUsage usage, PaymentApplication entity, BigDecimal amount) { + BigDecimal delta = amount.subtract(money(usage.getUsedAmount())).setScale(2, RoundingMode.HALF_UP); + BillLedger ledger = lockedBill(usage.getBillLedgerId()); + if (delta.compareTo(BigDecimal.ZERO) > 0 && delta.compareTo(money(ledger.getAvailableBalance())) > 0) { + throw new ServiceException("申请付款金额超过汇票可用余额"); + } + BigDecimal available = money(ledger.getAvailableBalance()).subtract(delta).setScale(2, RoundingMode.HALF_UP); + if (available.compareTo(BigDecimal.ZERO) < 0) { + throw new ServiceException("申请付款金额超过汇票可用余额"); + } + BigDecimal faceAmount = money(ledger.getFaceAmount()); + if (available.compareTo(faceAmount) > 0) { + available = faceAmount; + } + ledger.setAvailableBalance(available); billLedgerMapper.updateById(ledger); - BillLedgerUsage usage = new BillLedgerUsage(); - usage.setBillLedgerId(ledger.getId()); - usage.setPaymentApplicationId(entity.getId()); usage.setApplicationNo(entity.getPaymentNo()); usage.setUsedAmount(amount); usage.setUseDeptId(entity.getDeptId()); usage.setUseDeptName(entity.getDeptName()); - usage.setUsageStatus(TransportBusiConstant.APPROVED); - usage.setStatus(1); - billLedgerUsageMapper.insert(usage); + billLedgerUsageMapper.updateById(usage); + } + + private BigDecimal occupiedAmount(Long paymentApplicationId, Long billLedgerId) { + if (paymentApplicationId == null || billLedgerId == null) { + return BigDecimal.ZERO; + } + BillLedgerUsage usage = activeBillUsage(paymentApplicationId); + if (usage == null || !Objects.equals(usage.getBillLedgerId(), billLedgerId)) { + return BigDecimal.ZERO; + } + return money(usage.getUsedAmount()); + } + + private BillLedgerUsage activeBillUsage(Long paymentApplicationId) { + if (paymentApplicationId == null) { + return null; + } + return billLedgerUsageMapper.selectOne(Wrappers.lambdaQuery() + .eq(BillLedgerUsage::getPaymentApplicationId, paymentApplicationId) + .eq(BillLedgerUsage::getUsageStatus, TransportBusiConstant.APPROVED) + .last("limit 1")); + } + + private BillLedgerUsage lockedBillUsage(Long paymentApplicationId) { + if (paymentApplicationId == null) { + return null; + } + return billLedgerUsageMapper.selectOne(Wrappers.lambdaQuery() + .eq(BillLedgerUsage::getPaymentApplicationId, paymentApplicationId) + .last("limit 1 FOR UPDATE")); } private void releaseBillBalance(PaymentApplication entity) { @@ -1949,9 +2736,23 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpllambdaQuery() + .eq(BillLedgerUsage::getPaymentApplicationId, entity.getId()) + .eq(BillLedgerUsage::getUsageStatus, TransportBusiConstant.APPROVED).last("FOR UPDATE")); + if (usage == null) { + return; + } + releaseUsage(usage); + } + + private void releaseUsage(BillLedgerUsage usage) { BillLedger ledger = lockedBill(usage.getBillLedgerId()); ledger.setAvailableBalance(money(ledger.getAvailableBalance()).add(money(usage.getUsedAmount())) - .min(money(ledger.getFaceAmount()))); + .min(money(ledger.getFaceAmount())).setScale(2, RoundingMode.HALF_UP)); billLedgerMapper.updateById(ledger); usage.setUsageStatus("released"); billLedgerUsageMapper.updateById(usage); @@ -2000,8 +2801,14 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpllambdaQuery().likeRight(PaymentApplication::getPaymentNo, prefix)) + 1); } + /** + * 生成当日付款申请单号;按最大流水号 +1,且计入逻辑删除数据,避免撞 uk_payment_application_no + */ + private synchronized String nextNo() { + String prefix = "FKD" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); + Long maxSequence = baseMapper.selectMaxDailySequence(AuthUtil.getTenantId(), prefix); + return prefix + String.format("%05d", (maxSequence == null ? 0L : maxSequence) + 1); + } private BigDecimal nonNegative(BigDecimal value, String name) { if (value == null || value.compareTo(BigDecimal.ZERO) < 0) throw new ServiceException(name + "不能小于0"); return value; } private BigDecimal money(BigDecimal value) { return value == null ? BigDecimal.ZERO : value; } private String limit(String value, int length) { if (value != null && value.length() > length) throw new ServiceException("备注不能超过" + length + "个字符"); return value; } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PreSettlementServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PreSettlementServiceImpl.java index a03ae549..f828826f 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PreSettlementServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PreSettlementServiceImpl.java @@ -36,6 +36,7 @@ import org.springblade.core.secure.utils.AuthUtil; import org.springblade.core.tool.jackson.JsonUtil; import org.springblade.core.tool.utils.BeanUtil; import org.springblade.core.tool.utils.Func; +import org.springblade.transport.constant.TransportBusiConstant; import org.springblade.transport.mapper.PreSettlementAdvanceMapper; import org.springblade.transport.mapper.PreSettlementChangeRecordMapper; import org.springblade.transport.mapper.PreSettlementDetailFeeMapper; @@ -67,6 +68,7 @@ import org.springblade.transport.service.IContractManageService; import org.springblade.transport.service.ICustomerArchiveService; import org.springblade.transport.service.IPreSettlementService; import org.springblade.transport.service.IProjectApplyService; +import org.springblade.transport.service.ISettlementProjectAdvanceWriteOffService; import org.springblade.transport.service.IWaybillService; import org.springblade.transport.wrapper.PreSettlementWrapper; import org.springblade.system.cache.UserCache; @@ -83,7 +85,6 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; -import java.util.Comparator; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.LinkedHashSet; @@ -124,6 +125,7 @@ public class PreSettlementServiceImpl extends BaseServiceImpl selectPage(IPage page, PreSettlementVO query) { @@ -163,7 +165,7 @@ public class PreSettlementServiceImpl extends BaseServiceImpl categoryNames = DictBizCache.getList("fee_category").stream() .collect(Collectors.toMap(DictBiz::getDictKey, DictBiz::getDictValue, (first, second) -> first, LinkedHashMap::new)); - org.springblade.core.tool.api.R> response = sysClient.getFeeItems(); + org.springblade.core.tool.api.R> response = sysClient.getFeeItems(false); if (response == null || !response.isSuccess() || response.getData() == null) { throw new ServiceException("费用项基础档案读取失败,请稍后重试"); } @@ -184,6 +186,7 @@ public class PreSettlementServiceImpl extends BaseServiceImpl details = listDetails(id); + fillDetailBillingTypes(details); vo.setDetails(details); List detailIds = details.stream().map(PreSettlementDetail::getId).toList(); Map> feeMap = Func.isEmpty(detailIds) ? new HashMap<>() @@ -198,14 +201,24 @@ public class PreSettlementServiceImpl extends BaseServiceImpl advances = listAdvances(id); advances.forEach(advance -> advance.setCreateUserName(UserCache.getUserRealName(advance.getCreateUser()))); vo.setAdvances(advances); + vo.setProjectAdvanceWriteOffs(projectAdvanceWriteOffService.listBySettlement( + TransportBusiConstant.SETTLEMENT_BILL_PRE, id)); vo.setChangeRecords(changeRecordMapper.selectList(Wrappers.lambdaQuery() .eq(PreSettlementChangeRecord::getPreSettlementId, id) .eq(PreSettlementChangeRecord::getIsDeleted, 0) + .and(wrapper -> wrapper.isNull(PreSettlementChangeRecord::getChangeContent) + .or() + .ne(PreSettlementChangeRecord::getChangeContent, "预结算审批通过")) .orderByDesc(PreSettlementChangeRecord::getChangeTime))); vo.setPrintTemplates(resolvePrintTemplates(settlement)); return vo; } + @Override + public String nextNo() { + return nextPreSettlementNo(); + } + @Override public List> contractOptions(String keyword, Long projectId) { LambdaQueryWrapper wrapper = Wrappers.lambdaQuery() @@ -297,7 +310,9 @@ public class PreSettlementServiceImpl extends BaseServiceImpl sourcePage = sourceDetailMapper.selectPage( new Page<>(page.getCurrent(), page.getSize()), wrapper); Page> result = new Page<>(sourcePage.getCurrent(), sourcePage.getSize(), sourcePage.getTotal()); - result.setRecords(sourcePage.getRecords().stream().map(this::candidateMap).toList()); + List> records = sourcePage.getRecords().stream().map(this::candidateMap).toList(); + fillCandidateBillingTypes(records); + result.setRecords(records); return result; } @@ -343,9 +358,17 @@ public class PreSettlementServiceImpl extends BaseServiceImpl summaryBefore = created ? new ArrayList<>() : listSummaryFees(settlement.getId()); rebuildSummaryFees(settlement.getId(), true); applySummaryRequest(settlement.getId(), contractChanged ? List.of() : request.getSummaryFees()); + recordSummaryFeeChanges(settlement.getId(), summaryBefore); refreshSettlementAmount(settlement); + projectAdvanceWriteOffService.replaceWriteOffs( + TransportBusiConstant.SETTLEMENT_BILL_PRE, + settlement.getId(), + settlement.getContractId(), + settlement.getSettlementAmount(), + request.getProjectAdvanceWriteOffs()); if (created) { saveChange(settlement.getId(), "结算单基本信息", null, "新增", "新增预结算单" + settlement.getPreSettlementNo(), ""); @@ -362,6 +385,7 @@ public class PreSettlementServiceImpl extends BaseServiceImpl existingMap = detailFees(detail.getId()).stream() .collect(Collectors.toMap(PreSettlementDetailFee::getId, Function.identity())); if (request.getRows().size() != existingMap.size()) { @@ -619,7 +647,6 @@ public class PreSettlementServiceImpl extends BaseServiceImpl beforeFeeItems = parseFeeItems(fee.getFeeItemsJson()); fee.setTransportQuantity(nonNegative(requestRow.getTransportQuantity(), "运输总量")); fee.setMileage(nonNegative(requestRow.getMileage(), "里程")); @@ -632,7 +659,6 @@ public class PreSettlementServiceImpl extends BaseServiceImpl> records) { + if (records == null || records.isEmpty()) { + return; + } + List detailIds = records.stream() + .map(item -> Func.toLong(item.get("id"))) + .filter(id -> id != null && id > 0) + .distinct() + .toList(); + if (detailIds.isEmpty()) { + return; + } + Map billingTypeMap = sourceFeeMapper.selectList( + Wrappers.lambdaQuery() + .in(ReceivablePayableCargoFee::getDetailId, detailIds) + .orderByAsc(ReceivablePayableCargoFee::getLineNo)) + .stream() + .filter(fee -> Func.isNotEmpty(fee.getBillingType()) && !"-".equals(fee.getBillingType())) + .collect(Collectors.groupingBy(ReceivablePayableCargoFee::getDetailId, + Collectors.mapping(ReceivablePayableCargoFee::getBillingType, + Collectors.collectingAndThen(Collectors.toCollection(LinkedHashSet::new), + values -> String.join("、", values))))); + for (Map record : records) { + Long detailId = Func.toLong(record.get("id")); + record.put("billingType", billingTypeMap.getOrDefault(detailId, "")); + } + } + private void synchronizeDetails(PreSettlement settlement, List requestedIds, boolean allowSourceMismatch) { List distinctIds = requestedIds.stream().filter(Objects::nonNull).distinct().toList(); List existingDetails = listDetails(settlement.getId()); @@ -984,13 +1043,30 @@ public class PreSettlementServiceImpl extends BaseServiceImpllambdaQuery() .eq(PreSettlementSummaryFee::getPreSettlementId, settlementId) .eq(PreSettlementSummaryFee::getManualFlag, 0)); - int lineNo = 1; - for (Map.Entry entry : aggregates.entrySet()) { + // 尽量沿用已有系统归集行的序号,避免重建后与手工费用交错顺序被打乱。 + List> orderedAggregates = new ArrayList<>(aggregates.entrySet()); + orderedAggregates.sort((left, right) -> { + PreSettlementSummaryFee leftOld = existingGenerated.get(left.getKey()); + PreSettlementSummaryFee rightOld = existingGenerated.get(right.getKey()); + Integer leftLineNo = leftOld == null ? null : leftOld.getLineNo(); + Integer rightLineNo = rightOld == null ? null : rightOld.getLineNo(); + if (leftLineNo == null && rightLineNo == null) { + return 0; + } + if (leftLineNo == null) { + return 1; + } + if (rightLineNo == null) { + return -1; + } + return Integer.compare(leftLineNo, rightLineNo); + }); + for (Map.Entry entry : orderedAggregates) { String[] keyParts = entry.getKey().split("\\|", 2); PreSettlementSummaryFee old = existingGenerated.get(entry.getKey()); PreSettlementSummaryFee row = new PreSettlementSummaryFee(); row.setPreSettlementId(settlementId); - row.setLineNo(lineNo++); + row.setLineNo(old == null || old.getLineNo() == null ? Integer.MAX_VALUE : old.getLineNo()); row.setFeeType(keyParts[0]); row.setFeeItem(keyParts.length > 1 ? keyParts[1] : ""); // 原金额是明细首次生成时的快照,多次调整后不得被当前结算金额覆盖。 @@ -1006,7 +1082,14 @@ public class PreSettlementServiceImpl extends BaseServiceImpl requestRows) { - if (requestRows == null) return; + if (requestRows == null) { + return; + } + // 空列表表示沿用 rebuildSummaryFees 结果(如更换合同后),不得再按空请求删光合计行。 + if (requestRows.isEmpty()) { + renumberSummaryFees(settlementId); + return; + } Map> allowedManualFees = new LinkedHashMap<>(); if (requestRows.stream().anyMatch(row -> Integer.valueOf(1).equals(row.getManualFlag()))) { for (Map option : feeOptions()) { @@ -1026,6 +1109,7 @@ public class PreSettlementServiceImpl extends BaseServiceImpl availableGeneratedRows = new ArrayList<>(existingGeneratedRows); Set retainedManualIds = new LinkedHashSet<>(); Set retainedGeneratedIds = new LinkedHashSet<>(); + int lineNo = 1; for (PreSettlementSaveRequest.SummaryFee requestRow : requestRows) { if (Integer.valueOf(1).equals(requestRow.getManualFlag())) { String feeType = requiredText(requestRow.getFeeType(), "费用类型"); @@ -1042,11 +1126,15 @@ public class PreSettlementServiceImpl extends BaseServiceImpl fees = detailFees(detail.getId()); + BigDecimal transportQuantity = fees.stream().map(PreSettlementDetailFee::getTransportQuantity) + .filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add); + BigDecimal mileage = fees.stream().map(PreSettlementDetailFee::getMileage) + .filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add); BigDecimal freightAmount = fees.stream().map(PreSettlementDetailFee::getFreightAmount) .map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add); BigDecimal originalAmount = fees.stream().map(PreSettlementDetailFee::getOriginalAmount) .map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add); BigDecimal settlementAmountTax = fees.stream().map(PreSettlementDetailFee::getSettlementAmountTax) .map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add); + BigDecimal settlementAmountNoTax = fees.stream().map(PreSettlementDetailFee::getSettlementAmountNoTax) + .filter(Objects::nonNull).map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add); Map feeItems = new LinkedHashMap<>(); fees.forEach(fee -> parseFeeItems(fee.getFeeItemsJson()).forEach((name, amount) -> feeItems.merge(name, money(amount), BigDecimal::add))); + // 运输单价无法按行简单求和,与前端暂存逻辑一致:取首行费用单价。 + BigDecimal unitPrice = fees.stream().map(PreSettlementDetailFee::getUnitPrice) + .filter(Objects::nonNull).findFirst().orElse(null); + String remark = fees.stream().map(PreSettlementDetailFee::getRemark) + .filter(Func::isNotEmpty).findFirst().orElse(detail.getRemark()); + detail.setTransportQuantity(money(transportQuantity)); + detail.setMileage(mileage); + detail.setUnitPrice(unitPrice); detail.setFreightAmount(money(freightAmount)); detail.setOriginalAmount(money(originalAmount)); detail.setAdjustAmount(money(settlementAmountTax.subtract(originalAmount))); detail.setSettlementAmountTax(money(settlementAmountTax)); + detail.setSettlementAmountNoTax(fees.stream().anyMatch(fee -> fee.getSettlementAmountNoTax() != null) + ? money(settlementAmountNoTax) : null); detail.setFeeItemsJson(JsonUtil.toJson(feeItems)); + detail.setRemark(limitRemark(remark, 200)); detailMapper.updateById(detail); } + /** + * 结算金额以结算合计为准(含手工新增费用),与详情页合计及付款申请取数口径一致。 + * 合计行为空时回退为明细含税金额合计。 + */ private void refreshSettlementAmount(PreSettlement settlement) { - BigDecimal settlementAmount = listDetails(settlement.getId()).stream() - .map(PreSettlementDetail::getSettlementAmountTax).map(this::money) - .reduce(BigDecimal.ZERO, BigDecimal::add); + List summaryFees = listSummaryFees(settlement.getId()); + BigDecimal settlementAmount; + if (!summaryFees.isEmpty()) { + settlementAmount = summaryFees.stream() + .map(PreSettlementSummaryFee::getSettlementAmount) + .map(this::money) + .reduce(BigDecimal.ZERO, BigDecimal::add); + } else { + settlementAmount = listDetails(settlement.getId()).stream() + .map(PreSettlementDetail::getSettlementAmountTax) + .map(this::money) + .reduce(BigDecimal.ZERO, BigDecimal::add); + } settlement.setSettlementAmount(money(settlementAmount)); BigDecimal rate = normalizeRate(settlement.getExchangeRate(), settlement.getCurrency()); settlement.setExchangeRate(rate); @@ -1349,6 +1464,34 @@ public class PreSettlementServiceImpl extends BaseServiceImpl details) { + if (details == null || details.isEmpty()) { + return; + } + List sourceDetailIds = details.stream() + .map(PreSettlementDetail::getSourceDetailId) + .filter(Objects::nonNull) + .distinct() + .toList(); + if (sourceDetailIds.isEmpty()) { + return; + } + Map billingTypeMap = sourceFeeMapper.selectList( + Wrappers.lambdaQuery() + .in(ReceivablePayableCargoFee::getDetailId, sourceDetailIds) + .orderByAsc(ReceivablePayableCargoFee::getLineNo)) + .stream() + .filter(fee -> Func.isNotEmpty(fee.getBillingType()) && !"-".equals(fee.getBillingType())) + .collect(Collectors.groupingBy(ReceivablePayableCargoFee::getDetailId, + Collectors.mapping(ReceivablePayableCargoFee::getBillingType, + Collectors.collectingAndThen(Collectors.toCollection(LinkedHashSet::new), + values -> String.join("、", values))))); + for (PreSettlementDetail detail : details) { + detail.setBillingType(billingTypeMap.getOrDefault(detail.getSourceDetailId(), "")); + } + } + private List listDetails(Long settlementId) { return detailMapper.selectList(Wrappers.lambdaQuery() .eq(PreSettlementDetail::getPreSettlementId, settlementId) @@ -1388,6 +1531,89 @@ public class PreSettlementServiceImpl extends BaseServiceImpl beforeRows) { + List> beforeLines = summaryFeeLines(beforeRows); + List> afterLines = summaryFeeLines(listSummaryFees(settlementId)); + if (beforeLines.equals(afterLines)) { + return; + } + String content = summaryFeeChangeContent(beforeLines, afterLines); + if (Func.isEmpty(content)) { + return; + } + Map beforeData = new LinkedHashMap<>(); + Map afterData = new LinkedHashMap<>(); + beforeData.put("结算合计", beforeLines); + afterData.put("结算合计", afterLines); + String operationType = beforeLines.isEmpty() ? "新增" : afterLines.isEmpty() ? "删除" : "调整"; + saveChange(settlementId, "结算合计", null, operationType, content, "", beforeData, afterData); + } + + private List> summaryFeeLines(List rows) { + Map categoryNames = DictBizCache.getList("fee_category").stream() + .collect(Collectors.toMap(DictBiz::getDictKey, DictBiz::getDictValue, (first, second) -> first)); + List> lines = new ArrayList<>(); + for (PreSettlementSummaryFee row : rows) { + String feeType = row.getFeeType() == null ? "" : row.getFeeType(); + Map line = new LinkedHashMap<>(); + line.put("来源", Integer.valueOf(1).equals(row.getManualFlag()) ? "手工新增" : "系统归集"); + line.put("费用类型", categoryNames.getOrDefault(feeType, feeType)); + line.put("费用项", row.getFeeItem() == null ? "" : row.getFeeItem()); + line.put("原金额", money(row.getOriginalAmount())); + line.put("调整金额", money(row.getAdjustAmount())); + line.put("结算金额", money(row.getSettlementAmount())); + line.put("备注", row.getRemark() == null ? "" : row.getRemark()); + lines.add(line); + } + return lines; + } + + private String summaryFeeChangeContent(List> beforeLines, List> afterLines) { + Map> beforeMap = indexSummaryLines(beforeLines); + Map> afterMap = indexSummaryLines(afterLines); + List changes = new ArrayList<>(); + afterMap.forEach((key, afterLine) -> { + Map beforeLine = beforeMap.get(key); + String label = summaryLineLabel(afterLine); + if (beforeLine == null) { + changes.add("新增结算合计【" + label + "】结算金额【" + afterLine.get("结算金额") + "】"); + return; + } + if (!Objects.equals(beforeLine.get("原金额"), afterLine.get("原金额")) + || !Objects.equals(beforeLine.get("调整金额"), afterLine.get("调整金额")) + || !Objects.equals(beforeLine.get("结算金额"), afterLine.get("结算金额")) + || !Objects.equals(String.valueOf(beforeLine.get("备注")), String.valueOf(afterLine.get("备注")))) { + changes.add("调整结算合计【" + label + "】结算金额从【" + beforeLine.get("结算金额") + + "】调整为【" + afterLine.get("结算金额") + "】"); + } + }); + beforeMap.forEach((key, beforeLine) -> { + if (!afterMap.containsKey(key)) { + changes.add("删除结算合计【" + summaryLineLabel(beforeLine) + "】结算金额【" + beforeLine.get("结算金额") + "】"); + } + }); + return String.join(";", changes); + } + + private Map> indexSummaryLines(List> lines) { + Map> indexed = new LinkedHashMap<>(); + Map counts = new HashMap<>(); + for (Map line : lines) { + String base = String.valueOf(line.get("来源")) + "|" + line.get("费用类型") + "|" + line.get("费用项"); + int index = counts.merge(base, 1, Integer::sum); + indexed.put(base + "#" + index, line); + } + return indexed; + } + + private String summaryLineLabel(Map line) { + String feeType = String.valueOf(line.getOrDefault("费用类型", "")); + String feeItem = String.valueOf(line.getOrDefault("费用项", "")); + String source = String.valueOf(line.getOrDefault("来源", "")); + String name = feeType.isBlank() ? feeItem : feeType + "-" + feeItem; + return source + " " + name; + } + private void saveChange(Long settlementId, String changeType, Integer lineNo, String operationType, String content, String reason) { saveChange(settlementId, changeType, lineNo, operationType, content, reason, null, null); @@ -1602,44 +1828,25 @@ public class PreSettlementServiceImpl extends BaseServiceImpllambdaQuery() - .likeRight(PreSettlementAdvance::getAdvanceNo, prefix) - .orderByDesc(PreSettlementAdvance::getAdvanceNo) - .last("limit 1")); - return prefix + String.format("%05d", nextSequence(latest == null ? null : latest.getAdvanceNo(), prefix)); - } - - private String nextDailyCode(String code, Function getter) { - String prefix = code + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); - PreSettlement latest = list(Wrappers.lambdaQuery() - .and(query -> query.likeRight(PreSettlement::getPreSettlementNo, prefix) - .or().likeRight(PreSettlement::getFormalSettlementNo, prefix)) - .orderByDesc(PreSettlement::getCreateTime) - .last("limit 1")).stream().max(Comparator.comparing(item -> { - String value = getter.apply(item); - return value == null ? "" : value; - })).orElse(null); - String latestCode = latest == null ? null : getter.apply(latest); - return prefix + String.format("%05d", nextSequence(latestCode, prefix)); - } - - private int nextSequence(String latestCode, String prefix) { - if (latestCode == null || !latestCode.startsWith(prefix)) return 1; - try { - return Integer.parseInt(latestCode.substring(prefix.length())) + 1; - } catch (NumberFormatException exception) { - return 1; - } + Long maxSequence = advanceMapper.selectMaxDailySequence(AuthUtil.getTenantId(), prefix); + return prefix + String.format("%05d", (maxSequence == null ? 0L : maxSequence) + 1); } } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ProjectApplyServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ProjectApplyServiceImpl.java index 56815594..88ffcd89 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ProjectApplyServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ProjectApplyServiceImpl.java @@ -83,6 +83,7 @@ public class ProjectApplyServiceImpl extends BaseServiceImpl beforeData = buildProjectChangeSnapshot(oldRecord); prepare(projectApply); + normalizeUndertakeDept(projectApply); copyChangeFields(oldRecord, projectApply); validateSubmit(oldRecord); validateChange(oldRecord, projectApply.getChangeType()); @@ -382,13 +385,13 @@ public class ProjectApplyServiceImpl extends BaseServiceImpl wrapper .nested(formal -> formal.eq(ProjectApply::getEffectiveType, EFFECTIVE_FORMAL) - .in(ProjectApply::getApprovalStatus, STATUS_APPROVED, "change_approved")) + .in(ProjectApply::getApprovalStatus, STATUS_APPROVED, STATUS_CHANGE_APPROVED)) .or(temporary -> temporary.eq(ProjectApply::getEffectiveType, EFFECTIVE_TEMPORARY) .ne(ProjectApply::getApprovalStatus, STATUS_VOIDED))); } @@ -582,6 +585,11 @@ public class ProjectApplyServiceImpl extends BaseServiceImpl= 0 ? "claimed" : "partial"; + return claimedAmount.compareTo(money(claimCeiling)) >= 0 ? "claimed" : "partial"; + } + + /** + * 可认领上限:公告未认领金额与收款金额取较小正值(与收款流水侧口径一致) + */ + private BigDecimal claimCeiling(KingdeeReceiptFlow flow) { + BigDecimal unclaimed = money(flow.getUnclaimedAmount()); + BigDecimal receipt = money(flow.getReceiptAmount()); + if (receipt.compareTo(BigDecimal.ZERO) <= 0) { + return unclaimed; + } + if (unclaimed.compareTo(BigDecimal.ZERO) <= 0) { + return unclaimed; + } + return unclaimed.min(receipt); } private String amountStatus(BigDecimal paidAmount, BigDecimal settlementAmount) { diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceiptFlowServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceiptFlowServiceImpl.java index fe427db1..e6c6aecd 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceiptFlowServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceiptFlowServiceImpl.java @@ -194,12 +194,16 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl> settlementCandidates(String keyword, Long flowId) { existing(flowId); + // 本流水历史关联结算单优先保留(避免仅按创建时间 limit 截断后选不到) + Set relatedIds = relatedSettlementIds(flowId); List settlements = formalSettlementMapper.selectList( Wrappers.lambdaQuery() .eq(FormalSettlement::getSettlementType, RECEIVABLE) @@ -210,7 +214,24 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl loadedIds = settlements.stream().map(FormalSettlement::getId) + .filter(Objects::nonNull).collect(Collectors.toSet()); + List missingIds = relatedIds.stream().filter(id -> !loadedIds.contains(id)).toList(); + if (!missingIds.isEmpty()) { + List relatedSettlements = formalSettlementMapper.selectList( + Wrappers.lambdaQuery() + .in(FormalSettlement::getId, missingIds) + .eq(FormalSettlement::getSettlementType, RECEIVABLE) + .eq(FormalSettlement::getApprovalStatus, APPROVED) + .eq(FormalSettlement::getStatus, 1)); + List merged = new ArrayList<>(relatedSettlements.size() + settlements.size()); + merged.addAll(relatedSettlements); + merged.addAll(settlements); + settlements = merged; + } + } // 候选结算单的累计认领金额一次聚合查询(避免逐单 N+1) Map claimedMap = receiptClaimedAmounts(settlements.stream() .map(FormalSettlement::getId).toList()); @@ -218,6 +239,14 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl candidateRow(settlement, claimedMap.getOrDefault(settlement.getId(), BigDecimal.ZERO))) .filter(row -> ((BigDecimal) row.get("remainingReceiptAmount")).compareTo(BigDecimal.ZERO) > 0) + .sorted((left, right) -> { + boolean leftRelated = relatedIds.contains(toLong(left.get("id"))); + boolean rightRelated = relatedIds.contains(toLong(right.get("id"))); + if (leftRelated == rightRelated) { + return 0; + } + return leftRelated ? -1 : 1; + }) .toList(); } @@ -234,6 +263,7 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpllambdaQuery() .eq(ReceiptClaimSettlement::getFormalSettlementId, formalSettlementId) .eq(ReceiptClaimSettlement::getStatus, 1) + .eq(ReceiptClaimSettlement::getIsDeleted, 0) .orderByDesc(ReceiptClaimSettlement::getCreateTime)); if (relations.isEmpty()) { return List.of(); @@ -315,19 +345,19 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl 0) { - throw new ServiceException("累计认领金额不能超过公告未认领金额,剩余可认领 " - + unclaimedAmount.subtract(previousFlowClaimed).max(BigDecimal.ZERO)); + if (previousFlowClaimed.add(allocatedTotal).compareTo(claimCeiling) > 0) { + throw new ServiceException("累计认领金额不能超过可认领上限,剩余可认领 " + + claimCeiling.subtract(previousFlowClaimed).max(BigDecimal.ZERO)); } // 当前用户部门 @@ -380,6 +410,9 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl relatedSettlementIds(Long flowId) { + if (flowId == null) { + return Set.of(); + } + return claimSettlementMapper.selectList(Wrappers.lambdaQuery() + .select(ReceiptClaimSettlement::getFormalSettlementId) + .eq(ReceiptClaimSettlement::getReceiptFlowId, flowId) + .eq(ReceiptClaimSettlement::getStatus, 1) + .eq(ReceiptClaimSettlement::getIsDeleted, 0)).stream() + .map(ReceiptClaimSettlement::getFormalSettlementId) + .filter(Objects::nonNull) + .collect(Collectors.toCollection(HashSet::new)); + } + + /** + * 继续认领预填:本流水历史关联且仍有剩余可认领金额的结算单 + */ + private List> relatedSettlementCandidates(Long flowId) { + Set relatedIds = relatedSettlementIds(flowId); + if (relatedIds.isEmpty()) { + return List.of(); + } + List settlements = formalSettlementMapper.selectList( + Wrappers.lambdaQuery() + .in(FormalSettlement::getId, relatedIds) + .eq(FormalSettlement::getSettlementType, RECEIVABLE) + .eq(FormalSettlement::getApprovalStatus, APPROVED) + .eq(FormalSettlement::getStatus, 1)); + Map claimedMap = receiptClaimedAmounts(settlements.stream() + .map(FormalSettlement::getId).toList()); + return settlements.stream() + .map(settlement -> candidateRow(settlement, + claimedMap.getOrDefault(settlement.getId(), BigDecimal.ZERO))) + .filter(row -> ((BigDecimal) row.get("remainingReceiptAmount")).compareTo(BigDecimal.ZERO) > 0) + .toList(); + } + /** * 批量聚合结算单的累计有效认领金额:一次 IN 查询按结算单分组求和,避免逐单 N+1 */ @@ -631,7 +704,8 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpllambdaQuery() .in(ReceiptClaimSettlement::getFormalSettlementId, ids) - .eq(ReceiptClaimSettlement::getStatus, 1)).stream() + .eq(ReceiptClaimSettlement::getStatus, 1) + .eq(ReceiptClaimSettlement::getIsDeleted, 0)).stream() .collect(Collectors.groupingBy(ReceiptClaimSettlement::getFormalSettlementId, Collectors.reducing(BigDecimal.ZERO, item -> money(item.getAllocatedReceiptAmount()), BigDecimal::add))); @@ -640,12 +714,42 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpllambdaQuery() .eq(ReceiptClaimSettlement::getReceiptFlowId, flowId) - .eq(ReceiptClaimSettlement::getStatus, 1)).stream() + .eq(ReceiptClaimSettlement::getStatus, 1) + .eq(ReceiptClaimSettlement::getIsDeleted, 0)).stream() .map(ReceiptClaimSettlement::getAllocatedReceiptAmount) .map(this::money) .reduce(BigDecimal.ZERO, BigDecimal::add); } + /** + * 可认领上限:公告未认领金额与收款金额取较小正值。 + * 主口径仍为 A301-2 公告未认领金额;与收款金额取 min,避免未认领金额异常偏大导致无法进入「认领完成」。 + */ + private BigDecimal claimCeiling(KingdeeReceiptFlow flow) { + BigDecimal unclaimed = money(flow.getUnclaimedAmount()); + BigDecimal receipt = money(flow.getReceiptAmount()); + if (receipt.compareTo(BigDecimal.ZERO) <= 0) { + return unclaimed; + } + if (unclaimed.compareTo(BigDecimal.ZERO) <= 0) { + return unclaimed; + } + return unclaimed.min(receipt); + } + + private Long toLong(Object value) { + if (value == null) { + return null; + } + if (value instanceof Long longValue) { + return longValue; + } + if (value instanceof Number number) { + return number.longValue(); + } + return Func.toLong(String.valueOf(value)); + } + private KingdeeReceiptFlow lockedFlow(Long flowId) { KingdeeReceiptFlow flow = baseMapper.selectOne(Wrappers.lambdaQuery() .eq(KingdeeReceiptFlow::getId, flowId) @@ -824,18 +928,20 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl allocations = claimSettlementMapper.selectList( Wrappers.lambdaQuery() .eq(ReceiptClaimSettlement::getReceiptFlowId, flow.getId()) .eq(ReceiptClaimSettlement::getStatus, 1) + .eq(ReceiptClaimSettlement::getIsDeleted, 0) .orderByAsc(ReceiptClaimSettlement::getFormalSettlementId)); if (allocations.isEmpty()) { return ReceiptClaimPushResult.fail("该流水无有效认领分摊,无法推送"); @@ -1075,13 +1181,13 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl= 0 ? CLAIMED : PARTIAL; + return claimedAmount.compareTo(money(claimCeiling)) >= 0 ? CLAIMED : PARTIAL; } private String amountStatus(BigDecimal claimedAmount, BigDecimal settlementAmount) { diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceivablePayableDetailServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceivablePayableDetailServiceImpl.java index 382f925d..8ce2f5f7 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceivablePayableDetailServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceivablePayableDetailServiceImpl.java @@ -424,9 +424,7 @@ public class ReceivablePayableDetailServiceImpl } }); ReceivablePayableFeeDetailVO result = buildFeeDetail(rows); - LinkedHashSet feeItemNames = new LinkedHashSet<>(contractFeeItemNames(detail.getContractId())); - feeItemNames.addAll(result.getFeeItemNames()); - result.setFeeItemNames(new ArrayList<>(feeItemNames)); + // 只展示本明细已生成的费用项。合同上其他计费方案的费用项不能叠到当前明细。 return result; } @@ -607,14 +605,22 @@ public class ReceivablePayableDetailServiceImpl BigDecimal transportQuantity = money(adjusted.getTransportQuantity()); BigDecimal mileage = money(adjusted.getMileage()); BigDecimal freightAmount = money(adjusted.getFreightAmount()); + BigDecimal unitPrice = adjusted.getUnitPrice() == null ? null : money(adjusted.getUnitPrice()); Map effectiveFeeItems = feeItems; - boolean billingBasisChanged = money(existing.getTransportQuantity()).compareTo(transportQuantity) != 0 - || money(existing.getMileage()).compareTo(mileage) != 0; - if (billingBasisChanged) { + boolean quantityChanged = money(existing.getTransportQuantity()).compareTo(transportQuantity) != 0; + boolean mileageChanged = money(existing.getMileage()).compareTo(mileage) != 0; + boolean unitPriceChanged = money(existing.getUnitPrice()).compareTo(money(unitPrice)) != 0; + if (quantityChanged || mileageChanged || unitPriceChanged) { + String changedField = quantityChanged && !mileageChanged ? "transportQuantity" + : mileageChanged && !quantityChanged ? "mileage" + : unitPriceChanged && !quantityChanged && !mileageChanged ? "unitPrice" : null; AdjustedFeeCalculation calculation = calculateAdjustedFee(detail, existing, - transportQuantity, mileage, freightAmount, feeItems); + transportQuantity, mileage, freightAmount, feeItems, unitPrice, changedField); freightAmount = calculation.freightAmount(); effectiveFeeItems = calculation.feeItems(); + if (calculation.unitPrice() != null) { + adjusted.setUnitPrice(calculation.unitPrice().setScale(2, RoundingMode.HALF_UP)); + } } appendChange(changes, "规格", existing.getSpecification(), adjusted.getSpecification()); appendChange(changes, "型号", existing.getModel(), adjusted.getModel()); @@ -678,15 +684,21 @@ public class ReceivablePayableDetailServiceImpl } validateNonNegative(request.getTransportQuantity(), "运输量"); validateNonNegative(request.getMileage(), "里程"); + validateNonNegative(request.getUnitPrice(), "运输单价"); AdjustedFeeCalculation calculation = calculateAdjustedFee(detail, fee, money(request.getTransportQuantity()), money(request.getMileage()), - money(request.getFreightAmount()), normalizeFeeItems(request.getFeeItems())); + money(request.getFreightAmount()), normalizeFeeItems(request.getFeeItems()), + request.getUnitPrice() == null ? null : money(request.getUnitPrice()), + request.getChangedField()); ReceivablePayableCargoFeeVO result = Objects.requireNonNull( BeanUtil.copyProperties(fee, ReceivablePayableCargoFeeVO.class)); result.setTransportQuantity(request.getTransportQuantity()); result.setMileage(request.getMileage()); result.setFreightAmount(calculation.freightAmount()); result.setFeeItems(new LinkedHashMap<>(calculation.feeItems())); + if (calculation.unitPrice() != null) { + result.setUnitPrice(calculation.unitPrice().setScale(2, RoundingMode.HALF_UP)); + } BigDecimal afterAmount = adjustedAfterAmount(calculation.freightAmount(), calculation.feeItems()); result.setAdjustAmount(afterAmount.subtract(money(fee.getOriginalAmount()))); result.setAfterAmount(afterAmount); @@ -836,6 +848,12 @@ public class ReceivablePayableDetailServiceImpl if (existsByWaybill(waybill.getId(), targetSettlementType)) { continue; } + if ("payable".equals(targetSettlementType) + && trimToNull(waybill.getCarrierName()) != null + && !matchesWaybillCarrier(contract, waybill)) { + throw new ServiceException("运单【" + Func.toStr(waybill.getWaybillNo(), String.valueOf(waybill.getId())) + + "】的承运商不是所选承运商合同的乙方,不能使用项目内其他承运商合同生成应付"); + } ReceivablePayableDetail detail = buildDetail(waybill, contract, request.getBillingPlanId(), targetSettlementType); detail.setSourceType(SOURCE_MANUAL_GENERATION); save(detail); @@ -847,6 +865,186 @@ public class ReceivablePayableDetailServiceImpl } } + private static final String SOURCE_SETTLEMENT_IMPORT = "结算导入"; + + @Override + @Transactional(rollbackFor = Exception.class) + public List createDetailsFromSettlementImport(String settlementType, Long contractId, + List> feeRows) { + if (Func.isEmpty(settlementType) || Func.isEmpty(contractId)) { + throw new ServiceException("结算类型与合同不能为空"); + } + if (Func.isEmpty(feeRows)) { + return List.of(); + } + ContractManage contract = contractManageService.getById(contractId); + if (contract == null || Objects.equals(contract.getIsDeleted(), 1)) { + throw new ServiceException("合同不存在"); + } + String targetSettlementType = settlementType(settlementType); + Long currentUserId = AuthUtil.getUserId(); + Date generateTime = new Date(); + List createdIds = new ArrayList<>(); + for (Map feeRow : feeRows) { + if (feeRow == null) { + continue; + } + Long waybillId = toLong(feeRow.get("waybillId")); + if (waybillId == null) { + continue; + } + Waybill waybill = waybillService.getById(waybillId); + if (waybill == null || Objects.equals(waybill.getIsDeleted(), 1)) { + throw new ServiceException("运单不存在:" + waybillId); + } + assertWaybillNotSettledForImport(waybill, targetSettlementType); + + BigDecimal transportFee = money(decimal(feeRow.get("transportFee"))); + BigDecimal unitPrice = money(decimal(feeRow.get("unitPrice"))); + Map feeItems = normalizeFeeItems(toFeeItemMap(feeRow.get("feeItems"))); + BigDecimal feeItemTotal = feeItems.values().stream().map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add); + BigDecimal afterAmount = transportFee.add(feeItemTotal).setScale(2, RoundingMode.HALF_UP); + ReceivablePayableCargoFee cargoFee = buildSettlementImportCargoFee( + waybill, transportFee, unitPrice, feeItems, afterAmount, stringValue(feeRow.get("remark"))); + + ReceivablePayableDetail importDetail = getOne(Wrappers.lambdaQuery() + .eq(ReceivablePayableDetail::getWaybillId, waybillId) + .eq(ReceivablePayableDetail::getSettlementType, targetSettlementType) + .eq(ReceivablePayableDetail::getSourceType, SOURCE_SETTLEMENT_IMPORT) + .eq(ReceivablePayableDetail::getIsDeleted, 0) + .orderByDesc(ReceivablePayableDetail::getCreateTime) + .last("limit 1"), false); + if (importDetail != null) { + refreshSettlementImportDetail(importDetail, waybill, contract, targetSettlementType, + cargoFee, unitPrice, stringValue(feeRow.get("remark")), currentUserId, generateTime); + createdIds.add(importDetail.getId()); + continue; + } + + ReceivablePayableDetail detail = buildDetail(waybill, contract, targetSettlementType, + List.of(cargoFee), unitPrice); + detail.setSourceType(SOURCE_SETTLEMENT_IMPORT); + detail.setSettlementStatus("pending"); + if (Func.isNotEmpty(stringValue(feeRow.get("remark")))) { + detail.setRemark(stringValue(feeRow.get("remark"))); + } + saveAutomaticDetail(detail, List.of(cargoFee), false, currentUserId, generateTime); + createdIds.add(detail.getId()); + } + return createdIds; + } + + /** 运单在该结算类型下已进入预/正式结算时,禁止再导入。 */ + private void assertWaybillNotSettledForImport(Waybill waybill, String settlementType) { + List details = list(Wrappers.lambdaQuery() + .eq(ReceivablePayableDetail::getWaybillId, waybill.getId()) + .eq(ReceivablePayableDetail::getSettlementType, settlementType) + .eq(ReceivablePayableDetail::getIsDeleted, 0)); + for (ReceivablePayableDetail detail : details) { + String settlementStatus = Func.toStr(detail.getSettlementStatus(), "pending"); + if (Func.isNotEmpty(detail.getFormalSettlementNo()) + || Func.isNotEmpty(detail.getPreSettlementNo()) + || List.of("pre_settled", "formal_settled", "closed").contains(settlementStatus)) { + throw new ServiceException("运单" + Func.toStr(waybill.getWaybillNo(), String.valueOf(waybill.getId())) + + "已进入结算,无法重复导入"); + } + } + } + + private ReceivablePayableCargoFee buildSettlementImportCargoFee(Waybill waybill, BigDecimal transportFee, + BigDecimal unitPrice, Map feeItems, + BigDecimal afterAmount, String remark) { + ReceivablePayableCargoFee cargoFee = new ReceivablePayableCargoFee(); + cargoFee.setWaybillId(waybill.getId()); + cargoFee.setLineNo("0001"); + cargoFee.setDataSource(FEE_SOURCE_MANUAL); + cargoFee.setCargoName(waybill.getCargoName()); + cargoFee.setCargoType(waybill.getCargoType()); + cargoFee.setSpecification(waybill.getSpecification()); + cargoFee.setModel(waybill.getModel()); + cargoFee.setBillingFactor("按重量"); + cargoFee.setBillingType("固定单价"); + cargoFee.setTransportQuantity(money(waybill.getQuantity())); + cargoFee.setQuantityUnit(waybill.getQuantityUnit()); + cargoFee.setPriceUnit(waybill.getPriceUnit()); + cargoFee.setUnitPrice(unitPrice); + cargoFee.setMileage(normalizeGeneratedMileage(waybill.getMileage())); + cargoFee.setFreightAmount(transportFee); + cargoFee.setFeeItemsJson(JsonUtil.toJson(feeItems)); + cargoFee.setOriginalAmount(afterAmount); + cargoFee.setAdjustAmount(BigDecimal.ZERO.setScale(2)); + cargoFee.setAfterAmount(afterAmount); + cargoFee.setRemark(remark); + return cargoFee; + } + + /** 刷新已有结算导入明细:费用项目与金额始终覆盖为本次导入数据。 */ + private void refreshSettlementImportDetail(ReceivablePayableDetail detail, Waybill waybill, ContractManage contract, + String settlementType, ReceivablePayableCargoFee cargoFee, + BigDecimal unitPrice, String remark, Long currentUserId, Date generateTime) { + ReceivablePayableDetail rebuilt = buildDetail(waybill, contract, settlementType, List.of(cargoFee), unitPrice); + detail.setFreightAmount(rebuilt.getFreightAmount()); + detail.setOtherFeeAmount(rebuilt.getOtherFeeAmount()); + detail.setTotalAmount(rebuilt.getTotalAmount()); + detail.setUnitPrice(rebuilt.getUnitPrice()); + detail.setCurrency(rebuilt.getCurrency()); + detail.setFeeItemsJson(rebuilt.getFeeItemsJson()); + detail.setContractId(rebuilt.getContractId()); + detail.setContractNo(rebuilt.getContractNo()); + detail.setContractName(rebuilt.getContractName()); + detail.setCustomerName(rebuilt.getCustomerName()); + detail.setSourceType(SOURCE_SETTLEMENT_IMPORT); + detail.setSettlementStatus("pending"); + if (Func.isNotEmpty(remark)) { + detail.setRemark(remark); + } + updateById(detail); + cargoFeeMapper.delete(Wrappers.lambdaQuery() + .eq(ReceivablePayableCargoFee::getDetailId, detail.getId())); + ReceivablePayableCargoFee copy = BeanUtil.copyProperties(cargoFee, ReceivablePayableCargoFee.class); + copy.setId(null); + copy.setDetailId(detail.getId()); + fillGeneratedAuditFields(copy, currentUserId, generateTime); + cargoFeeMapper.insert(copy); + } + + @SuppressWarnings("unchecked") + private Map toFeeItemMap(Object value) { + Map result = new LinkedHashMap<>(); + if (!(value instanceof Map raw)) { + return result; + } + raw.forEach((key, amount) -> { + if (key == null) { + return; + } + result.put(String.valueOf(key), decimal(amount)); + }); + return result; + } + + private Long toLong(Object value) { + if (value == null || "".equals(value)) { + return null; + } + if (value instanceof Number number) { + return number.longValue(); + } + try { + return Long.valueOf(String.valueOf(value).trim()); + } catch (NumberFormatException exception) { + return null; + } + } + + private String stringValue(Object value) { + if (value == null) { + return null; + } + String text = String.valueOf(value).trim(); + return text.isEmpty() ? null : text; + } + @Override @Transactional(propagation = org.springframework.transaction.annotation.Propagation.REQUIRES_NEW, rollbackFor = Exception.class) @@ -884,11 +1082,20 @@ public class ReceivablePayableDetailServiceImpl List payableWaybills = waybills.stream() .filter(waybill -> !isSelfTransport(waybill)).toList(); if (payableWaybills.isEmpty()) return; + List carrierMatchedWaybills = payableWaybills.stream() + .filter(waybill -> matchesWaybillCarrier(carrierContract, waybill)).toList(); + List otherCarrierWaybills = payableWaybills.stream() + .filter(waybill -> !matchesWaybillCarrier(carrierContract, waybill)).toList(); + if (!otherCarrierWaybills.isEmpty()) { + generateAutomaticWaybillDetails(otherCarrierWaybills, false, + this::resolveCarrierContract, currentUserId, generateTime); + } + if (carrierMatchedWaybills.isEmpty()) return; generateAutomaticDetail(carrierContract, "payable", - () -> existsByLoading(loadingNo, "payable"), payableWaybills, + () -> existsByLoading(loadingNo, "payable"), carrierMatchedWaybills, (fees, unitPrice) -> { normalizeWaybillFeeLines(fees); - return buildLoadingDetail(loadingNo, carrierContract, payableWaybills, fees, unitPrice); + return buildLoadingDetail(loadingNo, carrierContract, carrierMatchedWaybills, fees, unitPrice); }, false, currentUserId, generateTime); } @@ -938,22 +1145,54 @@ public class ReceivablePayableDetailServiceImpl private ContractManage resolveWaybillCarrierContract(Waybill waybill) { if (Func.isNotEmpty(waybill.getLoadingNo())) return null; + return resolveCarrierContract(waybill); + } + + /** + * 应付只认运单选择的承运商。 + * 运单已记录承运商合同时,乙方必须就是该承运商;否则按承运商名称回查同一项目下的承运商合同。 + * 不允许落到项目里其他承运商合同的客商。 + */ + private ContractManage resolveCarrierContract(Waybill waybill) { + if (waybill == null || isSelfTransport(waybill)) { + return null; + } + String carrierName = trimToNull(waybill.getCarrierName()); if (Func.isNotEmpty(waybill.getCarrierContractId())) { - ContractManage contract = contractManageService.getById(waybill.getCarrierContractId()); - if (contract == null || !"承运商合同".equals(contract.getContractCategory())) { + ContractManage recorded = contractManageService.getById(waybill.getCarrierContractId()); + if (recorded == null || !"承运商合同".equals(recorded.getContractCategory())) { throw new ServiceException("运单记录的承运商合同不存在或合同类别不正确"); } - return contract; + if (carrierName == null || matchesWaybillCarrier(recorded, waybill)) { + return recorded; + } } - return findCarrierContract(waybill); + ContractManage matched = findCarrierContract(waybill); + if (matched != null && matchesWaybillCarrier(matched, waybill)) { + return matched; + } + return null; + } + + private boolean matchesWaybillCarrier(ContractManage contract, Waybill waybill) { + if (contract == null || waybill == null || !"承运商合同".equals(contract.getContractCategory())) { + return false; + } + if (waybill.getProjectId() != null && contract.getProjectId() != null + && !Objects.equals(waybill.getProjectId(), contract.getProjectId())) { + return false; + } + String carrierName = trimToNull(waybill.getCarrierName()); + String partyB = trimToNull(contract.getPartyB()); + return carrierName != null && carrierName.equals(partyB); } private ContractManage findCarrierContract(Waybill waybill) { - if (waybill.getProjectId() == null || Func.isEmpty(waybill.getContractId()) - || Func.isEmpty(waybill.getCarrierName())) { + String carrierName = trimToNull(waybill.getCarrierName()); + if (waybill.getProjectId() == null || Func.isEmpty(waybill.getContractId()) || carrierName == null) { return null; } - // 承运商合同甲方应与运单绑定的客户合同甲方一致,不能直接使用运单的客户名称拼接字段。 + // 承运商合同甲方应与运单绑定的客户合同甲方一致,乙方必须是运单选择的承运商。 ContractManage customerContract = contractManageService.getById(waybill.getContractId()); if (customerContract == null || Func.isEmpty(customerContract.getPartyA())) { return null; @@ -962,13 +1201,38 @@ public class ReceivablePayableDetailServiceImpl .eq(ContractManage::getIsDeleted, 0) .eq(ContractManage::getProjectId, waybill.getProjectId()) .eq(ContractManage::getPartyA, customerContract.getPartyA()) - .eq(ContractManage::getPartyB, waybill.getCarrierName()) + .eq(ContractManage::getPartyB, carrierName) .eq(ContractManage::getContractCategory, "承运商合同") .and(wrapper -> wrapper.isNull(ContractManage::getContractStage) .or().ne(ContractManage::getContractStage, "terminated")) .orderByDesc(ContractManage::getCreateTime), false); } + private String trimToNull(String value) { + if (value == null) { + return null; + } + String text = value.trim(); + return text.isEmpty() ? null : text; + } + + /** + * 应付客商取运单选择的承运商,避免写成项目内其他承运商合同的乙方。 + */ + private String resolveCounterpartyName(Waybill waybill, ContractManage contract, String settlementType) { + if ("payable".equals(settlementType)) { + String carrierName = trimToNull(waybill == null ? null : waybill.getCarrierName()); + if (carrierName != null) { + return carrierName; + } + return contract == null ? null : contract.getPartyB(); + } + if (contract != null && Func.isNotEmpty(contract.getPartyA())) { + return contract.getPartyA(); + } + return waybill == null ? null : waybill.getCustomerName(); + } + @Override @Transactional(propagation = org.springframework.transaction.annotation.Propagation.REQUIRES_NEW, rollbackFor = Exception.class) @@ -1021,12 +1285,24 @@ public class ReceivablePayableDetailServiceImpl .collect(Collectors.toMap(ContractManage::getId, contract -> contract)); Map> carrierWaybills = waybills.stream().collect(Collectors.groupingBy( Waybill::getCarrierContractId, LinkedHashMap::new, Collectors.toList())); + List mismatchedCarrierWaybills = waybills.stream() + .filter(waybill -> !matchesWaybillCarrier(carrierContracts.get(waybill.getCarrierContractId()), waybill)) + .toList(); + if (!mismatchedCarrierWaybills.isEmpty()) { + generateAutomaticWaybillDetails(mismatchedCarrierWaybills, false, + this::resolveCarrierContract, currentUserId, generateTime); + } for (Map.Entry> entry : carrierWaybills.entrySet()) { ContractManage carrierContract = carrierContracts.get(entry.getKey()); if (carrierContract == null || !"承运商合同".equals(carrierContract.getContractCategory())) { throw new ServiceException("总单【" + masterOrder.getMasterNo() + "】记录的承运商合同不存在或合同类别不正确"); } - List currentCarrierWaybills = entry.getValue(); + List currentCarrierWaybills = entry.getValue().stream() + .filter(waybill -> matchesWaybillCarrier(carrierContract, waybill)) + .toList(); + if (currentCarrierWaybills.isEmpty()) { + continue; + } generateAutomaticDetail(carrierContract, "payable", () -> existsByMasterOrderContract(masterOrder.getMasterNo(), "payable", carrierContract.getId()), currentCarrierWaybills, @@ -1455,9 +1731,7 @@ public class ReceivablePayableDetailServiceImpl detail.setDeptId(contract == null ? waybill.getDeptId() : contract.getOrganizationId()); detail.setDeptName(contract == null ? waybill.getDeptName() : contract.getOrganizationName()); detail.setFeeDate(waybill.getEndDate() == null ? LocalDate.now() : waybill.getEndDate()); - detail.setCustomerName(contract == null - ? ("payable".equals(settlementType) ? waybill.getCarrierName() : waybill.getCustomerName()) - : ("payable".equals(settlementType) ? contract.getPartyB() : contract.getPartyA())); + detail.setCustomerName(resolveCounterpartyName(waybill, contract, settlementType)); detail.setContractId(contract == null ? waybill.getContractId() : contract.getId()); detail.setContractNo(contract == null ? null : contract.getContractNo()); detail.setContractName(contract == null ? waybill.getContractName() : contract.getContractName()); @@ -1472,8 +1746,8 @@ public class ReceivablePayableDetailServiceImpl detail.setArrivalContact(waybill.getArrivalContact()); detail.setArrivalPhone(waybill.getArrivalPhone()); detail.setTransportType(waybill.getTransportType()); - detail.setCargoName(waybill.getCargoName()); - detail.setCargoType(waybill.getCargoType()); + detail.setCargoName(joinDistinctGoods(waybill, "cargoName", waybill.getCargoName())); + detail.setCargoType(joinDistinctGoods(waybill, "cargoType", waybill.getCargoType())); detail.setTransportQuantity(resolveWaybillTransportQuantity(waybill)); detail.setQuantityUnit(waybill.getQuantityUnit()); detail.setMileage(normalizeGeneratedMileage(waybill.getMileage())); @@ -1488,6 +1762,21 @@ public class ReceivablePayableDetailServiceImpl return detail; } + /** 一单多货时主表只保留首行货物,应付明细改记全部货物名称、货物类型。 */ + private String joinDistinctGoods(Waybill waybill, String field, String fallback) { + LinkedHashSet values = new LinkedHashSet<>(); + for (Map goods : parseList(waybill.getGoodsJson())) { + String value = stringValue(goods, field, ""); + if (Func.isNotEmpty(value)) { + values.add(value.trim()); + } + } + if (values.isEmpty()) { + return fallback; + } + return String.join(",", values); + } + /** * 多货物运单优先按 goodsJson 数量合计作为运输总量,避免主表 quantity 只落了首行。 */ @@ -1677,21 +1966,35 @@ public class ReceivablePayableDetailServiceImpl } private List calculatedFees(Waybill waybill, ContractManage contract, String planId) { - return calculatedFees(waybill, contract, planId, false); + return calculatedFees(waybill, contract, planId, false, false); } - private List calculatedFees(Waybill waybill, ContractManage contract, String planId, boolean matchOnly) { + private List calculatedFees(Waybill waybill, ContractManage contract, String planId, + boolean matchOnly) { + return calculatedFees(waybill, contract, planId, matchOnly, false); + } + + /** + * @param strictPlan 更新费用时为 true:选定方案有规则但未命中时,不再回退运单历史费用,避免旧费用项叠加 + */ + private List calculatedFees(Waybill waybill, ContractManage contract, String planId, + boolean matchOnly, boolean strictPlan) { List> plans = parseList(contract == null ? null : contract.getBillingPlanJson()); Map plan = "__matched__".equals(planId) ? resolveDefaultBillingPlan(plans, waybill.getTransportType()) : resolveBillingPlan(plans, planId); - return calculatedFees(waybill, plan, matchOnly); + return calculatedFees(waybill, plan, matchOnly, strictPlan); } private List calculatedFees(Waybill waybill, Map plan, boolean matchOnly) { + return calculatedFees(waybill, plan, matchOnly, false); + } + + private List calculatedFees(Waybill waybill, Map plan, + boolean matchOnly, boolean strictPlan) { if (plan == null || !(plan.get("rules") instanceof List)) { - return matchOnly ? List.of() : buildCargoFees(waybill); + return matchOnly || strictPlan ? List.of() : buildCargoFees(waybill); } Map, ReceivablePayableCargoFee> feesByCargo = new LinkedHashMap<>(); Map, Map> feeItemsByCargo = new LinkedHashMap<>(); @@ -1733,7 +2036,11 @@ public class ReceivablePayableDetailServiceImpl ReceivablePayableCargoFee fee = result.get(index); fee.setLineNo(String.format("%04d", index + 1)); } - return result.isEmpty() && !matchOnly ? buildCargoFees(waybill) : result; + // 已选定计费方案时,只保留该方案算出的费用项,不用运单上的历史费用回填。 + if (strictPlan || matchOnly) { + return result; + } + return result.isEmpty() ? buildCargoFees(waybill) : result; } private ReceivablePayableCargoFee buildCalculatedCargoFee(Waybill waybill, Waybill feeWaybill, @@ -2254,10 +2561,16 @@ public class ReceivablePayableDetailServiceImpl private AdjustedFeeCalculation calculateAdjustedFee(ReceivablePayableDetail detail, ReceivablePayableCargoFee fee, BigDecimal transportQuantity, BigDecimal mileage, BigDecimal freightAmount, - Map feeItems) { + Map feeItems, BigDecimal unitPrice, + String changedField) { if (isManualFee(fee)) { throw new ServiceException("手工费用不支持按合同计费规则试算"); } + boolean transportChanged = Func.isEmpty(changedField) + || List.of("transportQuantity", "mileage", "unitPrice").contains(changedField); + if (!transportChanged) { + return new AdjustedFeeCalculation(freightAmount, new LinkedHashMap<>(feeItems), null); + } Waybill adjustedWaybill = adjustedWaybill(detail, fee, transportQuantity, mileage); List> rules = parseList(fee.getBillingRulesJson()); if (rules.isEmpty()) { @@ -2273,26 +2586,94 @@ public class ReceivablePayableDetailServiceImpl if (rules.isEmpty()) { throw new ServiceException("未找到费用明细对应的合同计费规则,请先更新费用"); } - Map calculatedAmounts = new LinkedHashMap<>(); - BigDecimal calculatedFreight = BigDecimal.ZERO; - boolean freightRuleMatched = false; + Map calculatedFeeItems = new LinkedHashMap<>(feeItems); + BigDecimal calculatedFreight = null; + BigDecimal resolvedUnitPrice = null; + boolean calculated = false; for (Map rule : rules) { - BigDecimal amount = calculateRule(rule, adjustedWaybill); - if (amount == null) continue; + if (!shouldRecalculateRule(rule, changedField)) { + continue; + } + String type = stringValue(rule, "billingType", ""); + boolean freightRule = isFreightRule(rule); + Map effectiveRule = rule; + if (freightRule && unitPriceAdjustable(type) && unitPrice != null) { + effectiveRule = new LinkedHashMap<>(rule); + effectiveRule.put("unitPrice", unitPrice); + resolvedUnitPrice = unitPrice; + } + BigDecimal amount = calculateRule(effectiveRule, adjustedWaybill); + if (amount == null) { + continue; + } + calculated = true; String feeItem = stringValue(rule, "feeItem", "费用"); - calculatedAmounts.merge(feeItem, amount, BigDecimal::add); - if (isFreightRule(rule)) { - calculatedFreight = calculatedFreight.add(amount); - freightRuleMatched = true; + calculatedFeeItems.put(feeItem, amount); + if (freightRule) { + calculatedFreight = (calculatedFreight == null ? BigDecimal.ZERO : calculatedFreight).add(amount); + if (intervalBilling(type)) { + resolvedUnitPrice = matchedIntervalUnitPrice(rule, adjustedWaybill); + } } } - if (calculatedAmounts.isEmpty()) { + if (!calculated && Func.isEmpty(changedField)) { throw new ServiceException("费用明细对应的合同计费规则无法试算,请先更新费用"); } - Map calculatedFeeItems = new LinkedHashMap<>(feeItems); - calculatedAmounts.forEach(calculatedFeeItems::put); - return new AdjustedFeeCalculation(freightRuleMatched ? calculatedFreight : freightAmount, - calculatedFeeItems); + return new AdjustedFeeCalculation(calculatedFreight == null ? freightAmount : calculatedFreight, + calculatedFeeItems, resolvedUnitPrice); + } + + /** + * 运费在运输总量、里程、可调整单价变化后按方案重算。 + * 其他费用仅在计费要素使用了本次变化的运输总量或里程时重算,便于保留手工修改的费用总额。 + */ + private boolean shouldRecalculateRule(Map rule, String changedField) { + String element = stringValue(rule, "billingElement", ""); + String type = stringValue(rule, "billingType", ""); + if (isFreightRule(rule)) { + return !"unitPrice".equals(changedField) || unitPriceAdjustable(type); + } + if ("unitPrice".equals(changedField) || Func.isEmpty(changedField)) { + return Func.isEmpty(changedField) && (usesTransportQuantity(element) || usesMileage(element)); + } + if ("transportQuantity".equals(changedField)) { + return usesTransportQuantity(element); + } + if ("mileage".equals(changedField)) { + return usesMileage(element); + } + return false; + } + + private boolean usesTransportQuantity(String element) { + return List.of("按重量", "按体积", "按数量", "按吨·公里").contains(element); + } + + private boolean usesMileage(String element) { + return List.of("按里程", "按吨·公里").contains(element); + } + + private boolean unitPriceAdjustable(String billingType) { + return !tierUnitPrice(billingType) && !intervalBilling(billingType); + } + + private boolean tierUnitPrice(String billingType) { + return "阶梯单价".equals(billingType); + } + + private boolean intervalBilling(String billingType) { + String type = billingType == null ? "" : billingType; + return "区间单价".equals(type) || (type.contains("区间") && type.contains("一口价")); + } + + private BigDecimal matchedIntervalUnitPrice(Map rule, Waybill waybill) { + BigDecimal fallback = decimal(rule.get("unitPrice")); + return range(ranges(rule), measure(rule, waybill)) + .map(matched -> { + BigDecimal matchedPrice = decimal(matched.get("unitPrice")); + return matchedPrice.signum() == 0 ? fallback : matchedPrice; + }) + .orElse(fallback); } private Waybill adjustedWaybill(ReceivablePayableDetail detail, ReceivablePayableCargoFee fee, @@ -2365,7 +2746,8 @@ public class ReceivablePayableDetailServiceImpl return name != null && (name.contains("运费") || name.contains("运输费")); } - private record AdjustedFeeCalculation(BigDecimal freightAmount, Map feeItems) { + private record AdjustedFeeCalculation(BigDecimal freightAmount, Map feeItems, + BigDecimal unitPrice) { } private void appendChange(List changes, String field, BigDecimal before, BigDecimal after) { @@ -2568,7 +2950,7 @@ public class ReceivablePayableDetailServiceImpl throw new ServiceException("关联运单不存在"); } ContractManage contract = contractManageService.getById(detail.getContractId()); - List fees = calculatedFees(waybill, contract, billingPlanId); + List fees = calculatedFees(waybill, contract, billingPlanId, false, true); cargoFeeMapper.delete(Wrappers.lambdaQuery().eq(ReceivablePayableCargoFee::getDetailId, detail.getId())); fees.forEach(fee -> { fee.setDetailId(detail.getId()); cargoFeeMapper.insert(fee); }); BigDecimal freight = fees.stream().map(fee -> money(fee.getFreightAmount())) @@ -2593,7 +2975,7 @@ public class ReceivablePayableDetailServiceImpl ContractManage contract = contractManageService.getById(detail.getContractId()); List masterGoods = masterOrderGoods(masterOrder); List fees = masterGoods.stream() - .flatMap(goods -> calculatedFees(goods, contract, billingPlanId).stream()).toList(); + .flatMap(goods -> calculatedFees(goods, contract, billingPlanId, false, true).stream()).toList(); normalizeMasterFeeLines(fees); cargoFeeMapper.delete(Wrappers.lambdaQuery() .eq(ReceivablePayableCargoFee::getDetailId, detail.getId())); @@ -2629,7 +3011,7 @@ public class ReceivablePayableDetailServiceImpl } ContractManage contract = contractManageService.getById(detail.getContractId()); List fees = waybills.stream() - .flatMap(waybill -> calculatedFees(waybill, contract, billingPlanId).stream()).toList(); + .flatMap(waybill -> calculatedFees(waybill, contract, billingPlanId, false, true).stream()).toList(); normalizeWaybillFeeLines(fees); cargoFeeMapper.delete(Wrappers.lambdaQuery() .eq(ReceivablePayableCargoFee::getDetailId, detail.getId())); @@ -2704,21 +3086,29 @@ public class ReceivablePayableDetailServiceImpl if (detailIds.isEmpty()) return; List fees = cargoFeeMapper.selectList( Wrappers.lambdaQuery() - .select(ReceivablePayableCargoFee::getDetailId, ReceivablePayableCargoFee::getCargoName) + .select(ReceivablePayableCargoFee::getDetailId, ReceivablePayableCargoFee::getCargoName, + ReceivablePayableCargoFee::getBillingType) .in(ReceivablePayableCargoFee::getDetailId, detailIds) .eq(ReceivablePayableCargoFee::getIsDeleted, 0) .orderByAsc(ReceivablePayableCargoFee::getDetailId) .orderByAsc(ReceivablePayableCargoFee::getLineNo)); Map> cargoNames = new LinkedHashMap<>(); + Map> billingTypes = new LinkedHashMap<>(); for (ReceivablePayableCargoFee fee : fees) { - if (Func.isEmpty(fee.getCargoName())) continue; - cargoNames.computeIfAbsent(fee.getDetailId(), key -> new LinkedHashSet<>()).add(fee.getCargoName()); + if (Func.isNotEmpty(fee.getCargoName())) { + cargoNames.computeIfAbsent(fee.getDetailId(), key -> new LinkedHashSet<>()).add(fee.getCargoName()); + } + if (Func.isNotEmpty(fee.getBillingType()) && !"-".equals(fee.getBillingType())) { + billingTypes.computeIfAbsent(fee.getDetailId(), key -> new LinkedHashSet<>()).add(fee.getBillingType()); + } } details.forEach(detail -> { Set names = cargoNames.get(detail.getId()); if (Func.isNotEmpty(names)) { detail.setCargoName(String.join(",", names)); } + Set types = billingTypes.get(detail.getId()); + detail.setBillingType(Func.isEmpty(types) ? "" : String.join("、", types)); }); } @@ -2924,6 +3314,7 @@ public class ReceivablePayableDetailServiceImpl map.put("contractId", detail.getContractId()); map.put("contractNo", detail.getContractNo()); map.put("contractName", detail.getContractName()); + map.put("sourceType", detail.getSourceType()); map.put("preSettlementNo", detail.getPreSettlementNo()); map.put("formalSettlementNo", detail.getFormalSettlementNo()); map.put("waybillNo", detail.getWaybillNo()); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementAdjustmentServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementAdjustmentServiceImpl.java index 8dda1c1c..0f5b680b 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementAdjustmentServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementAdjustmentServiceImpl.java @@ -4,12 +4,18 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springblade.core.log.exception.ServiceException; import org.springblade.core.mp.base.BaseServiceImpl; import org.springblade.core.secure.utils.AuthUtil; import org.springblade.core.tool.utils.Func; +import org.springblade.core.tool.utils.SpringUtil; import org.springblade.system.cache.UserCache; +import org.springblade.transport.constant.KingdeeBfiConstant; import org.springblade.transport.constant.TransportBusiConstant; +import org.springblade.transport.event.KingdeeBillApprovedEvent; +import org.springblade.transport.support.KingdeeApBillSyncSupport; +import org.springblade.transport.support.KingdeeArBillSyncSupport; import org.springblade.transport.mapper.FormalSettlementDetailFeeMapper; import org.springblade.transport.mapper.FormalSettlementDetailMapper; import org.springblade.transport.mapper.FormalSettlementChangeRecordMapper; @@ -33,7 +39,10 @@ import org.springblade.transport.pojo.entity.PaymentApplicationSettlement; import org.springblade.transport.pojo.entity.SettlementAdjustment; import org.springblade.transport.pojo.entity.SettlementAdjustmentDetail; import org.springblade.transport.pojo.vo.SettlementAdjustmentVO; +import org.springblade.transport.service.IFormalSettlementService; import org.springblade.transport.service.ISettlementAdjustmentService; +import org.springblade.transport.service.ISettlementProjectAdvanceWriteOffService; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -51,6 +60,7 @@ import java.util.Set; @Service @RequiredArgsConstructor +@Slf4j public class SettlementAdjustmentServiceImpl extends BaseServiceImpl implements ISettlementAdjustmentService { private static final String PAID = "paid"; @@ -63,6 +73,11 @@ public class SettlementAdjustmentServiceImpl extends BaseServiceImpl selectPage(IPage page, SettlementAdjustmentVO query) { @@ -204,24 +219,142 @@ public class SettlementAdjustmentServiceImpl extends BaseServiceImpl + * 非事务编排:克隆+应用(独立事务)→ 推送(外部远程)→ 收尾回填,推送失败不回滚克隆, + * 重试时按来源原单号幂等复用已克隆新单,从推送步骤续上。 + */ @Override - @Transactional(rollbackFor = Exception.class) public String repush(Long adjustmentId) { SettlementAdjustment adjustment = existing(adjustmentId); - if (!TransportBusiConstant.APPROVED.equals(adjustment.getApprovalStatus())) throw new ServiceException("仅审批通过的结算调整单允许重新推送"); - FormalSettlement formal = formalMapper.selectById(adjustment.getFormalSettlementId()); - if (formal == null || !"synced".equals(formal.getKingdeeSyncStatus())) throw new ServiceException("关联正式结算单尚未推送金蝶,无需重新推送"); - String kingdeeNo = "K3AP" + DateTimeFormatter.ofPattern("yyyyMMddHHmmss").format(LocalDateTime.now()); - formal.setKingdeeBillNo(kingdeeNo); formal.setSyncedTime(LocalDateTime.now()); formalMapper.updateById(formal); - return kingdeeNo; + if (!TransportBusiConstant.APPROVED.equals(adjustment.getApprovalStatus())) { + throw new ServiceException("仅审批通过的结算调整单允许重新推送"); + } + FormalSettlement original = formalMapper.selectById(adjustment.getFormalSettlementId()); + if (original == null) { + throw new ServiceException("关联正式结算单不存在"); + } + if (!KingdeeBfiConstant.KINGDEE_STATUS_SYNCED.equals(original.getKingdeeSyncStatus())) { + throw new ServiceException("结算单[" + Func.toStr(original.getFormalSettlementNo()) + + "]尚未推送金蝶,请先执行「推送金蝶」"); + } + // 冲销硬闸门:仅当金蝶侧原应付/应收单已被冲销(部分冲销放行)才允许以新单号重推,避免重复生成财务单据 + if (!this.kingdeeWrittenOff(original)) { + throw new ServiceException("结算单[" + Func.toStr(original.getFormalSettlementNo()) + + "]的原应付/应收单尚未在金蝶冲销,禁止重推"); + } + // 幂等:同一原单已克隆过新单则复用(重试从推送步骤续上),避免重复建单 + FormalSettlement clone = existingClone(original.getFormalSettlementNo()); + if (clone == null) { + Long cloneId = self().cloneAndApplyForRepush(original.getId(), adjustment.getId()); + clone = formalMapper.selectById(cloneId); + } + // 推送新单:新单号天然组成新防重键,内部完成 A102-2/A302-2 推送 + 落锁 + 附件上传 + // shortcut: 成功后若再次点击重推,调整单已改挂新单且新单未冲销,会被冲销闸门拦截(无副作用);如需彻底隐藏由前端按「关联单是否克隆单」判断 + String billNo = formalSettlementService.syncKingdee(clone.getId()); + // 成功收尾:调整单改挂新单 + 回填金蝶单号/同步状态;原单标记已调整 + 旧锁解锁(业务数据不动) + SettlementAdjustment patch = new SettlementAdjustment(); + patch.setId(adjustment.getId()); + patch.setFormalSettlementId(clone.getId()); + patch.setFormalSettlementNo(clone.getFormalSettlementNo()); + patch.setKingdeeSyncStatus(KingdeeBfiConstant.KINGDEE_STATUS_SYNCED); + patch.setKingdeeBillNo(billNo); + updateById(patch); + formalSettlementService.markSettlementAdjusted(original.getId(), adjustment.getAdjustmentNo(), clone.getFormalSettlementNo()); + return billNo; } @Override @Transactional(rollbackFor = Exception.class) - public void approve(SettlementAdjustmentStatusRequest request) { + public Long cloneAndApplyForRepush(Long originalSettlementId, Long adjustmentId) { + IFormalSettlementService.CloneResult cloneResult = formalSettlementService.cloneFormalSettlement(originalSettlementId); + FormalSettlement clone = formalMapper.selectById(cloneResult.settlementId()); + applyAdjustmentToSettlement(clone, existing(adjustmentId), cloneResult.detailIdMap(), cloneResult.feeIdMap()); + return cloneResult.settlementId(); + } + + @Override + public String syncKingdee(Long adjustmentId) { + SettlementAdjustment adjustment = existing(adjustmentId); + if (!TransportBusiConstant.APPROVED.equals(adjustment.getApprovalStatus())) { + throw new ServiceException("仅审批通过的结算调整单允许推送金蝶"); + } + // 未推送/生成失败场景:推送关联正式结算单,成功后由正式结算单回填调整单金蝶单号与同步状态 + return formalSettlementService.syncKingdee(adjustment.getFormalSettlementId()); + } + + @Override + public Map kingdeeWriteOffPrecheck(Long formalSettlementId) { + Map result = new HashMap<>(); + FormalSettlement settlement = formalMapper.selectById(formalSettlementId); + if (settlement == null) { + throw new ServiceException("关联正式结算单不存在"); + } + if (!KingdeeBfiConstant.KINGDEE_STATUS_SYNCED.equals(settlement.getKingdeeSyncStatus())) { + result.put("syncStatus", KingdeeBfiConstant.KINGDEE_STATUS_UNSYNCED); + result.put("writtenOff", null); + result.put("message", "该结算单尚未推送金蝶,审批通过后将自动推送原单"); + return result; + } + result.put("syncStatus", KingdeeBfiConstant.KINGDEE_STATUS_SYNCED); + try { + boolean writtenOff = this.kingdeeWrittenOff(settlement); + result.put("writtenOff", writtenOff); + result.put("message", writtenOff + ? "该结算单已推送金蝶且旧单已冲销,审批后可直接重新推送新单" + : "该结算单已推送金蝶且旧单尚未冲销,审批通过后需先由财务在金蝶完成冲销,才能重新推送"); + } catch (Exception exception) { + // 预检为软提示:金蝶查询异常降级为「未校验」,不阻断建单,由审批分流与重推闸门兜底 + log.warn("结算调整单建单预检:金蝶冲销状态查询失败,降级未校验,正式结算单[{}]", + settlement.getFormalSettlementNo(), exception); + result.put("writtenOff", null); + result.put("message", "金蝶冲销状态查询失败,未校验;审批后请以实际冲销情况为准"); + } + return result; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public String approve(SettlementAdjustmentStatusRequest request) { SettlementAdjustment adjustment = existing(request.getId()); if (!TransportBusiConstant.REVIEWING.equals(adjustment.getApprovalStatus())) throw new ServiceException("仅审批中的结算调整单允许审核"); FormalSettlement formal = adjustableFormalSettlement(adjustment.getFormalSettlementId()); + boolean synced = KingdeeBfiConstant.KINGDEE_STATUS_SYNCED.equals(formal.getKingdeeSyncStatus()); + if (synced) { + // 已推送场景:原单业务数据零改动,仅追加 1 条重推提示变更记录,待财务在金蝶手工冲销旧单后重推 + saveFormalChange(formal.getId(), "合计费用项", null, "调整", + "结算调整单" + adjustment.getAdjustmentNo() + "审批通过,调整金额【" + money(adjustment.getAdjustmentAmount()) + "】,待重推金蝶", + adjustment.getRemark()); + } else { + // 未推送场景:回写原单费用/金额并重算结算金额,事务提交后由事件监听自动推送金蝶 + applyAdjustmentToSettlement(formal, adjustment, Map.of(), Map.of()); + saveFormalChange(formal.getId(), "合计费用项", null, "调整", + "调整单" + adjustment.getAdjustmentNo() + "调整金额【" + money(adjustment.getAdjustmentAmount()) + "】", + adjustment.getRemark()); + } + adjustment.setKingdeeSyncStatus(formal.getKingdeeSyncStatus()); adjustment.setApprovalStatus(TransportBusiConstant.APPROVED); adjustment.setCurrentNode("审批通过"); adjustment.setCurrentProcessor(AuthUtil.getUserName()); adjustment.setApprovedTime(LocalDateTime.now()); updateById(adjustment); + if (!synced) { + // 审批事务提交后(AFTER_COMMIT)自动推原单,成功回填调整单 synced + 金蝶单号、失败置 failed + eventPublisher.publishEvent(new KingdeeBillApprovedEvent( + KingdeeBillApprovedEvent.BIZ_FORMAL_SETTLEMENT, formal.getId())); + return null; + } + return "原结算单已推送金蝶,请财务人员在金蝶手工完成旧应付/应收单冲销处理后再重新推送"; + } + + /** + * 应用调整到目标正式结算单(未推送场景作用于原单,重推场景作用于克隆新单): + * 回写明细费项金额、汇总费用项、重算明细与结算金额;明细/费项 id 经映射转换(原单调整作用于原单时传空映射)。 + * + * @param formal 目标正式结算单(改动后需落库) + * @param adjustment 结算调整单 + * @param detailIdMap 原结算明细id -> 目标结算明细id(原单场景传空 Map) + * @param feeIdMap 原明细费项id -> 目标明细费项id(原单场景传空 Map) + */ + private void applyAdjustmentToSettlement(FormalSettlement formal, SettlementAdjustment adjustment, + Map detailIdMap, Map feeIdMap) { for (SettlementAdjustmentDetail item : detailMapper.selectList(Wrappers.lambdaQuery().eq(SettlementAdjustmentDetail::getAdjustmentId, adjustment.getId()))) { if (money(item.getAdjustmentAmountTax()).signum() == 0) continue; if (isManualFee(item)) { @@ -229,12 +362,14 @@ public class SettlementAdjustmentServiceImpl extends BaseServiceImpl 0) { + throw new ServiceException("调整后结算金额不能低于项目预付核销合计" + money(writeOffTotal) + ",请先调减项目预付核销"); + } formal.setSettlementAmount(amount); formal.setLocalSettlementAmount(amount.multiply(formal.getExchangeRate() == null ? BigDecimal.ONE : formal.getExchangeRate())); - formal.setRemainingPayableAmount(amount.subtract(money(formal.getPaidAmount())) - .max(BigDecimal.ZERO)); + formal.setRemainingPayableAmount(amount.subtract(money(formal.getPaidAmount())).max(BigDecimal.ZERO)); formalMapper.updateById(formal); - saveFormalChange(formal.getId(), "合计费用项", null, "调整", - "调整单" + adjustment.getAdjustmentNo() + "调整金额【" + money(adjustment.getAdjustmentAmount()) + "】", - adjustment.getRemark()); - adjustment.setKingdeeSyncStatus(formal.getKingdeeSyncStatus()); adjustment.setApprovalStatus(TransportBusiConstant.APPROVED); adjustment.setCurrentNode("审批通过"); adjustment.setCurrentProcessor(AuthUtil.getUserName()); adjustment.setApprovedTime(LocalDateTime.now()); updateById(adjustment); + // 重算收付汇总(含核销计入),保持付款状态与剩余可付准确 + formalSettlementService.refreshPaymentSummary(formal.getId()); + } + + /** 涉事事务的自代理:跨方法调用确保 {@link #cloneAndApplyForRepush} 的事务注解生效 */ + private ISettlementAdjustmentService self() { + return SpringUtil.getBean(ISettlementAdjustmentService.class); + } + + /** 按来源原正式结算单号查已克隆新单(重推幂等:同一原单不重复建单) */ + private FormalSettlement existingClone(String originFormalSettlementNo) { + List clones = formalMapper.selectList(Wrappers.lambdaQuery() + .eq(FormalSettlement::getOriginFormalSettlementNo, originFormalSettlementNo) + .orderByDesc(FormalSettlement::getCreateTime)); + return clones.isEmpty() ? null : clones.get(0); + } + + /** 按原单旧防重键(原结算单号_明细单据号)查金蝶是否已冲销(应付 A102-4 / 应收 A302-4,部分冲销放行) */ + private boolean kingdeeWrittenOff(FormalSettlement settlement) { + List details = formalDetailMapper.selectList(Wrappers.lambdaQuery() + .eq(FormalSettlementDetail::getFormalSettlementId, settlement.getId()) + .eq(FormalSettlementDetail::getIsDeleted, 0) + .orderByAsc(FormalSettlementDetail::getLineNo)); + if (Func.isEmpty(details)) { + return false; + } + if ("receivable".equals(settlement.getSettlementType())) { + return kingdeeArBillSyncSupport.queryArBillWrittenOff(kingdeeArBillSyncSupport.arBillSrcCode(settlement, details)); + } + return kingdeeApBillSyncSupport.queryApBillWrittenOff(kingdeeApBillSyncSupport.apBillSrcCode(settlement, details)); } private void saveFormalChange(Long settlementId, String changeType, Integer lineNo, @@ -392,7 +558,12 @@ public class SettlementAdjustmentServiceImpl extends BaseServiceImpllambdaQuery().likeRight(SettlementAdjustment::getAdjustmentNo, prefix)); return prefix + String.format("%04d", count + 1); } + /** 生成当日结算调整单号;按最大流水号 +1,且计入逻辑删除数据 */ + private synchronized String nextNo() { + String prefix = "TZ" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); + Long maxSequence = baseMapper.selectMaxDailySequence(AuthUtil.getTenantId(), prefix); + return prefix + String.format("%04d", (maxSequence == null ? 0L : maxSequence) + 1); + } private BigDecimal money(BigDecimal value) { return value == null ? BigDecimal.ZERO : value; } private String requiredText(String value, String field, int max) { if (Func.isEmpty(value)) throw new ServiceException(field + "不能为空"); return limit(value.trim(), max); } private String limit(String value, int max) { if (value != null && value.length() > max) throw new ServiceException("内容不能超过" + max + "个字"); return value; } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementImportBatchServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementImportBatchServiceImpl.java new file mode 100644 index 00000000..31f0a8b0 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementImportBatchServiceImpl.java @@ -0,0 +1,1405 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springblade.common.excel.ImportFailureExcelUtil; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.core.mp.base.BaseServiceImpl; +import org.springblade.core.secure.utils.AuthUtil; +import org.springblade.core.tool.api.R; +import org.springblade.core.tool.jackson.JsonUtil; +import org.springblade.core.tool.utils.BeanUtil; +import org.springblade.core.tool.utils.DateUtil; +import org.springblade.core.tool.utils.Func; +import org.springblade.system.cache.DictBizCache; +import org.springblade.system.cache.UserCache; +import org.springblade.system.feign.ISysClient; +import org.springblade.system.pojo.entity.DictBiz; +import org.springblade.system.pojo.entity.FeeItem; +import org.springblade.transport.constant.TransportBusiConstant; +import org.springblade.transport.excel.SettlementImportFeeReader; +import org.springblade.transport.excel.SettlementImportTemplateExcel; +import org.springblade.transport.excel.SettlementImportTemplateExporter; +import org.springblade.transport.mapper.SettlementImportBatchMapper; +import org.springblade.transport.pojo.dto.FormalSettlementSaveRequest; +import org.springblade.transport.pojo.dto.FormalSettlementStatusRequest; +import org.springblade.transport.pojo.dto.PreSettlementSaveRequest; +import org.springblade.transport.pojo.dto.PreSettlementStatusRequest; +import org.springblade.transport.pojo.dto.SettlementImportBatchRequest; +import org.springblade.transport.pojo.dto.SettlementImportVoidRequest; +import org.springblade.transport.pojo.dto.WaybillImportBatchRequest; +import org.springblade.transport.pojo.entity.ContractManage; +import org.springblade.transport.pojo.entity.FormalSettlement; +import org.springblade.transport.pojo.entity.PreSettlement; +import org.springblade.transport.pojo.entity.ProjectApply; +import org.springblade.transport.pojo.entity.ReceivablePayableDetail; +import org.springblade.transport.pojo.entity.SettlementImportBatch; +import org.springblade.transport.pojo.entity.Waybill; +import org.springblade.transport.pojo.vo.SettlementImportBatchVO; +import org.springblade.transport.pojo.vo.WaybillImportBatchVO; +import org.springblade.transport.pojo.vo.WaybillImportRowErrorVO; +import org.springblade.transport.pojo.vo.WaybillImportValidateResultVO; +import org.springblade.transport.service.IContractManageService; +import org.springblade.transport.service.IFormalSettlementService; +import org.springblade.transport.service.IPreSettlementService; +import org.springblade.transport.service.IProjectApplyService; +import org.springblade.transport.service.IReceivablePayableDetailService; +import org.springblade.transport.service.ISettlementImportBatchService; +import org.springblade.transport.service.IWaybillImportBatchService; +import org.springblade.transport.service.IWaybillService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 导入结算单批次服务实现 + * + * @author Chill + */ +@Service +@RequiredArgsConstructor +public class SettlementImportBatchServiceImpl + extends BaseServiceImpl + implements ISettlementImportBatchService { + + private static final String STATUS_DRAFT = "draft"; + private static final String STATUS_COMPLETED = "completed"; + private static final String STATUS_VOIDED = "voided"; + private static final String WAYBILL_MODE_NEW = "new"; + private static final String WAYBILL_MODE_HISTORY = "history"; + private static final String TYPE_RECEIVABLE = "receivable"; + private static final String TYPE_PAYABLE = "payable"; + private static final String BILL_TYPE_FORMAL = "formal"; + private static final String BILL_TYPE_PRE = "pre"; + private static final String[] VEHICLE_ALIAS_KEYS = { + "*车牌号/航班号/船号/班列号", + "车牌号/航班号/船号/班列号", + "*车牌号/船班号/船次号/班次号", + "车牌号/船班号/船次号/班次号", + "车号" + }; + + private final IProjectApplyService projectApplyService; + private final IContractManageService contractManageService; + private final IWaybillImportBatchService waybillImportBatchService; + private final IWaybillService waybillService; + private final IReceivablePayableDetailService receivablePayableDetailService; + private final IFormalSettlementService formalSettlementService; + private final IPreSettlementService preSettlementService; + private final ISysClient sysClient; + + @Override + public Map>> options() { + Map>> options = new LinkedHashMap<>(); + options.put("projects", projectApplyService.list().stream() + .filter(project -> List.of("approved", "change_approved").contains(project.getApprovalStatus())) + .map(project -> option(project.getId(), "projectName", project.getProjectName(), + "customerName", project.getCustomerNames())) + .toList()); + List contracts = contractManageService.list(Wrappers.lambdaQuery() + .eq(ContractManage::getIsDeleted, 0) + .in(ContractManage::getApprovalStatus, "approved", "change_approved") + .and(wrapper -> wrapper.isNull(ContractManage::getContractStage) + .or().ne(ContractManage::getContractStage, "terminated"))); + options.put("customerContracts", contracts.stream() + .filter(contract -> "客户合同".equals(contract.getContractCategory())) + .map(contract -> option(contract.getId(), "contractName", contract.getContractName(), + "projectId", contract.getProjectId(), "partyA", contract.getPartyA(), "partyB", contract.getPartyB())) + .toList()); + options.put("carrierContracts", contracts.stream() + .filter(contract -> "承运商合同".equals(contract.getContractCategory())) + .map(contract -> option(contract.getId(), "contractName", contract.getContractName(), + "projectId", contract.getProjectId(), "partyA", contract.getPartyA(), "partyB", contract.getPartyB())) + .toList()); + options.put("batchNos", list(Wrappers.lambdaQuery() + .select(SettlementImportBatch::getBatchNo) + .eq(SettlementImportBatch::getIsDeleted, 0) + .isNotNull(SettlementImportBatch::getBatchNo) + .orderByDesc(SettlementImportBatch::getCreateTime)).stream() + .map(SettlementImportBatch::getBatchNo) + .filter(Func::isNotEmpty) + .distinct() + .map(batchNo -> { + Map item = new LinkedHashMap<>(); + item.put("id", batchNo); + item.put("name", batchNo); + return item; + }) + .toList()); + Set creatorIds = new LinkedHashSet<>(); + list(Wrappers.lambdaQuery() + .select(SettlementImportBatch::getCreateUser) + .eq(SettlementImportBatch::getIsDeleted, 0) + .isNotNull(SettlementImportBatch::getCreateUser)) + .stream() + .map(SettlementImportBatch::getCreateUser) + .filter(Objects::nonNull) + .forEach(creatorIds::add); + options.put("creators", creatorIds.stream() + .map(userId -> option(userId, "name", UserCache.getUserRealName(userId))) + .toList()); + return options; + } + + @Override + public String nextBatchNo() { + return nextCode(); + } + + @Override + public IPage page(IPage page, SettlementImportBatchRequest request) { + LambdaQueryWrapper wrapper = Wrappers.lambdaQuery() + .eq(SettlementImportBatch::getIsDeleted, 0) + .like(Func.isNotEmpty(request.getBatchNo()), SettlementImportBatch::getBatchNo, request.getBatchNo()) + .eq(Func.isNotEmpty(request.getSettlementType()), SettlementImportBatch::getSettlementType, request.getSettlementType()) + .eq(Func.isNotEmpty(request.getImportStatus()), SettlementImportBatch::getImportStatus, request.getImportStatus()) + .like(Func.isNotEmpty(request.getProjectName()), SettlementImportBatch::getProjectName, request.getProjectName()) + .like(Func.isNotEmpty(request.getCustomerName()), SettlementImportBatch::getCustomerName, request.getCustomerName()) + .like(Func.isNotEmpty(request.getCarrierName()), SettlementImportBatch::getCarrierName, request.getCarrierName()) + .eq(request.getCreateUser() != null, SettlementImportBatch::getCreateUser, request.getCreateUser()) + .orderByDesc(SettlementImportBatch::getCreateTime); + IPage entityPage = page(page, wrapper); + List records = entityPage.getRecords().stream().map(this::toVO).toList(); + Page resultPage = new Page<>(entityPage.getCurrent(), entityPage.getSize(), entityPage.getTotal()); + resultPage.setRecords(records); + return resultPage; + } + + @Override + public SettlementImportBatchVO detail(Long id) { + SettlementImportBatch batch = getById(id); + if (batch == null || Objects.equals(batch.getIsDeleted(), 1)) { + throw new ServiceException("导入结算单批次不存在"); + } + SettlementImportBatchVO vo = toVO(batch); + List> rows = parseRowsJson(batch.getRowsJson()); + if (Func.isEmpty(rows) && !STATUS_DRAFT.equals(batch.getImportStatus())) { + rows = buildRowsFromWaybills(listBatchWaybills(batch)); + } + vo.setRows(rows); + return vo; + } + + @Override + public void exportTemplate(HttpServletResponse response) { + SettlementImportTemplateExporter.export(response, loadTransportTypeLabels(), loadEnabledFeeItemNames()); + } + + @Override + public Map parse(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new ServiceException("上传文件不能为空"); + } + SettlementImportFeeReader.ParseResult result = SettlementImportFeeReader.read(file); + Map data = new LinkedHashMap<>(); + data.put("rows", result.getRows()); + data.put("feeItemNames", result.getFeeItemNames()); + data.put("headers", result.getHeaders()); + data.put("warnings", result.getWarnings()); + return data; + } + + @Override + public WaybillImportValidateResultVO validate(SettlementImportBatchRequest request) { + prepareSettlementRows(request); + String waybillMode = resolveWaybillMode(request); + List errors = new ArrayList<>(); + if (WAYBILL_MODE_HISTORY.equals(waybillMode)) { + mergeErrors(errors, validateHistoryWaybillMatches(request)); + } else { + WaybillImportValidateResultVO waybillResult = waybillImportBatchService.validate(toWaybillRequest(request)); + if (waybillResult.getErrors() != null) { + errors.addAll(waybillResult.getErrors()); + } + } + Map settlementErrors = validateSettlementExtras(request); + mergeErrors(errors, settlementErrors); + WaybillImportValidateResultVO result = new WaybillImportValidateResultVO(); + result.setErrors(errors); + result.setSuccess(errors.isEmpty()); + return result; + } + + @Override + public void exportFailures(SettlementImportBatchRequest request, MultipartFile file, HttpServletResponse response) { + if (Func.isEmpty(request.getRows())) { + throw new ServiceException("请至少提供一条失败明细"); + } + List failureList = new ArrayList<>(); + for (Map row : request.getRows()) { + keepBlankVehicleNo(row); + SettlementImportTemplateExcel excel = mapToExcel(row); + excel.setErrorMessage(stringValue(row, "errorMessage", "导入失败原因", "失败原因")); + failureList.add(excel); + } + String fileName = "导入结算单失败明细" + DateUtil.time(); + if (file != null && !file.isEmpty()) { + ImportFailureExcelUtil.exportOriginalWithFailures(response, fileName, "导入失败明细", + file, failureList, SettlementImportTemplateExcel.class); + return; + } + ImportFailureExcelUtil.export(response, fileName, "导入失败明细", failureList, SettlementImportTemplateExcel.class); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public SettlementImportBatchVO saveDraft(SettlementImportBatchRequest request) { + prepareSettlementRows(request); + SettlementImportBatch batch = buildDraftBatch(request); + if (Func.isNotEmpty(request.getId())) { + SettlementImportBatch oldBatch = getById(request.getId()); + if (oldBatch == null || Objects.equals(oldBatch.getIsDeleted(), 1)) { + throw new ServiceException("导入结算单批次不存在"); + } + if (!STATUS_DRAFT.equals(oldBatch.getImportStatus())) { + throw new ServiceException("仅草稿状态的批次允许编辑"); + } + batch.setId(oldBatch.getId()); + batch.setBatchNo(oldBatch.getBatchNo()); + } + List> draftRows = sanitizeRowsForStorage(request.getRows()); + batch.setRowsJson(JsonUtil.toJson(draftRows)); + batch.setWaybillCount(primaryRows(draftRows).size()); + batch.setDetailCount(draftRows.size()); + saveOrUpdate(batch); + SettlementImportBatchVO vo = toVO(batch); + vo.setRows(draftRows); + return vo; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public SettlementImportBatchVO confirm(SettlementImportBatchRequest request) { + validateForm(request); + String waybillMode = resolveWaybillMode(request); + prepareSettlementRows(request); + WaybillImportValidateResultVO validateResult = validate(request); + if (!Boolean.TRUE.equals(validateResult.getSuccess())) { + String firstError = validateResult.getErrors().isEmpty() + ? "校验失败" : validateResult.getErrors().get(0).getErrorMessage(); + Integer firstIndex = validateResult.getErrors().isEmpty() + ? 0 : validateResult.getErrors().get(0).getRowIndex(); + throw new ServiceException("第" + (firstIndex + 1) + "行校验失败:" + firstError); + } + + SettlementImportBatch batch = buildBatch(request, STATUS_COMPLETED); + if (Func.isNotEmpty(request.getId())) { + SettlementImportBatch oldBatch = getById(request.getId()); + if (oldBatch == null || Objects.equals(oldBatch.getIsDeleted(), 1)) { + throw new ServiceException("导入结算单批次不存在"); + } + if (!STATUS_DRAFT.equals(oldBatch.getImportStatus())) { + throw new ServiceException("仅草稿状态的批次允许确认导入"); + } + batch.setId(oldBatch.getId()); + batch.setBatchNo(oldBatch.getBatchNo()); + } + saveOrUpdate(batch); + + List waybills; + Long waybillImportBatchId = null; + if (WAYBILL_MODE_HISTORY.equals(waybillMode)) { + // 历史运单:匹配已有运单(不新建),再按导入费用生成应收应付明细与预/正式结算单 + waybills = matchHistoryWaybills(request); + if (Func.isEmpty(waybills)) { + throw new ServiceException("未匹配到系统已有运单"); + } + List matchedIds = waybills.stream().map(Waybill::getId).filter(Objects::nonNull).toList(); + waybillService.update(Wrappers.lambdaUpdate() + .in(Waybill::getId, matchedIds) + .eq(Waybill::getIsDeleted, 0) + .set(Waybill::getSettlementImportBatchId, batch.getId())); + waybills.forEach(waybill -> waybill.setSettlementImportBatchId(batch.getId())); + } else { + // 新运单:同步生成运单,再按导入费用生成应收应付明细与预/正式结算单 + WaybillImportBatchRequest waybillRequest = toWaybillRequest(request); + waybillRequest.setCustomerName(batch.getCustomerName()); + waybillRequest.setContractName(batch.getCustomerContractName()); + waybillRequest.setCarrierName(batch.getCarrierName()); + waybillRequest.setStatus(STATUS_COMPLETED); + waybillRequest.setImportType("settlement"); + waybillRequest.setAppend(Boolean.FALSE); + WaybillImportBatchVO waybillBatch = waybillImportBatchService.confirm(waybillRequest); + waybillImportBatchId = waybillBatch.getId(); + waybills = waybillService.list(Wrappers.lambdaQuery() + .eq(Waybill::getImportBatchId, waybillBatch.getId()) + .eq(Waybill::getIsDeleted, 0) + .orderByAsc(Waybill::getCreateTime) + .orderByAsc(Waybill::getId)); + if (Func.isNotEmpty(waybills)) { + waybillService.update(Wrappers.lambdaUpdate() + .eq(Waybill::getImportBatchId, waybillBatch.getId()) + .eq(Waybill::getIsDeleted, 0) + .set(Waybill::getSettlementImportBatchId, batch.getId())); + waybills.forEach(waybill -> waybill.setSettlementImportBatchId(batch.getId())); + } + } + + // 明细与结算单费用项目/金额一律取自导入行(运输费 + 动态费用列),不取运单原系统计价 + List> feeRows = buildFeeRows(request.getRows(), waybills); + Long settlementContractId = TYPE_PAYABLE.equals(request.getSettlementType()) + ? request.getCarrierContractId() : request.getCustomerContractId(); + List detailIds = receivablePayableDetailService.createDetailsFromSettlementImport( + request.getSettlementType(), settlementContractId, feeRows); + String settlementBillType = resolveSettlementBillType(request); + if (Func.isEmpty(detailIds)) { + throw new ServiceException("未生成应收应付明细,无法创建" + + (BILL_TYPE_PRE.equals(settlementBillType) ? "预结算单" : "正式结算单")); + } + + batch.setWaybillImportBatchId(waybillImportBatchId); + batch.setWaybillCount(waybills.size()); + batch.setDetailCount(detailIds.size()); + batch.setSettlementBillType(settlementBillType); + if (BILL_TYPE_PRE.equals(settlementBillType)) { + PreSettlementSaveRequest preRequest = new PreSettlementSaveRequest(); + preRequest.setContractId(settlementContractId); + preRequest.setSettlementType(request.getSettlementType()); + preRequest.setSourceDetailIds(detailIds); + preRequest.setRemark(limit(request.getRemark(), 200)); + Long preSettlementId = preSettlementService.saveDraft(preRequest); + PreSettlement preSettlement = preSettlementService.getById(preSettlementId); + batch.setPreSettlementId(preSettlementId); + batch.setPreSettlementNo(preSettlement == null ? null : preSettlement.getPreSettlementNo()); + batch.setFormalSettlementId(null); + batch.setFormalSettlementNo(null); + } else { + FormalSettlementSaveRequest formalRequest = new FormalSettlementSaveRequest(); + formalRequest.setContractId(settlementContractId); + formalRequest.setSettlementType(request.getSettlementType()); + formalRequest.setSourceDetailIds(detailIds); + formalRequest.setRemark(limit(request.getRemark(), 200)); + Long formalSettlementId = formalSettlementService.saveDraft(formalRequest); + FormalSettlement formalSettlement = formalSettlementService.getById(formalSettlementId); + batch.setFormalSettlementId(formalSettlementId); + batch.setFormalSettlementNo(formalSettlement == null ? null : formalSettlement.getFormalSettlementNo()); + batch.setPreSettlementId(null); + batch.setPreSettlementNo(null); + } + batch.setImportStatus(STATUS_COMPLETED); + batch.setFeeItemNamesJson(JsonUtil.toJson(resolveFeeItemNames(request))); + batch.setRowsJson(JsonUtil.toJson(sanitizeRowsForStorage(request.getRows()))); + updateById(batch); + SettlementImportBatchVO vo = toVO(batch); + vo.setRows(parseRowsJson(batch.getRowsJson())); + return vo; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void removeDraft(Long id) { + if (id == null) { + throw new ServiceException("请选择需要删除的批次"); + } + SettlementImportBatch batch = getById(id); + if (batch == null || Objects.equals(batch.getIsDeleted(), 1)) { + throw new ServiceException("导入结算单批次不存在"); + } + if (!STATUS_DRAFT.equals(batch.getImportStatus())) { + throw new ServiceException("仅草稿状态的批次允许删除"); + } + deleteLogic(List.of(id)); + } + + @Override + public List> historyWaybills(SettlementImportBatchRequest request) { + if (request == null || Func.isEmpty(request.getProjectId()) + || Func.isEmpty(request.getCustomerContractId()) + || Func.isEmpty(request.getCarrierContractId())) { + throw new ServiceException("请先选择项目、客户合同和承运商合同"); + } + String settlementType = Func.isEmpty(request.getSettlementType()) + ? TYPE_RECEIVABLE : request.getSettlementType(); + if (!TYPE_RECEIVABLE.equals(settlementType) && !TYPE_PAYABLE.equals(settlementType)) { + throw new ServiceException("结算类型不正确"); + } + LambdaQueryWrapper wrapper = Wrappers.lambdaQuery() + .eq(Waybill::getIsDeleted, 0) + .eq(Waybill::getProjectId, request.getProjectId()) + .eq(Waybill::getContractId, request.getCustomerContractId()) + .eq(Waybill::getCarrierContractId, request.getCarrierContractId()) + .apply("not exists (select 1 from blade_receivable_payable_detail d" + + " where d.waybill_id = blade_waybill.id and d.settlement_type = {0}" + + " and d.is_deleted = 0)", settlementType) + .orderByDesc(Waybill::getCreateTime) + .last("limit 500"); + List waybills = waybillService.list(wrapper); + List> result = new ArrayList<>(); + for (Waybill waybill : waybills) { + Map item = new LinkedHashMap<>(); + item.put("id", waybill.getId()); + item.put("waybillNo", waybill.getWaybillNo()); + item.put("vehicleNo", waybill.getVehicleNo()); + item.put("transportType", waybill.getTransportType()); + item.put("departureAddress", waybill.getDepartureAddress()); + item.put("arrivalAddress", waybill.getArrivalAddress()); + item.put("cargoName", waybill.getCargoName()); + item.put("cargoType", waybill.getCargoType()); + item.put("quantity", waybill.getQuantity()); + item.put("quantityUnit", waybill.getQuantityUnit()); + item.put("model", waybill.getModel()); + item.put("mileage", waybill.getMileage()); + item.put("actualStartDate", waybill.getStartDate()); + item.put("actualEndDate", waybill.getEndDate()); + item.put("driverName", waybill.getDriverName()); + result.add(item); + } + return result; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void voidBatch(SettlementImportVoidRequest request) { + if (request == null || request.getId() == null) { + throw new ServiceException("请选择需要作废的批次"); + } + SettlementImportBatch batch = getById(request.getId()); + if (batch == null || Objects.equals(batch.getIsDeleted(), 1)) { + throw new ServiceException("导入结算单批次不存在"); + } + if (STATUS_VOIDED.equals(batch.getImportStatus())) { + throw new ServiceException("该批次已作废"); + } + + boolean draftSettlement = false; + if (batch.getFormalSettlementId() != null) { + FormalSettlement settlement = formalSettlementService.getById(batch.getFormalSettlementId()); + if (settlement != null && !Objects.equals(settlement.getIsDeleted(), 1)) { + if (TransportBusiConstant.DRAFT.equals(settlement.getApprovalStatus()) + || TransportBusiConstant.RETURNED.equals(settlement.getApprovalStatus())) { + formalSettlementService.removeDraft(settlement.getId()); + draftSettlement = true; + } else if (TransportBusiConstant.APPROVED.equals(settlement.getApprovalStatus()) + && !"synced".equals(settlement.getKingdeeSyncStatus())) { + FormalSettlementStatusRequest voidRequest = new FormalSettlementStatusRequest(); + voidRequest.setId(settlement.getId()); + voidRequest.setReason(Func.isEmpty(request.getReason()) ? "导入结算单批次作废" : request.getReason()); + formalSettlementService.voidBill(voidRequest); + } else { + throw new ServiceException("当前正式结算单状态不允许作废"); + } + } + } + if (batch.getPreSettlementId() != null) { + PreSettlement settlement = preSettlementService.getById(batch.getPreSettlementId()); + if (settlement != null && !Objects.equals(settlement.getIsDeleted(), 1)) { + if (TransportBusiConstant.DRAFT.equals(settlement.getApprovalStatus())) { + preSettlementService.removeDraft(settlement.getId()); + draftSettlement = true; + } else if (TransportBusiConstant.APPROVED.equals(settlement.getApprovalStatus())) { + PreSettlementStatusRequest voidRequest = new PreSettlementStatusRequest(); + voidRequest.setId(settlement.getId()); + voidRequest.setReason(Func.isEmpty(request.getReason()) ? "导入结算单批次作废" : request.getReason()); + preSettlementService.voidBill(voidRequest); + } else { + throw new ServiceException("当前预结算单状态不允许作废"); + } + } + } + + List waybills = listBatchWaybills(batch); + List waybillIds = waybills.stream().map(Waybill::getId).filter(Objects::nonNull).toList(); + boolean historyMode = WAYBILL_MODE_HISTORY.equals(batch.getWaybillMode()); + if (draftSettlement && Func.isNotEmpty(waybillIds)) { + LambdaQueryWrapper detailWrapper = Wrappers.lambdaQuery() + .in(ReceivablePayableDetail::getWaybillId, waybillIds) + .eq(ReceivablePayableDetail::getIsDeleted, 0); + if (historyMode) { + // 历史运单仅清理本批次结算导入产生的明细,保留运单原有费用明细 + detailWrapper.eq(ReceivablePayableDetail::getSourceType, "结算导入"); + } + List details = receivablePayableDetailService.list(detailWrapper); + if (Func.isNotEmpty(details)) { + receivablePayableDetailService.deleteLogic(details.stream().map(ReceivablePayableDetail::getId).toList()); + } + } + if (Func.isNotEmpty(waybillIds)) { + if (historyMode) { + // 历史运单不可删除,仅解除与导入批次的关联 + waybillService.update(Wrappers.lambdaUpdate() + .in(Waybill::getId, waybillIds) + .eq(Waybill::getSettlementImportBatchId, batch.getId()) + .set(Waybill::getSettlementImportBatchId, null)); + } else { + waybillService.deleteLogic(waybillIds); + } + } + batch.setImportStatus(STATUS_VOIDED); + batch.setRemark(mergeRemark(batch.getRemark(), request.getReason())); + updateById(batch); + } + + private List listBatchWaybills(SettlementImportBatch batch) { + LambdaQueryWrapper wrapper = Wrappers.lambdaQuery().eq(Waybill::getIsDeleted, 0); + if (batch.getId() != null && batch.getWaybillImportBatchId() != null) { + wrapper.and(query -> query.eq(Waybill::getSettlementImportBatchId, batch.getId()) + .or().eq(Waybill::getImportBatchId, batch.getWaybillImportBatchId())); + } else if (batch.getId() != null) { + wrapper.eq(Waybill::getSettlementImportBatchId, batch.getId()); + } else if (batch.getWaybillImportBatchId() != null) { + wrapper.eq(Waybill::getImportBatchId, batch.getWaybillImportBatchId()); + } else { + return List.of(); + } + return waybillService.list(wrapper); + } + + private void validateForm(SettlementImportBatchRequest request) { + if (request == null) { + throw new ServiceException("请求不能为空"); + } + if (Func.isEmpty(request.getProjectId())) { + throw new ServiceException("请选择项目"); + } + if (Func.isEmpty(request.getSettlementType()) + || (!TYPE_RECEIVABLE.equals(request.getSettlementType()) && !TYPE_PAYABLE.equals(request.getSettlementType()))) { + throw new ServiceException("结算类型仅支持 receivable 或 payable"); + } + resolveSettlementBillType(request); + resolveWaybillMode(request); + if (Func.isEmpty(request.getCustomerContractId())) { + throw new ServiceException("请选择客户合同"); + } + if (Func.isEmpty(request.getCarrierContractId())) { + throw new ServiceException("请选择承运商合同"); + } + if (Func.isEmpty(request.getRows())) { + throw new ServiceException("请上传至少一条运单明细"); + } + } + + /** 运单类型:new=新运单,history=历史运单。 */ + private String resolveWaybillMode(SettlementImportBatchRequest request) { + String waybillMode = Func.toStr(request == null ? null : request.getWaybillMode(), WAYBILL_MODE_NEW); + if (!WAYBILL_MODE_NEW.equals(waybillMode) && !WAYBILL_MODE_HISTORY.equals(waybillMode)) { + throw new ServiceException("运单类型仅支持新运单或历史运单"); + } + if (request != null) { + request.setWaybillMode(waybillMode); + } + return waybillMode; + } + + /** + * 历史运单匹配校验。 + *

+ * 规则:项目 + 车号 + 运输完成日期 + 客户 + 承运商 + 起运地 + 目的地 + 货物名称; + * 命中 1 条自动绑定;命中 ≥2 条需手工指定运单号;已填写运单号时按运单号精确匹配。 + */ + private Map validateHistoryWaybillMatches(SettlementImportBatchRequest request) { + Map errorMap = new LinkedHashMap<>(); + List> rows = request.getRows(); + Set matchedWaybillIds = new HashSet<>(); + for (Map row : primaryRows(rows)) { + int index = rows.indexOf(row); + if (index < 0) { + continue; + } + try { + Waybill waybill = matchHistoryWaybill(request, row); + row.put("waybillNo", waybill.getWaybillNo()); + row.put("matchedWaybillId", waybill.getId()); + if (!matchedWaybillIds.add(waybill.getId())) { + errorMap.put(index, "匹配到的运单与其他行重复:" + + Func.toStr(waybill.getWaybillNo(), String.valueOf(waybill.getId()))); + } + } catch (ServiceException exception) { + errorMap.put(index, exception.getMessage()); + } + } + return errorMap; + } + + private List matchHistoryWaybills(SettlementImportBatchRequest request) { + List waybills = new ArrayList<>(); + for (Map row : primaryRows(request.getRows())) { + Waybill waybill = matchHistoryWaybill(request, row); + row.put("waybillNo", waybill.getWaybillNo()); + row.put("matchedWaybillId", waybill.getId()); + waybills.add(waybill); + } + return waybills; + } + + /** + * 历史运单自动匹配:优先手工运单号,否则按业务维度唯一匹配。 + */ + private Waybill matchHistoryWaybill(SettlementImportBatchRequest request, Map row) { + String manualWaybillNo = stringValue(row, "waybillNo", "运单号", "matchedWaybillNo"); + if (Func.isNotEmpty(manualWaybillNo)) { + return matchHistoryWaybillByNo(request, manualWaybillNo); + } + String vehicleNo = stringValue(row, "vehicleNo", "*车牌号/航班号/船号/班列号", + "车牌号/航班号/船号/班列号", "*车牌号/船班号/船次号/班次号", "车牌号/船班号/船次号/班次号"); + String actualEndDateText = stringValue(row, "actualEndDate", "*实际完成时间", "实际完成时间", "endDate", "结束时间"); + String departureAddress = stringValue(row, "departureAddress", "*发货地址", "发货地址"); + String arrivalAddress = stringValue(row, "arrivalAddress", "*到货地址", "到货地址"); + String cargoName = stringValue(row, "cargoName", "*货物名称", "货物名称"); + if (Func.isEmpty(vehicleNo) || Func.isEmpty(actualEndDateText) || Func.isEmpty(departureAddress) + || Func.isEmpty(arrivalAddress) || Func.isEmpty(cargoName)) { + throw new ServiceException("历史运单需填写车号、运输完成日期、起运地、目的地、货物名称以匹配系统已有运单"); + } + LocalDate endDate = org.springblade.common.excel.LenientDateParser.parseDateLenientlyOrNull(actualEndDateText); + if (endDate == null) { + throw new ServiceException("运输完成日期格式不正确:" + actualEndDateText); + } + LambdaQueryWrapper wrapper = Wrappers.lambdaQuery() + .eq(Waybill::getIsDeleted, 0) + .eq(Waybill::getProjectId, request.getProjectId()) + .eq(Waybill::getContractId, request.getCustomerContractId()) + .eq(Waybill::getCarrierContractId, request.getCarrierContractId()) + .eq(Waybill::getVehicleNo, vehicleNo) + .eq(Waybill::getEndDate, endDate) + .eq(Waybill::getDepartureAddress, departureAddress) + .eq(Waybill::getArrivalAddress, arrivalAddress) + .eq(Waybill::getCargoName, cargoName); + applyUnsettledWaybillFilter(wrapper, request.getSettlementType()); + wrapper.orderByDesc(Waybill::getCreateTime).last("limit 3"); + List matches = waybillService.list(wrapper); + if (Func.isEmpty(matches)) { + throw new ServiceException("未匹配到系统已有运单(项目+车号+运输完成日期+客户+承运商+起运地+目的地+货物名称)"); + } + if (matches.size() > 1) { + throw new ServiceException("匹配到多条运单,无法自动识别,请手工指定运单号"); + } + return matches.get(0); + } + + /** 手工指定运单号时,在当前项目/客户/承运商范围内精确匹配。 */ + private Waybill matchHistoryWaybillByNo(SettlementImportBatchRequest request, String waybillNo) { + LambdaQueryWrapper wrapper = Wrappers.lambdaQuery() + .eq(Waybill::getIsDeleted, 0) + .eq(Waybill::getProjectId, request.getProjectId()) + .eq(Waybill::getContractId, request.getCustomerContractId()) + .eq(Waybill::getCarrierContractId, request.getCarrierContractId()) + .eq(Waybill::getWaybillNo, waybillNo); + applyUnsettledWaybillFilter(wrapper, request.getSettlementType()); + wrapper.orderByDesc(Waybill::getCreateTime).last("limit 2"); + List matches = waybillService.list(wrapper); + if (Func.isEmpty(matches)) { + throw new ServiceException("未匹配到运单号「" + waybillNo + "」对应的未结算运单"); + } + if (matches.size() > 1) { + throw new ServiceException("运单号「" + waybillNo + "」匹配到多条运单,请核对后重试"); + } + return matches.get(0); + } + + /** 仅保留尚未生成对应结算类型应收/应付明细的运单。 */ + private void applyUnsettledWaybillFilter(LambdaQueryWrapper wrapper, String settlementType) { + String type = Func.isEmpty(settlementType) ? TYPE_RECEIVABLE : settlementType; + wrapper.apply("not exists (select 1 from blade_receivable_payable_detail d" + + " where d.waybill_id = blade_waybill.id and d.settlement_type = {0}" + + " and d.is_deleted = 0)", type); + } + + private void prepareSettlementRows(SettlementImportBatchRequest request) { + if (request == null || Func.isEmpty(request.getRows())) { + return; + } + List feeItemNames = resolveFeeItemNames(request); + for (Map row : request.getRows()) { + if (row == null) { + continue; + } + keepBlankVehicleNo(row); + BigDecimal transportFee = decimalValue(row, "transportFee", "*运输费", "运输费", "freight", "运费"); + if (transportFee == null) { + transportFee = BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP); + } + Map feeItems = extractFeeItems(row, feeItemNames); + BigDecimal feeItemTotal = feeItems.values().stream() + .reduce(BigDecimal.ZERO, BigDecimal::add).setScale(2, RoundingMode.HALF_UP); + BigDecimal settlementAmount = decimalValue(row, "settlementAmount", "结算金额"); + if (settlementAmount == null) { + settlementAmount = transportFee.add(feeItemTotal).setScale(2, RoundingMode.HALF_UP); + } else { + settlementAmount = settlementAmount.setScale(2, RoundingMode.HALF_UP); + } + row.put("transportFee", transportFee); + row.put("feeItems", feeItems); + row.put("settlementAmount", settlementAmount); + row.put("freight", transportFee); + row.put("运费", transportFee); + row.put("otherFeeTotal", feeItemTotal); + row.put("其他费用合计", feeItemTotal); + row.put("freightTotal", settlementAmount); + row.put("运费合计", settlementAmount); + BigDecimal unitPrice = decimalValue(row, "unitPrice", "运输单价", "单价"); + if (unitPrice != null) { + row.put("unitPrice", unitPrice); + row.put("单价", unitPrice); + row.put("运输单价", unitPrice); + } + } + request.setFeeItemNames(feeItemNames); + } + + @SuppressWarnings("unchecked") + private Map extractFeeItems(Map row, List feeItemNames) { + Map feeItems = new LinkedHashMap<>(); + Object existing = row.get("feeItems"); + if (existing instanceof Map raw) { + raw.forEach((key, value) -> { + if (key != null) { + feeItems.put(String.valueOf(key), money(decimal(value))); + } + }); + } + if (Func.isNotEmpty(feeItemNames)) { + for (String name : feeItemNames) { + if (Func.isEmpty(name) || feeItems.containsKey(name)) { + continue; + } + BigDecimal amount = decimalValue(row, name); + if (amount != null) { + feeItems.put(name, money(amount)); + } + } + } + return feeItems; + } + + private Map validateSettlementExtras(SettlementImportBatchRequest request) { + Map errorMap = new LinkedHashMap<>(); + Set enabledFeeItemNames = new HashSet<>(); + Set disabledFeeItemNames = new HashSet<>(); + loadFeeItemNames(enabledFeeItemNames, disabledFeeItemNames); + List feeItemNames = resolveFeeItemNames(request); + List> rows = request.getRows(); + for (int index = 0; index < rows.size(); index++) { + List errors = new ArrayList<>(); + Map row = rows.get(index); + BigDecimal transportFee = decimalValue(row, "transportFee", "*运输费", "运输费", "freight", "运费"); + if (transportFee == null) { + errors.add("*运输费不能为空"); + } else if (transportFee.compareTo(BigDecimal.ZERO) < 0) { + errors.add("*运输费必须大于等于0"); + } + Map feeItems = extractFeeItems(row, feeItemNames); + for (Map.Entry entry : feeItems.entrySet()) { + String name = normalizeFeeName(entry.getKey()); + BigDecimal amount = entry.getValue() == null ? BigDecimal.ZERO : entry.getValue(); + // 用户自行增列的费用项金额为 0 时跳过校验;金额不为 0 须匹配系统已启用费用项 + if (Func.isEmpty(name) || amount.compareTo(BigDecimal.ZERO) == 0) { + continue; + } + if (enabledFeeItemNames.contains(name)) { + continue; + } + if (disabledFeeItemNames.contains(name)) { + errors.add("费用项「" + name + "」已停用"); + continue; + } + errors.add("费用项「" + name + "」未在费用项主数据中匹配"); + } + if (!errors.isEmpty()) { + errorMap.put(index, String.join("; ", errors)); + } + } + return errorMap; + } + + private void loadFeeItemNames(Set enabledFeeItemNames, Set disabledFeeItemNames) { + R> response = sysClient.getFeeItems(true); + if (response == null || !response.isSuccess() || response.getData() == null) { + throw new ServiceException("费用项基础档案读取失败,请稍后重试"); + } + for (FeeItem feeItem : response.getData()) { + String name = normalizeFeeName(feeItem.getName()); + if (Func.isEmpty(name)) { + continue; + } + if (feeItem.getStatus() == null || Objects.equals(feeItem.getStatus(), 1)) { + enabledFeeItemNames.add(name); + } else { + disabledFeeItemNames.add(name); + } + } + disabledFeeItemNames.removeAll(enabledFeeItemNames); + } + + private String normalizeFeeName(String name) { + return name == null ? "" : name.trim(); + } + + private List resolveFeeItemNames(SettlementImportBatchRequest request) { + if (request.getFeeItemNames() != null) { + return request.getFeeItemNames().stream().filter(Func::isNotEmpty).distinct().toList(); + } + Set names = new LinkedHashSet<>(); + if (Func.isNotEmpty(request.getRows())) { + for (Map row : request.getRows()) { + Object feeItems = row.get("feeItems"); + if (feeItems instanceof Map raw) { + raw.keySet().stream().filter(Objects::nonNull).map(String::valueOf).forEach(names::add); + } + } + } + return new ArrayList<>(names); + } + + private List> buildFeeRows(List> rows, List waybills) { + List> primaryRows = primaryRows(rows); + List> feeRows = new ArrayList<>(); + int size = Math.min(primaryRows.size(), waybills.size()); + for (int index = 0; index < size; index++) { + Map row = primaryRows.get(index); + Waybill waybill = waybills.get(index); + Map feeRow = new LinkedHashMap<>(); + feeRow.put("waybillId", waybill.getId()); + feeRow.put("transportFee", decimalValue(row, "transportFee", "*运输费", "运输费", "freight", "运费")); + feeRow.put("unitPrice", decimalValue(row, "unitPrice", "运输单价", "单价")); + feeRow.put("feeItems", extractFeeItems(row, resolveFeeItemNamesFromRow(row))); + feeRow.put("remark", stringValue(row, "remark", "备注")); + feeRows.add(feeRow); + } + return feeRows; + } + + @SuppressWarnings("unchecked") + private List resolveFeeItemNamesFromRow(Map row) { + Object feeItems = row.get("feeItems"); + if (!(feeItems instanceof Map raw)) { + return List.of(); + } + return raw.keySet().stream().filter(Objects::nonNull).map(String::valueOf).toList(); + } + + /** 与运单导入一致:同一运单标识号仅保留首行作为主行。 */ + private List> primaryRows(List> rows) { + List> primary = new ArrayList<>(); + Set seenIdentifiers = new HashSet<>(); + for (Map row : rows) { + String identifier = stringValue(row, "waybillIdentifier", "同一运单标识号"); + if (Func.isNotEmpty(identifier)) { + if (seenIdentifiers.contains(identifier)) { + continue; + } + seenIdentifiers.add(identifier); + } + primary.add(row); + } + return primary; + } + + private WaybillImportBatchRequest toWaybillRequest(SettlementImportBatchRequest request) { + WaybillImportBatchRequest waybillRequest = new WaybillImportBatchRequest(); + waybillRequest.setProjectId(request.getProjectId()); + waybillRequest.setProjectName(request.getProjectName()); + waybillRequest.setCustomerName(request.getCustomerName()); + waybillRequest.setContractId(request.getCustomerContractId()); + waybillRequest.setContractName(request.getCustomerContractName()); + waybillRequest.setCarrierContractId(request.getCarrierContractId()); + waybillRequest.setCarrierName(request.getCarrierName()); + waybillRequest.setCarrierType("承运商"); + waybillRequest.setImportType("settlement"); + waybillRequest.setStatus(STATUS_COMPLETED); + waybillRequest.setRemark(request.getRemark()); + waybillRequest.setRows(request.getRows()); + waybillRequest.setAppend(request.getAppend()); + return waybillRequest; + } + + private SettlementImportBatch buildDraftBatch(SettlementImportBatchRequest request) { + SettlementImportBatch batch = new SettlementImportBatch(); + batch.setBatchNo(Func.isEmpty(request.getId()) ? nextCode() : request.getBatchNo()); + batch.setSettlementType(Func.isEmpty(request.getSettlementType()) ? TYPE_RECEIVABLE : request.getSettlementType()); + batch.setSettlementBillType(resolveSettlementBillType(request)); + batch.setWaybillMode(resolveWaybillMode(request)); + batch.setImportStatus(STATUS_DRAFT); + batch.setRemark(limit(request.getRemark(), 200)); + batch.setFeeItemNamesJson(JsonUtil.toJson(resolveFeeItemNames(request))); + fillProjectAndContracts(batch, request, false); + return batch; + } + + private SettlementImportBatch buildBatch(SettlementImportBatchRequest request, String importStatus) { + SettlementImportBatch batch = new SettlementImportBatch(); + batch.setBatchNo(Func.isEmpty(request.getId()) ? nextCode() : request.getBatchNo()); + batch.setSettlementType(request.getSettlementType()); + batch.setSettlementBillType(resolveSettlementBillType(request)); + batch.setWaybillMode(resolveWaybillMode(request)); + batch.setImportStatus(importStatus); + batch.setRemark(limit(request.getRemark(), 200)); + batch.setFeeItemNamesJson(JsonUtil.toJson(resolveFeeItemNames(request))); + batch.setWaybillCount(0); + batch.setDetailCount(0); + fillProjectAndContracts(batch, request, true); + return batch; + } + + /** 应收固定正式结算;应付可选 formal/pre,缺省 formal。 */ + private String resolveSettlementBillType(SettlementImportBatchRequest request) { + String settlementType = request == null ? null : request.getSettlementType(); + String billType = request == null ? null : request.getSettlementBillType(); + if (!TYPE_PAYABLE.equals(settlementType)) { + return BILL_TYPE_FORMAL; + } + if (Func.isEmpty(billType)) { + return BILL_TYPE_FORMAL; + } + if (!BILL_TYPE_FORMAL.equals(billType) && !BILL_TYPE_PRE.equals(billType)) { + throw new ServiceException("结算单类型仅支持 formal 或 pre"); + } + return billType; + } + + private void fillProjectAndContracts(SettlementImportBatch batch, SettlementImportBatchRequest request, boolean required) { + if (Func.isEmpty(request.getProjectId())) { + if (required) { + throw new ServiceException("请选择项目"); + } + return; + } + ProjectApply project = projectApplyService.getById(request.getProjectId()); + if (project == null || !List.of("approved", "change_approved").contains(project.getApprovalStatus())) { + throw new ServiceException("仅允许选择审核通过或变更审核通过的项目"); + } + batch.setProjectId(project.getId()); + batch.setProjectName(project.getProjectName()); + if (Func.isNotEmpty(request.getCustomerContractId()) || required) { + ContractManage customerContract = requireContract(request.getCustomerContractId(), "客户合同"); + if (!"客户合同".equals(customerContract.getContractCategory())) { + throw new ServiceException("客户合同类别不正确"); + } + batch.setCustomerContractId(customerContract.getId()); + batch.setCustomerContractName(customerContract.getContractName()); + batch.setCustomerName(Func.isEmpty(request.getCustomerName()) + ? customerContract.getPartyA() : request.getCustomerName()); + } + if (Func.isNotEmpty(request.getCarrierContractId()) || required) { + ContractManage carrierContract = requireContract(request.getCarrierContractId(), "承运商合同"); + if (!"承运商合同".equals(carrierContract.getContractCategory())) { + throw new ServiceException("承运商合同类别不正确"); + } + batch.setCarrierContractId(carrierContract.getId()); + batch.setCarrierContractName(carrierContract.getContractName()); + batch.setCarrierName(Func.isEmpty(request.getCarrierName()) + ? carrierContract.getPartyB() : request.getCarrierName()); + } + if (Func.isEmpty(batch.getCustomerName()) && Func.isNotEmpty(request.getCustomerName())) { + batch.setCustomerName(request.getCustomerName()); + } + if (Func.isEmpty(batch.getCarrierName()) && Func.isNotEmpty(request.getCarrierName())) { + batch.setCarrierName(request.getCarrierName()); + } + } + + private ContractManage requireContract(Long contractId, String label) { + if (Func.isEmpty(contractId)) { + throw new ServiceException("请选择" + label); + } + ContractManage contract = contractManageService.getById(contractId); + if (contract == null || Objects.equals(contract.getIsDeleted(), 1)) { + throw new ServiceException(label + "不存在"); + } + if (!List.of("approved", "change_approved").contains(contract.getApprovalStatus()) + || "terminated".equals(contract.getContractStage())) { + throw new ServiceException(label + "未审核完成或已终止"); + } + return contract; + } + + private synchronized String nextCode() { + String prefix = "DJS" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); + Long maxSequence = baseMapper.selectMaxDailySequence(AuthUtil.getTenantId(), prefix); + return prefix + String.format("%04d", (maxSequence == null ? 0 : maxSequence) + 1); + } + + private SettlementImportBatchVO toVO(SettlementImportBatch batch) { + SettlementImportBatchVO vo = BeanUtil.copyProperties(batch, SettlementImportBatchVO.class); + if (vo == null) { + throw new ServiceException("导入结算单批次数据转换失败"); + } + vo.setCreateUserName(UserCache.getUserRealName(batch.getCreateUser())); + vo.setUpdateUserName(UserCache.getUserRealName(batch.getUpdateUser())); + vo.setImportStatusName(importStatusName(batch.getImportStatus())); + vo.setSettlementTypeName(settlementTypeName(batch.getSettlementType())); + vo.setSettlementBillTypeName(settlementBillTypeName(batch.getSettlementBillType())); + vo.setWaybillModeName(waybillModeName(batch.getWaybillMode())); + if (Func.isNotEmpty(batch.getFeeItemNamesJson())) { + vo.setFeeItemNames(JsonUtil.parseArray(batch.getFeeItemNamesJson(), String.class)); + } + return vo; + } + + private String importStatusName(String status) { + if (STATUS_DRAFT.equals(status)) { + return "草稿"; + } + if (STATUS_COMPLETED.equals(status)) { + return "已完成"; + } + if (STATUS_VOIDED.equals(status)) { + return "已作废"; + } + return Func.toStr(status); + } + + private String settlementTypeName(String settlementType) { + if (TYPE_RECEIVABLE.equals(settlementType)) { + return "应收"; + } + if (TYPE_PAYABLE.equals(settlementType)) { + return "应付"; + } + return Func.toStr(settlementType); + } + + private String settlementBillTypeName(String settlementBillType) { + if (BILL_TYPE_PRE.equals(settlementBillType)) { + return "预结算单"; + } + return "正式结算单"; + } + + private String waybillModeName(String waybillMode) { + if (WAYBILL_MODE_HISTORY.equals(waybillMode)) { + return "历史运单"; + } + return "新运单"; + } + + private void mergeErrors(List errors, Map settlementErrors) { + Map byIndex = new LinkedHashMap<>(); + for (WaybillImportRowErrorVO error : errors) { + byIndex.put(error.getRowIndex(), error); + } + for (Map.Entry entry : settlementErrors.entrySet()) { + WaybillImportRowErrorVO existing = byIndex.get(entry.getKey()); + if (existing == null) { + WaybillImportRowErrorVO error = new WaybillImportRowErrorVO(); + error.setRowIndex(entry.getKey()); + error.setErrorMessage(entry.getValue()); + errors.add(error); + } else { + existing.setErrorMessage(existing.getErrorMessage() + "; " + entry.getValue()); + } + } + } + + @SuppressWarnings("unchecked") + private SettlementImportTemplateExcel mapToExcel(Map row) { + SettlementImportTemplateExcel excel = new SettlementImportTemplateExcel(); + excel.setSerialNumber(intValue(row.get("serialNumber"))); + excel.setOriginalNo(stringValue(row, "originalNo", "原始单号")); + excel.setLoadingIdentifier(stringValue(row, "loadingIdentifier", "配载标识号")); + excel.setVehicleNo(vehicleNoValue(row)); + excel.setTransportType(stringValue(row, "transportType", "*运输方式", "运输方式")); + excel.setDriverName(stringValue(row, "driverName", "司机/船长姓名")); + excel.setDriverPhone(stringValue(row, "driverPhone", "司机/船长手机号")); + excel.setDepartureAddress(stringValue(row, "departureAddress", "*发货地址", "发货地址")); + excel.setDepartureContact(stringValue(row, "departureContact", "发货联系人")); + excel.setDeparturePhone(stringValue(row, "departurePhone", "发货联系人电话")); + excel.setArrivalAddress(stringValue(row, "arrivalAddress", "*到货地址", "到货地址")); + excel.setArrivalContact(stringValue(row, "arrivalContact", "收货联系人")); + excel.setArrivalPhone(stringValue(row, "arrivalPhone", "收货联系人电话")); + excel.setCargoName(stringValue(row, "cargoName", "*货物名称", "货物名称")); + excel.setCargoType(stringValue(row, "cargoType", "*货物类型", "货物类型")); + excel.setPackageType(stringValue(row, "packageType", "包装")); + excel.setQuantity(decimalValue(row, "quantity", "*数量", "数量")); + excel.setQuantityUnit(stringValue(row, "quantityUnit", "*数量单位", "数量单位")); + excel.setSpecification(stringValue(row, "specification", "规格")); + excel.setModel(stringValue(row, "model", "型号")); + excel.setMileage(decimalValue(row, "mileage", "里程(km)", "里程")); + excel.setUnitPrice(decimalValue(row, "unitPrice", "运输单价", "单价")); + excel.setTransportFee(decimalValue(row, "transportFee", "*运输费", "运输费")); + excel.setSettlementAmount(decimalValue(row, "settlementAmount", "结算金额")); + excel.setActualStartDate(stringValue(row, "actualStartDate", "*实际发货时间", "实际发货时间")); + excel.setActualEndDate(stringValue(row, "actualEndDate", "*实际完成时间", "实际完成时间")); + excel.setPlanStartDate(stringValue(row, "planStartDate", "预计发货时间")); + excel.setPlanEndDate(stringValue(row, "planEndDate", "预计完成时间")); + excel.setRemark(stringValue(row, "remark", "备注")); + excel.setWaybillIdentifier(stringValue(row, "waybillIdentifier", "同一运单标识号")); + return excel; + } + + /** + * 车号以 vehicleNo 为准。单元格没填时不再回退到表头别名,避免失败明细和落库被自动补上车号。 + */ + private void keepBlankVehicleNo(Map row) { + if (row == null || !row.containsKey("vehicleNo")) { + return; + } + String vehicleNo = stringValue(row, "vehicleNo"); + row.put("vehicleNo", vehicleNo); + if (Func.isNotEmpty(vehicleNo)) { + return; + } + for (String alias : VEHICLE_ALIAS_KEYS) { + row.put(alias, null); + } + } + + private String vehicleNoValue(Map row) { + if (row != null && row.containsKey("vehicleNo")) { + return stringValue(row, "vehicleNo"); + } + return stringValue(row, "vehicleNo", VEHICLE_ALIAS_KEYS); + } + + private Map option(Object id, String nameKey, Object nameValue) { + return option(id, nameKey, nameValue, null, null); + } + + private Map option(Object id, String nameKey, Object nameValue, String extraKey, Object extraValue) { + return option(id, nameKey, nameValue, extraKey, extraValue, null, null, null, null); + } + + private Map option(Object id, String nameKey, Object nameValue, + String extraKey, Object extraValue, + String thirdKey, Object thirdValue, + String fourthKey, Object fourthValue) { + Map item = new LinkedHashMap<>(); + item.put("id", id); + item.put(nameKey, nameValue); + item.put("name", nameValue); + if (Func.isNotEmpty(extraKey)) { + item.put(extraKey, extraValue); + } + if (Func.isNotEmpty(thirdKey)) { + item.put(thirdKey, thirdValue); + } + if (Func.isNotEmpty(fourthKey)) { + item.put(fourthKey, fourthValue); + } + return item; + } + + private String stringValue(Map row, String field, String... aliases) { + Object value = row.get(field); + if (value != null && !String.valueOf(value).trim().isEmpty()) { + return String.valueOf(value).trim(); + } + for (String alias : aliases) { + Object aliasValue = row.get(alias); + if (aliasValue != null && !String.valueOf(aliasValue).trim().isEmpty()) { + return String.valueOf(aliasValue).trim(); + } + } + return null; + } + + private BigDecimal decimalValue(Map row, String field, String... aliases) { + String value = stringValue(row, field, aliases); + if (Func.isEmpty(value) || "-1".equals(value)) { + return null; + } + try { + return new BigDecimal(value).setScale(2, RoundingMode.HALF_UP); + } catch (NumberFormatException exception) { + return null; + } + } + + private BigDecimal decimal(Object value) { + if (value == null || "".equals(value)) { + return BigDecimal.ZERO; + } + if (value instanceof BigDecimal bigDecimal) { + return bigDecimal; + } + try { + return new BigDecimal(String.valueOf(value).trim()).setScale(2, RoundingMode.HALF_UP); + } catch (Exception exception) { + return BigDecimal.ZERO; + } + } + + private BigDecimal money(BigDecimal value) { + return value == null ? BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP) + : value.setScale(2, RoundingMode.HALF_UP); + } + + private Integer intValue(Object value) { + if (value == null) { + return null; + } + if (value instanceof Number number) { + return number.intValue(); + } + try { + return new BigDecimal(String.valueOf(value).trim()).intValue(); + } catch (NumberFormatException exception) { + return null; + } + } + + private String limit(String value, int max) { + if (value == null) { + return null; + } + String text = value.trim(); + return text.length() <= max ? text : text.substring(0, max); + } + + private String mergeRemark(String original, String reason) { + if (Func.isEmpty(reason)) { + return original; + } + String merged = Func.isEmpty(original) ? "作废原因:" + reason : original + ";作废原因:" + reason; + return limit(merged, 200); + } + + @SuppressWarnings("unchecked") + private List> parseRowsJson(String rowsJson) { + if (Func.isEmpty(rowsJson)) { + return List.of(); + } + List rawList = JsonUtil.parseArray(rowsJson, Map.class); + if (rawList == null || rawList.isEmpty()) { + return List.of(); + } + List> rows = new ArrayList<>(); + for (Map raw : rawList) { + if (raw == null) { + continue; + } + rows.add(new LinkedHashMap<>(raw)); + } + return rows; + } + + private List> sanitizeRowsForStorage(List> rows) { + if (Func.isEmpty(rows)) { + return List.of(); + } + List> sanitized = new ArrayList<>(); + for (Map row : rows) { + if (row == null) { + continue; + } + Map copy = new LinkedHashMap<>(); + for (Map.Entry entry : row.entrySet()) { + String key = entry.getKey(); + if (key == null || key.startsWith("_")) { + continue; + } + copy.put(key, entry.getValue()); + } + sanitized.add(copy); + } + return sanitized; + } + + /** + * 运输方式取业务字典 transport_type 的字典名称(dictValue),不写死枚举。 + */ + private List loadTransportTypeLabels() { + LinkedHashSet labels = new LinkedHashSet<>(); + try { + List dictBizList = DictBizCache.getList("transport_type"); + if (Func.isNotEmpty(dictBizList)) { + dictBizList.stream() + .filter(dict -> dict != null && Func.isNotEmpty(dict.getDictValue()) + && !"-1".equals(Func.toStr(dict.getDictKey()))) + .map(dict -> dict.getDictValue().trim()) + .filter(Func::isNotEmpty) + .forEach(labels::add); + } + } catch (Exception exception) { + throw new ServiceException("业务字典 transport_type 读取失败,请检查业务字典配置"); + } + if (labels.isEmpty()) { + throw new ServiceException("业务字典 transport_type 未配置运输方式,请先维护字典名称"); + } + return new ArrayList<>(labels); + } + + private List loadEnabledFeeItemNames() { + R> response = sysClient.getFeeItems(false); + if (response == null || !response.isSuccess() || response.getData() == null) { + return List.of(); + } + return response.getData().stream() + .filter(item -> item != null && (item.getStatus() == null || Objects.equals(item.getStatus(), 1))) + .map(FeeItem::getName) + .filter(Func::isNotEmpty) + .map(String::trim) + .distinct() + .toList(); + } + + /** 历史已完成批次无 rows_json 时,按关联运单回填查看明细。 */ + private List> buildRowsFromWaybills(List waybills) { + if (Func.isEmpty(waybills)) { + return List.of(); + } + List> rows = new ArrayList<>(); + int serial = 1; + for (Waybill waybill : waybills) { + Map row = new LinkedHashMap<>(); + row.put("serialNumber", serial++); + row.put("waybillNo", waybill.getWaybillNo()); + row.put("vehicleNo", waybill.getVehicleNo()); + row.put("transportType", waybill.getTransportType()); + row.put("driverName", waybill.getDriverName()); + row.put("driverPhone", waybill.getDriverPhone()); + row.put("departureAddress", waybill.getDepartureAddress()); + row.put("departureContact", waybill.getDepartureContact()); + row.put("departurePhone", waybill.getDeparturePhone()); + row.put("arrivalAddress", waybill.getArrivalAddress()); + row.put("arrivalContact", waybill.getArrivalContact()); + row.put("arrivalPhone", waybill.getArrivalPhone()); + row.put("originalNo", waybill.getOriginalNo()); + row.put("loadingIdentifier", waybill.getLoadingIdentifier()); + row.put("cargoName", waybill.getCargoName()); + row.put("cargoType", waybill.getCargoType()); + row.put("quantity", waybill.getQuantity()); + row.put("quantityUnit", waybill.getQuantityUnit()); + row.put("specification", waybill.getSpecification()); + row.put("model", waybill.getModel()); + row.put("mileage", waybill.getMileage()); + row.put("unitPrice", waybill.getUnitPrice()); + row.put("actualStartDate", waybill.getStartDate()); + row.put("actualEndDate", waybill.getEndDate()); + row.put("remark", waybill.getRemark()); + rows.add(row); + } + return rows; + } +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementProjectAdvanceWriteOffServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementProjectAdvanceWriteOffServiceImpl.java new file mode 100644 index 00000000..473ee3d3 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementProjectAdvanceWriteOffServiceImpl.java @@ -0,0 +1,267 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import lombok.RequiredArgsConstructor; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.core.tool.utils.Func; +import org.springblade.system.cache.UserCache; +import org.springblade.transport.constant.KingdeeBfiConstant; +import org.springblade.transport.constant.TransportBusiConstant; +import org.springblade.transport.mapper.PaymentApplicationMapper; +import org.springblade.transport.mapper.SettlementProjectAdvanceWriteOffMapper; +import org.springblade.transport.pojo.dto.SettlementProjectAdvanceWriteOffRequest; +import org.springblade.transport.pojo.entity.PaymentApplication; +import org.springblade.transport.pojo.entity.SettlementProjectAdvanceWriteOff; +import org.springblade.transport.pojo.vo.ProjectAdvanceWriteOffOptionVO; +import org.springblade.transport.service.ISettlementProjectAdvanceWriteOffService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * 结算单项目预付核销服务实现 + * + * @author Chill + */ +@Service +@RequiredArgsConstructor +public class SettlementProjectAdvanceWriteOffServiceImpl implements ISettlementProjectAdvanceWriteOffService { + + private final SettlementProjectAdvanceWriteOffMapper writeOffMapper; + private final PaymentApplicationMapper paymentApplicationMapper; + + @Override + public List listOptions(Long contractId, String excludeSettlementType, + Long excludeSettlementId) { + if (contractId == null) { + throw new ServiceException("请先选择合同"); + } + List applications = paymentApplicationMapper.selectList( + Wrappers.lambdaQuery() + .eq(PaymentApplication::getContractId, contractId) + .eq(PaymentApplication::getPaymentType, TransportBusiConstant.PAYMENT_TYPE_PROJECT_ADVANCE) + .eq(PaymentApplication::getApprovalStatus, TransportBusiConstant.APPROVED) + .eq(PaymentApplication::getKingdeeStatus, KingdeeBfiConstant.KINGDEE_STATUS_PAID) + .eq(PaymentApplication::getIsDeleted, 0) + .orderByDesc(PaymentApplication::getCreateTime)); + List options = new ArrayList<>(); + for (PaymentApplication application : applications) { + BigDecimal projectAdvanceAmount = money(application.getAppliedAmount()); + BigDecimal usedAmount = money(writeOffMapper.sumWriteOffByPaymentApplication( + application.getId(), excludeSettlementType, excludeSettlementId)); + BigDecimal availableAmount = projectAdvanceAmount.subtract(usedAmount).max(BigDecimal.ZERO); + if (availableAmount.compareTo(BigDecimal.ZERO) <= 0) { + continue; + } + ProjectAdvanceWriteOffOptionVO option = new ProjectAdvanceWriteOffOptionVO(); + option.setPaymentApplicationId(application.getId()); + option.setPaymentNo(application.getPaymentNo()); + option.setProjectAdvanceAmount(projectAdvanceAmount); + option.setAvailableWriteOffAmount(availableAmount); + option.setKingdeeBillNo(application.getKingdeeBillNo()); + option.setCreateUserName(UserCache.getUserRealName(application.getCreateUser())); + option.setCreateTime(application.getCreateTime()); + options.add(option); + } + return options; + } + + @Override + public List listBySettlement(String settlementType, Long settlementId) { + if (Func.isEmpty(settlementType) || settlementId == null) { + return List.of(); + } + List rows = writeOffMapper.selectList( + Wrappers.lambdaQuery() + .eq(SettlementProjectAdvanceWriteOff::getSettlementType, settlementType) + .eq(SettlementProjectAdvanceWriteOff::getSettlementId, settlementId) + .eq(SettlementProjectAdvanceWriteOff::getIsDeleted, 0) + .orderByAsc(SettlementProjectAdvanceWriteOff::getCreateTime) + .orderByAsc(SettlementProjectAdvanceWriteOff::getId)); + for (SettlementProjectAdvanceWriteOff row : rows) { + PaymentApplication application = paymentApplicationMapper.selectById(row.getPaymentApplicationId()); + BigDecimal projectAdvanceAmount = application == null + ? money(row.getProjectAdvanceAmount()) : money(application.getAppliedAmount()); + BigDecimal usedAmount = money(writeOffMapper.sumWriteOffByPaymentApplication( + row.getPaymentApplicationId(), settlementType, settlementId)); + row.setProjectAdvanceAmount(projectAdvanceAmount); + row.setAvailableWriteOffAmount(projectAdvanceAmount.subtract(usedAmount).max(BigDecimal.ZERO)); + if (application != null) { + row.setPaymentNo(application.getPaymentNo()); + row.setKingdeeBillNo(application.getKingdeeBillNo()); + row.setCreateUserName(UserCache.getUserRealName(application.getCreateUser())); + row.setPaymentCreateTime(application.getCreateTime()); + } else { + row.setCreateUserName(UserCache.getUserRealName(row.getCreateUser())); + row.setPaymentCreateTime(row.getCreateTime()); + } + } + return rows; + } + + @Override + public BigDecimal sumBySettlement(String settlementType, Long settlementId) { + if (Func.isEmpty(settlementType) || settlementId == null) { + return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP); + } + return money(writeOffMapper.sumWriteOffBySettlement(settlementType, settlementId)); + } + + @Override + public Map sumBySettlementIds(String settlementType, Collection settlementIds) { + if (Func.isEmpty(settlementType) || Func.isEmpty(settlementIds)) { + return Map.of(); + } + Map result = new HashMap<>(); + for (Map row : writeOffMapper.sumWriteOffBySettlementIds(settlementType, settlementIds)) { + if (row.get("settlementId") instanceof Number settlementId && row.get("totalAmount") instanceof BigDecimal totalAmount) { + result.put(settlementId.longValue(), money(totalAmount)); + } + } + return result; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void replaceWriteOffs(String settlementType, Long settlementId, Long contractId, + BigDecimal settlementAmount, List writeOffs) { + if (Func.isEmpty(settlementType) || settlementId == null) { + throw new ServiceException("结算单不能为空"); + } + if (contractId == null) { + throw new ServiceException("合同不能为空"); + } + List requestRows = writeOffs == null ? List.of() : writeOffs; + BigDecimal settlementLimit = money(settlementAmount); + BigDecimal totalWriteOff = BigDecimal.ZERO; + Set paymentIds = new HashSet<>(); + List entities = new ArrayList<>(); + for (SettlementProjectAdvanceWriteOffRequest request : requestRows) { + if (request == null || request.getPaymentApplicationId() == null) { + throw new ServiceException("请选择项目预付单"); + } + if (!paymentIds.add(request.getPaymentApplicationId())) { + throw new ServiceException("同一项目预付单不可重复核销"); + } + BigDecimal writeOffAmount = money(request.getWriteOffAmount()); + if (writeOffAmount.compareTo(BigDecimal.ZERO) <= 0) { + throw new ServiceException("本次核销金额必须大于0"); + } + PaymentApplication application = paymentApplicationMapper.selectById(request.getPaymentApplicationId()); + if (application == null || Objects.equals(application.getIsDeleted(), 1)) { + throw new ServiceException("项目预付单不存在"); + } + if (!TransportBusiConstant.PAYMENT_TYPE_PROJECT_ADVANCE.equals(application.getPaymentType())) { + throw new ServiceException("仅可核销项目预付单"); + } + if (!TransportBusiConstant.APPROVED.equals(application.getApprovalStatus())) { + throw new ServiceException("仅可选择审核通过的项目预付单"); + } + if (!KingdeeBfiConstant.KINGDEE_STATUS_PAID.equals(application.getKingdeeStatus())) { + throw new ServiceException("预付单" + Func.toStr(application.getPaymentNo(), "") + "尚未付款,项目预付须金蝶付款完成后方可核销"); + } + if (!Objects.equals(contractId, application.getContractId())) { + throw new ServiceException("项目预付单与结算单合同不一致"); + } + BigDecimal projectAdvanceAmount = money(application.getAppliedAmount()); + BigDecimal usedAmount = money(writeOffMapper.sumWriteOffByPaymentApplication( + application.getId(), settlementType, settlementId)); + BigDecimal availableAmount = projectAdvanceAmount.subtract(usedAmount).max(BigDecimal.ZERO); + if (writeOffAmount.compareTo(availableAmount) > 0) { + throw new ServiceException("预付单" + Func.toStr(application.getPaymentNo(), "") + + "本次核销金额不能超过可核销金额"); + } + totalWriteOff = totalWriteOff.add(writeOffAmount); + SettlementProjectAdvanceWriteOff entity = new SettlementProjectAdvanceWriteOff(); + entity.setSettlementType(settlementType); + entity.setSettlementId(settlementId); + entity.setPaymentApplicationId(application.getId()); + entity.setPaymentNo(application.getPaymentNo()); + entity.setProjectAdvanceAmount(projectAdvanceAmount); + entity.setWriteOffAmount(writeOffAmount); + entity.setKingdeeBillNo(application.getKingdeeBillNo()); + entity.setStatus(1); + entity.setIsDeleted(0); + entities.add(entity); + } + if (totalWriteOff.compareTo(settlementLimit) > 0) { + throw new ServiceException("项目预付核销合计不能超过结算单金额"); + } + writeOffMapper.delete(Wrappers.lambdaQuery() + .eq(SettlementProjectAdvanceWriteOff::getSettlementType, settlementType) + .eq(SettlementProjectAdvanceWriteOff::getSettlementId, settlementId)); + for (SettlementProjectAdvanceWriteOff entity : entities) { + writeOffMapper.insert(entity); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteBySettlement(String settlementType, Long settlementId) { + if (Func.isEmpty(settlementType) || settlementId == null) { + return; + } + writeOffMapper.delete(Wrappers.lambdaQuery() + .eq(SettlementProjectAdvanceWriteOff::getSettlementType, settlementType) + .eq(SettlementProjectAdvanceWriteOff::getSettlementId, settlementId)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public int deleteBySettlementIds(String settlementType, Collection settlementIds) { + if (Func.isEmpty(settlementType) || Func.isEmpty(settlementIds)) { + return 0; + } + return writeOffMapper.delete(Wrappers.lambdaQuery() + .eq(SettlementProjectAdvanceWriteOff::getSettlementType, settlementType) + .in(SettlementProjectAdvanceWriteOff::getSettlementId, settlementIds)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public List deleteByPaymentApplicationIds(Collection paymentApplicationIds) { + if (Func.isEmpty(paymentApplicationIds)) { + return List.of(); + } + List rows = writeOffMapper.selectList( + Wrappers.lambdaQuery() + .eq(SettlementProjectAdvanceWriteOff::getIsDeleted, 0) + .in(SettlementProjectAdvanceWriteOff::getPaymentApplicationId, paymentApplicationIds)); + if (rows.isEmpty()) { + return List.of(); + } + writeOffMapper.delete(Wrappers.lambdaQuery() + .in(SettlementProjectAdvanceWriteOff::getPaymentApplicationId, paymentApplicationIds)); + return rows.stream() + .filter(row -> TransportBusiConstant.SETTLEMENT_BILL_FORMAL.equals(row.getSettlementType())) + .map(SettlementProjectAdvanceWriteOff::getSettlementId) + .filter(Objects::nonNull) + .distinct() + .toList(); + } + + private BigDecimal money(BigDecimal value) { + return value == null ? BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP) + : value.setScale(2, RoundingMode.HALF_UP); + } + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementRuleAutoGenerateServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementRuleAutoGenerateServiceImpl.java new file mode 100644 index 00000000..67818e9e --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementRuleAutoGenerateServiceImpl.java @@ -0,0 +1,622 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springblade.core.tenant.TenantUtil; +import org.springblade.core.tool.jackson.JsonUtil; +import org.springblade.core.tool.utils.Func; +import org.springblade.transport.constant.TransportBusiConstant; +import org.springblade.transport.mapper.PreSettlementDetailMapper; +import org.springblade.transport.mapper.ReceivablePayableDetailMapper; +import org.springblade.transport.pojo.dto.FormalSettlementSaveRequest; +import org.springblade.transport.pojo.dto.PreSettlementSaveRequest; +import org.springblade.transport.pojo.entity.ContractManage; +import org.springblade.transport.pojo.entity.PreSettlement; +import org.springblade.transport.pojo.entity.PreSettlementDetail; +import org.springblade.transport.pojo.entity.ReceivablePayableDetail; +import org.springblade.transport.service.IContractManageService; +import org.springblade.transport.service.IFormalSettlementService; +import org.springblade.transport.service.IPreSettlementService; +import org.springblade.transport.service.ISettlementRuleAutoGenerateService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.YearMonth; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * 按合同结算规则自动生成结算草稿。 + *

+ * 账期在次日零点视为关闭:日结/现结取前一日,周结和固定天数从账单开始日期顺延, + * 月结按自然月、固定截单日或自定义多周期截取。没有可归集明细时不生成空单。 + * 同一明细只进入一张结算单,预结算先于正式结算执行。正式结算同时归集费用日期完整落在账期内、且尚未转正式结算的已审批预结算单。 + * + * @author Chill + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class SettlementRuleAutoGenerateServiceImpl implements ISettlementRuleAutoGenerateService { + + private static final int QUERY_BATCH_SIZE = 500; + + private static final String LOCAL_CURRENCY = "RMB"; + + private final IContractManageService contractManageService; + + private final IPreSettlementService preSettlementService; + + private final IFormalSettlementService formalSettlementService; + + private final ReceivablePayableDetailMapper receivablePayableDetailMapper; + + private final PreSettlementDetailMapper preSettlementDetailMapper; + + private final PlatformTransactionManager transactionManager; + + @Override + public synchronized String generateDueSettlements() { + LocalDate today = LocalDate.now(); + List contracts = TenantUtil.ignore(this::listAutoContracts); + int preCount = 0; + int formalCount = 0; + int failedCount = 0; + for (ContractManage contract : contracts) { + if (Func.isBlank(contract.getTenantId())) { + log.warn("合同[{}]缺少租户,跳过自动生成结算单", contract.getContractNo()); + continue; + } + try { + int[] created = TenantUtil.use(contract.getTenantId(), () -> generateContract(contract, today)); + preCount += created[0]; + formalCount += created[1]; + } catch (Exception exception) { + failedCount++; + log.error("合同[{}]自动生成结算单失败", contract.getContractNo(), exception); + } + } + return "扫描合同" + contracts.size() + "份,生成预结算单" + preCount + "张,正式结算单" + formalCount + + "张,失败" + failedCount + "份"; + } + + private List listAutoContracts() { + return contractManageService.list(Wrappers.lambdaQuery() + .in(ContractManage::getApprovalStatus, "approved", "change_approved") + .in(ContractManage::getContractCategory, "客户合同", "承运商合同") + .and(query -> query.isNull(ContractManage::getContractStage) + .or().ne(ContractManage::getContractStage, "terminated")) + .and(query -> query.like(ContractManage::getPreSettlementConfigJson, "autoGenerate") + .or().like(ContractManage::getFormalSettlementConfigJson, "autoGenerate") + .or().like(ContractManage::getSettlementRuleJson, "autoGenerate"))); + } + + private int[] generateContract(ContractManage contract, LocalDate today) { + String settlementType = contractSettlementType(contract); + if (settlementType == null || contract.getProjectId() == null || contract.getOrganizationId() == null) { + log.warn("合同[{}]缺少结算类型、项目或所属组织,跳过自动生成", contract.getContractNo()); + return new int[]{0, 0}; + } + int preCount = generateBills(contract, settlementType, resolveRule(contract, true), today, true); + int formalCount = generateBills(contract, settlementType, resolveRule(contract, false), today, false); + return new int[]{preCount, formalCount}; + } + + private int generateBills(ContractManage contract, String settlementType, Map rule, + LocalDate today, boolean preBill) { + if (!enabled(rule.get("autoGenerate"))) { + return 0; + } + String cycleName = Func.toStr(rule.get("settlementType")).trim(); + if (!knownCycle(cycleName)) { + log.warn("合同[{}]{}结算周期[{}]无法识别,跳过自动生成", contract.getContractNo(), + preBill ? "预" : "正式", cycleName); + return 0; + } + List ranges = closedPeriods(rule, today); + if (ranges.isEmpty()) { + return 0; + } + if (preBill) { + return createPreBills(contract, ranges); + } + return createFormalBills(contract, settlementType, ranges); + } + + private int createPreBills(ContractManage contract, List ranges) { + List details = loadPendingDetails(contract, ranges); + Map> grouped = groupDetails(ranges, details); + int created = 0; + for (Map.Entry> entry : grouped.entrySet()) { + Map> byCurrency = new LinkedHashMap<>(); + for (ReceivablePayableDetail detail : entry.getValue()) { + String currency = Func.isEmpty(detail.getCurrency()) ? LOCAL_CURRENCY : detail.getCurrency(); + byCurrency.computeIfAbsent(currency, key -> new ArrayList<>()).add(detail.getId()); + } + for (List detailIds : byCurrency.values()) { + try { + inTransaction(() -> { + PreSettlementSaveRequest request = new PreSettlementSaveRequest(); + request.setContractId(contract.getId()); + request.setSourceDetailIds(detailIds); + request.setRemark(periodRemark(entry.getKey())); + preSettlementService.saveDraft(request); + }); + created++; + log.info("合同[{}]自动生成预结算单,账期{}至{},明细{}条", contract.getContractNo(), + entry.getKey().start(), entry.getKey().end(), detailIds.size()); + } catch (Exception exception) { + log.error("合同[{}]自动生成预结算单失败,账期{}至{}", contract.getContractNo(), + entry.getKey().start(), entry.getKey().end(), exception); + } + } + } + return created; + } + + private int createFormalBills(ContractManage contract, String settlementType, List ranges) { + Map> detailGroups = groupDetails(ranges, loadPendingDetails(contract, ranges)); + Map> preGroups = groupApprovedPres(contract, settlementType, ranges); + int created = 0; + for (DateRange range : ranges) { + Map> detailIds = new LinkedHashMap<>(); + for (ReceivablePayableDetail detail : detailGroups.getOrDefault(range, List.of())) { + String currency = detail.getCurrency() == null ? "" : detail.getCurrency(); + detailIds.computeIfAbsent(currency, key -> new ArrayList<>()).add(detail.getId()); + } + Map> preIds = new LinkedHashMap<>(); + for (PreSettlement preSettlement : preGroups.getOrDefault(range, List.of())) { + String currency = preSettlement.getCurrency() == null ? "" : preSettlement.getCurrency(); + preIds.computeIfAbsent(currency, key -> new ArrayList<>()).add(preSettlement.getId()); + } + Set currencies = new LinkedHashSet<>(); + currencies.addAll(detailIds.keySet()); + currencies.addAll(preIds.keySet()); + for (String currency : currencies) { + List sourcePreSettlementIds = preIds.getOrDefault(currency, List.of()); + List sourceDetailIds = detailIds.getOrDefault(currency, List.of()); + if (sourcePreSettlementIds.isEmpty() && sourceDetailIds.isEmpty()) { + continue; + } + try { + inTransaction(() -> { + FormalSettlementSaveRequest request = new FormalSettlementSaveRequest(); + request.setContractId(contract.getId()); + request.setSettlementType(settlementType); + request.setSourcePreSettlementIds(sourcePreSettlementIds.isEmpty() ? null : sourcePreSettlementIds); + request.setSourceDetailIds(sourceDetailIds.isEmpty() ? null : sourceDetailIds); + request.setRemark(periodRemark(range)); + formalSettlementService.saveDraft(request); + }); + created++; + log.info("合同[{}]自动生成正式结算单,账期{}至{},预结算{}张,明细{}条", contract.getContractNo(), + range.start(), range.end(), sourcePreSettlementIds.size(), sourceDetailIds.size()); + } catch (Exception exception) { + log.error("合同[{}]自动生成正式结算单失败,账期{}至{}", contract.getContractNo(), + range.start(), range.end(), exception); + } + } + } + return created; + } + + private List loadPendingDetails(ContractManage contract, List ranges) { + LocalDate minDate = ranges.get(0).start(); + LocalDate maxDate = ranges.get(0).end(); + for (DateRange range : ranges) { + if (range.start().isBefore(minDate)) { + minDate = range.start(); + } + if (range.end().isAfter(maxDate)) { + maxDate = range.end(); + } + } + LocalDate periodStart = minDate; + LocalDate periodEnd = maxDate; + return receivablePayableDetailMapper.selectList(Wrappers.lambdaQuery() + .eq(ReceivablePayableDetail::getIsDeleted, 0) + .eq(ReceivablePayableDetail::getContractId, contract.getId()) + .eq(ReceivablePayableDetail::getProjectId, contract.getProjectId()) + .eq(ReceivablePayableDetail::getDeptId, contract.getOrganizationId()) + .eq(ReceivablePayableDetail::getSettlementType, contractSettlementType(contract)) + .eq(ReceivablePayableDetail::getSettlementStatus, "pending") + .and(query -> query.isNull(ReceivablePayableDetail::getPreSettlementNo) + .or().eq(ReceivablePayableDetail::getPreSettlementNo, "")) + .and(query -> query.isNull(ReceivablePayableDetail::getFormalSettlementNo) + .or().eq(ReceivablePayableDetail::getFormalSettlementNo, "")) + .and(query -> query.eq(ReceivablePayableDetail::getCustomerName, contract.getPartyA()) + .or().eq(ReceivablePayableDetail::getCustomerName, contract.getPartyB())) + .and(query -> query.between(ReceivablePayableDetail::getFeeDate, periodStart, periodEnd) + .or(nested -> nested.isNull(ReceivablePayableDetail::getFeeDate) + .ge(ReceivablePayableDetail::getCreateTime, periodStart.atStartOfDay()) + .lt(ReceivablePayableDetail::getCreateTime, periodEnd.plusDays(1).atStartOfDay())))); + } + + private Map> groupApprovedPres(ContractManage contract, String settlementType, + List ranges) { + Map> grouped = new LinkedHashMap<>(); + ranges.forEach(range -> grouped.put(range, new ArrayList<>())); + List preSettlements = preSettlementService.list(Wrappers.lambdaQuery() + .eq(PreSettlement::getContractId, contract.getId()) + .eq(PreSettlement::getSettlementType, settlementType) + .eq(PreSettlement::getApprovalStatus, TransportBusiConstant.APPROVED) + .and(query -> query.isNull(PreSettlement::getFormalSettlementNo) + .or().eq(PreSettlement::getFormalSettlementNo, ""))); + if (preSettlements.isEmpty()) { + return grouped; + } + List preSettlementIds = preSettlements.stream().map(PreSettlement::getId).toList(); + Map> sourceIdsByPre = new LinkedHashMap<>(); + for (List batch : partition(preSettlementIds)) { + List lines = preSettlementDetailMapper.selectList(Wrappers.lambdaQuery() + .in(PreSettlementDetail::getPreSettlementId, batch) + .eq(PreSettlementDetail::getIsDeleted, 0)); + for (PreSettlementDetail line : lines) { + if (line.getSourceDetailId() == null) { + continue; + } + sourceIdsByPre.computeIfAbsent(line.getPreSettlementId(), key -> new ArrayList<>()).add(line.getSourceDetailId()); + } + } + Set sourceIds = new LinkedHashSet<>(); + sourceIdsByPre.values().forEach(sourceIds::addAll); + Map sourceMap = new LinkedHashMap<>(); + for (List batch : partition(sourceIds)) { + receivablePayableDetailMapper.selectBatchIds(batch) + .forEach(detail -> sourceMap.put(detail.getId(), detail)); + } + for (PreSettlement preSettlement : preSettlements) { + List sourceDetailIds = sourceIdsByPre.getOrDefault(preSettlement.getId(), List.of()); + LocalDate minDate = null; + LocalDate maxDate = null; + boolean invalid = sourceDetailIds.isEmpty(); + for (Long sourceDetailId : sourceDetailIds) { + ReceivablePayableDetail source = sourceMap.get(sourceDetailId); + LocalDate businessDate = source == null ? null : businessDate(source); + if (businessDate == null) { + invalid = true; + break; + } + minDate = minDate == null || businessDate.isBefore(minDate) ? businessDate : minDate; + maxDate = maxDate == null || businessDate.isAfter(maxDate) ? businessDate : maxDate; + } + if (invalid || minDate == null || maxDate == null) { + log.warn("预结算单[{}]缺少可归集的费用日期,跳过自动转正式结算", preSettlement.getPreSettlementNo()); + continue; + } + boolean matched = false; + for (DateRange range : ranges) { + if (!minDate.isBefore(range.start()) && !maxDate.isAfter(range.end())) { + grouped.get(range).add(preSettlement); + matched = true; + break; + } + } + if (!matched && !maxDate.isAfter(LocalDate.now().minusDays(1))) { + log.info("预结算单[{}]费用日期{}至{}未完整落入已关闭账期,暂不自动归集正式结算", + preSettlement.getPreSettlementNo(), minDate, maxDate); + } + } + return grouped; + } + + private Map> groupDetails(List ranges, + List details) { + Map> grouped = new LinkedHashMap<>(); + ranges.forEach(range -> grouped.put(range, new ArrayList<>())); + for (ReceivablePayableDetail detail : details) { + LocalDate businessDate = businessDate(detail); + if (businessDate == null) { + continue; + } + for (DateRange range : ranges) { + if (!businessDate.isBefore(range.start()) && !businessDate.isAfter(range.end())) { + grouped.get(range).add(detail); + break; + } + } + } + return grouped; + } + + private List closedPeriods(Map rule, LocalDate today) { + LocalDate billStart = dateValue(rule.get("billStartDate")); + LocalDate yesterday = today.minusDays(1); + if (billStart == null || yesterday.isBefore(billStart)) { + return List.of(); + } + String settlementType = Func.toStr(rule.get("settlementType")).trim(); + return switch (settlementType) { + case "现结", "日结" -> cyclePeriods(billStart, yesterday, 1); + case "周结" -> cyclePeriods(billStart, yesterday, 7); + case "按固定天数周期", "固定天数周期结算" -> { + Integer cycleDays = intValue(rule.get("cycleDays")); + if (cycleDays == null || cycleDays < 1 || cycleDays > 365) { + yield List.of(); + } + yield cyclePeriods(billStart, yesterday, cycleDays); + } + case "月结" -> monthlyPeriods(rule, billStart, yesterday); + default -> List.of(); + }; + } + + private List monthlyPeriods(Map rule, LocalDate billStart, LocalDate yesterday) { + String cycleType = Func.toStr(rule.get("billCycleType")).trim(); + if (Func.isBlank(cycleType) || "自然月".equals(cycleType)) { + return naturalMonthPeriods(billStart, yesterday); + } + if ("固定截单日".equals(cycleType)) { + Integer cutoffDay = intValue(rule.get("billCutoffDay")); + if (cutoffDay == null || cutoffDay < 1 || cutoffDay > 31) { + return List.of(); + } + return fixedCutoffPeriods(billStart, yesterday, cutoffDay); + } + if ("自定义多周期".equals(cycleType)) { + return customMonthPeriods(rule.get("customPeriods"), billStart, yesterday); + } + return List.of(); + } + + private List cyclePeriods(LocalDate billStart, LocalDate yesterday, int days) { + List ranges = new ArrayList<>(); + LocalDate cursor = billStart; + while (true) { + LocalDate end = cursor.plusDays(days - 1L); + if (end.isAfter(yesterday)) { + break; + } + ranges.add(new DateRange(cursor, end)); + cursor = end.plusDays(1); + } + return ranges; + } + + private List naturalMonthPeriods(LocalDate billStart, LocalDate yesterday) { + List ranges = new ArrayList<>(); + YearMonth cursor = YearMonth.from(billStart); + YearMonth lastMonth = YearMonth.from(yesterday); + while (!cursor.isAfter(lastMonth)) { + LocalDate end = cursor.atEndOfMonth(); + if (!end.isAfter(yesterday) && !end.isBefore(billStart)) { + LocalDate start = cursor.atDay(1).isBefore(billStart) ? billStart : cursor.atDay(1); + ranges.add(new DateRange(start, end)); + } + cursor = cursor.plusMonths(1); + } + return ranges; + } + + private List fixedCutoffPeriods(LocalDate billStart, LocalDate yesterday, int cutoffDay) { + List ranges = new ArrayList<>(); + YearMonth cursor = YearMonth.from(billStart).minusMonths(1); + YearMonth limit = YearMonth.from(yesterday).plusMonths(1); + LocalDate previousEnd = null; + while (!cursor.isAfter(limit)) { + LocalDate end = cursor.atDay(Math.min(cutoffDay, cursor.lengthOfMonth())); + if (!end.isBefore(billStart) && !end.isAfter(yesterday)) { + LocalDate start = previousEnd == null ? billStart : previousEnd.plusDays(1); + if (start.isBefore(billStart)) { + start = billStart; + } + if (!start.isAfter(end)) { + ranges.add(new DateRange(start, end)); + } + } + if (!end.isBefore(billStart.minusMonths(1))) { + previousEnd = end; + } + cursor = cursor.plusMonths(1); + } + return ranges; + } + + private List customMonthPeriods(Object periodsValue, LocalDate billStart, LocalDate yesterday) { + List periods = customPeriods(periodsValue); + if (periods.isEmpty()) { + return List.of(); + } + List ranges = new ArrayList<>(); + YearMonth cursor = YearMonth.from(billStart); + YearMonth lastMonth = YearMonth.from(yesterday); + while (!cursor.isAfter(lastMonth)) { + for (int[] period : periods) { + int startDay = Math.min(period[0], cursor.lengthOfMonth()); + int endDay = Math.min(period[1], cursor.lengthOfMonth()); + if (startDay > endDay) { + continue; + } + LocalDate end = cursor.atDay(endDay); + if (end.isAfter(yesterday) || end.isBefore(billStart)) { + continue; + } + LocalDate start = cursor.atDay(startDay); + if (start.isBefore(billStart)) { + start = billStart; + } + if (!start.isAfter(end)) { + ranges.add(new DateRange(start, end)); + } + } + cursor = cursor.plusMonths(1); + } + return ranges; + } + + private List customPeriods(Object value) { + if (!(value instanceof List periodList)) { + return List.of(); + } + List periods = new ArrayList<>(); + for (Object item : periodList) { + if (!(item instanceof Map period)) { + continue; + } + Integer startDay = intValue(period.get("startDay")); + Integer endDay = intValue(period.get("endDay")); + if (startDay == null || endDay == null || startDay < 1 || endDay > 31 || endDay < startDay) { + continue; + } + periods.add(new int[]{startDay, endDay}); + } + return periods; + } + + private Map resolveRule(ContractManage contract, boolean preRule) { + Map direct = parseObject(preRule ? contract.getPreSettlementConfigJson() : contract.getFormalSettlementConfigJson()); + if (!direct.isEmpty()) { + return direct; + } + Map payload = parseObject(contract.getSettlementRuleJson()); + Object nested = payload.get(preRule ? "preSettlementConfig" : "formalSettlementConfig"); + if (nested instanceof Map nestedMap) { + Map result = new LinkedHashMap<>(); + nestedMap.forEach((key, value) -> result.put(String.valueOf(key), value)); + return result; + } + if (!payload.containsKey("preSettlementConfig") && !payload.containsKey("formalSettlementConfig")) { + return payload; + } + return Map.of(); + } + + private Map parseObject(String json) { + if (Func.isBlank(json)) { + return Map.of(); + } + try { + Object parsed = JsonUtil.parse(json, Map.class); + if (!(parsed instanceof Map raw) || raw.isEmpty()) { + return Map.of(); + } + Map result = new LinkedHashMap<>(); + raw.forEach((key, value) -> result.put(String.valueOf(key), value)); + return result; + } catch (Exception exception) { + log.warn("结算规则JSON解析失败:{}", exception.getMessage()); + return Map.of(); + } + } + + private void inTransaction(Runnable runnable) { + new TransactionTemplate(transactionManager).executeWithoutResult(status -> runnable.run()); + } + + private List> partition(Collection ids) { + List source = ids instanceof List list ? list : new ArrayList<>(ids); + List> batches = new ArrayList<>(); + for (int index = 0; index < source.size(); index += QUERY_BATCH_SIZE) { + batches.add(source.subList(index, Math.min(index + QUERY_BATCH_SIZE, source.size()))); + } + return batches; + } + + private String contractSettlementType(ContractManage contract) { + if ("客户合同".equals(contract.getContractCategory())) { + return "receivable"; + } + if ("承运商合同".equals(contract.getContractCategory())) { + return "payable"; + } + return null; + } + + private LocalDate businessDate(ReceivablePayableDetail detail) { + if (detail.getFeeDate() != null) { + return detail.getFeeDate(); + } + if (detail.getCreateTime() == null) { + return null; + } + return detail.getCreateTime().toInstant().atZone(ZoneId.systemDefault()).toLocalDate(); + } + + private String periodRemark(DateRange range) { + return "系统按结算规则自动生成(" + range.start() + "至" + range.end() + ")"; + } + + private boolean enabled(Object value) { + if (value instanceof Boolean enabled) { + return enabled; + } + if (value instanceof Number number) { + return number.intValue() == 1; + } + String text = value == null ? "" : String.valueOf(value).trim(); + return "1".equals(text) || "true".equalsIgnoreCase(text); + } + + private boolean knownCycle(String settlementType) { + return List.of("现结", "日结", "周结", "月结", "按固定天数周期", "固定天数周期结算").contains(settlementType); + } + + private Integer intValue(Object value) { + if (value instanceof Number number) { + return number.intValue(); + } + if (value == null || Func.isBlank(String.valueOf(value))) { + return null; + } + try { + return Integer.parseInt(String.valueOf(value).trim()); + } catch (NumberFormatException exception) { + return null; + } + } + + private LocalDate dateValue(Object value) { + if (value == null || Func.isBlank(String.valueOf(value))) { + return null; + } + String text = String.valueOf(value).trim(); + if (text.length() >= 10) { + text = text.substring(0, 10); + } + try { + return LocalDate.parse(text); + } catch (DateTimeParseException exception) { + return null; + } + } + + private record DateRange(LocalDate start, LocalDate end) { + } + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TemporaryCreditLimitServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TemporaryCreditLimitServiceImpl.java index b75e757a..46e7e20e 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TemporaryCreditLimitServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TemporaryCreditLimitServiceImpl.java @@ -246,10 +246,8 @@ public class TemporaryCreditLimitServiceImpl extends BaseServiceImpl selectTransportPlanPage(IPage page, TransportPlanVO transportPlan) { + // 历史手工创建未写入 dept_id,allDept=0 按组织过滤时会被排除 + backfillMissingDept(transportPlan); IPage entityPage = page(page, buildQuery(transportPlan)); IPage resultPage = TransportPlanWrapper.build().pageVO(entityPage); fillCustomerNamesFromContracts(resultPage.getRecords()); @@ -151,6 +153,12 @@ public class TransportPlanServiceImpl extends BaseServiceImpllambdaUpdate() + .set(TransportPlan::getDeptId, dept.getId()) + .set(TransportPlan::getDeptName, dept.getDeptName()) + .isNull(TransportPlan::getDeptId) + .eq(TransportPlan::getCreateDept, dept.getId()) + .eq(TransportPlan::getIsDeleted, 0)); + } + private LambdaQueryWrapper buildQuery(TransportPlanVO transportPlan) { TransportBusinessSupport.validateAllDept(transportPlan.getAllDept(), "运输计划"); LambdaQueryWrapper queryWrapper = Wrappers.lambdaQuery().eq(TransportPlan::getIsDeleted, 0); if (!Objects.equals(transportPlan.getAllDept(), 1)) { - queryWrapper.eq(TransportPlan::getDeptId, TransportBusinessSupport.currentDeptId("运输计划")); + Long currentDeptId = TransportBusinessSupport.currentDeptId("运输计划"); + // dept_id 为空的历史数据按创建组织归入当前组织 + queryWrapper.and(wrapper -> wrapper.eq(TransportPlan::getDeptId, currentDeptId) + .or(item -> item.isNull(TransportPlan::getDeptId).eq(TransportPlan::getCreateDept, currentDeptId))); } else if (Func.isNotEmpty(transportPlan.getDeptId())) { queryWrapper.eq(TransportPlan::getDeptId, transportPlan.getDeptId()); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportReconciliationServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportReconciliationServiceImpl.java index c22902cc..065ec3b9 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportReconciliationServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportReconciliationServiceImpl.java @@ -1233,14 +1233,67 @@ public class TransportReconciliationServiceImpl private List assertAllMatched(TransportReconciliation bill) { List internals = internalRows(bill.getId()); List externals = externalRows(bill.getId()); - if (internals.isEmpty() || externals.isEmpty() || internals.size() != externals.size() - || internals.stream().anyMatch(item -> !MATCHED.equals(item.getMatchResult())) - || externals.stream().anyMatch(item -> !MATCHED.equals(item.getMatchStatus()) || Boolean.TRUE.equals(item.getSuspectedDuplicate()))) { + // 调整账单后旧的疑似重复标记可能已失效,按当前内容重算,避免已一一匹配仍被拦截 + syncSuspectedDuplicates(externals); + if (!oneToOneMatched(internals, externals)) { throw new ServiceException("所有内外部账单明细必须一一匹配且不存在疑似重复"); } return internals; } + private void syncSuspectedDuplicates(List externals) { + Map> groups = externals.stream() + .collect(Collectors.groupingBy(this::duplicateKey)); + Set duplicateIds = new HashSet<>(); + for (List group : groups.values()) { + if (group.size() > 1) { + group.stream().map(TransportReconciliationExternal::getId).forEach(duplicateIds::add); + } + } + for (TransportReconciliationExternal external : externals) { + boolean duplicate = duplicateIds.contains(external.getId()); + String matchStatus = external.getMatchStatus(); + if (!duplicate && DUPLICATE.equals(matchStatus) && external.getMatchedInternalId() != null) { + matchStatus = MATCHED; + } + if (Boolean.TRUE.equals(external.getSuspectedDuplicate()) == duplicate + && Objects.equals(matchStatus, external.getMatchStatus())) { + continue; + } + external.setSuspectedDuplicate(duplicate); + external.setMatchStatus(matchStatus); + externalMapper.update(null, Wrappers.lambdaUpdate() + .eq(TransportReconciliationExternal::getId, external.getId()) + .set(TransportReconciliationExternal::getSuspectedDuplicate, duplicate) + .set(TransportReconciliationExternal::getMatchStatus, matchStatus)); + } + } + + private boolean oneToOneMatched(List internals, + List externals) { + if (internals.isEmpty() || externals.isEmpty() || internals.size() != externals.size()) { + return false; + } + if (externals.stream().anyMatch(item -> Boolean.TRUE.equals(item.getSuspectedDuplicate()))) { + return false; + } + Map externalMap = externals.stream() + .collect(Collectors.toMap(TransportReconciliationExternal::getId, Function.identity(), (left, right) -> left)); + Set usedExternalIds = new HashSet<>(); + for (TransportReconciliationInternal internal : internals) { + if (!MATCHED.equals(internal.getMatchResult()) || internal.getMatchedExternalId() == null + || !usedExternalIds.add(internal.getMatchedExternalId())) { + return false; + } + TransportReconciliationExternal external = externalMap.get(internal.getMatchedExternalId()); + if (external == null || !MATCHED.equals(external.getMatchStatus()) + || !Objects.equals(external.getMatchedInternalId(), internal.getId())) { + return false; + } + } + return usedExternalIds.size() == externals.size(); + } + private void refreshStats(Long id) { TransportReconciliation bill = existing(id); List internals = internalRows(id); @@ -1459,20 +1512,14 @@ public class TransportReconciliationServiceImpl private BigDecimal sumExternalQuantity(List rows) { return rows.stream().map(TransportReconciliationExternal::getTransportQuantity).map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add); } private BigDecimal sumInternalAmount(List rows) { return rows.stream().map(TransportReconciliationInternal::getSettlementAmount).map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add); } private BigDecimal sumExternalAmount(List rows) { return rows.stream().map(TransportReconciliationExternal::getSettlementAmount).map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add); } + /** 生成当日运输对账单号;按最大流水号 +1,且计入逻辑删除数据 */ private synchronized String nextNo() { String prefix = "DZD" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); - int sequence = list(Wrappers.lambdaQuery() - .select(TransportReconciliation::getReconciliationNo) - .likeRight(TransportReconciliation::getReconciliationNo, prefix)) - .stream() - .map(TransportReconciliation::getReconciliationNo) - .filter(number -> number != null && number.length() == prefix.length() + 5) - .map(number -> number.substring(prefix.length())) - .filter(suffix -> suffix.chars().allMatch(Character::isDigit)) - .mapToInt(Integer::parseInt) - .max() - .orElse(0) + 1; - if (sequence > 99999) throw new ServiceException("当日运输对账单号流水已用完"); + Long maxSequence = baseMapper.selectMaxDailySequence(AuthUtil.getTenantId(), prefix); + long sequence = (maxSequence == null ? 0L : maxSequence) + 1; + if (sequence > 99999) { + throw new ServiceException("当日运输对账单号流水已用完"); + } return prefix + String.format("%05d", sequence); } } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportVehicleServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportVehicleServiceImpl.java index e28d7329..d9e1c734 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportVehicleServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportVehicleServiceImpl.java @@ -291,13 +291,13 @@ public class TransportVehicleServiceImpl extends BaseServiceImpl + * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.service.impl; + +import org.springblade.core.tool.utils.Func; +import org.springblade.transport.pojo.entity.Waybill; +import org.springblade.transport.support.WaybillProcessSupport; + +import java.util.List; + +/** + * 运次聚合口径:四档状态 + 接单需求 OR + 运次键 + *

+ * 四档与接单需求都按子运单的过程配置与接单态在这里推导,Tab 过滤与卡片状态用同一份推导, + * 库层不再按已落库的业务状态分档——业务状态靠读取时的惰性校正维持,与过程配置不一致是常态。 + *

+ * 运次键与它的库层表达式只在这里声明一次:库层分组(重新派单待办)与服务层分组必须是同一个键。 + */ +final class TripAggregation { + + /** 运单业务状态 */ + static final String PENDING = "pending"; + static final String WAITING_DISPATCH = "waiting_dispatch"; + static final String DISPATCHING = "dispatching"; + static final String RUNNING = "running"; + static final String COMPLETED = "completed"; + static final String CANCELLED = "cancelled"; + + /** 待接单档:与运单侧 Tab「待接单」取同一组状态 */ + static final List PENDING_STATUSES = List.of(PENDING, WAITING_DISPATCH, DISPATCHING); + /** 终态档 */ + static final List TERMINAL_STATUSES = List.of(COMPLETED, CANCELLED); + /** + * 进运次的子单状态:草稿尚未派单、不成运次;已取消要进, + * 否则「子单全已取消」会被算成运输中而不是已取消。 + */ + static final List CHILD_STATUSES = List.of( + PENDING, WAITING_DISPATCH, DISPATCHING, RUNNING, COMPLETED, CANCELLED + ); + + /** 待重新派单的口径:子单被拒且这趟车还没结束 */ + static final List REASSIGN_STATUSES = List.of(PENDING, RUNNING); + + /** + * 运次键的库层表达式:有配载按配载单号成组,未配载每张运单自成一组(W 前缀避免与配载单号撞键)。 + * 服务层换 {@link #keyOf(String, Long)} 算同一个键。 + */ + static final String SQL_TRIP_KEY = "IFNULL(NULLIF(loading_no, ''), CONCAT('W', id))"; + + private TripAggregation() { + } + + /** 运次键:与 {@link #SQL_TRIP_KEY} 同一个口径 */ + static String keyOf(String loadingNo, Long id) { + return Func.isEmpty(loadingNo) ? "W" + id : loadingNo; + } + + /** 运次的四档状态:0 待接单 / 1 运输中 / 2 已完成 / 3 已取消 */ + static Integer appStatus(String status) { + return switch (Func.toStr(status, "")) { + case PENDING -> 0; + case RUNNING -> 1; + case COMPLETED -> 2; + case CANCELLED -> 3; + default -> null; + }; + } + + /** + * 运次状态:终态优先——子单全为终态时有已完成取已完成、否则已取消; + * 否则需接单且未接单取待接单,其余取运输中。 + *

+ * 子单集合为空时无从判定,返回空。 + */ + static String statusOf(List children, boolean requireAccept, boolean accepted) { + String status = classify(count(children)); + if (status == null || TERMINAL_STATUSES.contains(status)) { + return status; + } + if (!requireAccept) { + return RUNNING; + } + return accepted ? RUNNING : PENDING; + } + + /** + * 子单聚合结果:子单总数、终态数、已完成数、待接单档数 + */ + static Counters count(List children) { + int total = 0; + int terminal = 0; + int completed = 0; + int pending = 0; + for (Waybill child : children) { + String status = child == null ? null : child.getBusinessStatus(); + total++; + if (status != null && TERMINAL_STATUSES.contains(status)) { + terminal++; + } + if (COMPLETED.equals(status)) { + completed++; + } + if (status != null && PENDING_STATUSES.contains(status)) { + pending++; + } + } + return new Counters(total, terminal, completed, pending); + } + + private static String classify(Counters counters) { + if (counters.total() == 0) { + return null; + } + if (counters.terminal() == counters.total()) { + return counters.completed() > 0 ? COMPLETED : CANCELLED; + } + return counters.pending() > 0 ? PENDING : RUNNING; + } + + /** + * 运次是否需要接单:任一子运单的过程配置有启用的接单节点即为是。 + *

+ * 跨项目的运次常遇到「要接单的票」与「不要接单的票」混在一起,按 OR 合并后整单按需接单处理。 + */ + static boolean requiresAccept(List children) { + return children.stream() + .anyMatch(child -> child != null + && WaybillProcessSupport.requiresDriverAcceptConfirmation(child.getProcessJson())); + } + + /** + * 运次接单态:任一子单已接单即为已接单(一次接单把整趟车的子单置为已接单), + * 否则有子单待接单为待接单,无从判定时为空。 + */ + static String acceptStatus(List children) { + boolean anyPending = false; + for (Waybill child : children) { + String status = child == null ? null : child.getDriverAcceptStatus(); + if (WaybillProcessSupport.ACCEPT_ACCEPTED.equals(status)) { + return WaybillProcessSupport.ACCEPT_ACCEPTED; + } + if (WaybillProcessSupport.ACCEPT_PENDING.equals(status)) { + anyPending = true; + } + } + return anyPending ? WaybillProcessSupport.ACCEPT_PENDING : null; + } + + /** 子单聚合计数 */ + record Counters(int total, int terminal, int completed, int pending) { + } + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TripQueryServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TripQueryServiceImpl.java new file mode 100644 index 00000000..3a3de257 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TripQueryServiceImpl.java @@ -0,0 +1,849 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import lombok.RequiredArgsConstructor; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.core.tool.utils.Func; +import org.springblade.transport.pojo.entity.LoadingManage; +import org.springblade.transport.pojo.entity.Waybill; +import org.springblade.transport.pojo.vo.CargoLineVO; +import org.springblade.transport.pojo.vo.TripQueryVO; +import org.springblade.transport.pojo.vo.TripStationVO; +import org.springblade.transport.pojo.vo.TripTabCountsVO; +import org.springblade.transport.pojo.vo.TripVO; +import org.springblade.transport.service.ILoadingManageService; +import org.springblade.transport.service.IProcessConfigService; +import org.springblade.transport.service.ITripQueryService; +import org.springblade.transport.service.IWaybillService; +import org.springblade.transport.support.LiveProcessConfigSupport; +import org.springblade.transport.support.WaybillCargoSupport; +import org.springblade.transport.support.WaybillProcessSupport; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * 运次读服务实现 + *

+ * 取数两段,段内都是批量查询:段一一次取回收口与行级筛选命中的可见运单行,分组、分档、排序与分页 + * 都在服务层算完再做;段二按这一页运次的配载单号批量取配载单。子运单不再回查——段一拿回来的行就是它们。 + *

+ * 分档只按过程配置与接单态推导,与卡片状态同一份口径:库里的业务状态靠读取时的惰性校正维持, + * 拿它分档会让「归在待接单的运次、卡片显示运输中」。分页因此必须在过滤后的完整运次序列上做, + * 先分页再过滤会把该在第一页的运次挤到后面几页。 + *

+ * 可见运次=派给该司机的未配载运单(每张自成运次),加上配载单派给该司机、且至少有一张可见子运单的运次。 + * 司机侧可见范围与运单列表同口径:司机手机号命中且未被该司机拒单;子单里已拒单的不进运次, + * 于是整趟车被拒单后整个运次自然消失。 + */ +@Service +@RequiredArgsConstructor +public class TripQueryServiceImpl implements ITripQueryService { + + private static final int DEFAULT_PAGE_SIZE = 10; + private static final int MAX_PAGE_SIZE = 50; + + /** 运输组织类型:有配载单为公路配载,否则普通运输 */ + private static final String TRANSPORT_LOAD = "load"; + private static final String TRANSPORT_COMMON = "common"; + + /** 未配载运次的键前缀:与 TripAggregation.SQL_TRIP_KEY 的 W 前缀同一个口径 */ + private static final String LONE_KEY_PREFIX = "W"; + + /** 异常筛选的取值:命中未完成异常的子单 */ + private static final String EXCEPTION_YES = "exception"; + private static final String EXCEPTION_NO = "normal"; + + /** 关键字在子单上匹配的字段 */ + private static final String[] KEYWORD_COLUMNS = { + "waybill_no", "loading_no", "departure_name", "arrival_name", + "departure_address", "arrival_address", "vehicle_no" + }; + + private final IWaybillService waybillService; + private final ILoadingManageService loadingManageService; + private final IProcessConfigService processConfigService; + + @Override + public IPage page(TripQueryVO query) { + TripQueryVO condition = condition(query); + int pageNo = pageNo(condition.getCurrent()); + int pageSize = pageSize(condition.getSize()); + if (!scoped(condition)) { + // 收口缺失时返回空页,不能退回「不按司机收口」:那等于把别人的运次发出去 + return new Page<>(pageNo, pageSize); + } + List trips = trips(condition); + Page page = new Page<>(pageNo, pageSize, trips.size()); + int from = Math.min((pageNo - 1) * pageSize, trips.size()); + int to = Math.min(from + pageSize, trips.size()); + page.setRecords(assemble(trips.subList(from, to))); + return page; + } + + @Override + public TripTabCountsVO tabCounts(TripQueryVO query) { + TripQueryVO condition = condition(query); + TripTabCountsVO counts = new TripTabCountsVO(); + if (!scoped(condition)) { + return counts; + } + // 一次取数算清四档:分档口径与列表、卡片同一份推导,不再按库里的业务状态分批计数 + long pending = 0; + long doing = 0; + long done = 0; + for (TripRows trip : trips(withoutTab(condition))) { + Integer tier = trip.tier(); + if (tier == null) { + continue; + } + switch (tier) { + case 0 -> pending++; + case 1 -> doing++; + case 2 -> done++; + default -> { + } + } + } + counts.setPending(pending); + counts.setDoing(doing); + counts.setDone(done); + counts.setAll(pending + doing + done); + return counts; + } + + @Override + public TripVO detail(TripQueryVO query) { + TripQueryVO condition = condition(query); + Long id = tripId(condition.getTripId()); + if (id == null || !scoped(condition)) { + throw new ServiceException("运次不存在或无权操作"); + } + LoadingManage loading = visibleLoading(id, condition); + if (loading != null) { + List children = loadChildren(Set.of(loading.getLoadingNo()), Set.of()) + .getOrDefault(loading.getLoadingNo(), List.of()); + if (children.isEmpty()) { + // 子单全部被拒单(或已释放)的配载运次对司机不可见,与列表口径一致 + throw new ServiceException("运次不存在或无权操作"); + } + // 详情与列表同一份分档口径:接单需求也按项目生效中的配置判,否则列表说待接单、详情与接收入口说无需接单 + applyLiveProcessConfig(children); + return loadingTrip(loading, children, true); + } + Waybill waybill = visibleWaybill(id, condition); + if (waybill == null) { + throw new ServiceException("运次不存在或无权操作"); + } + applyLiveProcessConfig(List.of(waybill)); + return waybillTrip(waybill, true); + } + + /** + * 全部可见运次:一次取回运单行后在服务层分组、分档、筛选并按排序键倒序 + *

+ * 分档用过程配置与接单态推导,于是 Tab 与卡片必然同档;行级筛选(组织类型 / 异常 / 创建时间)落在库层, + * 命中的行同时决定这一趟车的子单集合与分档。 + */ + private List trips(TripQueryVO condition) { + Map> grouped = new LinkedHashMap<>(); + for (Waybill row : visibleRows(condition)) { + grouped.computeIfAbsent(TripAggregation.keyOf(row.getLoadingNo(), row.getId()), + ignored -> new ArrayList<>()).add(row); + } + Set excluded = condition.isExcludeRejectedTrips() ? rejectedTripKeys() : Set.of(); + Set tiers = tiers(condition); + List trips = new ArrayList<>(grouped.size()); + grouped.forEach((key, rows) -> { + if (excluded.contains(key)) { + return; + } + List children = sortedChildren(rows); + if (!inScope(children, condition)) { + return; + } + boolean requireAccept = TripAggregation.requiresAccept(children); + boolean accepted = WaybillProcessSupport.ACCEPT_ACCEPTED.equals(TripAggregation.acceptStatus(children)); + String status = TripAggregation.statusOf(children, requireAccept, accepted); + Integer tier = TripAggregation.appStatus(status); + if (tier == null || !tiers.contains(tier)) { + return; + } + trips.add(new TripRows(key, rows.get(0).getLoadingNo(), children, status, tier, maxCreateTime(children))); + }); + // 排序键是子单 create_time 的最大值,倒序;键相同再按运次键定序,免得同分页里顺序漂移 + trips.sort(Comparator.comparing(TripRows::sortTime, Comparator.nullsLast(Comparator.reverseOrder())) + .thenComparing(TripRows::key)); + return trips; + } + + /** 子单按创建时刻升序:站点拼装与货物汇总都按这个顺序 */ + private List sortedChildren(List rows) { + List children = new ArrayList<>(rows); + children.sort(Comparator.comparing(Waybill::getCreateTime, Comparator.nullsLast(Comparator.naturalOrder()))); + return children; + } + + /** + * 段一:一次取回该收口与行级筛选命中的可见运单行 + *

+ * 只 select 拼装与分档用得上的列:运单表上有几张大 JSON 列,整行拖回不值得。 + * goods_json 必须随行取回——卡片上的货名、货物汇总与合计重量都以它为准,一张运单可以录多条货物, + * 主表 cargo_name 只落了第一行。可见口径与运单列表同一条:状态进运次的才取,被该司机拒单的不取。 + */ + private List visibleRows(TripQueryVO condition) { + LambdaQueryWrapper wrapper = Wrappers.lambdaQuery() + .select(Waybill::getId, Waybill::getLoadingNo, Waybill::getWaybillNo, Waybill::getBusinessStatus, + Waybill::getDriverAcceptStatus, Waybill::getProcessJson, Waybill::getProjectId, Waybill::getProjectName, + Waybill::getCargoName, Waybill::getCargoType, Waybill::getQuantity, Waybill::getQuantityUnit, + // 多货物运单的主表字段只有首行,货物明细只能从 goods_json 拿 + Waybill::getGoodsJson, + Waybill::getDepartureName, Waybill::getDepartureAddress, Waybill::getArrivalName, + Waybill::getArrivalAddress, Waybill::getDriverName, Waybill::getDriverPhone, Waybill::getVehicleNo, + Waybill::getCustomerName, Waybill::getTransportType, Waybill::getTaskRemark, + // 未配载运次没有配载单可依,计划时间要取运单自己的,故必须随行取回 + Waybill::getEstimatedStartTime, Waybill::getEstimatedEndTime, Waybill::getCreateTime) + .in(Waybill::getBusinessStatus, TripAggregation.CHILD_STATUSES) + .and(visible -> visible.isNull(Waybill::getDriverAcceptStatus) + .or().ne(Waybill::getDriverAcceptStatus, WaybillProcessSupport.ACCEPT_REJECTED)); + applyScope(wrapper, condition); + applyDeptScope(wrapper, condition); + applyOrgTypeFilter(wrapper, condition); + applyKeyword(wrapper, condition.getKeyword()); + List rows = waybillService.list(wrapper); + applyLiveProcessConfig(rows); + return rows; + } + + /** + * 组织收口:条件里给了组织 ID 集合时只出这些组织的运单行,空集合表示不过滤。 + *

+ * 与司机手机号收口一样落在库层的运单行上:分档、分页、总数都由它缩过的行集合算出来。 + * 读服务不自己解析当前用户的组织——它同时服务司机端与管理端,可见范围由调用方算好再传。 + */ + private void applyDeptScope(LambdaQueryWrapper wrapper, TripQueryVO condition) { + if (Func.isEmpty(condition.getDeptIds())) { + return; + } + wrapper.in(Waybill::getDeptId, condition.getDeptIds()); + } + + /** + * 运输组织类型按子单行筛:它在同一趟车里必然是同一个值(配载的每张子单带同一个配载单号, + * 未配载的每张自成一趟车),所以这一层不会把一趟车拆开。 + */ + private void applyOrgTypeFilter(LambdaQueryWrapper wrapper, TripQueryVO condition) { + if (TRANSPORT_LOAD.equals(condition.getTransportOrgType())) { + wrapper.apply("IFNULL(loading_no, '') <> ''"); + } else if (TRANSPORT_COMMON.equals(condition.getTransportOrgType())) { + wrapper.apply("IFNULL(loading_no, '') = ''"); + } + } + + /** + * 这趟车在不在筛选范围内:异常按「任一子单命中」,创建时间按子单 create_time 的最大值(运次列表的排序键, + * 也是调度端卡片上显示的创建时间)。两个判据都按整趟车的子单集合算——先按筛选把子单集合缩小再分档, + * 会让同一趟车归在某一档、卡片却显示另一档。 + *

+ * 筛选只决定这趟车在不在范围内,分档仍看它完整的可见子单集合。 + */ + private boolean inScope(List children, TripQueryVO condition) { + Set exceptionIds = condition.getExceptionWaybillIds(); + if (Func.isNotEmpty(exceptionIds)) { + boolean hit = children.stream().map(Waybill::getId).anyMatch(exceptionIds::contains); + if (EXCEPTION_YES.equals(condition.getException()) && !hit) { + return false; + } + if (EXCEPTION_NO.equals(condition.getException()) && hit) { + return false; + } + } + Date created = maxCreateTime(children); + if (condition.getCreatedFrom() != null + && (created == null || created.before(condition.getCreatedFrom()))) { + return false; + } + return condition.getCreatedTo() == null + || (created != null && !created.after(condition.getCreatedTo())); + } + + /** + * 按单票路径的口径把生效中的过程配置写回内存里的行:项目启用中的配置优先,它没有可打卡节点时回退运单快照。 + *

+ * 只改内存里的对象,分档与卡片下发都读它,不落库;生效配置一次取回后按项目复用,不逐条回查。 + * 取法与二选一规则都在 {@link LiveProcessConfigSupport},这里只做批量写回。 + */ + private void applyLiveProcessConfig(List rows) { + if (Func.isEmpty(rows)) { + return; + } + Set projectIds = rows.stream().map(Waybill::getProjectId).filter(Objects::nonNull) + .collect(Collectors.toCollection(LinkedHashSet::new)); + if (projectIds.isEmpty()) { + return; + } + Map live = LiveProcessConfigSupport.byProjects(processConfigService, projectIds); + if (live.isEmpty()) { + return; + } + for (Waybill row : rows) { + String liveJson = live.get(row.getProjectId()); + if (Func.isNotEmpty(liveJson)) { + row.setProcessJson(LiveProcessConfigSupport.resolveForPunch(liveJson, row.getProcessJson())); + } + } + } + + /** + * 有子单被拒、且这趟车还没结束的运次键:整单拒绝后这趟车不再算待处理 + */ + private Set rejectedTripKeys() { + return waybillService.list(Wrappers.lambdaQuery() + .select(Waybill::getId, Waybill::getLoadingNo) + .eq(Waybill::getDriverAcceptStatus, WaybillProcessSupport.ACCEPT_REJECTED) + .in(Waybill::getBusinessStatus, TripAggregation.REASSIGN_STATUSES)) + .stream() + .map(row -> TripAggregation.keyOf(row.getLoadingNo(), row.getId())) + .collect(Collectors.toCollection(LinkedHashSet::new)); + } + + /** + * 要哪几档:多档优先,其次单档,都不传按「全部」=待接单/进行中/已完成(不含已取消) + */ + private Set tiers(TripQueryVO condition) { + if (Func.isNotEmpty(condition.getStatuses())) { + return new LinkedHashSet<>(condition.getStatuses()); + } + if (condition.getStatus() != null) { + return Set.of(condition.getStatus()); + } + return Set.of(0, 1, 2); + } + + /** + * 运次可见范围:配载段按配载单的司机手机号收口,未配载段按运单的司机手机号收口。 + *

+ * 手机号允许带空格,与运单列表的收口判据一致。 + */ + private void applyScope(LambdaQueryWrapper wrapper, TripQueryVO condition) { + if (condition.isAllDrivers()) { + return; + } + String phone = Func.toStr(condition.getDriverPhone(), "").trim(); + wrapper.and(scope -> scope + .apply("(IFNULL(loading_no, '') <> '' AND loading_no IN (SELECT lm.loading_no FROM blade_loading_manage lm" + + " WHERE lm.is_deleted = 0 AND lm.driver_phone = {0}))", phone) + .or(lone -> lone + .apply("IFNULL(loading_no, '') = ''") + .and(row -> row.eq(Waybill::getDriverPhone, phone) + .or().apply("REPLACE(IFNULL(driver_phone, ''), ' ', '') = {0}", phone)))); + } + + /** + * 关键字按运次匹配:任一子单或配载单号命中即整趟车命中。 + *

+ * 先按关键字查出命中的运次键,再要求这一行的所属运次在其中——关键字不缩小分组内的子单集合, + * 否则状态与排序会按命中的那一部分子单算。 + */ + private void applyKeyword(LambdaQueryWrapper wrapper, String keyword) { + if (Func.isEmpty(keyword)) { + return; + } + Set keys = keywordTripKeys(keyword.trim()); + if (keys.isEmpty()) { + wrapper.apply("1 = 0"); + return; + } + Set loadingNos = keys.stream().filter(key -> !key.startsWith(LONE_KEY_PREFIX)) + .collect(Collectors.toCollection(LinkedHashSet::new)); + Set loneIds = keys.stream().filter(key -> key.startsWith(LONE_KEY_PREFIX)) + .map(key -> key.substring(LONE_KEY_PREFIX.length())) + .map(this::tripId) + .filter(Objects::nonNull) + .collect(Collectors.toCollection(LinkedHashSet::new)); + wrapper.and(matched -> { + if (!loadingNos.isEmpty()) { + matched.in(Waybill::getLoadingNo, loadingNos); + } + if (!loneIds.isEmpty()) { + if (!loadingNos.isEmpty()) { + matched.or(); + } + matched.apply("IFNULL(loading_no, '') = ''").in(Waybill::getId, loneIds); + } + }); + } + + /** 关键字命中的运次键:在子单上按关键字找一遍,再换算成运次键 */ + private Set keywordTripKeys(String keyword) { + String like = "%" + keyword + "%"; + Object[] params = new Object[KEYWORD_COLUMNS.length]; + StringBuilder matched = new StringBuilder(); + for (int i = 0; i < KEYWORD_COLUMNS.length; i++) { + if (i > 0) { + matched.append(" OR "); + } + matched.append(KEYWORD_COLUMNS[i]).append(" LIKE {").append(i).append('}'); + params[i] = like; + } + return waybillService.list(Wrappers.lambdaQuery() + .select(Waybill::getId, Waybill::getLoadingNo) + .apply("(" + matched + ")", params)) + .stream() + .map(row -> TripAggregation.keyOf(row.getLoadingNo(), row.getId())) + .collect(Collectors.toCollection(LinkedHashSet::new)); + } + + /** + * 把这一页的运次组装成运次:先批量取配载单,再逐趟拼装 + *

+ * 子运单就是段一取回来的那些行,不再回查:回查会拿回未经行级筛选的子单, + * 卡片的状态与货物汇总就会与分档、与筛选结果对不上。 + */ + private List assemble(List trips) { + if (Func.isEmpty(trips)) { + return new ArrayList<>(); + } + Set loadingNos = trips.stream() + .map(TripRows::loadingNo) + .filter(Func::isNotEmpty) + .collect(Collectors.toCollection(LinkedHashSet::new)); + Map loadings = loadLoadings(loadingNos); + List result = new ArrayList<>(trips.size()); + for (TripRows trip : trips) { + if (Func.isEmpty(trip.loadingNo())) { + result.add(waybillTrip(trip.children().get(0), false)); + continue; + } + LoadingManage loading = loadings.get(trip.loadingNo()); + if (loading == null) { + // 配载单被并发删除:不给出没有主键的运次 + continue; + } + result.add(loadingTrip(loading, trip.children(), false)); + } + return result; + } + + /** + * 段二:按本页运次的配载单号一次取回配载单 + */ + private Map loadLoadings(Set loadingNos) { + if (loadingNos.isEmpty()) { + return Map.of(); + } + return loadingManageService.list(Wrappers.lambdaQuery() + .in(LoadingManage::getLoadingNo, loadingNos)) + .stream() + .collect(Collectors.toMap(LoadingManage::getLoadingNo, Function.identity(), (first, ignored) -> first)); + } + + /** + * 段二:按本页运次的配载单号与未配载运单主键一次取回子运单,不逐条查 + *

+ * 子单归属一律以配载单号为准:库层就是按配载单号分组聚合的,服务层复核用别的关联关系, + * 同一页里就会出现「Tab 说它在进行中、卡片按子单算出来是待接单」。 + * 配载单上的运单号集合只是派单时的快照,子单被释放(配载单号清空)后不再属于该运次。 + */ + private Map> loadChildren(Set loadingNos, Set loneIds) { + if (loadingNos.isEmpty() && loneIds.isEmpty()) { + return Map.of(); + } + List children = waybillService.list(Wrappers.lambdaQuery() + .in(Waybill::getBusinessStatus, TripAggregation.CHILD_STATUSES) + .and(visible -> visible.isNull(Waybill::getDriverAcceptStatus) + .or().ne(Waybill::getDriverAcceptStatus, WaybillProcessSupport.ACCEPT_REJECTED)) + .and(scope -> scope.in(Func.isNotEmpty(loadingNos), Waybill::getLoadingNo, loadingNos) + .or(Func.isNotEmpty(loneIds), lone -> lone.in(Waybill::getId, loneIds)))); + Map> grouped = new LinkedHashMap<>(); + for (Waybill child : children) { + String key = Func.isEmpty(child.getLoadingNo()) ? String.valueOf(child.getId()) : child.getLoadingNo(); + grouped.computeIfAbsent(key, ignored -> new ArrayList<>()).add(child); + } + grouped.values().forEach(group -> group.sort( + Comparator.comparing(Waybill::getCreateTime, Comparator.nullsLast(Comparator.naturalOrder())))); + return grouped; + } + + /** + * 配载运次:司机、车辆、路线取配载单(配载单是运输执行主体),状态与接单需求由子单聚合 + */ + private TripVO loadingTrip(LoadingManage loading, List children, boolean withPayload) { + TripVO trip = new TripVO(); + trip.setId(tripIdOf(loading.getId())); + trip.setLoadingNo(loading.getLoadingNo()); + trip.setTripNo(loading.getLoadingNo()); + trip.setTransportOrgType(TRANSPORT_LOAD); + trip.setDriverName(Func.toStr(loading.getDriverName(), "")); + trip.setDriverPhone(Func.toStr(loading.getDriverPhone(), "")); + trip.setVehicleNo(Func.toStr(loading.getVehicleNo(), "")); + trip.setTrailerVehicleNo(Func.toStr(loading.getTrailerVehicleNo(), "")); + trip.setProjectName(Func.toStr(loading.getProjectName(), "")); + trip.setCustomerName(Func.toStr(loading.getCustomerName(), "")); + trip.setTransportType(Func.toStr(loading.getTransportType(), "")); + trip.setCargoName(firstNotEmpty(loading.getCargoName(), children, Waybill::getCargoName)); + trip.setCargoType(firstNotEmpty(loading.getCargoType(), children, Waybill::getCargoType)); + trip.setDepartureAddress(Func.toStr(loading.getDepartureAddress(), "")); + trip.setTransitAddress(Func.toStr(loading.getTransitAddress(), "")); + trip.setArrivalAddress(Func.toStr(loading.getArrivalAddress(), "")); + trip.setEstimatedStartDate(loading.getEstimatedStartDate()); + trip.setEstimatedEndDate(loading.getEstimatedEndDate()); + trip.setTaskRemark(Func.toStr(loading.getTaskRemark(), "")); + if (withPayload) { + trip.setRouteJson(loading.getRouteJson()); + trip.setGoodsJson(loading.getGoodsJson()); + } + fillAggregation(trip, children); + fillRouteAndCargo(trip, loading, children, withPayload); + return trip; + } + + /** + * 未配载运次:整张运单就是这一趟车 + */ + private TripVO waybillTrip(Waybill waybill, boolean withPayload) { + TripVO trip = new TripVO(); + trip.setId(tripIdOf(waybill.getId())); + trip.setTripNo(waybill.getWaybillNo()); + trip.setTransportOrgType(TRANSPORT_COMMON); + trip.setDriverName(Func.toStr(waybill.getDriverName(), "")); + trip.setDriverPhone(Func.toStr(waybill.getDriverPhone(), "")); + trip.setVehicleNo(Func.toStr(waybill.getVehicleNo(), "")); + trip.setProjectName(Func.toStr(waybill.getProjectName(), "")); + trip.setCustomerName(Func.toStr(waybill.getCustomerName(), "")); + trip.setTransportType(Func.toStr(waybill.getTransportType(), "")); + trip.setCargoName(Func.toStr(waybill.getCargoName(), "")); + trip.setCargoType(Func.toStr(waybill.getCargoType(), "")); + trip.setDepartureAddress(Func.toStr(waybill.getDepartureAddress(), "")); + trip.setArrivalAddress(Func.toStr(waybill.getArrivalAddress(), "")); + // 未配载没有配载单可依,计划时间只能取这张运单自己的 + trip.setEstimatedStartDate(waybill.getEstimatedStartTime()); + trip.setEstimatedEndDate(waybill.getEstimatedEndTime()); + trip.setTaskRemark(Func.toStr(waybill.getTaskRemark(), "")); + if (withPayload) { + trip.setRouteJson(waybill.getRouteJson()); + trip.setGoodsJson(waybill.getGoodsJson()); + } + fillAggregation(trip, List.of(waybill)); + fillRouteAndCargo(trip, null, List.of(waybill), withPayload); + return trip; + } + + /** + * 聚合出运次的状态、接单需求、接单态与排序键:与分档用同一份推导,Tab 与卡片因此必然同档 + */ + private void fillAggregation(TripVO trip, List children) { + String acceptStatus = TripAggregation.acceptStatus(children); + boolean requireAccept = TripAggregation.requiresAccept(children); + boolean accepted = WaybillProcessSupport.ACCEPT_ACCEPTED.equals(acceptStatus); + trip.setAcceptStatus(acceptStatus); + trip.setRequireAccept(requireAccept); + trip.setStatus(TripAggregation.appStatus(TripAggregation.statusOf(children, requireAccept, accepted))); + trip.setWaybillCount(children.size()); + trip.setSortTime(maxCreateTime(children)); + trip.setWaybills(children.stream().map(this::toWaybillVO).toList()); + } + + /** + * 站点与货物都按整趟车算:站点明细只在详情下发,列表带走起/经/终、站点数与货物汇总 + */ + private void fillRouteAndCargo(TripVO trip, LoadingManage loading, List children, boolean withPayload) { + List stations = TripStationAssembler.assemble(loading, children); + trip.setDepartureName(TripStationAssembler.departureName(stations)); + trip.setTransitName(TripStationAssembler.transitName(stations)); + trip.setArrivalName(TripStationAssembler.arrivalName(stations)); + trip.setStationCount(stations.size()); + if (withPayload) { + trip.setStations(stations); + } + List cargos = children.stream() + .flatMap(child -> WaybillCargoSupport.of(child).stream()) + .toList(); + List cargoNames = WaybillCargoSupport.cargoNames(cargos); + // 运次级货名覆盖整趟车:主表 cargo_name 只是首行,配载单的 cargo_name 也是派单时的快照 + trip.setCargoNames(cargoNames); + if (!cargoNames.isEmpty()) { + trip.setCargoName(String.join("、", cargoNames)); + } + List cargoTypes = cargos.stream() + .map(WaybillCargoSupport.Cargo::cargoType) + .filter(Func::isNotEmpty) + .distinct() + .toList(); + trip.setCargoTypes(cargoTypes); + if (!cargoTypes.isEmpty()) { + trip.setCargoType(String.join("、", cargoTypes)); + } + trip.setCargoSummary(cargoSummary(children)); + trip.setCargos(cargoLines(cargos)); + fillTotalQuantity(trip, children); + fillWeighing(trip, cargos); + } + + /** + * 货物汇总:同名货物合并且单位一致时取数量合计,没有数量的只列货名 + *

+ * 一条运单可以有多条货物,明细在 goods_json;主表 cargo_name 只落了第一行,拿它汇总等于把其余货物丢掉。 + */ + private String cargoSummary(List children) { + Map> grouped = new LinkedHashMap<>(); + for (Waybill child : children) { + List cargos = WaybillCargoSupport.of(child); + if (cargos.isEmpty()) { + continue; + } + for (WaybillCargoSupport.Cargo cargo : cargos) { + String name = cargo.displayName(); + String unit = Func.toStr(cargo.quantityUnit(), ""); + Map byUnit = grouped.computeIfAbsent(name, key -> new LinkedHashMap<>()); + BigDecimal quantity = cargo.quantity() == null ? BigDecimal.ZERO : cargo.quantity(); + byUnit.merge(unit, quantity, BigDecimal::add); + } + } + List parts = new ArrayList<>(); + grouped.forEach((name, byUnit) -> byUnit.forEach((unit, quantity) -> + parts.add(quantity.signum() == 0 ? name : name + " " + formatQuantity(quantity, unit)))); + return String.join("、", parts); + } + + /** + * 货物明细行:与 cargoSummary 同一套合并口径,但数量与单位分开留,供详情页逐行展示 + */ + private List cargoLines(List cargos) { + List lines = new ArrayList<>(); + for (WaybillCargoSupport.Line line : WaybillCargoSupport.lines(cargos)) { + CargoLineVO vo = new CargoLineVO(); + vo.setCargoName(line.name()); + vo.setQuantity(line.quantity() == null ? "" : formatQuantity(line.quantity(), line.unit())); + lines.add(vo); + } + return lines; + } + + /** + * 合计重量:只有全部货物同单位时才能相加,单位不一致时留空(汇总文案里仍分单位列出) + *

+ * 按货物行算而不是按运单行算:一张运单里的多条货物可能是不同的单位与数量, + * 主表 quantity 是这些行的合计、quantity_unit 只是首行的单位,拿它当合计会把「20堆 + 50方」说成「70堆」。 + */ + private void fillTotalQuantity(TripVO trip, List children) { + String unit = null; + BigDecimal total = BigDecimal.ZERO; + boolean any = false; + for (Waybill child : children) { + for (WaybillCargoSupport.Cargo cargo : WaybillCargoSupport.of(child)) { + String cargoUnit = Func.toStr(cargo.quantityUnit(), ""); + if (cargo.quantity() == null || Func.isEmpty(cargoUnit)) { + return; + } + if (unit == null) { + unit = cargoUnit; + } else if (!unit.equals(cargoUnit)) { + return; + } + total = total.add(cargo.quantity()); + any = true; + } + } + if (!any || unit == null) { + return; + } + trip.setTotalQuantity(total); + trip.setQuantityUnit(unit); + } + + /** + * 计重合计:卡片上那个「货物重量」,口径见 WaybillCargoSupport#weighing + */ + private void fillWeighing(TripVO trip, List cargos) { + WaybillCargoSupport.Quantity weighing = WaybillCargoSupport.weighing(cargos); + if (weighing == null) { + return; + } + trip.setWeighingQuantity(weighing.value()); + trip.setWeighingUnit(weighing.unit()); + } + + private String formatQuantity(BigDecimal quantity, String unit) { + String value = quantity.stripTrailingZeros().toPlainString(); + return Func.isEmpty(unit) ? value : value + unit; + } + + private TripVO.TripWaybillVO toWaybillVO(Waybill waybill) { + TripVO.TripWaybillVO child = new TripVO.TripWaybillVO(); + child.setId(tripIdOf(waybill.getId())); + child.setWaybillNo(waybill.getWaybillNo()); + child.setBusinessStatus(waybill.getBusinessStatus()); + child.setAcceptStatus(waybill.getDriverAcceptStatus()); + child.setRequireAccept(WaybillProcessSupport.requiresDriverAcceptConfirmation(waybill.getProcessJson())); + child.setProjectId(waybill.getProjectId()); + child.setProjectName(waybill.getProjectName()); + // 货名按运单上实际录入的货物给全:主表 cargo_name 只是首行,多货物运单会漏掉其余货物 + List cargos = WaybillCargoSupport.of(waybill); + child.setCargoName(String.join("、", WaybillCargoSupport.cargoNames(cargos))); + child.setCargoNames(WaybillCargoSupport.cargoNames(cargos)); + child.setCargoTypes(cargos.stream() + .map(WaybillCargoSupport.Cargo::cargoType) + .filter(Func::isNotEmpty) + .distinct() + .toList()); + child.setCargoType(waybill.getCargoType()); + child.setQuantity(waybill.getQuantity()); + child.setQuantityUnit(waybill.getQuantityUnit()); + child.setDepartureName(waybill.getDepartureName()); + child.setDepartureAddress(waybill.getDepartureAddress()); + child.setArrivalName(waybill.getArrivalName()); + child.setArrivalAddress(waybill.getArrivalAddress()); + child.setCreateTime(waybill.getCreateTime()); + return child; + } + + /** 排序键:子单 create_time 的最大值 */ + private Date maxCreateTime(List children) { + return children.stream() + .map(Waybill::getCreateTime) + .filter(Objects::nonNull) + .max(Comparator.naturalOrder()) + .orElse(null); + } + + private String firstNotEmpty(String preferred, List children, Function getter) { + if (Func.isNotEmpty(preferred)) { + return preferred; + } + return children.stream().map(getter).filter(Func::isNotEmpty).findFirst().orElse(""); + } + + /** + * 按运次主键取配载单:主键命中配载单即配载运次,再按司机收口 + */ + private LoadingManage visibleLoading(Long id, TripQueryVO condition) { + LoadingManage loading = loadingManageService.getById(id); + if (loading == null) { + return null; + } + if (condition.isAllDrivers()) { + return loading; + } + return samePhone(loading.getDriverPhone(), condition.getDriverPhone()) ? loading : null; + } + + /** + * 按运次主键取未配载运单:必须还没配载、在可见状态里,且属于收口范围 + */ + private Waybill visibleWaybill(Long id, TripQueryVO condition) { + Waybill waybill = waybillService.getById(id); + if (waybill == null + || Func.isNotEmpty(waybill.getLoadingNo()) + || !TripAggregation.CHILD_STATUSES.contains(waybill.getBusinessStatus()) + || WaybillProcessSupport.ACCEPT_REJECTED.equals(waybill.getDriverAcceptStatus())) { + return null; + } + if (condition.isAllDrivers()) { + return waybill; + } + return samePhone(waybill.getDriverPhone(), condition.getDriverPhone()) ? waybill : null; + } + + private boolean scoped(TripQueryVO condition) { + return condition.isAllDrivers() || Func.isNotEmpty(Func.toStr(condition.getDriverPhone(), "").trim()); + } + + private boolean samePhone(String left, String right) { + String expected = Func.toStr(right, "").trim().replace(" ", ""); + if (Func.isEmpty(expected)) { + return false; + } + return expected.equals(Func.toStr(left, "").trim().replace(" ", "")); + } + + private TripQueryVO condition(TripQueryVO query) { + return query == null ? new TripQueryVO() : query; + } + + /** 去掉 Tab 条件、其余照搬:统计四档时不按某一档收窄 */ + private TripQueryVO withoutTab(TripQueryVO source) { + TripQueryVO copy = new TripQueryVO(); + copy.setDriverPhone(source.getDriverPhone()); + copy.setAllDrivers(source.isAllDrivers()); + copy.setDeptIds(source.getDeptIds()); + copy.setKeyword(source.getKeyword()); + copy.setTransportOrgType(source.getTransportOrgType()); + copy.setException(source.getException()); + copy.setExceptionWaybillIds(source.getExceptionWaybillIds()); + copy.setCreatedFrom(source.getCreatedFrom()); + copy.setCreatedTo(source.getCreatedTo()); + copy.setExcludeRejectedTrips(source.isExcludeRejectedTrips()); + return copy; + } + + private Long tripId(String value) { + if (Func.isEmpty(value)) { + return null; + } + try { + return Long.valueOf(value.trim()); + } catch (NumberFormatException ignored) { + // 运次主键只有配载单与运单两种数字雪花号,其余一律当作不存在的运次 + return null; + } + } + + /** 运次主键按字符串下发:19 位雪花号按数字下发会被前端取整 */ + private String tripIdOf(Long id) { + return id == null ? null : String.valueOf(id); + } + + /** 一趟车:运次键、子单行(段一取回的、含行级筛选后的)、派生状态与排序键 */ + private record TripRows(String key, String loadingNo, List children, String status, Integer tier, + Date sortTime) { + } + + private int pageNo(Integer current) { + return current == null || current < 1 ? 1 : current; + } + + private int pageSize(Integer size) { + if (size == null || size < 1) { + return DEFAULT_PAGE_SIZE; + } + return Math.min(size, MAX_PAGE_SIZE); + } + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TripStationAssembler.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TripStationAssembler.java new file mode 100644 index 00000000..134e2a34 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TripStationAssembler.java @@ -0,0 +1,368 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.service.impl; + +import org.springblade.core.tool.jackson.JsonUtil; +import org.springblade.core.tool.utils.Func; +import org.springblade.transport.pojo.entity.LoadingManage; +import org.springblade.transport.pojo.entity.Waybill; +import org.springblade.transport.pojo.vo.TripStationVO; +import org.springblade.transport.support.WaybillCargoSupport; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * 运次站点拼装:把一趟车的实际途经顺序与每站的装卸、货物条目拼出来 + *

+ * 站点顺序优先取配载单的路线信息(有序站点数组);站点标记「装N / 卸N」的 N 指向配载单运单集合里的第 N 张子运单。 + * 没有路线信息时回退到子运单的起讫点去重成站(保留首次出现顺序),再退到配载单自己的发货/途经/到货地址; + * 都没有则给空站点数组,前端按空站点渲染而不是崩。 + *

+ * 货物条目取子运单而不是配载单的货物信息:配载单的货物条数与子运单不一定同数,行序也对不上站点编号。 + */ +final class TripStationAssembler { + + private static final String LOAD = "load"; + private static final String UNLOAD = "unload"; + private static final String LOAD_UNLOAD = "load_unload"; + /** 途经站点之间的连接符 */ + private static final String TRANSIT_JOINER = " → "; + /** 站点去重用的键分隔符:站名与地址都为空的两个点不该被当成同一个 */ + private static final String KEY_SEPARATOR = "\u0000"; + + private TripStationAssembler() { + } + + /** + * 拼装整趟车的站点,站序从 1 开始 + * + * @param loading 配载单,未配载传 null + * @param children 该运次的可见子运单 + */ + static List assemble(LoadingManage loading, List children) { + List stations = fromRoute(loading, children); + if (stations.isEmpty()) { + stations = fromWaybillPoints(children); + } + if (stations.isEmpty() && loading != null) { + stations = fromLoadingAddresses(loading); + } + for (int i = 0; i < stations.size(); i++) { + stations.get(i).setIndex(i + 1); + } + return stations; + } + + /** + * 起点名称:首站站名,没有站点时为空 + */ + static String departureName(List stations) { + return stations.isEmpty() ? "" : Func.toStr(stations.get(0).getName(), ""); + } + + /** + * 终点名称:末站站名,没有站点时为空 + */ + static String arrivalName(List stations) { + return stations.isEmpty() ? "" : Func.toStr(stations.get(stations.size() - 1).getName(), ""); + } + + /** + * 途经站点:中间各站按顺序连接,不足三站为空 + */ + static String transitName(List stations) { + if (stations.size() < 3) { + return ""; + } + List names = new ArrayList<>(); + for (int i = 1; i < stations.size() - 1; i++) { + String name = Func.toStr(stations.get(i).getName(), ""); + if (Func.isNotEmpty(name)) { + names.add(name); + } + } + return String.join(TRANSIT_JOINER, names); + } + + /** + * 站点来自配载单的路线信息:按数组顺序成站,标记里第 N 张子运单按配载单运单集合的顺序取 + */ + private static List fromRoute(LoadingManage loading, List children) { + if (loading == null) { + return new ArrayList<>(); + } + List> route = objectList(loading.getRouteJson()); + if (route.isEmpty()) { + return new ArrayList<>(); + } + List waybillIds = stringList(loading.getWaybillIdsJson()); + Map byId = new LinkedHashMap<>(); + for (Waybill child : children) { + if (child.getId() != null) { + byId.put(String.valueOf(child.getId()), child); + } + } + List stations = new ArrayList<>(); + for (Map element : route) { + String address = stringValue(element.get("address")); + String display = stringValue(element.get("displayAddress")); + TripStationVO station = new TripStationVO(); + station.setName(Func.toStrWithEmpty(display, address)); + station.setAddress(Func.toStrWithEmpty(address, station.getName())); + List tags = stringList(element.get("tags")); + station.setTags(tags); + List cargos = new ArrayList<>(); + boolean load = false; + boolean unload = false; + for (String tag : tags) { + boolean loadTag = tag.contains("装"); + boolean unloadTag = tag.contains("卸"); + if (!loadTag && !unloadTag) { + continue; + } + Waybill child = childOfTag(tag, waybillIds, byId); + if (child == null) { + continue; + } + load |= loadTag; + unload |= unloadTag; + addCargo(cargos, child, loadTag ? LOAD : UNLOAD); + if (loadTag && unloadTag) { + addCargo(cargos, child, UNLOAD); + } + } + fillOperation(station, load, unload); + station.setCargos(cargos); + stations.add(station); + } + return stations; + } + + /** + * 站点回退到子运单的起讫点:按子运单顺序每张的起点、终点依次成站,同名同址只留首次出现 + */ + private static List fromWaybillPoints(List children) { + Map stations = new LinkedHashMap<>(); + Map operations = new LinkedHashMap<>(); + for (Waybill child : children) { + String fromName = pointName(child.getDepartureName(), child.getDepartureAddress()); + String fromAddress = Func.toStrWithEmpty(child.getDepartureAddress(), fromName); + String toName = pointName(child.getArrivalName(), child.getArrivalAddress()); + String toAddress = Func.toStrWithEmpty(child.getArrivalAddress(), toName); + point(stations, operations, fromName, fromAddress, true, child); + point(stations, operations, toName, toAddress, false, child); + } + operations.forEach((key, flags) -> fillOperation(stations.get(key), flags[0], flags[1])); + return new ArrayList<>(stations.values()); + } + + private static void point(Map stations, Map operations, + String name, String address, boolean load, Waybill child) { + if (Func.isEmpty(name) && Func.isEmpty(address)) { + return; + } + String key = name + KEY_SEPARATOR + address; + TripStationVO station = stations.get(key); + if (station == null) { + station = new TripStationVO(); + station.setName(name); + station.setAddress(address); + station.setTags(new ArrayList<>()); + station.setCargos(new ArrayList<>()); + stations.put(key, station); + operations.put(key, new boolean[2]); + } + boolean[] flags = operations.get(key); + flags[0] |= load; + flags[1] |= !load; + addCargo(station.getCargos(), child, load ? LOAD : UNLOAD); + } + + /** + * 站点再退到配载单自己的发货 / 途经 / 到货地址 + */ + private static List fromLoadingAddresses(LoadingManage loading) { + List stations = new ArrayList<>(); + for (String address : List.of( + Func.toStr(loading.getDepartureAddress(), ""), + Func.toStr(loading.getTransitAddress(), ""), + Func.toStr(loading.getArrivalAddress(), ""))) { + if (Func.isEmpty(address)) { + continue; + } + TripStationVO station = new TripStationVO(); + station.setName(address); + station.setAddress(address); + station.setTags(new ArrayList<>()); + station.setCargos(new ArrayList<>()); + stations.add(station); + } + return stations; + } + + /** + * 站点标记里第 N 张子运单:N 是配载单运单集合里的位次,不是可见子单里的位次 + */ + private static Waybill childOfTag(String tag, List waybillIds, Map byId) { + String digits = tag.replaceAll("\\D+", ""); + if (Func.isEmpty(digits)) { + return null; + } + int order = Func.toInt(digits, 0); + if (order < 1 || order > waybillIds.size()) { + return null; + } + return byId.get(waybillIds.get(order - 1)); + } + + /** + * 一条子运单在该站的货物条目:运单上录了几条货物就出几条,同一张运单的多条货物不能被折成一行, + * 否则站点上看不出这票到底装/卸了哪几种货。去重键含货名,站点标记重复出现时不会重复列出。 + *

+ * 一条货物都没录的运单仍出一行(货名为空):站点上的「这站有这张票」由这一行承载, + * 只按货名判断有没有货物会让没填货名的单在任务安排里凭空消失。 + */ + private static void addCargo(List cargos, Waybill child, String operation) { + if (child == null) { + return; + } + String waybillId = child.getId() == null ? null : String.valueOf(child.getId()); + List items = WaybillCargoSupport.of(child); + if (items.isEmpty()) { + // 一条货物都没录:站点条目仍要有这一行,否则任务安排里这票凭空消失 + TripStationVO.TripCargoVO cargo = new TripStationVO.TripCargoVO(); + cargo.setWaybillId(waybillId); + cargo.setWaybillNo(child.getWaybillNo()); + cargo.setCargoName(""); + cargo.setCustomerName(child.getCustomerName()); + cargo.setOperation(operation); + cargo.setProjectName(child.getProjectName()); + cargos.add(cargo); + return; + } + for (WaybillCargoSupport.Cargo item : items) { + String cargoName = item.displayName(); + boolean exists = cargos.stream().anyMatch(cargo -> + Objects.equals(cargo.getWaybillId(), waybillId) + && Objects.equals(cargo.getOperation(), operation) + && Objects.equals(cargo.getCargoName(), cargoName)); + if (exists) { + continue; + } + TripStationVO.TripCargoVO cargo = new TripStationVO.TripCargoVO(); + cargo.setWaybillId(waybillId); + cargo.setWaybillNo(child.getWaybillNo()); + cargo.setCargoName(cargoName); + cargo.setCustomerName(child.getCustomerName()); + cargo.setQuantity(item.quantity()); + cargo.setQuantityUnit(item.quantityUnit()); + cargo.setOperation(operation); + cargo.setProjectName(child.getProjectName()); + cargos.add(cargo); + } + } + + private static void fillOperation(TripStationVO station, boolean load, boolean unload) { + if (load && unload) { + station.setOperationType(LOAD_UNLOAD); + station.setOperationTypeLabel("装卸"); + } else if (load) { + station.setOperationType(LOAD); + station.setOperationTypeLabel("装货"); + } else if (unload) { + station.setOperationType(UNLOAD); + station.setOperationTypeLabel("卸货"); + } else { + station.setOperationType(null); + station.setOperationTypeLabel(""); + } + } + + private static String pointName(String name, String address) { + return Func.toStrWithEmpty(name, Func.toStr(address, "")); + } + + private static List> objectList(String json) { + if (Func.isEmpty(json)) { + return new ArrayList<>(); + } + try { + Object parsed = JsonUtil.parse(json, List.class); + if (parsed instanceof List list) { + List> result = new ArrayList<>(); + for (Object item : list) { + if (item instanceof Map map) { + Map element = new LinkedHashMap<>(); + map.forEach((key, value) -> element.put(String.valueOf(key), value)); + result.add(element); + } + } + return result; + } + } catch (Exception ignored) { + // 路线信息坏了不该让整趟车读不出来,按没有路线信息回退 + } + return new ArrayList<>(); + } + + private static List stringList(Object value) { + List result = new ArrayList<>(); + if (value instanceof List list) { + for (Object item : list) { + String text = stringValue(item); + if (Func.isNotEmpty(text)) { + result.add(text); + } + } + return result; + } + String text = stringValue(value); + if (Func.isEmpty(text)) { + return result; + } + try { + Object parsed = JsonUtil.parse(text, List.class); + if (parsed instanceof List list) { + for (Object item : list) { + String item2 = stringValue(item); + if (Func.isNotEmpty(item2)) { + result.add(item2); + } + } + return result; + } + } catch (Exception ignored) { + // 不是 JSON 数组就当逗号分隔处理 + } + return Func.toStrList(",", text); + } + + private static String stringValue(Object value) { + return value == null ? "" : String.valueOf(value).trim(); + } + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillImportBatchServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillImportBatchServiceImpl.java index 5a40decd..61604120 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillImportBatchServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillImportBatchServiceImpl.java @@ -25,11 +25,14 @@ import org.springblade.system.cache.UserCache; import org.springblade.system.feign.ISysClient; import org.springblade.system.pojo.entity.DictBiz; import org.springblade.system.pojo.entity.CargoType; +import org.springblade.system.pojo.entity.MeasurementUnit; import org.springblade.transport.excel.WaybillImportBatchExcel; import org.springblade.transport.mapper.WaybillImportBatchMapper; import org.springblade.transport.pojo.dto.WaybillImportBatchRequest; import org.springblade.transport.pojo.entity.CustomerArchive; +import org.springblade.transport.pojo.entity.LoadingManage; import org.springblade.transport.pojo.entity.ProjectApply; +import org.springblade.transport.pojo.entity.ReceivablePayableDetail; import org.springblade.transport.pojo.entity.TransportPlan; import org.springblade.transport.pojo.entity.Waybill; import org.springblade.transport.pojo.entity.WaybillImportBatch; @@ -75,6 +78,13 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl entityPage = page(page, queryWrapper); List records = entityPage.getRecords().stream().map(this::toVO).toList(); + fillSettlementStatus(records); Page resultPage = new Page<>(entityPage.getCurrent(), entityPage.getSize(), entityPage.getTotal()); resultPage.setRecords(records); return resultPage; @@ -385,13 +396,36 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl idList = Func.toLongList(ids); if (Func.isEmpty(idList)) throw new ServiceException("请选择需要删除的运单批次"); + List batches = listByIds(idList).stream() + .filter(batch -> !Objects.equals(batch.getIsDeleted(), 1)) + .toList(); + if (Func.isEmpty(batches)) { + throw new ServiceException("运单批次不存在或已删除"); + } + Map> waybillsByBatchId = listWaybillsByBatchIds( + batches.stream().map(WaybillImportBatch::getId).toList()); + List allWaybillIds = waybillsByBatchId.values().stream() + .flatMap(List::stream) + .map(Waybill::getId) + .filter(Objects::nonNull) + .toList(); + Set settledWaybillIds = receivablePayableDetailService.settlementLinkedWaybillIds(allWaybillIds); + // 仅未结算批次可整批删除;选中含部分结算/已结算时整批拒绝 + for (WaybillImportBatch batch : batches) { + List waybills = waybillsByBatchId.getOrDefault(batch.getId(), List.of()); + String settlementStatus = resolveSettlementStatus(waybills, settledWaybillIds); + if (!SETTLEMENT_UNSETTLED.equals(settlementStatus)) { + throw new ServiceException("仅未结算的运单批次可以删除"); + } + } BusinessRemoveResultVO result = new BusinessRemoveResultVO(); - for (WaybillImportBatch batch : listByIds(idList)) { - if (Objects.equals(batch.getIsDeleted(), 1)) continue; - List waybills = waybillService.list(Wrappers.lambdaQuery() - .eq(Waybill::getImportBatchId, batch.getId()).eq(Waybill::getIsDeleted, 0)); - if (Func.isNotEmpty(waybills)) { - waybillService.deleteLogic(waybills.stream().map(Waybill::getId).toList()); + for (WaybillImportBatch batch : batches) { + List waybills = waybillsByBatchId.getOrDefault(batch.getId(), List.of()); + List waybillIds = waybills.stream().map(Waybill::getId).filter(Objects::nonNull).toList(); + if (Func.isNotEmpty(waybillIds)) { + clearUnsettledReceivableDetails(waybillIds); + clearImportedLoadings(waybills); + waybillService.deleteLogic(waybillIds); } deleteLogic(List.of(batch.getId())); result.setSuccessCount(result.getSuccessCount() + 1); @@ -456,7 +490,7 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl row) { + if (row != null && row.containsKey("vehicleNo")) { + return stringValue(row, "vehicleNo"); + } + return stringValue(row, "vehicleNo", "车牌号/船班号/船次号/班次号", "车牌号/航班号/船号/班列号", "车号"); + } + private Long longValue(Map row, String field, String fieldName) { String value = stringValue(row, field); if (Func.isEmpty(value)) return null; @@ -654,11 +696,148 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl records) { + if (Func.isEmpty(records)) { + return; + } + List batchIds = records.stream().map(WaybillImportBatchVO::getId).filter(Objects::nonNull).toList(); + Map> waybillsByBatchId = listWaybillsByBatchIds(batchIds); + List allWaybillIds = waybillsByBatchId.values().stream() + .flatMap(List::stream) + .map(Waybill::getId) + .filter(Objects::nonNull) + .toList(); + Set settledWaybillIds = receivablePayableDetailService.settlementLinkedWaybillIds(allWaybillIds); + for (WaybillImportBatchVO record : records) { + List waybills = waybillsByBatchId.getOrDefault(record.getId(), List.of()); + String settlementStatus = resolveSettlementStatus(waybills, settledWaybillIds); + record.setSettlementStatus(settlementStatus); + record.setSettlementStatusName(settlementStatusName(settlementStatus)); + } + } + + private Map> listWaybillsByBatchIds(List batchIds) { + if (Func.isEmpty(batchIds)) { + return Map.of(); + } + List waybills = waybillService.list(Wrappers.lambdaQuery() + .select(Waybill::getId, Waybill::getImportBatchId, Waybill::getLoadingNo) + .in(Waybill::getImportBatchId, batchIds) + .eq(Waybill::getIsDeleted, 0)); + return waybills.stream() + .filter(waybill -> waybill.getImportBatchId() != null) + .collect(Collectors.groupingBy(Waybill::getImportBatchId)); + } + + private String resolveSettlementStatus(List waybills, Set settledWaybillIds) { + if (Func.isEmpty(waybills)) { + return SETTLEMENT_UNSETTLED; + } + int settledCount = 0; + for (Waybill waybill : waybills) { + if (waybill.getId() != null && settledWaybillIds.contains(waybill.getId())) { + settledCount++; + } + } + if (settledCount <= 0) { + return SETTLEMENT_UNSETTLED; + } + if (settledCount >= waybills.size()) { + return SETTLEMENT_SETTLED; + } + return SETTLEMENT_PARTIAL; + } + + private String settlementStatusName(String settlementStatus) { + if (SETTLEMENT_SETTLED.equals(settlementStatus)) { + return "已结算"; + } + if (SETTLEMENT_PARTIAL.equals(settlementStatus)) { + return "部分结算"; + } + return "未结算"; + } + + /** 删除批次前清理尚未进入预/正式结算的应收应付明细,避免残留待结算数据。 */ + private void clearUnsettledReceivableDetails(List waybillIds) { + if (Func.isEmpty(waybillIds)) { + return; + } + List details = receivablePayableDetailService.list( + Wrappers.lambdaQuery() + .in(ReceivablePayableDetail::getWaybillId, waybillIds) + .eq(ReceivablePayableDetail::getIsDeleted, 0) + .and(wrapper -> wrapper.isNull(ReceivablePayableDetail::getPreSettlementNo) + .or().eq(ReceivablePayableDetail::getPreSettlementNo, "")) + .and(wrapper -> wrapper.isNull(ReceivablePayableDetail::getFormalSettlementNo) + .or().eq(ReceivablePayableDetail::getFormalSettlementNo, ""))); + if (Func.isNotEmpty(details)) { + receivablePayableDetailService.deleteLogic( + details.stream().map(ReceivablePayableDetail::getId).toList()); + } + } + + /** 清理本批次运单关联的批量导入配载单(配载单运单均属本批时整单删除)。 */ + private void clearImportedLoadings(List waybills) { + if (Func.isEmpty(waybills)) { + return; + } + Set batchWaybillIds = waybills.stream() + .map(Waybill::getId) + .filter(Objects::nonNull) + .collect(Collectors.toCollection(HashSet::new)); + Set loadingNos = waybills.stream() + .map(Waybill::getLoadingNo) + .filter(Func::isNotEmpty) + .collect(Collectors.toCollection(HashSet::new)); + if (Func.isEmpty(loadingNos)) { + return; + } + List loadings = loadingManageService.list(Wrappers.lambdaQuery() + .in(LoadingManage::getLoadingNo, loadingNos) + .eq(LoadingManage::getDataSource, LOADING_DATA_SOURCE_IMPORT) + .eq(LoadingManage::getIsDeleted, 0)); + List deleteLoadingIds = new ArrayList<>(); + for (LoadingManage loading : loadings) { + List loadingWaybillIds = parseLoadingWaybillIds(loading.getWaybillIdsJson()); + if (Func.isEmpty(loadingWaybillIds) || batchWaybillIds.containsAll(loadingWaybillIds)) { + deleteLoadingIds.add(loading.getId()); + } + } + if (Func.isNotEmpty(deleteLoadingIds)) { + loadingManageService.deleteLogic(deleteLoadingIds); + } + } + + private List parseLoadingWaybillIds(String waybillIdsJson) { + if (Func.isEmpty(waybillIdsJson)) { + return List.of(); + } + try { + Object parsed = JsonUtil.parse(waybillIdsJson, List.class); + if (parsed instanceof List list) { + return list.stream() + .filter(Objects::nonNull) + .map(item -> Long.valueOf(String.valueOf(item))) + .toList(); + } + } catch (Exception ignored) { + // 兼容历史逗号分隔存储 + } + return Func.toLongList(waybillIdsJson); + } + @Override public String nextBatchNo() { return nextCode(); @@ -741,25 +920,22 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl row, List errors) { - String vehicleNo = stringValue(row, "vehicleNo", "车牌号/船班号/船次号/班次号", "车牌号/航班号/船号/班列号"); + String vehicleNo = vehicleNoFromRow(row); String transportType = stringValue(row, "transportType", "运输类型", "*运输类型", "运输方式", "*运输方式"); if (Func.isEmpty(transportType)) { @@ -911,38 +1087,14 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl row, List quantityUnitOptions, List errors) { - String quantityUnit = stringValue(row, "quantityUnit", "数量单位"); + String quantityUnit = stringValue(row, "quantityUnit", "数量单位", "*数量单位"); if (Func.isEmpty(quantityUnit)) { return; } - - if (!quantityUnitOptions.contains(quantityUnit)) { - errors.add("数量单位必须为系统枚举值之一"); - } - } - - private void validateFreightTotal(Map row, List errors) { - String freightTotalStr = stringValue(row, "freightTotal", "运费合计"); - if (Func.isEmpty(freightTotalStr) || isImportOptionalNumberEmpty(freightTotalStr)) { - return; - } - - try { - BigDecimal freightTotal = new BigDecimal(freightTotalStr); - String freightStr = stringValue(row, "freight", "运费"); - String otherFeeTotalStr = stringValue(row, "otherFeeTotal", "其他费用合计"); - - BigDecimal freight = Func.isNotEmpty(freightStr) && !isImportOptionalNumberEmpty(freightStr) - ? new BigDecimal(freightStr) : BigDecimal.ZERO; - BigDecimal otherFeeTotal = Func.isNotEmpty(otherFeeTotalStr) && !isImportOptionalNumberEmpty(otherFeeTotalStr) - ? new BigDecimal(otherFeeTotalStr) : BigDecimal.ZERO; - - BigDecimal calculatedTotal = freight.add(otherFeeTotal); - if (freightTotal.compareTo(calculatedTotal) != 0) { - errors.add("运费合计应等于运费+其他费用合计"); - } - } catch (NumberFormatException e) { - errors.add("运费相关字段格式不正确"); + String normalized = quantityUnit.trim(); + boolean matched = quantityUnitOptions.stream().anyMatch(option -> option.equalsIgnoreCase(normalized)); + if (!matched) { + errors.add("数量单位「" + normalized + "」未在计量单位主数据中匹配"); } } @@ -1045,12 +1197,12 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl loadQuantityUnitOptions() { - // 常见数量单位 - return List.of("吨", "千克", "立方米", "件", "箱", "台", "个", "升", "米", "平方米"); + R> response = sysClient.getMeasurementUnits(); + if (response == null || !response.isSuccess() || response.getData() == null) { + throw new ServiceException("计量单位基础数据读取失败,请稍后重试"); + } + Set options = new HashSet<>(); + for (MeasurementUnit unit : response.getData()) { + if (unit == null || (unit.getStatus() != null && !Objects.equals(unit.getStatus(), 1))) { + continue; + } + if (Func.isNotEmpty(unit.getUnitName())) { + options.add(unit.getUnitName().trim()); + } + if (Func.isNotEmpty(unit.getUnitCode())) { + options.add(unit.getUnitCode().trim()); + } + } + if (options.isEmpty()) { + throw new ServiceException("计量单位主数据为空,请先在基础数据中配置计量单位"); + } + return new ArrayList<>(options); } /** @@ -1190,7 +1361,7 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl @jakarta.annotation.Resource private WaybillEnroutePunchMapper waybillEnroutePunchMapper; + @jakarta.annotation.Resource + private ExceptionDisposalMapper exceptionDisposalMapper; + @jakarta.annotation.Resource private ILbsClient lbsClient; @@ -822,27 +829,20 @@ public class WaybillServiceImpl extends BaseServiceImpl return null; } - /** 动态过程配置优先,回退运单快照 */ + /** + * 生效配置优先,回退运单快照。 + *

+ * 取法与二选一规则在 {@link LiveProcessConfigSupport};这里不用「生效配置没有可打卡节点就回退快照」 + * 那一版——接单需求、节点门禁、节点订正都读这份配置判断节点存不存在,生效配置是权威, + * 不能因为快照里有节点而被顶掉。 + */ private String resolveLiveProcessJson(Waybill waybill) { - if (waybill.getProjectId() != null) { - String projectId = String.valueOf(waybill.getProjectId()); - String live = processConfigService.list(Wrappers.lambdaQuery() - .eq(ProcessConfig::getStatus, 1) - .eq(ProcessConfig::getIsDeleted, 0) - .like(ProcessConfig::getProjectIds, projectId) - .orderByDesc(ProcessConfig::getUpdateTime) - .orderByDesc(ProcessConfig::getCreateTime)) - .stream() - .filter(cfg -> containsProjectId(cfg.getProjectIds(), projectId)) - .map(ProcessConfig::getNodeConfigJson) - .filter(Func::isNotEmpty) - .findFirst() - .orElse(null); - if (Func.isNotEmpty(live)) { - return live; - } + if (waybill == null) { + return null; } - return waybill.getProcessJson(); + return LiveProcessConfigSupport.resolve( + LiveProcessConfigSupport.byProject(processConfigService, waybill.getProjectId()), + waybill.getProcessJson()); } private Map> buildVoucherTypesIndex(String processJson) { @@ -1175,15 +1175,6 @@ public class WaybillServiceImpl extends BaseServiceImpl waybill.setDriverRejectReason(null); } - private boolean containsProjectId(String projectIds, String projectId) { - if (Func.isEmpty(projectIds)) { - return false; - } - return List.of(projectIds.split(",")).stream() - .map(String::trim) - .anyMatch(projectId::equals); - } - @Override @Transactional(rollbackFor = Exception.class) public BusinessRemoveResultVO removeWaybill(String ids) { @@ -1211,15 +1202,14 @@ public class WaybillServiceImpl extends BaseServiceImpl @Override public List exportWaybill(WaybillVO waybill, String ids) { - TransportBusinessSupport.validateAllDept(waybill.getAllDept(), "运单管理"); List idList = Func.toLongList(ids); LambdaQueryWrapper queryWrapper; if (Func.isNotEmpty(idList)) { queryWrapper = Wrappers.lambdaQuery().eq(Waybill::getIsDeleted, 0); - if (!Objects.equals(waybill.getAllDept(), 1)) { - queryWrapper.eq(Waybill::getDeptId, TransportBusinessSupport.currentDeptId("运单管理")); - } else if (Func.isNotEmpty(waybill.getDeptId())) { - queryWrapper.eq(Waybill::getDeptId, waybill.getDeptId()); + List deptIdList = TransportBusinessSupport.resolveDeptFilterIds( + waybill.getAllDept(), waybill.getDeptId(), "运单管理"); + if (Func.isNotEmpty(deptIdList)) { + queryWrapper.eq(Waybill::getDeptId, deptIdList.get(0)); } queryWrapper.in(Waybill::getId, idList).orderByDesc(Waybill::getCreateTime); } else { @@ -1496,7 +1486,8 @@ public class WaybillServiceImpl extends BaseServiceImpl @Override @Transactional(rollbackFor = Exception.class) public boolean complete(Long id) { - return doComplete(loadEditable(id, true)); + // 暂时取消组织校验:历史运单 deptId 可能为 -1 / 与当前组织不一致,完成时不再拦截 + return doComplete(loadEditable(id, false)); } @Override @@ -1512,6 +1503,61 @@ public class WaybillServiceImpl extends BaseServiceImpl return doComplete(waybill); } + @Override + @Transactional(rollbackFor = Exception.class) + public boolean confirmReturnReceipt(Long id) { + Waybill waybill = loadEditable(id, true); + String processJson = LiveProcessConfigSupport.resolve( + LiveProcessConfigSupport.byProject(processConfigService, waybill.getProjectId()), + waybill.getProcessJson()); + if (WaybillProcessSupport.returnNeedsNoConfirmation(processJson)) { + // 不要求确认时司机打完末位卡就已经完成了,运单不会停在进行中等确认;配置对不上要说清, + // 否则用户会以为是确认没生效 + throw new ServiceException("该运单的回单不需要业务员确认"); + } + assertPunchNodesFinished(waybill.getId(), processJson); + assertNoUnresolvedException(waybill.getId()); + return doComplete(waybill); + } + + /** + * 司机打卡节点(在途除外)必须全部已打卡:完成接口本身不要求节点打卡, + * 不挡住就能从「确认回单」绕过打卡门禁。 + */ + private void assertPunchNodesFinished(Long waybillId, String processJson) { + List missing = WaybillProcessSupport.missingPunchNodeNames(processJson, punchedNodeKeys(waybillId)); + if (!missing.isEmpty()) { + throw new ServiceException("该运单还有未打卡的节点:" + String.join("、", missing)); + } + } + + /** 该运单已打卡的节点编码与名称(小写):打卡记录里两者都写过,对节点时任一命中即可 */ + private Set punchedNodeKeys(Long waybillId) { + Set keys = new LinkedHashSet<>(); + List punches = waybillNodePunchMapper.selectList(Wrappers.lambdaQuery() + .eq(WaybillNodePunch::getWaybillId, waybillId)); + for (WaybillNodePunch punch : punches) { + if (Func.isNotEmpty(punch.getNodeCode())) { + keys.add(punch.getNodeCode().trim().toLowerCase(Locale.ROOT)); + } + if (Func.isNotEmpty(punch.getNodeName())) { + keys.add(punch.getNodeName().trim().toLowerCase(Locale.ROOT)); + } + } + return keys; + } + + /** 存在未了结异常时不允许置完成:判据与司机端完成同一道门(处置单达终态才算解除) */ + private void assertNoUnresolvedException(Long waybillId) { + Long unresolved = exceptionDisposalMapper.selectCount(Wrappers.lambdaQuery() + .eq(ExceptionDisposal::getWaybillId, waybillId) + .eq(ExceptionDisposal::getIsDeleted, 0) + .notIn(ExceptionDisposal::getDisposalStatus, WaybillProcessSupport.SETTLED_DISPOSAL_STATUSES)); + if (unresolved != null && unresolved > 0) { + throw new ServiceException("该运单存在未处理异常,处理完成后才能确认回单"); + } + } + /** * 完成运单核心逻辑:状态改为 completed,并检查生成应收应付明细。 */ @@ -1604,12 +1650,12 @@ public class WaybillServiceImpl extends BaseServiceImpl } private LambdaQueryWrapper buildQuery(WaybillVO waybill) { - TransportBusinessSupport.validateAllDept(waybill.getAllDept(), "运单管理"); LambdaQueryWrapper queryWrapper = Wrappers.lambdaQuery().eq(Waybill::getIsDeleted, 0); - if (!Objects.equals(waybill.getAllDept(), 1)) { - queryWrapper.eq(Waybill::getDeptId, TransportBusinessSupport.currentDeptId("运单管理")); - } else if (Func.isNotEmpty(waybill.getDeptId())) { - queryWrapper.eq(Waybill::getDeptId, waybill.getDeptId()); + // 与常用线路等模块一致:超管默认不按组织过滤;普通用户 allDept!=1 时强制当前组织 + List deptIdList = TransportBusinessSupport.resolveDeptFilterIds( + waybill.getAllDept(), waybill.getDeptId(), "运单管理"); + if (Func.isNotEmpty(deptIdList)) { + queryWrapper.eq(Waybill::getDeptId, deptIdList.get(0)); } if (Func.isNotEmpty(waybill.getWaybillNo())) { queryWrapper.eq(Waybill::getWaybillNo, waybill.getWaybillNo()); @@ -1691,6 +1737,12 @@ public class WaybillServiceImpl extends BaseServiceImpl if (Objects.equals(waybill.getOnlyUnassignedLoading(), 1)) { queryWrapper.and(wrapper -> wrapper.isNull(Waybill::getLoadingNo).or().eq(Waybill::getLoadingNo, "")); } + if (Func.isNotEmpty(waybill.getIds())) { + queryWrapper.in(Waybill::getId, waybill.getIds()); + } + if (Objects.equals(waybill.getLoadableOnly(), 1)) { + LoadableAdmissionSupport.applyLoadableConditions(queryWrapper); + } if (Func.isNotEmpty(waybill.getBatchNo())) { queryWrapper.like(Waybill::getBatchNo, waybill.getBatchNo()); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeApBillSyncSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeApBillSyncSupport.java index f091b00f..db94c665 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeApBillSyncSupport.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeApBillSyncSupport.java @@ -32,11 +32,9 @@ import org.springblade.core.log.exception.ServiceException; import org.springblade.core.tool.api.R; import org.springblade.core.tool.jackson.JsonUtil; import org.springblade.core.tool.utils.Func; -import org.springblade.system.cache.DictBizCache; import org.springblade.system.cache.SysCache; import org.springblade.system.feign.ISysClient; import org.springblade.system.pojo.entity.Dept; -import org.springblade.system.pojo.entity.DictBiz; import org.springblade.system.pojo.entity.FeeItem; import org.springblade.thirdparty.bfi.pojo.dto.BfiApBillQueryDTO; import org.springblade.thirdparty.bfi.pojo.dto.BfiApBillSaveDTO; @@ -102,8 +100,10 @@ public class KingdeeApBillSyncSupport { private static final int PAY_RESULT_PAGE_SIZE = 200; /** 结算明细行(运费)计费方案匹配费用项(运输类无税率残差组标识用) */ private static final String FREIGHT_FEE_ITEM = "运输费"; - /** 业务字典:费用类型(fee_category),费用项档案分类编码 -> 名称 */ - private static final String FEE_CATEGORY_DICT = "fee_category"; + /** 费用大类编码(业务字典 fee_category 键值):运输 */ + public static final String FEE_CATEGORY_FREIGHT = "A01"; + /** 费用大类编码(业务字典 fee_category 键值):装卸 */ + public static final String FEE_CATEGORY_LOADING = "A10"; private final IBfiService bfiService; private final ContractManageMapper contractManageMapper; @@ -200,6 +200,33 @@ public class KingdeeApBillSyncSupport { return null; } + /** + * A102-4 冲销状态查询:按防重键查询财务应付单,返回该防重键下是否存在「已被冲销」单据 + * (BfiApBillVO.hadwrittenoff=true,部分冲销同样视为已冲销)。 + * 结算调整重推硬闸门用:仅当原单已被冲销才允许以新来源单据号重推新应付单 + * + * @param awrpSrcCode 财务应付单防重键 + * @return true=金蝶侧原应付单已被冲销 + */ + public boolean queryApBillWrittenOff(String awrpSrcCode) { + if (Func.isBlank(awrpSrcCode)) { + return false; + } + BfiApBillQueryDTO param = new BfiApBillQueryDTO(); + param.setAwrpSrcCode(List.of(awrpSrcCode)); + param.setAwrpSrcSys(bfiProperties.getSrcSys()); + for (BfiApBillVO apBill : BfiUtil.queryAllPages(pageNo -> { + param.setPageNo(pageNo); + param.setPageSize(PAY_RESULT_PAGE_SIZE); + return bfiService.queryApBill(param); + })) { + if (awrpSrcCode.equals(apBill.getAwrpSrcCode()) && Boolean.TRUE.equals(apBill.getHadwrittenoff())) { + return true; + } + } + return false; + } + // ==================== A102-2 财务应付单保存 ==================== /** @@ -233,7 +260,7 @@ public class KingdeeApBillSyncSupport { if (Func.isBlank(billtypeNumber)) { throw new ServiceException("推送金蝶财务应付单失败:单据类型[" + KingdeeBfiConstant.BFI_AP_BILL_BILLTYPE_NAME + "]未在映射字典中维护"); } - String orgNumber = bfiOrgNumber(contract); + String orgNumber = bfiOrgNumber(contract, context); // 币种取合同结算币种 String currency = bfiCurrency(contract); // 单据日期:yyyy-MM-dd HH:mm:ss(各接口统一格式),流程拆分后以同步当日作为应付单据日期 @@ -397,7 +424,7 @@ public class KingdeeApBillSyncSupport { ContractManage contract = requireContract(settlement.getContractId(), context); // 合同计费方案规则:费用类型/费用项 -> 税率(拆行分组用) Map taxRateByRule = taxRateByRule(contract); - // 费用项档案:费用项名称 -> 费用类型名称(费用大类判定,字典 fee_category 翻译) + // 费用项档案:费用项名称 -> 费用大类编码(费用大类判定) Map feeCategoryByName = feeCategoryByName(context); EntryResolveContext resolveContext = new EntryResolveContext(settlement, project, taxRateByRule, feeCategoryByName); // 结算明细费用行:一车多货按货物拆分的费用快照行 @@ -513,7 +540,7 @@ public class KingdeeApBillSyncSupport { String feeType = Func.toStr(summaryFee.getFeeType()).trim(); String feeItem = Func.toStr(summaryFee.getFeeItem()).trim(); String sourceDesc = "结算合计[" + feeType + "-" + feeItem + "]"; - // 非运费类(费用项档案分类/费用类型/费用项名称均不含“运费/运输费”)统一映射装卸物料 + // 非运费类(费用项档案分类编码非 A01 且名称无运费字样)统一映射装卸物料 boolean loadingFee = !isFreightCategory(feeType, feeItem, context.feeCategoryByName()); String materialNumber = resolveMaterialNumber(loadingFee, context.project(), fallbackTransportType, sourceDesc); // 结算合计费用项行无运输量,数量默认 1 @@ -640,12 +667,12 @@ public class KingdeeApBillSyncSupport { /** * 费用项按费用大类/税率拆分(纯函数): - * 费用项经费用项档案/业务字典判为装卸大类(名称含“装卸”的费用类型)、其余归运输大类; + * 费用项经费用项档案判为装卸大类(费用大类编码 A10)、其余归运输大类; * 各大类内按计费方案规则税率分组(未配置税率的费用项归入无税率组), * 费用项金额合计与结算金额的残差并入运输类无税率组,保证拆分后各行金额合计与结算金额勾稽 * * @param feeItems 费用项名称 -> 金额(费用项JSON解析结果,保持行序) - * @param feeCategoryByName 费用项档案:费用项名称 -> 费用类型名称(空 Map 时回退名称关键字判定) + * @param feeCategoryByName 费用项档案:费用项名称 -> 费用大类编码(空 Map 时回退名称关键字判定) * @param taxRateByRule 合同计费方案:费用类型/费用项 -> 税率(百分数) * @param rowAmount 货物费用行结算金额(含税),残差勾稽基准 * @return 拆分行列表(保持费用项出现顺序) @@ -693,29 +720,33 @@ public class KingdeeApBillSyncSupport { } /** - * 装卸大类判定:费用项在费用项档案(blade_fee_item)中的费用类型名称(业务字典 fee_category 翻译) - * 含“装卸”归装卸大类;档案未维护该费用项时回退费用项名称关键字判定 + * 装卸大类判定:费用项在费用项档案(blade_fee_item)中的费用大类编码为 A10 归装卸大类; + * 档案未维护该费用项时回退费用项名称关键字判定 */ static boolean isLoadingCategory(String feeItemName, Map feeCategoryByName) { - String categoryName = feeItemName == null || feeCategoryByName == null ? null : feeCategoryByName.get(feeItemName); - if (Func.isNotBlank(categoryName)) { - return categoryName.contains("装卸"); + String categoryCode = feeItemName == null || feeCategoryByName == null ? null : feeCategoryByName.get(feeItemName); + if (Func.isNotBlank(categoryCode)) { + return FEE_CATEGORY_LOADING.equals(categoryCode); } return isLoadingFeeItem(feeItemName); } - /** 运费类费用判定(关键字兜底):名称含“运费”或“运输费” */ + /** 运费类费用判定(关键字兜底):名称含“运费”或“运输”(业务字典 fee_category 运输类的字典值为“运输”) */ static boolean isFreightFeeText(String name) { - return name != null && (name.contains("运费") || name.contains("运输费")); + return name != null && (name.contains("运费") || name.contains("运输")); } /** - * 运费类判定(手动添加费用行物料映射用):费用项档案分类名称、费用类型、费用项名称任一 - * 含“运费/运输费”即归运费类映射运输物料;档案未维护时退化为名称关键字判定(与原口径一致) + * 运费类判定(手动添加费用行物料映射用):费用项档案维护的费用大类编码为 A01 归运费类映射运输物料; + * 档案未维护时回退行费用类型编码与费用项名称关键字判定 */ - static boolean isFreightCategory(String feeTypeName, String feeItemName, Map feeCategoryByName) { - String categoryName = feeItemName == null || feeCategoryByName == null ? null : feeCategoryByName.get(feeItemName); - return isFreightFeeText(categoryName) || isFreightFeeText(feeTypeName) || isFreightFeeText(feeItemName); + public static boolean isFreightCategory(String feeTypeCode, String feeItemName, Map feeCategoryByName) { + String categoryCode = feeItemName == null || feeCategoryByName == null ? null : feeCategoryByName.get(feeItemName); + if (Func.isNotBlank(categoryCode)) { + // 档案维护的费用项以档案分类编码为准 + return FEE_CATEGORY_FREIGHT.equals(categoryCode); + } + return FEE_CATEGORY_FREIGHT.equals(feeTypeCode) || isFreightFeeText(feeItemName); } /** @@ -817,8 +848,8 @@ public class KingdeeApBillSyncSupport { } /** - * 费用项档案分类:费用项名称 -> 费用类型名称(blade_fee_item.fee_category 编码经业务字典 - * {@link #FEE_CATEGORY_DICT} 翻译;批量同步时全批共享一次加载结果),费用大类判定用; + * 费用项档案分类:费用项名称 -> 费用大类编码(blade_fee_item.fee_category,业务字典 fee_category 键值, + * 运输 A01 / 装卸 A10;批量同步时全批共享一次加载结果),费用大类判定用; * 档案读取失败直接拦截 */ public Map feeCategoryByName(KingdeeSyncContext context) { @@ -826,9 +857,7 @@ public class KingdeeApBillSyncSupport { if (!cache.isEmpty()) { return cache; } - Map categoryNames = DictBizCache.getList(FEE_CATEGORY_DICT).stream() - .collect(Collectors.toMap(DictBiz::getDictKey, DictBiz::getDictValue, (first, second) -> first)); - R> response = sysClient.getFeeItems(); + R> response = sysClient.getFeeItems(false); if (response == null || !response.isSuccess() || Func.isEmpty(response.getData())) { throw new ServiceException("推送金蝶失败:费用项基础档案读取失败,请稍后重试"); } @@ -837,10 +866,9 @@ public class KingdeeApBillSyncSupport { if (feeItem == null || Func.isBlank(feeItem.getName()) || Func.isBlank(feeItem.getFeeCategory())) { continue; } - loaded.putIfAbsent(feeItem.getName().trim(), - categoryNames.getOrDefault(feeItem.getFeeCategory().trim(), feeItem.getFeeCategory().trim())); + loaded.putIfAbsent(feeItem.getName().trim(), feeItem.getFeeCategory().trim()); } - log.info("费用项档案加载:{}项(费用项名称 -> 费用类型名称)", loaded.size()); + log.info("费用项档案加载:{}项(费用项名称 -> 费用大类编码)", loaded.size()); cache.putAll(loaded); return cache; } @@ -860,7 +888,7 @@ public class KingdeeApBillSyncSupport { BfiBillSaveResultItemVO item = saveResult.getResult().get(0); if (!Boolean.TRUE.equals(item.getBillStatus())) { String errorText = Func.isBlank(item.getErrorText()) ? "未知错误" : item.getErrorText(); - throw new ServiceException(action + "失败:" + errorText); + throw new ServiceException(action + "失败:" + errorText + item.getFailMark()); } } @@ -925,24 +953,75 @@ public class KingdeeApBillSyncSupport { } /** - * 合同所属组织编码 + * 合同金蝶组织编码:合同关联项目 → 项目业务部门沿部门树向上反查唯一平台公司 → 平台公司金蝶组织编码 * * @param contract 合同实体 + * @param context 金蝶同步上下文(缓存载体) * @return 组织编码 */ - public String bfiOrgNumber(ContractManage contract) { - Dept dept = contract.getOrganizationId() == null ? null : SysCache.getDept(contract.getOrganizationId()); - // dept为空,直接报错 - if (dept == null) { - throw new ServiceException("推送金蝶失败,合同所属组织不存在,请检查合同所属组织配置"); + public String bfiOrgNumber(ContractManage contract, KingdeeSyncContext context) { + if (contract.getProjectId() == null) { + throw new ServiceException("推送金蝶失败,合同[" + Func.toStr(contract.getContractNo()) + "]未关联项目,无法确定金蝶组织编码"); } - // kingdeeDeptCode为空,直接报错 - if (Func.isBlank(dept.getKingdeeDeptCode())) { - throw new ServiceException("推送金蝶失败,合同所属组织编码不能为空,请检查组织[" + dept.getDeptName() + "]是否维护金蝶组织编码"); + return this.kingdeeOrgNumber(contract.getProjectId(), context); + } + + /** + * 金蝶组织编码:项目业务部门沿部门树向上反查唯一平台公司(是否平台公司=是),取该平台公司的金蝶组织编码; + * 项目未关联/未维护业务部门/业务部门未归属平台公司/平台公司未维护金蝶组织编码时拦截 + * + * @param projectId 项目id + * @param context 金蝶同步上下文(缓存载体) + * @return 组织编码 + */ + public String kingdeeOrgNumber(Long projectId, KingdeeSyncContext context) { + ProjectApply project = this.requireOrgProject(projectId, context); + // 沿部门树向上反查平台公司,上限 20 级防脏数据成环死循环 + Dept dept = SysCache.getDept(project.getBusinessDeptId()); + for (int depth = 0; dept != null && depth < 20; depth++) { + if (Objects.equals(dept.getIsPlatformCompany(), 1)) { + if (Func.isBlank(dept.getKingdeeDeptCode())) { + throw new ServiceException("推送金蝶失败,平台公司[" + dept.getDeptName() + "]未维护金蝶组织编码,请检查部门数据"); + } + return dept.getKingdeeDeptCode(); + } + dept = dept.getParentId() == null ? null : SysCache.getDept(dept.getParentId()); + } + throw new ServiceException("推送金蝶失败,项目业务部门[" + Func.toStr(project.getBusinessDeptName()) + "]未归属平台公司,无法确定金蝶组织编码"); + } + + /** + * 金蝶部门编码:项目业务部门自身的金蝶编码(blade_dept.kingdee_dept_code,不上溯平台公司), + * 用于付款申请单 awrp_department_number(部门.编码);项目未关联/未维护业务部门/业务部门未维护金蝶编码时拦截 + * + * @param projectId 项目id + * @param context 金蝶同步上下文(缓存载体) + * @return 部门编码 + */ + public String kingdeeDeptNumber(Long projectId, KingdeeSyncContext context) { + ProjectApply project = this.requireOrgProject(projectId, context); + Dept dept = SysCache.getDept(project.getBusinessDeptId()); + if (dept == null || Func.isBlank(dept.getKingdeeDeptCode())) { + throw new ServiceException("推送金蝶失败,项目业务部门[" + Func.toStr(project.getBusinessDeptName()) + "]未维护金蝶编码,请检查部门数据"); } return dept.getKingdeeDeptCode(); } + /** 金蝶组织/部门编码共用校验:项目存在且已维护业务部门 */ + private ProjectApply requireOrgProject(Long projectId, KingdeeSyncContext context) { + if (projectId == null) { + throw new ServiceException("推送金蝶失败,单据未关联项目,无法确定金蝶组织编码"); + } + ProjectApply project = context.projectCache().computeIfAbsent(projectId, projectApplyMapper::selectById); + if (project == null) { + throw new ServiceException("推送金蝶失败,项目[" + projectId + "]不存在,无法确定金蝶组织编码"); + } + if (project.getBusinessDeptId() == null) { + throw new ServiceException("推送金蝶失败,项目[" + Func.toStr(project.getProjectName()) + "]未维护业务部门,无法确定金蝶组织编码"); + } + return project; + } + /** * 合同结算币种转金蝶币别代码(RMB 统一映射 CNY) * @@ -955,7 +1034,7 @@ public class KingdeeApBillSyncSupport { return "RMB".equalsIgnoreCase(currency) ? "CNY" : currency; } - /** 财务应付单往来类型:合同类别为“客户合同”时为 bd_customer(客户),否则 bd_supplier(供应商) */ + /** 财务应付单/付款申请/应收单/收票单统一往来类型:固定 bd_customer(按合同类别区分供应商的逻辑已注释停用,恢复时同步影响付款申请) */ public String bfiAsstactType(ContractManage contract) { return KingdeeBfiConstant.BFI_CUSTOMER_ASSTACT_TYPE; /*return "客户合同".equals(contract.getContractCategory()) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeArBillSyncSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeArBillSyncSupport.java index 34bf1de3..64a3a99a 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeArBillSyncSupport.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeArBillSyncSupport.java @@ -138,6 +138,33 @@ public class KingdeeArBillSyncSupport { return null; } + /** + * A302-4 冲销状态查询:按防重键查询财务应收单,返回该防重键下是否存在「已被冲销」单据 + * (BfiArBillVO.hadwrittenoff=true,部分冲销同样视为已冲销)。 + * 结算调整重推硬闸门用:仅当原单已被冲销才允许以新来源单据号重推新应收单 + * + * @param arBillSrcCode 财务应收单防重键 + * @return true=金蝶侧原应收单已被冲销 + */ + public boolean queryArBillWrittenOff(String arBillSrcCode) { + if (Func.isBlank(arBillSrcCode)) { + return false; + } + BfiArBillQueryDTO param = new BfiArBillQueryDTO(); + param.setAwrpSrcCode(List.of(arBillSrcCode)); + param.setAwrpSrcSys(bfiProperties.getSrcSys()); + for (BfiArBillVO arBill : BfiUtil.queryAllPages(pageNo -> { + param.setPageNo(pageNo); + param.setPageSize(AR_RESULT_PAGE_SIZE); + return bfiService.queryArBill(param); + })) { + if (arBillSrcCode.equals(arBill.getAwrpSrcCode()) && Boolean.TRUE.equals(arBill.getHadwrittenoff())) { + return true; + } + } + return false; + } + // ==================== A302-2 财务应收单保存 ==================== /** @@ -170,7 +197,7 @@ public class KingdeeArBillSyncSupport { if (Func.isBlank(billtypeNumber)) { throw new ServiceException("推送金蝶财务应收单失败:单据类型[" + KingdeeBfiConstant.BFI_AR_BILL_BILLTYPE_NAME + "]未在映射字典中维护"); } - String orgNumber = kingdeeApBillSyncSupport.bfiOrgNumber(contract); + String orgNumber = kingdeeApBillSyncSupport.bfiOrgNumber(contract, context); // 币种取合同结算币种 String currency = kingdeeApBillSyncSupport.bfiCurrency(contract); // 单据日期:yyyy-MM-dd HH:mm:ss(各接口统一格式),以同步当日作为应收单据日期 @@ -193,8 +220,8 @@ public class KingdeeArBillSyncSupport { param.setRecorgNumber(orgNumber); // 【必填】单据日期 param.setBizdate(billDateText); - // 【必填】往来类型:应收固定客户 - param.setAsstacttype(KingdeeBfiConstant.BFI_CUSTOMER_ASSTACT_TYPE); + // 【必填】往来类型:统一固定客户(KingdeeApBillSyncSupport#bfiAsstactType,与应付单/付款申请同口径) + param.setAsstacttype(kingdeeApBillSyncSupport.bfiAsstactType(contract)); // 【必填】往来户.编码:结算单付款方(客户) param.setAsstactNumber(customerCode); // 【必填】付款方式(CASH:现销, CREDIT:赊销),现销由金蝶自动推收款 diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeReceivedInvoiceSyncSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeReceivedInvoiceSyncSupport.java index cd80fd69..48a74c14 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeReceivedInvoiceSyncSupport.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeReceivedInvoiceSyncSupport.java @@ -183,10 +183,10 @@ public class KingdeeReceivedInvoiceSyncSupport { param.setInvoicetypef7Number(mirror.getInvoiceTypeCode()); // 【必填】付款方式:(与应付单一致,不允许金蝶自动推付款) param.setPaymenttype(BfiConstant.PAY_MODE_CASH); - // 【必填】往来类型:进项收票固定供应商 - param.setAsstacttype(BfiConstant.ASSTACT_TYPE_SUPPLIER); - // 【必填】结算组织.编码:合同所属组织(与应付单同源) - param.setOrgNumber(kingdeeApBillSyncSupport.bfiOrgNumber(contract)); + // 【必填】往来类型:统一固定客户(KingdeeApBillSyncSupport#bfiAsstactType,与应付单/付款申请同口径) + param.setAsstacttype(kingdeeApBillSyncSupport.bfiAsstactType(contract)); + // 【必填】结算组织.编码:合同关联项目业务部门反查平台公司(与应付单同源) + param.setOrgNumber(kingdeeApBillSyncSupport.bfiOrgNumber(contract, context)); // 【必填】结算币别:合同结算币种 param.setCurrencyNumber(kingdeeApBillSyncSupport.bfiCurrency(contract)); // 【必填】往来户.编码:合同乙方(开票供应商)客商编码 diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/LiveProcessConfigSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/LiveProcessConfigSupport.java new file mode 100644 index 00000000..366e03b1 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/LiveProcessConfigSupport.java @@ -0,0 +1,161 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.support; + +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import org.springblade.core.tool.utils.Func; +import org.springblade.transport.pojo.entity.ProcessConfig; +import org.springblade.transport.service.IProcessConfigService; + +import java.util.Arrays; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * 过程配置的取法与「生效配置还是运单快照」的二选一规则,只此一处。 + *

+ * 生效配置({@code blade_process_config})按项目生效,是活的:改一次,该项目下所有运单下次读取 + * 都按新配置呈现;运单创建时另存一份 {@code process_json} 快照,只在生效配置取不到时兜底。 + * 生效配置按更新时间倒序取第一条命中该项目的,项目集合是逗号串,故先 LIKE 粗筛再按逗号分段精确比对 —— + * 只靠 LIKE 会把「1234」命中到「12345」上。 + *

+ * 两个二选一版本的区别是**得到什么结果**,不是风格: + *

    + *
  • {@link #resolve} 生效配置优先 —— 接单需求、节点门禁、节点订正这些判断节点存不存在的路径用它。 + * 生效配置是权威,即使它没配可打卡节点也不能被快照顶掉,否则判断依据会随时间漂移。
  • + *
  • {@link #resolveForPunch} 生效配置没有可打卡节点时回退快照 —— 司机端打卡页与列表卡用它。 + * 配置被改坏(节点全删)时,司机端仍能按快照渲染出打卡节点,不至于空白。
  • + *
+ */ +public final class LiveProcessConfigSupport { + + private LiveProcessConfigSupport() { + } + + /** + * 单个项目的生效配置 JSON,没有则返回 null + * + * @param service 过程配置服务 + * @param projectId 项目主键 + */ + public static String byProject(IProcessConfigService service, Long projectId) { + if (service == null || projectId == null) { + return null; + } + String projectIdStr = String.valueOf(projectId); + return service.list(Wrappers.lambdaQuery() + .eq(ProcessConfig::getStatus, 1) + .eq(ProcessConfig::getIsDeleted, 0) + .like(ProcessConfig::getProjectIds, projectIdStr) + .orderByDesc(ProcessConfig::getUpdateTime) + .orderByDesc(ProcessConfig::getCreateTime)) + .stream() + .filter(config -> containsProjectId(config.getProjectIds(), projectIdStr)) + .map(ProcessConfig::getNodeConfigJson) + .filter(Func::isNotEmpty) + .findFirst() + .orElse(null); + } + + /** + * 一批项目的生效配置:项目主键 → 节点配置 JSON,一次查全再按项目匹配,供列表页避免逐条回查 + * + * @param service 过程配置服务 + * @param projectIds 项目主键集合,允许含 null + */ + public static Map byProjects(IProcessConfigService service, Collection projectIds) { + Map live = new LinkedHashMap<>(); + if (service == null || Func.isEmpty(projectIds)) { + return live; + } + Set wanted = new LinkedHashSet<>(); + projectIds.stream().filter(Objects::nonNull).forEach(wanted::add); + if (wanted.isEmpty()) { + return live; + } + List configs = service.list(Wrappers.lambdaQuery() + .eq(ProcessConfig::getStatus, 1) + .eq(ProcessConfig::getIsDeleted, 0) + .orderByDesc(ProcessConfig::getUpdateTime) + .orderByDesc(ProcessConfig::getCreateTime)); + if (Func.isEmpty(configs)) { + return live; + } + for (Long projectId : wanted) { + String projectIdStr = String.valueOf(projectId); + for (ProcessConfig config : configs) { + if (Func.isEmpty(config.getNodeConfigJson()) + || !containsProjectId(config.getProjectIds(), projectIdStr)) { + continue; + } + live.put(projectId, config.getNodeConfigJson()); + break; + } + } + return live; + } + + /** + * 生效配置优先,取不到时回退运单快照 + * + * @param liveJson 生效配置 JSON,可为空 + * @param snapshot 运单快照 JSON,可为空 + */ + public static String resolve(String liveJson, String snapshot) { + return Func.isNotEmpty(liveJson) ? liveJson : snapshot; + } + + /** + * 生效配置优先;生效配置没有可打卡节点时回退快照,两边都没有节点就按生效配置来 + * + * @param liveJson 生效配置 JSON,可为空 + * @param snapshot 运单快照 JSON,可为空 + */ + public static String resolveForPunch(String liveJson, String snapshot) { + if (Func.isEmpty(liveJson)) { + return snapshot; + } + if (!WaybillProcessSupport.listDriverPunchNodes(liveJson).isEmpty()) { + return liveJson; + } + if (Func.isNotEmpty(snapshot) && !WaybillProcessSupport.listDriverPunchNodes(snapshot).isEmpty()) { + return snapshot; + } + return liveJson; + } + + /** 项目集合是逗号串,必须按逗号分段精确比对 */ + private static boolean containsProjectId(String projectIds, String projectId) { + if (Func.isEmpty(projectIds) || Func.isEmpty(projectId)) { + return false; + } + return Arrays.stream(projectIds.split(",")) + .map(String::trim) + .anyMatch(projectId::equals); + } +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/LoadableAdmissionSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/LoadableAdmissionSupport.java new file mode 100644 index 00000000..eb7d9a43 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/LoadableAdmissionSupport.java @@ -0,0 +1,181 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.support; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import org.springblade.transport.pojo.entity.Waybill; + +import java.util.List; +import java.util.Locale; + +/** + * 可配载准入判据:公路 + 待执行 + 未关联其它配载单。 + *

+ * 接单状态不进判据:拒单票照样可配载。配载提交会把它的接单事实清成待接单, + * 否则新司机看不到这张票——司机端入口一律排除已拒绝的运单。 + *

+ * 候选列表取数与配载提交/编辑/重新派单的校验共用这一份定义。 + */ +public final class LoadableAdmissionSupport { + + /** 可配载只放行待执行:已完成、已取消、草稿与调度中间态一律不可配载 */ + public static final String LOADABLE_BUSINESS_STATUS = WaybillProcessSupport.STATUS_PENDING; + + /** + * 公路运输方式的同义写法,按「包含」比对,与前端运单管理页的识别口径一致。 + *

+ * 运输方式字典只有 road/railway/river/ocean/air,而库里存在「公路运输」「水路运输」这类中文取值, + * 因此词表写在代码里,不从字典取。 + */ + public static final List ROAD_TRANSPORT_KEYWORDS = List.of("公路", "道路", "汽车", "陆运", "road", "highway"); + + /** 公路运输方式的历史短码,按整体相等比对(按包含比对会误伤含 gl 的其它取值) */ + public static final List ROAD_TRANSPORT_CODES = List.of("gl"); + + private LoadableAdmissionSupport() { + } + + /** + * 不可配载的原因,按判定顺序排列:公路 → 待执行 → 未关联其它配载单。 + *

+ * 判据只产出原因,中文文案由调用方映射——面向用户的措辞不进这一层。 + */ + public enum UnloadableReason { + + /** 运输方式不是公路 */ + NOT_ROAD, + + /** 业务状态不是待执行 */ + NOT_PENDING, + + /** 已关联其它配载单 */ + ON_OTHER_MANIFEST + } + + /** + * 是否公路运输。未知或为空一律按非公路处理。 + * + * @param transportType 运单的运输方式取值 + * @return true-公路 + */ + public static boolean isRoadTransport(String transportType) { + String value = text(transportType).toLowerCase(Locale.ROOT); + if (value.isEmpty()) { + return false; + } + if (ROAD_TRANSPORT_CODES.contains(value)) { + return true; + } + for (String keyword : ROAD_TRANSPORT_KEYWORDS) { + if (value.contains(keyword)) { + return true; + } + } + return false; + } + + /** + * 该运单此刻是否可被配载。 + *

+ * ownLoadingNo 是正在编辑或重新派单的配载单号:运单已挂在本单上不算冲突, + * 否则编辑保存会把自己的子运单判为不可用。 + * + * @param waybill 运单 + * @param ownLoadingNo 本配载单号;新建时传 null + * @return true-可配载 + */ + public static boolean isLoadable(Waybill waybill, String ownLoadingNo) { + return unloadableReason(waybill, ownLoadingNo) == null; + } + + /** + * 该运单此刻不可配载的原因。 + * + * @param waybill 运单 + * @param ownLoadingNo 本配载单号;新建时传 null + * @return 命中的第一条原因;可配载时返回 null + */ + public static UnloadableReason unloadableReason(Waybill waybill, String ownLoadingNo) { + if (waybill == null || !isRoadTransport(waybill.getTransportType())) { + return UnloadableReason.NOT_ROAD; + } + if (!LOADABLE_BUSINESS_STATUS.equals(text(waybill.getBusinessStatus()))) { + return UnloadableReason.NOT_PENDING; + } + String loadingNo = text(waybill.getLoadingNo()); + if (loadingNo.isEmpty() || loadingNo.equals(text(ownLoadingNo))) { + return null; + } + return UnloadableReason.ON_OTHER_MANIFEST; + } + + /** + * 该运单是否已挂在本配载单上。 + *

+ * 本单既有子运单在先前提交时已过准入判据,重新提交/编辑时不再重复判: + * 不需接单项目的票提交后即为进行中,重复判会让本单再也保存不了,重新派单也无从下手。 + * + * @param waybill 运单 + * @param ownLoadingNo 本配载单号;新建时传 null + * @return true-已是本单子运单 + */ + public static boolean isOwnChild(Waybill waybill, String ownLoadingNo) { + if (waybill == null) { + return false; + } + String own = text(ownLoadingNo); + return !own.isEmpty() && own.equals(text(waybill.getLoadingNo())); + } + + /** + * 把整条可配载判据加到运单查询上:公路 + 待执行 + 未配载。 + * + * @param wrapper 运单列表查询条件 + */ + public static void applyLoadableConditions(LambdaQueryWrapper wrapper) { + applyRoadTransportCondition(wrapper); + wrapper.eq(Waybill::getBusinessStatus, LOADABLE_BUSINESS_STATUS); + wrapper.and(nested -> nested.isNull(Waybill::getLoadingNo).or().eq(Waybill::getLoadingNo, "")); + } + + private static void applyRoadTransportCondition(LambdaQueryWrapper wrapper) { + wrapper.and(nested -> { + boolean started = false; + for (String keyword : ROAD_TRANSPORT_KEYWORDS) { + if (started) { + nested.or(); + } + nested.like(Waybill::getTransportType, keyword); + started = true; + } + for (String code : ROAD_TRANSPORT_CODES) { + nested.or().eq(Waybill::getTransportType, code); + } + }); + } + + private static String text(String value) { + return value == null ? "" : value.trim(); + } + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/OcrFieldSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/OcrFieldSupport.java index ef4e7798..74184e28 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/OcrFieldSupport.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/OcrFieldSupport.java @@ -279,6 +279,10 @@ public final class OcrFieldSupport { Map> aliases = new LinkedHashMap<>(); aliases.put("realName", List.of("姓名")); aliases.put("idCardNo", List.of("公民身份号码", "公民身份证号码", "身份证号", "身份证号码")); + // 身份证正面的「出生」是一整串日期(1990年1月1日),按 birthday 归一成 yyyy-MM-dd; + // 住址是身份证上的登记住址,写进 driver.address + aliases.put("birthday", List.of("出生", "出生年月", "出生日期")); + aliases.put("address", List.of("住址", "地址")); aliases.put("idCardValidStart", List.of("签发日期", "有效起始日期", "有效期限起")); aliases.put("idCardValidEnd", List.of("失效日期", "有效期限", "有效期限止")); return aliases; @@ -319,7 +323,8 @@ public final class OcrFieldSupport { } private static boolean isDateField(String key) { - return key.endsWith("Date") || key.endsWith("ValidStart") || key.endsWith("ValidEnd"); + // birthday 的语义是日期但拼写不以 Date 结尾,漏了它会把「1990年1月1日」原样下发 + return "birthday".equals(key) || key.endsWith("Date") || key.endsWith("ValidStart") || key.endsWith("ValidEnd"); } private static boolean isNumberField(String key) { diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/TransportBusinessSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/TransportBusinessSupport.java index f5b9b143..ad497230 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/TransportBusinessSupport.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/TransportBusinessSupport.java @@ -28,7 +28,9 @@ import org.springblade.core.secure.utils.AuthUtil; import org.springblade.core.tool.constant.RoleConstant; import org.springblade.core.tool.utils.Func; import org.springblade.system.cache.SysCache; +import org.springblade.system.cache.UserCache; import org.springblade.system.pojo.entity.Dept; +import org.springblade.system.pojo.entity.User; import java.math.BigDecimal; import java.time.LocalDate; @@ -50,6 +52,8 @@ public final class TransportBusinessSupport { public static final String DATA_SOURCE_EXTERNAL = "外部系统"; private static final Pattern PHONE_PATTERN = Pattern.compile("^(1\\d{10}|0\\d{2,3}-?\\d{7,8})$"); + /** 用户档案「数据权限范围」的取值:全部组织,与组织树过滤口径一致 */ + private static final int DATA_SCOPE_ALL = 2; private static final BigDecimal MIN_LONGITUDE = new BigDecimal("-180"); private static final BigDecimal MAX_LONGITUDE = new BigDecimal("180"); private static final BigDecimal MIN_LATITUDE = new BigDecimal("-90"); @@ -76,6 +80,26 @@ public final class TransportBusinessSupport { return false; } + /** + * 是否被授予跨组织数据权限:超级管理员,或用户档案「数据权限范围」为全部组织。 + *

+ * 「数据权限范围」在用户管理里配置,组织树已按它过滤(DeptServiceImpl 的组织树数据权限); + * 运输业务的列表过滤与写入校验必须与组织树同一口径。 + * + * @return true-不限组织 + */ + public static boolean hasAllDeptScope() { + if (isAdministrator()) { + return true; + } + Long userId = AuthUtil.getUserId(); + if (Func.isEmpty(userId) || userId <= 0) { + return false; + } + User user = UserCache.getUser(userId); + return Objects.equals(user == null ? null : user.getDataScopeRange(), DATA_SCOPE_ALL); + } + public static Long currentDeptId(String moduleName) { Long deptId = Func.firstLong(AuthUtil.getDeptId()); if (Func.isEmpty(deptId) || deptId <= 0) { @@ -92,14 +116,22 @@ public final class TransportBusinessSupport { return dept; } + /** + * 校验目标业务数据属于当前用户可操作的组织。 + *

+ * 超管与「数据权限范围=全部组织」的用户不限组织;其余用户仍严格要求与当前会话组织相等。 + */ public static void assertCurrentDept(Long deptId, String moduleName) { + if (hasAllDeptScope()) { + return; + } if (!Objects.equals(deptId, currentDeptId(moduleName))) { throw new ServiceException("无权操作其他组织" + moduleName); } } public static void validateAllDept(Integer allDept, String moduleName) { - if (Objects.equals(allDept, 1) && !isAdministrator()) { + if (Objects.equals(allDept, 1) && !hasAllDeptScope()) { throw new ServiceException("无权查看全部组织" + moduleName); } } @@ -107,7 +139,8 @@ public final class TransportBusinessSupport { /** * 解析列表组织筛选 ID。 *

- * 普通用户 allDept!=1 时强制当前组织;超管默认不按组织过滤,仅在 allDept=1 且传入 deptId 时精确匹配该组织。 + * 普通用户 allDept!=1 时强制当前组织;超管与「全部组织」用户默认不按组织过滤, + * 仅在 allDept=1 且传入 deptId 时精确匹配该组织。 * * @param allDept 是否查看全部组织 * @param deptId 筛选组织 ID @@ -116,7 +149,7 @@ public final class TransportBusinessSupport { */ public static List resolveDeptFilterIds(Integer allDept, Long deptId, String moduleName) { validateAllDept(allDept, moduleName); - if (isAdministrator()) { + if (hasAllDeptScope()) { if (Objects.equals(allDept, 1) && Func.isNotEmpty(deptId) && deptId > 0) { return Collections.singletonList(deptId); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillCargoSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillCargoSupport.java new file mode 100644 index 00000000..c486b558 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillCargoSupport.java @@ -0,0 +1,302 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.support; + +import org.springblade.core.tool.jackson.JsonUtil; +import org.springblade.core.tool.utils.Func; +import org.springblade.transport.pojo.entity.Waybill; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 运单货物明细的取法,只此一处。 + *

+ * 一张运单可以录多条货物,明细落在 {@code goods_json};主表的 {@code cargo_name} / {@code cargo_type} / + * {@code quantity} 只是给列表与报表用的首行汇总(数量是各行合计),拿它当货物清单会把其余货物丢掉。 + * 所以「这张运单装了什么」一律以 goods_json 为准,主表字段只在 goods_json 缺失或解析不出任何货名时兜底。 + *

+ * 货名也可能是主表里一格多名的历史写法(顿号、逗号分隔),按同一口径摊平。 + */ +public final class WaybillCargoSupport { + + /** 计重时唯一允许跨货物相加的单位:其他单位只在「全部货物同单位」时才合计 */ + private static final String UNIT_TON = "吨"; + + /** 货物没有填名称时的展示名:宁可让司机看到「货物」,也不要出现空行 */ + private static final String FALLBACK_NAME = "货物"; + + /** 货名分隔符:历史数据里顿号、中英文逗号都出现过 */ + private static final String NAME_SEPARATOR_REGEX = "[、,,]"; + + /** 明细里可能承载数量的字段,按优先级取第一个有值的 */ + private static final String[] QUANTITY_KEYS = {"quantity", "cargoQuantity", "goodsQuantity"}; + + /** 明细里可能承载单位的字段,按优先级取第一个有值的 */ + private static final String[] UNIT_KEYS = {"quantityUnit", "goodsQuantityUnit", "unit"}; + + /** 明细里可能承载名称的字段,按优先级取第一个有值的 */ + private static final String[] NAME_KEYS = {"cargoName", "goodsName"}; + + /** 明细里可能承载类型的字段,按优先级取第一个有值的 */ + private static final String[] TYPE_KEYS = {"cargoType", "goodsType"}; + + private WaybillCargoSupport() { + } + + /** + * 一张运单的货物条目:goods_json 能解析出货物就用它,否则回退到主表的货物字段。 + *

+ * 每条明细至少出一条:货名、数量都为空的行也保留,让「这张票录了一条空货物」与「这条货物没填数量」可区分。 + */ + public static List of(Waybill waybill) { + if (waybill == null) { + return List.of(); + } + List detail = parseDetail(waybill.getGoodsJson()); + if (!detail.isEmpty()) { + return detail; + } + return fallback(waybill); + } + + /** + * 货名清单:同名的只留一次,保持录入顺序;档口与报表需要横向摊平时用它 + */ + public static List cargoNames(List cargos) { + List names = new ArrayList<>(); + for (Cargo cargo : cargos) { + String name = Func.toStr(cargo.cargoName(), "").trim(); + if (Func.isNotEmpty(name) && !names.contains(name)) { + names.add(name); + } + } + return names; + } + + /** + * 解析 goods_json 为货物条目;字段缺失、JSON 非法或解析不出任何条目都返回空集合,由调用方兜底 + */ + private static List parseDetail(String goodsJson) { + if (Func.isEmpty(goodsJson)) { + return List.of(); + } + List rows; + try { + rows = JsonUtil.parseArray(goodsJson, Map.class); + } catch (Exception exception) { + // 历史脏数据:JSON 坏了就当没有明细,回退主表字段,不让列表与详情因一条脏数据打不开 + return List.of(); + } + if (Func.isEmpty(rows)) { + return List.of(); + } + List cargos = new ArrayList<>(rows.size()); + for (Map row : rows) { + if (row == null) { + continue; + } + cargos.add(new Cargo( + firstText(row, NAME_KEYS), + firstText(row, TYPE_KEYS), + quantityOf(row), + firstText(row, UNIT_KEYS))); + } + return cargos; + } + + /** + * 回退:主表货名按历史分隔符摊平,每一段配同一个数量与单位(主表数量是各行合计,摊到哪一段都不准, + * 故只给一段配数量;多段说明主表数量不可拆分,宁可只展示货名也不重复同一份合计) + */ + private static List fallback(Waybill waybill) { + List names = splitCargoNames(waybill.getCargoName()); + if (names.isEmpty()) { + return List.of(); + } + List cargos = new ArrayList<>(names.size()); + boolean first = true; + for (String name : names) { + cargos.add(new Cargo( + name, + Func.toStr(waybill.getCargoType(), ""), + first ? waybill.getQuantity() : null, + first ? Func.toStr(waybill.getQuantityUnit(), "") : "")); + first = false; + } + return cargos; + } + + /** + * 计重合计:卡片上那个「货物重量」 + *

+ * 全部货物同单位时就是该单位合计;单位不一致时只合计「吨」项,其余单位不参与—— + * 「100吨 + 20堆」报 100 吨,「20堆 + 50方」没有吨项就不出合计。 + * 有货物缺数量时该单位合计不完整,宁可不报也不报一个偏小的数。 + * + * @return 有可用合计时返回数量与单位,否则 null + */ + public static Quantity weighing(List cargos) { + Map totals = new LinkedHashMap<>(); + Set incomplete = new HashSet<>(); + for (Cargo cargo : cargos) { + String unit = Func.toStr(cargo.quantityUnit(), ""); + if (Func.isEmpty(unit)) { + continue; + } + if (cargo.quantity() == null) { + incomplete.add(unit); + continue; + } + totals.merge(unit, cargo.quantity(), BigDecimal::add); + } + if (totals.isEmpty()) { + return null; + } + String unit; + if (totals.size() == 1) { + unit = totals.keySet().iterator().next(); + } else { + unit = totals.containsKey(UNIT_TON) ? UNIT_TON : null; + } + if (unit == null || incomplete.contains(unit)) { + return null; + } + return new Quantity(totals.get(unit), unit); + } + + /** 计重合计的数量与单位 */ + public record Quantity(BigDecimal value, String unit) { + } + + /** + * 货物明细行:一条货物一行,同名同单位的合并数量,按录入顺序 + *

+ * 与 {@link #cargoSummary(List)} 的合并口径一致,但数量与单位保持分开,供详情页逐行展示。 + * 数量为空的货物只留名称,不按 0 参与合并。 + */ + public static List lines(List cargos) { + Map index = new LinkedHashMap<>(); + List lines = new ArrayList<>(); + for (Cargo cargo : cargos) { + String name = cargo.displayName(); + String unit = Func.toStr(cargo.quantityUnit(), ""); + String key = name + '\u0000' + unit; + Integer at = index.get(key); + if (at == null) { + index.put(key, lines.size()); + lines.add(new Line(name, cargo.quantity(), unit)); + continue; + } + Line merged = lines.get(at); + if (cargo.quantity() == null) { + continue; + } + BigDecimal quantity = merged.quantity() == null + ? cargo.quantity() + : merged.quantity().add(cargo.quantity()); + lines.set(at, new Line(name, quantity, unit)); + } + return lines; + } + + /** 一条货物明细:数量可能为空(录入时只填了名称) */ + public record Line(String name, BigDecimal quantity, String unit) { + } + + /** + * 主表货名按顿号、逗号摊平;空格与空段去掉,全空时给空集合 + */ + public static List splitCargoNames(String cargoName) { + if (Func.isEmpty(cargoName)) { + return List.of(); + } + List names = new ArrayList<>(); + for (String part : cargoName.split(NAME_SEPARATOR_REGEX)) { + String name = part.trim(); + if (Func.isNotEmpty(name)) { + names.add(name); + } + } + return names; + } + + private static String firstText(Map row, String... keys) { + for (String key : keys) { + Object value = row.get(key); + if (value == null) { + continue; + } + String text = String.valueOf(value).trim(); + if (Func.isNotEmpty(text)) { + return text; + } + } + return ""; + } + + private static BigDecimal quantityOf(Map row) { + for (String key : QUANTITY_KEYS) { + Object value = row.get(key); + if (value == null) { + continue; + } + String text = String.valueOf(value).trim(); + if (Func.isEmpty(text)) { + continue; + } + try { + BigDecimal quantity = new BigDecimal(text); + // 录入端用 -1 表示「未填」,落到汇总里会把合计算成负数 + if (quantity.compareTo(BigDecimal.ONE.negate()) == 0) { + continue; + } + return quantity; + } catch (NumberFormatException exception) { + // 这一字段不是数字:继续试下一个候选字段 + } + } + return null; + } + + /** + * 运单上的一条货物 + *

+ * 数量与单位可能为空:录货物时允许只填名称。展示端要把「没填数量」与「数量为 0」区分开。 + */ + public record Cargo(String cargoName, String cargoType, BigDecimal quantity, String quantityUnit) { + + /** + * 展示用货名:空名称回退成「货物」 + */ + public String displayName() { + return Func.toStrWithEmpty(cargoName, FALLBACK_NAME); + } + } + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillProcessSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillProcessSupport.java index 8de4c8b9..4462c3a3 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillProcessSupport.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillProcessSupport.java @@ -38,6 +38,7 @@ import java.util.Date; import java.util.HashSet; import java.util.LinkedHashSet; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Set; @@ -132,6 +133,81 @@ public final class WaybillProcessSupport { return ACCEPT_REJECTED.equalsIgnoreCase(stringVal(driverAcceptStatus)); } + /** + * 运单已结束:已完成或已取消。此后不再接受司机打卡。 + *

+ * 只认这两个状态,大小写不敏感。草稿、待执行、待调度、调度中的运单司机仍要能打卡, + * 所以不能按 {@link #isTerminalBusinessStatus} 的「终态或调度中间态」口径判定。 + */ + public static boolean isPunchEnded(String businessStatus) { + String status = stringVal(businessStatus); + return "completed".equalsIgnoreCase(status) || "cancelled".equalsIgnoreCase(status); + } + + /** + * 结束态下的打卡拒绝文案;非结束态返回 null。 + */ + public static String punchEndedReason(String businessStatus) { + String status = stringVal(businessStatus); + if ("completed".equalsIgnoreCase(status)) { + return "运单已完成"; + } + if ("cancelled".equalsIgnoreCase(status)) { + return "运单已取消"; + } + return null; + } + + /** + * 运单已开始运输:只有进行中可以打卡(在途与普通节点同一判据)。 + *

+ * 草稿、待执行(待接单)、待调度、调度中都还没上路,任何打卡都没有承载物。 + * 判据是校正后落库的状态:无需接单的待执行运单在 {@link #normalizeBusinessStatus} + * 里已被提升为进行中,未提升的待执行就是真的还没接单。 + */ + public static boolean isTransitPunchStarted(String businessStatus) { + return STATUS_RUNNING.equalsIgnoreCase(stringVal(businessStatus)); + } + + /** + * 未开始运输时的打卡拒绝文案;进行中与已结束返回 null(结束文案由 {@link #punchEndedReason} 给)。 + */ + public static String punchNotStartedReason(String businessStatus) { + if (isTransitPunchStarted(businessStatus) || isPunchEnded(businessStatus)) { + return null; + } + return "运单尚未开始运输,暂不可打卡"; + } + + /** + * 指定自然日是否已打过在途卡。 + *

+ * 与运单状态无关,只回答「这一天打过没有」。{@link TransitCheckinDecision#doneToday()} + * 只在进行中分支计算,状态一非进行中就回到 false,两者不可互相替代。 + */ + public static boolean isPunchedOn(Date lastPunchAt, LocalDateTime now) { + LocalDate day = (now == null ? LocalDateTime.now() : now).toLocalDate(); + LocalDate last = toLocalDate(lastPunchAt); + return last != null && last.equals(day); + } + + /** + * 上报异常前的运单状态门禁原因;可上报时返回 null。 + *

+ * 与打卡共用「已开始运输」判据,但文案说的是能不能报,不是能不能打。 + * 结束优先于未开始:已完成/已取消的运单先说结束,不再提「等你上路」。 + */ + public static String reportBlockedReason(String businessStatus) { + String endedReason = punchEndedReason(businessStatus); + if (endedReason != null) { + return endedReason + ",不能上报异常"; + } + if (!isTransitPunchStarted(businessStatus)) { + return "运单尚未开始运输,暂不可上报异常"; + } + return null; + } + public static boolean isTerminalBusinessStatus(String businessStatus) { return "draft".equals(businessStatus) || "completed".equals(businessStatus) @@ -197,15 +273,20 @@ public final class WaybillProcessSupport { int frequencyDays = parsePositiveInt(transit.get("frequencyDays"), 1); String timeStart = normalizeHm(stringVal(transit.get("timeStart")), "00:00"); String timeEnd = normalizeHm(stringVal(transit.get("timeEnd")), "23:59"); - // punch=是即展示卡片;非进行中仅不可作为「今日待打」 - if (!STATUS_RUNNING.equals(businessStatus)) { - return new TransitCheckinDecision(true, true, false, false, frequencyDays, timeStart, timeEnd); + // 结束不可逆,没有解除途径:卡片仍下发,但不可提交,也不算今日待打 + if (isPunchEnded(businessStatus)) { + return new TransitCheckinDecision(false, true, false, false, frequencyDays, timeStart, timeEnd); + } + // punch=是即展示卡片;未开始运输(草稿/待执行/待调度/调度中)的卡片仍下发, + // 但不可提交——打卡的前提是运单已上路(判据同时用于节点打卡)。 + if (!isTransitPunchStarted(businessStatus)) { + return new TransitCheckinDecision(false, true, false, false, frequencyDays, timeStart, timeEnd); } LocalDateTime current = now == null ? LocalDateTime.now() : now; LocalDate today = current.toLocalDate(); LocalDate lastDate = toLocalDate(lastPunchAt); - boolean doneToday = lastDate != null && lastDate.equals(today); + boolean doneToday = isPunchedOn(lastPunchAt, current); boolean dueByFrequency; if (lastDate == null) { dueByFrequency = true; @@ -419,6 +500,28 @@ public final class WaybillProcessSupport { return Collections.emptySet(); } + /** + * 顺序门禁点名的那一个前置节点名称:取**最先未完成**的。 + *

+ * 它才是司机现在能去打卡的那一步——后面那些未完成的前置节点自己也还打不了, + * 一起列出来既长,又不是一个可执行的下一步。 + * + * @param processJson 过程配置 + * @param nodeKeys 未完成的前置节点 key,即 {@link #unfinishedPrerequisiteKeys} 的返回 + */ + public static String firstUnfinishedPrerequisiteName(String processJson, Collection nodeKeys) { + if (nodeKeys == null || nodeKeys.isEmpty()) { + return ""; + } + for (Map node : listDriverPunchNodes(processJson)) { + String key = nodeKey(node); + if (nodeKeys.contains(key)) { + return nodeName(node); + } + } + return ""; + } + /** * 两个节点编码是否指同一个节点:两侧去空白、忽略大小写。 *

@@ -637,6 +740,34 @@ public final class WaybillProcessSupport { return true; } + /** + * 还没打卡的司机打卡节点名称(在途除外):回单由业务员确认前,这些节点必须都打过卡,否则 + * 「确认回单」就成了绕过打卡门禁的通道——完成接口本身不要求节点打卡。 + *

+ * 在途是频次打卡节点、不是必经的一步,故不参与;空列表表示该打卡的都打完了。 + * + * @param punchedKeys 已打卡的节点编码与名称,**小写**(库里的 node_code / node_name 都可能被用来对节点) + */ + public static List missingPunchNodeNames(String processJson, Collection punchedKeys) { + List> punchConfigs = listDriverPunchNodes(processJson); + if (punchConfigs.isEmpty()) { + return Collections.emptyList(); + } + Collection punched = punchedKeys == null ? Collections.emptySet() : punchedKeys; + List missing = new ArrayList<>(); + for (Map node : punchConfigs) { + if (isTransitNode(node)) { + continue; + } + String code = nodeKey(node).toLowerCase(Locale.ROOT); + String name = nodeName(node).toLowerCase(Locale.ROOT); + if (!punched.contains(code) && !punched.contains(name)) { + missing.add(nodeName(node)); + } + } + return missing; + } + private static boolean isTransitNode(Map node) { String key = stringVal(node.get("key")); String name = stringVal(node.get("name")); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/task/SettlementRuleAutoGenerateTask.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/task/SettlementRuleAutoGenerateTask.java new file mode 100644 index 00000000..80cc5d8b --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/task/SettlementRuleAutoGenerateTask.java @@ -0,0 +1,59 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.task; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springblade.transport.service.ISettlementRuleAutoGenerateService; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * 合同结算规则定时生成结算单。 + *

+ * 每天零点执行:预结算或正式结算配置开启自动生成时,按账单开始日期和结算周期归集已关闭账期的运单明细。 + * + * @author Chill + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class SettlementRuleAutoGenerateTask { + + private final ISettlementRuleAutoGenerateService settlementRuleAutoGenerateService; + + /** 每天 00:00 生成到期的预结算单、正式结算单 */ + @Scheduled(cron = "${blade.settlement.auto-generate-cron:0 0 0 * * ?}") + public void generateDueSettlements() { + try { + String summary = settlementRuleAutoGenerateService.generateDueSettlements(); + log.info("定时自动生成结算单:{}", summary); + } catch (Exception exception) { + log.error("定时自动生成结算单异常", exception); + } + } + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/FormalSettlementWrapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/FormalSettlementWrapper.java index 4f4a331a..68142f7d 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/FormalSettlementWrapper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/FormalSettlementWrapper.java @@ -45,6 +45,7 @@ public class FormalSettlementWrapper extends BaseEntityWrapper "已作废"; default -> entity.getApprovalStatus(); }); + vo.setAdjustStatusName("adjusted".equals(entity.getAdjustStatus()) ? "已调整" : null); return vo; } } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/ReceiptFlowWrapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/ReceiptFlowWrapper.java index b4212704..e0e59387 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/ReceiptFlowWrapper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/ReceiptFlowWrapper.java @@ -70,10 +70,15 @@ public class ReceiptFlowWrapper extends BaseEntityWrapper "处理失败"; default -> "未推送"; }); - // 剩余可认领金额 = 公告未认领金额 − 累计认领金额(认领上限为公告口径) + // 剩余可认领金额 = 可认领上限 − 累计认领金额(上限取公告未认领与收款金额较小值) BigDecimal unclaimedAmount = money(entity.getUnclaimedAmount()); + BigDecimal receiptAmount = money(entity.getReceiptAmount()); + BigDecimal claimCeiling = unclaimedAmount; + if (receiptAmount.compareTo(BigDecimal.ZERO) > 0 && unclaimedAmount.compareTo(BigDecimal.ZERO) > 0) { + claimCeiling = unclaimedAmount.min(receiptAmount); + } BigDecimal claimedAmount = money(entity.getClaimedAmount()); - vo.setRemainingAmount(unclaimedAmount.subtract(claimedAmount).max(BigDecimal.ZERO)); + vo.setRemainingAmount(claimCeiling.subtract(claimedAmount).max(BigDecimal.ZERO)); vo.setCreateUserName(UserCache.getUserRealName(entity.getCreateUser())); vo.setUpdateUserName(UserCache.getUserRealName(entity.getUpdateUser())); return vo; diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/KingdeeEntrySplitTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/KingdeeEntrySplitTest.java index 9b0575b1..50584e52 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/KingdeeEntrySplitTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/KingdeeEntrySplitTest.java @@ -86,11 +86,11 @@ class KingdeeEntrySplitTest { assertEquals(new BigDecimal("800"), splits.get(1).getAmount()); } - /** 费用项档案分类判定:名称不含“装卸”的费用项,档案费用类型名称含“装卸”即归装卸大类 */ + /** 费用项档案分类判定:名称不含“装卸”的费用项,档案费用大类编码 A10 即归装卸大类 */ @Test void loadingCategoryFromFeeItemArchive() { Map feeItems = Map.of("吊装作业", new BigDecimal("500")); - Map feeCategoryByName = Map.of("吊装作业", "装卸"); + Map feeCategoryByName = Map.of("吊装作业", "A10"); List splits = KingdeeApBillSyncSupport.splitFeeItems(feeItems, feeCategoryByName, Map.of(), new BigDecimal("500")); assertEquals(1, splits.size()); assertTrue(splits.get(0).isLoading()); @@ -166,4 +166,23 @@ class KingdeeEntrySplitTest { List splits = KingdeeApBillSyncSupport.splitFeeItems(feeItems, Map.of(), Map.of(), BigDecimal.ZERO); assertTrue(splits.isEmpty()); } + + /** 手工合计行运费类判定:费用项档案分类编码 A01 归运费类映射运输物料(SJS2026101900004 车辆维修费误映射装卸物料回归用例) */ + @Test + void freightCategoryMatchesArchiveCode() { + Map feeCategoryByName = Map.of( + "车辆维修费", "A01", + "人工装卸费用", "A10"); + assertTrue(KingdeeApBillSyncSupport.isFreightCategory("A01", "车辆维修费", feeCategoryByName)); + assertFalse(KingdeeApBillSyncSupport.isFreightCategory("A10", "人工装卸费用", feeCategoryByName)); + // 档案编码优先于名称关键字:A02 档案的“装卸搬运作业费”不归装卸大类 + Map feeItems = Map.of("装卸搬运作业费", new BigDecimal("100")); + List splits = KingdeeApBillSyncSupport.splitFeeItems(feeItems, + Map.of("装卸搬运作业费", "A02"), Map.of(), new BigDecimal("100")); + assertFalse(splits.get(0).isLoading()); + // 档案未维护:回退行费用类型编码与名称关键字判定 + assertTrue(KingdeeApBillSyncSupport.isFreightCategory("A01", "车辆维修费", Map.of())); + assertFalse(KingdeeApBillSyncSupport.isFreightCategory("A10", "人工装卸费用", Map.of())); + assertTrue(KingdeeApBillSyncSupport.isFreightCategory("", "运输费", Map.of())); + } } diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/CertAuditServiceImplTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/CertAuditServiceImplTest.java index e570361d..5de77bd9 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/CertAuditServiceImplTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/CertAuditServiceImplTest.java @@ -110,7 +110,7 @@ class CertAuditServiceImplTest { ArgumentCaptor captor = ArgumentCaptor.forClass(TransportVehicle.class); verify(transportVehicleService).updateById(captor.capture()); TransportVehicle patched = captor.getValue(); - assertEquals("桂物物流集团", patched.getOwnerName(), "所有人要跟着生效值更新"); + assertEquals("广西现代物流集团有限公司", patched.getOwnerName(), "所有人要跟着生效值更新"); assertEquals("管理挂靠", patched.getBusinessRelation()); assertEquals(18000, patched.getApprovedLoadKg()); assertEquals(RECORD_ID, patched.getEffectiveCertRecordId(), "内容与指针必须是同一次 UPDATE"); @@ -441,7 +441,7 @@ class CertAuditServiceImplTest { */ static String completeVehicleSnapshot() { return "{\"drivingLicenseNo\":\"450100123456\",\"approvedLoadKg\":18000," - + "\"ownerName\":\"桂物物流集团\",\"businessRelation\":\"管理挂靠\"," + + "\"ownerName\":\"广西现代物流集团有限公司\",\"businessRelation\":\"管理挂靠\"," + "\"vehicleType\":\"重型货车\",\"driveLicenseImage\":\"http://oss/xsz.jpg\"}"; } diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverCertServiceImplTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverCertServiceImplTest.java index 505f57a1..44d58724 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverCertServiceImplTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverCertServiceImplTest.java @@ -464,13 +464,13 @@ class DriverCertServiceImplTest { assertEquals("重型货车", vehicleCaptor.getValue().getVehicleType()); assertNull(vehicleCaptor.getValue().getEffectiveCertRecordId(), "新建的车不得自带生效快照——是否已认证只能由审核通过决定"); - assertEquals("桂物物流集团", vehicleCaptor.getValue().getOrganizationName(), + assertEquals("广西现代物流集团有限公司", vehicleCaptor.getValue().getOrganizationName(), "所属组织必须从司机档案带出:该列 NOT NULL 且无默认值,空着这条 INSERT 直接报错"); assertEquals("管理挂靠", vehicleCaptor.getValue().getBusinessRelation(), "业务关系由司机在表单里选,服务端照实落库、不替用户猜"); assertNotNull(vehicleCaptor.getValue().getRegistrationNo(), "机动车登记编号该列 NOT NULL:司机端选填,缺值要写空串而不是 null"); - assertEquals("桂物物流集团", vehicleCaptor.getValue().getOwnerName(), + assertEquals("广西现代物流集团有限公司", vehicleCaptor.getValue().getOwnerName(), "所有人是行驶证上的登记主体,与所属组织是两个字段"); // 绑定:司机的 driving_vehicle 被追加该车牌 @@ -663,7 +663,7 @@ class DriverCertServiceImplTest { VehicleCertSubmitRequest request = new VehicleCertSubmitRequest(); request.setPlateNo(plateNo); request.setVehicleType("重型货车"); - request.setOwnerName("桂物物流集团"); + request.setOwnerName("广西现代物流集团有限公司"); request.setPlateColor("黄牌"); request.setEnergyType("柴油"); request.setBusinessRelation("管理挂靠"); @@ -691,7 +691,7 @@ class DriverCertServiceImplTest { driver.setDriverName("张三"); // 车辆表的 organization_name 是 NOT NULL 且无默认值:司机缺组织时建车必然失败, // 故建车链路要求它非空,fixture 也必须给 - driver.setOrganizationName("桂物物流集团"); + driver.setOrganizationName("广西现代物流集团有限公司"); driver.setDrivingVehicle(drivingVehicle); return driver; } diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverNodePunchPhotoTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverNodePunchPhotoTest.java index 86b9a3e7..703070cb 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverNodePunchPhotoTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverNodePunchPhotoTest.java @@ -28,6 +28,7 @@ import org.springblade.transport.service.IDriverService; import org.springblade.transport.service.IProcessConfigService; import org.springblade.transport.service.ITransportVehicleService; import org.springblade.transport.service.IWaybillService; +import org.springblade.transport.service.ITripQueryService; import org.springblade.transport.support.CertificationGate; import java.util.ArrayList; @@ -58,6 +59,7 @@ class DriverNodePunchPhotoTest { private static final Long WAYBILL_ID = 5601L; private static final Long USER_ID = 7001L; private static final String PLATE = "桂AAW3255"; + private static final String PHONE = "13800138003"; private static final String CLOCK_PHOTO = "https://oss/clock.jpg"; private static final String WAYBILL_PHOTO = "https://oss/waybill.jpg"; private static final String RECEIPT_PHOTO = "https://oss/receipt.jpg"; @@ -99,7 +101,8 @@ class DriverNodePunchPhotoTest { mock(CertificationGate.class), mock(WaybillEnroutePunchMapper.class), nodePunchMapper, - exceptionDisposalMapper); + exceptionDisposalMapper, + mock(ITripQueryService.class)); when(driverService.getOne(any())).thenReturn(driver()); when(waybillService.getOne(any())).thenReturn(waybill()); @@ -308,6 +311,7 @@ class DriverNodePunchPhotoTest { driver.setId(9001L); driver.setUserId(USER_ID); driver.setDrivingVehicle(PLATE); + driver.setMobile(PHONE); return driver; } @@ -316,6 +320,7 @@ class DriverNodePunchPhotoTest { waybill.setId(WAYBILL_ID); waybill.setWaybillNo("YD202610070001"); waybill.setVehicleNo(PLATE); + waybill.setDriverPhone(PHONE); waybill.setProcessJson(PROCESS_JSON); waybill.setBusinessStatus("running"); waybill.setIsDeleted(0); diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverPunchFinishFlagTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverPunchFinishFlagTest.java new file mode 100644 index 00000000..daecf3cf --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverPunchFinishFlagTest.java @@ -0,0 +1,163 @@ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springblade.system.feign.IMpUserClient; +import org.springblade.transport.mapper.ExceptionDisposalMapper; +import org.springblade.transport.mapper.WaybillEnroutePunchMapper; +import org.springblade.transport.mapper.WaybillNodePunchMapper; +import org.springblade.transport.pojo.entity.ExceptionDisposal; +import org.springblade.transport.pojo.entity.Waybill; +import org.springblade.transport.pojo.entity.WaybillEnroutePunch; +import org.springblade.transport.pojo.entity.WaybillNodePunch; +import org.springblade.transport.pojo.vo.DriverPunchNodeVO; +import org.springblade.transport.service.IDriverService; +import org.springblade.transport.service.IProcessConfigService; +import org.springblade.transport.service.ITransportVehicleService; +import org.springblade.transport.service.ITripQueryService; +import org.springblade.transport.service.IWaybillService; +import org.springblade.transport.support.CertificationGate; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * 末位打卡卡上的「完成运单」标记:打这一张卡这张运单就完成了,客户端据此把按钮文案从「提交打卡」改成「完成运单」。 + *

+ * 判据必须与 {@code submitNode} 的自动完成同一份——末位可打卡节点 + 到货/卸货/签收 + 回单不要求业务员确认; + * 两处不同源就会出现「按钮写着完成运单、打下去这张运单没完成」。接单与回单不是司机打卡节点,不参与排位。 + */ +class DriverPunchFinishFlagTest { + + private static final Long WAYBILL_ID = 5601L; + + private static final String NO_RETURN_JSON = """ + [{"key":"accept","name":"接单","type":"confirm","enabled":true,"punch":true}, + {"key":"load","name":"装货","type":"cargo","enabled":true,"punch":true}, + {"key":"sign","name":"签收","type":"cargo","enabled":true,"punch":true}] + """; + + private static final String RETURN_NO_CONFIRM_JSON = """ + [{"key":"load","name":"装货","type":"cargo","enabled":true,"punch":true}, + {"key":"sign","name":"签收","type":"cargo","enabled":true,"punch":true}, + {"key":"return","name":"回单","type":"confirm","enabled":true,"punch":true,"confirmMode":"no_confirm_complete"}] + """; + + private static final String RETURN_NEED_CONFIRM_JSON = """ + [{"key":"load","name":"装货","type":"cargo","enabled":true,"punch":true}, + {"key":"sign","name":"签收","type":"cargo","enabled":true,"punch":true}, + {"key":"return","name":"回单","type":"confirm","enabled":true,"punch":true,"confirmMode":"yes"}] + """; + + /** 末位可打卡节点是「到场」:不在到货/卸货/签收之列,打完不算完成 */ + private static final String LAST_NODE_NOT_DELIVERY_JSON = """ + [{"key":"load","name":"装货","type":"cargo","enabled":true,"punch":true}, + {"key":"arrive_scene","name":"到场","type":"location","enabled":true,"punch":true}] + """; + + private IWaybillService waybillService; + private WaybillNodePunchMapper nodePunchMapper; + private DriverWaybillServiceImpl service; + + @BeforeAll + static void initTableInfo() { + // 构造 LambdaQueryWrapper 需要 MyBatis-Plus 的实体元数据缓存 + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, WaybillNodePunch.class); + TableInfoHelper.initTableInfo(assistant, WaybillEnroutePunch.class); + TableInfoHelper.initTableInfo(assistant, ExceptionDisposal.class); + } + + @BeforeEach + void setUp() { + waybillService = mock(IWaybillService.class); + nodePunchMapper = mock(WaybillNodePunchMapper.class); + WaybillEnroutePunchMapper enroutePunchMapper = mock(WaybillEnroutePunchMapper.class); + ExceptionDisposalMapper exceptionDisposalMapper = mock(ExceptionDisposalMapper.class); + service = new DriverWaybillServiceImpl( + waybillService, + mock(IDriverService.class), + mock(IMpUserClient.class), + mock(IProcessConfigService.class), + mock(ITransportVehicleService.class), + mock(CertificationGate.class), + enroutePunchMapper, + nodePunchMapper, + exceptionDisposalMapper, + mock(ITripQueryService.class)); + + when(waybillService.syncDriverAcceptState(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(nodePunchMapper.selectList(any())).thenReturn(List.of()); + when(enroutePunchMapper.selectOne(any())).thenReturn(null); + when(enroutePunchMapper.selectList(any())).thenReturn(List.of()); + when(exceptionDisposalMapper.selectList(any())).thenReturn(List.of()); + } + + @Test + @DisplayName("末位是签收且没有回单节点:只有它带完成标记,装货与接单都不带") + void lastDeliveryNodeCarriesFinishFlag() { + Map nodes = punchNodes(NO_RETURN_JSON); + + assertTrue(Boolean.TRUE.equals(nodes.get("sign").getFinishOnPunch()), "签收是末位到货类节点,打完即完成"); + assertFalse(Boolean.TRUE.equals(nodes.get("load").getFinishOnPunch()), "装货不是末位"); + assertFalse(nodes.containsKey("accept"), "接单不是司机打卡节点,不出现在卡片里"); + } + + @Test + @DisplayName("回单要求业务员确认:末位卡不带完成标记(司机打完不置完成,等业务员确认)") + void returnNeedingConfirmationTakesTheFlagAway() { + Map nodes = punchNodes(RETURN_NEED_CONFIRM_JSON); + + assertFalse(Boolean.TRUE.equals(nodes.get("sign").getFinishOnPunch()), + "回单要业务员确认时,签收打完不算完成——按钮文案也不能写成完成运单"); + } + + @Test + @DisplayName("回单不要求业务员确认:末位卡仍带完成标记") + void returnWithoutConfirmationKeepsTheFlag() { + Map nodes = punchNodes(RETURN_NO_CONFIRM_JSON); + + assertTrue(Boolean.TRUE.equals(nodes.get("sign").getFinishOnPunch())); + } + + @Test + @DisplayName("末位节点不是到货/卸货/签收:不带完成标记") + void lastNodeOutsideDeliveryDoesNotCarryFinishFlag() { + Map nodes = punchNodes(LAST_NODE_NOT_DELIVERY_JSON); + + assertFalse(Boolean.TRUE.equals(nodes.get("arrive_scene").getFinishOnPunch()), + "到场不在到货/卸货/签收之列,打完不置完成"); + } + + // ------------------------------------------------------------ 夹具 + + /** 用给定过程配置快照取这趟车下发的节点卡,按 key 索引 */ + private Map punchNodes(String processJson) { + Waybill waybill = new Waybill(); + waybill.setId(WAYBILL_ID); + waybill.setWaybillNo("YD202610180001"); + waybill.setProcessJson(processJson); + waybill.setBusinessStatus("running"); + waybill.setIsDeleted(0); + when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill); + + return service.detailPunchSnapshot(WAYBILL_ID).getPunchNodes().stream() + .collect(Collectors.toMap(DriverPunchNodeVO::getKey, Function.identity())); + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverPunchNodeCardTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverPunchNodeCardTest.java index ed1e5495..2b35117f 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverPunchNodeCardTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverPunchNodeCardTest.java @@ -23,6 +23,7 @@ import org.springblade.transport.service.IDriverService; import org.springblade.transport.service.IProcessConfigService; import org.springblade.transport.service.ITransportVehicleService; import org.springblade.transport.service.IWaybillService; +import org.springblade.transport.service.ITripQueryService; import org.springblade.transport.support.CertificationGate; import java.time.LocalDateTime; @@ -92,7 +93,8 @@ class DriverPunchNodeCardTest { mock(CertificationGate.class), enroutePunchMapper, nodePunchMapper, - exceptionDisposalMapper); + exceptionDisposalMapper, + mock(ITripQueryService.class)); Waybill waybill = waybill(); when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill); @@ -182,6 +184,45 @@ class DriverPunchNodeCardTest { assertTrue(json.contains("\"9007199254740993\""), json); } + @Test + @DisplayName("运单已完成:在途卡不可展开,已打卡的普通节点仍可展开回看") + void completedWaybillTransitNodeIsNotExpandable() { + Waybill completed = waybill(); + completed.setBusinessStatus("completed"); + when(waybillService.getById(WAYBILL_ID)).thenReturn(completed); + + Map nodes = punchNodes(); + + DriverPunchNodeVO transit = nodes.get("transit"); + assertTrue(Boolean.TRUE.equals(transit.getEnded()), "已完成运单的在途卡要进结束态"); + assertFalse(Boolean.TRUE.equals(transit.getExpandable()), "已完成运单的在途卡不允许展开"); + assertFalse(Boolean.TRUE.equals(transit.getActionable())); + assertTrue(Boolean.TRUE.equals(nodes.get("load").getExpandable()), + "已打卡的普通节点仍可展开回看时间与照片"); + assertFalse(Boolean.TRUE.equals(nodes.get("sign").getExpandable()), + "未打卡节点展开没有内容,也不可展开"); + } + + @Test + @DisplayName("运单未开始运输:未打卡的在途与普通节点都不可打、不可展开,文案说明是状态原因") + void notStartedWaybillLocksEveryUnpunchedCard() { + Waybill dispatching = waybill(); + dispatching.setBusinessStatus("dispatching"); + when(waybillService.getById(WAYBILL_ID)).thenReturn(dispatching); + + Map nodes = punchNodes(); + + assertTrue(Boolean.TRUE.equals(nodes.get("load").getExpandable()), + "已打卡的装货仍可展开回看,未开始运输不影响历史记录"); + for (String key : List.of("sign", "transit")) { + DriverPunchNodeVO node = nodes.get(key); + assertFalse(Boolean.TRUE.equals(node.getDone()), key + " 未打卡"); + assertFalse(Boolean.TRUE.equals(node.getActionable()), key + " 未开始运输不可打"); + assertEquals("运单尚未开始运输,暂不可打卡", node.getLockReason(), key); + assertFalse(Boolean.TRUE.equals(node.getExpandable()), key + " 不可展开"); + } + } + // ------------------------------------------------------------ 夹具 private void stubUnresolved(ExceptionDisposal... disposals) { diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverServiceImplTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverServiceImplTest.java new file mode 100644 index 00000000..878fa815 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverServiceImplTest.java @@ -0,0 +1,189 @@ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.core.secure.utils.AuthUtil; +import org.springblade.core.tool.api.R; +import org.springblade.system.feign.IMpUserClient; +import org.springblade.system.feign.ISysClient; +import org.springblade.system.pojo.entity.MpUser; +import org.springblade.transport.mapper.DriverMapper; +import org.springblade.transport.pojo.entity.Driver; + +import java.lang.reflect.Field; +import java.time.LocalDate; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 司机管理保存与状态变更测试。 + *

+ * 守住的是一条不变式:司机档案的行更新一律不得把「驾驶车辆」绑定写掉。 + * 该列标了 {@code FieldStrategy.ALWAYS},patch 里不带它就写成 NULL, + * 司机端车辆列表随即变成「暂无绑定车辆」,而司机在界面上无法自行恢复。 + */ +class DriverServiceImplTest { + + private static final Long DRIVER_ID = 9001L; + private static final Long USER_ID = 7001L; + private static final String TENANT_ID = "000000"; + private static final String PLATE = "桂A32523"; + private static final String MOBILE = "15975348524"; + + private DriverMapper driverMapper; + private IMpUserClient mpUserClient; + private ISysClient sysClient; + private DriverServiceImpl service; + + @BeforeAll + static void initTableInfo() { + // saveOrUpdate 需要 MyBatis-Plus 的实体元数据缓存 + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, Driver.class); + } + + @BeforeEach + void setUp() throws Exception { + driverMapper = mock(DriverMapper.class); + mpUserClient = mock(IMpUserClient.class); + sysClient = mock(ISysClient.class); + service = new DriverServiceImpl(mpUserClient, sysClient); + inject("baseMapper", driverMapper); + } + + // ------------------------------------------------------------ 启用 / 停用 + + @Test + @DisplayName("启用/停用司机:patch 必须带上驾驶车辆原值,否则这次更新就把车辆绑定清成 NULL") + void changeStatusKeepsDrivingVehicle() { + when(driverMapper.selectById(DRIVER_ID)).thenReturn(driver(PLATE)); + when(driverMapper.updateById(any(Driver.class))).thenReturn(1); + + assertTrue(service.changeStatus(DRIVER_ID, 2)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Driver.class); + verify(driverMapper).updateById(captor.capture()); + assertEquals(2, captor.getValue().getStatus()); + assertEquals(PLATE, captor.getValue().getDrivingVehicle(), + "只改 status 的 patch 不带 driving_vehicle 会把司机的车辆绑定写掉"); + } + + @Test + @DisplayName("启用/停用司机:状态取值不合法时拒绝,且不写库") + void changeStatusRejectsInvalidStatus() { + assertThrows(ServiceException.class, () -> service.changeStatus(DRIVER_ID, 9)); + verify(driverMapper, never()).updateById(any(Driver.class)); + } + + // ------------------------------------------------------------ 保存司机 + + @Test + @DisplayName("保存司机:关联小程序账号的那次 patch 也必须带上驾驶车辆原值") + void submitKeepsDrivingVehicleWhenLinkingMpUser() { + Driver driver = driver(PLATE); + // 提交前校验按身份证号与主键各查一次 + when(driverMapper.selectByIdIncludingDeleted(DRIVER_ID)).thenReturn(driver); + when(driverMapper.selectByIdCardNoIncludingDeleted(driver.getIdCardNo())).thenReturn(null); + // 完整对象更新:saveOrUpdate 判存在走更新 + when(driverMapper.selectById(DRIVER_ID)).thenReturn(driver); + when(driverMapper.updateById(any(Driver.class))).thenReturn(1); + // 先查不到小程序用户 → 建号 → 回查拿到新建用户 + MpUser created = new MpUser(); + created.setId(USER_ID); + when(mpUserClient.userByPhone(TENANT_ID, MOBILE)).thenReturn(null, R.data(created)); + when(mpUserClient.saveUser(any(MpUser.class))).thenReturn(R.data(true)); + + try (MockedStatic authUtil = mockStatic(AuthUtil.class)) { + authUtil.when(AuthUtil::getTenantId).thenReturn(TENANT_ID); + assertTrue(service.submit(driver)); + } + + ArgumentCaptor captor = ArgumentCaptor.forClass(Driver.class); + verify(driverMapper, times(2)).updateById(captor.capture()); + // captor 存的是引用:完整对象更新带走的是 driver 本身,而它随后会被就地写上 userId, + // 所以只能用对象身份把「只带少数几列的 patch」摘出来,不能按 userId 认 + Driver linkPatch = captor.getAllValues().stream() + .filter(patch -> patch != driver) + .findFirst() + .orElseThrow(() -> new AssertionError("没有发出关联小程序账号的那次 patch")); + assertEquals(USER_ID, linkPatch.getUserId()); + assertEquals(PLATE, linkPatch.getDrivingVehicle(), + "关联账号的 patch 不带 driving_vehicle 会把司机的车辆绑定写掉"); + } + + @Test + @DisplayName("保存司机:司机不存在时拒绝,不写库") + void submitRejectsUnknownDriver() { + Driver driver = driver(PLATE); + when(driverMapper.selectByIdIncludingDeleted(DRIVER_ID)).thenReturn(null); + + assertThrows(ServiceException.class, () -> service.submit(driver)); + verify(driverMapper, never()).updateById(any(Driver.class)); + } + + // ------------------------------------------------------------ 夹具 + + private Driver driver(String drivingVehicle) { + Driver driver = new Driver(); + driver.setId(DRIVER_ID); + driver.setIsDeleted(0); + driver.setDriverName("张三"); + driver.setIdCardNo("110101199001011234"); + driver.setGender("男"); + driver.setPosts("司机"); + driver.setDrivingType("A2"); + driver.setDrivingLicenseNo("110101199001011234"); + driver.setDrivingLicenseStartDate(LocalDate.of(2020, 1, 1)); + driver.setDrivingLicenseLongTerm(1); + driver.setQualificationType("道路运输"); + driver.setQualificationNo("Q123456"); + driver.setQualificationLongTerm(1); + driver.setDriverType("自有"); + driver.setMobile(MOBILE); + driver.setOrganizationName("测试组织"); + driver.setEmergencyContactName("李四"); + driver.setEmergencyContactMobile("13800000000"); + driver.setIdCardFront("https://oss/id-front.jpg"); + driver.setIdCardBack("https://oss/id-back.jpg"); + driver.setDrivingLicenseFront("https://oss/dl-front.jpg"); + driver.setDrivingLicenseBack("https://oss/dl-back.jpg"); + driver.setDrivingVehicle(drivingVehicle); + return driver; + } + + private void inject(String field, Object value) throws Exception { + Class type = service.getClass(); + while (type != null) { + try { + Field declared = type.getDeclaredField(field); + declared.setAccessible(true); + declared.set(service, value); + return; + } catch (NoSuchFieldException ignored) { + type = type.getSuperclass(); + } + } + throw new IllegalStateException("未找到字段:" + field); + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverTripAcceptRejectTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverTripAcceptRejectTest.java new file mode 100644 index 00000000..24d1d635 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverTripAcceptRejectTest.java @@ -0,0 +1,442 @@ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.core.secure.BladeUser; +import org.springblade.core.secure.utils.AuthUtil; +import org.springblade.system.feign.IMpUserClient; +import org.springblade.transport.mapper.ExceptionDisposalMapper; +import org.springblade.transport.mapper.WaybillEnroutePunchMapper; +import org.springblade.transport.mapper.WaybillNodePunchMapper; +import org.springblade.transport.pojo.entity.Driver; +import org.springblade.transport.pojo.entity.Waybill; +import org.springblade.transport.pojo.vo.TripQueryVO; +import org.springblade.transport.pojo.vo.TripVO; +import org.springblade.transport.service.IDriverService; +import org.springblade.transport.service.IProcessConfigService; +import org.springblade.transport.service.ITransportVehicleService; +import org.springblade.transport.service.ITripQueryService; +import org.springblade.transport.service.IWaybillService; +import org.springblade.transport.support.CertificationGate; + +import java.util.Collection; +import java.util.Date; +import java.util.List; +import java.util.function.Supplier; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 运次级接单与拒单:一次写完整趟车、写不上已完成/已取消的子单、司机字段一概不动。 + *

+ * 运次的解析与收口在运次读服务里,这里以 mock 给出「该司机可见的运次」,只验运次级校验与写入口径。 + */ +class DriverTripAcceptRejectTest { + + private static final Long USER_ID = 7401L; + private static final Long DRIVER_ID = 9001L; + private static final String PHONE = "17587421208"; + private static final String TRIP_ID = "2101146192739459074"; + private static final Long PENDING_ID = 2101144166395699202L; + private static final Long SECOND_ID = 2101145869819994113L; + + private IWaybillService waybillService; + private IDriverService driverService; + private CertificationGate certificationGate; + private ITripQueryService tripQueryService; + private DriverWaybillServiceImpl service; + + @BeforeAll + static void initTableInfo() { + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, Waybill.class); + } + + @BeforeEach + void setUp() { + waybillService = mock(IWaybillService.class); + driverService = mock(IDriverService.class); + certificationGate = mock(CertificationGate.class); + tripQueryService = mock(ITripQueryService.class); + service = new DriverWaybillServiceImpl( + waybillService, + driverService, + mock(IMpUserClient.class), + mock(IProcessConfigService.class), + mock(ITransportVehicleService.class), + certificationGate, + mock(WaybillEnroutePunchMapper.class), + mock(WaybillNodePunchMapper.class), + mock(ExceptionDisposalMapper.class), + tripQueryService); + lenient().when(driverService.getOne(any())).thenReturn(driver()); + lenient().when(certificationGate.isDriverAvailable(any())).thenReturn(true); + lenient().when(waybillService.update(any())).thenReturn(true); + } + + @Test + @DisplayName("运次接单:一次把两张子运单都写成已接单 + 运输中 + 同一接单时刻,司机字段一个不碰") + void tripAcceptWritesWholeTrip() { + stubTrip(requireAcceptChild(PENDING_ID, "pending", "pending"), + requireAcceptChild(SECOND_ID, "running", null)); + + boolean result = runAsDriver(() -> service.tripAccept(TRIP_ID)); + + assertTrue(result); + assertEquals(List.of( + "driver_accept_status", + "driver_accept_time", + "driver_accept_driver_id", + "driver_reject_time", + "driver_reject_reason", + "business_status", + "update_time", + "update_user"), updatedColumns(), "只写接单相关的列与刷新痕迹,司机字段一概不动"); + assertTrue(capturedUpdateParams().containsAll(List.of(PENDING_ID, SECOND_ID, DRIVER_ID)), + "两张子运单与接单司机一起下发:" + capturedUpdateParams()); + assertTrue(capturedWhereSql().contains("(id IN ("), capturedWhereSql()); + assertEquals(TRIP_ID, capturedQuery().getTripId()); + assertEquals(PHONE, capturedQuery().getDriverPhone(), "归属仍按司机手机号收口"); + } + + @Test + @DisplayName("运次接单的更新时间与接单时刻是同一个 now、更新人是当前操作人") + void tripAcceptRefreshesUpdateTrace() { + stubTrip(requireAcceptChild(PENDING_ID, "pending", "pending")); + + runAsDriver(() -> service.tripAccept(TRIP_ID)); + + List dates = capturedUpdateDates(); + assertEquals(2, dates.size(), "接单时刻与更新时间各占一列"); + assertSame(dates.get(0), dates.get(1), "update_time 与接单时刻必须是同一个 now"); + assertTrue(capturedUpdateParams().contains(USER_ID), "update_user 就是当前操作人:" + capturedUpdateParams()); + } + + @Test + @DisplayName("没有登录态时不写 update_user,但 update_time 仍随操作刷新") + void tripAcceptWithoutLoginSkipsUpdateUser() { + stubTrip(requireAcceptChild(PENDING_ID, "pending", "pending")); + + runWithoutLoginUser(() -> service.tripAccept(TRIP_ID)); + + assertEquals(List.of( + "driver_accept_status", + "driver_accept_time", + "driver_accept_driver_id", + "driver_reject_time", + "driver_reject_reason", + "business_status", + "update_time"), updatedColumns(), "无登录态时与框架填充口径一致:只刷 update_time"); + } + + @Test + @DisplayName("运次拒单的更新时间与拒单时刻是同一个 now、更新人是当前操作人") + void tripRejectRefreshesUpdateTrace() { + stubTrip(requireAcceptChild(PENDING_ID, "pending", "pending")); + + runAsDriver(() -> service.tripReject(TRIP_ID, "不要了")); + + List dates = capturedUpdateDates(); + assertEquals(2, dates.size(), "拒单时刻与更新时间各占一列"); + assertSame(dates.get(0), dates.get(1), "update_time 与拒单时刻必须是同一个 now"); + assertTrue(capturedUpdateParams().contains(USER_ID), "update_user 就是当前操作人:" + capturedUpdateParams()); + } + + @Test + @DisplayName("任一子单已接单 → 整单已接单,不再重复写") + void tripAcceptRefusesSecondCall() { + stubTrip(requireAcceptChild(PENDING_ID, "running", "accepted"), + requireAcceptChild(SECOND_ID, "running", "accepted")); + + ServiceException exception = assertThrows(ServiceException.class, + () -> runAsDriver(() -> service.tripAccept(TRIP_ID))); + + assertEquals("该运单已接单", exception.getMessage()); + verify(waybillService, never()).update(any()); + } + + @Test + @DisplayName("无接单节点的运次不进接单流程:给出「该运单无需确认接单」") + void tripAcceptWithoutAcceptNodeIsRefused() { + TripVO.TripWaybillVO first = child(PENDING_ID, "running", null, false); + TripVO.TripWaybillVO second = child(SECOND_ID, "running", null, false); + stubTrip(first, second); + + ServiceException exception = assertThrows(ServiceException.class, + () -> runAsDriver(() -> service.tripAccept(TRIP_ID))); + + assertEquals("该运单无需确认接单", exception.getMessage()); + verify(waybillService, never()).update(any()); + } + + @Test + @DisplayName("接单只写没走完的子单,已完成的子单不回到运输中") + void tripAcceptSkipsTerminalChildren() { + // 已完成的子单(自动完成,没有接单状态)不进写入集合;另一张还在途,整趟车仍可接单 + stubTrip(new TripVO.TripWaybillVO[]{ + child(PENDING_ID, "completed", null, true), + requireAcceptChild(SECOND_ID, "running", null)}); + + runAsDriver(() -> service.tripAccept(TRIP_ID)); + + assertTrue(capturedUpdateParams().contains(SECOND_ID)); + assertFalse(capturedUpdateParams().contains(PENDING_ID), "已完成的子单不进写入集合:" + capturedUpdateParams()); + } + + @Test + @DisplayName("子单全走完的运次不允许接单") + void tripAcceptAllTerminalIsRefused() { + stubTrip(new TripVO.TripWaybillVO[]{ + child(PENDING_ID, "completed", "accepted", true), + child(SECOND_ID, "cancelled", null, true)}); + + ServiceException exception = assertThrows(ServiceException.class, + () -> runAsDriver(() -> service.tripAccept(TRIP_ID))); + + assertEquals("当前运单状态不允许接单", exception.getMessage()); + verify(waybillService, never()).update(any()); + } + + @Test + @DisplayName("司机与车辆的认证门槛按整车只校验一次") + void tripAcceptChecksCertificationOnce() { + stubTrip(requireAcceptChild(PENDING_ID, "pending", "pending"), + requireAcceptChild(SECOND_ID, "pending", "pending")); + when(certificationGate.isDriverAvailable(any())).thenReturn(false); + when(certificationGate.driverUnavailableReason()).thenReturn("司机认证未通过"); + + ServiceException exception = assertThrows(ServiceException.class, + () -> runAsDriver(() -> service.tripAccept(TRIP_ID))); + + assertEquals("司机认证未通过", exception.getMessage()); + verify(certificationGate, times(1)).isDriverAvailable(any()); + verify(waybillService, never()).update(any()); + } + + @Test + @DisplayName("运次拒单:两张子运单写同一个原因、同一拒单时刻,接单记录清空,司机字段不动") + void tripRejectWritesWholeTrip() { + stubTrip(requireAcceptChild(PENDING_ID, "pending", "pending"), + requireAcceptChild(SECOND_ID, "running", null)); + + boolean result = runAsDriver(() -> service.tripReject(TRIP_ID, " 车坏了 ")); + + assertTrue(result); + assertEquals(List.of( + "driver_accept_status", + "driver_accept_time", + "driver_accept_driver_id", + "driver_reject_time", + "driver_reject_reason", + "business_status", + "update_time", + "update_user"), updatedColumns(), "只写拒单相关的列与刷新痕迹,司机字段一概不动"); + assertTrue(capturedUpdateParams().contains("车坏了"), "拒单原因原样下发(去首尾空格):" + capturedUpdateParams()); + assertTrue(capturedUpdateParams().containsAll(List.of(PENDING_ID, SECOND_ID))); + } + + @Test + @DisplayName("拒单原因可空,超 200 字直接拒绝且不写库") + void tripRejectReasonValidation() { + stubTrip(requireAcceptChild(PENDING_ID, "pending", "pending")); + + assertTrue(runAsDriver(() -> service.tripReject(TRIP_ID, null))); + assertTrue(capturedUpdateParams().stream().noneMatch(""::equals), "空原因按 null 下发"); + + stubTrip(requireAcceptChild(PENDING_ID, "pending", "pending")); + ServiceException exception = assertThrows(ServiceException.class, + () -> runAsDriver(() -> service.tripReject(TRIP_ID, "x".repeat(201)))); + + assertEquals("拒绝原因不能超过200字", exception.getMessage()); + } + + @Test + @DisplayName("任一子单已接单 → 整单不能拒单") + void tripRejectRefusesAcceptedTrip() { + stubTrip(requireAcceptChild(PENDING_ID, "running", "accepted"), + requireAcceptChild(SECOND_ID, "pending", "pending")); + + ServiceException exception = assertThrows(ServiceException.class, + () -> runAsDriver(() -> service.tripReject(TRIP_ID, "不要了"))); + + assertEquals("该运单已接单,无法拒绝", exception.getMessage()); + verify(waybillService, never()).update(any()); + } + + @Test + @DisplayName("子单全走完的运次不允许拒单") + void tripRejectAllTerminalIsRefused() { + stubTrip(new TripVO.TripWaybillVO[]{ + child(PENDING_ID, "completed", null, true), + child(SECOND_ID, "completed", null, true)}); + + ServiceException exception = assertThrows(ServiceException.class, + () -> runAsDriver(() -> service.tripReject(TRIP_ID, "不要了"))); + + assertEquals("当前运单状态不允许拒绝接单", exception.getMessage()); + verify(waybillService, never()).update(any()); + } + + @Test + @DisplayName("未配载运单只写它自己") + void unloadedTripWritesItselfOnly() { + TripVO trip = new TripVO(); + trip.setId(String.valueOf(PENDING_ID)); + trip.setWaybills(List.of(requireAcceptChild(PENDING_ID, "pending", "pending"))); + when(tripQueryService.detail(any())).thenReturn(trip); + + assertTrue(runAsDriver(() -> service.tripAccept(String.valueOf(PENDING_ID)))); + + assertTrue(capturedUpdateParams().contains(PENDING_ID)); + assertEquals(1, capturedUpdateParams().stream().filter(PENDING_ID::equals).count()); + } + + @Test + @DisplayName("越权与不存在的运次一律被拒,且不写库") + void foreignTripIsRefused() { + when(tripQueryService.detail(any())).thenThrow(new ServiceException("运次不存在或无权操作")); + + assertEquals("运次不存在或无权操作", assertThrows(ServiceException.class, + () -> runAsDriver(() -> service.tripAccept(TRIP_ID))).getMessage()); + assertEquals("运次不存在或无权操作", assertThrows(ServiceException.class, + () -> runAsDriver(() -> service.tripReject(TRIP_ID, null))).getMessage()); + verify(waybillService, never()).update(any()); + } + + @Test + @DisplayName("运次里一张可见子单都没有时不给出写入入口") + void emptyChildrenAreRefused() { + TripVO trip = new TripVO(); + trip.setId(TRIP_ID); + when(tripQueryService.detail(any())).thenReturn(trip); + + assertEquals("运次不存在或无权操作", assertThrows(ServiceException.class, + () -> runAsDriver(() -> service.tripAccept(TRIP_ID))).getMessage()); + verify(waybillService, never()).update(any()); + } + + // ------------------------------------------------------------ 夹具 + + private T runAsDriver(Supplier action) { + try (MockedStatic authUtil = mockStatic(AuthUtil.class)) { + authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID); + authUtil.when(AuthUtil::getUser).thenReturn(bladeUser()); + return action.get(); + } + } + + /** 框架填充更新人看的是 getUserId() 背后的登录用户:为空就不填,这里把这一口径钉住 */ + private T runWithoutLoginUser(Supplier action) { + try (MockedStatic authUtil = mockStatic(AuthUtil.class)) { + authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID); + authUtil.when(AuthUtil::getUser).thenReturn(null); + return action.get(); + } + } + + private BladeUser bladeUser() { + BladeUser user = new BladeUser(); + user.setUserId(USER_ID); + user.setUserName("黄宇富"); + return user; + } + + private void stubTrip(TripVO.TripWaybillVO... children) { + stubTrip(new TripVO.TripWaybillVO[0], children); + } + + private void stubTrip(TripVO.TripWaybillVO[] leading, TripVO.TripWaybillVO... children) { + TripVO trip = new TripVO(); + trip.setId(TRIP_ID); + trip.setDriverPhone(PHONE); + List all = new java.util.ArrayList<>(List.of(leading)); + all.addAll(List.of(children)); + trip.setWaybills(all); + when(tripQueryService.detail(any())).thenReturn(trip); + } + + private TripVO.TripWaybillVO requireAcceptChild(Long id, String businessStatus, String acceptStatus) { + return child(id, businessStatus, acceptStatus, true); + } + + private TripVO.TripWaybillVO child(Long id, String businessStatus, String acceptStatus, boolean requireAccept) { + TripVO.TripWaybillVO child = new TripVO.TripWaybillVO(); + child.setId(String.valueOf(id)); + child.setWaybillNo("YD-" + id); + child.setBusinessStatus(businessStatus); + child.setAcceptStatus(acceptStatus); + child.setRequireAccept(requireAccept); + return child; + } + + private Driver driver() { + Driver driver = new Driver(); + driver.setId(DRIVER_ID); + driver.setUserId(USER_ID); + driver.setDriverName("黄宇富"); + driver.setDrivingVehicle("桂A32523"); + driver.setMobile(PHONE); + return driver; + } + + private TripQueryVO capturedQuery() { + ArgumentCaptor captor = ArgumentCaptor.forClass(TripQueryVO.class); + verify(tripQueryService, org.mockito.Mockito.atLeastOnce()).detail(captor.capture()); + return captor.getValue(); + } + + @SuppressWarnings("unchecked") + private LambdaUpdateWrapper capturedUpdate() { + ArgumentCaptor> captor = ArgumentCaptor.forClass(Wrapper.class); + verify(waybillService, org.mockito.Mockito.atLeastOnce()).update(captor.capture()); + return (LambdaUpdateWrapper) captor.getValue(); + } + + private String capturedWhereSql() { + return capturedUpdate().getTargetSql(); + } + + /** SET 子句里真正被写的列名:司机字段是否被碰,看这张清单最直白 */ + private List updatedColumns() { + return java.util.Arrays.stream(capturedUpdate().getSqlSet().split(",")) + .map(part -> part.substring(0, part.indexOf('=')).trim()) + .toList(); + } + + private List capturedUpdateDates() { + return capturedUpdateParams().stream().filter(Date.class::isInstance).map(Date.class::cast).toList(); + } + + /** SET 与 WHERE 的参数都是渲染时才进参数表的,取值前先把两段都渲染一遍 */ + private Collection capturedUpdateParams() { + LambdaUpdateWrapper update = capturedUpdate(); + update.getSqlSet(); + update.getTargetSql(); + return update.getParamNameValuePairs().values(); + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverTripCardTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverTripCardTest.java new file mode 100644 index 00000000..7bdcbba1 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverTripCardTest.java @@ -0,0 +1,474 @@ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.core.secure.utils.AuthUtil; +import org.springblade.system.feign.IMpUserClient; +import org.springblade.transport.mapper.ExceptionDisposalMapper; +import org.springblade.transport.mapper.WaybillEnroutePunchMapper; +import org.springblade.transport.mapper.WaybillNodePunchMapper; +import org.springblade.transport.pojo.entity.Driver; +import org.springblade.transport.pojo.entity.LoadingManage; +import org.springblade.transport.pojo.entity.Waybill; +import org.springblade.transport.pojo.vo.DriverWaybillCardVO; +import org.springblade.transport.pojo.vo.DriverWaybillPreviewVO; +import org.springblade.transport.pojo.vo.DriverWaybillTabCountsVO; +import org.springblade.transport.pojo.vo.TripQueryVO; +import org.springblade.transport.pojo.vo.TripStationVO; +import org.springblade.transport.pojo.vo.TripTabCountsVO; +import org.springblade.transport.pojo.vo.TripVO; +import org.springblade.transport.service.IDriverService; +import org.springblade.transport.service.IProcessConfigService; +import org.springblade.transport.service.ITransportVehicleService; +import org.springblade.transport.service.ITripQueryService; +import org.springblade.transport.service.IWaybillService; +import org.springblade.transport.support.CertificationGate; + +import java.math.BigDecimal; +import java.util.Date; +import java.util.List; +import java.util.function.Supplier; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 司机端运次接口:五个入口切运次口径后的卡片与分页,以及未配载运次照运单详情的只读下发。 + *

+ * 运次侧的取数与聚合在运次服务里,这里只验司机端的映射与收口;运次服务以 mock 给出「库层已经算好的运次」。 + */ +class DriverTripCardTest { + + private static final Long USER_ID = 7401L; + private static final Long DRIVER_ID = 9001L; + private static final String PHONE = "17587421208"; + private static final String LOADING_NO = "PZ-20261017-001"; + private static final Long LOADING_ID = 2111350784070275123L; + private static final Long WAYBILL_ID = 2101508430981652482L; + private static final String ROUTE_JSON = "[" + + "{\"address\":\"广西壮族自治区南宁市南宁吴圩机场\",\"displayAddress\":\"广西壮族自治区南宁市南宁吴圩机场\",\"tags\":[\"装1\"]}," + + "{\"address\":\"广西壮族自治区北海市合浦县白沙镇广西新福兴硅科技有限公司\"," + + "\"displayAddress\":\"广西壮族自治区北海市合浦县白沙镇广西新福兴硅科技有限公司\",\"tags\":[\"卸1\",\"装2\",\"装3\",\"装4\"]}," + + "{\"address\":\"广西壮族自治区南宁市宾阳县宾州镇宾阳县宾阳县人民政府\"," + + "\"displayAddress\":\"广西壮族自治区南宁市宾阳县宾州镇宾阳县宾阳县人民政府\",\"tags\":[\"卸2\",\"卸3\",\"卸4\"]}]"; + private static final String WAYBILL_IDS_JSON = + "[\"2102780956493299713\", \"2092186187239780353\", \"2092186187470467073\", \"2092186187571130369\"]"; + + private IWaybillService waybillService; + private IDriverService driverService; + private IMpUserClient mpUserClient; + private ITripQueryService tripQueryService; + private WaybillEnroutePunchMapper enroutePunchMapper; + private WaybillNodePunchMapper nodePunchMapper; + private DriverWaybillServiceImpl service; + + @BeforeAll + static void initTableInfo() { + com.baomidou.mybatisplus.core.MybatisConfiguration configuration = + new com.baomidou.mybatisplus.core.MybatisConfiguration(); + org.apache.ibatis.builder.MapperBuilderAssistant assistant = + new org.apache.ibatis.builder.MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + com.baomidou.mybatisplus.core.metadata.TableInfoHelper.initTableInfo(assistant, Waybill.class); + } + + @BeforeEach + void setUp() { + waybillService = mock(IWaybillService.class); + driverService = mock(IDriverService.class); + mpUserClient = mock(IMpUserClient.class); + tripQueryService = mock(ITripQueryService.class); + enroutePunchMapper = mock(WaybillEnroutePunchMapper.class); + nodePunchMapper = mock(WaybillNodePunchMapper.class); + service = new DriverWaybillServiceImpl( + waybillService, + driverService, + mpUserClient, + mock(IProcessConfigService.class), + mock(ITransportVehicleService.class), + mock(CertificationGate.class), + enroutePunchMapper, + nodePunchMapper, + mock(ExceptionDisposalMapper.class), + tripQueryService); + lenient().when(driverService.getOne(any())).thenReturn(driver()); + lenient().when(enroutePunchMapper.selectList(any())).thenReturn(List.of()); + lenient().when(enroutePunchMapper.selectOne(any())).thenReturn(null); + lenient().when(nodePunchMapper.selectList(any())).thenReturn(List.of()); + } + + @Test + @DisplayName("运次列表按运次分页:一页 10 个运次,卡片带运次号、整趟车起/经/终与站点数") + void tripPageMapsTripCard() { + stubTripPage(1, loadingTrip()); + TripQueryVO given = null; + + IPage page = runAsDriver(() -> service.tripPage(1, 10, null, null)); + + given = capturedQuery(); + assertEquals(PHONE, given.getDriverPhone()); + assertEquals(1, given.getCurrent()); + assertEquals(10, given.getSize(), "一页 10 条即 10 个运次"); + assertNull(given.getStatus()); + assertNull(given.getTripId()); + + DriverWaybillCardVO card = page.getRecords().get(0); + assertEquals(String.valueOf(LOADING_ID), card.getTripId()); + assertEquals(LOADING_NO, card.getLoadingNo()); + assertEquals(LOADING_NO, card.getTripNo(), "有配载取配载单号"); + assertEquals("load", card.getTransportOrgType()); + assertEquals(1, card.getStatus(), "四张子单都在运输中"); + assertEquals(Boolean.FALSE, card.getRequireAccept()); + assertEquals("广西壮族自治区南宁市南宁吴圩机场", card.getFromName()); + assertEquals("广西壮族自治区北海市合浦县白沙镇广西新福兴硅科技有限公司", card.getViaName()); + assertEquals("广西壮族自治区南宁市宾阳县宾州镇宾阳县宾阳县人民政府", card.getToName()); + assertEquals(3, card.getStationCount()); + assertEquals(List.of("石英砂A", "技嘉4070显卡"), card.getCargoNames()); + assertEquals("石英砂A 131.84吨、技嘉4070显卡 30吨", card.getCargoSummary()); + assertEquals("161.84吨", card.getWeight(), "合计重量按同单位相加"); + assertEquals("161.84吨", card.getTotalWeight()); + assertEquals("桂A32523", card.getVehicleNo()); + } + + @Test + @DisplayName("运次列表不因配载卡去回查运单,也不写库") + void tripPageDoesNotLookUpWaybillsOrWrite() { + stubTripPage(1, loadingTrip()); + + runAsDriver(() -> service.tripPage(1, 10, 1, "PZ-20261017")); + + verify(waybillService, never()).getById(any()); + verify(waybillService, never()).list(anyWrapper()); + verify(waybillService, never()).updateById(any()); + verify(waybillService, never()).update(any(), any()); + verify(waybillService, never()).syncDriverAcceptState(any()); + assertEquals(1, capturedQuery().getStatus()); + assertEquals("PZ-20261017", capturedQuery().getKeyword()); + } + + @Test + @DisplayName("运次卡片总数就是运次数:总数与每页条数按运次传下去") + void tripPageTotalCountsTrips() { + Page trips = new Page<>(1, 10, 71); + trips.setRecords(List.of(loadingTrip())); + when(tripQueryService.page(any())).thenReturn(trips); + + IPage page = runAsDriver(() -> service.tripPage(1, 10, null, null)); + + assertEquals(71, page.getTotal(), "总数按运次算"); + assertEquals(10, page.getSize()); + assertEquals(1, page.getRecords().size()); + } + + @Test + @DisplayName("未配载运单仍是独立运次:主键是运单主键的字符串形式,未被取整") + void unloadedTripKeepsStringPrimaryKey() { + stubTripPage(1, unloadedTrip()); + + IPage page = runAsDriver(() -> service.tripPage(1, 10, null, null)); + + DriverWaybillCardVO card = page.getRecords().get(0); + assertEquals("2101508430981652482", card.getTripId()); + assertEquals(19, card.getTripId().length()); + assertEquals(Long.valueOf(WAYBILL_ID), card.getId(), "旧字段仍是运单主键"); + assertEquals("", card.getLoadingNo()); + assertEquals("YD-20260920-0003", card.getTripNo()); + assertEquals("common", card.getTransportOrgType()); + assertEquals("2026-09-20", card.getPlanShipTime(), "未配载运次的卡片也要带计划时间"); + assertEquals("2026-09-22", card.getPlanFinishTime()); + } + + @Test + @DisplayName("Tab 统计按运次计,全部=待接单+进行中+已完成") + void tripTabCountsArePerTrip() { + TripTabCountsVO counts = new TripTabCountsVO(); + counts.setPending(3); + counts.setDoing(24); + counts.setDone(44); + counts.setAll(71); + when(tripQueryService.tabCounts(any())).thenReturn(counts); + + DriverWaybillTabCountsVO result = runAsDriver(() -> service.tripTabCounts()); + + assertEquals(3, result.getPending()); + assertEquals(24, result.getDoing()); + assertEquals(44, result.getDone()); + assertEquals(71, result.getAll()); + } + + @Test + @DisplayName("首页当前任务取运输中运次,最多一条") + void tripCurrentTaskTakesRunningTrip() { + stubTripPage(1, loadingTrip()); + + DriverWaybillCardVO card = runAsDriver(() -> service.tripCurrentTask()); + + assertNotNull(card); + assertEquals(1, capturedQuery().getStatus()); + assertEquals(1, capturedQuery().getSize()); + + Page empty = new Page<>(1, 1, 0); + when(tripQueryService.page(any())).thenReturn(empty); + assertNull(runAsDriver(() -> service.tripCurrentTask())); + } + + @Test + @DisplayName("首页待接预览按运次计,总数是待接运次数") + void tripPendingPreviewCountsTrips() { + Page trips = new Page<>(1, 2, 3); + trips.setRecords(List.of(loadingTrip(), unloadedTrip())); + when(tripQueryService.page(any())).thenReturn(trips); + + DriverWaybillPreviewVO preview = runAsDriver(() -> service.tripPendingPreview(null)); + + assertEquals(3, preview.getTotal()); + assertEquals(2, preview.getRecords().size()); + assertEquals(0, capturedQuery().getStatus()); + assertEquals(2, capturedQuery().getSize(), "不传时预览两条"); + } + + @Test + @DisplayName("运次详情的站点是整趟车:三站按途经顺序、第二站装卸、货物明细带运单号") + void tripDetailCarriesWholeCarStations() { + stubTripDetail(loadingTrip()); + + DriverWaybillCardVO card = runAsDriver(() -> service.tripDetail(String.valueOf(LOADING_ID))); + + assertEquals(String.valueOf(LOADING_ID), card.getTripId()); + assertEquals(3, card.getRoutePoints().size(), "装卸点是整趟车的三站,不再只有本票两点"); + assertEquals(3, card.getStations().size()); + assertEquals(1, card.getStations().get(0).getIndex()); + assertEquals("装货", card.getStations().get(0).getOperationTypeLabel()); + assertEquals("装卸", card.getStations().get(1).getOperationTypeLabel()); + assertEquals("卸货", card.getStations().get(2).getOperationTypeLabel()); + assertEquals("YD-20260923-0008", card.getStations().get(1).getCargos().get(0).getWaybillNo(), + "货物明细里能看到运单号"); + assertEquals("广西壮族自治区南宁市南宁吴圩机场", card.getRoutePoints().get(0).getName()); + verify(waybillService, never()).getById(any()); + } + + @Test + @DisplayName("未配载运次详情照运单详情下发,且只读:不调用会落库的接单态归一化") + void unloadedTripDetailStaysReadOnly() { + TripVO trip = unloadedTrip(); + stubTripDetail(trip); + Waybill waybill = unloadedWaybill(); + when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill); + + DriverWaybillCardVO first = runAsDriver(() -> service.tripDetail(String.valueOf(WAYBILL_ID))); + DriverWaybillCardVO second = runAsDriver(() -> service.tripDetail(String.valueOf(WAYBILL_ID))); + + assertEquals("2101508430981652482", first.getTripId()); + assertEquals("", first.getLoadingNo()); + assertNotNull(first.getProcessJson(), "运单详情的过程配置照旧下发"); + assertNotNull(first.getPunchNodes()); + assertEquals(2, first.getStations().size()); + assertEquals(2, first.getRoutePoints().size()); + assertEquals("运单备注", first.getRemark(), "未配载运次的备注仍是运单备注"); + assertEquals("2026-09-20", first.getPlanShipTime(), "未配载运次的计划时间仍是运单的计划时间"); + assertEquals("在途", first.getCurrentNode(), "未配载运次的当前节点照旧"); + assertEquals(first.getStatus(), second.getStatus(), "连读两次状态不漂移"); + verify(waybillService, never()).syncDriverAcceptState(any()); + verify(waybillService, never()).updateById(any()); + verify(waybillService, never()).update(any(), any()); + } + + @Test + @DisplayName("收口缺失时不放行:空页、零统计、当前任务为空,且不查运次") + void missingPhoneIsRefused() { + when(driverService.getOne(any())).thenReturn(null); + when(mpUserClient.userById(any())).thenReturn(null); + + IPage page = runAsDriver(() -> service.tripPage(null, null, null, null)); + DriverWaybillPreviewVO preview = runAsDriver(() -> service.tripPendingPreview(null)); + DriverWaybillTabCountsVO counts = runAsDriver(() -> service.tripTabCounts()); + + assertEquals(0, page.getTotal()); + assertTrue(page.getRecords().isEmpty()); + assertEquals(10, page.getSize()); + assertEquals(0, preview.getTotal()); + assertTrue(preview.getRecords().isEmpty()); + assertEquals(0, counts.getAll()); + assertNull(runAsDriver(() -> service.tripCurrentTask())); + assertThrows(ServiceException.class, () -> runAsDriver(() -> service.tripDetail("1"))); + verify(tripQueryService, never()).page(any()); + verify(tripQueryService, never()).tabCounts(any()); + verify(tripQueryService, never()).detail(any()); + } + + // ------------------------------------------------------------ 夹具 + + @SuppressWarnings("unchecked") + private static com.baomidou.mybatisplus.core.conditions.Wrapper anyWrapper() { + return org.mockito.ArgumentMatchers.>any(); + } + + private T runAsDriver(Supplier action) { + try (MockedStatic authUtil = mockStatic(AuthUtil.class)) { + authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID); + return action.get(); + } + } + + private void stubTripDetail(TripVO trip) { + when(tripQueryService.detail(any())).thenReturn(trip); + } + + private void stubTripPage(long total, TripVO... trips) { Page page = new Page<>(1, 10, total); + page.setRecords(List.of(trips)); + when(tripQueryService.page(any())).thenReturn(page); + } + + private TripQueryVO capturedQuery() { + ArgumentCaptor captor = ArgumentCaptor.forClass(TripQueryVO.class); + verify(tripQueryService, org.mockito.Mockito.atLeastOnce()).page(captor.capture()); + return captor.getValue(); + } + + private Driver driver() { + Driver driver = new Driver(); + driver.setId(DRIVER_ID); + driver.setUserId(USER_ID); + driver.setDriverName("黄宇富"); + driver.setDrivingVehicle("桂A32523"); + driver.setMobile(PHONE); + return driver; + } + + /** 本地库实测的 PZ-20261017-001:4 张子单、3 站、状态运输中 */ + private TripVO loadingTrip() { + // 回查到的子单按创建时间升序(与服务取子单的顺序一致) + List children = List.of( + tripChild("2092186187239780353", "YD-20260825-0006", "石英砂A", "广西武宣冠超矿业有限公司", "42.10"), + tripChild("2092186187470467073", "YD-20260825-0007", "石英砂A", "广西武宣冠超矿业有限公司", "47.57"), + tripChild("2092186187571130369", "YD-20260825-0008", "石英砂A", "广西武宣冠超矿业有限公司", "42.17"), + tripChild("2102780956493299713", "YD-20260923-0008", "技嘉4070显卡", "9/20测试mk001", "30.00")); + LoadingManage loading = new LoadingManage(); + loading.setId(LOADING_ID); + loading.setLoadingNo(LOADING_NO); + loading.setRouteJson(ROUTE_JSON); + loading.setWaybillIdsJson(WAYBILL_IDS_JSON); + List stations = TripStationAssembler.assemble(loading, children); + + TripVO trip = new TripVO(); + trip.setId(String.valueOf(LOADING_ID)); + trip.setLoadingNo(LOADING_NO); + trip.setTripNo(LOADING_NO); + trip.setTransportOrgType("load"); + trip.setStatus(1); + trip.setRequireAccept(false); + trip.setSortTime(new Date(1787671422000L)); + trip.setDepartureName(TripStationAssembler.departureName(stations)); + trip.setTransitName(TripStationAssembler.transitName(stations)); + trip.setArrivalName(TripStationAssembler.arrivalName(stations)); + trip.setStationCount(stations.size()); + trip.setStations(stations); + trip.setCargoSummary("石英砂A 131.84吨、技嘉4070显卡 30吨"); + trip.setTotalQuantity(new BigDecimal("161.84")); + trip.setQuantityUnit("吨"); + trip.setWeighingQuantity(new BigDecimal("161.84")); + trip.setWeighingUnit("吨"); + trip.setDriverName("黄宇富"); + trip.setDriverPhone(PHONE); + trip.setVehicleNo("桂A32523"); + trip.setProjectName("武宣建筑原料运输项目"); + trip.setTaskRemark("多点装卸测试"); + trip.setWaybills(children.stream().map(this::tripChildVO).toList()); + return trip; + } + + private TripVO unloadedTrip() { + Waybill waybill = unloadedWaybill(); + TripVO trip = new TripVO(); + trip.setId(String.valueOf(WAYBILL_ID)); + trip.setTripNo("YD-20260920-0003"); + trip.setTransportOrgType("common"); + trip.setStatus(0); + trip.setRequireAccept(true); + trip.setAcceptStatus("pending"); + trip.setSortTime(new Date(1787629629000L)); + trip.setEstimatedStartDate(java.time.LocalDate.of(2026, 9, 20)); + trip.setEstimatedEndDate(java.time.LocalDate.of(2026, 9, 22)); + trip.setDepartureName("南宁武鸣"); + trip.setArrivalName("钦州港"); + trip.setStationCount(2); + trip.setCargoSummary("石灰石 30吨"); + trip.setTotalQuantity(new BigDecimal("30")); + trip.setQuantityUnit("吨"); + trip.setWeighingQuantity(new BigDecimal("30")); + trip.setWeighingUnit("吨"); + trip.setDriverName("黄宇富"); + trip.setDriverPhone(PHONE); + trip.setVehicleNo("桂A32523"); + trip.setWaybills(List.of(tripChildVO(waybill))); + trip.setStations(TripStationAssembler.assemble(null, List.of(waybill))); + return trip; + } + + private Waybill unloadedWaybill() { + Waybill waybill = new Waybill(); + waybill.setId(WAYBILL_ID); + waybill.setWaybillNo("YD-20260920-0003"); + waybill.setBusinessStatus("pending"); + waybill.setDriverAcceptStatus("pending"); + waybill.setDriverPhone(PHONE); + waybill.setVehicleNo("桂A32523"); + waybill.setDepartureName("南宁武鸣"); + waybill.setDepartureAddress("南宁武鸣"); + waybill.setArrivalName("钦州港"); + waybill.setArrivalAddress("钦州港"); + waybill.setCargoName("石灰石"); + waybill.setQuantity(new BigDecimal("30")); + waybill.setQuantityUnit("吨"); + waybill.setProcessJson("[{\"key\":\"accept\",\"name\":\"接单\",\"enabled\":true,\"confirmMode\":\"yes\"}]"); + waybill.setCreateTime(new Date(1787629629000L)); + waybill.setRemark("运单备注"); + waybill.setTaskRemark("任务备注"); + waybill.setCurrentProcessNode("在途"); + waybill.setEstimatedStartTime(java.time.LocalDate.of(2026, 9, 20)); + waybill.setEstimatedEndTime(java.time.LocalDate.of(2026, 9, 22)); + return waybill; + } + + private Waybill tripChild(String id, String waybillNo, String cargoName, String customerName, String quantity) { + Waybill waybill = new Waybill(); + waybill.setId(Long.valueOf(id)); + waybill.setWaybillNo(waybillNo); + waybill.setCargoName(cargoName); + waybill.setCustomerName(customerName); + waybill.setQuantity(new BigDecimal(quantity)); + waybill.setQuantityUnit("吨"); + waybill.setBusinessStatus("running"); + return waybill; + } + + private TripVO.TripWaybillVO tripChildVO(Waybill waybill) { + TripVO.TripWaybillVO child = new TripVO.TripWaybillVO(); + child.setId(String.valueOf(waybill.getId())); + child.setWaybillNo(waybill.getWaybillNo()); + child.setCargoName(waybill.getCargoName()); + child.setCargoType("矿石"); + child.setQuantity(waybill.getQuantity()); + child.setQuantityUnit(waybill.getQuantityUnit()); + child.setBusinessStatus(waybill.getBusinessStatus()); + return child; + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillAutoCompleteTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillAutoCompleteTest.java index 1aef3f7c..f2eeac76 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillAutoCompleteTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillAutoCompleteTest.java @@ -8,6 +8,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mockito.MockedStatic; +import org.springblade.core.log.exception.ServiceException; import org.springblade.core.secure.utils.AuthUtil; import org.springblade.system.feign.IMpUserClient; import org.springblade.transport.mapper.ExceptionDisposalMapper; @@ -24,11 +25,14 @@ import org.springblade.transport.service.IDriverService; import org.springblade.transport.service.IProcessConfigService; import org.springblade.transport.service.ITransportVehicleService; import org.springblade.transport.service.IWaybillService; +import org.springblade.transport.service.ITripQueryService; import org.springblade.transport.support.CertificationGate; import java.util.List; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; @@ -49,6 +53,7 @@ class DriverWaybillAutoCompleteTest { private static final Long WAYBILL_ID = 5801L; private static final Long USER_ID = 7001L; private static final String PLATE = "桂AAW3255"; + private static final String PHONE = "13800138001"; private static final String CLOCK_PHOTO = "https://oss/clock.jpg"; /** 末位打卡节点是「签收」,没有回单节点 */ @@ -99,7 +104,8 @@ class DriverWaybillAutoCompleteTest { mock(CertificationGate.class), mock(WaybillEnroutePunchMapper.class), nodePunchMapper, - exceptionDisposalMapper); + exceptionDisposalMapper, + mock(ITripQueryService.class)); when(driverService.getOne(any())).thenReturn(driver()); when(waybillService.syncDriverAcceptState(any())).thenAnswer(invocation -> invocation.getArgument(0)); @@ -164,28 +170,29 @@ class DriverWaybillAutoCompleteTest { // ------------------------------------------------------------ 前置:运单本身能否完成 @Test - @DisplayName("业务状态为 draft 时只打卡不完成,打卡照常成功") - void draftWaybillIsPunchedButNotCompleted() { + @DisplayName("草稿运单未开始运输:打卡被拒,不落流水也不自动完成") + void draftWaybillCannotBePunched() { givenWaybill(SIGN_LAST_JSON, "draft"); - DriverNodePunchVO vo = submit("sign"); + ServiceException exception = assertThrows(ServiceException.class, () -> submit("sign")); + assertEquals("运单尚未开始运输,暂不可打卡", exception.getMessage()); + verify(nodePunchMapper, never()).insert(any(WaybillNodePunch.class)); verify(waybillService, never()).completeWithoutDeptCheck(any()); - verify(nodePunchMapper).insert(any(WaybillNodePunch.class)); - assertFalse(vo.getWaybillCompleted()); } @Test - @DisplayName("待执行与调度中间态同样允许自动完成") - void dispatchableStatusesStillComplete() { + @DisplayName("待执行、待调度、调度中都未开始运输:节点打卡一律被拒") + void dispatchableStatusesCannotPunch() { for (String status : List.of("pending", "waiting_dispatch", "dispatching")) { givenWaybill(SIGN_LAST_JSON, status); - when(waybillService.completeWithoutDeptCheck(WAYBILL_ID)).thenReturn(true); - DriverNodePunchVO vo = submit("sign"); + ServiceException exception = assertThrows(ServiceException.class, () -> submit("sign"), status); - assertTrue(vo.getWaybillCompleted(), status + " 属于可完成状态"); + assertEquals("运单尚未开始运输,暂不可打卡", exception.getMessage(), status); } + verify(nodePunchMapper, never()).insert(any(WaybillNodePunch.class)); + verify(waybillService, never()).completeWithoutDeptCheck(any()); } @Test @@ -219,6 +226,7 @@ class DriverWaybillAutoCompleteTest { waybill.setId(WAYBILL_ID); waybill.setWaybillNo("YD202610080001"); waybill.setVehicleNo(PLATE); + waybill.setDriverPhone(PHONE); waybill.setProcessJson(processJson); waybill.setBusinessStatus(businessStatus); waybill.setIsDeleted(0); @@ -237,6 +245,7 @@ class DriverWaybillAutoCompleteTest { driver.setId(9001L); driver.setUserId(USER_ID); driver.setDrivingVehicle(PLATE); + driver.setMobile(PHONE); return driver; } diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillCardLocationTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillCardLocationTest.java index 86033d39..35e8dc58 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillCardLocationTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillCardLocationTest.java @@ -20,6 +20,7 @@ import org.springblade.transport.service.IDriverService; import org.springblade.transport.service.IProcessConfigService; import org.springblade.transport.service.ITransportVehicleService; import org.springblade.transport.service.IWaybillService; +import org.springblade.transport.service.ITripQueryService; import org.springblade.transport.support.CertificationGate; import java.util.List; @@ -78,7 +79,8 @@ class DriverWaybillCardLocationTest { mock(CertificationGate.class), enroutePunchMapper, nodePunchMapper, - exceptionDisposalMapper); + exceptionDisposalMapper, + mock(ITripQueryService.class)); when(waybillService.syncDriverAcceptState(any())).thenAnswer(invocation -> invocation.getArgument(0)); when(nodePunchMapper.selectList(any())).thenReturn(List.of()); diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillNodePunchTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillNodePunchTest.java index 483ed637..83e8475e 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillNodePunchTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillNodePunchTest.java @@ -16,6 +16,7 @@ import org.springblade.transport.service.IDriverService; import org.springblade.transport.service.IProcessConfigService; import org.springblade.transport.service.ITransportVehicleService; import org.springblade.transport.service.IWaybillService; +import org.springblade.transport.service.ITripQueryService; import org.springblade.transport.support.CertificationGate; import java.util.List; @@ -60,7 +61,8 @@ class DriverWaybillNodePunchTest { mock(CertificationGate.class), mock(WaybillEnroutePunchMapper.class), nodePunchMapper, - mock(ExceptionDisposalMapper.class)); + mock(ExceptionDisposalMapper.class), + mock(ITripQueryService.class)); } @Test diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillOwnershipTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillOwnershipTest.java index 035a2db1..a96f086f 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillOwnershipTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillOwnershipTest.java @@ -22,6 +22,7 @@ import org.springblade.transport.service.IDriverService; import org.springblade.transport.service.IProcessConfigService; import org.springblade.transport.service.ITransportVehicleService; import org.springblade.transport.service.IWaybillService; +import org.springblade.transport.service.ITripQueryService; import org.springblade.transport.support.CertificationGate; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; @@ -72,7 +73,8 @@ class DriverWaybillOwnershipTest { mock(CertificationGate.class), mock(WaybillEnroutePunchMapper.class), mock(WaybillNodePunchMapper.class), - mock(ExceptionDisposalMapper.class)); + mock(ExceptionDisposalMapper.class), + mock(ITripQueryService.class)); } @Test diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillRejectDetachTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillRejectDetachTest.java index 3553c9c3..b11451aa 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillRejectDetachTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillRejectDetachTest.java @@ -28,6 +28,7 @@ import org.springblade.transport.service.IDriverService; import org.springblade.transport.service.IProcessConfigService; import org.springblade.transport.service.ITransportVehicleService; import org.springblade.transport.service.IWaybillService; +import org.springblade.transport.service.ITripQueryService; import org.springblade.transport.support.CertificationGate; import java.util.List; @@ -57,6 +58,7 @@ class DriverWaybillRejectDetachTest { private static final Long USER_ID = 7401L; private static final Long DRIVER_ID = 9001L; private static final String PLATE = "沪AE31312"; + private static final String PHONE = "13800138002"; private static final Long REJECTED_ID = 7201L; private IDriverService driverService; @@ -87,7 +89,8 @@ class DriverWaybillRejectDetachTest { certificationGate, mock(WaybillEnroutePunchMapper.class), mock(WaybillNodePunchMapper.class), - mock(ExceptionDisposalMapper.class)); + mock(ExceptionDisposalMapper.class), + mock(ITripQueryService.class)); lenient().when(driverService.getOne(any())).thenReturn(driver()); lenient().when(waybillService.updateById(any())).thenReturn(true); // syncDriverAcceptState 是 IWaybillService 的方法,mock 默认返 null 会让调用方 NPE; @@ -97,7 +100,7 @@ class DriverWaybillRejectDetachTest { } @Test - @DisplayName("首页待接预览的取单条件排除已拒单,且排除子句与车牌条件并列") + @DisplayName("首页待接预览的取单条件排除已拒单,且排除子句与取单条件并列") void pendingPreviewQueryExcludesRejected() { when(waybillService.list(ArgumentMatchers.>any())).thenReturn(List.of()); @@ -107,7 +110,7 @@ class DriverWaybillRejectDetachTest { assertTrue(sql.contains("driver_accept_status"), "取单条件必须排除已拒单:" + sql); assertTrue(sql.toUpperCase().contains("AND"), - "排除子句不能与车牌条件「或」在一起,否则等于没排除:" + sql); + "排除子句不能与取单条件「或」在一起,否则等于没排除:" + sql); } @Test @@ -179,7 +182,9 @@ class DriverWaybillRejectDetachTest { void enrouteSubmitKeepsUnfilteredScope() { EnrouteSubmitDTO dto = new EnrouteSubmitDTO(); dto.setWaybillId(REJECTED_ID); - when(waybillService.getOne(any())).thenReturn(rejectedWaybill()); + // 已拒单 + 进行中:历史脏数据,取单不能被拒单条件挡住,收口交给后面的过程配置检查 + when(waybillService.getOne(any())) + .thenReturn(waybill(REJECTED_ID, "YD-20261010-0001", "running", "rejected")); // 断言的是「取单这一步没有被拒单条件拦住」:若被拦住会抛「运单不存在或无权操作」, // 而后面因过程配置为空会走「该运单未启用在途打卡」,两者可区分 @@ -212,6 +217,7 @@ class DriverWaybillRejectDetachTest { driver.setUserId(USER_ID); driver.setDriverName("黄大富"); driver.setDrivingVehicle(PLATE); + driver.setMobile(PHONE); return driver; } @@ -224,6 +230,7 @@ class DriverWaybillRejectDetachTest { waybill.setId(id); waybill.setWaybillNo(no); waybill.setVehicleNo(PLATE); + waybill.setDriverPhone(PHONE); waybill.setBusinessStatus(businessStatus); waybill.setDriverAcceptStatus(acceptStatus); waybill.setIsDeleted(0); diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalReportGateTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalReportGateTest.java index be77a57c..4a64ae2a 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalReportGateTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalReportGateTest.java @@ -245,6 +245,51 @@ class ExceptionDisposalReportGateTest { assertEquals("韦司机", savedDisposal().getReporterName()); } + // ------------------------------------------------------------ 运单状态门禁 + + @Test + @DisplayName("未开始运输的运单不能上报:整单异常同样被拒") + void notStartedWaybillRejectsWaybillLevelReport() { + stubWaybillStatus("waiting_dispatch"); + + ServiceException exception = submitExpectRejected(null); + + assertEquals("运单尚未开始运输,暂不可上报异常", exception.getMessage()); + } + + @Test + @DisplayName("已完成的运单不能再上报") + void completedWaybillRejectsReport() { + stubWaybillStatus("completed"); + + ServiceException exception = submitExpectRejected("load"); + + assertEquals("运单已完成,不能上报异常", exception.getMessage()); + } + + @Test + @DisplayName("已取消的运单不能再上报") + void cancelledWaybillRejectsReport() { + stubWaybillStatus("cancelled"); + + ServiceException exception = submitExpectRejected(null); + + assertEquals("运单已取消,不能上报异常", exception.getMessage()); + } + + @Test + @DisplayName("运单状态门禁在节点门禁之前:未开始运输时先说没上路,不报节点已打卡") + void statusGateBeatsNodeGate() { + stubWaybillStatus("pending"); + stubNode("load", "装货", "cargo"); + when(driverWaybillService.isNodePunched(WAYBILL_ID, "load")).thenReturn(true); + + ServiceException exception = submitExpectRejected("load"); + + assertEquals("运单尚未开始运输,暂不可上报异常", exception.getMessage()); + verify(driverWaybillService, never()).findPunchNode(any(), any()); + } + // ------------------------------------------------------------ 上报人归属 @Test @@ -273,6 +318,13 @@ class ExceptionDisposalReportGateTest { // ------------------------------------------------------------ 夹具 + /** 改写夹具运单的业务状态,供状态门禁用例复写 getById 的桩 */ + private void stubWaybillStatus(String businessStatus) { + Waybill waybill = waybill(); + waybill.setBusinessStatus(businessStatus); + when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill); + } + private void stubNode(String key, String name, String type) { when(driverWaybillService.findPunchNode(WAYBILL_ID, key)).thenReturn(node(key, name, type)); when(driverWaybillService.isNodePunched(WAYBILL_ID, key)).thenReturn(false); @@ -358,6 +410,8 @@ class ExceptionDisposalReportGateTest { waybill.setWaybillNo("YD202610070001"); waybill.setVehicleNo("桂AAW3255"); waybill.setIsDeleted(0); + // 状态门禁:夹具默认进行中,状态类用例自己改 + waybill.setBusinessStatus("running"); return waybill; } diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/LoadingManageAdmissionTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/LoadingManageAdmissionTest.java new file mode 100644 index 00000000..a8eccbcd --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/LoadingManageAdmissionTest.java @@ -0,0 +1,412 @@ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentMatchers; +import org.mockito.MockedStatic; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.core.secure.utils.AuthUtil; +import org.springblade.transport.mapper.LoadingManageMapper; +import org.springblade.transport.mapper.WaybillMapper; +import org.springblade.transport.pojo.entity.ContractManage; +import org.springblade.transport.pojo.entity.LoadingManage; +import org.springblade.transport.pojo.entity.Waybill; +import org.springblade.transport.service.IContractManageService; + +import java.lang.reflect.Field; +import java.util.List; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 配载提交/编辑/重新派单的准入校验:落库前的一处判据决定这批运单能不能被配载。 + *

+ * 逐张准入用的是读侧候选列表的同一份判据;已挂在本单号上的子运单在先前提交时已过判据, + * 重新提交不再重复判——不需接单项目的票提交后即为进行中,重复判会让本单再也保存不了。 + * 暂存只预占运单号、未过判据,故草稿转正式时本单号不构成豁免。 + *

+ * 跨行判据(同项目、合同属于本项目)只对提交/编辑/重新派单生效,暂存草稿不受影响。 + */ +class LoadingManageAdmissionTest { + + private static final Long DEPT_ID = 1001L; + private static final Long PROJECT_A = 2001L; + private static final Long PROJECT_B = 2002L; + private static final Long CONTRACT_ID = 3001L; + private static final Long OTHER_CONTRACT_ID = 3002L; + private static final Long OWN_LOADING_ID = 9001L; + private static final String OWN_LOADING_NO = "PZ20261018-001"; + private static final String WAYBILL_NO_A = "YD202610180001"; + private static final String WAYBILL_NO_B = "YD202610180002"; + + private LoadingManageServiceImpl service; + private WaybillMapper waybillMapper; + private LoadingManageMapper loadingManageMapper; + private IContractManageService contractManageService; + private MockedStatic authUtil; + + @BeforeAll + static void initTableInfo() { + // 构造 LambdaQueryWrapper 需要 MyBatis-Plus 的实体元数据缓存 + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, Waybill.class); + TableInfoHelper.initTableInfo(assistant, LoadingManage.class); + TableInfoHelper.initTableInfo(assistant, ContractManage.class); + } + + @BeforeEach + void setUp() throws Exception { + waybillMapper = mock(WaybillMapper.class); + loadingManageMapper = mock(LoadingManageMapper.class); + contractManageService = mock(IContractManageService.class); + service = new LoadingManageServiceImpl(); + inject("baseMapper", loadingManageMapper); + inject("waybillMapper", waybillMapper); + inject("contractManageService", contractManageService); + authUtil = mockStatic(AuthUtil.class); + authUtil.when(AuthUtil::getDeptId).thenReturn(String.valueOf(DEPT_ID)); + } + + @AfterEach + void tearDown() { + authUtil.close(); + } + + // ------------------------------------------------------------ 逐张准入 + + @Test + @DisplayName("非公路运单被拒,错误含运单号与原因") + void nonRoadWaybillRejected() { + stubWaybills(waybill(1L, WAYBILL_NO_A, "railway", "pending", null, PROJECT_A)); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.submit(submitPayload(List.of(1L)))); + + assertEquals("运单" + WAYBILL_NO_A + "运输方式不是公路,不可配载", exception.getMessage()); + } + + @Test + @DisplayName("状态不是待执行一律被拒:进行中、已完成、已取消、草稿") + void nonPendingStatusRejected() { + for (String status : List.of("running", "completed", "cancelled", "draft")) { + stubWaybills(waybill(1L, WAYBILL_NO_A, "road", status, null, PROJECT_A)); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.submit(submitPayload(List.of(1L))), status); + + assertEquals("运单" + WAYBILL_NO_A + "当前状态不是待执行,不可配载", exception.getMessage(), status); + } + } + + @Test + @DisplayName("已挂在其它的配载单上的运单被拒") + void waybillOnAnotherManifestRejected() { + stubWaybills(waybill(1L, WAYBILL_NO_A, "road", "pending", "PZ20261018-999", PROJECT_A)); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.submit(submitPayload(List.of(1L)))); + + assertEquals("运单" + WAYBILL_NO_A + "已关联其他配载单", exception.getMessage()); + } + + @Test + @DisplayName("已拒单但未配载的公路待执行运单可通过提交:接单事实由配载写入路径清除,新司机由此看到它") + void rejectedWaybillIsAdmitted() { + Waybill rejected = waybill(1L, WAYBILL_NO_A, "road", "pending", null, PROJECT_A); + rejected.setDriverAcceptStatus("rejected"); + stubWaybills(rejected); + stubContract(CONTRACT_ID, PROJECT_A); + + assertDoesNotThrow(() -> service.submit(submitPayload(List.of(1L)))); + } + + // ------------------------------------------------------------ 本单既有子运单 + + @Test + @DisplayName("编辑本单:子运单挂的就是本单号,不算冲突") + void editingOwnChildrenPasses() throws Exception { + stubExistingManifest(); + stubWaybills(waybill(1L, WAYBILL_NO_A, "road", "pending", OWN_LOADING_NO, PROJECT_A)); + stubContract(CONTRACT_ID, PROJECT_A); + + LoadingManage payload = submitPayload(List.of(1L)); + payload.setId(OWN_LOADING_ID); + + assertDoesNotThrow(() -> service.submit(payload)); + } + + @Test + @DisplayName("不需接单项目的子运单提交后即为进行中;重新提交本单不得因此被挡") + void editingOwnRunningChildrenPasses() throws Exception { + stubExistingManifest(); + stubWaybills(waybill(1L, WAYBILL_NO_A, "road", "running", OWN_LOADING_NO, PROJECT_A)); + stubContract(CONTRACT_ID, PROJECT_A); + + LoadingManage payload = submitPayload(List.of(1L)); + payload.setId(OWN_LOADING_ID); + + assertDoesNotThrow(() -> service.submit(payload)); + } + + @Test + @DisplayName("本单既有子运单放行,但同一次请求新塞进来的进行中运单仍被拒") + void newlyAddedRunningWaybillIsStillRejected() throws Exception { + stubExistingManifest(); + stubWaybills( + waybill(1L, WAYBILL_NO_A, "road", "running", OWN_LOADING_NO, PROJECT_A), + waybill(2L, WAYBILL_NO_B, "road", "running", null, PROJECT_A)); + + LoadingManage payload = submitPayload(List.of(1L, 2L)); + payload.setId(OWN_LOADING_ID); + + ServiceException exception = assertThrows(ServiceException.class, () -> service.submit(payload)); + + assertEquals("运单" + WAYBILL_NO_B + "当前状态不是待执行,不可配载", exception.getMessage()); + } + + @Test + @DisplayName("重新派单:本单被拒的子运单仍挂在本单号上,不因拒单被挡——重新派单本就是为它而设") + void reassignKeepsRejectedOwnChild() throws Exception { + stubExistingManifest(); + when(waybillMapper.selectCount(anyWaybillWrapper())).thenReturn(1L); + Waybill rejected = waybill(1L, WAYBILL_NO_A, "road", "pending", OWN_LOADING_NO, PROJECT_A); + rejected.setDriverAcceptStatus("rejected"); + stubWaybills(rejected); + stubContract(CONTRACT_ID, PROJECT_A); + + LoadingManage payload = submitPayload(List.of(1L)); + payload.setId(OWN_LOADING_ID); + + assertDoesNotThrow(() -> service.reassign(payload)); + } + + @Test + @DisplayName("交叉判据同时命中时按 公路 → 待执行 → 已关联其它配载单 取第一条") + void firstFailingConditionWins() { + stubWaybills(waybill(1L, WAYBILL_NO_A, "railway", "pending", "PZ20261018-999", PROJECT_A)); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.submit(submitPayload(List.of(1L)))); + + assertEquals("运单" + WAYBILL_NO_A + "运输方式不是公路,不可配载", exception.getMessage()); + } + + // ------------------------------------------------------------ 草稿预占 + + @Test + @DisplayName("暂存只预占运单号:草稿里那张进行中的票在草稿转正式时被拒") + void draftReservedRunningWaybillRejected() { + stubManifest("draft"); + stubWaybills(waybill(1L, WAYBILL_NO_A, "road", "running", OWN_LOADING_NO, PROJECT_A)); + stubContract(CONTRACT_ID, PROJECT_A); + + assertDoesNotThrow(() -> service.saveDraft(existingPayload(List.of(1L)))); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.submit(existingPayload(List.of(1L)))); + + assertEquals("运单" + WAYBILL_NO_A + "当前状态不是待执行,不可配载", exception.getMessage()); + } + + @Test + @DisplayName("暂存只预占运单号:草稿里那张非公路的票在草稿转正式时被拒") + void draftReservedNonRoadWaybillRejected() { + stubManifest("draft"); + stubWaybills(waybill(1L, WAYBILL_NO_A, "railway", "pending", OWN_LOADING_NO, PROJECT_A)); + stubContract(CONTRACT_ID, PROJECT_A); + + assertDoesNotThrow(() -> service.saveDraft(existingPayload(List.of(1L)))); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.submit(existingPayload(List.of(1L)))); + + assertEquals("运单" + WAYBILL_NO_A + "运输方式不是公路,不可配载", exception.getMessage()); + } + + @Test + @DisplayName("暂存预占的票本身合格时,草稿转正式照常通过") + void draftReservationStaysAdmissibleOnSubmit() { + stubManifest("draft"); + stubWaybills(waybill(1L, WAYBILL_NO_A, "road", "pending", OWN_LOADING_NO, PROJECT_A)); + stubContract(CONTRACT_ID, PROJECT_A); + + assertDoesNotThrow(() -> service.saveDraft(existingPayload(List.of(1L)))); + assertDoesNotThrow(() -> service.submit(existingPayload(List.of(1L)))); + } + + // ------------------------------------------------------------ 跨行判据 + + @Test + @DisplayName("跨项目的子运单集合被拒,错误点名与其它子运单不同项目的运单号") + void crossProjectChildrenRejected() { + stubWaybills( + waybill(1L, WAYBILL_NO_A, "road", "pending", null, PROJECT_A), + waybill(2L, WAYBILL_NO_B, "road", "pending", null, PROJECT_B)); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.submit(submitPayload(List.of(1L, 2L)))); + + assertEquals("配载单的子运单必须属于同一项目,运单" + WAYBILL_NO_B + "与其它子运单不属于同一项目", + exception.getMessage()); + } + + @Test + @DisplayName("合同不属于本项目:与本项目无可选合同区分开") + void contractOfAnotherProjectRejected() { + stubWaybills(waybill(1L, WAYBILL_NO_A, "road", "pending", null, PROJECT_A)); + stubContract(CONTRACT_ID, PROJECT_B); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.submit(submitPayload(List.of(1L)))); + + assertEquals("所选承运商合同不属于运单" + WAYBILL_NO_A + "的项目", exception.getMessage()); + } + + @Test + @DisplayName("合同本身不可用(未审核通过或已失效)仍报原有文案,不与「不属于本项目」混同") + void unavailableContractKeepsExistingMessage() { + stubWaybills(waybill(1L, WAYBILL_NO_A, "road", "pending", null, PROJECT_A)); + when(contractManageService.list(anyContractWrapper())).thenReturn(List.of()); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.submit(submitPayload(List.of(1L)))); + + assertEquals("所选承运商合同不存在、未审核通过或已失效", exception.getMessage()); + } + + // ------------------------------------------------------------ 放行 + + @Test + @DisplayName("公路 + 待执行 + 未配载 + 同项目 + 合同属于本项目:通过并带出合同乙方") + void legalRequestPasses() { + stubWaybills( + waybill(1L, WAYBILL_NO_A, "road", "pending", null, PROJECT_A), + waybill(2L, WAYBILL_NO_B, "公路运输", "pending", "", PROJECT_A)); + stubContract(CONTRACT_ID, PROJECT_A); + + LoadingManage payload = submitPayload(List.of(1L, 2L)); + + assertDoesNotThrow(() -> service.submit(payload)); + assertEquals("甲承运商", payload.getCarrierName()); + } + + @Test + @DisplayName("暂存不受准入校验影响:跨项目、非公路、进行中的票照旧落草稿") + void draftIsNotGated() { + LoadingManage draft = new LoadingManage(); + draft.setDeptId(DEPT_ID); + draft.setLoadingSubNos(WAYBILL_NO_A); + draft.setCarrierType("自运"); + draft.setWaybillIdsJson(waybillIdsJson(List.of(1L, 2L))); + + assertDoesNotThrow(() -> service.saveDraft(draft)); + verify(waybillMapper, never()).selectList(anyWaybillWrapper()); + } + + // ------------------------------------------------------------ 夹具 + + private Waybill waybill(Long id, String waybillNo, String transportType, String businessStatus, + String loadingNo, Long projectId) { + Waybill waybill = new Waybill(); + waybill.setId(id); + waybill.setWaybillNo(waybillNo); + waybill.setTransportType(transportType); + waybill.setBusinessStatus(businessStatus); + waybill.setLoadingNo(loadingNo); + waybill.setProjectId(projectId); + waybill.setDeptId(DEPT_ID); + waybill.setIsDeleted(0); + return waybill; + } + + private LoadingManage submitPayload(List waybillIdList) { + LoadingManage loadingManage = new LoadingManage(); + loadingManage.setDeptId(DEPT_ID); + loadingManage.setLoadingSubNos(WAYBILL_NO_A); + loadingManage.setTransportType("road"); + loadingManage.setCarrierType("承运商"); + loadingManage.setCarrierContractId(CONTRACT_ID); + loadingManage.setVehicleNo("苏A12345"); + loadingManage.setDepartureAddress("南京"); + loadingManage.setArrivalAddress("上海"); + loadingManage.setWaybillIdsJson(waybillIdsJson(waybillIdList)); + return loadingManage; + } + + private LoadingManage existingPayload(List waybillIdList) { + LoadingManage loadingManage = submitPayload(waybillIdList); + loadingManage.setId(OWN_LOADING_ID); + return loadingManage; + } + + private static String waybillIdsJson(List waybillIdList) { + return waybillIdList.stream().map(String::valueOf).collect(Collectors.joining(",", "[", "]")); + } + + private void stubExistingManifest() { + stubManifest("pending"); + } + + private void stubManifest(String businessStatus) { + LoadingManage existing = new LoadingManage(); + existing.setId(OWN_LOADING_ID); + existing.setLoadingNo(OWN_LOADING_NO); + existing.setDeptId(DEPT_ID); + existing.setBusinessStatus(businessStatus); + when(loadingManageMapper.selectById(OWN_LOADING_ID)).thenReturn(existing); + } + + private void stubWaybills(Waybill... waybills) { + when(waybillMapper.selectList(anyWaybillWrapper())).thenReturn(List.of(waybills)); + } + + private void stubContract(Long contractId, Long projectId) { + ContractManage contract = new ContractManage(); + contract.setId(contractId); + contract.setProjectId(projectId); + contract.setPartyB(contractId.equals(OTHER_CONTRACT_ID) ? "乙承运商" : "甲承运商"); + when(contractManageService.list(anyContractWrapper())).thenReturn(List.of(contract)); + } + + private static Wrapper anyWaybillWrapper() { + return ArgumentMatchers.>any(); + } + + private static Wrapper anyContractWrapper() { + return ArgumentMatchers.>any(); + } + + private void inject(String field, Object value) throws Exception { + Class type = service.getClass(); + while (type != null) { + try { + Field declared = type.getDeclaredField(field); + declared.setAccessible(true); + declared.set(service, value); + return; + } catch (NoSuchFieldException ignored) { + type = type.getSuperclass(); + } + } + throw new IllegalStateException("找不到字段:" + field); + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/LoadingManageChildStatusWriteTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/LoadingManageChildStatusWriteTest.java new file mode 100644 index 00000000..52ab19f9 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/LoadingManageChildStatusWriteTest.java @@ -0,0 +1,477 @@ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.ArgumentMatchers; +import org.mockito.MockedStatic; +import org.springblade.core.secure.utils.AuthUtil; +import org.springblade.core.tool.jackson.JsonUtil; +import org.springblade.transport.mapper.WaybillMapper; +import org.springblade.transport.pojo.entity.LoadingManage; +import org.springblade.transport.pojo.entity.ProcessConfig; +import org.springblade.transport.pojo.entity.Waybill; +import org.springblade.transport.service.IContractManageService; +import org.springblade.transport.service.IProcessConfigService; +import org.springblade.transport.service.IReceivablePayableDetailService; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 配载提交 / 重新派单写入子运单状态:状态逐张按各票自己项目的实时过程配置推导, + * 并把这批票的接单事实清成「待接单」,使新司机能看到它们。 + *

+ * 推导必须与运单读路径的状态归一化同口径(同一份实时配置、同一个归一化函数), + * 否则写完再读会漂移。 + */ +class LoadingManageChildStatusWriteTest { + + /** 当前登录组织,与夹具运单的 deptId 一致 */ + private static final String DEPT_ID = "1123598813738675201"; + private static final Long DEPT_ID_LONG = 1123598813738675201L; + private static final String LOADING_NO = "PZ-20261018-001"; + private static final Long LOADING_ID = 1800000000000000001L; + private static final String DRIVER_PHONE = "13800138000"; + + private static final Long NEED_ACCEPT_WAYBILL_ID = 3101L; + private static final Long NO_ACCEPT_WAYBILL_ID = 3102L; + private static final Long NEED_ACCEPT_PROJECT_ID = 2101L; + private static final Long NO_ACCEPT_PROJECT_ID = 2102L; + + /** 接单节点「是否确认=是」→ 需要司机接单确认 */ + private static final String NEED_ACCEPT_JSON = + "[{\"key\":\"accept\",\"name\":\"接单\",\"enabled\":true,\"confirmMode\":\"yes\"}]"; + /** 接单节点「无需确认接单」→ 不需要接单 */ + private static final String NO_ACCEPT_JSON = + "[{\"key\":\"accept\",\"name\":\"接单\",\"enabled\":true,\"confirmMode\":\"no_confirm_accept\"}]"; + + /** 配载写入给子运单落的全部配载字段,逐条都不能因为状态要分批写而漏掉 */ + private static final List SYNC_COLUMNS = List.of( + "loading_no", + "business_status", + "carrier_type", + "carrier_contract_id", + "carrier_name", + "driver_name", + "driver_phone", + "vehicle_no", + "trailer_vehicle_no", + "escort_name", + "escort_phone", + "mileage", + "estimated_start_time", + "estimated_end_time", + "task_remark", + "task_info_json", + "current_process_node"); + + private static final List ACCEPT_CLEAR_COLUMNS = List.of( + "driver_accept_status", + "driver_accept_time", + "driver_accept_driver_id", + "driver_reject_time", + "driver_reject_reason"); + + private WaybillMapper waybillMapper; + private IProcessConfigService processConfigService; + private IContractManageService contractManageService; + private IReceivablePayableDetailService receivablePayableDetailService; + private LoadingManageServiceImpl service; + private MockedStatic authUtil; + + @BeforeAll + static void initTableInfo() { + // 构造 LambdaQueryWrapper / LambdaUpdateWrapper 需要 MyBatis-Plus 的实体元数据缓存 + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, Waybill.class); + TableInfoHelper.initTableInfo(assistant, ProcessConfig.class); + } + + @BeforeEach + void setUp() { + waybillMapper = mock(WaybillMapper.class); + processConfigService = mock(IProcessConfigService.class); + contractManageService = mock(IContractManageService.class); + receivablePayableDetailService = mock(IReceivablePayableDetailService.class); + service = spy(new LoadingManageServiceImpl()); + inject(service, "waybillMapper", waybillMapper); + inject(service, "processConfigService", processConfigService); + inject(service, "contractManageService", contractManageService); + inject(service, "receivablePayableDetailService", receivablePayableDetailService); + doReturnTrue(service); + authUtil = mockStatic(AuthUtil.class); + authUtil.when(AuthUtil::getDeptId).thenReturn(DEPT_ID); + } + + @AfterEach + void tearDown() { + authUtil.close(); + } + + @Test + @DisplayName("不需接单的项目:提交时子单直接落进行中,不留待执行等读路径去提升") + void submitWritesRunningForProjectWithoutAcceptNode() { + stubWaybills(waybill(NO_ACCEPT_WAYBILL_ID, NO_ACCEPT_PROJECT_ID)); + stubConfigs(config(NO_ACCEPT_PROJECT_ID, NO_ACCEPT_JSON)); + + assertTrue(service.submit(loadingForm(NO_ACCEPT_WAYBILL_ID))); + + CapturedUpdate statusUpdate = statusSyncUpdates().get(0); + assertEquals("running", statusUpdate.setValue("business_status")); + assertEquals(List.of(NO_ACCEPT_WAYBILL_ID), statusUpdate.whereIds()); + assertEquals(SYNC_COLUMNS, statusUpdate.columns(), "配载字段一张不少地随状态一起写下去"); + } + + @Test + @DisplayName("需接单的项目:提交时子单落待执行,等新司机接单") + void submitWritesPendingForProjectWithAcceptNode() { + stubWaybills(waybill(NEED_ACCEPT_WAYBILL_ID, NEED_ACCEPT_PROJECT_ID)); + stubConfigs(config(NEED_ACCEPT_PROJECT_ID, NEED_ACCEPT_JSON)); + + assertTrue(service.submit(loadingForm(NEED_ACCEPT_WAYBILL_ID))); + + assertEquals("pending", statusSyncUpdates().get(0).setValue("business_status")); + } + + @Test + @DisplayName("跨项目的子单逐张按各自项目推导,互不牵连,配置只查一次") + void deriveEachWaybillStatusFromItsOwnProject() throws Exception { + stubConfigs( + config(NEED_ACCEPT_PROJECT_ID, NEED_ACCEPT_JSON), + config(NO_ACCEPT_PROJECT_ID, NO_ACCEPT_JSON)); + + // 提交与重新派单在准入阶段就拦下跨项目的子单,故直接测逐张推导本身 + assertEquals(Map.of( + NEED_ACCEPT_WAYBILL_ID, "pending", + NO_ACCEPT_WAYBILL_ID, "running"), + deriveBusinessStatuses( + waybill(NEED_ACCEPT_WAYBILL_ID, NEED_ACCEPT_PROJECT_ID), + waybill(NO_ACCEPT_WAYBILL_ID, NO_ACCEPT_PROJECT_ID))); + verify(processConfigService, times(1)).list(ArgumentMatchers.>any()); + } + + @Test + @DisplayName("项目生效配置优先:项目不需要接单时,运单自己的历史快照说需要接单也不采信") + void liveConfigWinsOverWaybillSnapshot() { + stubWaybills(waybill(NEED_ACCEPT_WAYBILL_ID, NEED_ACCEPT_PROJECT_ID, NEED_ACCEPT_JSON)); + stubConfigs(config(NEED_ACCEPT_PROJECT_ID, NO_ACCEPT_JSON)); + + assertTrue(service.submit(loadingForm(NEED_ACCEPT_WAYBILL_ID))); + + assertEquals("running", statusSyncUpdates().get(0).setValue("business_status")); + } + + @Test + @DisplayName("没有项目也没有快照:视为不需要接单,写进行中") + void waybillWithoutProjectAndSnapshotWritesRunning() { + stubWaybills(waybill(NO_ACCEPT_WAYBILL_ID, null)); + stubConfigs(); + + assertTrue(service.submit(loadingForm(NO_ACCEPT_WAYBILL_ID))); + + assertEquals("running", statusSyncUpdates().get(0).setValue("business_status")); + } + + @Test + @DisplayName("项目为空的运单:配置取不到时按运单快照兜底,与读路径的归一化保持一致") + void waybillWithoutProjectFallsBackToSnapshot() { + stubWaybills(waybill(NEED_ACCEPT_WAYBILL_ID, null, NEED_ACCEPT_JSON)); + stubConfigs(); + + assertTrue(service.submit(loadingForm(NEED_ACCEPT_WAYBILL_ID))); + + assertEquals("pending", statusSyncUpdates().get(0).setValue("business_status")); + } + + @Test + @DisplayName("提交清除本次合进来子单的接单事实:置为待接单,接单/拒单痕迹一并清空") + void submitClearsAcceptRecordOfWaybillsTakenIn() { + // 同一配载单的子单必须同项目,故两张票同项目,接单事实按整批清 + stubWaybills( + waybill(NEED_ACCEPT_WAYBILL_ID, NEED_ACCEPT_PROJECT_ID), + waybill(NO_ACCEPT_WAYBILL_ID, NEED_ACCEPT_PROJECT_ID)); + stubConfigs(config(NEED_ACCEPT_PROJECT_ID, NEED_ACCEPT_JSON)); + + assertTrue(service.submit(loadingForm(NEED_ACCEPT_WAYBILL_ID, NO_ACCEPT_WAYBILL_ID))); + + CapturedUpdate clearUpdate = acceptClearUpdates().get(0); + assertEquals(ACCEPT_CLEAR_COLUMNS, clearUpdate.columns()); + assertEquals("pending", clearUpdate.setValue("driver_accept_status")); + assertNull(clearUpdate.setValue("driver_accept_time")); + assertNull(clearUpdate.setValue("driver_accept_driver_id")); + assertNull(clearUpdate.setValue("driver_reject_time")); + assertNull(clearUpdate.setValue("driver_reject_reason")); + assertEquals( + Set.of(NEED_ACCEPT_WAYBILL_ID, NO_ACCEPT_WAYBILL_ID), + Set.copyOf(clearUpdate.whereIds())); + } + + @Test + @DisplayName("重新派单:与提交同一套逐张推导,并同样清除接单事实;先写状态后清事实") + void reassignDerivesStatusAndClearsAcceptRecord() { + stubLoadingRecord(loadingRecord("pending", NEED_ACCEPT_WAYBILL_ID)); + stubWaybills(waybill(NEED_ACCEPT_WAYBILL_ID, NEED_ACCEPT_PROJECT_ID)); + stubConfigs(config(NEED_ACCEPT_PROJECT_ID, NEED_ACCEPT_JSON)); + stubRejectedWaybillExists(); + + LoadingManage form = editForm(LOADING_ID, NEED_ACCEPT_WAYBILL_ID); + assertTrue(service.reassign(form)); + + List updates = waybillUpdates(); + assertEquals("pending", updates.get(0).setValue("business_status")); + assertEquals(List.of(NEED_ACCEPT_WAYBILL_ID), updates.get(1).whereIds()); + assertEquals(ACCEPT_CLEAR_COLUMNS, updates.get(1).columns(), "清接单事实紧跟状态写入之后"); + } + + @Test + @DisplayName("编辑配载单移除运单:被移除的票照旧退回待执行,不参与本次的状态推导与清接单事实") + void removedWaybillIsReleasedWithoutDerivation() { + stubLoadingRecord(loadingRecord("pending", NEED_ACCEPT_WAYBILL_ID, NO_ACCEPT_WAYBILL_ID)); + stubWaybills(waybill(NO_ACCEPT_WAYBILL_ID, NO_ACCEPT_PROJECT_ID)); + stubConfigs(config(NO_ACCEPT_PROJECT_ID, NO_ACCEPT_JSON)); + + assertTrue(service.submit(editForm(LOADING_ID, NO_ACCEPT_WAYBILL_ID))); + + assertEquals( + Map.of(NO_ACCEPT_WAYBILL_ID, "running"), + statusByWaybillId(), + "留下的票按自己项目推导,被移除的票不出现在状态写入里"); + CapturedUpdate releaseUpdate = waybillUpdates().stream() + .filter(update -> !update.columns().contains("driver_name")) + .findFirst() + .orElseThrow(); + assertEquals(List.of(NEED_ACCEPT_WAYBILL_ID), releaseUpdate.whereIds(), "被移除的票退回未配载"); + assertEquals("pending", releaseUpdate.setValue("business_status")); + assertEquals(List.of(NO_ACCEPT_WAYBILL_ID), acceptClearUpdates().get(0).whereIds()); + } + + @Test + @DisplayName("回归:开始配载仍整批置进行中,不按项目逐张推导") + void startStillWritesRunningForWholeBatch() { + stubLoadingRecord(loadingRecord("pending", NEED_ACCEPT_WAYBILL_ID, NO_ACCEPT_WAYBILL_ID)); + + assertTrue(service.start(LOADING_ID)); + + CapturedUpdate statusUpdate = statusSyncUpdates().get(0); + assertEquals("running", statusUpdate.setValue("business_status")); + assertEquals( + Set.of(NEED_ACCEPT_WAYBILL_ID, NO_ACCEPT_WAYBILL_ID), + Set.copyOf(statusUpdate.whereIds())); + verify(processConfigService, never()).list(ArgumentMatchers.>any()); + } + + @Test + @DisplayName("回归:完成配载仍整批置已完成,结算触发照旧") + void completeStillWritesCompletedForWholeBatch() { + stubLoadingRecord(loadingRecord("running", NEED_ACCEPT_WAYBILL_ID, NO_ACCEPT_WAYBILL_ID)); + + assertTrue(service.complete(LOADING_ID)); + + CapturedUpdate statusUpdate = statusSyncUpdates().get(0); + assertEquals("completed", statusUpdate.setValue("business_status")); + assertEquals( + Set.of(NEED_ACCEPT_WAYBILL_ID, NO_ACCEPT_WAYBILL_ID), + Set.copyOf(statusUpdate.whereIds())); + verify(receivablePayableDetailService).generateForCompletedLoading( + List.of(NEED_ACCEPT_WAYBILL_ID, NO_ACCEPT_WAYBILL_ID), null, LOADING_NO); + verify(processConfigService, never()).list(ArgumentMatchers.>any()); + } + + // ------------------------------------------------------------ 夹具 + + private LoadingManage loadingForm(Long... waybillIds) { + return fillForm(new LoadingManage(), List.of(waybillIds)); + } + + private LoadingManage editForm(Long loadingId, Long... waybillIds) { + LoadingManage form = new LoadingManage(); + form.setId(loadingId); + return fillForm(form, List.of(waybillIds)); + } + + private LoadingManage fillForm(LoadingManage form, List ids) { + form.setLoadingNo(LOADING_NO); + form.setDeptId(DEPT_ID_LONG); + form.setDeptName("测试组织"); + form.setWaybillIdsJson(JsonUtil.toJson(ids)); + form.setLoadingSubNos(ids.stream().map(id -> "YD-" + id).reduce((left, right) -> left + "," + right).orElse("")); + form.setTransportType("公路运输"); + form.setCarrierType("自运"); + form.setVehicleNo("桂A12345"); + form.setDriverName("张三"); + form.setDriverPhone(DRIVER_PHONE); + form.setDepartureAddress("南宁"); + form.setArrivalAddress("广州"); + return form; + } + + private static LoadingManage loadingRecord(String businessStatus, Long... waybillIds) { + LoadingManage record = new LoadingManage(); + record.setId(LOADING_ID); + record.setLoadingNo(LOADING_NO); + record.setDeptId(DEPT_ID_LONG); + record.setDeptName("测试组织"); + record.setCarrierType("自运"); + record.setBusinessStatus(businessStatus); + record.setWaybillIdsJson(JsonUtil.toJson(List.of(waybillIds))); + return record; + } + + private void stubLoadingRecord(LoadingManage record) { + doReturn(record).when(service).getById(LOADING_ID); + } + + /** 重新派单只对「有被拒子单」的配载单开放 */ + private void stubRejectedWaybillExists() { + when(waybillMapper.selectCount(ArgumentMatchers.>any())).thenReturn(1L); + } + + private static Waybill waybill(Long id, Long projectId) { + return waybill(id, projectId, null); + } + + private static Waybill waybill(Long id, Long projectId, String processJson) { + Waybill waybill = new Waybill(); + waybill.setId(id); + waybill.setWaybillNo("YD-" + id); + waybill.setProjectId(projectId); + waybill.setProcessJson(processJson); + waybill.setDeptId(DEPT_ID_LONG); + // 可配载只放行公路 + 待执行 + 未配载,夹具的票都是这种票 + waybill.setTransportType("公路运输"); + waybill.setBusinessStatus("pending"); + return waybill; + } + + private void stubWaybills(Waybill... waybills) { + when(waybillMapper.selectList(ArgumentMatchers.>any())).thenReturn(List.of(waybills)); + } + + private void stubConfigs(ProcessConfig... configs) { + when(processConfigService.list(ArgumentMatchers.>any())).thenReturn(List.of(configs)); + } + + private static ProcessConfig config(Long projectId, String nodeConfigJson) { + ProcessConfig config = new ProcessConfig(); + config.setProjectIds(String.valueOf(projectId)); + config.setNodeConfigJson(nodeConfigJson); + return config; + } + + /** 提交路径上 mock 掉框架自带的落库与回查,只留业务写入可断言 */ + private static void doReturnTrue(LoadingManageServiceImpl target) { + doReturn(true).when(target).saveOrUpdate(any(LoadingManage.class)); + doReturn(true).when(target).updateById(any(LoadingManage.class)); + } + + /** 配载字段 + 状态那条批量更新;退回未配载的那条只写 loading_no/合同/状态,靠 driver_name 区分 */ + private List statusSyncUpdates() { + return waybillUpdates().stream().filter(update -> update.columns().contains("driver_name")).toList(); + } + + /** 状态写入的 where 只有运单 id,故一条更新对应一张票 */ + private Map statusByWaybillId() { + Map statusByWaybillId = new LinkedHashMap<>(); + statusSyncUpdates().forEach(update -> statusByWaybillId.put( + update.whereIds().get(0), String.valueOf(update.setValue("business_status")))); + return statusByWaybillId; + } + + /** 逐张推导的结果:提交/重新派单在准入阶段就拦下跨项目子单,跨项目推导只能直接测这个函数 */ + private Map deriveBusinessStatuses(Waybill... waybills) throws Exception { + Method method = LoadingManageServiceImpl.class + .getDeclaredMethod("deriveWaybillBusinessStatuses", List.class); + method.setAccessible(true); + @SuppressWarnings("unchecked") + Map statusByWaybillId = (Map) method.invoke(service, List.of(waybills)); + return statusByWaybillId; + } + + private List acceptClearUpdates() { + return waybillUpdates().stream() + .filter(update -> update.columns().contains("driver_accept_status")) + .toList(); + } + + private List waybillUpdates() { + ArgumentCaptor> captor = ArgumentCaptor.forClass(Wrapper.class); + verify(waybillMapper, atLeastOnce()).update(ArgumentMatchers.isNull(), captor.capture()); + return captor.getAllValues().stream().map(CapturedUpdate::of).toList(); + } + + private static void inject(LoadingManageServiceImpl target, String fieldName, Object value) { + try { + Field field = LoadingManageServiceImpl.class.getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } catch (ReflectiveOperationException exception) { + throw new IllegalStateException("注入测试替身失败:" + fieldName, exception); + } + } + + /** 一条被捕获的批量更新:SET 的列 → 值、WHERE 的运单 id */ + private record CapturedUpdate(String sqlSet, String whereSql, Map params, List columns) { + + @SuppressWarnings("unchecked") + static CapturedUpdate of(Wrapper wrapper) { + LambdaUpdateWrapper update = (LambdaUpdateWrapper) wrapper; + String sqlSet = update.getSqlSet(); + // SET 与 WHERE 的参数都是渲染时才进参数表,取值前先把两段都渲染一遍 + String whereSql = update.getTargetSql(); + List columns = Arrays.stream(sqlSet.split(",")) + .map(part -> part.substring(0, part.indexOf('=')).trim()) + .toList(); + return new CapturedUpdate(sqlSet, whereSql, update.getParamNameValuePairs(), columns); + } + + Object setValue(String column) { + for (String part : sqlSet.split(",")) { + int equalsIndex = part.indexOf('='); + if (!column.equals(part.substring(0, equalsIndex).trim())) { + continue; + } + String reference = part.substring(equalsIndex + 1).trim(); + if (!reference.startsWith("#{")) { + return reference; + } + String key = reference.substring(reference.lastIndexOf('.') + 1, reference.length() - 1); + return params.get(key); + } + return null; + } + + List whereIds() { + return params.values().stream().filter(Long.class::isInstance).map(Long.class::cast).toList(); + } + } +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ManageAdminHomeFeedTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ManageAdminHomeFeedTest.java index 1d27f177..3b37513b 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ManageAdminHomeFeedTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ManageAdminHomeFeedTest.java @@ -2,22 +2,27 @@ package org.springblade.transport.service.impl; import com.baomidou.mybatisplus.core.MybatisConfiguration; import com.baomidou.mybatisplus.core.conditions.Wrapper; -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.mockito.ArgumentMatchers; +import org.mockito.MockedStatic; +import org.springblade.core.secure.utils.AuthUtil; import org.springblade.transport.pojo.entity.ExceptionDisposal; import org.springblade.transport.pojo.entity.Waybill; import org.springblade.transport.pojo.vo.AdminTodoItemVO; import org.springblade.transport.service.IDriverService; import org.springblade.transport.service.IDriverWaybillService; import org.springblade.transport.service.IExceptionDisposalService; +import org.springblade.transport.service.ILoadingManageService; import org.springblade.transport.service.IRiskDisposalService; +import org.springblade.transport.service.ITripQueryService; import org.springblade.transport.service.IWaybillService; import java.time.LocalDateTime; @@ -32,6 +37,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -49,10 +55,14 @@ class ManageAdminHomeFeedTest { private static final Long EXCEPTION_ID = 8801L; private static final String REJECTED_WAYBILL_NO = "YD-20261010-0001"; + private static final String REJECTED_LOADING_NO = "PZ-20261017-001"; + private static final String ROLE_ORDINARY = "yewu"; + private static final String DEPT_ID = "1123598813738675201"; private ManageWaybillServiceImpl service; private IWaybillService waybillService; private IExceptionDisposalService exceptionService; + private MockedStatic authUtil; @BeforeAll static void initTableInfo() { @@ -72,11 +82,22 @@ class ManageAdminHomeFeedTest { mock(IDriverService.class), exceptionService, mock(IRiskDisposalService.class), - mock(IDriverWaybillService.class)); + mock(IDriverWaybillService.class), + mock(ITripQueryService.class), + mock(ILoadingManageService.class)); when(exceptionService.list(ArgumentMatchers.>any())) .thenReturn(new ArrayList<>()); when(waybillService.list(ArgumentMatchers.>any())) .thenReturn(new ArrayList<>()); + // 重派待办的库层查询要带可见组织;没有请求上下文时按普通用户 + 本组织给出 + authUtil = mockStatic(AuthUtil.class); + authUtil.when(AuthUtil::getUserRole).thenReturn(ROLE_ORDINARY); + authUtil.when(AuthUtil::getDeptId).thenReturn(DEPT_ID); + } + + @AfterEach + void tearDown() { + authUtil.close(); } @Test @@ -141,6 +162,43 @@ class ManageAdminHomeFeedTest { "拒单发生在昨日 22:00,异常上报于 26 小时前(更早),故重派条目在前"); } + @Test + @DisplayName("整单拒绝的多张子单按运次收成一条:同一配载单只出一条重新派单待办") + void rejectedLoadingChildrenCollapseToOneTodo() { + // 库层按运次分组后,一张配载单无论几张子单被拒都只回一行(分组键见下面的 SQL 断言) + Waybill grouped = rejectedWaybill(7210L, "黄大富", "车辆不符"); + grouped.setLoadingNo(REJECTED_LOADING_NO); + when(waybillService.list(ArgumentMatchers.>any())).thenReturn(List.of(grouped)); + + List feed = service.buildAdminFeed(); + + assertEquals(1, feed.size(), "整单拒绝只应出一条待办"); + assertEquals(REJECTED_LOADING_NO, feed.get(0).getWaybillNo(), "配载运次的待办展示配载单号"); + assertEquals(Long.valueOf(7210L), feed.get(0).getId(), "跳转参数仍是运单主键:重新派单按运单写入"); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(Wrapper.class); + verify(waybillService).list(captor.capture()); + QueryWrapper wrapper = (QueryWrapper) captor.getValue(); + String sql = wrapper.getTargetSql(); + assertTrue(sql.contains("GROUP BY"), "去重要落在库层分组上,否则多张被拒子单会各出一条:" + sql); + assertTrue(sql.contains("CONCAT('W', id)"), + "分组键与运次读服务同源:有配载按配载单号、未配载每张运单自成一组:" + sql); + } + + @Test + @DisplayName("两张未配载运单被拒仍是两条待办:未配载运单各自就是一趟运次") + void twoLoneRejectedWaybillsStayTwoTodos() { + when(waybillService.list(ArgumentMatchers.>any())).thenReturn(List.of( + rejectedWaybill(7221L, "黄大富", null), + rejectedWaybill(7222L, "黄宇富", null))); + + List feed = service.buildAdminFeed(); + + assertEquals(2, feed.size(), "未配载运单不合并"); + assertEquals(List.of(7221L, 7222L), + feed.stream().map(AdminTodoItemVO::getId).sorted().collect(Collectors.toList())); + } + @Test @DisplayName("判据只认「已拒单 + 未取消未完成」,已取消已完成即便被拒过也不参与") void reassignQueryExcludesTerminalStatus() { @@ -148,7 +206,7 @@ class ManageAdminHomeFeedTest { ArgumentCaptor> captor = ArgumentCaptor.forClass(Wrapper.class); verify(waybillService).list(captor.capture()); - LambdaQueryWrapper wrapper = (LambdaQueryWrapper) captor.getValue(); + QueryWrapper wrapper = (QueryWrapper) captor.getValue(); String sql = wrapper.getTargetSql(); assertTrue(sql.contains("driver_accept_status"), "重派判据要落在接单状态上:" + sql); diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ManageTripListDetailTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ManageTripListDetailTest.java new file mode 100644 index 00000000..b5771621 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ManageTripListDetailTest.java @@ -0,0 +1,338 @@ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.ArgumentMatchers; +import org.mockito.MockedStatic; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.core.secure.utils.AuthUtil; +import org.springblade.transport.pojo.entity.ExceptionDisposal; +import org.springblade.transport.pojo.entity.LoadingManage; +import org.springblade.transport.pojo.entity.Waybill; +import org.springblade.transport.pojo.vo.AdminWaybillCardVO; +import org.springblade.transport.pojo.vo.AdminWaybillDetailVO; +import org.springblade.transport.pojo.vo.TripQueryVO; +import org.springblade.transport.pojo.vo.TripStationVO; +import org.springblade.transport.pojo.vo.TripVO; +import org.springblade.transport.service.IDriverService; +import org.springblade.transport.service.IDriverWaybillService; +import org.springblade.transport.service.IExceptionDisposalService; +import org.springblade.transport.service.ILoadingManageService; +import org.springblade.transport.service.IRiskDisposalService; +import org.springblade.transport.service.ITripQueryService; +import org.springblade.transport.service.IWaybillService; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 调度端列表与详情的运次口径:一卡一运次、整趟车的站点与货物。 + *

+ * 卡片上的状态、站点与货物汇总都出自共享运次读服务,本类只做字段裁剪;这里钉住的是 + * 「运次字段有没有被摆到卡片与详情上」,以及取不到运次时按运单主键与配载单主键的回落路径。 + */ +class ManageTripListDetailTest { + + private static final String LOADING_NO = "PZ-20261017-001"; + private static final Long LOADING_ID = 2111350784070275074L; + private static final Long CHILD_A = 2111350784070275123L; + private static final Long CHILD_B = 2102780956493299713L; + private static final String ROLE_ORDINARY = "yewu"; + private static final String DEPT_ID = "1123598813738675201"; + + private ManageWaybillServiceImpl service; + private IWaybillService waybillService; + private IExceptionDisposalService exceptionDisposalService; + private ITripQueryService tripQueryService; + private ILoadingManageService loadingManageService; + private MockedStatic authUtil; + private IDriverWaybillService driverWaybillService; + + private static java.time.LocalDate toLocalDate(Date date) { + return java.time.Instant.ofEpochMilli(date.getTime()).atZone(java.time.ZoneId.systemDefault()).toLocalDate(); + } + + @BeforeAll + static void initTableInfo() { + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, Waybill.class); + TableInfoHelper.initTableInfo(assistant, ExceptionDisposal.class); + TableInfoHelper.initTableInfo(assistant, LoadingManage.class); + } + + @BeforeEach + void setUp() { + waybillService = mock(IWaybillService.class); + exceptionDisposalService = mock(IExceptionDisposalService.class); + tripQueryService = mock(ITripQueryService.class); + loadingManageService = mock(ILoadingManageService.class); + driverWaybillService = mock(IDriverWaybillService.class); + service = new ManageWaybillServiceImpl( + waybillService, + mock(IDriverService.class), + exceptionDisposalService, + mock(IRiskDisposalService.class), + driverWaybillService, + tripQueryService, + loadingManageService); + when(exceptionDisposalService.list(ArgumentMatchers.>any())) + .thenReturn(new ArrayList<>()); + when(waybillService.list(ArgumentMatchers.>any())).thenReturn(new ArrayList<>()); + // 列表与待处理都要先算出可见组织;没有请求上下文时按普通用户 + 本组织给出 + authUtil = mockStatic(AuthUtil.class); + authUtil.when(AuthUtil::getUserRole).thenReturn(ROLE_ORDINARY); + authUtil.when(AuthUtil::getDeptId).thenReturn(DEPT_ID); + } + + @AfterEach + void tearDown() { + authUtil.close(); + } + + @Test + @DisplayName("列表一卡一运次:单号、组织类型、起经终、站点数、货物汇总都取运次字段") + void pageListShowsOneCardPerTrip() { + when(tripQueryService.page(any())).thenReturn(pageOf(1, trip())); + + IPage page = service.pageList(1, 10, null, null, null, null, null, null); + + assertEquals(1, page.getTotal()); + assertEquals(1, page.getRecords().size(), "四张子单只出一条卡"); + AdminWaybillCardVO card = page.getRecords().get(0); + assertEquals(String.valueOf(LOADING_ID), card.getTripId()); + assertEquals(LOADING_NO, card.getLoadingNo()); + assertEquals(LOADING_NO, card.getTripNo()); + assertEquals("load", card.getTransportType(), "有配载单即为公路配载"); + assertEquals(2, card.getStatus()); + assertEquals(4, card.getWaybillCount()); + assertEquals(3, card.getStationCount()); + assertEquals("南宁吴圩机场", card.getFromName()); + assertEquals("新福兴硅科技有限公司", card.getViaName()); + assertEquals("宾阳", card.getToName()); + assertEquals("石英砂A 131.84吨、技嘉4070显卡 30吨", card.getCargoSummary()); + assertEquals("161.84吨", card.getTotalWeight()); + assertEquals("2026-09-23 23:23:42", card.getCreateTime(), "卡片时间取运次排序键"); + assertEquals(Long.valueOf(CHILD_A), card.getId(), "旧 id 仍是运单主键,打卡订正等按它进入"); + assertEquals("YD-20260825-0006", card.getWaybillNo()); + assertFalse(card.getNeedReassign()); + } + + @Test + @DisplayName("列表筛选走同一次共享取数:组织类型、异常与创建时间随条件一起交给运次读服务") + void pageListFiltersGoThroughSharedQuery() { + when(tripQueryService.page(any())).thenReturn(pageOf(1, trip())); + + service.pageList(2, 20, "PZ-2026", "0", "normal", "load", "2026-09-01", "2026-09-30"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(TripQueryVO.class); + verify(tripQueryService, times(1)).page(captor.capture()); + TripQueryVO query = captor.getValue(); + assertEquals("load", query.getTransportOrgType()); + assertEquals("normal", query.getException()); + assertEquals(0, query.getStatus()); + assertEquals("PZ-2026", query.getKeyword()); + assertEquals(2, query.getCurrent()); + assertEquals(20, query.getSize()); + assertEquals(java.time.LocalDate.of(2026, 9, 1), toLocalDate(query.getCreatedFrom())); + assertEquals(java.time.LocalDate.of(2026, 9, 30), toLocalDate(query.getCreatedTo())); + verify(waybillService, never()).page(any(), any()); + verify(tripQueryService, never()).detail(any()); + } + + @Test + @DisplayName("待处理列表:两档与「未拒单」交给同一次共享取数;待重新派单仍走子单被拒那条路") + void pendingListUsesSharedQuery() { + when(tripQueryService.page(any())).thenReturn(pageOf(0)); + when(waybillService.page(any(), any())).thenAnswer(invocation -> invocation.getArgument(0)); + + service.pendingList(1, 10, null, false); + + ArgumentCaptor captor = ArgumentCaptor.forClass(TripQueryVO.class); + verify(tripQueryService, times(1)).page(captor.capture()); + assertEquals(List.of(0, 1), captor.getValue().getStatuses(), "待处理只要待接单与进行中两档"); + assertTrue(captor.getValue().isExcludeRejectedTrips(), "未拒单也交给共享取数"); + + service.pendingList(1, 10, null, true); + + verify(tripQueryService, times(1)).page(any()); + verify(waybillService, times(1)).page(any(), any()); + } + + @Test + @DisplayName("详情按运次主键取:配载单号、组织类型、站点 ①②③ 与每站货物都带运单号") + void detailShowsWholeTripStations() { + when(tripQueryService.detail(any())).thenReturn(trip()); + + AdminWaybillDetailVO detail = service.detail(String.valueOf(LOADING_ID)); + + assertEquals(String.valueOf(LOADING_ID), detail.getTripId()); + assertEquals(LOADING_NO, detail.getLoadingNo()); + assertEquals(LOADING_NO, detail.getTripNo()); + assertEquals("load", detail.getTransportOrgType()); + assertEquals(2, detail.getStatus()); + assertEquals(3, detail.getStationCount()); + assertEquals(3, detail.getStations().size()); + assertEquals(3, detail.getRoutePoints().size(), "点位列与站点同源"); + assertEquals("161.84吨", detail.getTotalWeight()); + + TripStationVO first = detail.getStations().get(0); + assertEquals(1, first.getIndex()); + assertEquals("装货", first.getOperationTypeLabel()); + assertEquals("2102780956493299713", first.getCargos().get(0).getWaybillId(), "货物条目带所属子运单主键"); + assertEquals("YD-20260923-0008", first.getCargos().get(0).getWaybillNo()); + + TripStationVO second = detail.getStations().get(1); + assertEquals("装卸", second.getOperationTypeLabel()); + assertEquals(2, second.getCargos().size()); + assertEquals("YD-20260923-0008", second.getCargos().get(0).getWaybillNo(), "同一站可卸一张、装另一张"); + assertEquals("2131150784070275011", second.getCargos().get(1).getWaybillId()); + } + + @Test + @DisplayName("取不到运次时按运单主键与配载单主键回落:整单被拒的配载单仍能打开并重新派单") + void detailFallsBackWhenTripIsNotReachable() { + when(tripQueryService.detail(any())).thenThrow(new ServiceException("运次不存在或无权操作")); + when(waybillService.getById(CHILD_A)).thenReturn(null); + when(loadingManageService.getById(LOADING_ID)).thenReturn(loading()); + + AdminWaybillDetailVO detail = service.detail(String.valueOf(LOADING_ID)); + + assertEquals(String.valueOf(LOADING_ID), detail.getTripId()); + assertEquals(LOADING_NO, detail.getLoadingNo()); + assertEquals("load", detail.getTransportOrgType()); + assertTrue(detail.getNeedReassign(), "整单被拒时按配载单回落,标记待重新派单"); + assertEquals(3, detail.getStationCount(), "子单为空时站点退回配载单自己的发货/途经/到货"); + assertEquals("南宁吴圩机场", detail.getFromName()); + assertEquals("宾阳", detail.getToName()); + } + + @Test + @DisplayName("运次主键不是数字时直接拒绝,不去查库") + void detailRefusesUnparsableTripId() { + assertThrows(ServiceException.class, () -> service.detail("not-a-number")); + assertThrows(ServiceException.class, () -> service.detail(null)); + } + + // ------------------------------------------------------------ 夹具 + + private Page pageOf(long total, TripVO... trips) { + Page page = new Page<>(1, 10, total); + page.setRecords(List.of(trips)); + return page; + } + + /** + * 一趟 4 张子单的配载运次:站点与货物按本库真实样本同形(三站,第二站装卸) + */ + private TripVO trip() { + TripVO trip = new TripVO(); + trip.setId(String.valueOf(LOADING_ID)); + trip.setTripNo(LOADING_NO); + trip.setLoadingNo(LOADING_NO); + trip.setTransportOrgType("load"); + trip.setStatus(2); + trip.setRequireAccept(false); + trip.setWaybillCount(4); + trip.setDriverName("黄宇富"); + trip.setVehicleNo("桂A32523"); + trip.setDepartureName("南宁吴圩机场"); + trip.setTransitName("新福兴硅科技有限公司"); + trip.setArrivalName("宾阳"); + trip.setStationCount(3); + trip.setCargoSummary("石英砂A 131.84吨、技嘉4070显卡 30吨"); + trip.setTotalQuantity(new BigDecimal("161.84")); + trip.setQuantityUnit("吨"); + trip.setSortTime(at(1790177022000L)); + trip.setEstimatedStartDate(LocalDate.of(2026, 8, 25)); + trip.setEstimatedEndDate(LocalDate.of(2026, 9, 23)); + trip.setWaybills(List.of( + tripWaybill(CHILD_A, "YD-20260825-0006"), + tripWaybill(2111350784070275124L, "YD-20260825-0007"), + tripWaybill(2111350784070275125L, "YD-20260825-0008"), + tripWaybill(CHILD_B, "YD-20260923-0008"))); + trip.setStations(List.of( + station(1, "南宁吴圩机场", "load", "装货", cargo("2102780956493299713", "YD-20260923-0008", "技嘉4070显卡", "30")), + station(2, "武宣", "load_unload", "装卸", + cargo("2102780956493299713", "YD-20260923-0008", "技嘉4070显卡", "30"), + cargo("2131150784070275011", "YD-20260825-0006", "石英砂A", "42.10")), + station(3, "宾阳", "unload", "卸货", cargo("2131150784070275011", "YD-20260825-0006", "石英砂A", "42.10")))); + return trip; + } + + private TripVO.TripWaybillVO tripWaybill(Long id, String waybillNo) { + TripVO.TripWaybillVO child = new TripVO.TripWaybillVO(); + child.setId(String.valueOf(id)); + child.setWaybillNo(waybillNo); + child.setBusinessStatus("running"); + return child; + } + + private TripStationVO station(int index, String name, String operationType, String label, + TripStationVO.TripCargoVO... cargos) { + TripStationVO station = new TripStationVO(); + station.setIndex(index); + station.setName(name); + station.setAddress(name); + station.setOperationType(operationType); + station.setOperationTypeLabel(label); + station.setTags(new ArrayList<>()); + station.setCargos(List.of(cargos)); + return station; + } + + private TripStationVO.TripCargoVO cargo(String waybillId, String waybillNo, String cargoName, String quantity) { + TripStationVO.TripCargoVO cargo = new TripStationVO.TripCargoVO(); + cargo.setWaybillId(waybillId); + cargo.setWaybillNo(waybillNo); + cargo.setCargoName(cargoName); + cargo.setQuantity(new BigDecimal(quantity)); + cargo.setQuantityUnit("吨"); + return cargo; + } + + private LoadingManage loading() { + LoadingManage loading = new LoadingManage(); + loading.setId(LOADING_ID); + loading.setLoadingNo(LOADING_NO); + loading.setDriverName("黄宇富"); + loading.setDriverPhone("17587421208"); + loading.setVehicleNo("桂A32523"); + loading.setDepartureAddress("南宁吴圩机场"); + loading.setTransitAddress("武宣"); + loading.setArrivalAddress("宾阳"); + loading.setCargoName("石英砂A"); + return loading; + } + + private Date at(long millis) { + return new Date(millis); + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ManageWaybillOrgScopeTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ManageWaybillOrgScopeTest.java new file mode 100644 index 00000000..95346246 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ManageWaybillOrgScopeTest.java @@ -0,0 +1,226 @@ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.ArgumentMatchers; +import org.mockito.MockedStatic; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.core.secure.utils.AuthUtil; +import org.springblade.transport.pojo.entity.ExceptionDisposal; +import org.springblade.transport.pojo.entity.Waybill; +import org.springblade.transport.pojo.vo.TripQueryVO; +import org.springblade.transport.pojo.vo.TripTabCountsVO; +import org.springblade.transport.pojo.vo.TripVO; +import org.springblade.transport.service.IDriverService; +import org.springblade.transport.service.IDriverWaybillService; +import org.springblade.transport.service.IExceptionDisposalService; +import org.springblade.transport.service.ILoadingManageService; +import org.springblade.transport.service.IRiskDisposalService; +import org.springblade.transport.service.ITripQueryService; +import org.springblade.transport.service.IWaybillService; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 调度端的组织可见范围:普通用户只出自己所属组织,超级管理员不过滤。 + *

+ * 三个取数入口必须同一条规则——列表、待处理、首页三档统计各走一次查询,漏一处就会出现 + * 「统计说 46 张、列表只出 18 张」这种对不上的数;待重新派单另走库层分组查询,也要单独带上。 + *

+ * 所属组织为空时不拦截:小程序调度端不要求账号必须挂组织。 + */ +class ManageWaybillOrgScopeTest { + + private static final String DEPT_ID = "1123598813738675202"; + private static final Long DEPT = 1123598813738675202L; + private static final String ROLE_ORDINARY = "yewu"; + private static final String ROLE_ADMINISTRATOR = "administrator"; + + private ManageWaybillServiceImpl service; + private IWaybillService waybillService; + private ITripQueryService tripQueryService; + private MockedStatic authUtil; + + @BeforeAll + static void initTableInfo() { + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, Waybill.class); + TableInfoHelper.initTableInfo(assistant, ExceptionDisposal.class); + } + + @BeforeEach + void setUp() { + waybillService = mock(IWaybillService.class); + tripQueryService = mock(ITripQueryService.class); + IExceptionDisposalService exceptionDisposalService = mock(IExceptionDisposalService.class); + service = new ManageWaybillServiceImpl( + waybillService, + mock(IDriverService.class), + exceptionDisposalService, + mock(IRiskDisposalService.class), + mock(IDriverWaybillService.class), + tripQueryService, + mock(ILoadingManageService.class)); + when(exceptionDisposalService.list(ArgumentMatchers.>any())) + .thenReturn(new ArrayList<>()); + when(waybillService.list(ArgumentMatchers.>any())).thenReturn(new ArrayList<>()); + // 待重派那次分页查询回它收到的 Page(总数从入参上读,回 null 会空指针) + when(waybillService.page(any(), any())).thenAnswer(invocation -> invocation.getArgument(0)); + // 运次读服务的两次取数:本类只看传给它的条件,行集合给空页即可 + when(tripQueryService.page(any())).thenReturn(emptyTripPage()); + when(tripQueryService.tabCounts(any())).thenReturn(new TripTabCountsVO()); + authUtil = mockStatic(AuthUtil.class); + asOrdinaryUser(); + } + + @AfterEach + void tearDown() { + authUtil.close(); + } + + @Test + @DisplayName("普通用户:列表把当前组织交给运次读服务,行级收口落在库层") + void pageListScopesCurrentDept() { + service.pageList(1, 10, null, null, null, null, null, null); + + TripQueryVO query = capturedPageQuery(); + assertEquals(List.of(DEPT), query.getDeptIds(), "列表只出本组织"); + assertTrue(query.isAllDrivers(), "组织收口不影响司机收口口径"); + } + + @Test + @DisplayName("普通用户:待处理列表与列表同一条组织规则") + void pendingListScopesCurrentDept() { + service.pendingList(1, 10, null, false); + + assertEquals(List.of(DEPT), capturedPageQuery().getDeptIds()); + } + + @Test + @DisplayName("普通用户:首页三档统计与列表同一口径,否则统计与列表的数对不上") + void statsScopesCurrentDept() { + service.stats(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(TripQueryVO.class); + verify(tripQueryService, atLeastOnce()).tabCounts(captor.capture()); + assertEquals(List.of(DEPT), captor.getValue().getDeptIds()); + } + + @Test + @DisplayName("超级管理员:三处都不带组织,看全租户") + void administratorSeesEveryDept() { + asAdministrator(); + + service.pageList(1, 10, null, null, null, null, null, null); + ArgumentCaptor pageCaptor = ArgumentCaptor.forClass(TripQueryVO.class); + verify(tripQueryService).page(pageCaptor.capture()); + assertTrue(pageCaptor.getValue().getDeptIds().isEmpty(), "超管不过滤组织"); + + service.stats(); + ArgumentCaptor statsCaptor = ArgumentCaptor.forClass(TripQueryVO.class); + verify(tripQueryService, atLeastOnce()).tabCounts(statsCaptor.capture()); + assertTrue(statsCaptor.getValue().getDeptIds().isEmpty(), "超管的统计也不过过滤组织"); + } + + @Test + @DisplayName("待重新派单:库层分组查询也带组织,否则重派列表与角标会出别的组织的单") + void reassignQueryScopesCurrentDept() { + service.pendingList(1, 10, null, true); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(Wrapper.class); + verify(waybillService).page(any(), captor.capture()); + QueryWrapper wrapper = (QueryWrapper) captor.getValue(); + String sql = String.valueOf(wrapper.getTargetSql()); + assertTrue(sql.contains("dept_id"), "重派查询要带上组织:" + sql); + Set values = new HashSet<>(wrapper.getParamNameValuePairs().values()); + assertTrue(values.contains(DEPT), "组织值要作为参数下发:" + values); + } + + @Test + @DisplayName("待重新派单:超管的库层分组查询不带组织") + void reassignQuerySkipsScopeForAdministrator() { + asAdministrator(); + + service.pendingList(1, 10, null, true); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(Wrapper.class); + verify(waybillService).page(any(), captor.capture()); + String sql = String.valueOf(captor.getValue().getTargetSql()); + assertFalse(sql.contains("dept_id"), "超管不按组织收口:" + sql); + } + + @Test + @DisplayName("详情不按组织收口:按主键进入,不带组织条件") + void detailIsNotScoped() { + when(tripQueryService.detail(any())).thenThrow(new ServiceException("运次不存在或无权操作")); + when(waybillService.getById(any())).thenReturn(null); + assertThrows(ServiceException.class, () -> service.detail("7201")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(TripQueryVO.class); + verify(tripQueryService).detail(captor.capture()); + assertTrue(captor.getValue().getDeptIds() == null || captor.getValue().getDeptIds().isEmpty(), + "详情与后台同口径:不按组织收口"); + } + + @Test + @DisplayName("组织取不到时不拦截,小程序接口不按组织收口") + void blankDeptDoesNotFail() { + authUtil.when(AuthUtil::getDeptId).thenReturn(""); + + service.pageList(1, 10, null, null, null, null, null, null); + + assertTrue(capturedPageQuery().getDeptIds().isEmpty(), "没有组织时不报错、不按组织过滤"); + } + + // ------------------------------------------------------------ 夹具 + + private IPage emptyTripPage() { + Page page = new Page<>(1, 10, 0); + page.setRecords(List.of()); + return page; + } + + private TripQueryVO capturedPageQuery() { + ArgumentCaptor captor = ArgumentCaptor.forClass(TripQueryVO.class); + verify(tripQueryService).page(captor.capture()); + return captor.getValue(); + } + + private void asOrdinaryUser() { + authUtil.when(AuthUtil::getUserRole).thenReturn(ROLE_ORDINARY); + authUtil.when(AuthUtil::getDeptId).thenReturn(DEPT_ID); + } + + private void asAdministrator() { + authUtil.when(AuthUtil::getUserRole).thenReturn(ROLE_ADMINISTRATOR); + authUtil.when(AuthUtil::getDeptId).thenReturn(DEPT_ID); + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/PersonCertRoundTripTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/PersonCertRoundTripTest.java index e4639e36..fa1671b7 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/PersonCertRoundTripTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/PersonCertRoundTripTest.java @@ -18,6 +18,7 @@ import org.springblade.transport.service.IDriverService; import org.springblade.transport.service.ITransportCertRecordService; import org.springblade.transport.service.ITransportVehicleService; +import java.time.LocalDate; import java.util.HashMap; import java.util.Map; @@ -239,6 +240,15 @@ class PersonCertRoundTripTest { if (patch.getIdCardNo() != null) { target.setIdCardNo(patch.getIdCardNo()); } + if (patch.getBirthday() != null) { + target.setBirthday(patch.getBirthday()); + } + if (patch.getAddressRegion() != null) { + target.setAddressRegion(patch.getAddressRegion()); + } + if (patch.getAddress() != null) { + target.setAddress(patch.getAddress()); + } if (patch.getIdCardFront() != null) { target.setIdCardFront(patch.getIdCardFront()); } @@ -287,6 +297,9 @@ class PersonCertRoundTripTest { @DisplayName("审核通过:证件内容写回司机档案,档案与生效值一致") void approveWritesCertContentBackToDriver() { PersonCertSubmitRequest request = personCert("张三", "110101199001011234"); + request.setBirthday(LocalDate.of(1990, 1, 1)); + request.setAddressRegion("广西壮族自治区柳州市柳北区"); + request.setAddress("跃进路88号"); request.setHeadPhoto("http://example.com/head.jpg"); request.setDriverClass("A2"); request.setDriverFileNo("450100769875"); @@ -302,6 +315,9 @@ class PersonCertRoundTripTest { // 档案里必须是这次提交的内容:只切指针、不写内容的话, // 认证状态显示「已通过」而档案仍是旧值,管理端与司机端会各看各的 + assertEquals(LocalDate.of(1990, 1, 1), driver.getBirthday()); + assertEquals("广西壮族自治区柳州市柳北区", driver.getAddressRegion()); + assertEquals("跃进路88号", driver.getAddress()); assertEquals("http://example.com/head.jpg", driver.getHeadPhoto()); assertEquals("A2", driver.getDrivingType()); assertEquals("450100769875", driver.getDrivingLicenseNo()); @@ -313,6 +329,25 @@ class PersonCertRoundTripTest { assertEquals("http://example.com/q-back.jpg", driver.getQualificationBack()); } + @Test + @DisplayName("审核通过:住址留空不清空管理端已填的住址") + void approveKeepsAddressWhenSnapshotBlank() { + driver.setAddressRegion("广西壮族自治区柳州市柳北区"); + driver.setAddress("跃进路88号"); + + PersonCertSubmitRequest request = personCert("张三", "110101199001011234"); + request.setAddressRegion(" "); + request.setAddress(""); + Long recordId = driverCertService.submitPersonCert(request); + certAuditService.audit(recordId, true, null); + + // 框架的 not_null 更新策略只跳过 null:写空串会把管理端填过的住址清掉 + assertEquals("广西壮族自治区柳州市柳北区", driver.getAddressRegion(), + "区划是选填项,没选就保持档案原值"); + assertEquals("跃进路88号", driver.getAddress(), + "详细地址是选填项,没填就保持档案原值"); + } + @Test @DisplayName("审核通过:姓名与身份证号跟着认证资料走,档案与生效值不再各说各话") void approveOverwritesIdentity() { diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/TripAggregationTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/TripAggregationTest.java new file mode 100644 index 00000000..a5375241 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/TripAggregationTest.java @@ -0,0 +1,204 @@ +package org.springblade.transport.service.impl; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springblade.transport.pojo.entity.Waybill; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 运次四档状态、接单需求合并与运次键口径。 + *

+ * 四档只在这一处推导演算,Tab 过滤与卡片状态都用它:这里断言的就是分档与卡片共用的那份口径。 + */ +class TripAggregationTest { + + private static final String ACCEPT_YES = "[{\"key\":\"accept\",\"name\":\"接单\",\"enabled\":true,\"confirmMode\":\"yes\"}]"; + private static final String ACCEPT_YES_DISABLED = "[{\"key\":\"accept\",\"name\":\"接单\",\"enabled\":false,\"confirmMode\":\"yes\"}]"; + private static final String ACCEPT_NO = "[{\"key\":\"accept\",\"name\":\"接单\",\"enabled\":true,\"confirmMode\":\"no_confirm_accept\"}]"; + + @Test + @DisplayName("子单全部完成 → 已完成") + void allCompleted() { + List children = List.of(waybill("completed", null, null), waybill("completed", "accepted", null)); + + assertEquals("completed", TripAggregation.statusOf(children, false, false)); + assertEquals(2, TripAggregation.appStatus(TripAggregation.statusOf(children, false, false))); + } + + @Test + @DisplayName("子单全部取消 → 已取消") + void allCancelled() { + List children = List.of(waybill("cancelled", null, null), waybill("cancelled", null, null)); + + String status = TripAggregation.statusOf(children, false, false); + + assertEquals("cancelled", status); + assertEquals(3, TripAggregation.appStatus(status)); + } + + @Test + @DisplayName("子单全是终态且含已完成 → 已完成(不看取消的那张)") + void mixedTerminalKeepsCompleted() { + List children = List.of(waybill("completed", null, null), waybill("cancelled", null, null)); + + assertEquals("completed", TripAggregation.statusOf(children, false, false)); + } + + @Test + @DisplayName("需接单且未接单 → 待接单") + void requireAcceptWithoutAcceptance() { + List children = List.of(waybill("pending", "pending", ACCEPT_YES)); + + String status = TripAggregation.statusOf(children, true, false); + + assertEquals("pending", status); + assertEquals(0, TripAggregation.appStatus(status)); + } + + @Test + @DisplayName("不需接单 → 运输中") + void withoutAcceptRequirement() { + List children = List.of(waybill("pending", null, ACCEPT_NO)); + + String status = TripAggregation.statusOf(children, false, false); + + assertEquals("running", status); + assertEquals(1, TripAggregation.appStatus(status)); + } + + @Test + @DisplayName("需接单且已接单 → 运输中") + void requireAcceptAccepted() { + List children = List.of(waybill("pending", "accepted", ACCEPT_YES)); + + assertEquals("running", TripAggregation.statusOf(children, true, true)); + } + + @Test + @DisplayName("子单还有没走完的且需接单 → 待接单") + void pendingChildKeepsTripPending() { + List children = List.of(waybill("running", null, ACCEPT_YES), waybill("dispatching", null, null)); + + assertEquals("pending", TripAggregation.statusOf(children, true, false)); + } + + @Test + @DisplayName("子单集合为空不抛异常,状态无可判定为空") + void emptyChildrenDoNotThrow() { + List children = List.of(); + + assertNull(TripAggregation.statusOf(children, true, false)); + assertNull(TripAggregation.appStatus(null)); + assertEquals(0, TripAggregation.count(children).total()); + assertNull(TripAggregation.acceptStatus(children)); + assertFalse(TripAggregation.requiresAccept(children)); + } + + @Test + @DisplayName("跨项目运次按 OR 合并接单需求:一张要接单整趟车都要接单") + void crossProjectRequirementOr() { + List children = List.of( + waybill("running", null, null), + waybill("running", null, ACCEPT_YES)); + + assertTrue(TripAggregation.requiresAccept(children)); + assertEquals("pending", TripAggregation.statusOf(children, true, false)); + } + + @Test + @DisplayName("跨项目但都没接单节点 → 整趟车不需要接单") + void crossProjectWithoutAnyRequirement() { + List children = List.of( + waybill("running", null, null), + waybill("running", null, ACCEPT_NO)); + + assertFalse(TripAggregation.requiresAccept(children)); + } + + @Test + @DisplayName("停用的接单节点不算需要接单") + void disabledAcceptNodeIsNotRequirement() { + List children = List.of(waybill("running", null, ACCEPT_YES_DISABLED)); + + assertFalse(TripAggregation.requiresAccept(children)); + } + + @Test + @DisplayName("接单态:任一子单已接单即整单已接单,否则待接单,无从判定为空") + void acceptStatusAggregation() { + assertEquals("accepted", TripAggregation.acceptStatus(List.of( + waybill("running", "pending", ACCEPT_YES), waybill("running", "accepted", ACCEPT_YES)))); + assertEquals("pending", TripAggregation.acceptStatus(List.of( + waybill("running", "pending", ACCEPT_YES), waybill("running", null, ACCEPT_YES)))); + assertNull(TripAggregation.acceptStatus(List.of(waybill("completed", null, null)))); + } + + @Test + @DisplayName("四档都由同一份推导给出:待接单 / 进行中 / 已完成 / 已取消") + void tiersComeFromTheSameDerivation() { + // 需接单且未接单 → 待接单档 + assertEquals(0, tier(List.of(waybill("pending", "pending", ACCEPT_YES)))); + // 需接单且已接单 → 进行中档 + assertEquals(1, tier(List.of(waybill("running", "accepted", ACCEPT_YES)))); + // 子单全完成 → 已完成档 + assertEquals(2, tier(List.of(waybill("completed", "accepted", ACCEPT_YES)))); + // 子单全取消 → 已取消档 + assertEquals(3, tier(List.of(waybill("cancelled", null, ACCEPT_YES)))); + // 子单无从判定(空集合)时不落任何档,不会混进「全部」 + assertNull(tier(List.of())); + } + + @Test + @DisplayName("库层业务状态与过程配置不一致时按推导分档,不按库值") + void tiersFollowDerivationNotStoredStatus() { + // 库里落的是 pending(惰性校正没跑过),但该项目不需要接单 → 推导为进行中,不进待接单档 + assertEquals(1, tier(List.of(waybill("pending", "pending", ACCEPT_NO)))); + // 反过来:库里落的是 running,但项目要接单且没接 → 推导为待接单 + assertEquals(0, tier(List.of(waybill("running", "pending", ACCEPT_YES)))); + } + + @Test + @DisplayName("拼载的多张子单按 OR 合并接单需求:任一要接单且未接单就落在待接单档") + void mixedTripFollowsOrRule() { + assertEquals(0, tier(List.of(waybill("running", null, ACCEPT_NO), waybill("running", "pending", ACCEPT_YES)))); + assertEquals(1, tier(List.of(waybill("running", null, ACCEPT_NO), waybill("running", "accepted", ACCEPT_YES)))); + } + + @Test + @DisplayName("运次键:有配载取配载单号,未配载取 W + 运单主键") + void tripKeyFollowsLoadingNo() { + assertEquals("PZ-20260919-001", TripAggregation.keyOf("PZ-20260919-001", 123L)); + assertEquals("W123", TripAggregation.keyOf(null, 123L)); + assertEquals("W123", TripAggregation.keyOf("", 123L)); + } + + private Integer tier(List children) { + boolean requireAccept = TripAggregation.requiresAccept(children); + boolean accepted = org.springblade.transport.support.WaybillProcessSupport.ACCEPT_ACCEPTED + .equals(TripAggregation.acceptStatus(children)); + return TripAggregation.appStatus(TripAggregation.statusOf(children, requireAccept, accepted)); + } + + @Test + @DisplayName("草稿子单不进运次,已取消子单要进(否则全取消会被算成运输中)") + void childStatusScope() { + assertFalse(TripAggregation.CHILD_STATUSES.contains("draft")); + assertTrue(TripAggregation.CHILD_STATUSES.contains("cancelled")); + assertTrue(TripAggregation.CHILD_STATUSES.containsAll(TripAggregation.TERMINAL_STATUSES)); + } + + private Waybill waybill(String businessStatus, String acceptStatus, String processJson) { + Waybill waybill = new Waybill(); + waybill.setBusinessStatus(businessStatus); + waybill.setDriverAcceptStatus(acceptStatus); + waybill.setProcessJson(processJson); + return waybill; + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/TripQueryServiceImplTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/TripQueryServiceImplTest.java new file mode 100644 index 00000000..8679be08 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/TripQueryServiceImplTest.java @@ -0,0 +1,710 @@ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.ArgumentMatchers; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.transport.pojo.entity.LoadingManage; +import org.springblade.transport.pojo.entity.ProcessConfig; +import org.springblade.transport.pojo.entity.Waybill; +import org.springblade.transport.pojo.vo.TripQueryVO; +import org.springblade.transport.pojo.vo.TripTabCountsVO; +import org.springblade.transport.pojo.vo.TripVO; +import org.springblade.transport.service.ILoadingManageService; +import org.springblade.transport.service.IProcessConfigService; +import org.springblade.transport.service.IWaybillService; + +import java.time.LocalDate; +import java.util.Date; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 运次读服务的取数与聚合输出。 + *

+ * 库层只负责取回运单行:分组、分档、筛选、排序与分页都在服务层,mock 出来的行必须能自己撑起一趟车, + * 不再有「库层已经分好组、回查子单」的第二段。 + */ +class TripQueryServiceImplTest { + + private static final String PHONE = "17587421208"; + private static final String LOADING_NO = "PZ-20261017-001"; + private static final Long LOADING_ID = 2111350784070275123L; + private static final Long WAYBILL_ID = 2110690671695380513L; + private static final String ACCEPT_YES = "[{\"key\":\"accept\",\"name\":\"接单\",\"enabled\":true,\"confirmMode\":\"yes\"}]"; + private static final String ACCEPT_NO = "[{\"key\":\"accept\",\"name\":\"接单\",\"enabled\":true,\"confirmMode\":\"no_confirm_accept\"}]"; + + private IWaybillService waybillService; + private ILoadingManageService loadingManageService; + private IProcessConfigService processConfigService; + private TripQueryServiceImpl service; + + @BeforeAll + static void initTableInfo() { + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, Waybill.class); + TableInfoHelper.initTableInfo(assistant, LoadingManage.class); + } + + @BeforeEach + void setUp() { + waybillService = mock(IWaybillService.class); + loadingManageService = mock(ILoadingManageService.class); + processConfigService = mock(IProcessConfigService.class); + lenient().when(processConfigService.list(anyConfigWrapper())).thenReturn(List.of()); + service = new TripQueryServiceImpl(waybillService, loadingManageService, processConfigService); + lenient().when(waybillService.list(anyWaybillWrapper())).thenReturn(List.of()); + lenient().when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of()); + } + + @Test + @DisplayName("段一在库层只做收口与行级筛选,分组与分档不在 SQL 里") + void rowsAreFetchedWithoutDatabaseGrouping() { + TripQueryVO query = TripQueryVO.forDriver(PHONE); + query.setStatus(1); + query.setCurrent(1); + query.setSize(10); + + service.page(query); + + String sql = capturedRowSql(); + assertFalse(sql.contains("GROUP BY"), "库层不再分组:" + sql); + assertFalse(sql.contains("HAVING"), "库层不再分档:" + sql); + assertFalse(sql.contains("LIMIT"), "分页在服务层,不在 wrapper 里拼:" + sql); + assertFalse(sql.contains("MAX(create_time)"), "排序键在服务层算:" + sql); + assertTrue(sql.contains("blade_loading_manage"), "配载段按配载单的司机收口:" + sql); + assertTrue(sql.contains("lm.driver_phone = ?"), "司机手机号要作为参数下发:" + sql); + assertTrue(sql.contains("REPLACE(IFNULL(driver_phone, ''), ' ', '') = ?"), + "未配载段按运单司机收口,且容忍手机号带空格:" + sql); + assertTrue(sql.contains("business_status IN"), "草稿子单不成运次:" + sql); + assertTrue(sql.contains("driver_accept_status"), "被该司机拒单的子单不进运次:" + sql); + } + + @Test + @DisplayName("段一只 select 用得上的列,大 JSON 列不拖回来") + void onlyNeededColumnsAreSelected() { + service.page(TripQueryVO.forDriver(PHONE)); + + String select = String.valueOf(capturedRowQuery().getSqlSelect()); + assertTrue(select.contains("process_json"), "分档要按过程配置推导,必须取:" + select); + assertTrue(select.contains("create_time"), "排序键要用:" + select); + assertTrue(select.contains("estimated_start_time"), "未配载运次的计划时间取自运单,必须取:" + select); + assertTrue(select.contains("estimated_end_time"), select); + assertFalse(select.contains("route_json"), "运单表上的大 JSON 列不必拖回:" + select); + assertTrue(select.contains("goods_json"), "货物明细只能从 goods_json 拿,必须随行取回:" + select); + assertFalse(select.contains("task_info_json"), select); + assertFalse(select.contains(String.valueOf((Object) null)) && select.contains("*"), "不得整行拖回:" + select); + } + + @Test + @DisplayName("组织收口给了就落在库层运单行上,分档与分页都由缩过的行集合算") + void deptScopeIsAppliedOnRows() { + TripQueryVO query = TripQueryVO.forDriver(PHONE); + query.setDeptIds(List.of(1123598813738675202L, 1123598813738675201L)); + + service.page(query); + + String sql = capturedRowSql(); + assertTrue(sql.contains("dept_id IN"), "组织收口要下发到库层:" + sql); + Set values = new HashSet<>(capturedRowQuery().getParamNameValuePairs().values()); + assertTrue(values.contains(1123598813738675202L) && values.contains(1123598813738675201L), + "组织值按参数下发:" + values); + assertTrue(capturedRowSql().contains("driver_phone"), "组织收口不顶替司机收口:两种收口同时生效"); + } + + @Test + @DisplayName("统计四档时组织收口不能丢:去掉的只有 Tab 条件,其余条件照搬") + void tabCountsKeepsDeptScope() { + TripQueryVO query = TripQueryVO.forAdmin(); + query.setDeptIds(List.of(1123598813738675202L)); + + service.tabCounts(query); + + assertTrue(capturedRowSql().contains("dept_id IN"), + "统计与列表必须是同一份可见范围,否则首页三张卡与列表的数对不上:" + capturedRowSql()); + } + + @Test + @DisplayName("组织收口缺省时不下发组织条件:超管与司机端都按不传处理") + void deptScopeIsOptional() { + service.page(TripQueryVO.forDriver(PHONE)); + + assertFalse(capturedRowSql().contains("dept_id"), "不给组织集合即不过滤:" + capturedRowSql()); + } + + @Test + @DisplayName("Tab 在服务层按推导分档过滤,每张卡的 status 与该档一致") + void tabFilterHappensOnDerivedTier() { + stubRows( + waybill(1L, "YD-1", null, "running", null, at(3000L), ACCEPT_NO), + waybill(2L, "YD-2", null, "completed", "accepted", at(2000L), ACCEPT_NO), + waybill(3L, "YD-3", null, "running", "accepted", at(1000L), ACCEPT_NO)); + + TripQueryVO doing = TripQueryVO.forDriver(PHONE); + doing.setStatus(1); + IPage doingPage = service.page(doing); + + assertEquals(2, doingPage.getTotal(), "只有两趟车在进行中"); + assertEquals(List.of("1", "3"), doingPage.getRecords().stream().map(TripVO::getId).toList(), + "排序键是子单 create_time 最大值,倒序(YD-1 的 3000 > YD-3 的 1000)"); + assertTrue(doingPage.getRecords().stream().allMatch(trip -> Integer.valueOf(1).equals(trip.getStatus())), + "每张卡的 status 都要等于这一档"); + + TripQueryVO done = TripQueryVO.forDriver(PHONE); + done.setStatus(2); + assertEquals(List.of("2"), service.page(done).getRecords().stream().map(TripVO::getId).toList()); + + TripQueryVO all = TripQueryVO.forDriver(PHONE); + all.setStatus(null); + assertEquals(3, service.page(all).getTotal(), "不传 Tab 为全部(不含已取消)"); + } + + @Test + @DisplayName("库里落的状态与过程配置不一致时按推导分档,不按库值") + void tierFollowsDerivationNotStoredStatus() { + // 一张库值 pending 但项目不需要接单(推导为进行中),一张库值 running 但项目要接单且没接(推导为待接单) + stubRows( + waybill(2L, "YD-2", null, "running", null, at(2000L), ACCEPT_YES), + waybill(1L, "YD-1", null, "pending", "pending", at(1000L), ACCEPT_NO)); + + TripQueryVO pending = TripQueryVO.forDriver(PHONE); + pending.setStatus(0); + assertEquals(List.of("2"), service.page(pending).getRecords().stream().map(TripVO::getId).toList(), + "库值 running 但要接单未接 → 落待接单档"); + + TripQueryVO doing = TripQueryVO.forDriver(PHONE); + doing.setStatus(1); + assertEquals(List.of("1"), service.page(doing).getRecords().stream().map(TripVO::getId).toList(), + "库值 pending 但不需要接单 → 落进行中档"); + } + + @Test + @DisplayName("关键字按运次匹配:任一子单或配载单号命中即整趟车命中,不缩小这一趟车的子单集合") + void keywordMatchesWholeTrip() { + stubRowsBySql( + List.of(waybill(9L, "YD-9", LOADING_NO, "running", null, at(1000L), ACCEPT_NO)), + null, + List.of( + waybill(1L, "YD-1", LOADING_NO, "running", null, at(3000L), ACCEPT_YES), + waybill(2L, "YD-2", LOADING_NO, "running", null, at(2000L), ACCEPT_YES))); + when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of(loading(PHONE))); + TripQueryVO query = TripQueryVO.forDriver(PHONE); + query.setKeyword("PZ-20261017"); + + TripVO trip = service.page(query).getRecords().get(0); + + String keywordSql = capturedKeywordSql(); + assertTrue(keywordSql.contains("waybill_no LIKE ?"), "关键字在子单上找:" + keywordSql); + assertTrue(keywordSql.contains("loading_no LIKE ?"), keywordSql); + assertEquals(2, trip.getWaybillCount(), "整趟车的子单都留着,不按命中的那一张缩小"); + assertTrue(capturedRowSql().contains("loading_no IN ("), "命中的运次键换成配载单号下发:" + capturedRowSql()); + } + + @Test + @DisplayName("未配载运单自成一个运次,主键是 19 位运单主键的字符串形式") + void unloadedWaybillIsItsOwnTrip() { + stubRows(waybill(WAYBILL_ID, "YD-20260920-0003", null, "running", null, at(1000L), ACCEPT_NO)); + + IPage page = service.page(TripQueryVO.forDriver(PHONE)); + + assertEquals(1, page.getTotal()); + assertEquals(1, page.getRecords().size()); + TripVO trip = page.getRecords().get(0); + assertEquals("2110690671695380513", trip.getId()); + assertEquals(19, trip.getId().length(), "19 位雪花号按字符串下发,不被取整"); + assertNull(trip.getLoadingNo()); + assertEquals("common", trip.getTransportOrgType()); + assertEquals("YD-20260920-0003", trip.getTripNo()); + assertEquals(1, trip.getStatus()); + assertEquals(1, trip.getWaybillCount()); + assertEquals(1, trip.getWaybills().size()); + assertEquals("2110690671695380513", trip.getWaybills().get(0).getId()); + verify(waybillService, times(1)).list(anyWaybillWrapper()); + } + + @Test + @DisplayName("未配载运次的状态与接单需求来自段一取回的那张运单") + void unloadedTripStateComesFromFetchedWaybill() { + stubRows(waybill(WAYBILL_ID, "YD-20260920-0003", null, "pending", "pending", at(1000L), ACCEPT_YES)); + + TripVO trip = service.page(TripQueryVO.forDriver(PHONE)).getRecords().get(0); + + assertEquals(Boolean.TRUE, trip.getRequireAccept()); + assertEquals("pending", trip.getAcceptStatus()); + assertEquals(0, trip.getStatus()); + assertEquals(1, trip.getWaybillCount()); + assertEquals("YD-20260920-0003", trip.getTripNo()); + } + + @Test + @DisplayName("未配载运次的计划时间取自这张运单:没有配载单可依,不能留空") + void unloadedTripPlanTimeComesFromWaybill() { + Waybill row = waybill(WAYBILL_ID, "YD-20260920-0003", null, "running", null, at(1000L), ACCEPT_NO); + row.setEstimatedStartTime(LocalDate.of(2026, 8, 7)); + row.setEstimatedEndTime(LocalDate.of(2026, 8, 24)); + stubRows(row); + + TripVO listed = service.page(TripQueryVO.forDriver(PHONE)).getRecords().get(0); + + assertEquals(LocalDate.of(2026, 8, 7), listed.getEstimatedStartDate(), "列表卡片与详情同源,不能只在列表补"); + assertEquals(LocalDate.of(2026, 8, 24), listed.getEstimatedEndDate()); + + when(loadingManageService.getById(WAYBILL_ID)).thenReturn(null); + when(waybillService.getById(WAYBILL_ID)).thenReturn(row); + + TripVO detailed = service.detail(tripQuery(String.valueOf(WAYBILL_ID))); + + assertEquals(LocalDate.of(2026, 8, 7), detailed.getEstimatedStartDate(), "管理端详情走这条路,不留空"); + assertEquals(LocalDate.of(2026, 8, 24), detailed.getEstimatedEndDate()); + } + + @Test + @DisplayName("配载单在两次查询之间被删掉时,不给出没有主键的运次") + void loadingMissingIsDropped() { + stubRows(waybill(2092186187239780353L, "YD-20260825-0006", LOADING_NO, "running", null, at(1000L), ACCEPT_YES)); + + IPage page = service.page(TripQueryVO.forDriver(PHONE)); + + assertTrue(page.getRecords().isEmpty(), "配载单取不到就不给这一趟车"); + assertEquals(1, page.getTotal(), "总数按运次算,卡片少一张时总数仍在"); + } + + @Test + @DisplayName("配载运次:主键取配载单主键,子单就是段一取回的行,状态与排序键按子单聚合") + void loadingTripAggregatesChildren() { + Date earlier = at(1787657033000L); + Date latest = at(1787671422000L); + stubRows( + waybill(2092186187239780353L, "YD-20260825-0006", LOADING_NO, "running", null, earlier, ACCEPT_NO), + waybill(2092186187470467073L, "YD-20260825-0007", LOADING_NO, "running", null, earlier, ACCEPT_NO), + waybill(2092186187571130369L, "YD-20260825-0008", LOADING_NO, "running", null, earlier, ACCEPT_NO), + waybill(2102780956493299713L, "YD-20260923-0008", LOADING_NO, "running", null, latest, ACCEPT_NO)); + when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of(loading(PHONE))); + + TripVO trip = service.page(TripQueryVO.forDriver(PHONE)).getRecords().get(0); + + assertEquals(String.valueOf(LOADING_ID), trip.getId()); + assertEquals(LOADING_NO, trip.getLoadingNo()); + assertEquals(LOADING_NO, trip.getTripNo()); + assertEquals("load", trip.getTransportOrgType()); + assertEquals(4, trip.getWaybillCount()); + assertEquals(4, trip.getWaybills().size()); + assertEquals(1, trip.getStatus(), "四张子单都在运输中"); + assertEquals(Boolean.FALSE, trip.getRequireAccept()); + assertEquals(latest, trip.getSortTime(), "排序键是子单 create_time 的最大值"); + verify(waybillService, times(1)).list(anyWaybillWrapper()); + verify(loadingManageService, times(1)).list(anyLoadingWrapper()); + } + + @Test + @DisplayName("配载运次的状态与接单态由子单聚合:需接单未接为待接单,无需接单为运输中") + void loadingTripStatusFollowsProcessConfig() { + stubRows( + waybill(2092186187239780353L, "YD-1", LOADING_NO, "pending", "pending", at(2000L), ACCEPT_YES), + waybill(2092186187470467073L, "YD-2", LOADING_NO, "running", null, at(1000L), ACCEPT_NO)); + when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of(loading(PHONE))); + + TripVO trip = service.page(TripQueryVO.forDriver(PHONE)).getRecords().get(0); + + assertEquals(Boolean.TRUE, trip.getRequireAccept(), "跨项目按 OR 合并:一张要接单整趟车都要接单"); + assertEquals("pending", trip.getAcceptStatus()); + assertEquals(0, trip.getStatus()); + } + + @Test + @DisplayName("同一运次连读两次状态与接单态不漂移,聚合路径零写库") + void repeatedReadsDoNotDriftAndNeverWrite() { + stubOneLoadedTrip(); + + TripVO first = service.page(TripQueryVO.forDriver(PHONE)).getRecords().get(0); + TripVO second = service.page(TripQueryVO.forDriver(PHONE)).getRecords().get(0); + + assertEquals(first.getStatus(), second.getStatus()); + assertEquals(first.getAcceptStatus(), second.getAcceptStatus()); + assertEquals(first.getRequireAccept(), second.getRequireAccept()); + assertEquals(first.getSortTime(), second.getSortTime()); + assertEquals(first.getWaybillCount(), second.getWaybillCount()); + verify(waybillService, never()).updateById(any()); + verify(waybillService, never()).update(any(), any()); + verify(waybillService, never()).saveOrUpdate(any()); + verify(waybillService, never()).syncDriverAcceptState(any()); + verify(loadingManageService, never()).updateById(any()); + verify(loadingManageService, never()).update(any(), any()); + } + + @Test + @DisplayName("收口缺失时不放行:返回空页、零统计,详情直接拒绝,且不查库") + void missingScopeIsRefused() { + TripQueryVO blank = new TripQueryVO(); + + IPage page = service.page(blank); + TripTabCountsVO counts = service.tabCounts(blank); + + assertEquals(0, page.getTotal()); + assertTrue(page.getRecords().isEmpty()); + assertEquals(0, counts.getAll()); + assertThrows(ServiceException.class, () -> service.detail(blank)); + verify(waybillService, never()).list(anyWaybillWrapper()); + verify(waybillService, never()).page(any(), any()); + } + + @Test + @DisplayName("分页落在过滤后的完整运次序列上:总数即运次数,换每页条数不串页") + void pagingIsPerTrip() { + stubRows( + waybill(3L, "YD-3", null, "running", null, at(3000L), ACCEPT_YES), + waybill(2L, "YD-2", null, "running", null, at(2000L), ACCEPT_YES), + waybill(1L, "YD-1", null, "running", null, at(1000L), ACCEPT_YES)); + + TripQueryVO firstPage = TripQueryVO.forDriver(PHONE); + firstPage.setCurrent(1); + firstPage.setSize(2); + assertEquals(3, service.page(firstPage).getTotal(), "总数是过滤后的运次数,不是运单数"); + assertEquals(List.of("3", "2"), service.page(firstPage).getRecords().stream().map(TripVO::getId).toList()); + + TripQueryVO secondPage = TripQueryVO.forDriver(PHONE); + secondPage.setCurrent(2); + secondPage.setSize(2); + assertEquals(List.of("1"), service.page(secondPage).getRecords().stream().map(TripVO::getId).toList(), + "第 2 页接着第 1 页,不重复不遗漏"); + + TripQueryVO bySeven = TripQueryVO.forDriver(PHONE); + bySeven.setCurrent(1); + bySeven.setSize(7); + assertEquals(3, service.page(bySeven).getTotal(), "换每页条数总数不变"); + assertTrue(service.page(bySeven).getCurrent() == 1); + } + + @Test + @DisplayName("Tab 统计一次取数算清四档,全部=待接单+进行中+已完成") + void tabCountsPerTrip() { + stubRows( + waybill(4L, "YD-4", null, "pending", "pending", at(4000L), ACCEPT_YES), + waybill(3L, "YD-3", null, "running", null, at(3000L), ACCEPT_NO), + waybill(2L, "YD-2", null, "running", null, at(2000L), ACCEPT_NO), + waybill(1L, "YD-1", null, "completed", "accepted", at(1000L), ACCEPT_NO)); + + TripTabCountsVO counts = service.tabCounts(TripQueryVO.forDriver(PHONE)); + + assertEquals(1, counts.getPending()); + assertEquals(2, counts.getDoing()); + assertEquals(1, counts.getDone()); + assertEquals(4, counts.getAll(), "全部不含已取消档"); + verify(waybillService, times(1)).list(anyWaybillWrapper()); + } + + @Test + @DisplayName("待处理两档与「未拒单」都走同一次取数:多档筛选与已拒运次排除") + void multiTierAndRejectedExclusion() { + stubRowsBySql(null, + // 被拒子单的运次键:只有 YD-9 那一趟 + List.of(waybill(9L, "YD-9", null, "pending", "rejected", at(500L), ACCEPT_NO)), + List.of( + waybill(3L, "YD-3", null, "running", null, at(3000L), ACCEPT_NO), + waybill(2L, "YD-2", null, "pending", "pending", at(2000L), ACCEPT_NO), + waybill(1L, "YD-1", null, "completed", "accepted", at(1000L), ACCEPT_NO))); + TripQueryVO query = TripQueryVO.forAdmin(); + query.setStatuses(List.of(0, 1)); + query.setExcludeRejectedTrips(true); + + List ids = service.page(query).getRecords().stream().map(TripVO::getId).toList(); + + assertEquals(List.of("3", "2"), ids, "只要待接单与进行中两档"); + assertEquals(2, service.page(query).getTotal()); + } + + @Test + @DisplayName("管理端筛选只决定哪些运次在范围内,分档仍看整趟车的子单") + void filtersDoNotShrinkTheTieringChildSet() { + // 一趟车两张子单:一张已完成(命中异常子单,也落在创建时间区间内)、一张还在运输中 + stubRows( + waybill(2L, "YD-2", LOADING_NO, "completed", "accepted", at(2000L), ACCEPT_NO), + waybill(1L, "YD-1", LOADING_NO, "running", null, at(1000L), ACCEPT_NO)); + when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of(loading(PHONE))); + TripQueryVO query = TripQueryVO.forAdmin(); + query.setException("exception"); + query.setExceptionWaybillIds(java.util.Set.of(2L)); + query.setCreatedFrom(at(1900L)); + query.setCreatedTo(at(2100L)); + + TripVO trip = service.page(query).getRecords().get(0); + + assertEquals(2, trip.getWaybillCount(), "筛选不缩小子单集合"); + assertEquals(1, trip.getStatus(), "整趟车还在运输中,不能因为命中的是那张已完成的子单就归到已完成档"); + } + + @Test + @DisplayName("管理端筛选:组织类型按子单行筛(同一趟车里是同一个值),异常与创建时间不进 SQL") + void adminFiltersStayAtTripGranularity() { + TripQueryVO query = TripQueryVO.forAdmin(); + query.setTransportOrgType("common"); + query.setException("normal"); + query.setExceptionWaybillIds(java.util.Set.of(7L, 8L)); + query.setCreatedFrom(at(1000L)); + query.setCreatedTo(at(2000L)); + + service.page(query); + + String sql = capturedRowSql(); + assertTrue(sql.contains("IFNULL(loading_no, '') = ''"), "普通运输在库层按子单行筛:" + sql); + assertFalse(sql.contains("id NOT IN ("), "异常要看整趟车,不能在库层缩小子单集合:" + sql); + assertFalse(sql.contains("create_time"), "创建时间按运次排序键在服务层判:" + sql); + assertFalse(sql.contains("blade_loading_manage"), "管理端不按司机收口:" + sql); + } + + @Test + @DisplayName("创建时间按子单 create_time 的最大值(卡片上显示的就是它)判区间") + void createdRangeUsesTripSortKey() { + // 两张子单:一张在区间外、一张在区间内,运次的排序键取后创建的那张 + stubRows( + waybill(2L, "YD-2", null, "running", null, at(2000L), ACCEPT_NO), + waybill(1L, "YD-1", null, "running", null, at(1000L), ACCEPT_NO)); + + TripQueryVO inside = TripQueryVO.forAdmin(); + inside.setCreatedFrom(at(1900L)); + inside.setCreatedTo(at(2100L)); + assertEquals(1, service.page(inside).getTotal(), "排序键 2000 落在区间内 → 这趟车在范围内"); + + TripQueryVO outside = TripQueryVO.forAdmin(); + outside.setCreatedFrom(at(1100L)); + outside.setCreatedTo(at(1900L)); + assertEquals(0, service.page(outside).getTotal(), "排序键 2000 不在区间内 → 这趟车不在范围内"); + } + + @Test + @DisplayName("详情与列表同口径:子单快照没有接单节点、项目生效配置要求接单时,详情也算待接单") + void detailUsesLiveProcessConfig() { + when(loadingManageService.getById(LOADING_ID)).thenReturn(loading(PHONE)); + Waybill child = waybill(2092186187239780353L, "YD-20260915-0001", LOADING_NO, "running", "pending", + at(2000L), ACCEPT_NO); + child.setProjectId(7001L); + when(waybillService.list(anyWaybillWrapper())).thenReturn(List.of(child)); + when(processConfigService.list(anyConfigWrapper())).thenReturn(List.of(processConfig(7001L, ACCEPT_YES))); + + TripVO trip = service.detail(tripQuery(String.valueOf(LOADING_ID))); + + assertEquals(0, trip.getStatus(), "生效配置要求接单且未接 → 待接单档,与列表一致"); + assertEquals(Boolean.TRUE, trip.getRequireAccept(), "接单需求按生效配置判,不看快照"); + assertEquals(Boolean.TRUE, trip.getWaybills().get(0).getRequireAccept()); + assertEquals("pending", trip.getAcceptStatus()); + } + + @Test + @DisplayName("详情仍是只读取数:生效配置只写回内存里的行,不落库") + void detailStaysReadOnly() { + when(loadingManageService.getById(LOADING_ID)).thenReturn(loading(PHONE)); + Waybill child = waybill(2092186187239780353L, "YD-20260915-0001", LOADING_NO, "running", "pending", + at(2000L), ACCEPT_NO); + child.setProjectId(7001L); + when(waybillService.list(anyWaybillWrapper())).thenReturn(List.of(child)); + when(processConfigService.list(anyConfigWrapper())).thenReturn(List.of(processConfig(7001L, ACCEPT_YES))); + + service.detail(tripQuery(String.valueOf(LOADING_ID))); + + verify(waybillService, never()).updateById(any()); + verify(waybillService, never()).update(any(), any()); + verify(waybillService, never()).syncDriverAcceptState(any()); + assertEquals(ACCEPT_YES, child.getProcessJson(), "生效配置只写回内存里的行,库里的快照列不动"); + } + + @Test + @DisplayName("详情:未配载按运单主键取,配载按配载单主键取") + void detailResolvesBothTripKinds() { + when(loadingManageService.getById(WAYBILL_ID)).thenReturn(null); + when(waybillService.getById(WAYBILL_ID)) + .thenReturn(waybill(WAYBILL_ID, "YD-20260920-0003", null, "running", null, at(1000L), ACCEPT_YES)); + when(loadingManageService.getById(LOADING_ID)).thenReturn(loading(PHONE)); + stubChildren(); + + TripVO lone = service.detail(tripQuery(String.valueOf(WAYBILL_ID))); + TripVO loaded = service.detail(tripQuery(String.valueOf(LOADING_ID))); + + assertEquals("2110690671695380513", lone.getId()); + assertEquals(1, lone.getWaybillCount()); + assertEquals(String.valueOf(LOADING_ID), loaded.getId()); + assertEquals(LOADING_NO, loaded.getTripNo()); + assertEquals(2, loaded.getWaybillCount()); + } + + @Test + @DisplayName("详情的收口与列表一致:别人的运次、子单全被拒单的配载运次都不可达") + void detailRefusesForeignTrips() { + when(loadingManageService.getById(LOADING_ID)).thenReturn(loading("13800000001")); + when(waybillService.getById(WAYBILL_ID)) + .thenReturn(waybill(WAYBILL_ID, "YD-20260920-0003", null, "running", null, at(1000L), ACCEPT_YES)); + + assertThrows(ServiceException.class, () -> service.detail(tripQuery(String.valueOf(LOADING_ID)))); + + when(loadingManageService.getById(LOADING_ID)).thenReturn(loading(PHONE)); + when(waybillService.list(anyWaybillWrapper())).thenReturn(List.of()); + assertThrows(ServiceException.class, () -> service.detail(tripQuery(String.valueOf(LOADING_ID)))); + + when(waybillService.getById(WAYBILL_ID)).thenReturn(null); + assertThrows(ServiceException.class, () -> service.detail(tripQuery(String.valueOf(WAYBILL_ID)))); + assertThrows(ServiceException.class, () -> service.detail(tripQuery("not-a-number"))); + } + + @Test + @DisplayName("已拒单的子单不进运次,整趟车只剩被拒的那张时该运次不再出现") + void rejectedChildrenLeaveTheTrip() { + // 段一的可见口径已经把被拒子单挡在库层,取回来是空则这趟车不存在 + stubRows(); + when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of(loading(PHONE))); + + IPage page = service.page(TripQueryVO.forDriver(PHONE)); + + assertTrue(page.getRecords().isEmpty(), "可见子单为空时不给没有子单的运次"); + assertEquals(0, page.getTotal()); + } + + // ------------------------------------------------------------ 夹具 + + @SuppressWarnings("unchecked") + private static Wrapper anyWaybillWrapper() { + return ArgumentMatchers.>any(); + } + + @SuppressWarnings("unchecked") + private static Wrapper anyLoadingWrapper() { + return ArgumentMatchers.>any(); + } + + @SuppressWarnings("unchecked") + private static Wrapper anyConfigWrapper() { + return ArgumentMatchers.>any(); + } + + /** 段一取回的可见运单行 */ + private void stubRows(Waybill... rows) { + when(waybillService.list(anyWaybillWrapper())).thenReturn(List.of(rows)); + } + + /** + * 关键字先查一次命中的运次键、再查一次运单行,两次都走 list:按 SQL 里有没有 LIKE 分流 + */ + private void stubRowsBySql(List keywordRows, List rejectedRows, List rows) { + when(waybillService.list(anyWaybillWrapper())).thenAnswer(invocation -> { + Wrapper wrapper = invocation.getArgument(0); + String sql = String.valueOf(wrapper.getTargetSql()); + if (sql.contains("LIKE")) { + return keywordRows; + } + // 被拒运次那次按等值找已拒子单;段一那次用 IS NULL 兼容为空 + return sql.contains("IS NULL") ? rows : rejectedRows; + }); + } + + private void stubOneLoadedTrip() { + stubRows( + waybill(2092186187239780353L, "YD-20260825-0006", LOADING_NO, "running", null, at(2000L), ACCEPT_YES), + waybill(2092186187470467073L, "YD-20260825-0007", LOADING_NO, "running", "pending", at(1000L), ACCEPT_NO)); + when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of(loading(PHONE))); + } + + private void stubChildren() { + when(waybillService.list(anyWaybillWrapper())).thenReturn(List.of( + waybill(2092186187239780353L, "YD-20260825-0006", LOADING_NO, "running", null, at(2000L), ACCEPT_YES), + waybill(2092186187470467073L, "YD-20260825-0007", LOADING_NO, "running", "pending", at(1000L), ACCEPT_NO))); + } + + private ProcessConfig processConfig(Long projectId, String nodeConfigJson) { + ProcessConfig config = new ProcessConfig(); + config.setProjectIds(String.valueOf(projectId)); + config.setNodeConfigJson(nodeConfigJson); + config.setStatus(1); + config.setIsDeleted(0); + return config; + } + + private TripQueryVO tripQuery(String tripId) { + TripQueryVO query = TripQueryVO.forDriver(PHONE); + query.setTripId(tripId); + return query; + } + + private LoadingManage loading(String driverPhone) { + LoadingManage loading = new LoadingManage(); + loading.setId(LOADING_ID); + loading.setLoadingNo(LOADING_NO); + loading.setDriverPhone(driverPhone); + loading.setDriverName("黄宇富"); + loading.setVehicleNo("桂A32523"); + loading.setProjectName("武宣建筑原料运输项目"); + return loading; + } + + private static Date at(long millis) { + return new Date(millis); + } + + private Waybill waybill(Long id, String no, String loadingNo, String businessStatus, String acceptStatus, + Date createTime, String processJson) { + Waybill waybill = new Waybill(); + waybill.setId(id); + waybill.setWaybillNo(no); + waybill.setLoadingNo(loadingNo); + waybill.setBusinessStatus(businessStatus); + waybill.setDriverAcceptStatus(acceptStatus); + waybill.setDriverPhone(PHONE); + waybill.setProcessJson(processJson); + waybill.setCreateTime(createTime); + return waybill; + } + + @SuppressWarnings("unchecked") + private ArgumentCaptor> rowCaptor() { + return ArgumentCaptor.forClass(LambdaQueryWrapper.class); + } + + /** 段一的运单行查询:按可见口径那次(带 IS NULL)取,关键字与已拒运次那两次另算 */ + @SuppressWarnings("unchecked") + private LambdaQueryWrapper capturedRowQuery() { + ArgumentCaptor> captor = ArgumentCaptor.forClass(Wrapper.class); + verify(waybillService, org.mockito.Mockito.atLeastOnce()).list(captor.capture()); + return (LambdaQueryWrapper) captor.getAllValues().stream() + .filter(wrapper -> String.valueOf(wrapper.getTargetSql()).contains("IS NULL")) + .findFirst() + .orElseThrow(() -> new AssertionError("没有发出段一的运单行查询")); + } + + private String capturedRowSql() { + return String.valueOf(capturedRowQuery().getTargetSql()); + } + + /** 关键字那次查询:带 LIKE 的那次调用 */ + private String capturedKeywordSql() { + ArgumentCaptor> captor = ArgumentCaptor.forClass(Wrapper.class); + verify(waybillService, times(2)).list(captor.capture()); + return captor.getAllValues().stream() + .map(wrapper -> String.valueOf(wrapper.getTargetSql())) + .filter(sql -> sql.contains("LIKE")) + .findFirst() + .orElseThrow(() -> new AssertionError("没有发出关键字查询")); + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/TripReadServiceBoundaryTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/TripReadServiceBoundaryTest.java new file mode 100644 index 00000000..ec37714d --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/TripReadServiceBoundaryTest.java @@ -0,0 +1,414 @@ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentMatchers; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.transport.pojo.entity.LoadingManage; +import org.springblade.transport.pojo.entity.ProcessConfig; +import org.springblade.transport.pojo.entity.Waybill; +import org.springblade.transport.pojo.vo.TripQueryVO; +import org.springblade.transport.pojo.vo.TripVO; +import org.springblade.transport.service.ILoadingManageService; +import org.springblade.transport.service.IProcessConfigService; +import org.springblade.transport.service.IWaybillService; + +import java.math.BigDecimal; +import java.util.Date; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * 运次读服务的聚合边界:四档状态、排序键、空子单集合与主键下发形态。 + *

+ * 断言都从服务出口读({@code page} / {@code detail}),不直接调判定函数:随卡片下发的 + * {@code status} 与 {@code requireAccept} 是两个字段,只验静态判定看不出服务有没有把子单真身取回来—— + * 库层分组行只投影主键、分组键与排序键,business_status 是 NULL。 + */ +class TripReadServiceBoundaryTest { + + private static final String PHONE = "17587421208"; + private static final String LOADING_NO = "PZ-20261017-001"; + /** 19 位雪花号,按数字下发会被双精度取整 */ + private static final Long LOADING_ID = 2111350784070275074L; + private static final Long CHILD_A = 2111350784070275123L; + private static final Long CHILD_B = 2102780956493299713L; + private static final String ACCEPT_YES = "[{\"key\":\"accept\",\"name\":\"接单\",\"enabled\":true,\"confirmMode\":\"yes\"}]"; + private static final String ACCEPT_NO = "[{\"key\":\"accept\",\"name\":\"接单\",\"enabled\":true,\"confirmMode\":\"no_confirm_accept\"}]"; + + private IWaybillService waybillService; + private ILoadingManageService loadingManageService; + private IProcessConfigService processConfigService; + private TripQueryServiceImpl service; + + @BeforeAll + static void initTableInfo() { + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, Waybill.class); + TableInfoHelper.initTableInfo(assistant, LoadingManage.class); + } + + @BeforeEach + void setUp() { + waybillService = mock(IWaybillService.class); + loadingManageService = mock(ILoadingManageService.class); + processConfigService = mock(IProcessConfigService.class); + lenient().when(processConfigService.list(anyConfigWrapper())).thenReturn(List.of()); + service = new TripQueryServiceImpl(waybillService, loadingManageService, processConfigService); + lenient().when(waybillService.list(anyWaybillWrapper())).thenReturn(List.of()); + lenient().when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of()); + } + + @Test + @DisplayName("子单全部完成 → 运次已完成(2)") + void allChildrenCompleted() { + stubLoadedTrip( + child(CHILD_A, "completed", null, ACCEPT_NO, at(1787657033000L)), + child(CHILD_B, "completed", "accepted", ACCEPT_NO, at(1787671422000L))); + + TripVO trip = onlyTrip(); + + assertEquals(2, trip.getStatus(), "终态且含已完成为已完成档"); + assertEquals(2, trip.getWaybillCount()); + assertEquals("accepted", trip.getAcceptStatus(), "任一子单已接单即整单已接单"); + } + + @Test + @DisplayName("子单全部取消 → 运次已取消(3),不是运输中") + void allChildrenCancelled() { + stubLoadedTrip( + child(CHILD_A, "cancelled", null, ACCEPT_NO, at(1787657033000L)), + child(CHILD_B, "cancelled", null, ACCEPT_NO, at(1787671422000L))); + + // 「全部」按定义不含已取消档,别在它上面找这趟车;这一趟车本身仍要算成已取消 + TripQueryVO query = TripQueryVO.forDriver(PHONE); + query.setStatus(3); + TripVO trip = onlyTrip(query); + + assertEquals(3, trip.getStatus(), "已取消子单必须进运次,否则全取消会被算成运输中"); + } + + @Test + @DisplayName("混合终态且含 completed → 运次已完成(2),不看取消的那张") + void mixedTerminalWithCompleted() { + stubLoadedTrip( + child(CHILD_A, "completed", null, ACCEPT_NO, at(1787657033000L)), + child(CHILD_B, "cancelled", null, ACCEPT_NO, at(1787671422000L))); + + TripVO trip = onlyTrip(); + + assertEquals(2, trip.getStatus()); + } + + @Test + @DisplayName("需接单且未接单 → 待接单(0)") + void requireAcceptWithoutAcceptance() { + Waybill required = child(CHILD_A, "pending", "pending", ACCEPT_YES, at(1787657033000L)); + required.setProjectId(1L); + required.setProjectName("MK项目测试-1"); + stubLoadedTrip(required); + + TripVO trip = onlyTrip(); + + assertEquals(Boolean.TRUE, trip.getRequireAccept()); + assertEquals("pending", trip.getAcceptStatus()); + assertEquals(0, trip.getStatus()); + } + + @Test + @DisplayName("不需接单 → 运输中(1)") + void withoutAcceptRequirement() { + Waybill notRequired = child(CHILD_A, "pending", null, ACCEPT_NO, at(1787657033000L)); + notRequired.setProjectId(2L); + notRequired.setProjectName("武宣建筑原料运输项目"); + stubLoadedTrip(notRequired); + + TripVO trip = onlyTrip(); + + assertEquals(Boolean.FALSE, trip.getRequireAccept()); + assertEquals(1, trip.getStatus()); + } + + @Test + @DisplayName("跨项目子单的接单需求按 OR 合并:一张要接单整趟车都要接单") + void crossProjectRequirementMergesWithOr() { + Waybill required = child(CHILD_A, "running", null, ACCEPT_YES, at(1787657033000L)); + required.setProjectId(1L); + required.setProjectName("MK项目测试-1"); + Waybill notRequired = child(CHILD_B, "running", null, ACCEPT_NO, at(1787671422000L)); + notRequired.setProjectId(2L); + notRequired.setProjectName("武宣建筑原料运输项目"); + stubLoadedTrip(required, notRequired); + + TripVO trip = onlyTrip(); + + assertEquals(Boolean.TRUE, trip.getRequireAccept()); + assertEquals(0, trip.getStatus()); + assertEquals(Boolean.TRUE, trip.getWaybills().get(0).getRequireAccept()); + assertEquals(Boolean.FALSE, trip.getWaybills().get(1).getRequireAccept()); + } + + @Test + @DisplayName("跨项目但都没接单节点 → 整趟车不需要接单") + void crossProjectWithoutAnyRequirement() { + Waybill first = child(CHILD_A, "running", null, null, at(1787657033000L)); + first.setProjectId(1L); + Waybill second = child(CHILD_B, "running", null, ACCEPT_NO, at(1787671422000L)); + second.setProjectId(2L); + stubLoadedTrip(first, second); + + TripVO trip = onlyTrip(); + + assertEquals(Boolean.FALSE, trip.getRequireAccept()); + assertEquals(1, trip.getStatus()); + } + + @Test + @DisplayName("排序键取子单 create_time 的最大值,不取第一张也不取最后一张") + void sortTimeIsMaxChildCreateTime() { + Date earliest = at(1787657033000L); + Date latest = at(1787671422000L); + Date middle = at(1787663000000L); + stubLoadedTrip( + child(CHILD_A, "running", null, ACCEPT_NO, middle), + child(2111350784070275999L, "running", null, ACCEPT_NO, latest), + child(CHILD_B, "running", null, ACCEPT_NO, earliest)); + + TripVO trip = onlyTrip(); + + assertEquals(latest, trip.getSortTime()); + assertEquals(3, trip.getWaybillCount()); + assertEquals(earliest, trip.getWaybills().get(0).getCreateTime(), "子单按创建时间升序摆"); + } + + @Test + @DisplayName("子单集合为空不抛异常:列表不给这张卡,详情直接拒绝而不是 NPE") + void emptyChildrenDoNotThrow() { + stubLoadedTrip(); + + IPage page = service.page(TripQueryVO.forDriver(PHONE)); + + assertTrue(page.getRecords().isEmpty(), "配载单在、可见子单为空时不给没有子单的运次"); + + when(loadingManageService.getById(LOADING_ID)).thenReturn(loading()); + assertThrows(ServiceException.class, () -> service.detail(detailQuery()), + "子单全被拒单的配载运次对司机不可见"); + } + + @Test + @DisplayName("子单状态为空时不抛异常:读不出状态按没走完兜底,不崩也不误判成终态") + void nullBusinessStatusDoesNotThrow() { + Waybill blank = new Waybill(); + blank.setId(CHILD_A); + blank.setWaybillNo("YD-20260920-0003"); + blank.setLoadingNo(LOADING_NO); + blank.setBusinessStatus(null); + stubLoadedTrip(blank); + + TripVO trip = onlyTrip(); + + assertEquals(1, trip.getStatus(), "读不出状态按没走完处理,不是终态也不抛异常"); + assertEquals(1, trip.getWaybillCount()); + } + + @Test + @DisplayName("未配载运次的状态与接单需求取自段一取回的那张运单") + void loneTripReadsStateFromFetchedWaybill() { + Waybill bare = new Waybill(); + bare.setId(CHILD_A); + bare.setWaybillNo("YD-20260920-0003"); + bare.setBusinessStatus("pending"); + bare.setDriverAcceptStatus("pending"); + bare.setProcessJson(ACCEPT_YES); + bare.setDriverPhone(PHONE); + when(waybillService.list(anyWaybillWrapper())).thenReturn(List.of(bare)); + + TripVO trip = onlyTrip(); + + assertEquals("2111350784070275123", trip.getId()); + assertNull(trip.getLoadingNo()); + assertEquals(19, trip.getId().length(), "19 位雪花号的末位不被取整"); + assertEquals(Boolean.TRUE, trip.getRequireAccept(), "接单需求取自回查到的真身"); + assertEquals(0, trip.getStatus()); + assertEquals(1, trip.getWaybillCount()); + } + + @Test + @DisplayName("运次主键按字符串下发:19 位雪花号的末位不被双精度取整") + void tripIdIsSentAsString() { + stubLoadedTrip(child(CHILD_A, "running", null, ACCEPT_NO, at(1787657033000L))); + + TripVO trip = onlyTrip(); + + assertNotNull(trip.getId()); + assertEquals("2111350784070275074", trip.getId()); + assertEquals(19, trip.getId().length()); + assertEquals("2111350784070275123", trip.getWaybills().get(0).getId()); + assertEquals(19, trip.getWaybills().get(0).getId().length()); + assertTrue(trip.getId() instanceof String); + } + + @Test + @DisplayName("货物与合计重量按整趟车算:同名同单位合并,单位不一致时合计留空") + void cargoSummarySpansWholeTrip() { + Waybill first = child(CHILD_A, "running", null, ACCEPT_NO, at(1787657033000L)); + first.setCargoName("石英砂A"); + first.setQuantity(new BigDecimal("42.10")); + first.setQuantityUnit("吨"); + Waybill second = child(CHILD_B, "running", null, ACCEPT_NO, at(1787671422000L)); + second.setCargoName("石英砂A"); + second.setQuantity(new BigDecimal("47.57")); + second.setQuantityUnit("吨"); + stubLoadedTrip(first, second); + + TripVO trip = onlyTrip(); + + assertEquals("石英砂A 89.67吨", trip.getCargoSummary()); + assertEquals(0, new BigDecimal("89.67").compareTo(trip.getTotalQuantity())); + assertEquals("吨", trip.getQuantityUnit()); + } + + @Test + @DisplayName("一张运单录了多条货物时,货名与汇总都要全带上,不能只取主表首行") + void multiCargoWaybillKeepsEveryCargo() { + Waybill multi = child(CHILD_A, "running", null, ACCEPT_NO, at(1787657033000L)); + // 主表只落了首行:货名与单位都是第一行的,数量却是两行的合计 + multi.setCargoName("阿什顿"); + multi.setCargoType("服饰"); + multi.setQuantity(new BigDecimal("70.00")); + multi.setQuantityUnit("堆"); + multi.setGoodsJson("[{\"cargoName\":\"阿什顿\",\"cargoType\":\"服饰\",\"quantity\":\"20\",\"quantityUnit\":\"堆\"}," + + "{\"cargoName\":\"显卡050\",\"cargoType\":\"显卡\",\"quantity\":\"50\",\"quantityUnit\":\"方\"}]"); + stubLoadedTrip(multi); + + TripVO trip = onlyTrip(); + + assertEquals(List.of("阿什顿", "显卡050"), trip.getCargoNames(), "两种货物都要在,不能只剩主表首行"); + assertEquals(List.of("阿什顿", "显卡050"), trip.getWaybills().get(0).getCargoNames(), "子单也要带上完整货名"); + assertEquals("阿什顿 20堆、显卡050 50方", trip.getCargoSummary()); + assertNull(trip.getTotalQuantity(), "两种货物单位不同,不能把 20堆+50方 说成 70堆"); + assertNull(trip.getQuantityUnit()); + } + + @Test + @DisplayName("计重合计:单位不一致时只合计吨项,堆方不参与;全是吨时就是吨合计") + void weighingOnlyCountsTonsWhenUnitsDiffer() { + Waybill mixed = child(CHILD_A, "running", null, ACCEPT_NO, at(1787657033000L)); + mixed.setCargoName("阿什顿"); + mixed.setQuantity(new BigDecimal("100")); + mixed.setQuantityUnit("吨"); + mixed.setGoodsJson("[{\"cargoName\":\"阿什顿\",\"quantity\":\"100\",\"quantityUnit\":\"吨\"}," + + "{\"cargoName\":\"显卡050\",\"quantity\":\"20\",\"quantityUnit\":\"堆\"}]"); + stubLoadedTrip(mixed); + + TripVO trip = onlyTrip(); + + assertNull(trip.getTotalQuantity(), "单位不一致,合计重量仍然为空"); + assertEquals(0, new BigDecimal("100").compareTo(trip.getWeighingQuantity()), "计重合计只算吨项"); + assertEquals("吨", trip.getWeighingUnit()); + } + + @Test + @DisplayName("计重合计:没有吨项且单位不一致时不出合计,合计重量同样为空") + void weighingEmptyWhenNoTonAndUnitsDiffer() { + Waybill mixed = child(CHILD_A, "running", null, ACCEPT_NO, at(1787657033000L)); + mixed.setCargoName("阿什顿"); + mixed.setQuantity(new BigDecimal("70.00")); + mixed.setQuantityUnit("堆"); + mixed.setGoodsJson("[{\"cargoName\":\"阿什顿\",\"quantity\":\"20\",\"quantityUnit\":\"堆\"}," + + "{\"cargoName\":\"显卡050\",\"quantity\":\"50\",\"quantityUnit\":\"方\"}]"); + stubLoadedTrip(mixed); + + TripVO trip = onlyTrip(); + + assertNull(trip.getWeighingQuantity(), "没有吨项就不出计重合计"); + assertNull(trip.getWeighingUnit()); + } + + // ------------------------------------------------------------ 夹具 + + @SuppressWarnings("unchecked") + private static Wrapper anyWaybillWrapper() { + return ArgumentMatchers.>any(); + } + + @SuppressWarnings("unchecked") + private static Wrapper anyLoadingWrapper() { + return ArgumentMatchers.>any(); + } + + @SuppressWarnings("unchecked") + private static Wrapper anyConfigWrapper() { + return ArgumentMatchers.>any(); + } + + /** 配载运次:段一给一行分组行,段二给配载单与这一组的子单 */ + private void stubLoadedTrip(Waybill... children) { + when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of(loading())); + when(waybillService.list(anyWaybillWrapper())).thenReturn(List.of(children)); + } + + private TripVO onlyTrip() { + return onlyTrip(TripQueryVO.forDriver(PHONE)); + } + + private TripVO onlyTrip(TripQueryVO query) { + IPage page = service.page(query); + assertEquals(1, page.getRecords().size(), "本页应只有一个运次"); + return page.getRecords().get(0); + } + + private TripQueryVO detailQuery() { + TripQueryVO query = TripQueryVO.forDriver(PHONE); + query.setTripId(String.valueOf(LOADING_ID)); + return query; + } + + private LoadingManage loading() { + LoadingManage loading = new LoadingManage(); + loading.setId(LOADING_ID); + loading.setLoadingNo(LOADING_NO); + loading.setDriverPhone(PHONE); + loading.setDriverName("黄宇富"); + loading.setVehicleNo("桂A32523"); + loading.setProjectName("武宣建筑原料运输项目"); + return loading; + } + + private Waybill child(Long id, String businessStatus, String acceptStatus, String processJson, Date createTime) { + Waybill waybill = new Waybill(); + waybill.setId(id); + waybill.setWaybillNo("YD-" + id); + waybill.setLoadingNo(LOADING_NO); + waybill.setBusinessStatus(businessStatus); + waybill.setDriverAcceptStatus(acceptStatus); + waybill.setDriverPhone(PHONE); + waybill.setProcessJson(processJson); + waybill.setCreateTime(createTime); + return waybill; + } + + private Date at(long millis) { + return new Date(millis); + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/TripStationAssemblerTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/TripStationAssemblerTest.java new file mode 100644 index 00000000..4df658c9 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/TripStationAssemblerTest.java @@ -0,0 +1,218 @@ +package org.springblade.transport.service.impl; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springblade.transport.pojo.entity.LoadingManage; +import org.springblade.transport.pojo.entity.Waybill; +import org.springblade.transport.pojo.vo.TripStationVO; + +import java.math.BigDecimal; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 运次站点拼装:配载单路线信息 → 站点 ①②③,站点标记指向配载单运单集合里的第 N 张子运单。 + *

+ * 夹具用的是本地库实测的配载单数据(路线信息、运单集合顺序与四张子运单的货名/货主/数量), + * 单元测试里没有库,所以「第 N 张」只能按运单集合的位次断言。 + */ +class TripStationAssemblerTest { + + /** PZ-20261017-001 的路线信息:三站,标记分别是 装1 / 卸1,装2,装3,装4 / 卸2,卸3,卸4 */ + private static final String ROUTE_JSON = "[" + + "{\"address\":\"广西壮族自治区南宁市南宁吴圩机场\",\"displayAddress\":\"广西壮族自治区南宁市南宁吴圩机场\"," + + "\"tags\":[\"装1\"],\"locked\":false}," + + "{\"address\":\"广西壮族自治区北海市合浦县白沙镇广西新福兴硅科技有限公司\"," + + "\"displayAddress\":\"广西壮族自治区北海市合浦县白沙镇广西新福兴硅科技有限公司\"," + + "\"tags\":[\"卸1\",\"装2\",\"装3\",\"装4\"],\"locked\":false}," + + "{\"address\":\"广西壮族自治区南宁市宾阳县宾州镇宾阳县宾阳县人民政府\"," + + "\"displayAddress\":\"广西壮族自治区南宁市宾阳县宾州镇宾阳县宾阳县人民政府\"," + + "\"tags\":[\"卸2\",\"卸3\",\"卸4\"],\"locked\":false}]"; + + private static final String WAYBILL_IDS_JSON = + "[\"2102780956493299713\", \"2092186187239780353\", \"2092186187470467073\", \"2092186187571130369\"]"; + + @Test + @DisplayName("路线信息成三站:第一站装货、第二站卸货并装货、第三站卸货,货物条目取子运单") + void stationsFollowRouteTags() { + LoadingManage loading = loading(ROUTE_JSON, WAYBILL_IDS_JSON); + List children = List.of( + child("2102780956493299713", "YD-20260923-0008", "技嘉4070显卡", "9/20测试mk001", "30.00"), + child("2092186187239780353", "YD-20260825-0006", "石英砂A", "广西武宣冠超矿业有限公司", "42.10"), + child("2092186187470467073", "YD-20260825-0007", "石英砂A", "广西武宣冠超矿业有限公司", "47.57"), + child("2092186187571130369", "YD-20260825-0008", "石英砂A", "广西武宣冠超矿业有限公司", "42.17")); + + List stations = TripStationAssembler.assemble(loading, children); + + assertEquals(3, stations.size()); + assertEquals(List.of(1, 2, 3), stations.stream().map(TripStationVO::getIndex).toList()); + + TripStationVO first = stations.get(0); + assertEquals("广西壮族自治区南宁市南宁吴圩机场", first.getName(), "站名取展示地址"); + assertEquals("装货", first.getOperationTypeLabel()); + assertEquals("load", first.getOperationType()); + assertEquals(List.of("装1"), first.getTags()); + assertEquals(1, first.getCargos().size()); + assertEquals("YD-20260923-0008", first.getCargos().get(0).getWaybillNo()); + assertEquals("技嘉4070显卡", first.getCargos().get(0).getCargoName()); + assertEquals("9/20测试mk001", first.getCargos().get(0).getCustomerName()); + assertEquals(0, new BigDecimal("30.00").compareTo(first.getCargos().get(0).getQuantity())); + assertEquals("load", first.getCargos().get(0).getOperation()); + + TripStationVO second = stations.get(1); + assertEquals("装卸", second.getOperationTypeLabel(), "同一站既卸又装"); + assertEquals(4, second.getCargos().size(), "卸 1 张 + 装 3 张"); + assertEquals("unload", second.getCargos().get(0).getOperation()); + assertEquals("YD-20260923-0008", second.getCargos().get(0).getWaybillNo()); + assertEquals(List.of("YD-20260825-0006", "YD-20260825-0007", "YD-20260825-0008"), + second.getCargos().subList(1, 4).stream().map(TripStationVO.TripCargoVO::getWaybillNo).toList()); + assertEquals(List.of("42.10", "47.57", "42.17"), + second.getCargos().subList(1, 4).stream() + .map(cargo -> cargo.getQuantity().toPlainString()).toList()); + assertTrue(second.getCargos().subList(1, 4).stream() + .allMatch(cargo -> "load".equals(cargo.getOperation()))); + + TripStationVO third = stations.get(2); + assertEquals("卸货", third.getOperationTypeLabel()); + assertEquals(3, third.getCargos().size()); + assertTrue(third.getCargos().stream().allMatch(cargo -> "unload".equals(cargo.getOperation()))); + + assertEquals(first.getName(), TripStationAssembler.departureName(stations)); + assertEquals(second.getName(), TripStationAssembler.transitName(stations), "中间站按顺序连接"); + assertEquals(third.getName(), TripStationAssembler.arrivalName(stations)); + } + + @Test + @DisplayName("站点标记的第 N 张按配载单运单集合的位次算,被拒单看不到的子单只是缺一条明细") + void tagOrderFollowsWaybillIdsNotVisibleChildren() { + LoadingManage loading = loading(ROUTE_JSON, WAYBILL_IDS_JSON); + // 第 1 张子单(2102780956493299713)在当前可见子单里不存在,但它仍占运单集合的第 1 位 + List children = List.of( + child("2092186187239780353", "YD-20260825-0006", "石英砂A", "广西武宣冠超矿业有限公司", "42.10")); + + List stations = TripStationAssembler.assemble(loading, children); + + assertTrue(stations.get(0).getCargos().isEmpty(), "标记指向的子单不可见时该站没有明细"); + assertEquals("load", stations.get(1).getCargos().get(0).getOperation()); + assertEquals("YD-20260825-0006", stations.get(1).getCargos().get(0).getWaybillNo(), + "装2 仍指向运单集合的第 2 张"); + assertEquals(1, stations.get(2).getCargos().size()); + assertEquals("unload", stations.get(2).getCargos().get(0).getOperation()); + assertEquals("YD-20260825-0006", stations.get(2).getCargos().get(0).getWaybillNo(), + "卸2 也指向运单集合的第 2 张"); + } + + @Test + @DisplayName("没有路线信息时回退子运单起讫点去重成站,保留首次出现顺序") + void fallsBackToWaybillPoints() { + LoadingManage loading = loading(null, WAYBILL_IDS_JSON); + List children = List.of( + points("2102786022868598785", "YD-20260923-0010", "广西壮族自治区南宁市宾阳县", "广西壮族自治区来宾市武宣县"), + points("2102786023208337409", "YD-20260923-0011", "广西壮族自治区来宾市武宣县", "福建省三明市三明沙县机场"), + points("2102786023485161474", "YD-20260923-0012", "福建省三明市三明沙县机场", "广西壮族自治区北海市合浦县白沙镇")); + + List stations = TripStationAssembler.assemble(loading, children); + + assertEquals(List.of( + "广西壮族自治区南宁市宾阳县", + "广西壮族自治区来宾市武宣县", + "福建省三明市三明沙县机场", + "广西壮族自治区北海市合浦县白沙镇"), + stations.stream().map(TripStationVO::getName).toList()); + assertEquals("装货", stations.get(0).getOperationTypeLabel()); + assertEquals("装卸", stations.get(1).getOperationTypeLabel(), "上一张的终点=下一张的起点"); + assertEquals("卸货", stations.get(3).getOperationTypeLabel()); + assertEquals(2, stations.get(1).getCargos().size()); + } + + @Test + @DisplayName("起讫点没有名称时用地址,名称与地址都没有才退到配载单自己的地址") + void fallsBackToAddressThenLoadingAddress() { + LoadingManage loading = loading(null, WAYBILL_IDS_JSON); + loading.setDepartureAddress("配载单发货地址"); + loading.setTransitAddress("配载单途经地址"); + loading.setArrivalAddress("配载单到货地址"); + List addressOnly = List.of(points("1", "YD-1", null, null)); + + List fromLoading = TripStationAssembler.assemble(loading, addressOnly); + + assertEquals(List.of("配载单发货地址", "配载单途经地址", "配载单到货地址"), + fromLoading.stream().map(TripStationVO::getName).toList()); + assertNull(fromLoading.get(0).getOperationType(), "退到配载单地址时无从判定站点操作"); + assertTrue(fromLoading.get(0).getCargos().isEmpty()); + } + + @Test + @DisplayName("站点、起讫点、货物全为空时不抛异常,下发空站点数组") + void emptyEverythingStaysEmpty() { + List stations = TripStationAssembler.assemble(loading(null, null), List.of(points("1", "YD-1", null, null))); + + assertTrue(stations.isEmpty()); + assertEquals("", TripStationAssembler.departureName(stations)); + assertEquals("", TripStationAssembler.transitName(stations)); + assertEquals("", TripStationAssembler.arrivalName(stations)); + assertEquals("", TripStationAssembler.transitName(TripStationAssembler.assemble(null, List.of()))); + } + + @Test + @DisplayName("未配载运单成两点:起点装货、终点卸货") + void unloadedWaybillBecomesTwoPoints() { + List children = List.of(points("2101508430981652482", "YD-20260920-0003", "南宁武鸣", "钦州港")); + + List stations = TripStationAssembler.assemble(null, children); + + assertEquals(2, stations.size()); + assertEquals("南宁武鸣", stations.get(0).getName()); + assertEquals("装货", stations.get(0).getOperationTypeLabel()); + assertEquals("钦州港", stations.get(1).getName()); + assertEquals("卸货", stations.get(1).getOperationTypeLabel()); + assertNotNull(stations.get(0).getCargos()); + } + + @Test + @DisplayName("路线信息坏掉时按没有路线信息处理,不抛异常") + void brokenRouteJsonFallsBack() { + List children = List.of(points("1", "YD-1", "起点", "终点")); + + List stations = TripStationAssembler.assemble(loading("{不是数组", WAYBILL_IDS_JSON), children); + + assertEquals(2, stations.size()); + assertEquals("起点", stations.get(0).getName()); + } + + private LoadingManage loading(String routeJson, String waybillIdsJson) { + LoadingManage loading = new LoadingManage(); + loading.setRouteJson(routeJson); + loading.setWaybillIdsJson(waybillIdsJson); + return loading; + } + + private Waybill child(String id, String waybillNo, String cargoName, String customerName, String quantity) { + Waybill waybill = new Waybill(); + waybill.setId(Long.valueOf(id)); + waybill.setWaybillNo(waybillNo); + waybill.setCargoName(cargoName); + waybill.setCustomerName(customerName); + waybill.setQuantity(new BigDecimal(quantity)); + waybill.setQuantityUnit("吨"); + waybill.setProjectName("武宣建筑原料运输项目"); + return waybill; + } + + /** 只有起讫点的子运单(名称与地址都取自参数) */ + private Waybill points(String id, String waybillNo, String departure, String arrival) { + Waybill waybill = new Waybill(); + waybill.setId(Long.valueOf(id)); + waybill.setWaybillNo(waybillNo); + waybill.setDepartureName(departure); + waybill.setDepartureAddress(departure); + waybill.setArrivalName(arrival); + waybill.setArrivalAddress(arrival); + return waybill; + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillConfirmReturnTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillConfirmReturnTest.java new file mode 100644 index 00000000..3cfe106f --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillConfirmReturnTest.java @@ -0,0 +1,229 @@ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.core.secure.utils.AuthUtil; +import org.springblade.transport.mapper.ExceptionDisposalMapper; +import org.springblade.transport.mapper.WaybillEnroutePunchMapper; +import org.springblade.transport.mapper.WaybillMapper; +import org.springblade.transport.mapper.WaybillNodePunchMapper; +import org.springblade.transport.pojo.entity.ExceptionDisposal; +import org.springblade.transport.pojo.entity.ProcessConfig; +import org.springblade.transport.pojo.entity.Waybill; +import org.springblade.transport.pojo.entity.WaybillNodePunch; +import org.springblade.transport.service.ILoadingManageService; +import org.springblade.transport.service.IProcessConfigService; +import org.springblade.transport.service.IReceivablePayableDetailService; + +import java.lang.reflect.Field; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 确认回单:回单节点要求业务员确认时,内部人员确认后把运单置为已完成。 + *

+ * 完整入口不校验节点打卡,故这里必须自己挡住两件事——该运单的司机打卡节点(在途除外)全部已打卡、 + * 无未了结异常;不挡住就成了绕过打卡门禁与异常门禁的通道。回单不要求确认时不能走这个入口。 + */ +class WaybillConfirmReturnTest { + + private static final Long WAYBILL_ID = 5701L; + private static final String DEPT_ID = "1123598813738675201"; + + private static final String RETURN_NEED_CONFIRM_JSON = """ + [{"key":"load","name":"装货","type":"cargo","enabled":true,"punch":true}, + {"key":"sign","name":"签收","type":"cargo","enabled":true,"punch":true}, + {"key":"return","name":"回单","type":"confirm","enabled":true,"punch":true,"confirmMode":"yes"}] + """; + + private static final String RETURN_NO_CONFIRM_JSON = """ + [{"key":"load","name":"装货","type":"cargo","enabled":true,"punch":true}, + {"key":"sign","name":"签收","type":"cargo","enabled":true,"punch":true}, + {"key":"return","name":"回单","type":"confirm","enabled":true,"punch":true,"confirmMode":"no_confirm_complete"}] + """; + + /** 带在途节点:在途是频次打卡节点、不是必经一步,没打不该拦住确认回单 */ + private static final String WITH_TRANSIT_JSON = """ + [{"key":"load","name":"装货","type":"cargo","enabled":true,"punch":true}, + {"key":"transit","name":"在途","type":"transit","enabled":true,"punch":true}, + {"key":"sign","name":"签收","type":"cargo","enabled":true,"punch":true}, + {"key":"return","name":"回单","type":"confirm","enabled":true,"punch":true,"confirmMode":"yes"}] + """; + + private WaybillServiceImpl service; + private WaybillMapper waybillMapper; + private WaybillNodePunchMapper nodePunchMapper; + private ExceptionDisposalMapper exceptionDisposalMapper; + private ILoadingManageService loadingManageService; + private IReceivablePayableDetailService receivablePayableDetailService; + private MockedStatic authUtil; + + @BeforeAll + static void initTableInfo() { + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, Waybill.class); + TableInfoHelper.initTableInfo(assistant, WaybillNodePunch.class); + TableInfoHelper.initTableInfo(assistant, ExceptionDisposal.class); + TableInfoHelper.initTableInfo(assistant, ProcessConfig.class); + } + + @BeforeEach + void setUp() throws Exception { + waybillMapper = mock(WaybillMapper.class); + nodePunchMapper = mock(WaybillNodePunchMapper.class); + exceptionDisposalMapper = mock(ExceptionDisposalMapper.class); + loadingManageService = mock(ILoadingManageService.class); + receivablePayableDetailService = mock(IReceivablePayableDetailService.class); + IProcessConfigService processConfigService = mock(IProcessConfigService.class); + when(processConfigService.list(org.mockito.ArgumentMatchers.>any())).thenReturn(List.of()); + + service = new WaybillServiceImpl(); + inject("baseMapper", waybillMapper); + inject("waybillNodePunchMapper", nodePunchMapper); + inject("waybillEnroutePunchMapper", mock(WaybillEnroutePunchMapper.class)); + inject("exceptionDisposalMapper", exceptionDisposalMapper); + inject("processConfigService", processConfigService); + inject("loadingManageService", loadingManageService); + inject("receivablePayableDetailService", receivablePayableDetailService); + + when(waybillMapper.updateById(any(Waybill.class))).thenReturn(1); + when(nodePunchMapper.selectList(any())).thenReturn(List.of()); + when(exceptionDisposalMapper.selectCount(any())).thenReturn(0L); + authUtil = mockStatic(AuthUtil.class); + authUtil.when(AuthUtil::getDeptId).thenReturn(DEPT_ID); + } + + @AfterEach + void tearDown() { + authUtil.close(); + } + + @Test + @DisplayName("回单要求确认且节点都打完:置为已完成,并照常生成应收应付、尝试完成配载单") + void confirmsReturnAndCompletesWaybill() { + stubWaybill(RETURN_NEED_CONFIRM_JSON); + stubPunches("load", "sign"); + + boolean result = service.confirmReturnReceipt(WAYBILL_ID); + + assertEquals(true, result); + verify(waybillMapper).updateById(org.mockito.ArgumentMatchers.argThat(waybill -> + "completed".equals(waybill.getBusinessStatus()) && WAYBILL_ID.equals(waybill.getId()))); + verify(receivablePayableDetailService).generateForCompletedWaybills(List.of(WAYBILL_ID)); + verify(loadingManageService).completeIfAllWaybillsCompleted(any()); + } + + @Test + @DisplayName("在途节点没打卡也放行:它不是必经一步") + void transitNodeDoesNotBlockConfirmation() { + stubWaybill(WITH_TRANSIT_JSON); + stubPunches("load", "sign"); + + assertEquals(true, service.confirmReturnReceipt(WAYBILL_ID)); + } + + @Test + @DisplayName("还有节点没打卡:拒绝并点名缺哪个节点,不改状态") + void unfinishedPunchNodeBlocksConfirmation() { + stubWaybill(RETURN_NEED_CONFIRM_JSON); + stubPunches("load"); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.confirmReturnReceipt(WAYBILL_ID)); + + assertEquals("该运单还有未打卡的节点:签收", exception.getMessage()); + verify(waybillMapper, never()).updateById(any(Waybill.class)); + } + + @Test + @DisplayName("有未了结异常:拒绝,不改状态") + void unresolvedExceptionBlocksConfirmation() { + stubWaybill(RETURN_NEED_CONFIRM_JSON); + stubPunches("load", "sign"); + when(exceptionDisposalMapper.selectCount(any())).thenReturn(1L); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.confirmReturnReceipt(WAYBILL_ID)); + + assertEquals("该运单存在未处理异常,处理完成后才能确认回单", exception.getMessage()); + verify(waybillMapper, never()).updateById(any(Waybill.class)); + } + + @Test + @DisplayName("回单不要求业务员确认:这个入口不适用,说清而不是静默完成") + void returnWithoutConfirmationIsRejected() { + stubWaybill(RETURN_NO_CONFIRM_JSON); + stubPunches("load", "sign"); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.confirmReturnReceipt(WAYBILL_ID)); + + assertEquals("该运单的回单不需要业务员确认", exception.getMessage()); + verify(waybillMapper, never()).updateById(any(Waybill.class)); + } + + // ------------------------------------------------------------ 夹具 + + private void stubWaybill(String processJson) { + Waybill waybill = new Waybill(); + waybill.setId(WAYBILL_ID); + waybill.setWaybillNo("YD202610180002"); + waybill.setDeptId(Long.valueOf(DEPT_ID)); + waybill.setProjectId(7001L); + waybill.setProcessJson(processJson); + waybill.setBusinessStatus("running"); + waybill.setLoadingNo("PZ-20261018-001"); + waybill.setIsDeleted(0); + when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill); + } + + private void stubPunches(String... nodeCodes) { + List punches = java.util.Arrays.stream(nodeCodes).map(code -> { + WaybillNodePunch punch = new WaybillNodePunch(); + punch.setWaybillId(WAYBILL_ID); + punch.setNodeCode(code); + return punch; + }).toList(); + when(nodePunchMapper.selectList(any())).thenReturn(punches); + } + + private void inject(String field, Object value) throws Exception { + Class type = service.getClass(); + while (type != null) { + try { + Field declared = type.getDeclaredField(field); + declared.setAccessible(true); + declared.set(service, value); + return; + } catch (NoSuchFieldException ignored) { + type = type.getSuperclass(); + } + } + throw new IllegalStateException("未找到字段:" + field); + } + + @SuppressWarnings("unused") + private static Wrapper anyWaybillWrapper() { + return org.mockito.ArgumentMatchers.any(); + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillLoadableQueryTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillLoadableQueryTest.java new file mode 100644 index 00000000..54e6eacf --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillLoadableQueryTest.java @@ -0,0 +1,171 @@ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; +import org.springblade.core.secure.utils.AuthUtil; +import org.springblade.transport.mapper.WaybillMapper; +import org.springblade.transport.pojo.entity.Waybill; +import org.springblade.transport.pojo.vo.WaybillVO; +import org.springblade.transport.support.LoadableAdmissionSupport; + +import java.lang.reflect.Field; +import java.util.Collection; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 运单列表的可配载取数:开关一次带上整条判据,另支持按主键集合回查。 + *

+ * 候选列表与「公路配载」按钮预检都走这次查询,所以断言下在生成的 SQL 片段上—— + * 开关不启用时默认列表不能被顺手改口径,那是全部运单入口共用的查询。 + */ +class WaybillLoadableQueryTest { + + private WaybillMapper waybillMapper; + private WaybillServiceImpl service; + private MockedStatic authUtil; + + @BeforeAll + static void initTableInfo() { + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, Waybill.class); + } + + @BeforeEach + void setUp() throws Exception { + waybillMapper = mock(WaybillMapper.class); + service = new WaybillServiceImpl(); + inject("baseMapper", waybillMapper); + when(waybillMapper.selectPage(any(), any())).thenReturn(new Page(1, 10)); + // 组织过滤只看当前用户;超级管理员不带组织条件,断言里就只剩本用例关心的条件 + authUtil = mockStatic(AuthUtil.class); + when(AuthUtil.getUserRole()).thenReturn("administrator"); + } + + @AfterEach + void tearDown() { + authUtil.close(); + } + + @Test + @DisplayName("开关开启:公路词表、待执行、未配载一次全部进 wrapper,接单状态不参与") + void loadableSwitchAppliesWholePredicate() { + LambdaQueryWrapper wrapper = capturedQuery(loadableQuery()); + String sql = String.valueOf(wrapper.getTargetSql()); + Collection params = wrapper.getParamNameValuePairs().values(); + + assertTrue(sql.contains("transport_type"), "公路判定要落在运单自己的运输方式上:" + sql); + for (String keyword : LoadableAdmissionSupport.ROAD_TRANSPORT_KEYWORDS) { + assertTrue(params.contains("%" + keyword + "%"), + "公路词表要整份进条件,只写 road 会漏掉中文取值的运单:" + params); + } + assertTrue(sql.contains("business_status =") && params.contains("pending"), + "只放行待执行:" + sql + " " + params); + assertFalse(params.contains("running"), + "进行中一律不可配载,不能沿用「待执行与进行中都可配载」的旧口径:" + params); + assertTrue(sql.contains("loading_no IS NULL") && sql.contains("loading_no ="), + "未配载要同时覆盖 NULL 与空串:" + sql); + assertFalse(sql.contains("driver_accept_status"), + "接单状态不进可配载判据:拒单票照样可被重新配载:" + sql); + } + + @Test + @DisplayName("开关开启 + 主键集合:收窄到所选运单,用于按钮预检") + void loadableSwitchNarrowsBySelectedIds() { + WaybillVO query = loadableQuery(); + query.setIds(List.of(11L, 22L)); + + LambdaQueryWrapper wrapper = capturedQuery(query); + String sql = String.valueOf(wrapper.getTargetSql()); + Collection params = wrapper.getParamNameValuePairs().values(); + + assertTrue(sql.contains("id IN"), "按主键回查:" + sql); + assertTrue(params.contains(11L) && params.contains(22L), "所选运单主键要进条件:" + params); + assertTrue(sql.contains("transport_type"), "回查仍要带整条判据:" + sql); + } + + @Test + @DisplayName("只传主键集合:按主键收窄,不附带可配载判据") + void idsNarrowWithoutTheSwitch() { + WaybillVO query = new WaybillVO(); + query.setAllDept(1); + query.setIds(List.of(33L)); + + LambdaQueryWrapper wrapper = capturedQuery(query); + String sql = String.valueOf(wrapper.getTargetSql()); + + assertTrue(sql.contains("id IN"), "按主键回查:" + sql); + assertTrue(wrapper.getParamNameValuePairs().values().contains(33L), sql); + assertFalse(sql.contains("transport_type"), "没开可配载开关就不加公路条件:" + sql); + } + + @Test + @DisplayName("开关未开启:运单管理默认列表条件与展示口径不变") + void withoutSwitchTheDefaultListIsUntouched() { + WaybillVO query = new WaybillVO(); + query.setAllDept(1); + + LambdaQueryWrapper wrapper = capturedQuery(query); + String sql = String.valueOf(wrapper.getTargetSql()); + + assertFalse(sql.contains("transport_type"), sql); + assertFalse(sql.contains("business_status"), sql); + assertFalse(sql.contains("loading_no"), sql); + assertFalse(sql.contains("driver_accept_status"), sql); + } + + // ------------------------------------------------------------ 夹具 + + private WaybillVO loadableQuery() { + WaybillVO query = new WaybillVO(); + query.setAllDept(1); + query.setLoadableOnly(1); + return query; + } + + private LambdaQueryWrapper capturedQuery(WaybillVO query) { + IPage page = service.selectWaybillPage(new Page<>(1, 10), query); + assertTrue(page.getRecords().isEmpty()); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(waybillMapper).selectPage(any(), captor.capture()); + return captor.getValue(); + } + + private void inject(String field, Object value) throws Exception { + Class type = service.getClass(); + while (type != null) { + try { + Field declared = type.getDeclaredField(field); + declared.setAccessible(true); + declared.set(service, value); + return; + } catch (NoSuchFieldException ignored) { + type = type.getSuperclass(); + } + } + throw new IllegalStateException("找不到字段:" + field); + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/LiveProcessConfigSupportTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/LiveProcessConfigSupportTest.java new file mode 100644 index 00000000..7ae583a9 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/LiveProcessConfigSupportTest.java @@ -0,0 +1,158 @@ +package org.springblade.transport.support; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentMatchers; +import org.springblade.transport.pojo.entity.ProcessConfig; +import org.springblade.transport.service.IProcessConfigService; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 生效配置的取法与「生效配置 vs 快照」二选一的唯一实现处。 + *

+ * 两条规则必须分得清:接单需求与节点门禁读 {@code resolve}(生效配置是权威,不能因为快照里有节点被顶掉), + * 司机端打卡页读 {@code resolveForPunch}(生效配置被改坏成没有可打卡节点时回退快照,避免打卡页空白)。 + * 排序由 SQL 负责(更新时间倒序、创建时间倒序),这里按「已排好序的列表」构造输入。 + */ +class LiveProcessConfigSupportTest { + + /** 有可打卡节点(装货,punch=是) */ + private static final String PUNCH_JSON = + "[{\"key\":\"load\",\"name\":\"装货\",\"enabled\":true,\"punch\":true,\"location\":true}]"; + /** 一条可打卡节点都没有(装货被关掉 punch) */ + private static final String NO_PUNCH_JSON = + "[{\"key\":\"load\",\"name\":\"装货\",\"enabled\":true,\"punch\":false}]"; + /** 另一个项目用的、与 PUNCH_JSON 可区分的一份 */ + private static final String OTHER_PUNCH_JSON = + "[{\"key\":\"unload\",\"name\":\"卸货\",\"enabled\":true,\"punch\":true,\"location\":true}]"; + + private IProcessConfigService processConfigService; + + @BeforeAll + static void initTableInfo() { + // 构造 LambdaQueryWrapper 需要 MyBatis-Plus 的实体元数据缓存 + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, ProcessConfig.class); + } + + @BeforeEach + void setUp() { + processConfigService = mock(IProcessConfigService.class); + } + + private void stubConfigs(ProcessConfig... configs) { + when(processConfigService.list(anyConfigWrapper())).thenReturn(List.of(configs)); + } + + private static Wrapper anyConfigWrapper() { + return ArgumentMatchers.>any(); + } + + private static ProcessConfig config(String projectIds, String nodeConfigJson) { + ProcessConfig config = new ProcessConfig(); + config.setProjectIds(projectIds); + config.setNodeConfigJson(nodeConfigJson); + return config; + } + + @Test + @DisplayName("单项目:命中项目的第一条生效配置(列表已按更新时间倒序)") + void singleProjectTakesFirstHit() { + stubConfigs(config("999", OTHER_PUNCH_JSON), config("1234,5678", PUNCH_JSON)); + + assertEquals(PUNCH_JSON, LiveProcessConfigSupport.byProject(processConfigService, 1234L)); + } + + @Test + @DisplayName("单项目:项目 id 按逗号分段精确比对,1234 不命中 12345") + void singleProjectMatchesExactId() { + stubConfigs(config("12345", PUNCH_JSON)); + + assertNull(LiveProcessConfigSupport.byProject(processConfigService, 1234L)); + } + + @Test + @DisplayName("单项目:没有生效配置、或项目为空时返回 null") + void singleProjectWithoutConfig() { + stubConfigs(); + + assertNull(LiveProcessConfigSupport.byProject(processConfigService, 1234L)); + assertNull(LiveProcessConfigSupport.byProject(processConfigService, null)); + assertNull(LiveProcessConfigSupport.byProject(null, 1234L)); + } + + @Test + @DisplayName("单项目:节点配置为空的配置跳过,取后面那条有内容的") + void singleProjectSkipsEmptyNodeJson() { + stubConfigs(config("1234", ""), config("1234", PUNCH_JSON)); + + assertEquals(PUNCH_JSON, LiveProcessConfigSupport.byProject(processConfigService, 1234L)); + } + + @Test + @DisplayName("批量:一次查全后按项目分别命中,未命中的项目不进结果") + void batchByProject() { + stubConfigs(config("1234", PUNCH_JSON), config("5678", OTHER_PUNCH_JSON)); + + Map live = LiveProcessConfigSupport.byProjects(processConfigService, List.of(1234L, 5678L, 4321L)); + + assertEquals(2, live.size()); + assertEquals(PUNCH_JSON, live.get(1234L)); + assertEquals(OTHER_PUNCH_JSON, live.get(5678L)); + assertTrue(live.containsKey(1234L)); + } + + @Test + @DisplayName("resolve:生效配置优先,取不到才用快照") + void resolvePrefersLive() { + assertEquals(PUNCH_JSON, LiveProcessConfigSupport.resolve(PUNCH_JSON, NO_PUNCH_JSON)); + assertEquals(NO_PUNCH_JSON, LiveProcessConfigSupport.resolve(null, NO_PUNCH_JSON)); + assertNull(LiveProcessConfigSupport.resolve(null, null)); + } + + @Test + @DisplayName("resolveForPunch:生效配置没有可打卡节点时才回退快照,两边都没有节点仍用生效配置") + void resolveForPunchFallsBackToSnapshot() { + assertEquals(PUNCH_JSON, LiveProcessConfigSupport.resolveForPunch(PUNCH_JSON, NO_PUNCH_JSON), + "生效配置有可打卡节点:用生效配置"); + assertEquals(PUNCH_JSON, LiveProcessConfigSupport.resolveForPunch(NO_PUNCH_JSON, PUNCH_JSON), + "生效配置没有可打卡节点、快照有:回退快照"); + assertEquals(NO_PUNCH_JSON, LiveProcessConfigSupport.resolveForPunch(NO_PUNCH_JSON, NO_PUNCH_JSON), + "两边都没有可打卡节点:用生效配置"); + assertEquals(NO_PUNCH_JSON, LiveProcessConfigSupport.resolveForPunch(null, NO_PUNCH_JSON), + "项目没有生效配置:用快照"); + assertEquals(NO_PUNCH_JSON, LiveProcessConfigSupport.resolveForPunch(NO_PUNCH_JSON, null), + "快照为空:用生效配置"); + } + + @Test + @DisplayName("生效配置只查一次:批量取法与单项目取法都不逐条回查项目") + void configQueryIsInvokedOncePerCall() { + stubConfigs(config("1234", PUNCH_JSON), config("5678", OTHER_PUNCH_JSON)); + + LiveProcessConfigSupport.byProjects(processConfigService, List.of(1234L, 5678L)); + + verify(processConfigService, times(1)).list(ArgumentMatchers.>any()); + verify(processConfigService, never()).getById(any()); + } +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/LoadableAdmissionSupportTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/LoadableAdmissionSupportTest.java new file mode 100644 index 00000000..9b5c72ad --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/LoadableAdmissionSupportTest.java @@ -0,0 +1,116 @@ +package org.springblade.transport.support; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springblade.transport.pojo.entity.Waybill; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 可配载准入判据:公路词表与单张运单的准入检查。 + *

+ * 判据是读侧候选列表与写侧提交/编辑/重新派单校验共用的同一份定义。 + */ +class LoadableAdmissionSupportTest { + + // ------------------------------------------------------------ 公路词表 + + @Test + @DisplayName("公路:road 与中文同义写法都命中") + void roadVocabularyHits() { + assertTrue(LoadableAdmissionSupport.isRoadTransport("road")); + assertTrue(LoadableAdmissionSupport.isRoadTransport("ROAD")); + assertTrue(LoadableAdmissionSupport.isRoadTransport("公路运输")); + assertTrue(LoadableAdmissionSupport.isRoadTransport("公路")); + assertTrue(LoadableAdmissionSupport.isRoadTransport("道路运输")); + assertTrue(LoadableAdmissionSupport.isRoadTransport("汽车运输")); + assertTrue(LoadableAdmissionSupport.isRoadTransport("陆运")); + assertTrue(LoadableAdmissionSupport.isRoadTransport("highway")); + assertTrue(LoadableAdmissionSupport.isRoadTransport("gl")); + } + + @Test + @DisplayName("非公路与空值一律不命中——未知运输方式按非公路拒绝,不能放行") + void nonRoadVocabularyMisses() { + assertFalse(LoadableAdmissionSupport.isRoadTransport("railway")); + assertFalse(LoadableAdmissionSupport.isRoadTransport("water")); + assertFalse(LoadableAdmissionSupport.isRoadTransport("air")); + assertFalse(LoadableAdmissionSupport.isRoadTransport("river")); + assertFalse(LoadableAdmissionSupport.isRoadTransport("水路运输")); + assertFalse(LoadableAdmissionSupport.isRoadTransport("铁路运输")); + assertFalse(LoadableAdmissionSupport.isRoadTransport("航空运输")); + assertFalse(LoadableAdmissionSupport.isRoadTransport("")); + assertFalse(LoadableAdmissionSupport.isRoadTransport(" ")); + assertFalse(LoadableAdmissionSupport.isRoadTransport(null)); + } + + // ------------------------------------------------------------ 单张运单准入 + + @Test + @DisplayName("公路 + 待执行 + 未配载才可配载") + void loadableOnlyWhenAllConditionsHold() { + assertTrue(LoadableAdmissionSupport.isLoadable(waybill("road", "pending", null, "pending"), null)); + assertTrue(LoadableAdmissionSupport.isLoadable(waybill("公路运输", "pending", "", null), null)); + } + + @Test + @DisplayName("已拒单的票照样可配载:接单事实由配载提交清除,不靠把拒单票挡在候选之外") + void rejectedWaybillIsLoadable() { + assertTrue(LoadableAdmissionSupport.isLoadable(waybill("road", "pending", null, "rejected"), null)); + assertTrue(LoadableAdmissionSupport.isLoadable(waybill("road", "pending", null, "rejected"), "PZ20260101-001")); + } + + @Test + @DisplayName("非公路、非待执行、已关联配载单各自单独即可拒绝") + void eachConditionRejectsOnItsOwn() { + assertFalse(LoadableAdmissionSupport.isLoadable(waybill("railway", "pending", null, "pending"), null), + "非公路不可配载"); + assertFalse(LoadableAdmissionSupport.isLoadable(waybill("road", "running", null, null), null), + "进行中不可配载:不需要接单的项目保存后即为进行中,那是过程配置的产物,不代表可配载"); + assertFalse(LoadableAdmissionSupport.isLoadable(waybill("road", "draft", null, null), null)); + assertFalse(LoadableAdmissionSupport.isLoadable(waybill("road", "completed", null, null), null)); + assertFalse(LoadableAdmissionSupport.isLoadable(waybill("road", "cancelled", null, null), null)); + assertFalse(LoadableAdmissionSupport.isLoadable(waybill("road", "pending", "PZ20260101-001", null), null), + "已挂在其它配载单上的票不可再配载"); + assertFalse(LoadableAdmissionSupport.isLoadable(null, null)); + } + + @Test + @DisplayName("不可配载原因按 公路 → 待执行 → 未关联其它配载单 取第一条") + void unloadableReasonFollowsPredicateOrder() { + assertEquals(LoadableAdmissionSupport.UnloadableReason.NOT_ROAD, + LoadableAdmissionSupport.unloadableReason( + waybill("railway", "running", "PZ20260101-002", null), "PZ20260101-001")); + assertEquals(LoadableAdmissionSupport.UnloadableReason.NOT_PENDING, + LoadableAdmissionSupport.unloadableReason( + waybill("road", "running", "PZ20260101-002", null), "PZ20260101-001")); + assertEquals(LoadableAdmissionSupport.UnloadableReason.ON_OTHER_MANIFEST, + LoadableAdmissionSupport.unloadableReason( + waybill("road", "pending", "PZ20260101-002", null), "PZ20260101-001")); + assertNull(LoadableAdmissionSupport.unloadableReason(waybill("road", "pending", null, null), null)); + assertNull(LoadableAdmissionSupport.unloadableReason( + waybill("road", "pending", "PZ20260101-001", null), "PZ20260101-001")); + } + + @Test + @DisplayName("编辑/重新派单:loadingNo 等于本配载单号时不算冲突,其它配载单号仍拒绝") + void ownLoadingNoIsNotAConflict() { + assertTrue(LoadableAdmissionSupport.isLoadable(waybill("road", "pending", "PZ20260101-001", null), "PZ20260101-001")); + assertTrue(LoadableAdmissionSupport.isLoadable(waybill("road", "pending", null, null), "PZ20260101-001")); + assertFalse(LoadableAdmissionSupport.isLoadable(waybill("road", "pending", "PZ20260101-002", null), "PZ20260101-001"), + "挂在别的配载单上时即使正在编辑本单也不能合进来"); + } + + private Waybill waybill(String transportType, String businessStatus, String loadingNo, String driverAcceptStatus) { + Waybill waybill = new Waybill(); + waybill.setTransportType(transportType); + waybill.setBusinessStatus(businessStatus); + waybill.setLoadingNo(loadingNo); + waybill.setDriverAcceptStatus(driverAcceptStatus); + return waybill; + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/OcrFieldSupportTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/OcrFieldSupportTest.java index 52530a56..25330741 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/OcrFieldSupportTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/OcrFieldSupportTest.java @@ -32,7 +32,7 @@ class OcrFieldSupportTest { "words_result": { "号牌号码": {"words": "桂A32523"}, "车辆类型": {"words": "重型厢式货车"}, - "所有人": {"words": "桂物物流集团"}, + "所有人": {"words": "广西现代物流集团有限公司"}, "车辆识别代码": {"words": "LGAX2A130J1001234"}, "档案编号": {"words": "450100123456"}, "核定载质量": {"words": "18000kg"}, @@ -48,7 +48,7 @@ class OcrFieldSupportTest { assertEquals("桂A32523", fields.get("plateNo")); assertEquals("重型厢式货车", fields.get("vehicleType")); - assertEquals("桂物物流集团", fields.get("ownerName")); + assertEquals("广西现代物流集团有限公司", fields.get("ownerName")); assertEquals("LGAX2A130J1001234", fields.get("vin")); assertEquals("450100123456", fields.get("drivingLicenseNo")); assertEquals(18000, fields.get("approvedLoadKg"), "带 kg 单位也要能取到数字"); @@ -124,6 +124,8 @@ class OcrFieldSupportTest { { "words_result": { "姓名": {"words": "黄宇富"}, + "出生": {"words": "1979年11月17日"}, + "住址": {"words": "广西壮族自治区柳州市柳北区跃进路88号"}, "公民身份号码": {"words": "452730197911172318"}, "签发日期": {"words": "2015-03-12"}, "失效日期": {"words": "2035-03-12"} @@ -133,6 +135,8 @@ class OcrFieldSupportTest { Map person = OcrFieldSupport.extract(BaiduOcrType.ID_CARD, "front", idCard); assertEquals("黄宇富", person.get("realName")); assertEquals("452730197911172318", person.get("idCardNo")); + assertEquals("1979-11-17", person.get("birthday"), "「出生」是一串中文日期,要归一成 yyyy-MM-dd 才能填进日期框"); + assertEquals("广西壮族自治区柳州市柳北区跃进路88号", person.get("address")); assertEquals("2015-03-12", person.get("idCardValidStart")); assertEquals("2035-03-12", person.get("idCardValidEnd")); @@ -160,7 +164,7 @@ class OcrFieldSupportTest { { "words_result": { "道路运输证号": {"words": "桂交运管桂字450100123456号"}, - "业户名称": {"words": "桂物物流集团"}, + "业户名称": {"words": "广西现代物流集团有限公司"}, "有效期": {"words": "2020-01-01 至 2030-01-01"} } } @@ -169,7 +173,7 @@ class OcrFieldSupportTest { Map fields = OcrFieldSupport.extract(BaiduOcrType.ROAD_TRANSPORT_CERTIFICATE, null, result); assertEquals("桂交运管桂字450100123456号", fields.get("roadTransportCertNo")); - assertEquals("桂物物流集团", fields.get("ownerName")); + assertEquals("广西现代物流集团有限公司", fields.get("ownerName")); assertEquals("2030-01-01", fields.get("roadTransportCertEndDate"), "止期字段取区间的最后一段,取第一段会把有效期起填成止"); assertEquals("2020-01-01", fields.get("roadTransportCertStartDate"), "区间要同时带出起止,否则用户得照图再敲一遍"); } diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/WaybillProcessSupportTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/WaybillProcessSupportTest.java index fa3334c2..b09c82c4 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/WaybillProcessSupportTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/WaybillProcessSupportTest.java @@ -3,14 +3,18 @@ package org.springblade.transport.support; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import java.time.LocalDateTime; +import java.time.ZoneId; import java.util.Arrays; import java.util.Collections; +import java.util.Date; import java.util.LinkedHashSet; import java.util.List; import java.util.Set; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; /** @@ -123,6 +127,24 @@ class WaybillProcessSupportTest { "「到货」还没打,所以卸货的前置是到货;在途不是前置(它没有完成含义)"); } + @Test + @DisplayName("顺序门禁只点名最先未完成的那一个前置节点,不把没打的前置全列出来") + void namesOnlyTheEarliestUnfinishedPrerequisite() { + Set unfinished = WaybillProcessSupport.unfinishedPrerequisiteKeys( + TRANSPORT_JSON, Collections.emptySet(), "arrival"); + + assertEquals(keys("arrive_scene", "load", "dispatch"), unfinished, "到货前面三个打卡节点都没打"); + assertEquals("到场", WaybillProcessSupport.firstUnfinishedPrerequisiteName(TRANSPORT_JSON, unfinished), + "只报司机现在能去打卡的那一个:装货、发货自己也还打不了,列出来不是可执行的下一步"); + } + + @Test + @DisplayName("没有未完成的前置节点时不点名") + void namesNothingWhenNothingIsUnfinished() { + assertEquals("", + WaybillProcessSupport.firstUnfinishedPrerequisiteName(TRANSPORT_JSON, Collections.emptySet())); + } + @Test @DisplayName("顺序门禁:在途豁免,前置一个都没打也返回空集") void transitIsExemptFromOrderGate() { @@ -312,4 +334,130 @@ class WaybillProcessSupportTest { "TRANSPORT_JSON 的回单启用且没有 confirmMode:缺字段当没配「无需确认完成」,否则会跳过本该人工确认的回单"); } + // ------------------------------------------------------------ 结束门禁 + + @Test + @DisplayName("结束门禁:只认已完成与已取消,大小写不敏感") + void punchEndedOnlyForCompletedAndCancelled() { + assertTrue(WaybillProcessSupport.isPunchEnded("completed")); + assertTrue(WaybillProcessSupport.isPunchEnded("COMPLETED")); + assertTrue(WaybillProcessSupport.isPunchEnded("cancelled")); + assertTrue(WaybillProcessSupport.isPunchEnded("Cancelled")); + } + + @Test + @DisplayName("结束门禁:草稿、待执行、待调度、调度中都不算结束") + void punchEndedExcludesStatusesThatStillAllowPunching() { + for (String status : List.of("draft", "pending", "waiting_dispatch", "dispatching", "running")) { + assertFalse(WaybillProcessSupport.isPunchEnded(status), status + " 不是结束态"); + assertNull(WaybillProcessSupport.punchEndedReason(status), status + " 不该有结束文案"); + } + assertFalse(WaybillProcessSupport.isPunchEnded(null)); + assertFalse(WaybillProcessSupport.isPunchEnded("")); + assertFalse(WaybillProcessSupport.isPunchEnded(" ")); + } + + @Test + @DisplayName("结束门禁文案:完成与取消各一句,非结束态没有文案") + void punchEndedReasonOnlyForEndedStatuses() { + assertEquals("运单已完成", WaybillProcessSupport.punchEndedReason("completed")); + assertEquals("运单已完成", WaybillProcessSupport.punchEndedReason(" COMPLETED ")); + assertEquals("运单已取消", WaybillProcessSupport.punchEndedReason("cancelled")); + assertNull(WaybillProcessSupport.punchEndedReason(null)); + assertNull(WaybillProcessSupport.punchEndedReason("")); + assertNull(WaybillProcessSupport.punchEndedReason(" ")); + } + + @Test + @DisplayName("在途卡:已完成的运单今天打过卡也不可再打,且不算今日已打") + void transitCardOnEndedWaybillIsVisibleButNotPunchable() { + LocalDateTime now = LocalDateTime.of(2026, 10, 20, 12, 0); + // 打卡时间就落在 now 所在的那一天 + Date lastPunchAt = Date.from(now.toLocalDate().atStartOfDay(ZoneId.systemDefault()).plusHours(8).toInstant()); + + WaybillProcessSupport.TransitCheckinDecision decision = + WaybillProcessSupport.evaluateTransitCheckin(TRANSPORT_JSON, "completed", lastPunchAt, now); + + assertTrue(decision.visible(), "卡片仍要下发,司机能回看今天打过没有"); + assertFalse(decision.punchEnabled(), "运单已结束,不能提交打卡"); + assertFalse(decision.doneToday(), "结束不是「今日已打卡」,两者的后续动作不同"); + assertFalse(decision.dueToday()); + } + + @Test + @DisplayName("在途卡回归:进行中且今天没打卡时仍可打") + void transitCardOnRunningWaybillStaysPunchable() { + LocalDateTime now = LocalDateTime.of(2026, 10, 20, 12, 0); + + WaybillProcessSupport.TransitCheckinDecision decision = + WaybillProcessSupport.evaluateTransitCheckin(TRANSPORT_JSON, "running", null, now); + + assertTrue(decision.visible()); + assertTrue(decision.punchEnabled()); + assertTrue(decision.dueToday(), "frequencyDays 默认为 1、无历史打卡,且 noon 落在默认时段内"); + assertFalse(decision.doneToday()); + } + + // ------------------------------------------------------------ 开始运输门禁 + + @Test + @DisplayName("开始门禁:只有进行中算已开始运输") + void transitPunchStartedOnlyRunning() { + assertTrue(WaybillProcessSupport.isTransitPunchStarted("running")); + assertTrue(WaybillProcessSupport.isTransitPunchStarted(" RUNNING ")); + for (String status : List.of("draft", "pending", "waiting_dispatch", "dispatching", "completed", "cancelled")) { + assertFalse(WaybillProcessSupport.isTransitPunchStarted(status), status + " 还没开始运输"); + } + assertFalse(WaybillProcessSupport.isTransitPunchStarted(null)); + assertFalse(WaybillProcessSupport.isTransitPunchStarted("")); + } + + @Test + @DisplayName("开始门禁文案:未开始一句话,进行中与已结束都交给别的门禁") + void punchNotStartedReasonOnlyForNotStartedStatuses() { + assertEquals("运单尚未开始运输,暂不可打卡", WaybillProcessSupport.punchNotStartedReason("pending")); + assertEquals("运单尚未开始运输,暂不可打卡", WaybillProcessSupport.punchNotStartedReason("dispatching")); + assertNull(WaybillProcessSupport.punchNotStartedReason("running")); + // 结束态由结束文案负责,两句话不能抢 + assertNull(WaybillProcessSupport.punchNotStartedReason("completed")); + assertNull(WaybillProcessSupport.punchNotStartedReason("cancelled")); + } + + @Test + @DisplayName("在途卡:未开始运输的运单仍下发卡片,但不可打卡") + void transitCardOnNotStartedWaybillIsVisibleButNotPunchable() { + LocalDateTime now = LocalDateTime.of(2026, 10, 20, 12, 0); + + WaybillProcessSupport.TransitCheckinDecision decision = + WaybillProcessSupport.evaluateTransitCheckin(TRANSPORT_JSON, "dispatching", null, now); + + assertTrue(decision.visible(), "卡片仍要下发,司机要看得到这张卡为什么不能打"); + assertFalse(decision.punchEnabled(), "未开始运输不能提交打卡"); + assertFalse(decision.doneToday()); + assertFalse(decision.dueToday()); + } + + @Test + @DisplayName("同日判重:只认同一个自然日,跨日与空值都不算") + void isPunchedOnComparesByNaturalDay() { + LocalDateTime now = LocalDateTime.of(2026, 10, 20, 12, 0); + Date today = Date.from(LocalDateTime.of(2026, 10, 20, 8, 0).atZone(ZoneId.systemDefault()).toInstant()); + Date yesterday = Date.from(LocalDateTime.of(2026, 10, 19, 23, 0).atZone(ZoneId.systemDefault()).toInstant()); + + assertTrue(WaybillProcessSupport.isPunchedOn(today, now)); + assertFalse(WaybillProcessSupport.isPunchedOn(yesterday, now)); + assertFalse(WaybillProcessSupport.isPunchedOn(null, now)); + } + + @Test + @DisplayName("上报状态门禁:只有进行中可报,未开始与已结束各有各的话") + void reportBlockedReasonOnlyForRunningWaybill() { + assertNull(WaybillProcessSupport.reportBlockedReason("running")); + assertEquals("运单尚未开始运输,暂不可上报异常", WaybillProcessSupport.reportBlockedReason("pending")); + assertEquals("运单尚未开始运输,暂不可上报异常", WaybillProcessSupport.reportBlockedReason("dispatching")); + // 结束优先:已完成/已取消先说结束,不提「等你上路」 + assertEquals("运单已完成,不能上报异常", WaybillProcessSupport.reportBlockedReason("completed")); + assertEquals("运单已取消,不能上报异常", WaybillProcessSupport.reportBlockedReason("cancelled")); + } + } diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/config/BfiMockConfig.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/config/BfiMockConfig.java new file mode 100644 index 00000000..fcf24015 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/config/BfiMockConfig.java @@ -0,0 +1,64 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.thirdparty.bfi.config; + +import org.springblade.thirdparty.bfi.feign.IBfiClient; +import org.springblade.thirdparty.bfi.mock.BfiClientMock; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; + +/** + * BFI 客户端 Mock 配置 + *

+ * thirdParty.bfi.mock-enabled=true(Nacos 单入口总开关)时注册 @Primary 的 mock 实现, + * 覆盖同类型 Feign 客户端 bean,{@link org.springblade.thirdparty.bfi.service.impl.BfiServiceImpl} 注入的 {@link IBfiClient} + * 无感知切换为"有桩用桩、没桩走接口":{mockDir}/{方法名}.json 有桩走本地 JSON 桩, + * 无桩透传真实 Feign 接口({@link BfiClientMock},目录经 thirdParty.bfi.mockDir 配置),调用侧零改动。 + * 仅本地自测/演示环境开启,生产必须保持 false。 + * + * @author Chill + */ +@Configuration +public class BfiMockConfig { + + /** + * mock 版 BFI 客户端(@Primary 优先于 Feign 客户端注入) + * + * @param properties BFI 配置(取桩文件目录 mockDir) + * @param realClientProvider 真实客户端候选 + * @return mock 客户端 + */ + @Bean + @Primary + // @ConditionalOnProperty 不支持宽松绑定,key 必须与 Nacos 实际键完全一致(本仓库 Nacos 为 thirdParty.bfi.* 驼峰根) + @ConditionalOnProperty(name = "thirdParty.bfi.mock-enabled", havingValue = "true") + public IBfiClient bfiClientMock(BfiProperties properties, ObjectProvider realClientProvider) { + return BfiClientMock.create(properties.getMockDir(), realClientProvider); + } +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/config/BfiProperties.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/config/BfiProperties.java index dd979537..fd0ef5de 100644 --- a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/config/BfiProperties.java +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/config/BfiProperties.java @@ -65,7 +65,12 @@ public class BfiProperties { */ private String language = "zh_CN"; /** - * 是否启用 mock 模式(开发调试用) + * 是否启用 mock 模式(开发调试用):开启后 IBfiClient 走本地桩目录(有桩用桩、无桩透传真实接口,见 BfiClientMock) */ private boolean mockEnabled = false; + /** + * mock 客户端桩文件目录,文件名为接口方法名:{mockDir}/{方法名}.json,如 payApplyQuery.json; + * 仅 mock-enabled=true 生效,文件每次调用现读,改文件即生效 + */ + private String mockDir = "./mock/bfi"; } diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/feign/IBfiClient.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/feign/IBfiClient.java index fea916a7..8e321535 100644 --- a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/feign/IBfiClient.java +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/feign/IBfiClient.java @@ -85,7 +85,8 @@ import java.util.List; * * @author Chill */ -@FeignClient(name = "BFI", url = "${thirdParty.bfi.baseUrl}", configuration = BfiFeignClientConfig.class) +// primary = false:Feign 客户端默认 primary=true,开启 mock(BfiMockConfig 注册 @Primary 实现)会双 primary 启动失败 +@FeignClient(name = "BFI", url = "${thirdParty.bfi.baseUrl}", configuration = BfiFeignClientConfig.class, primary = false) public interface IBfiClient { String GET_TOKEN_URL = "${thirdParty.bfi.getTokenUrl:/ierp/kapi/oauth2/getToken}"; diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/mock/BfiClientMock.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/mock/BfiClientMock.java new file mode 100644 index 00000000..795d324c --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/mock/BfiClientMock.java @@ -0,0 +1,176 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.thirdparty.bfi.mock; + +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.type.TypeFactory; +import feign.Response; +import lombok.extern.slf4j.Slf4j; +import org.springblade.core.tool.jackson.JsonUtil; +import org.springblade.thirdparty.bfi.exception.BfiException; +import org.springblade.thirdparty.bfi.feign.IBfiClient; +import org.springblade.thirdparty.bfi.pojo.vo.BfiResultVO; +import org.springframework.beans.factory.ObjectProvider; + +import java.io.InputStream; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * BFI 客户端本地 Mock(thirdParty.bfi.mock-enabled=true 时经 BfiMockConfig 注册为 @Primary,包装真实 Feign 客户端) + *

+ * 单入口总开关,方法级混合:每个 IBfiClient 方法对应一个外部目录桩文件 {mockDir}/{方法名}.json(thirdParty.bfi.mockDir 配置), + * 有桩即走 mock(内容即金蝶真实响应报文,含 BfiResultVO/BfiOpenApiResultVO 包装,按方法返回泛型反序列化,调用侧无感知), + * 无桩自动透传真实 Feign 接口,实现"有桩用桩、没桩走接口"。 + * 桩文件每次调用现读,改文件即生效无需重启;桩解析失败返回失败的 BfiResultVO,不静默吞错。 + * 文件下载接口 invoiceDownload 返回 feign.Response,无法按 JSON 桩反序列化,始终透传真实接口。 + * + * @author Chill + */ +@Slf4j +public class BfiClientMock implements InvocationHandler { + + /** + * 桩文件后缀(文件名即接口方法名) + */ + private static final String MOCK_FILE_SUFFIX = ".json"; + + /** + * 桩文件目录 + */ + private final Path mockDir; + + /** + * 真实客户端候选(排除 mock 自身,运行期首次透传时解析并缓存) + */ + private final ObjectProvider realClientProvider; + + /** + * 真实 Feign 客户端缓存(double-checked locking) + */ + private volatile IBfiClient realClient; + + /** + * 构建 mock 版 IBfiClient + * + * @param mockDir 桩文件目录(third-party.bfi.mock-dir) + * @param realClientProvider 真实客户端候选(容器内 IBfiClient 全量,含 mock 自身,解析时排除) + * @return mock 客户端 + */ + public static IBfiClient create(String mockDir, ObjectProvider realClientProvider) { + return (IBfiClient) Proxy.newProxyInstance(IBfiClient.class.getClassLoader(), + new Class[]{IBfiClient.class}, new BfiClientMock(mockDir, realClientProvider)); + } + + private BfiClientMock(String mockDir, ObjectProvider realClientProvider) { + this.mockDir = Path.of(mockDir); + this.realClientProvider = realClientProvider; + } + + @Override + public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { + // Object 基础方法(toString/hashCode/equals)直接执行 + if (method.getDeclaringClass() != IBfiClient.class) { + return method.invoke(this, args); + } + // 文件下载返回 feign.Response,无法按 JSON 桩反序列化,始终透传 + if (method.getReturnType() == Response.class) { + return this.invokeReal(method, args, proxy); + } + return this.loadMock(method, args, proxy); + } + + /** + * 有桩读桩,无桩透传真实接口 + * + * @param method 接口方法 + * @param args 入参 + * @param proxy mock 代理自身(解析真实客户端时排除) + * @return 桩数据或真实接口响应 + */ + private Object loadMock(Method method, Object[] args, Object proxy) throws Throwable { + Path mockFile = this.mockDir.resolve(method.getName() + MOCK_FILE_SUFFIX); + if (!Files.exists(mockFile)) { + log.debug("BFI mock 桩文件缺失,透传真实接口:{}", method.getName()); + return this.invokeReal(method, args, proxy); + } + // 外部桩每次调用现读,改文件即生效 + try (InputStream inputStream = Files.newInputStream(mockFile)) { + JavaType javaType = TypeFactory.defaultInstance().constructType(method.getGenericReturnType()); + return JsonUtil.getInstance().readValue(inputStream, javaType); + } catch (Exception exception) { + log.error("BFI mock 桩文件解析失败:{}", mockFile.toAbsolutePath(), exception); + return this.failResult("BFI mock 桩文件解析失败:" + mockFile.toAbsolutePath()); + } + } + + /** + * 透传真实 Feign 客户端(首次调用时懒解析,排除 mock 代理自身,异常原样抛出) + * + * @param method 接口方法 + * @param args 入参 + * @param proxy mock 代理自身 + * @return 真实接口响应 + * @throws Throwable 真实调用异常(Feign 降级/业务异常原样透传) + */ + private Object invokeReal(Method method, Object[] args, Object proxy) throws Throwable { + IBfiClient real = this.realClient; + if (real == null) { + synchronized (this) { + if (this.realClient == null) { + this.realClient = this.realClientProvider.stream() + .filter(client -> client != proxy) + .findFirst() + .orElseThrow(() -> new BfiException("BFI mock 桩缺失且未找到真实 BFI 客户端:" + method.getName())); + } + real = this.realClient; + } + } + try { + return method.invoke(real, args); + } catch (InvocationTargetException exception) { + throw exception.getCause(); + } + } + + /** + * 构造失败的 BfiResultVO(桩解析异常时兜底,避免调用侧拿到 null) + * + * @param msg 失败原因 + * @return 失败结果 + */ + private BfiResultVO failResult(String msg) { + BfiResultVO result = new BfiResultVO<>(); + result.setStatus(false); + result.setErrorCode("errors.mock"); + result.setMessage(msg); + return result; + } +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiBillSaveResultItemVO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiBillSaveResultItemVO.java index 341cdca8..11424966 100644 --- a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiBillSaveResultItemVO.java +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiBillSaveResultItemVO.java @@ -25,6 +25,7 @@ */ package org.springblade.thirdparty.bfi.pojo.vo; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.databind.annotation.JsonSerialize; import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; @@ -91,4 +92,16 @@ public class BfiBillSaveResultItemVO implements Serializable { .filter(text -> !text.isEmpty()) .collect(Collectors.joining(";")); } + + /** + * 失败定位信息:金蝶单据号,供异常提示展示, + * 「已提交状态下的单据不允许修改」等场景可凭金蝶单据号直接定位星瀚侧单据 + * + * @return 定位信息文本,形如(金蝶单据:xxx),无可用信息返回空串 + */ + @JsonIgnore + public String getFailMark() { + String billNo = keys == null ? null : keys.get("billno"); + return billNo == null || billNo.isBlank() ? "" : "(金蝶单据:" + billNo + ")"; + } } diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiBlacklistVO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiBlacklistVO.java index e8fe9d91..fbbd3fb8 100644 --- a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiBlacklistVO.java +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiBlacklistVO.java @@ -94,6 +94,7 @@ public class BfiBlacklistVO implements Serializable { /** * 失信类别[A:禁止交易类, B:风险警示类] */ + @JsonProperty("awrp_combofield") private String awrpCombofield; /** * 失信类别标题 diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/service/impl/BfiServiceImpl.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/service/impl/BfiServiceImpl.java index 0cc4b18c..de5f8b77 100644 --- a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/service/impl/BfiServiceImpl.java +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/service/impl/BfiServiceImpl.java @@ -79,6 +79,7 @@ import org.springblade.thirdparty.bfi.pojo.vo.BfiApBillVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiArBillVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiAttachmentUploadVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiBillLockResultVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultItemVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiBlacklistVO; import org.springblade.thirdparty.bfi.pojo.vo.BfiClaimAnnounceVO; @@ -112,6 +113,7 @@ import java.util.List; import java.util.Optional; import java.util.UUID; import java.util.function.Supplier; +import java.util.stream.Collectors; /** * 业财融合(BFI)服务实现 @@ -655,7 +657,7 @@ public class BfiServiceImpl implements IBfiService { String message = Optional.ofNullable(result).map(BfiResultVO::getMessage).filter(StringUtil::isNotBlank) .orElseGet(() -> Optional.ofNullable(result).map(BfiResultVO::getErrorCode) .map(errorCode -> "错误码 " + errorCode).orElse("响应为空或解析失败")); - throw new BfiException("调用BFI接口" + type + "异常 " + message); + throw new BfiException("调用BFI接口" + type + "异常 " + message + billSaveFailDetail(result)); } catch (BfiException e) { log.error("调用BFI接口 {},BfiException异常", type, e); throw e; @@ -665,6 +667,26 @@ public class BfiServiceImpl implements IBfiService { } } + /** + * 单据保存失败定位后缀:取失败分录的金蝶单据号,非单据保存类调用返回空串 + * + * @param result BFI 响应 + * @param 响应数据类型 + * @return 定位后缀,形如 (金蝶单据:xxx) + */ + private String billSaveFailDetail(BfiResultVO result) { + if (result == null || !(result.getData() instanceof BfiBillSaveResultVO billSave) + || billSave.getResult() == null || billSave.getResult().isEmpty()) { + return ""; + } + String detail = billSave.getResult().stream() + .filter(item -> !Boolean.TRUE.equals(item.getBillStatus())) + .map(BfiBillSaveResultItemVO::getFailMark) + .filter(mark -> !mark.isEmpty()) + .collect(Collectors.joining(";")); + return detail.isEmpty() ? "" : " " + detail; + } + /** * 收票 rim/message 契约公共调用方法,成功错误码兼容 0 与 0000 * diff --git a/blade-third-party-api/blade-oa-api/src/main/java/org/springblade/thirdparty/oa/constant/OAConvertConstant.java b/blade-third-party-api/blade-oa-api/src/main/java/org/springblade/thirdparty/oa/constant/OAConvertConstant.java index e157641b..10144bb0 100644 --- a/blade-third-party-api/blade-oa-api/src/main/java/org/springblade/thirdparty/oa/constant/OAConvertConstant.java +++ b/blade-third-party-api/blade-oa-api/src/main/java/org/springblade/thirdparty/oa/constant/OAConvertConstant.java @@ -1,5 +1,7 @@ package org.springblade.thirdparty.oa.constant; +import java.util.Set; + /** * OA转换常量类 * @author bfhuange @@ -33,11 +35,15 @@ public class OAConvertConstant { /** * OA 同步挂载的本地顶级组织主键(集团) */ - public static final Long OA_SYNC_ROOT_DEPT_ID = 135L; + public static final Long OA_SYNC_ROOT_DEPT_ID = 1L; /** - * OA 同步挂载的本地顶级组织默认名称(集团;已存在时不覆盖原名称,除非误写成外部组织) + * 历史集团顶级主键(迁移前为 id=135) */ - public static final String OA_SYNC_ROOT_DEPT_NAME = "桂物物流集团"; + public static final Long LEGACY_OA_SYNC_ROOT_DEPT_ID = 135L; + /** + * OA 同步挂载的本地顶级组织默认名称(集团) + */ + public static final String OA_SYNC_ROOT_DEPT_NAME = "广西现代物流集团有限公司"; /** * OA 同步挂载的本地顶级组织编码(集团) */ @@ -55,11 +61,15 @@ public class OAConvertConstant { */ public static final String LEGACY_ROOT_DEPT_CODE = "OACROOT"; /** - * 历史人员同步创建的伪顶级名称(与集团同名时仅清理非 id=135 的记录) + * 历史集团顶级名称(迁移/清理旧名时使用) */ public static final String LEGACY_ROOT_DEPT_NAME = "桂物物流集团"; /** * 同步人员时挂载的顶级组织名称(兼容旧引用) */ public static final String ROOT_COMPANY_NAME = OA_SYNC_ROOT_DEPT_NAME; + /** + * OA 同步保留的二级公司主键(OA 侧 id) + */ + public static final Set OA_SYNC_KEEP_SECOND_LEVEL_COMPANY_IDS = Set.of("2", "135"); } diff --git a/doc/docs/业务流程/付款申请同步金蝶(星瀚)技术文档.md b/doc/docs/业务流程/2026-09-16-付款申请同步金蝶(星瀚)技术文档.md similarity index 95% rename from doc/docs/业务流程/付款申请同步金蝶(星瀚)技术文档.md rename to doc/docs/业务流程/2026-09-16-付款申请同步金蝶(星瀚)技术文档.md index 8f2980b7..13b52fd4 100644 --- a/doc/docs/业务流程/付款申请同步金蝶(星瀚)技术文档.md +++ b/doc/docs/业务流程/2026-09-16-付款申请同步金蝶(星瀚)技术文档.md @@ -131,7 +131,8 @@ TMS 付款申请审核通过 | --- | --- | --- | --- | | awrp_src_code | 来源单据号(防重键) | `paymentNo` | 付款申请单号 | | awrp_src_sys | 外部系统编号(防重键) | 配置 `payApply.srcSys` | 默认 `WLTMS` | -| settleorg_number / applyorg_number / payorg_number / purorg_number | 结算/申请/付款/采购组织编码 | 合同所属组织 | `organizationId` → 组织编码 `deptCode`,为空回退组织名称;缺失报错 | +| settleorg_number / applyorg_number / payorg_number | 结算/申请/付款组织编码 | 合同关联项目业务部门上溯平台公司 | `kingdeeOrgNumber` → 平台公司 `blade_dept.kingdee_dept_code`;缺失报错 | +| awrp_department_number | 部门编码 | 项目业务部门自身 | `kingdeeDeptNumber` → 业务部门 `blade_dept.kingdee_dept_code`(部门级编码,不上溯平台公司);未维护报错 | | billtype_number | 单据类型编码 | 配置 `payApply.billtype` | 默认 `ap_payapply_oth_BT_S` 其他付款申请 | | applydate | 申请日期 | `applyDate` | yyyy-MM-dd,空取当天 | | applycause | 请款事由 | paymentType 映射 | 见 5.1 | @@ -158,7 +159,7 @@ TMS 付款申请审核通过 | awrp_entry_code | 对方唯一行 id(防重) | `paymentNo + "-1"` | 不得重复 | | e_remark | 备注 | `remark` | 可选 | -未传的可选字段:purdept_number、awrp_department_number、e_duedate、e_settlementtype_number、e_costcenter_number、awrp_src_con_eno、awrp_splb_number。 +未传的可选字段:purorg_number、purdept_number、e_duedate、e_settlementtype_number、e_costcenter_number、awrp_src_con_eno、awrp_splb_number。 > 前置校验:推送前按 `contractId` 加载合同,未关联合同、合同不存在、所属组织编码为空、结算币种为空时,同步失败并标记 `kingdeeStatus = failed`。 @@ -222,7 +223,7 @@ TMS 付款申请审核通过 | 场景 | 行为 | | --- | --- | | 单笔同步 `syncKingdee` | 校验失败/推送失败置 `failed` 并抛 `ServiceException`,前端提示原因(含金蝶 errors 明细) | -| 批量同步 `syncKingdeeBatch` | 逐单循环,**单条失败不中断批次**;失败单标记 `failed` 后继续下一单;返回结果列表中附 `付款申请[id]同步失败:原因` | +| 批量同步 `syncKingdeeBatch` | 逐单循环,**单条失败不中断批次**;失败单标记 `failed` 后继续下一单;返回结果列表中附 `付款申请[付款申请单号]同步失败:原因` | | 金蝶接口级失败(status=false / errorCode≠0) | `BfiServiceImpl.commonCall` 抛 `BfiException`,同上处理 | | 金蝶单据级失败(billStatus=false) | 以 `errors` 拼接抛出 | | 重复点击同步 | 已 `synced` 单据跳过;`failed`/`unsynced` 允许重推(防重键保证金蝶侧幂等) | diff --git a/doc/docs/业务流程/2026-09-16-付款管理正式付款同步金蝶(财务应付单+付款申请下推)技术文档.md b/doc/docs/业务流程/2026-09-16-付款管理正式付款同步金蝶(财务应付单+付款申请下推)技术文档.md index c0df77c1..a7e9df35 100644 --- a/doc/docs/业务流程/2026-09-16-付款管理正式付款同步金蝶(财务应付单+付款申请下推)技术文档.md +++ b/doc/docs/业务流程/2026-09-16-付款管理正式付款同步金蝶(财务应付单+付款申请下推)技术文档.md @@ -2,7 +2,7 @@ > 适用系统:TMS(BladeX 微服务平台,服务:blade-transport) > 对接系统:金蝶云·星瀚(业财融合 BFI,经 iPaaS 网关) -> 关联文档:`doc/docs/TMS业财融合-集成流程图文字版与业务流程梳理.md`、`doc/docs/业财融合系统接口文档.xlsx`、`doc/docs/业务流程/付款申请同步金蝶(星瀚)技术文档.md`(A103-3 预付款路径) +> 关联文档:`doc/docs/TMS业财融合-集成流程图文字版与业务流程梳理.md`、`doc/docs/业财融合系统接口文档.xlsx`、`doc/docs/业务流程/2026-09-16-付款申请同步金蝶(星瀚)技术文档.md`(A103-3 预付款路径) > 最后更新:2026-09-18(字段映射与联调默认值按当前代码重新梳理) --- @@ -15,7 +15,7 @@ - 对接接口: - **A102-2 财务应付单保存** `POST /ierp/kapi/v2/awrp/ap/ap/ap_finapbill/save` - **A103-1 付款申请保存(应付下推)** `POST /ierp/kapi/v2/awrp/ap/ap/ap_finapbill/push` -- 路径划分:**正式付款**(settlement_payment)走本文档 A102-2 → A103-1 两步链路;**预付款**(project_advance / progress_advance)维持原 A103-3 直推付款申请单,见《付款申请同步金蝶(星瀚)技术文档》 +- 路径划分:**正式付款**(settlement_payment)走本文档 A102-2 → A103-1 两步链路;**预付款**(project_advance / progress_advance)维持原 A103-3 直推付款申请单,见《2026-09-16-付款申请同步金蝶(星瀚)技术文档》 - 调用方式:前端 → TMS 后端转发 → 星瀚(OAuth2 凭证仅存后端,前端不持有) - 落库结果:A103-1 返回的付款申请单 id 写入 `kingdee_bill_no`(既有字段);**A102-2 返回的应付单 id 不落库**,仅校验保存成功,后续如需可经 A102-4 按防重键(`awrp_src_sys` + `awrp_src_code`)查询(`BfiServiceImpl.queryApBill` 已封装),本次无表结构变更 @@ -216,7 +216,7 @@ A102-2 所需的金蝶侧固定档案编码**不进 Nacos 配置**,统一定 | --- | --- | | 单笔同步 `syncKingdee` | 校验失败/推送失败置 `failed` 并抛 `ServiceException`,前端提示原因(含金蝶 errors 明细) | | ①成功②失败 | `kingdeeStatus = failed`;重推时①按防重键走更新语义,继续② | -| 批量同步 `syncKingdeeBatch` | 逐单循环,**单条失败不中断批次**;返回结果列表附 `付款申请[id]同步失败:原因` | +| 批量同步 `syncKingdeeBatch` | 逐单循环,**单条失败不中断批次**;返回结果列表附 `付款申请[付款申请单号]同步失败:原因` | | 货物类型未维护档案编码 / 计量单位为空 | 分录解析(`collectEntryGroups`)时拦截,报错提示补充档案 | | 合同乙方为空 / 客商档案未维护客商编码 | 往来户取值(`bfiAsstactNumber`)时拦截,报错提示合同号与乙方名称 | | 金蝶接口级失败 | `BfiServiceImpl.commonCall` 抛 `BfiException`,同上处理 | diff --git a/doc/docs/业务流程/2026-09-17-付款管理金蝶付款结果定时回写(A103-2付款申请单查询+A103-4付款处理查询)技术文档.md b/doc/docs/业务流程/2026-09-17-付款管理金蝶付款结果定时回写(A103-2付款申请单查询+A103-4付款处理查询)技术文档.md index 46d7f4d1..4f80f684 100644 --- a/doc/docs/业务流程/2026-09-17-付款管理金蝶付款结果定时回写(A103-2付款申请单查询+A103-4付款处理查询)技术文档.md +++ b/doc/docs/业务流程/2026-09-17-付款管理金蝶付款结果定时回写(A103-2付款申请单查询+A103-4付款处理查询)技术文档.md @@ -2,7 +2,7 @@ > 适用系统:TMS(BladeX 微服务平台,服务:blade-transport) > 对接系统:金蝶云·星瀚(业财融合 BFI,经 iPaaS 网关) -> 关联文档:`doc/docs/TMS业财融合-集成流程图文字版与业务流程梳理.md`(4.3 节步骤 11)、`付款申请同步金蝶(星瀚)技术文档.md`、`2026-09-16-付款管理正式付款同步金蝶(财务应付单+付款申请下推)技术文档.md`、`2026-09-17-BFI分页查询通用翻页工具(BfiUtil-queryAllPages)技术文档.md` +> 关联文档:`doc/docs/TMS业财融合-集成流程图文字版与业务流程梳理.md`(4.3 节步骤 11)、`2026-09-16-付款申请同步金蝶(星瀚)技术文档.md`、`2026-09-16-付款管理正式付款同步金蝶(财务应付单+付款申请下推)技术文档.md`、`2026-09-17-BFI分页查询通用翻页工具(BfiUtil-queryAllPages)技术文档.md` > 最后更新:2026-09-17 --- diff --git a/doc/docs/业务流程/2026-09-29-预付款申请A103-3金蝶链路补齐方案(e_bebank_number统一-消失解锁-回写默认开启).md b/doc/docs/业务流程/2026-09-29-预付款申请A103-3金蝶链路补齐方案(e_bebank_number统一-消失解锁-回写默认开启).md index 130d952c..c72838ed 100644 --- a/doc/docs/业务流程/2026-09-29-预付款申请A103-3金蝶链路补齐方案(e_bebank_number统一-消失解锁-回写默认开启).md +++ b/doc/docs/业务流程/2026-09-29-预付款申请A103-3金蝶链路补齐方案(e_bebank_number统一-消失解锁-回写默认开启).md @@ -4,7 +4,7 @@ > 关联文档: > - `doc/docs/TMS业财融合-集成流程图文字版与业务流程梳理.md`(图03 财务应付及付款,步骤4 推送金蝶 / 步骤13 异常回收) > - `doc/adr/0005-B901事件驱动双表锁单模型.md`(UNLOCK 置失败态不自动重推;A103-2 连续两轮查询消失兜底复用 `unlockBill()`) -> - `doc/docs/业务流程/付款申请同步金蝶(星瀚)技术文档.md`(A103-3 接口定义) +> - `doc/docs/业务流程/2026-09-16-付款申请同步金蝶(星瀚)技术文档.md`(A103-3 接口定义) > - `doc/docs/业务流程/2026-09-17-付款管理金蝶付款结果定时回写(A103-2付款申请单查询+A103-4付款处理查询)技术文档.md` > - `doc/docs/业务流程/2026-09-18-付款管理金蝶同步全链路逻辑梳理(推送链路与回写链路连贯性).md` diff --git a/doc/docs/业务流程/2026-10-09-金蝶单据锁定与解锁业务流程(lockBusiData锁定-unLockNotifyBusiData回调-失败补偿-删单还原).md b/doc/docs/业务流程/2026-10-09-金蝶单据锁定与解锁业务流程(lockBusiData锁定-unLockNotifyBusiData回调-失败补偿-删单还原).md index 0424a375..6e03d642 100644 --- a/doc/docs/业务流程/2026-10-09-金蝶单据锁定与解锁业务流程(lockBusiData锁定-unLockNotifyBusiData回调-失败补偿-删单还原).md +++ b/doc/docs/业务流程/2026-10-09-金蝶单据锁定与解锁业务流程(lockBusiData锁定-unLockNotifyBusiData回调-失败补偿-删单还原).md @@ -1,6 +1,7 @@ # 金蝶单据锁定与解锁业务流程(lockBusiData 锁定 - unLockNotifyBusiData 回调 - 失败补偿 - 删单还原) -- 日期:2026-10-09(2026-10-10 变更:① lockBusiData 确认为 1对1 接口,不支持逗号拼接批量,锁定/补偿改为逐张调用;② 付款申请单纳入锁单范围并完成接线) +- 日期:2026-10-09(2026-10-10 变更:① lockBusiData 确认为 1对1 接口,不支持逗号拼接批量,锁定/补偿改为逐张调用;② 付款申请单纳入锁单范围并完成接线。 + 2026-10-15 联调修正:③ 回调方向与锁定相反,解锁定位改为「金蝶单号+我方单号」双条件反查(targetBillType 回调为空不参与匹配);④ 回调处理批量化去 N+1;⑤ 付款申请 DELETE 还原链路实测通过;⑥ 解锁终态页面标识通用方案(KingdeeUnlockMark)) - 范围:正式结算单(应付 A102-2 / 应收 A302-2)、开票申请单(A303-1)、付款申请单(A103-1 应付下推 / A103-3 直接保存,2026-10-10 新纳入)推送金蝶成功后的单据锁定、金蝶解锁/删单回调、锁定失败定时补偿 - 设计文档:`docs/superpowers/specs/2026-10-09-kingdee-bill-lock-unlock-design.md` - 锁记录表:`blade_kingdee_bill_lock`(一行 = 一张金蝶目标单) @@ -46,31 +47,71 @@ ### 2. 金蝶侧解锁回调(金蝶 → WLTMS) -金蝶在星瀚侧对单据解锁或删单后,回调 `POST /kingdee-bfi/unLockNotifyBusiData`(`UNLOCK`;targetBillNo 兼容单张/逗号拼接): +金蝶在星瀚侧对单据解锁或删单后,回调 `POST /kingdee-bfi/unLockNotifyBusiData`(`UNLOCK`)。 + +**回调方向与锁定相反(2026-10-15 真实报文确认)**:lockBusiData 由 WLTMS 发起(source=我方、target=金蝶), +unLockNotifyBusiData 由金蝶发起(source=金蝶、target=我方)。因此回调报文中: +`sourceBillNo`=金蝶单号(即锁记录 `targetBillNo`)、`targetBillNo`=WLTMS 单号(即锁记录 `sourceBillNo`)、 +`targetBillType` **实测为空**(不参与匹配)。真实报文示例: + +```json +{"lockAction":"UNLOCK","operationType":"DELETE","operator":"刘泉佳","sourceBillNo":"FKSQ-00000227", + "sourceBillType":"ap_payapply","sourceSystem":"XH","targetBillNo":"FKD2026101400001", + "targetBillType":"","targetSystem":"WLTMS","time":"2026-10-15 10:10:56"} +``` + +处理逻辑(`KingdeeBillLockServiceImpl.onUnlockNotify`): ``` -校验:sourceSystem=XH、targetSystem=WLTMS、lockAction=UNLOCK(否则应答失败) - │ - ├─ operationType=SAVE(仅解锁,没删单) - │ └─ 锁记录 → unlocked,记录回调操作人/时间,解锁动作留痕 - │ - └─ operationType=DELETE(金蝶把单删了) - ├─ 锁记录 → unlocked(同上) - └─ 按 targetBillType 反查 WLTMS 单据类型,分发还原: - ├─ SETTLEMENT → 结算单作废(当前节点→已作废), - │ 同步信息还原推送前状态:kingdeeSyncStatus→unsynced、 - │ 清 kingdeeBillNo/kingdeeBillId/syncedTime - │ 变更流水记"金蝶删单回调自动废除",操作人=金蝶报文 operator - ├─ INVOICE_APPLY → 开票申请作废,kingdeeStatus→unsynced、清单据编号, - │ 刷新关联结算单开票状态,动作流水记 kingdee_delete - └─ PAY_APPLY → 付款申请复用 unlockBill(id, "金蝶删单回调")(ADR-0005 口径): - kingdeeStatus→生成失败、清 kingdeeBillId/kingdeeBillNo(原值日志留档), - 不作废源单、不自动重推,人工核实后重推(A103-2 勾稽预检防重复推单) +校验:sourceSystem=XH、targetSystem=WLTMS、lockAction=UNLOCK、sourceBillNo/targetBillNo 非空(否则应答失败) + │ + ├─ 一次 IN 查询:target_bill_no in (回调sourceBillNo拆分, 支持逗号批量) and source_bill_no = 回调targetBillNo, + │ 按 id 降序取回,内存归并出各金蝶单号最新一条锁记录(避免逐张查询 N+1) + │ + ├─ 逐张匹配:无记录或非 locked → 幂等跳过(warn 留痕,重复通知防金蝶重试风暴) + │ + ├─ 命中 locked → 解锁状态批量落库:lock_status→unlocked、operationType、unlockTime、notifyOperator + │ (updateBatchById 一次批量更新,避免逐张 update N+1) + │ + └─ operationType=DELETE(金蝶把单删了)→ 按「锁记录自身 targetBillType」反查 WLTMS 单据类型,分发还原: + ├─ SETTLEMENT → 结算单作废(当前节点→已作废), + │ 同步信息还原推送前状态:kingdeeSyncStatus→unsynced、 + │ 清 kingdeeBillNo/kingdeeBillId/syncedTime + │ 变更流水记"金蝶删单回调自动废除",操作人=金蝶报文 operator + ├─ INVOICE_APPLY → 开票申请作废,kingdeeStatus→unsynced、清单据编号, + │ 刷新关联结算单开票状态,动作流水记 kingdee_delete + └─ PAY_APPLY → 付款申请复用 unlockBill(id, "金蝶删单回调")(ADR-0005 口径): + kingdeeStatus→生成失败、清 kingdeeBillId/kingdeeBillNo(原值日志留档), + 不作废源单、不自动重推,人工核实后重推(A103-2 勾稽预检防重复推单) + 应答:{updateCount: 处理张数, updateResultList: [1]} ``` 重复回调幂等:单据已作废或锁记录已 unlocked → 该张跳过,不重复还原。 +**事务语义**:整个回调处理在 `onUnlockNotify` 单一事务内(还原分支 `voidByKingdee`/`unlockBill` 均为 REQUIRED 传播加入同事务)—— +任一张还原失败,**整批回滚**(包括已批量落库的解锁状态),应答失败触发金蝶重试,重试时按幂等口径从头重处理。 +不存在"部分成功"中间态;若单张问题单阻塞整批回调,需人工处理后金蝶重试自动闭环。 + +**解锁后数据变化**(2026-10-15 实测口径): + +锁记录(三类单据通用,`blade_kingdee_bill_lock`): + +| 字段 | 变化 | +| --- | --- | +| lock_status | locked → unlocked(终态) | +| operation_type | 回调报文 operationType(SAVE-金蝶锁单(保存触发解锁,单据仍在)/ DELETE-金蝶删单) | +| unlock_time | WLTMS 处理时间(注意:非金蝶报文 time,金蝶侧操作时间以日志为准) | +| notify_operator | 回调报文 operator(金蝶侧操作人) | + +业务单据(按类型): + +| 业务单据 | 作废状态 | 金蝶同步信息 | 其他 | +| --- | --- | --- | --- | +| 正式结算单(SETTLEMENT) | approval_status → voided(已作废,终态),当前节点→已作废 | kingdeeSyncStatus → unsynced,清 kingdeeBillNo/kingdeeBillId/syncedTime | 变更流水记"金蝶删单回调自动废除",操作人=金蝶报文 operator | +| 开票申请(INVOICE_APPLY) | approval_status → voided(作废) | kingdeeStatus → unsynced,清单据编号 | 刷新关联结算单开票状态;动作流水记 kingdee_delete | +| 付款申请(PAY_APPLY) | **不作废**,approval_status 不变 | kingdeeStatus synced/paying → failed(仅这两态可变,幂等守卫),清 kingdeeBillId/kingdeeBillNo(原值日志留档) | 不自动重推,人工核实后重推;页面靠解锁标识区分金蝶删单与推送失败 | + ### 3. 锁定失败补偿(定时兜底,不依赖人工) ``` @@ -100,7 +141,7 @@ failed(锁定失败,待补偿)→ locked(金蝶锁定中)→ unlocked │ │ │ ├─ 锁定成功:locked(冻结,不可动) │ │ │ - │ │ ├─ 金蝶解锁(SAVE):unlocked,流水留痕 + │ │ ├─ 金蝶锁单(SAVE):unlocked,流水留痕 │ │ └─ 金蝶删单(DELETE):作废 + 同步信息还原 unsynced │ │ (结算明细/付款/开票关联的后续动作 │ │ 由各自前置校验自然拦截重推) @@ -113,8 +154,9 @@ failed(锁定失败,待补偿)→ locked(金蝶锁定中)→ unlocked 1. **先占位再锁**:锁记录落库先于远程调用,补偿任务扫 `failed` 就是完整的待重试集合,不漏单 2. **锁失败不回滚推送**:金蝶单已生成,本地回滚只会制造断链;代价是短暂"已推未锁"窗口,由补偿任务闭合 3. **1对1逐张锁**:lockBusiData 单次仅支持一个源单号对一个金蝶单号,逐张调用互不连坐;成败结果 updateBatchById 批量落库,避免逐张 update -4. **回调幂等**:按 targetBillType+targetBillNo 找最新 locked 记录,找不到/已 unlocked 直接跳过应答成功,防金蝶重试风暴 -5. **DELETE 还原口径**:作废源单 + 清金蝶同步信息 + 关联状态刷新;作废是终态,重推需新单重走流程(口径待业务联调确认) +4. **回调幂等**(2026-10-15 修正):回调方向与锁定相反,按「金蝶单号(回调 sourceBillNo)+ 我方单号(回调 targetBillNo)」双条件找最新 locked 记录(targetBillType 回调为空不参与匹配),找不到/已 unlocked 直接跳过应答成功,防金蝶重试风暴; + 批量回调一次 IN 查询 + updateBatchById 批量落库,无逐张 N+1 +5. **DELETE 还原口径**:作废源单 + 清金蝶同步信息 + 关联状态刷新(结算单/开票申请);付款申请不作废源单,走生成失败+人工重推(2026-10-15 联调实测通过);作废是终态,重推需新单重走流程 6. **前端锁状态展示**(2026-10-10 新增):结算单/开票申请列表与详情 VO 填充 `kingdeeLockStatus`/`kingdeeLockFailReason`/`kingdeeTryCount` (后端 `mapActiveLocks` 每页一次批量查锁表,无 N+1;unlocked 终态不返回即 null=无锁/已解锁)。 前端渲染:`locked` 红 tag「金蝶锁定中」并禁用编辑/作废(后端守卫是权威);`failed` 且 `tryCount<5` 橙 tag「锁定失败·补偿中」tooltip 显示原因; @@ -123,6 +165,12 @@ failed(锁定失败,待补偿)→ locked(金蝶锁定中)→ unlocked DELETE 还原不作废源单,复用既有 `unlockBill`(ADR-0005:生成失败+清编号内码+人工重推),与 A103-2 反审核删除兜底同口径; 推送结果即时回填 kingdeeBillNo(`requireBfiBillIdAndFillNo`),推送事务提交后即以该单号 `lockAfterPush`; 锁定失败摘要拼入「同步金蝶」返回值(与开票申请同款),页面直接可见 +8. **解锁终态页面标识**(2026-10-15 新增,通用方案):付款申请解锁后不作废(仅 kingdeeStatus=failed),与推送失败无法区分; + 结算单/开票申请虽变已作废,但看不出是金蝶删单导致。统一方案: + VO 实现 `KingdeeUnlockMark` 展示契约(解锁时间/操作人/动作名称三字段), + `KingdeeBillLockService.fillUnlockMarks` 对无活跃锁的行一次 IN 反查最新 unlocked 锁记录(`mapUnlockedLocks`)统一回填; + 前端三个页面(付款申请/开票申请/正式结算单)金蝶锁状态列渲染:`kingdeeUnlockTime` 非空 → + 「金蝶删单」红 tag /「金蝶锁单」橙 tag,tooltip 显示操作人+时间;后续单据(如收票单)接入锁单时 VO 实现契约即可复用 ## 五、代码落点速查 @@ -131,6 +179,7 @@ failed(锁定失败,待补偿)→ locked(金蝶锁定中)→ unlocked | 锁单 Feign/DTO/应答 VO | `blade-bfi-api`:`BfiBillLockDTO`、`BfiBillLockResultVO`、`IBfiClient.lockBusiData`、`IBfiService.lockBusiData` | | 锁类型常量与映射 | `transport/constant/KingdeeLockConstant`(PAY_APPLY/ap_payapply_oth_BT_S 已进 TARGET_TO_SOURCE_MAP) | | 锁状态展示填充 | `IKingdeeBillLockService.mapActiveLocks`(批量查活跃锁)+ `FormalSettlementServiceImpl/InvoiceApplicationServiceImpl/PaymentApplicationServiceImpl.fillKingdeeLockStatus`(列表/详情填充)+ 三个 VO 的 `kingdeeLockStatus/kingdeeLockFailReason/kingdeeTryCount` 字段 | +| 解锁终态标识(2026-10-15) | 展示契约 `KingdeeUnlockMark`(三 VO 实现)+ `IKingdeeBillLockService.mapUnlockedLocks/fillUnlockMarks`(批量反查 unlocked 留痕并回填);VO 字段 `kingdeeUnlockTime/kingdeeUnlockOperator/kingdeeUnlockOperationName`;前端 `payment-application.vue`/`invoice-application.vue`/`formal-settlement-table-panel.vue` 的 `lockTag` 渲染金蝶删单/解锁标签 | | 锁记录实体/服务 | `KingdeeBillLock`、`IKingdeeBillLockService` + `KingdeeBillLockServiceImpl` | | 回调端点 | `KingdeeBfiPublicController`:`POST /kingdee-bfi/unLockNotifyBusiData`(PERMIT_ALL + TenantIgnore) | | 补偿定时任务 | `KingdeeBillLockRetryTask`(`thirdParty.bfi.billLock.retryEnabled`,默认每5分钟) | @@ -150,4 +199,5 @@ failed(锁定失败,待补偿)→ locked(金蝶锁定中)→ unlocked Nacos 实际配置已核对一致;环境级 blade-dev/test/prod.yaml 无 secure 段覆盖。 联调验证:服务启动后无令牌 POST 应返回业务参数校验错误(JSON),返回 401「请求未授权」即白名单未生效 4. 与金蝶确认 WLTMS 单据类型编码(SETTLEMENT/INVOICE_APPLY/PAY_APPLY)与 DELETE 还原口径 - (付款申请 DELETE 不作废源单、走生成失败+人工重推,与 A103-2 反审核删除兜底同口径,待业务确认) + (付款申请 DELETE 不作废源单、走生成失败+人工重推,与 A103-2 反审核删除兜底同口径; + 2026-10-15 付款申请链路已联调实测通过:回调方向反转定位、DELETE 还原、解锁标识均验证 OK) diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).md b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).md index 97ddcdd2..6eeac324 100644 --- a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).md +++ b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).md @@ -419,7 +419,7 @@ iPaaS 网关(/gwzh/BFI,不直连金蝶) - 推送闸门:已推送(pushed)流水跳过不重推;`billid`(认领公告 id)缺失拦截「请先同步金蝶收款流水」;结算单金蝶内码缺失拦截不推。 - 幂等:`awrp_src_code` 重试复用同一编码;作废认领不再生成本地冲单(金蝶单据状态保留原值)。 -- A301-3 字段待金蝶确认项仍在:`recpaytype_number`(示例 101)、`paymenttype`、`recpayer`(金蝶付款人 master id)。 +- A301-3 字段待金蝶确认项仍在:`recpaytype_number`(常量当前值 "999",注释示例 101 待确认)、`paymenttype`、`recpayer`(金蝶付款人 master id)。 --- @@ -621,7 +621,7 @@ iPaaS 网关(/gwzh/BFI,不直连金蝶) | # | 事项 | 证据/说明 | |---|---|---| | 1 | **A103-1 勾稽键口径**:代码传本地组装的应付防重键,`PaymentApplicationServiceImpl:1431-1443` 注释仍写「A102-4 反查 awrp_entry_code_p、禁止自行拼接」——注释与代码矛盾,金蝶侧付款计划分录标识是否等于防重键需联调实测 | 高优先 | -| 2 | **项目编码 999 残留**:A102-2/A302-2/A103-1/开票申请仍传测试值 999(仅 A103-3 已用真实项目编码) | 上线前处理 | +| 2 | **项目编码 999 残留**:全部推送链路(应付/应收/付款/预付款/开票申请)仍传测试值 999;A103-3「取项目档案真实编码」的逻辑已注释停用(详见入参映射表 14-#2) | 上线前处理 | | 3 | **计量单位不送**:分录 DTO 有字段无赋值——需与金蝶确认不送计量单位是否影响单据校验 | 联调确认 | | 4 | **A301-3 字段待金蝶确认**:`recpaytype_number`(101)/`paymenttype`/`recpayer` 取值口径 | 联调确认 | | 5 | **A301-2 claimusers 未配置风险**:缺省空串调 A301-2 查不到数据(无报错),需在 Nacos 必配 | 配置项 | @@ -636,5 +636,6 @@ iPaaS 网关(/gwzh/BFI,不直连金蝶) ## 附:文档关系 -- 本文为新总纲(基线 2026-10-13 `805ca9d3`),替代 09-27 版;单链路细节仍以 `doc/docs/业务流程/` 对应专项文档为深入参考,冲突处以本文 + 当前代码为准。 +- 本文为新总纲(基线 2026-10-13 `805ca9d3`,2026-10-15 增补),替代 09-27 版;单链路细节仍以 `doc/docs/业务流程/` 对应专项文档为深入参考,冲突处以本文 + 当前代码为准。 +- **各推送接口的逐字段入参取值映射(页面字段→表字段/配置对照)见《2026-10-15-金蝶(星瀚)BFI推送入参取值映射表(页面字段-表字段-配置对照).md》**,其第 14 节含与本文记载不一致的代码级核对结论。 - 09-27 版中被本文修订的关键结论:A801-3 回写缺陷、票池「实时回源无定时」架构、收款认领「全手动无定时」、分录调试覆盖、物料映射常量、failed 落库口径、A801-5/A801-2/A902「未实现」状态。 diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/img.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/img.png new file mode 100644 index 00000000..dde2579c Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/img.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).md b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).md index 4483b8a8..270be3fa 100644 --- a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).md +++ b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).md @@ -8,6 +8,13 @@ --- ## 1. 全局须知 +金蝶测试环境地址:https://cqcs-test.gxwuzi.com:8022/ierp/login.html + +账号密码: + +​ 13545909224 liuquanjia@123 + +​ 13737128880 websoft5386339 ### 1.1 金蝶单据状态含义(列表里看到的字段) @@ -177,6 +184,8 @@ 1. 选票:表单里点发票号放大镜 → 弹出「查询金蝶票据池」→ 勾选 → 确认(带出票面信息)。 ![查询金蝶票据池弹窗:按发票号/日期/税号/票种等筛选;每行可「查看/下载」发票原件(PDF 优先);点「选择」带出到表单](./2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/07-收票登记-金蝶票池选票.png) + + ![img.png](./2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/img.png) - 票池数据来自定时同步(默认每 30 分钟);票号精确搜索时若本地没有会**自动去金蝶现查**。 - 查不到票 → 等同步或让运维检查票种白名单配置/手动触发同步(见 4.2)。 2. 保存:系统自动**重查金蝶票池校验可用性**——票已被别人登记/认领/红冲/作废会保存失败并提示原因。 @@ -239,21 +248,62 @@ ### 4.1 任务清单(Nacos `thirdparty.bfi.*` 配置) -| 任务 | 干什么 | 开关键 | cron 键(默认频率) | 手动替代按钮 | +| 任务 | 干什么 | 开关键 | cron 键(默认值) | 手动替代按钮 | |---|---|---|---|---| -| 付款结果回写 | 付款状态(paid/closed)+ 付款记录/凭证回写;顺带应收勾稽纠偏、开票结果回写 | `thirdParty.bfi.payResult.syncEnabled`(样例已开) | `…payResult.cron`(10 分钟) | 付款「同步付款结果」、开票结果手动端点、结算勾稽手动端点 | -| 收票结果回写 | 收票单状态回写、pushed 断点补发指定应付 | `…receivedInvoiceResult.syncEnabled` | 每 10 分钟 | 收票「同步金蝶结果」 | -| 客商同步 | 金蝶审核状态复查(synced→exist)、停用联动、failed 重推 | `…customer.syncEnabled` | 每天 02:00 | 客商「同步金蝶」 | -| 收款认领推送 | 自动推送认领完成的流水到金蝶 | `…receiptClaimPush.syncEnabled` | 每 10 分钟 | 流水行内「推送金蝶」 | -| 全票池同步 | 拉金蝶全票池发票到本地镜像、补绑定 | `…invoiceReceipt.syncEnabled` | 每 30 分钟 | 手动端点 `POST /invoice-receipt/sync-invoice-pool` | -| failed 重推 | 自动重推所有推送失败的结算/付款/开票/收票单 | `…autoSync.retryEnabled` | 每 10 分钟 | 各页面「同步金蝶」 | -| 锁单补偿 | 推送成功但金蝶锁定失败的自动补锁(≤5 次) | `…billLock.retryEnabled` | 每 5 分钟 | 手动端点 `POST /formal-settlement/retry-kingdee-bill-lock` | +| 付款结果回写 | 付款状态(paid/closed)+ 付款记录/凭证回写;顺带应收勾稽纠偏、开票结果回写 | `thirdParty.bfi.payResult.syncEnabled`(样例已开) | `…payResult.cron`(默认 `0 1/10 * * * ?`) | 付款「同步付款结果」、开票结果手动端点、结算勾稽手动端点 | +| 收票结果回写 | 收票单状态回写、pushed 断点补发指定应付 | `…receivedInvoiceResult.syncEnabled` | `…receivedInvoiceResult.cron`(默认 `0 8/10 * * * ?`) | 收票「同步金蝶结果」 | +| 客商同步 | 金蝶审核状态复查(synced→exist)、停用联动、failed 重推 | `…customer.syncEnabled` | `…customer.cron`(默认 `0 0 2 * * ?`) | 客商「同步金蝶」 | +| 收款认领推送 | 自动推送认领完成的流水到金蝶 | `…receiptClaimPush.syncEnabled` | `…receiptClaimPush.cron`(默认 `0 4/10 * * * ?`) | 流水行内「推送金蝶」 | +| 全票池同步 | 拉金蝶全票池发票到本地镜像、补绑定 | `…invoiceReceipt.syncEnabled` | `…invoiceReceipt.cron`(默认 `0 16/30 * * * ?`) | 手动端点 `POST /invoice-receipt/sync-invoice-pool` | +| failed 重推 | 自动重推所有推送失败的结算/付款/开票/收票单 | `…autoSync.retryEnabled` | `…autoSync.retryCron`(默认 `0 5/10 * * * ?`) | 各页面「同步金蝶」 | +| 锁单补偿 | 推送成功但金蝶锁定失败的自动补锁(≤5 次) | `…billLock.retryEnabled` | `…billLock.retryCron`(默认 `0 2/5 * * * ?`) | 手动端点 `POST /formal-settlement/retry-kingdee-bill-lock` | -### 4.2 审批自动同步开关(按业务域独立) +### 4.2 各任务的运转逻辑 + +**共性规则(先读这个)** + +- **终态不轮询**:付款 paid(已付款)/closed(已关闭)、开票 invoiced(已开票)、收票 assigned(已指定应付)、客商 exist(金蝶已审核)、认领流水 pushed(已推送金蝶)后自动退出扫描范围——任务只处理「还没走完」的单据,越跑越轻,不会重复处理。 +- **定时与手动同一套逻辑**:每个任务与它的「手动替代按钮」调用同一个服务方法,效果完全一致;区别只是触发方式。 +- **单条失败不拖批次**:一轮里某条单据失败只记录原因,同批其余照常处理;失败的下轮自动再试(兜底见 ⑥ failed 重推)。 +- **默认执行时间错峰**(付款 :01、认领 :04、failed 重推 :05、收票 :08、锁补偿每 5 分钟的 :02、票池每 30 分钟的 :16),互不挤占;自行改 cron 时建议保留错峰。 + +**① 付款结果回写(10 分钟)** +扫「审批通过且已推送 / 付款中 / 推送失败」的付款申请 → 到金蝶查付款申请单和付款处理单 → 推进 synced(已推送受理)→ paying(金蝶付款中)→ **paid(已付款)/ closed(已关闭)**,paid 时回写付款记录/凭证,并联动刷新预结算垫资、结算付款记录(3.4 第 3 步的「正常等定时回写」就是它)。 +- 自愈一(应收勾稽):已推送但失败、或缺金蝶单号的**应收结算单**,查金蝶有则自动补单号、纠回 synced——「实际推成功但本地显示失败」的场景自动痊愈; +- 自愈二(开票结果):已受理的开票申请自动落发票信息并推进 **invoiced(已开票,终态)**(3.5 第 3 步的定时回写就是它); +- 防呆(金蝶删单):synced(已推送受理)/paying(金蝶付款中)的单据**连续 2 轮(约 20 分钟)在金蝶查不到** → 判定金蝶侧已删,自动解锁置 failed(推送失败)并清空金蝶单号,等人工核实后重推;重推带幂等预检,金蝶单据其实还在时会自动回填,不会重复生成。 + +**② 收票结果回写(10 分钟)** +扫 pushed(金蝶收票单已生成)/ assigned(已指定应付)的收票登记,按近 **30 天**回看窗口查金蝶收票单,一轮处理三种情况: +- 票在金蝶被作废/红冲 → 置 failed(推送失败)并提示人工处理(**不要重推**,见 3.6); +- 卡在 pushed → 自动补发「指定应付」推进 **assigned(已指定应付,终态)**(3.6 说的「卡在 pushed 不用管」就是它); +- assigned 但缺金蝶收票单号 → 自动补登单号。 +窗口内查不到的保持现状不动(不判删除),由人工重推兜底。 + +**③ 客商同步(每天 02:00)** +一次扫全部审批通过客商,干三件事: +- **未推送/推送失败** → 先复查金蝶:已有且已审核的直接置 **exist(金蝶已有该客商且审核通过,终态)**(不重复建档);金蝶侧审核中则置 failed(推送失败)提示稍后;确实没有才推送; +- **已推送(synced,已推送受理)** → 金蝶审核通过自动升级 **exist(金蝶已审核,终态)**(3.1 第 5 步的定时升级就是它); +- **金蝶侧停用(enable=0)** → TMS 档案联动停用;金蝶恢复可用**不会**自动启用(单向联动,需人工启用)。 + +**④ 收款认领推送(10 分钟)** +扫「认领完成(claimed)且未推送/推送失败」的收款流水 → 按认领人逐人拉最新认领公告**对账**(本地认领金额与公告未认领金额一致才推)→ 推送成功回写金蝶收款单号、流水置 **pushed(已推送金蝶)**(3.7 第 3 步的「自动」就是它)。失败的下一轮自动重试,防重码按分摊结算单生成、重试不变,不会在金蝶重复生成收款单。 + +**⑤ 全票池同步(30 分钟)** +按近 **30 天**增量窗口分页拉取金蝶全票池发票到本地镜像表,收票选票、票池查询全部走本地镜像——所以**刚在金蝶开的票要等同步后才能选到**;急用可让运维手动触发同步端点,或用票号精确搜索(会自动去金蝶现查,见 3.6)。 + +**⑥ failed 重推(10 分钟)** +只重推 **failed(推送失败)** 单据(结算 → 付款 → 开票 → 收票四个域依次处理);**unsynced(审批通过但从未推送)不会自动推**——历史存量单不会被上线后批量补推,仍走手动按钮。前置没就绪(如上游应付单还没生成)会再次 failed、下轮继续;前置就绪后自动续推成功。 +⚠️ 重推**没有次数上限**:根因不修(联行号缺失、组织编码缺失等)会每 10 分钟失败一次刷日志,修复根因是唯一正解(呼应 4.4)。 + +**⑦ 锁单补偿(5 分钟)** +扫「推送成功但金蝶锁定失败」的锁记录,重试加锁,**最多 5 次**;超过 5 次放弃,需运维调手动补偿端点 `POST /formal-settlement/retry-kingdee-bill-lock`(对应 FAQ 的「锁定失败」标签)。 + +### 4.3 审批自动同步开关(按业务域独立) `thirdParty.bfi.autoSync.settlement`(结算单)/ `…payment`(付款)/ `…invoiceApply`(开票)/ `…receipt`(收票),默认全关。**代码默认全部关闭,必须在 Nacos 显式开启**;客商档案审批推送不受此开关控制(始终自动)。 -### 4.3 运维注意 +### 4.4 运维注意 - 所有定时任务带**分布式锁**:多实例部署只跑一份;手动按钮和定时共用锁,定时运行中手动触发会直接跳过(属正常)。 - 开了「failed 重推」后,持续失败的单据每 10 分钟会重试一次,**修复根因**(配置/数据)比反复观察更重要。 @@ -315,4 +365,4 @@ --- -> 本文基于 2026-10-14 代码现状整理;系统升级后按钮与提示如有出入,以页面实际为准,并核对全链路总纲文档的更新说明。 +> 本文基于 2026-10-14 代码现状整理;2026-10-19 依据当前代码核对并补充 4.2 定时任务运转逻辑(cron 默认值、错峰、终态不轮询、防呆与自愈行为)。系统升级后按钮与提示如有出入,以页面实际为准,并核对全链路总纲文档的更新说明。 diff --git a/doc/docs/业务流程/2026-10-15-金蝶(星瀚)BFI推送入参取值映射表(页面字段-表字段-配置对照).md b/doc/docs/业务流程/2026-10-15-金蝶(星瀚)BFI推送入参取值映射表(页面字段-表字段-配置对照).md new file mode 100644 index 00000000..21f883f3 --- /dev/null +++ b/doc/docs/业务流程/2026-10-15-金蝶(星瀚)BFI推送入参取值映射表(页面字段-表字段-配置对照).md @@ -0,0 +1,409 @@ +# 金蝶(星瀚)BFI 推送入参取值映射表:页面字段 → 数据库表字段 / 配置对照 + +> 整理日期:2026-10-15 +> 代码基线:`tms-api` 当前 master(分支 `feature/payment-apply-bfi-sync`,提交 `259407c3` 之后)。 +> 用途:给联调/实施/排查用——每个送金蝶的入参,**页面上是哪个字段、落库在哪张表哪个字段、或来自哪个配置/常量**。逐字段核对代码(文件+行号见各节备注)。 +> 配套文档:《2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).md》。 + +--- + +## 目录 + +- [0. 公共取数与通用规则](#0-公共取数与通用规则) +- [1. A100-1 获取令牌](#1-a100-1-获取令牌) +- [2. A902-2 客户保存](#2-a902-2-客户保存) +- [3. A801-5 黑名单查询](#3-a801-5-黑名单查询) +- [4. A102-2 财务应付单保存](#4-a102-2-财务应付单保存) +- [5. A302-2 财务应收单保存(与应付差异)](#5-a302-2-财务应收单保存与应付差异) +- [6. A103-1 付款申请下推](#6-a103-1-付款申请下推) +- [7. A103-3 预付款申请保存](#7-a103-3-预付款申请保存) +- [8. A303-1 开票申请单保存](#8-a303-1-开票申请单保存) +- [9. A303-2 收票单保存 + A302-6 指定应付](#9-a303-2-收票单保存--a302-6-指定应付) +- [10. A301-3 收款认领结果推送](#10-a301-3-收款认领结果推送) +- [11. lockBusiData 单据锁定](#11-lockbusidata-单据锁定) +- [12. A902 附件上传](#12-a902-附件上传) +- [13. 查询类接口入参简表](#13-查询类接口入参简表) +- [14. 与总纲记载不一致的核对结论](#14-与总纲记载不一致的核对结论) + +--- + +## 0. 公共取数与通用规则 + +### 0.1 配置前缀(两套并存,勿混) + +| 前缀 | 归属 | 内容 | +|---|---|---| +| `third-party.bfi.*` | bfi-api 客户端(令牌/网关) | baseUrl、authorization、clientId、clientSecret、username、accountId、language | +| `thirdparty.bfi.*` | blade-transport 业务 | srcSys、各业务开关/cron、payApply.*、invoiceApply.*、receivedInvoice.*、material.*、claimAnnounce.*、blacklist.*、billLock.*(宽松绑定兼容 camelCase) | + +### 0.2 通用取数 + +| 项 | 规则 | 代码 | +|---|---|---| +| awrp_src_sys / systemsource / sourceSystem | Nacos `thirdparty.bfi.srcSys`,默认常量 `WLTMS` | BfiProperties(srcSys) | +| 组织 org/结算/付款/收款组织(核算/资金类) | **统一取公司级编码**:项目业务部门沿部门树向上反查平台公司(`is_platform_company=1`)→ 平台公司 `blade_dept.kingdee_dept_code`;未归属平台公司/未维护编码直接拦截 | `kingdeeOrgNumber`(KingdeeApBillSyncSupport L976-999),项目经 `blade_project_apply.business_dept_id` | +| 申请部门类字段(A303-1 awrp_orgfield) | 申请部门自身 `kingdee_dept_code`(部门级业务单元编码,星瀚允许) | 申请单 dept_id → `blade_dept.kingdee_dept_code` | +| 币种 | 合同结算币种,`RMB→CNY` | `blade_contract_manage.settlement_currency`(bfiCurrency L952-956) | +| 往来户 | 名称 → `blade_customer_archive` 按 `full_name/short_name` 匹配(limit 1)取 `customer_code`,查不到报错 | queryCustomerCode L966-984 | +| 日期文本 | 统一 `yyyy-MM-dd HH:mm:ss` | bfiDateTimeText L922-925 | +| 防重键 awrp_src_code | `ExternalSrcCodeSupport.buildSrcCode`:下划线拼接、去空白、去重保序 | ExternalSrcCodeSupport L54-63 | + +### 0.3 分录拆分规则(A102-2/A302-2/A303-2 共用 `resolveEntryGroups`) + +- 拆分维度:**货物 → 费用大类(装卸/运输)→ 税率** 三级分组,各组成一条分录;手动合计费用项(manual_flag=1)每行一条。 +- 物料编码:装卸大类/手动非运费杂费 → `thirdparty.bfi.material.loading`(默认 XDWL11002004);项目业务模式或业务类型含「国际」→ `.international`(Item-00001081);国内按运单运输方式 → `.domestic.公路运输`=XDWL11010001 / `.铁路运输`=XDWL11010002 / `.水路运输`=XDWL11010003 / `.航空运输`=Item-00001082;运输方式未配置直接拦截。 +- 税率:货物费用行按合同计费方案规则分组(`blade_contract_manage.billing_plan_json` 的 `rules[].feeType/feeItem/taxRate`);税率未配置并入无税率组。 +- 数量:运输类=结算明细运输量(≤0 兜底 1,同税率拆多行每行都取运输量);装卸/合计费用项行=1;单价=金额÷数量反算(2 位 HALF_UP)。 +- 0 元费用项(signum==0)与 0 元货物行整行跳过。 +- 计量单位**当前不送**(DTO 有字段无赋值)。 + +--- + +## 1. A100-1 获取令牌 + +| 入参 | 含义 | 来源:配置键 / 生成方式 | +|---|---|---| +| client_id | 应用编码 | `third-party.bfi.clientId` | +| client_secret | 认证密钥 | `third-party.bfi.clientSecret` | +| username | 访问用户名 | `third-party.bfi.username` | +| accountId | 数据中心 ID | `third-party.bfi.accountId` | +| language | 语言 | `third-party.bfi.language`(默认 `zh_CN`) | +| nonce | 随机数 | 每次生成 `UUID` | +| timestamp | 时间戳(±5 分钟校验) | 每次生成 `yyyy-MM-dd HH:mm:ss` | + +> 令牌缓存 Redis `blade:third:bfi:token`,TTL=expires_in−120s;`setIfAbsent` 写入。代码:BfiServiceImpl L136-156、BfiTokenDTO L44-65。 + +--- + +## 2. A902-2 客户保存 + +调用链:`CustomerArchiveServiceImpl.syncKingdee → pushCustomerToKingdee → CustomerBfiConverter.buildSaveDTO` + +| 金蝶入参 | 含义 | 页面字段 | 表.字段 / 常量 | +|---|---|---|---| +| number | 客户编码(不传由星瀚生成) | 客商编号(提交时生成 KS+6 位) | blade_customer_archive.customer_code | +| name【必】 | 名称 | 客商名称 | blade_customer_archive.full_name | +| societycreditcode【必】 | 统一社会信用代码(金蝶候选键) | 统一社会信用代码 | blade_customer_archive.unified_credit_code | +| tx_register_no | 纳税人识别号 | 同上(同一字段) | blade_customer_archive.unified_credit_code | +| artificialperson | 法人代表 | 企业法人 | blade_customer_archive.legal_person | +| type【必】 | 伙伴类型(1~17) | 客商性质字典 | blade_customer_archive.customer_nature + 字典 `nature_of_client` dictKey(文本值反查 dictKey,无效抛异常) | +| bizpartner_address | 详细地址 | 注册地址 | blade_customer_archive.registered_address | +| bizpartner_phone | 联系电话 | 联系电话 | blade_customer_archive.contact_phone | +| businessterm | 营业期限 | 营业期限类型=长期→「长期」;否则「至 yyyy-MM-dd」 | blade_customer_archive.business_term_type / business_end_date | +| businessscope | 经营范围 | 经营范围多选 → 字典 `scope_of_business` 文本「、」拼接 | blade_customer_archive.business_scope | +| bizfunction | 业务职能 | 固定全选 | 常量 `"1,2,3,4"` | +| fcusbeizhu | 备注 | 备注(空不传) | blade_customer_archive.remark | +| awrp_datasourcesys | 数据来源 | 配置 | `thirdparty.bfi.srcSys` | +| awrp_cusfortrade【必】 | 是否贸易客商 | 固定 | 常量 `"A"` | +| entry_groupstandard[].standardid_number【必】 | 分类标准 | 固定 | 常量 `"JBFLBZ"` | +| entry_groupstandard[].groupid_number【必】 | 客户分组 | 客户类型:internal→1101,其余→1102 | blade_customer_archive.customer_kind + 常量 1101/1102 | +| entry_bank[].bankaccount | 银行账号(金蝶候选键) | 收款账号 | blade_customer_receipt_account.bank_account | +| entry_bank[].accountname | 账户名称 | **传开户行名称** | blade_customer_receipt_account.bank_name | +| entry_bank[].bank_number | 开户银行.编码(联行号) | 联行号 | blade_customer_receipt_account.cnaps_code | +| entry_bank[].currency_number【必】 | 币种 | 固定 | 常量 `"CNY"` | +| entry_bank[].isdefault_bank | 默认银行 | 固定 | `true` | +| entry_linkman[].contactperson | 联系人 | 客商负责人(空则整组分录不传) | blade_customer_archive.principal | +| entry_linkman[].phone | 电话 | 联系电话 | blade_customer_archive.contact_phone | +| entry_linkman[].isdefault_linkman | 默认联系人 | 固定 | `true` | + +不传字段:status、linkman、establishdate、postal_code、createorg_number(已注释)等。 +> 代码:CustomerBfiConverter L67-280、常量 CustomerBfiConstants / BfiConstant L288-313。 + +--- + +## 3. A801-5 黑名单查询 + +| 入参 | 含义 | 来源 | +|---|---|---| +| enable | 使用状态 | 固定 `"1"`(可用) | +| number | 企业税号数组 | 客商发票信息所有纳税人识别号,`trim().toUpperCase()` 去重 → blade_customer_invoice_info.tax_no | +| pageNo / pageSize | 分页 | 自动翻页(50 页保护)/ 固定 `2000` | +| name | 企业名称数组 | 不传 | + +> 命中判定按应答 awrpCombofield:A 硬拦截;B 默认放行告警(`thirdparty.bfi.blacklist.blockRiskWarning=true` 时拦截);查询异常上抛阻断提审。代码:CustomerArchiveServiceImpl L1043-1092、BfiConstant L90。 + +--- + +## 4. A102-2 财务应付单保存 + +### 4.1 单据头 + +| 金蝶入参 | 含义 | 页面字段 | 表.字段 / 配置 / 常量 | +|---|---|---|---| +| awrp_src_code【必·候选键】 | 来源单据号(防重键) | 结算单号+结算明细单据号 | blade_formal_settlement.formal_settlement_no + blade_formal_settlement_detail.document_no(document_no 空兜底明细行 id)下划线拼接 | +| awrp_src_sys【必】 | 外部系统编号 | 配置 | `thirdparty.bfi.srcSys`(WLTMS) | +| awrp_src_type【必】 | 外部单据类型 | — | 常量 `TMS财务应付单` | +| billtypeid_number【必】 | 单据类型.编码 | — | 常量字典「服务采购应付」→ `ApFin_service_BT_S` | +| org_number / payorg_number【必】 | 结算/付款组织.编码 | 合同关联项目业务部门上溯平台公司 | kingdeeOrgNumber → 平台公司 `blade_dept.kingdee_dept_code`(公司级核算组织编码) | +| bizdate【必】 | 单据日期 | 同步当日 | LocalDate.now() | +| asstacttype【必】 | 往来类型 | — | 常量 `bd_customer`(固定,供应商分支已注释) | +| asstact_number【必】 | 往来户.编码 | 合同乙方 | blade_contract_manage.party_b → blade_customer_archive.customer_code | +| purmode【必】 | 付款方式 | — | 常量 `CASH`(现购) | +| currency_number【必】 | 结算币别 | 合同结算币种 | blade_contract_manage.settlement_currency(RMB→CNY) | +| payproperty_number【必】 | 款项性质.编码 | — | 常量 `2001`(材料采购) | +| isincludetax / ispricetotal | 含税价/总价录入 | — | `true` / `true` | +| isintax | 按应付金额分配 | — | `false` | +| exratedate / exchangerate | 汇率日期/汇率 | 结算单页面「汇率日期/结算汇率」,未维护不送 | blade_formal_settlement.exchange_rate_date / .exchange_rate | +| settlementtype_number | 结算方式 | **不送**(仅 A103-1 分录需要) | — | +| taxrateid_number / purorg_number / exratetable_number / planentity 等 | 可选 | **均不送**(planentity 由金蝶按分录自动汇总付款计划) | — | + +### 4.2 明细分录 detailentry + +| 金蝶入参 | 含义 | 来源 | 表.字段 / 配置 / 常量 | +|---|---|---|---| +| awrp_entry_code【必·候选键】 | 分录标识 | 拆分行=`明细单据号-货物序号-大类-税率`;合计费用项行=`结算单号-费用类型-费用项` | blade_formal_settlement_detail.document_no / blade_formal_settlement_summary_fee.fee_type+fee_item | +| material_number【必】 | 物料编码.编码 | 按拆分规则映射(见 0.3) | 配置 `thirdparty.bfi.material.loading / international / domestic.公路运输 / .铁路运输 / .水路运输 / .航空运输`;判定字段 blade_project_apply.business_mode / business_type、blade_formal_settlement_detail.transport_type | +| awrp_transport | 运输货物 | 货物名称(合计费用项行不传) | blade_formal_settlement_detail.cargo_name(一车多货取 blade_formal_settlement_detail_fee.cargo_name) | +| awrp_lpn | 车号 | 车号(合计费用项行不传) | blade_formal_settlement_detail.vehicle_no | +| quantity【必】 | 数量 | 运输量(≤0 兜底 1;装卸/合计行=1) | blade_formal_settlement_detail.transport_quantity(一车多货取 detail_fee.transport_quantity) | +| price | 单价 | 金额÷数量反算 | 派生值 | +| e_pricetaxtotal【必】 | 应付金额(含税) | 结算金额 | blade_formal_settlement_detail.settlement_amount_tax / blade_formal_settlement_summary_fee.settlement_amount | +| e_amount | 金额(不含税) | 不含税结算金额(仅运输类携带;合计行不传) | blade_formal_settlement_detail.settlement_amount_no_tax | +| project_number【必】 | 项目.编码 | 联调临时值 | 常量 `"999"` | +| taxrateid_number | 税率.编码 | 不送(税率只作拆行依据) | blade_contract_manage.billing_plan_json | + +--- + +## 5. A302-2 财务应收单保存(与应付差异) + +分录与应付**同源同口径**(复用 resolveEntryGroups,分录字段名前缀 e_:e_material_number/e_quantity/e_unitprice/e_recamount(含税)/e_amount(不含税))。头字段差异: + +| 金蝶入参 | 应收取值 | 来源 | +|---|---|---| +| awrp_src_type | `TMS财务应收单` | 常量 | +| billtype_number | 「服务销售应收」→ `arfin_sersal_BT_S` | 常量映射 | +| recorg_number | 收款组织=结算组织 | 同 org_number | +| asstact_number | **结算单付款方**(非合同乙方) | blade_formal_settlement.payer_name → blade_customer_archive.customer_code | +| paymode | 现销 `CASH` | 常量 | +| payproperty_number | `1001`(一般销售) | 常量 | +| bizdate | 同步当日 | LocalDate.now() | +| settlementtype_number / duedate 等 | 不送(TODO 待金蝶确认) | — | + +--- + +## 6. A103-1 付款申请下推 + +| 金蝶入参 | 含义 | 页面字段 | 表.字段 / 配置 / 常量 | +|---|---|---|---| +| awrp_src_code【必·候选键】 | 来源单据号 | 付款申请单号 | blade_payment_application.payment_no | +| awrp_src_sys【必】 | 外部系统 | 配置 | `thirdparty.bfi.srcSys` | +| applydate【必】 | 申请日期 | 付款申请页-申请日期(空取当天) | blade_payment_application.apply_date | +| entry[].e_paymenttype_number【必】 | 付款类型 | 正式付款 | 常量 `201` | +| entry[].e_settlementtype_number | 结算方式 | 付款方式含「汇票」→`012-02`(电子银行承兑汇票),其余→`02`(银行转账) | 字典 `pay_method` + blade_payment_application.payment_method | +| entry[].awrp_finapbill_entryid【必】 | 勾稽应付单分录标识 | 主结算单应付单防重键(本地组装) | blade_formal_settlement.formal_settlement_no + blade_formal_settlement_detail.document_no(主结算单=blade_payment_application.settlement_id,空取关联明细首个结算单) | +| entry[].awrp_entry_code【必】 | 行 id | 付款申请号-1 | payment_no + "-1" | +| entry[].e_assacct【必】 | 往来账户(收款账号) | 收款账号 | blade_payment_application.bank_account | +| entry[].e_bebank_number【必】 | 往来银行.编码(联行号) | 收款账户联行号(缺失拦截;兜底按合同乙方客商+收款账号匹配) | blade_customer_receipt_account.cnaps_code(按 blade_payment_application.receipt_account_id 直查) | +| entry[].e_applyamount【必】 | 申请金额 | 申请付款金额(2 位 HALF_UP) | blade_payment_application.applied_amount | +| entry[].project_number【必】 | 项目.编码 | 联调临时值 | 常量 `"999"` | +| entry[].awrp_src_type【必】 | 外部单据类型 | 配置 | `thirdparty.bfi.payApply.srcType`(默认 `0KzXs`) | +| exratetable_number / awrp_textfield1 | 汇率表/付款渠道 | **实际不送**(仅 A103-3 送;配置 exratetable=01 在此接口不生效) | — | + +--- + +## 7. A103-3 预付款申请保存 + +| 金蝶入参 | 含义 | 来源 | 表.字段 / 配置 / 常量 | +|---|---|---|---| +| billtype_number【必】 | 单据类型 | 配置 | `thirdparty.bfi.payApply.billtype`(默认 `ap_payapply_oth_BT_S`) | +| settleorg/applyorg/payorg_number【必】 | 结算/申请/付款组织.编码 | 合同关联项目业务部门上溯平台公司 | kingdeeOrgNumber → 平台公司 `blade_dept.kingdee_dept_code`(公司级核算组织编码) | +| awrp_department_number【必】 | 部门.编码 | 项目业务部门自身 | kingdeeDeptNumber → 业务部门 `blade_dept.kingdee_dept_code`(部门级编码,不上溯平台公司;未维护拦截) | +| paycurrency/settlecurrency_number【必】 | 币种 | 合同结算币种(RMB→CNY) | blade_contract_manage.settlement_currency | +| applycause | 请款事由 | 按付款类型映射文案:项目垫资→「项目预付款申请」、进度垫资→「进度预付款申请」、结算付款→「结算付款申请」 | blade_payment_application.payment_type | +| exratetable_number【必】 | 汇率表 | 配置(**此接口实际送**) | `thirdparty.bfi.payApply.exratetable`(默认 `01`) | +| exratedate【必】 | 汇率日期 | 关联结算单维护值,兜底申请日期 | blade_formal_settlement.exchange_rate_date → blade_pre_settlement.exchange_rate_date → blade_payment_application.apply_date | +| awrp_textfield1 | 付款渠道 | 付款方式字典标签(**此接口实际送**) | 字典 `pay_method` 翻译 blade_payment_application.payment_method | +| entry[].e_paymenttype_number【必】 | 付款类型 | 结算付款→`201`;项目垫资/进度垫资→`202` | 常量 BFI_PAYMENT_TYPE_FORMAL / BFI_PAYMENT_TYPE_PREPAY | +| entry[].e_asstacttype / e_asstactnumber【必】 | 往来类型/户 | 固定客户 / 合同乙方→客商编码 | `bd_customer`;blade_contract_manage.party_b → customer_code | +| entry[].e_assacct / e_bebank_number【必】 | 账号/联行号 | 同 A103-1 | blade_payment_application.bank_account / blade_customer_receipt_account.cnaps_code | +| entry[].e_applyamount【必】 | 申请金额 | 申请付款金额 | blade_payment_application.applied_amount | +| entry[].project_number【必】 | 项目.编码 | **实际固定 999**(取项目档案真实编码的逻辑已注释停用) | 常量 `"999"`(备用:blade_project_apply.project_code) | +| entry[].e_remark | 备注 | 付款申请备注(空不传) | blade_payment_application.remark | +| entry[].awrp_entry_code / awrp_src_type【必】 | 行 id / 单据类型 | payment_no-1 / 配置 | `thirdparty.bfi.payApply.srcType` | + +--- + +## 8. A303-1 开票申请单保存(每张发票张次一单) + +### 8.1 主表 + +| 金蝶入参 | 含义 | 页面字段 | 表.字段 / 配置 / 常量 | +|---|---|---|---| +| billno【必·候选键】 | 单据编号 | 开票申请单号(多张加张次序号) | blade_invoice_application.application_no(多张 + "-" + blade_invoice_application_sheet.sheet_no) | +| orgid_number【必】 | 组织.编码 | 申请关联项目业务部门上溯平台公司 | kingdeeOrgNumber → 平台公司金蝶组织编码(绑定税号的核算组织) | +| awrp_orgfield_number【必】 | 申请部门.编码 | 申请部门 | blade_invoice_application.dept_id → blade_dept.kingdee_dept_code(部门级业务单元编码) | +| billdate【必】 | 单据日期 | 申请日期(空取当天) | blade_invoice_application.application_date | +| billproperties / taxationstyle / buyerproperty | 单据性质/征税方式/购方类型 | 固定 | `"1"` / `"0"` / `"0"` | +| invoicetype【必】 | 发票种类 | 发票类型:数电专票→`08xdp`,其余→`10xdp` | blade_invoice_application.invoice_type | +| buyeremail【必】 | 推送邮箱 | 部门邮箱(空拦截) | blade_invoice_application.department_emails | +| buyertaxno / buyername【必】 | 购方税号/名称 | 受票方快照 | blade_invoice_application.taxpayer_no / receiver_name | +| buyeraddr / buyerbank | 购方地址电话/开户行账号 | 受票方快照 | blade_invoice_application.registered_address+contact_phone / bank_name+bank_account | +| salername【必】 | 销方名称 | 开票单位(空拦截) | blade_invoice_application.issuer_name | +| salertaxno【必】 | 销方税号 | 开票单位→客商档案 | blade_customer_archive.unified_credit_code(按 issuer_name 匹配) | +| saleraddr / salerbank | 销方地址电话/开户行账号(数电专票强制) | 客商档案(启用开票信息优先) | blade_customer_archive.registered_address+contact_phone;blade_customer_invoice_info.bank_name+bank_account | +| totalamount【必】 | 申请价税合计 | 本张开票金额 | blade_invoice_application_sheet.invoice_amount | +| hsbz | 是否含税 | 固定不含税 | 常量 `"0"` | +| specialtype | 特殊票种 | 本张商品行**全部**为货物运输类→`E04`,否则→`00`(名称/分类含「运费/货物运输」关键字) | 常量 BFI_INVOICE_SPECIAL_TYPE_FREIGHT / NONE + BFI_INVOICE_FREIGHT_KEYWORDS | +| freights[] | 货物运输单据体(E04 报送) | 结算明细快照,见 8.2 | blade_invoice_application_detail | +| customnameid_number【必】 | 客户.编码 | 受票方客户 | blade_invoice_application.receiver_customer_id → blade_customer_archive.customer_code | +| awrp_project_number【必】 | 项目.编码 | 联调临时值 | 常量 `"999"` | +| awrp_src_bt | 上游单据类型 | 固定 | `ar_finarbill` | +| awrp_src_con_no | 上游单据号 | 首张关联结算单金蝶应收防重键 | blade_formal_settlement.formal_settlement_no + detail.document_no(经 blade_invoice_application_settlement 关联) | +| systemsource | 来源系统 | 配置 | `thirdparty.bfi.srcSys` | +| showbuyeraddresstel / showbuyerbank | 票面展示开关 | 固定 | `true` | + +### 8.2 freights[](按「车牌×起运×到达×货物×种类」去重归并,全空行剔除) + +| 入参 | 来源:blade_invoice_application_detail 字段 | +|---|---| +| transporttype 运输工具种类 | transport_type(字典 `transport_type` 翻译,不在合法枚举兜底「其它运输工具」) | +| licenseplate 牌号 | vehicle_no | +| transportgoods 货物名称 | cargo_name | +| startplace 起运地 | departure_address | +| endplace 到达地 | arrival_address | + +### 8.3 sim_original_bill_item[] 分录(blade_invoice_application_line 商品行,line_no 升序) + +| 入参 | 来源:blade_invoice_application_line 字段 | +|---|---| +| awrp_entry_code【必】 | id(雪花 id) | +| rowtype / benchmark【必】 | 常量 `"2"`(商品行)/ `"0"`(数量基准) | +| goodsname【必】 | goods_name(货物或服务简称) | +| goodssimplename【必】 | goods_category(商品和服务分类) | +| taxratecodeid_number / goodscode【必】 | tax_classification_code(税收分类编码,空拦截) | +| num / unit | quantity(0 兜底 1)/ unit(空兜底「个」) | +| unitprice / amount / tax | unit_price_no_tax / amount_no_tax / tax_amount | +| taxrate | tax_rate(百分数÷100 转小数,0 送 "0.00") | +| remark | remark | +| awrp_src_type【必】 | 配置 `thirdparty.bfi.invoiceApply.srcType`(默认 `0KzXs`) | + +(specification 规格型号、含税金额等本期不传。) + +--- + +## 9. A303-2 收票单保存 + A302-6 指定应付 + +### A303-2 收票单批量保存 + +| 金蝶入参 | 含义 | 来源 | 表.字段 / 配置 / 常量 | +|---|---|---|---| +| awrp_src_code【必·候选键】 | 来源单据号 | 收票登记 id(一票一登记) | blade_invoice_receipt.id | +| awrp_src_sys / awrp_src_type【必】 | 系统/单据类型 | 配置 / 常量 | `thirdparty.bfi.srcSys`;`TMS收票登记` | +| invoiceno | 发票号码 | 收票登记发票号 | blade_invoice_receipt.invoice_no | +| receivedate【必】 | 收票日期 | 推送当日 | LocalDate.now() | +| issuedate【必】 | 开票日期 | 票池镜像票面 | blade_kingdee_invoice_pool.invoice_date | +| biztype【必】 | 业务类型 | 固定采购 | `PUR` | +| invoicetypef7_number【必】 | 发票类型.编码 | 票易通原始编码(缺失拦截并按票号回源 A801-1 补填) | blade_kingdee_invoice_pool.invoice_type_code | +| paymenttype【必】 | 付款方式 | 固定现购 | `CASH` | +| asstacttype【必】 | 往来类型 | 统一固定客户(与应付单/付款申请同口径) | `bd_customer` | +| org_number / currency_number【必】 | 组织/币种 | 分摊应付结算单关联合同 → 项目业务部门上溯平台公司 | kingdeeOrgNumber → 平台公司编码 / settlement_currency | +| receivablessupp_number【必】 | 往来户.编码 | 合同乙方→客商编码 | blade_contract_manage.party_b → customer_code | +| exratetable_number【必】 | 汇率表 | 固定 | `"01"` | +| asstactname / buyertin / sellertin【必】 | 销方名称/购销方税号 | 票池镜像票面 | blade_kingdee_invoice_pool.issuer_name / buyer_tax_no / saler_tax_no | +| buyername【必】 | 购方名称 | 镜像受票单位 | blade_kingdee_invoice_pool.receiver_name | +| sellertel / sellerbank / selleracct | 销方电话/开户行/账号 | 镜像 | blade_kingdee_invoice_pool.phone / bank_name / bank_account | +| isincludetax | 含税录入 | 固定 | `true` | +| entry[] | 分录 | 与 A102-2 同源同口径(复用 resolveEntryGroups) | 同 4.2 | + +### A302-6 收票指定应付 + +| 入参 | 来源 | +|---|---| +| op | 常量 `"assign"` | +| ap_invoice[0].awrp_src_code / awrp_src_sys | blade_invoice_receipt.id / `thirdparty.bfi.srcSys` | +| ap_finapbill[0].awrp_src_code / awrp_src_sys | 应付防重键(结算单号_明细单据号)/ 同上 | + +--- + +## 10. A301-3 收款认领结果推送 + +| 金蝶入参 | 含义 | 来源 | 表.字段 / 配置 / 常量 | +|---|---|---|---| +| billid【必】 | 认领公告 id | 流水同步落库(缺失拦截「请先同步金蝶收款流水」) | blade_kingdee_receipt_flow.bill_id | +| awrp_src_sys【必】 | 来源系统 | 配置 | `thirdparty.bfi.srcSys` | +| awrp_src_code【必】 | 来源单据编码 | 该流水全部分摊结算单号去重下划线拼接(推送时生成落库,分摊不变重试复用) | blade_receipt_claim_settlement.formal_settlement_no(status=1,结算单 id 升序)→ 回写 blade_kingdee_receipt_flow.awrp_src_code | +| recpaytype_number【必】 | 收款类型 | **常量当前值 `"999"`**(注释「示例 101 待金蝶确认」) | KingdeeBfiConstant.BFI_CLAIM_REC_PAY_TYPE | +| paymenttype【必】 | 付款人类型 | 固定 | `bd_customer` | +| recviewpayer / recpayer【必】 | 付款人名称/id | 流水付款方 | blade_kingdee_receipt_flow.payer_name / payer_id | +| entryentity[0].e_receivableamt【必】 | 应收金额(单条分录) | 全部有效认领分摊金额合计 | SUM(blade_receipt_claim_settlement.allocated_receipt_amount) | +| entryentity[0].seq / e_corebillno【必】 | 行序/核心单据编号 | 第一张分摊结算单 id / 其金蝶星瀚内码(缺失拦截「未同步金蝶」) | blade_receipt_claim_settlement.formal_settlement_id / blade_formal_settlement.kingdee_bill_id | +| entryentity[0].e_corebilltype | 核心单据类型 | **注释不传** | — | + +--- + +## 11. lockBusiData 单据锁定 + +| 入参 | 含义 | 来源 | +|---|---|---| +| lockAction【必】 | 操作 | 常量 `LOCK` | +| sourceSystem【必】 | 源系统 | `thirdparty.bfi.srcSys`(默认 WLTMS) | +| targetSystem【必】 | 目标系统 | 常量 `XH` | +| operationType【必】 | 操作类型 | 常量 `SAVE` | +| sourceBillType【必】 | 源单据类型 | 见下表常量 | +| sourceBillNo【必】 | 本地单据号 | 见下表 | +| targetBillType【必】 | 金蝶单据类型 | 见下表 | +| targetBillNo【必】 | 金蝶单号(逐张) | 见下表 | +| operator | 操作人 | 当前用户名(定时/补偿兜底 `system`;补偿取 blade_kingdee_bill_lock.lock_operator) | +| time | 操作时间 | 当前时间 `yyyy-MM-dd HH:mm:ss` | + +| sourceBillType | sourceBillNo | targetBillType | targetBillNo | +|---|---|---|---| +| `SETTLEMENT` | blade_formal_settlement.formal_settlement_no | receivable→`arfin_other_BT_S`;否则 `ApFin_other_BT_S` | 推送返回 billNo(多张逗号拆分逐张锁) | +| `PAY_APPLY` | blade_payment_application.payment_no | `ap_payapply_oth_BT_S` | blade_payment_application.kingdee_bill_no | +| `INVOICE_APPLY` | blade_invoice_application.application_no | `cdm_paybill_bank_elect_BT_S` | 推送返回 billNo(多张逗号拆分) | +| `RECEIVED_INVOICE` | — | `cdm_recbill_bank_BT_S` | 预留,**本期不锁** | + +> 锁记录表 blade_kingdee_bill_lock(lock_status: failed/locked/unlocked,try_count≤5);开关 `thirdparty.bfi.billLock.enabled`(默认 true)。代码:KingdeeBillLockServiceImpl L331-344、KingdeeLockConstant L24-87。 + +--- + +## 12. A902 附件上传 + +multipart 上传:`file`=文件流,`args`=JSON `{entityNumber, billPkId, controlKey}`。 + +| 入参 | 来源 | +|---|---| +| entityNumber(星止单据类型) | biz_type 映射:`pay_application`→ap_payapply;`formal_settlement_ap`→ap_finapbill;`formal_settlement_ar`→ar_finarbill;`invoice_application`→sim_original_bill;`invoice_receipt`→ap_invoice | +| billPkId(星瀚内码) | 推送落库的金蝶内码:blade_payment_application.kingdee_bill_id / blade_invoice_application.kingdee_bill_id / 结算单与收票推送返回内码(为空整单跳过待重推补传) | +| controlKey | 常量 `attachmentpanel` | +| 文件名 | 业务表 attachments_json 行内 originalName/name/fileName(空兜底 attachment;票池镜像兜底「发票文件」) | +| 文件流 | 业务表 attachments_json 的 url 下载;收票票池镜像按 path 经 A801-2 下载 | + +- 附件存储:各业务单据 `attachments_json` 列(非 blade_attachment 表);同步流水落 `blade_kingdee_attachment_sync`(biz_type/biz_id/attachment_key/kingdee_path/sync_status/fail_reason)。 +- 单文件软限制 20MB,超限拦截(重推时跳过该件继续其余)。 +- 调用时机:推送成功事务提交后联带上传,失败不污染主单状态;重推补传。 +> 代码:KingdeeAttachmentConstant L45-108、KingdeeAttachmentSyncSupport L125-264、BfiServiceImpl L613-638。 + +--- + +## 13. 查询类接口入参简表 + +| 接口 | 关键入参 | 取值来源 / 常量 / 配置 | +|---|---|---| +| A102-4 应付单查询 | awrp_src_code 多值(50/批)、awrp_src_sys、pageSize 200 | 应付防重键批次;`thirdparty.bfi.srcSys` | +| A302-4 应收单查询 | 同上(50/批、200) | 应收防重键批次 | +| A103-2 付款申请单查询 | awrp_src_code=paymentNo(50/批)、pageSize 200 | blade_payment_application.payment_no | +| A103-4 付款处理查询 | org_number(优先取 A103-2 返回 payorg_number,兜底合同组织)、业务日期今天回溯 1 个月、200/页 | A103-2 返回值 → blade_dept.kingdee_dept_code | +| A303-4 开票申请查询 | billno 多值(application_no、-张次序号)、pageSize=max(张数,10) | blade_invoice_application(.sheet) | +| A303-5 开票发票查询(openApi) | serialNo=金蝶单号逗号拆分(≤50/批)、sellerTaxpayerId=开票单位税号、请求体 Base64 | blade_invoice_application.kingdee_bill_no;blade_customer_archive.unified_credit_code | +| A801-3 收票单查询 | buyername(按镜像受票单位分组)、modifytimeS/E=回看 30 天、200/页;本地按 srcCode=登记 id 匹配 | blade_kingdee_invoice_pool.receiver_name;`thirdparty.bfi.receivedInvoice.queryWindowDays/resultWindowDays`(默认 30);blade_invoice_receipt.id | +| A801-1 票池查询 | invoiceStatus=`0`(正常票);invoiceType 单值(白名单逐轮);updateTime 窗口=incrementDays 增量、2.5 万触顶二分;500/页。**expenseStatus 不下推**(可用性本地过滤 amount_use_status=0 且 available_amount>0) | `thirdparty.bfi.invoiceReceipt.invoiceTypes / incrementDays`(30);本地表 blade_kingdee_invoice_pool | +| A801-2 附件下载 | path=文件地址(镜像优先、实时回源兜底;优先级 pdfurl→taxPdfUrl→downloadUrl,fileType=4 判 OFD) | blade_kingdee_invoice_pool.attachments_json | +| A301-1 收款单查询 | 业务日期今天回溯 30 天、200/页;勾稽 sourcebillnumber==awrp_src_code | blade_kingdee_receipt_flow.awrp_src_code | +| A301-2 认领公告查询 | claimusers=**Nacos 配置值**(登录人入参已废弃)、modifytimeS/E=回溯 months 个月、200/页;billstatus 字段存在但不传 | `thirdParty.bfi.claimAnnounce.claimusers`(必配)、`.months`(6) | + +--- + +## 14. 与总纲记载不一致的核对结论(已按代码核实) + +| # | 事项 | 代码事实 | +|---|---|---| +| 1 | A103-1 的 exratetable_number / awrp_textfield1 | **实际不送**(L1245/L1249 注释);配置 `payApply.exratetable=01` 只在 A103-3 生效 | +| 2 | A103-3 项目编码 | **实际固定 999**,「取项目档案 projectCode 真实编码」逻辑已注释停用(bfiProjectNumber L1508-1513)——总纲 12.2-#2「仅 A103-3 用真实编码」的说法作废,全部链路均为 999 | +| 3 | A301-3 recpaytype_number | 常量当前值 **"999"**(非旧文档写的 101;注释「示例 101 待金蝶确认」) | +| 4 | A301-2 claimusers | 只读 Nacos 配置,方法入参(当前登录人姓名)已不参与取值;billstatus 字段存在但不传 | +| 5 | A801-1 expenseStatus | 同步**不下推**(常量已定义未使用),「在用/已用票随窗口刷新」,可选票过滤在本地 SQL | +| 6 | 应付 asstact_number | 取**合同乙方 party_b**(payer_name 仅应收侧使用) | +| 7 | 组织编码层级 | 核算/结算/付款/收款/开票**组织类字段已统一改为公司级编码**:`kingdeeOrgNumber`(L976-999)按项目业务部门沿部门树上溯 `is_platform_company=1` 平台公司取其 `kingdee_dept_code`;仅 A303-1 申请部门(awrp_orgfield)保留部门级编码。部门级编码(如工业物流中心 01.02.05.06.01.015)不再直接作为结算/付款组织送出,自动替换为其平台公司编码(如国安运输 01.02.05.06.01);前置数据要求见《2026-10-16-金蝶组织编码部门数据维护清单》 | + +> 校验提醒:以上配置键的**环境实际值**以 Nacos 为准(本文只列键名与代码默认值);blade_* 表列名按 Entity 驼峰↔下划线约定标注,个别快照列以建表 SQL 为准。 diff --git a/doc/docs/业务流程/2026-10-16-付款申请金蝶生成即回显预结算预付信息(synced提前建档-作废联动清理).md b/doc/docs/业务流程/2026-10-16-付款申请金蝶生成即回显预结算预付信息(synced提前建档-作废联动清理).md new file mode 100644 index 00000000..1cd0cae7 --- /dev/null +++ b/doc/docs/业务流程/2026-10-16-付款申请金蝶生成即回显预结算预付信息(synced提前建档-作废联动清理).md @@ -0,0 +1,53 @@ +# 付款申请金蝶生成即回显预结算预付信息:synced 提前建档 + 作废联动清理 + +> 日期:2026-10-16 +> 关联文档: +> - `doc/docs/业务流程/2026-09-29-预付款申请A103-3金蝶链路补齐方案(e_bebank_number统一-消失解锁-回写默认开启).md`(A103-3 推送与 A103-2 消失解锁) +> - `doc/docs/业务流程/2026-09-17-付款管理金蝶付款结果定时回写(A103-2付款申请单查询+A103-4付款处理查询)技术文档.md`(回写任务与 `applyPayApplyResult`) +> - `doc/docs/业务流程/2026-09-20-应付申请与付款申请同步处理全链路业务流程(A102-2下推-A102-4查询-A103-1生成-A103-2回写).md` + +## 1. 背景与问题 + +排查预结算单 YJ2026101500001(已完成预结算审批、付款申请 FKD2026101500001 审批、金蝶付款申请单生成 FKSQ-00000229):预结算单详情页"预付信息"卡片始终空白。 + +根因:`blade_pre_settlement_advance`(预付记录表,详情接口 `advances` 字段的数据源)此前**只在金蝶回写"付款完成"(`kingdeeStatus=paid`)时才写入**(`applyPayApplyResult` → `syncPreSettlementAdvance`)。付款申请推进到"已生成"(synced)阶段不落记录,页面预付信息必须等付款完成才可见;且基本信息区的预付申请/已付金额来自付款申请实时汇总(`fillPaymentApplicationAmounts`),金额有值、明细卡片空白,容易误判。 + +## 2. 改造内容(`PaymentApplicationServiceImpl`) + +| # | 变更点 | 变更前 | 变更后 | +|---|---|---|---| +| 1 | `syncPreSettlementAdvance` | 无参方法,写死 `billStatus="paid"`、已付=申请全额 | 参数化 `(entity, billStatus, paidAmount)`,建档与付款完成共用;按付款申请单号查重幂等 | +| 2 | `doSyncKingdee` synced 落库处 | 仅更新付款申请状态 | **生成成功即建档**:`approved` / 已付 0,同事务落库,预付信息提前回显 | +| 3 | `applyPayApplyResult` paid 分支 | 调原方法 | `paid` / 申请全额(语义不变),已建档时更新为已付款 | +| 4 | `applyPayApplyResult` 勾稽分支(新增) | failed 单据经 A103-2 勾稽确认 synced/paying 时不落预付记录 | 与推送成功同语义,`approved` / 已付 0 建档 | +| 5 | 新增 `voidPreSettlementAdvance` | 无 | 按付款申请单号将未付预付记录置 `voided`(`paid` 记录不联动,付款完成事实不回退),并刷新预结算汇总 | +| 6 | `voidBill`(付款申请作废) | 不清理预付记录 | 联动作废,避免残留记录阻断预结算作废(`PreSettlementServiceImpl#voidBill` 的"存在关联预付申请"拦截) | +| 7 | `unlockBill`(金蝶反审核解锁,含 A103-2 连续两轮消失判定) | 不清理预付记录 | 联动作废,重推生成新单后重新建档 | + +建档/作废后的联动刷新(`refreshPreSettlementAdvanceSummary` 预结算 advance_no/预付金额列、`formal_settlement_source` 预付金额、正式结算付款汇总)复用原有尾部逻辑,无需额外改动。转正式结算时来源表预付金额不再恒为 0。 + +## 3. 状态与联动时序 + +| 付款申请阶段 | 预付记录(blade_pre_settlement_advance) | 页面预付信息 | +|---|---|---| +| 审批通过、未推送 | 不存在 | 空白 | +| 金蝶付款申请单生成成功(synced) | `approved` / 已付 0(提前建档) | **回显**(状态"审批通过") | +| 付款中(paying)/ 勾稽确认已生成 | `approved`(补建或维持) | 回显 | +| 付款完成(paid 回写) | `paid` / 已付全额 | 回显(状态"已付款") | +| 付款申请作废 / 金蝶反审核解锁 | 未付记录置 `voided`(`paid` 不动) | 消失(汇总同步扣减) | + +前端零改动:`pre-settlement-editor.vue` 的 `advanceStatusName` 已含 `approved→审批通过` 映射。 + +## 4. 定时任务影响 + +- **付款结果回写任务(`syncKingdeePaymentResult`)**:零改动。轮询条件基于付款申请自身状态,不依赖预付记录表;paid 回写时命中已有记录更新,不产生重复。 +- **失败重推(`retryKingdeeFailed`)**:failed 阶段未建档,重推成功经 synced 建档一次,幂等查重兜底。 +- **A103-4 付款记录回填、金蝶单据锁补偿**:不涉及预付记录表,无影响。 + +## 5. 行为变化与存量数据 + +| 项 | 说明 | +|---|---| +| 预结算作废拦截提前 | "存在关联预付申请,请先作废预付申请"从付款完成后提前到金蝶生成即生效——作废预结算前需先作废付款申请(预期收紧) | +| 存量单据不自动补 | 改造上线前已 synced 的付款申请(如 FKD2026101500001)不会重推(状态门禁跳过 synced),预付记录要等 paid 回写补建;需立即回显的,手工补一条 `blade_pre_settlement_advance`(`advance_no=付款申请单号`、`bill_status='approved'`、已付 0) | +| 金额模糊匹配触发提前 | synced 建档时同额未付记录的兜底匹配与 paid 阶段同一逻辑,仅触发时点提前;如出现误绑定,可将模糊匹配收敛到 paid 分支 | diff --git a/doc/docs/业务流程/2026-10-16-收款流水多次认领与作废口径梳理.md b/doc/docs/业务流程/2026-10-16-收款流水多次认领与作废口径梳理.md new file mode 100644 index 00000000..e0bb492c --- /dev/null +++ b/doc/docs/业务流程/2026-10-16-收款流水多次认领与作废口径梳理.md @@ -0,0 +1,130 @@ +# 收款流水(认领通知单)多次认领与作废口径梳理 + +> 日期:2026-10-16 +> 范围:`tms-api` blade-transport 收款流水认领、认领记录模块;`tms-erp-web` 认领记录页面 + +--- + +## 一、结论速览 + +| 问题 | 结论 | +| --- | --- | +| 一个通知单能被多次认领,每次认领生成一条还是多条认领记录? | **每次认领操作生成一条认领主单**(`blade_receipt_claim`),多次认领即多条主单;每次认领同时按所选拆分生成 N 条结算单分摊明细(`blade_receipt_claim_settlement`) | +| 认领记录页面是一条一条显示还是只显示一条? | **一条认领主单一行,逐条显示**,不按通知单合并;同一通知单认领 3 次就显示 3 行,每行"认领金额"只是该次认领的金额 | +| 作废是逐条作废归还金额,还是整个通知单全部作废? | **逐条作废、按条归还金额**:作废入口在单条认领记录上,回退金额 = 该条认领主单的 `claim_amount`,流水已认领金额只减这一条;其余认领记录不受影响 | + +--- + +## 二、数据模型 + +``` +blade_kingdee_receipt_flow(收款流水 / 认领通知单,1) + │ receipt_flow_id + ├─< blade_receipt_claim(认领主单,N) ← 每次认领一条 + │ claim_amount 本次认领总金额 + │ claimer_id 认领人(记录页面按本人过滤) + │ claim_status claimed / voided + │ voided_by / voided_time 作废人 / 作废时间 + │ + └─< blade_receipt_claim_settlement(分摊明细,N × M) ← 每次认领按结算单拆行 + receipt_claim_id 归属哪次认领 + formal_settlement_id/no 关联应收正式结算单 + allocated_receipt_amount 本次分摊金额 + claimed_receipt_amount 认领前该结算单已认领金额 + status 1 有效 / 0 无效(作废置 0,不物理删除) +``` + +关键点:**认领主单的粒度是"一次认领操作"**,不是通知单。通知单维度只有累计金额(`claimed_amount`)和状态(`claim_status`:unclaimed / partial / claimed),由每次认领/作废累加回退得出。 + +--- + +## 三、认领:一次操作生成一条主单 + N 条分摊 + +代码:`ReceiptFlowServiceImpl#claim(ReceiptClaimRequest)` + +1. 锁定流水(通知单)与所选结算单。 +2. 校验两条累计上限: + - 每张结算单:已有累计认领 + 本次分摊 ≤ 结算总应收含税金额; + - 流水:已有累计认领(`flowClaimedAmount`,只算未作废记录)+ 本次总额 ≤ 公告未认领金额(`unclaimed_amount`,A301-2 口径)。 +3. **插入一条** `ReceiptClaim` 主单:`claim_amount` = 本次分摊总额,`claim_status = claimed`。 +4. 按所选结算单逐张**插入 N 条** `ReceiptClaimSettlement` 分摊明细。 +5. 累加流水 `claimed_amount`,按累计值重算流水状态(< 未认领金额 → partial,= → claimed)。 +6. 只登记本地,不推送金蝶;推送由定时任务/手动按钮触发。 + +所以:**同一通知单第二次认领是再插一条新主单**,不存在"找已有主单合并金额"的逻辑;校验全部按"未作废记录累计"实时计算,天然支持多次认领。 + +### 认领按钮的显示条件(为什么有些行没有"认领") + +收款流水列表操作列(`receipt-flow.vue` → `canClaim(row)`): + +```js +canClaim(row) { + return this.hasPermission('receipt_flow_claim') && Number(row.remainingAmount || 0) > 0; +} +``` + +两个条件同时满足才显示"认领"按钮: + +1. **按钮权限**:当前用户拥有 `receipt_flow_claim` 按钮权限(admin 直接放行,普通用户取决于菜单按钮配置); +2. **剩余可认领金额 > 0**:`remainingAmount` 由后端 `ReceiptFlowWrapper` 统一计算: + + ``` + remainingAmount = max(公告未认领金额 unclaimed_amount − 累计认领金额 claimed_amount, 0) + ``` + +按认领状态换算: + +| 流水认领状态 | remainingAmount | 认领按钮 | +| --- | --- | --- | +| unclaimed 未认领(公告金额 > 0) | = 公告未认领金额 > 0 | 显示 | +| partial 部分认领 | > 0 | 显示 | +| claimed 认领完成 | = 0(累计 ≥ 公告金额) | **不显示** | +| 公告未认领金额为 0 的流水 | = 0 | **不显示** | +| 无 `receipt_flow_claim` 权限 | — | **不显示** | + +即:**按钮显隐只看金额余额与权限,不看认领状态字段本身**;`claimed` 状态的行因为剩余额度为 0 自然不显示按钮。按钮不显示且不满足"推送金蝶"条件时,操作列显示"-"。 + +注意:顶部"批量认领"按钮只判断权限与是否勾选行(`receipt_flow_claim` + `selectedRows.length`),**不按行剩余金额过滤**;对无剩余额度的流水发起批量认领时,进入批量页面后由提交校验/后端 `claim()` 兜底拦截。 + +--- + +## 四、认领记录页面:逐条显示 + +后端:`ReceiptClaimMapper.xml#selectClaimRecordPage` → `FROM blade_receipt_claim c JOIN blade_kingdee_receipt_flow f`,固定条件 `c.claimer_id = 当前用户`。 + +- **列表一行 = 一条认领主单**,无按流水/通知单的 GROUP BY 合并; +- 同一通知单多次认领 → 多行,各行的认领金额、认领日期、金蝶单号都是当次认领自己的; +- 关联结算单列用 `GROUP_CONCAT(分摊明细.结算单号)` 拼成一串展示(只展示,不参与合并); +- 详情页(`selectClaimRecordDetail` + 分摊明细列表)展示的也是**该次认领**的分摊明细,明细里的"认领前已认领金额"记录的是认领那一刻结算单的累计值,可还原多次认领的先后关系; +- 前端 `receipt-claim-record.vue` 列表 + 行级"作废"按钮(`api.voidClaim(row.id)`),确认文案为"作废后将回退本次认领金额"——与逐条口径一致。 + +另:收款流水列表的详情弹窗里有一个"认领记录"区块,展示的是该流水的**操作日志**(`blade_receipt_flow_record`,action_type = claim/push),按时间线展示每次认领/推送动作,同样不是合并视图。 + +--- + +## 五、作废:逐条作废、按条归还金额 + +代码:`ReceiptClaimRecordServiceImpl#voidClaim(Long id)`,入参就是**单条认领主单 id**。 + +流程(单事务): + +1. 锁定该条认领主单,校验:本人记录、`claim_status = claimed`(已作废不可再作废)。 +2. 锁定关联流水,校验:**未推送金蝶**——已推送的不允许作废,提示"请在金蝶侧人工处置"。 +3. 取该主单名下有效分摊明细(status=1),全部置 `status = 0`(保留审计,不删行)。 +4. 批量刷新相关结算单收款汇总(`refreshPaymentSummaries`)。 +5. **流水 `claimed_amount` 只减去本条主单的 `claim_amount`**(下限 0),并按减后累计重算流水认领状态(0 → unclaimed,< 未认领金额 → partial,≥ → claimed)。 +6. 主单置 `voided`,记录作废人、作废时间;写 `void_claim` 操作日志(金额 = 本次回退金额)。 +7. **不生成金蝶认领冲单**:金蝶单号/状态保留原值,金蝶侧冲销/红红由人工处置;冲单逻辑代码已注释保留(`buildKingdeeBillNo`),后续迭代可放开。 + +边界与影响: + +- 作废某一条,只归还这一条的金额,**同一通知单的其他认领记录不受影响**,仍正常显示、仍可单独作废; +- 作废后该条记录仍在列表中(状态"已作废"),不允许再改附件、不可重复作废; +- 归还后的金额空间立即可被再次认领(认领校验按未作废累计实时计算); +- 附件/操作日志/分摊明细均保留,金额回退可追溯。 + +--- + +## 六、一句话总结 + +通知单(收款流水)是"累计额度"维度,认领记录是"每次操作"维度:**认领一次记一条、页面逐条展示、作废按条回退金额**;整单性质的只有流水上的累计已认领金额与认领状态,由各条记录的认领/作废实时推导,没有任何"按通知单整体作废"的入口。 diff --git a/doc/docs/业务流程/2026-10-16-结算调整单接入金蝶设计(未推送自动推-已推送冲销后重推新单).md b/doc/docs/业务流程/2026-10-16-结算调整单接入金蝶设计(未推送自动推-已推送冲销后重推新单).md new file mode 100644 index 00000000..a33bdcc2 --- /dev/null +++ b/doc/docs/业务流程/2026-10-16-结算调整单接入金蝶设计(未推送自动推-已推送冲销后重推新单).md @@ -0,0 +1,290 @@ +# 结算调整单接入金蝶设计(未推送自动推 · 已推送手工冲销后重推新单) + +> 整理日期:2026-10-16 +> 目标:把「结算调整单」正式接入金蝶链路。核心约束——**原正式结算单一旦推送过金蝶,其本地数据一律冻结不动**;调整后的数据落在重推时新建的一张正式结算单上,用它向金蝶生成**全新**的财务应付/应收单。 +> 依据:`2026-09-20-应付单与付款申请同步金蝶全链路业务流程(A102-2保存-A102-4查询-A103-1下推-A103-2回写).md`、`2026-09-21-应收结算与开票申请同步金蝶全链路业务流程(A302-2保存-A302-4勾稽-A303-1新增-A303-4预检-A303-5回写).md`、`2026-10-05-审批通过自动同步金蝶设计(事件触发+定时重推+手动兜底).md`、`2026-10-09-金蝶单据锁定与解锁业务流程(...).md`、当前代码(探索核实)。 + +--- + +## 1. 背景与目标 + +结算调整单(`blade_settlement_adjustment`)用于在正式结算单审批通过后调整其费用项目与金额。目前它只有本地审批与一个假的 `repush`(拼 `K3AP+时间戳`),并未真正接入金蝶。 + +需要落地的三段操作: + +| 操作 | 内容 | +|---|---| +| 操作 1 新增调整单 | 只允许选**审核通过**且**未下推付款申请**的正式结算单;自动回带客商、调整类型、合同等 | +| 操作 2 审批通过 | 回写/记录调整 → 按原结算单金蝶状态分流(未推送→推原单;已推送→不动金蝶,提示财务手工冲销) | +| 操作 3 重新推送(高危) | 旧应付/应收单已在金蝶冲销后,把**调整后的数据**以**全新来源单据号**推成一张新的财务应付/应收单 | + +> 全程交互使用中文。 + +## 2. 名词与接口速查 + +| 代号 | 含义 | +|---|---| +| A102-2 | 财务应付单**保存**(推送) | +| A102-4 | 财务应付单**查询**(幂等预检;返回 `BfiApBillVO`,含 `iswrittenoff` / `hadwrittenoff`) | +| A302-2 | 财务应收单**保存**(推送) | +| A302-4 | 财务应收单**查询**(幂等预检,同上) | +| 防重键 | 金蝶来源单据号 `awrp_src_code`,由 `ExternalSrcCodeSupport.buildSrcCode(mainCode, partCodes)` 下划线拼接,规则 `结算单号_明细单据号` | +| 正式结算单 | `blade_formal_settlement`,单号 `FJS`(应付)/`SJS`(应收) + `yyyyMMdd` + 4 位流水(`nextNo`) | +| 结算调整单 | `blade_settlement_adjustment` | + +## 3. 现状与差距(代码核实) + +| # | 位置 | 现状 | 差距 | +|---|---|---|---| +| 1 | `SettlementAdjustmentServiceImpl.approve()`(约 221-261) | 仅改本地:回写原结算单费用、写变更记录、置 `approved`;`setKingdeeSyncStatus(formal.getKingdeeSyncStatus())`(推送前快照) | 未触发金蝶推送;未回填推送后的真实状态,导致前端「重新推送」按钮(条件 `approved && kingdeeSyncStatus='synced'`)不出现 | +| 2 | `SettlementAdjustmentServiceImpl.repush()`(约 207-217) | 假实现:拼 `K3AP+时间戳` 直接置 synced | 未校验冲销、未生成新单、未真正推送 | +| 3 | `FormalSettlementServiceImpl.syncKingdee/doSyncKingdee`(640/805) | 真实推送 + 落锁 + 失败重推链路;`doSyncKingdee` 在 `synced` 时**幂等早退** | 重推前须重置状态,否则被早退 | +| 4 | `KingdeeApBillSyncSupport.pushApBill`(220)/`queryApBillExistNos`(157) | A102-4 预检**不区分是否已冲销** | 冲销后同键重推会被预检判为"已存在"而跳过 | +| 5 | `BfiApBillVO` | 已含 `iswrittenoff`(154)/`hadwrittenoff`(158) | 尚未用于冲销判定 | +| 6 | `SettlementAdjustment` 实体 / `SettlementAdjustmentVO` | 实体无 `kingdeeBillNo`;VO 有(20) | 金蝶单号无处落库 | +| 7 | `IKingdeeBillLockService` | `lockAfterPush` / `checkEditable`;**冲销不触发解锁回调** | 原单锁记录会一直停在 `locked` | +| 8 | `IKingdeeBillLockService.checkEditable` | 仅开票申请、付款申请调用;**正式结算单未调用** | 正式结算单的编辑保护主要在前端 + `voidBill` 的 `synced` 拦截 | + +## 4. 关键决策(已确认) + +| 决策 | 结论 | +|---|---| +| **A 原单去向** | 已推送场景下**原单业务数据零改动**(费用/金额/状态全不动),只加 1 条变更记录 + 标记 + 解锁;新单是**全新独立单**,原单的下游关联(开票申请/收款认领/对账)**不迁移**,留在原单 | +| **B 展示标记** | 原单标「已调整」;新单标「调整后单」并显示原单号 | +| **B1 建单时机** | 「重新推送」时新建,审批阶段不动 | +| **B2 新单编号** | 走 `nextNo` 正式流水(`FJS`/`SJS` + 日期 + 4 位),**不**加后缀 | +| **B3 原单处理** | 保持原状,不做作废/删除 | +| **B4 调整单挂载** | 重推后调整单**改挂新单** | +| **B5 来源预结算** | 新单**重建** `FormalSettlementSource` 行(接续原单来源) | +| **B6 范围** | 应付、**应收都要做**(应付 A102-2/A102-4,应收 A302-2/A302-4) | +| **B7 列表** | 新旧两张单并存,带标识展示 | +| **C 冲销判定** | 只认 `hadwrittenoff=true`;部分冲销也放行 | +| **D 原单旧锁** | 重推时把原单旧锁标记为 `unlocked`,不改其它 | +| **E 未推送场景** | 审批通过后**自动推**(复用既有 `KingdeeBillApprovedEvent` 机制) | +| 新单状态 | 克隆后直接置 `approvalStatus=approved`,不再走审批 | +| 原单变更记录/unlock 时机 | 变更记录审批时写;`unlock` + `已调整`标记在**重推时**执行(审批时财务可能还没冲销) | +| **选单预检** | 操作 1 选定结算单后**预检**其金蝶冲销状态(软提示,不硬拦截);金蝶不可用时降级放行 | +| **重推冲销闸门** | 真正的**硬拦截**保留在操作 3 重推;预检不替代闸门 | + +## 5. 总体方案 + +### 5.1 流程图 + +> 操作 1 选单后会先做一次金蝶冲销**预检(软提示,不阻断)**,见第 6.2 节。 + +``` + ┌── 原结算单 kingdeeSyncStatus ──┐ +操作2 调整单审批通过 ──────────┤ │ + ▼ ▼ + 未推送(unsynced) 已推送(synced) + │ │ + ① 回写原单费用/金额 ① 原单业务数据【零改动】 + ② 原单写变更记录 ② 原单仅写 1 条变更记录 + ③ 发布 KingdeeBillApprovedEvent ③ 调整单置 approved + (formal-settlement, 原单id) ④ toast:原结算单已推送金蝶, + ④ AFTER_COMMIT → syncKingdee(原单) 请财务在金蝶手工完成旧应付单冲销处理 + ⑤ 回填调整单 synced + kingdee_bill_no + │ │ + │ 【财务在金蝶手工冲销旧应付/应收单】 + │ │ + │ 操作3 重新推送(高危) + │ │ + │ ① 校验调整单 approved + │ ② 旧键查 A102-4/A302-4 → 要求 hadwrittenoff=true + │ ③ 克隆原单 → 新正式结算单(+应用调整明细、重建 source) + │ ④ 新单号组新键推 A102-2/A302-2 + │ ⑤ 新单落锁 + 附件 + │ ⑥ 调整单改挂新单 + 回填 kingdee_bill_no/synced + │ ⑦ 原单:标记「已调整」+ 旧锁 unlocked + │ ⑧ 写变更记录;失败置 failed 可重试 + ▼ ▼ + 金蝶应付/应收单 金蝶【全新】应付/应收单 + (同原单号来源) (新单号来源,旧单已冲销) +``` + +### 5.2 三条不变式 + +1. **原单一经推送金蝶即冻结**:已推送场景下原单除「变更记录 / 标记 / 解锁」外,任何业务字段与状态均不改。 +2. **调整后的金额只体现在新单**:未推送场景作用于原单;已推送场景作用于重推时克隆出的新单。两者共用同一段「应用调整到结算单」的逻辑。 +3. **重推必须锚定金蝶侧已冲销**:以 A102-4/A302-4 的 `hadwrittenoff=true` 为唯一放行条件,避免重复生成应付/应收单。 + +## 6. 操作 1|新增调整单 + +### 6.1 选单来源(保持现状) + +选单来源 `candidateFormalSettlements`(`SettlementAdjustmentServiceImpl` 约 98)已符合要求,不改: + +- 仅 `approvalStatus = approved` 的正式结算单; +- 排除已下推付款申请的单(`tailPaymentSettlementIds`,约 324); +- 排除收付完成/尾款等不可调整(`adjustableFormalSettlement`,约 295); +- 保存时自动回带客商、调整类型、合同、原结算金额等。 + +### 6.2 选单后金蝶冲销预检(新增,软提示) + +选定关联结算单号、回带信息后,前端调后端**预检接口**: + +1. 若结算单**未推送金蝶**(`kingdeeSyncStatus != synced`)→ 返回"未推送",正常继续,后续按设计文档走审批自动推分支; +2. 若**已推送**→ 按防重键(`正式结算单号_结算明细单据号`)批量调 A102-4(应付)/ A302-4(应收),读 `hadwrittenoff`: + - `hadwrittenoff = true` → 返回"已推送·旧单已冲销",提示"旧应付/应收单已冲销,审批后可重新推送新单"; + - `hadwrittenoff != true`(含查询不到)→ 返回"已推送·旧单未冲销",给出**醒目提示**:"该结算单已推送金蝶且旧单尚未冲销,审批通过后需先由财务在金蝶完成冲销,才能重新推送"。 + +**口径**: + +- 预检为**软提示**,不阻断建单(默认建议);是否升级为硬拦截由业务另行确认。 +- **降级**:金蝶查询超时/不可用/接口异常时,预检返回"未校验",**不阻断**建单与回带,由后续审批分流与重推闸门兜底。 +- 预检结果**只用于提示**,不作为后续环节的结论缓存:审批(操作 2)、重推(操作 3)各自按**当下**状态重新判定。 + +> 为什么不把硬拦截放在选单:冲销是金蝶侧**不可逆/需人工撤销**的对外操作,而审批只是 TMS 内部决策。若选单即要求已冲销,会出现"旧单已冲销、调整单却被驳回/删除"的**应付单空档**(详见第 13 节)。 + +## 7. 操作 2|审批通过 + +### 7.1 未推送场景(原单 `kingdeeSyncStatus != synced`) + +沿用现有 `approve()` 逻辑并补推送: + +1. `applyAdjustmentToSettlement(原单, 调整单)`:回写费用项目、明细、合计、重算结算金额(**抽成公共方法**); +2. 原单追加变更记录、挂载调整单; +3. 调整单置 `approved`; +4. 事务提交后发布 `KingdeeBillApprovedEvent("formal-settlement", 原单id)`,由既有 `KingdeeApprovalSyncListener`(AFTER_COMMIT + 域开关 + 全量 try/catch)调 `formalSettlementService.syncKingdee(原单id)` 完成 A102-2/A302-2 推送 + 落锁; +5. 推送成功回填调整单 `kingdeeSyncStatus=synced` + `kingdeeBillNo`;失败置 `failed`(不改「已审批」结果,进入重推循环)。 + +> 复用 `2026-10-05-审批通过自动同步金蝶设计` 的事件机制,不另写推送路径;自动推送失败不影响审批结果。 + +### 7.2 已推送场景(原单 `kingdeeSyncStatus = synced`)★ 关键约束 + +**原单业务数据一律不动**,只做: + +1. 原单追加 1 条变更记录:`结算调整单XX审批通过,调整金额XX,待重推金蝶`; +2. 调整单置 `approved`(`kingdeeSyncStatus` 保持 `synced` 快照,前端据状态显示「重新推送」入口); +3. 返回前端 toast 提示:**「原结算单已推送金蝶,请财务人员在金蝶手工完成旧应付单冲销处理」**。 + +> 不做:不回写原单费用/金额、不修改旧应付、不自动推送新单、不作废原单。 + +## 8. 操作 3|重新推送(高危) + +`repush(调整单id)` 重写为: + +1. **前置校验**:调整单 `approvalStatus = approved`;原单存在。 +2. **冲销校验(硬闸门)**:取调整单号 + 原单号,按旧键(`原结算单号_明细单据号`)查 A102-4(应付)/ A302-4(应收);要求存在且 `hadwrittenoff = true`,否则抛出「原应付/应收单尚未在金蝶冲销,禁止重推」。 +3. **克隆新正式结算单**:`cloneFormalSettlement(原单, 调整单)` + - 新号由 `nextNo` 取正式流水(`FJS`/`SJS`+日期+4 位); + - 克隆明细、明细费项、汇总费项、发票、汇率等; + - 调 `applyAdjustmentToSettlement(新单, 调整单)` 应用调整(金额 = 原单 + 调整); + - **重建** `FormalSettlementSource`(接续原单来源预结算); + - `approvalStatus = approved`、`kingdeeSyncStatus = unsynced`、`originFormalSettlementNo = 原单号`; + - 附件复制/挂载。 +4. **推送新单**:新单号天然组成新防重键(`新结算单号_明细单据号`),调 `syncKingdee(新单id)`(内部 A102-2/A302-2),**推前把新单状态置 `unsynced`** 以免被 `doSyncKingdee` 的 `synced` 早退。 +5. **落锁**:新单 `lockAfterPush`(`TARGET_AP_BILL`/`TARGET_AR_BILL`)+ 附件上传。 +6. **改挂 + 回填**:调整单 `formalSettlementId/No` 改指新单,回填 `kingdeeBillNo` + `kingdeeSyncStatus=synced`。 +7. **原单收尾**:标记 `adjustStatus='已调整'` + 旧锁记录置 `unlocked`;追加重推变更记录。业务字段不动。 +8. **失败处理**:新单 `kingdeeSyncStatus=failed` + 失败原因,支持复用 `retryKingdeeFailed` 重试(重试即从第 4 步继续,克隆已存在则跳过,注意幂等)。 + +## 9. 关键机制 + +### 9.1 防重键与新单号 + +- 金蝶 `awrp_src_code = 结算单号_明细单据号`。因为重推**新建了真实结算单记录**(新 `FJS/SJS` 号),新键天然与原键不同,**无需再额外拼调整单号**——早期「拼调整单号改防重键」的方案已被「新建结算单」覆盖。 +- 旧键(原结算单号)用于查冲销;新键(新结算单号)用于生成全新应付/应收单。 + +### 9.2 冲销判定 + +- 只认 `hadwrittenoff = true`(`BfiApBillVO` 158 / `BfiArBillVO` 138)。部分冲销同样放行。 +- `iswrittenoff` 不单独作为放行依据。落地前需用 Apifox 实测 A102-4/A302-4 确实回传 `hadwrittenoff`。 +- **两处判定,用途不同**:操作 1 选单预检 = 软提示(第 6.2 节);操作 3 重推 = **硬闸门**(第 8 节)。预检不替代闸门——冲销在金蝶可被撤销,且建单到重推期间状态可能变化。 + +### 9.3 克隆新正式结算单 + +- 复用现有「保存/组装」逻辑而非另写:明细、费项、汇总、来源、附件、汇率、收付款单位等按原单快照复制。 +- 新单不再走审批流(直接 `approved`),因此不写审批变更记录以外的流程痕迹;是否补一条「由调整单XX生成」变更记录可选。 + +### 9.4 金蝶锁 + +- 新单推送成功 → `lockAfterPush` 落锁,状态与既有一致。 +- 原单旧锁:重推时置 `unlocked`(决策 D)。因正式结算单未调用 `checkEditable`,解锁主要影响前端标签,不构成后端编辑漏洞。 + +### 9.5 失败与重试 + +- 复用 `markKingdeeSyncFailed` / `retryKingdeeFailed` 模式:失败落 `failed` + 原因,可被定时重推任务扫描。 +- 克隆步骤需幂等:以 `originFormalSettlementNo + 调整单` 作为唯一判断,避免重试时重复建单。 + +### 9.6 展示标记 + +| 对象 | 标记 | 来源 | +|---|---|---| +| 新正式结算单 | 「调整后单」(并显示原单号) | `origin_formal_settlement_no` 非空 | +| 原正式结算单 | 「已调整」 | `adjust_status = 'adjusted'` | +| 结算调整单 | 金蝶状态 / 金蝶单据号 | `kingdee_sync_status` / `kingdee_bill_no` | + +## 10. 数据模型改动(DDL) + +新增脚本 `doc/sql/transport/blade_settlement_adjustment_kingdee_20261016.sql`(多数据库脚本同步,排序规则 `utf8mb4_general_ci`): + +```sql +-- 结算调整单:落金蝶单据号 +ALTER TABLE blade_settlement_adjustment ADD COLUMN kingdee_bill_no VARCHAR(64) NULL COMMENT '金蝶单据号'; + +-- 正式结算单:来源原单号(新单) + 调整标记(原单) +ALTER TABLE blade_formal_settlement ADD COLUMN origin_formal_settlement_no VARCHAR(64) NULL COMMENT '来源原正式结算单号(结算调整重推生成)'; +ALTER TABLE blade_formal_settlement ADD COLUMN adjust_status VARCHAR(32) NULL COMMENT '调整状态:adjusted=已被结算调整'; +``` + +推送金蝶与重新推送合并为**单一按钮**(按金蝶状态分流),复用既有「重新推送」菜单,无需新增菜单 SQL。 + +## 11. 改动清单(文件级) + +| # | 文件 | 改动 | +|---|---|---| +| 1 | `blade_service_api/.../pojo/entity/SettlementAdjustment.java` | 加 `kingdeeBillNo` | +| 2 | `blade_service_api/.../pojo/entity/FormalSettlement.java` | 加 `originFormalSettlementNo`、`adjustStatus` | +| 3 | `blade_service_api/.../pojo/vo/SettlementAdjustmentVO.java`、`FormalSettlementVO.java` | 补展示字段/翻译 | +| 4 | `SettlementAdjustmentServiceImpl.approve()` | 未推送→发 `KingdeeBillApprovedEvent` + 回填;已推送→仅变更记录 | +| 5 | `SettlementAdjustmentServiceImpl.repush()` | 重写:冲销校验 → 克隆新单 → 推 → 落锁 → 改挂 → 回填 → 原单标记/解锁 → 失败置 failed | +| 6 | `SettlementAdjustmentServiceImpl` | 抽公共方法 `applyAdjustmentToSettlement(结算单, 调整单)` | +| 7 | `FormalSettlementServiceImpl`(或新 support) | 新增 `cloneFormalSettlement(...)`(含重建 `FormalSettlementSource`) | +| 8 | `FormalSettlementServiceImpl` / `KingdeeApBillSyncSupport` | 冲销查询(A102-4/A302-4 读 `hadwrittenoff`)封装 | +| 9 | `FormalSettlementWrapper` / 列表 Mapper | 回填 `adjustStatus` / `originFormalSettlementNo` | +| 10 | 前端 `settlement-adjustment.vue`、`settlementAdjustment.js` | 金蝶状态/单号列、「推送金蝶 / 重新推送」合并按钮(未同步→推原单;已同步→冲销后重推新单,重推前冲销预检);选单后调预检接口并展示软提示 | +| 11 | 前端 `formal-settlement.vue` / `formal-settlement-table-panel.vue` / 详情 | 「调整后单 / 已调整」标识、查看弹窗补重推入口 | +| 12 | `doc/sql/...` | DDL + 菜单 SQL | +| 13 | `SettlementAdjustmentServiceImpl` + `SettlementAdjustmentController` | 新增选单预检接口(返回 未推送 / 已推送已冲销 / 已推送未冲销;金蝶异常降级返回未校验) | + +## 12. 边界场景推演 + +| 场景 | 行为 | +|---|---| +| 调整单未审批就重推 | 前置校验拒绝(要求 approved) | +| 旧应付单未冲销 | A102-4 返回 `hadwrittenoff=false`/不存在 → 拒绝,提示先冲销 | +| 部分冲销 | `hadwrittenoff=true` → 放行 | +| 旧应付单被删除(非冲销) | 查询不到 → 拒绝(不满足 `hadwrittenoff=true`) | +| 已推送场景审批后财务未冲销 | 重推被拦;原单与新单均未产生重复应付单 | +| 重推推送失败 | 新单置 `failed` + 原因;原单已标「已调整」+ 解锁;重试从推送步骤续上 | +| 重推成功但落库前崩溃 | 重试时幂等判断:已存在同一来源新单则跳过克隆,仅补推/补回填 | +| 应收结算单(A302-2/A302-4) | 走同一套逻辑,应收侧下游关联不迁移(决策 A) | +| 原单锁残留 | 重推时统一置 `unlocked` | +| 未推送场景自动推失败 | 审批仍成功;调整单 `failed`,走重推循环 | +| 选单时结算单已推送且旧单未冲销 | 预检返回"未冲销"并**软提示**(不阻断建单);审批后需先冲销再重推 | +| 选单时金蝶超时/不可用 | 预检**降级为"未校验"**,不阻断建单;由审批分流与重推闸门判定 | +| 建单时未推送、审批时已被推送到金蝶 | 审批按**当下**状态走"已推送"分支:不自动推、提示财务冲销;预检结论不沿用 | +| 选单时已冲销、重推前被金蝶撤销冲销 | 重推硬闸门重新查询 `hadwrittenoff` → 拦截,提示先冲销 | + +## 13. 风险与待验证 + +1. **A102-4/A302-4 的 `hadwrittenoff` 实际回传**:必须先用 Apifox 实测,否则整套冲销判定可能推翻。 +2. **克隆新单**:`FormalSettlementSource` 重建会牵动预结算占用(`PreSettlement.formalSettlementNo`),是最易产生脏数据的一步,需单独小步验证。 +3. **原单下游关联不迁移**(开票申请/收款认领/对账):新单为全新独立单,应收场景下新单将显示「未开票/未收款」,属已确认口径,但需业务知悉。 +4. **重推幂等**:克隆需以来源唯一键保证不重复建单,验证重试与并发(同一调整单重复点击)。 +5. **正式结算单无 `checkEditable`**:解锁不构成后端漏洞,但需确认前端在 `approved` 下仍不暴露编辑入口,避免原单被改。 +6. **冲销前置到选单的风险(故不做硬拦截)**:冲销是金蝶侧不可逆/需人工撤销的对外操作,审批是 TMS 内部决策。若选单即硬拦截(要求已冲销),会出现"旧单已冲销、调整单却被驳回/删除"的**应付单空档**,需人工在金蝶恢复冲销。故预检仅作软提示,硬闸门留在重推。 +7. **预检接口不可成为单点**:金蝶不可用时必须降级放行,否则远程依赖会阻断调整单的建单与回带。 + +--- + +## 14. 落地实现备注(2026-10-16) + +1. **未推送场景回填**:推送成功/失败分别由 `FormalSettlementServiceImpl.doSyncKingdee` / `markKingdeeSyncFailed` 按 `formal_settlement_id + approval_status=approved` 回填调整单 `kingdee_sync_status`(synced/failed)与 `kingdee_bill_no`,复用 §7.1 的事件自动推送链路,未新增推送路径。 +2. **重推为「非事务编排 + 段内事务」**:`repush` 依次执行 —— 冲销硬闸门(旧键查 A102-4/A302-4,`hadwrittenoff=true`)→ 自代理事务 `cloneAndApplyForRepush`(克隆新单 + 应用调整,原子)→ `syncKingdee(新单)` 推送 → 成功后调整单改挂新单 + 原单标记「已调整」+ 旧锁解锁。推送失败**不回滚已克隆新单**,重试按 `origin_formal_settlement_no = 调整单当前关联单号` 幂等复用,从推送步骤续上(§8.8、§9.5)。 +3. **改挂时机**:调整单改挂新单在**推送成功之后**,故成功后再点重推会被冲销闸门拦截(无副作用);如需彻底隐藏,前端按「关联单是否克隆单」判断。 +4. **新单不克隆发票**:遵循决策 A「下游关联不迁移」,新单 `invoiceStatus=unreceived`、`paymentStatus=unpaid`,`invoiceAmount/paidAmount/appliedPaymentAmount` 归零,发票不复制。 +5. **按钮与菜单**:「推送金蝶」与「重新推送」合并为**单一入口**(按金蝶状态分流:未同步/失败→推原结算单;已同步→冲销后重推新单),统一复用既有菜单 `settlement_adjustment_repush`,不新增菜单。 +6. **待实测**:A102-4/A302-4 的 `hadwrittenoff` 实际回传(§13.1);克隆新单对 `PreSettlement` 占用的影响(§13.2)。 + diff --git a/doc/docs/业务流程/2026-10-17-结算调整单操作流程说明(分场景).md b/doc/docs/业务流程/2026-10-17-结算调整单操作流程说明(分场景).md new file mode 100644 index 00000000..16522c1b --- /dev/null +++ b/doc/docs/业务流程/2026-10-17-结算调整单操作流程说明(分场景).md @@ -0,0 +1,190 @@ +# 结算调整单操作流程说明(分场景) + +> 整理日期:2026-10-17 +> 定位:面向操作者的流程说明,只讲"谁在哪个页面做什么、系统发生什么、结果是什么",不含代码细节。 +> 依据:`2026-10-16-结算调整单接入金蝶设计(未推送自动推-已推送冲销后重推新单).md` 及当前已落地实现。 + +--- + +## 一、结算调整单是什么 + +正式结算单**审批通过后不允许直接改金额**,一切费用调整都必须通过「结算调整单」完成。 + +- **作用**:对一张已审批通过的正式结算单,调整其费用项目和金额(调增、调减、新增手工费用)。 +- **单号规则**:`TZ + 日期 + 4 位流水`,保存草稿时自动生成。 +- **只能手工新增**:系统不会自动生成调整单,全部由业务人员在页面发起。 +- **没有作废功能**:调整单一旦提交走审批,不支持作废;错了只能走"驳回 → 修改"或重新建单。 +- **推送金蝶按钮是高危操作**:点击前有二次确认,请确认金蝶侧状态后再操作。 + +--- + +## 二、状态与可用操作总览 + +### 2.1 状态流转 + +``` +新增/保存草稿 ──→ 草稿 ──提交──→ 审批中 ──审批通过──→ 审批通过(终态) + ↑ │ + │ 驳回 + └──── 已驳回 ←───┘ + (可改后重新提交) +``` + +- 草稿、已驳回:可编辑、可删除(草稿)、可提交。 +- 审批中:只能"通过"或"驳回"。 +- 审批通过:终态,只剩金蝶推送相关操作。 + +### 2.2 各状态下的按钮 + +| 状态 | 可用操作 | 说明 | +|---|---|---| +| 草稿 | 编辑 / 删除 / 提交 | 删除为物理删除,含明细 | +| 已驳回 | 编辑 / 提交 | 须先"编辑"保存一次(保存自动回到草稿)才能提交,直接提交会被拒绝 | +| 审批中 | 通过 / 驳回 | 审批人操作 | +| 审批通过 | 推送金蝶 / 重新推送(合一按钮)| 按金蝶状态自动分流,高危确认 | + +### 2.3 金蝶同步状态(调整单上的展示字段) + +| 状态 | 含义 | +|---|---| +| 未同步 | 原结算单还没推过金蝶,或推送尚未发生 | +| 已同步 | 已在金蝶生成应付/应收单,调整单上可看到金蝶单据号 | +| 生成失败 | 推送失败,可用"推送金蝶"重试或等定时重推 | + +--- + +## 三、通用操作说明(每个操作:前置条件 → 系统动作 → 结果) + +### 3.1 新增调整单 + +| 项 | 说明 | +|---|---| +| 入口 | 结算调整单列表 → 新增 | +| 前置 | 必须先选定一张**可调整的正式结算单**,条件缺一不可:① 审批通过;② 未完成收款/付款;③ 非尾款结算;④ 未发起尾款付款/付款申请。不满足的单在选单列表里根本搜不到 | +| 系统动作 | 选定结算单后自动回带:客商、调整类型(应收/应付)、合同、原结算金额、原单费用明细;同时做一次**金蝶冲销预检(软提示)** | +| 预检提示 | ① 未推送金蝶 → 正常继续;② 已推送且旧单已冲销 → 提示"审批后可重新推送新单";③ 已推送且旧单未冲销 → 醒目提示"审批后需先由财务在金蝶完成冲销,才能重新推送";④ 金蝶查不到/超时 → 提示"未校验",**不阻断建单** | +| 结果 | 生成一张草稿,获得单号 | + +> 预检只是提示,不拦人;真正的拦截在"重新推送"那一步。填单时必须至少录入一条调整费用,否则无法提交。 + +### 3.2 保存草稿 / 编辑 / 删除 + +- 草稿和已驳回的单可反复编辑;删除仅限草稿,删除后单号作废、不留痕迹。 +- 手工费用行(不对应原单明细的费用)与关联原单明细的费用行在明细里混排,提交时会校验关联完整性。 + +### 3.3 提交 + +- 草稿 / 已驳回 → 审批中,进入审批人的待办。 +- 提交后单据锁定,提交人不能再改。 + +### 3.4 审批通过 ★ 核心分流点 + +审批人点"通过"后,系统按**原结算单是否推送过金蝶**自动走两条完全不同的路(见第四、五节场景 A / B): + +- **原单未推送**:把调整直接回写到原结算单,然后自动推金蝶。 +- **原单已推送**:原单数据一律不动,只留一条变更记录,等财务冲销后再人工重推。 + +> 注:审批接口会顺带返回自动推送的失败原因(如有),前端直接提示,不用去翻日志。 + +### 3.5 驳回 + +- 审批中 → 已驳回,驳回原因写入备注。 +- 驳回后单据解锁,提交人可修改后重新提交,回到审批中。注意:重新提交前必须先点一次「编辑」并保存(保存会把单据重置回草稿),对已驳回单直接点提交会被拒绝。 + +### 3.6 推送金蝶 / 重新推送(合一按钮,高危) + +列表和详情里只有一个按钮,系统按金蝶状态自动分流: + +| 调整单金蝶状态 | 按钮实际行为 | 前置闸门 | +|---|---|---| +| 未同步 / 生成失败 | 推送其关联的**原结算单**到金蝶 | 无冲销要求 | +| 已同步 | **重新推送(高危)**:确认金蝶旧单已冲销后,克隆一张新正式结算单并推送 | 硬闸门:金蝶旧单必须已冲销,否则直接拒绝并提示先冲销 | + +按钮点击均有高危二次确认弹窗。 + +--- + +## 四、分场景流程 + +### 场景 A:原结算单未推送金蝶(最常见、全自动) + +适用:正式结算单审批通过了,但还没推到金蝶。 + +| 步骤 | 操作人 | 操作 | 系统动作 | +|---|---|---|---| +| 1 | 业务 | 新增调整单,选定结算单,录调整费用,保存草稿 | 回带信息、生成 TZ 单号 | +| 2 | 业务 | 提交 | 草稿 → 审批中 | +| 3 | 审批人 | 审批通过 | ① 调整直接回写原结算单(费用、明细、合计、结算金额全部更新);② 原单追加变更记录;③ 调整单 → 审批通过;④ 事务提交后**自动触发金蝶推送**(走正式结算单既有的推送链路:生成财务应付/应收单 + 单据落锁 + 附件上传) | +| 4 | 系统 | — | 推送成功:调整单回填"已同步 + 金蝶单据号";推送失败:调整单置"生成失败"+ 原因,**审批结果不受影响**,转入场景 D 处理 | + +结束后:原结算单即调整后的金额,金蝶里是一张含调整金额的应付/应收单。 + +> 注意:审批后自动推送受系统开关控制。若环境未开启自动同步,审批仍成功,但需人工在列表点"推送金蝶"完成推送。 + +### 场景 B:原结算单已推送金蝶(需财务在金蝶冲销后重推) + +适用:正式结算单已在金蝶生成应付/应收单。核心约束:**原单一经推送金蝶即冻结,本地数据一律不动**。 + +| 步骤 | 操作人 | 操作 | 系统动作 | +|---|---|---|---| +| 1 | 业务 | 新增调整单(选单时预检会提示"旧单未冲销"或"已冲销"),提交 | 同场景 A 步骤 1-2 | +| 2 | 审批人 | 审批通过 | ① 原结算单业务数据**零改动**,只追加 1 条变更记录("调整单XX审批通过,调整金额XX,待重推金蝶");② 调整单 → 审批通过,金蝶状态保持"已同步"快照("重新推送"按钮因此亮起);③ 前端提示:**"原结算单已推送金蝶,请财务人员在金蝶手工完成旧应付/应收单冲销处理"** | +| 3 | 财务 | **在金蝶系统内手工冲销**旧的应付/应收单 | TMS 不参与,这是金蝶侧的人工操作 | +| 4 | 业务 | 回到 TMS,点"重新推送(高危)"并二次确认 | 系统依次:① 硬闸门——按原结算单号去金蝶查旧单,**必须已冲销**(部分冲销也算已冲销),未冲销直接拒绝;② **克隆一张新的正式结算单**(全新 FJS/SJS 单号),把调整应用上去,直接置为审批通过(新单不再走审批流);③ 用新单号推送金蝶,生成**全新**的财务应付/应收单 + 落锁 + 附件;④ 调整单改挂到新单,回填"已同步 + 金蝶单据号";⑤ 原单标记"已调整",原单在金蝶的旧锁解锁 | +| 5 | 系统 | — | 完成。此时列表里**新旧两张正式结算单并存**,带标识区分(见第六节) | + +失败处理:第 4 步若推送失败,已克隆的新单不回滚,新单置"生成失败";再点"重新推送"会**复用已克隆的新单**从推送步骤继续,不会重复建单(幂等)。调整单已改挂新单后再点重推,会被冲销闸门拦截,无副作用。 + +> 为什么第 2 步不自动推:金蝶侧已有一张旧单,直接再推会重复生成应付/应收单;必须先冲销旧单,且冲销只能由财务在金蝶完成,TMS 无法代办。 + +### 场景 C:审批驳回 → 修改后重新提交 + +1. 审批人在"审批中"单据上点"驳回",填写驳回原因。 +2. 单据回到"已驳回",提交人可编辑(改费用、改金额),重新提交。 +3. 提交后回到"审批中",等同重新走审批。 +4. 若已驳回单不想调了:留置即可(无作废功能),或改对后再次提交。 + +> 已审批通过的单**无法驳回、无法作废、无法撤回**,录入时请谨慎;金额确认错的分析原因后新建调整单二次调整。 + +### 场景 D:金蝶推送失败 + +| 触发 | 现象 | 处理 | +|---|---|---| +| 场景 A 审批后自动推送失败 | 调整单状态"生成失败",审批接口直接返回失败原因,原结算单也有失败标记 | ① 等定时重推任务自动扫描重试;② 或人工在调整单列表点"推送金蝶"立即重试 | +| 场景 B 重推新单失败 | 新正式结算单"生成失败",调整单显示失败状态 | 再点"重新推送",系统复用已克隆的新单从推送步骤续推,不会重复建单 | + +审批结果与推送结果相互独立:推送失败不影响"审批通过",只影响金蝶侧是否生成单据。 + +### 场景 E:边界情况速查 + +| 情况 | 系统行为 | +|---|---| +| 未审批就点重推 | 拒绝,要求先审批通过 | +| 金蝶旧单未冲销就点重推 | 硬拦截,提示先冲销 | +| 金蝶旧单被删除(非冲销)| 查不到旧单 → 同样拦截 | +| 部分冲销 | 视同已冲销,放行重推 | +| 审批时原单刚好已被推送到金蝶 | 按**当下**状态走场景 B,选单时的预检结论不作数 | +| 选单时金蝶超时/不可用 | 预检降级"未校验",不阻断建单 | +| 重推成功前原单旧锁残留 | 重推成功时统一解锁 | +| 应收结算单 | 与应付完全同一套流程,仅接口不同(应收 A302 系 / 应付 A102 系) | + +--- + +## 五、单据标识与字段说明(列表里怎么认单) + +| 单据/字段 | 含义 | +|---|---| +| 正式结算单标"**已调整**" | 该单已被调整单重推过,金额已作废(数据保留),不要再用它做下游业务 | +| 正式结算单标"**调整后单**"并显示原单号 | 这是重推克隆出的新单,金额 = 原单 + 调整 | +| 调整单"金蝶单据号" | 该调整最终落到金蝶哪张应付/应收单上 | +| 调整单"金蝶同步状态" | 见 2.3 | +| 调整金额 / 原结算金额 / 调整后金额 | 调整后金额 = 原结算金额 + 调整金额 | + +--- + +## 六、重要业务口径(须知) + +1. **原单冻结原则**:原结算单一经推送金蝶,TMS 本地对其零改动;调整金额只体现在重推克隆的新单上。 +2. **新单是全新独立单**:原单的下游关联(开票申请、收款认领、对账)**不迁移**,留在原单;新单发票状态、收付款状态均从零开始(未收票、未付款)。应收场景下新单会显示"未开票/未收款",属正常现象。 +3. **新单不走审批流**:克隆的新正式结算单直接是审批通过状态,无需再审批。 +4. **冲销是财务的人工操作**:TMS 不自动冲销金蝶单据,也不会因为调整单驳回/删除而自动恢复冲销——所以建单阶段只做软提示,把硬拦截留在重推。 diff --git a/doc/docs/业务流程/2026-10-18-正式结算单预付整合设计(进度预付+项目预付核销·金额口径与记录流转).md b/doc/docs/业务流程/2026-10-18-正式结算单预付整合设计(进度预付+项目预付核销·金额口径与记录流转).md new file mode 100644 index 00000000..6c3805c7 --- /dev/null +++ b/doc/docs/业务流程/2026-10-18-正式结算单预付整合设计(进度预付+项目预付核销·金额口径与记录流转).md @@ -0,0 +1,284 @@ +# 正式结算单预付整合设计(进度预付 + 项目预付核销) + +> 关联文档:同目录《2026-10-18-预付完整流程梳理(进度预付-项目预付-正式结算单-付款-金蝶回写).md》(改造前现状梳理) +> 设计日期:2026-10-18。范围:正式结算单(FormalSettlement)对两类预付的金额整合与付款记录关联显示。 +> **落地后全链路逻辑见同目录《2026-10-18-预付与正式结算单全链路逻辑梳理(进度预付-项目预付核销·前端到后端各环节).md》。** +> **v2 修订**:纳入评审意见——核销计入口径改为"仅已付款预付可核销"(待业务确认)、新增核销行迁移设计(预结算转正式 / 调整重推)、作废联动删行补全(含结算单作废/金蝶删单回调/预结算作废)、调整单校验与刷新、列表页 N+1 规避、对账与调整完成判定影响面、细节修正。见文末修订记录。 + +## 一、背景与目标 + +两条预付线的"单据流"已经打通(进度预付经预结算单继承、项目预付经核销组件落库),但**金额与记录**没有整合进正式结算单: + +| # | 差距 | 现状锚点 | +|---|---|---| +| 1 | 结算合计区域看不到预付抵扣:原金额、已付金额(含预付/核销)、剩余未付金额 | `formal-settlement-editor.vue` 结算合计区只渲染费用明细合计(L99-180) | +| 2 | 项目预付核销金额不计入结算单 `appliedPaymentAmount / paidAmount / remainingPayableAmount / paymentStatus` | `calculatePaymentSummary` payable 分支只加来源预结算的预付快照(`FormalSettlementServiceImpl` L1881-1890) | +| 3 | 付款记录只显示尾款(`settlement_payment`)申请,进度预付/项目预付的已付款记录不关联显示 | `detail()` 查询付款申请固定 `paymentType=结算`(L339) | +| 4 | 核销行没有"流转"设计:预结算转正式、调整重推时核销行丢失或额度双占 | `cloneFormalSettlement` L641-708 不克隆核销行;编辑器新建固定重置 `[]`(L1410) | +| 5 | 各类作废不清理核销行,详情残留、批量刷新会把作废单核销计入已付 | `FormalSettlementServiceImpl.voidBill` L600-609、`voidByKingdee` L613-637、`PreSettlementServiceImpl.voidBill` L443 均无删行 | + +**零表结构变更**:所需字段均已存在(`FormalSettlementSource` 预付快照、`SettlementProjectAdvanceWriteOff` 核销行、`PreSettlementAdvance` 预付台账),本设计只做计算口径、记录流转与展示整合。 + +## 二、已确认决策 + +| 决策 | 结论 | 状态 | +|---|---|---| +| D1 结算合计"扣除"的呈现 | `settlementAmount` **保持全额**(费用合计,开票匹配上限、金蝶 A102-2 应付单金额、结算调整单基线都不变)。结算合计卡片下增加汇总栏:**结算合计(原金额)/ 已付金额(含预付、核销)/ 剩余未付金额**,编辑态与只读态都显示。 | ✅ 已确认 | +| D2 核销计入口径(修订) | **仅已付款的项目预付可核销**:`listOptions` 选单过滤与 `replaceWriteOffs` 保存校验都增加 `kingdeeStatus='paid'` 条件(当前只要求 `approved`,L157)。核销时点钱已付出,核销金额同时计入 `appliedPaymentAmount` 与 `paidAmount` 属实付口径,不存在"审批未付却推成 paid"的失真,与付款记录行展示的底层实付一致。 | ✅ 已确认(方案②) | +| D3 付款记录关联显示 | 后端 detail 新增统一预付付款记录列表 `advancePayments`;前端在只读"付款信息"表格中**合并成一张表**,以"付款类型"列区分:进度预付 / 项目预付核销 / 结算尾款。 | ✅ 已确认 | +| D4 范围 | 只做正式结算单(FORMAL)侧汇总;金蝶预付抵扣字段(`y_billtype/y_billid/y_settleamt`)维持本期不传;应收(receivable)侧不动。**但 PRE→FORMAL 的核销行搬运在范围内**(见 D5)。 | ✅ 已确认 | +| D5 核销行流转原则 | **同一笔预付的核销在链路上只存在一份**:预结算转正式时来源 PRE 核销行转移为本单 FORMAL 行;调整重推时原单核销行迁移到新单。转移 = 删源行 + 落新行,同一事务。 | ✅ 已确认 | +| D6 作废联动删行 | 付款申请作废删其核销行(既有"作废联动"模式);正式结算单作废(`voidBill`/`voidByKingdee`)与预结算单作废(`voidBill`)补 `deleteBySettlement`,消除详情残留与汇总污染。 | ✅ 已确认 | +| D7 结算调整单兜底 | 调整落库(`SettlementAdjustmentServiceImpl` L384-388)后:①校验**核销合计 ≤ 新结算金额**,超出则拒绝并提示先调减核销;②调用 `refreshPaymentSummary` 重算汇总(含核销)。 | ✅ 已确认 | +| D8 影响面确认点 | ① 对账单 `copyHeader` 复制 `paidAmount`(`TransportReconciliationServiceImpl` L1325)→ 对账单已付口径将含核销,**按可接受设计**;② `isSettlementCompleted`(`SettlementAdjustmentServiceImpl` L468-471)判定 → 全额核销的结算单 `paymentStatus=paid`,将**不能再发起结算调整**,按"全额抵扣即结清"设计。历史数据问题明确不考虑。 | ✅ 已确认 | + +## 三、金额口径 + +前提(D2 修订):核销行的底层付款申请必为 `kingdeeStatus='paid'`。 + +``` +结算金额 settlementAmount = Σ 结算合计费用项 settlementAmount(不变) +已付金额 paidAmount = 尾款已付(直挂+关系表, approved) + + Σ 来源预结算 advancePaidAmount(进度预付,已有) + + Σ 本单核销行 writeOffAmount(项目预付,本次新增;行必对应已付款预付) +已申请付款 appliedPaymentAmount = 尾款申请中(直挂+关系表, reviewing) + + Σ 来源预结算 advanceAppliedAmount(已有) + + Σ 本单核销行 writeOffAmount(本次新增) +剩余可付 remainingPayableAmount = max(settlementAmount − paidAmount, 0)(已有公式,输入变了) +付款状态 paymentStatus = unpaid / partial / paid(已有公式,输入变了) +``` + +> 与进度预付口径的准确表述:进度预付在结算单上是"申请(advanceAppliedAmount)/ 实付(advancePaidAmount)"两条快照分开跟踪;项目预付在核销时点已完成实付(D2),故核销额计入实付。付款记录行的"已付款金额"(底层实付)与汇总栏"已付金额"必然一致,不存在同屏矛盾。 + +## 四、后端改动(tms-api / blade-transport) + +### 4.1 汇总计入核销金额 + +**`FormalSettlementServiceImpl.calculatePaymentSummary(settlement, data)`**(L1864-1901): + +`payable` 分支(L1881-1890)在累加来源预付快照之后追加: + +```java +// 项目预付核销计入已付/已申请(D2:核销行底层预付必已付款,核销额即实付抵扣) +BigDecimal writeOffAmount = data.writeOffAmounts() != null + ? data.writeOffAmounts().getOrDefault(settlement.getId(), BigDecimal.ZERO) + : this.projectAdvanceWriteOffService.sumBySettlement( + TransportBusiConstant.SETTLEMENT_BILL_FORMAL, settlement.getId()); +appliedAmount = appliedAmount.add(this.money(writeOffAmount)); +paidAmount = paidAmount.add(this.money(writeOffAmount)); +``` + +**`PaymentSummaryData` record**(L1926-1929)新增组件: + +```java +@param writeOffAmounts 结算单id → 核销金额合计(仅应付结算单;null 表示由纯计算按需查询) +..., BigDecimal receiptClaimPaid, Map writeOffAmounts +``` + +同步修改两处构造调用: +- 批量版 `refreshPaymentSummaries`(L1544-1627):`hasPayable` 时按 `settledIds` **一次**查询核销行分组求和(4.5 新增方法),按结算单分发,避免 N+1; +- 单笔版包装方法(L1839-1858):传 `null` 走按需查询。 + +> 说明:`saveDraft` 保存核销后由 `refreshSettlementAmount`(内部 L1524 刷新)+ 新增的一次 `refreshPaymentSummary` **连续两次完整刷新是有意为之**——核销校验依赖 `refreshSettlementAmount` 重算后的最终结算金额,故 `replaceWriteOffs` 只能排在其后,二次刷新不能合并省略。后续优化前先读此注释。 + +### 4.2 保存核销后刷新汇总 + +**`FormalSettlementServiceImpl.saveDraft`**(L546-553):`replaceWriteOffs` 保持在 `refreshSettlementAmount`(L546)之后(校验用最终结算金额),其后追加: + +```java +projectAdvanceWriteOffService.replaceWriteOffs(...); // L548-553 原位置不动 +this.refreshPaymentSummary(settlement.getId()); // 新增:核销行计入汇总 +``` + +### 4.3 核销行流转(D5) + +**通用删行方法**(`ISettlementProjectAdvanceWriteOffService` 新增,事务内逻辑删除): + +```java +/** 删除指定结算单上的核销行(配合转移/作废场景),返回被删行数 */ +int deleteBySettlementIds(String settlementType, Collection settlementIds); +/** 按付款申请删除核销行,返回受影响的正式结算单id(settlementType=formal,供汇总刷新) */ +List deleteByPaymentApplicationIds(Collection paymentApplicationIds); +``` + +**预结算转正式(`saveDraft` 完整建单路径)**: +1. 前端:编辑器 `confirmCandidates` 选定来源预结算后,对每个新选 `preSettlementId` 复用**既有** `preSettlement.getDetail(id)` 取 `projectAdvanceWriteOffs` 预填进本单核销组件(去重、金额 Number 化),用户可在弹窗中增删改; +2. 后端 `saveDraft`:在 `replaceWriteOffs`(L548)**之前**执行 `deleteBySettlementIds("pre", request.getSourcePreSettlementIds())`——先释放来源占用,`replaceWriteOffs` 的"不超可核销余额"校验才能通过预填金额;随后 `replaceWriteOffs` 将预填行落为本单 FORMAL 行。同一事务,失败整体回滚。 +3. 轻量标记路径(`PreSettlementServiceImpl.formalSettlement(id)`,L550):不产生正式单实体,PRE 核销行原地保留(预结算实体仍是载体),待后续真正建正式单时按上述规则转移。 + +**调整重推(`cloneFormalSettlement`,L641-708)**:与既有"新单付款口径归零"哲学(L658 注释)冲突时以**额度正确性优先**—— +1. 在来源克隆循环后,克隆原单活跃核销行到新单(`settlementId` 换新、`resetAuditFields`); +2. `deleteBySettlementIds("formal", List.of(sourceSettlementId))` 删原单核销行(原单 `adjustStatus=adjusted` 为历史存档,已付归零以新单为准,文档明示); +3. `refreshPaymentSummaries(List.of(原单id, 新单id))` 一次批量刷新两单(新单头 L662-664 置零的 applied/paid 由重算纠正为核销额)。 + +> 新单由克隆直接 `approved`(L647),无草稿编辑机会,故必须迁移而非"让用户重新核销"。 + +### 4.4 作废联动删行(D6) + +| 触发点 | 改动 | +|---|---| +| `PaymentApplicationServiceImpl.voidBill`(L446-460,L458 `voidPreSettlementAdvance` 后) | `deleteByPaymentApplicationIds(List.of(entity.getId()))` → 返回的 formal ids 非空则 `formalSettlementService.refreshPaymentSummaries(ids)` | +| `FormalSettlementServiceImpl.voidBill`(L600-609) | `deleteBySettlementIds("formal", List.of(id))` | +| `FormalSettlementServiceImpl.voidByKingdee`(L613-637) | 同上 | +| `PreSettlementServiceImpl.voidBill`(L443) | `deleteBySettlementIds("pre", List.of(id))`(预结算作废前置"先作废预付"校验不受影响,核销行不阻断) | + +金蝶反审核自动解锁置 `failed`(未作废)的场景**不删行**:预付申请仍是 `approved`,已核销行继续计入;人工作废后经上表路径回落。 + +存量数据提示:v2 之前保存的核销行可能对应"approved 未 paid"的预付,本次不做数据修正,如需口径清洗另出脚本(`replaceWriteOffs` 校验只约束新保存)。 + +### 4.5 核销服务与 Mapper + +**`ISettlementProjectAdvanceWriteOffService` / `SettlementProjectAdvanceWriteOffServiceImpl`**: +- 4.3 两个删除方法(见上); +- `Map sumBySettlementIds(String settlementType, Collection settlementIds)`——一次 group-by,供批量刷新与列表页(4.7)使用。 + +**`SettlementProjectAdvanceWriteOffMapper.java`(接口)+ `SettlementProjectAdvanceWriteOffMapper.xml`**:新增 `sumWriteOffBySettlementIds`(`is_deleted=0` + `settlement_type` + `settlement_id in (...)`,`GROUP BY settlement_id`),与既有 `sumWriteOffBySettlement`(XML L27-33)口径一致。 + +**选单与校验加 paid 条件(D2)**: +- `listOptions`(ServiceImpl L48-81):查询条件追加 `kingdeeStatus='paid'`; +- `replaceWriteOffs`(L124-193):在 `approvalStatus=approved` 校验(L157-159)后追加 `kingdeeStatus='paid'` 校验,提示文案"项目预付已付款后方可核销"。 + +### 4.6 detail 返回统一预付付款记录 + +**新 VO `SettlementAdvancePaymentVO`**(`blade-transport-api/pojo/vo/`,仿 `ProjectAdvanceWriteOffOptionVO` 风格,含 BladeX 许可头): + +| 字段 | 说明 | +|---|---| +| `recordType` | `progress_advance` 进度预付 / `project_advance` 项目预付核销 | +| `paymentNo` | 进度预付 = `PreSettlementAdvance.advanceNo`(FKD 单号);项目预付 = 核销行 `paymentNo` | +| `appliedAmount` | 进度预付 = `appliedAmount`;项目预付 = 核销行 `writeOffAmount`(**本次核销金额**,非付款申请金额) | +| `paidAmount` | 进度预付 = `paidAmount`;项目预付 = 底层付款申请 `paidAmount`(D2 下 = 核销额,同屏一致) | +| `billStatus` | 进度预付 = 台账 `billStatus`;项目预付 = 付款申请 `approvalStatus` | +| `kingdeeBillNo` | 进度预付 = `kingdeeAdvanceNo`;项目预付 = 核销行 `kingdeeBillNo` | +| `createUserName` / `createTime` | `UserCache.getUserRealName(createUser)`(AGENTS.md 6.9);**createTime 统一取底层付款申请创建时间**:项目行用核销行已回填的 `paymentCreateTime`(`listBySettlement` 现成),进度行按 `advanceNo in (...)` 批量反查 `PaymentApplication.createTime`(一次 in 查询)。列表排序与展示均用该时间 | + +**`FormalSettlementVO`** 新增 `@TableField(exist = false) private List advancePayments;`(L74 `paymentApplications` 旁)。 + +**`FormalSettlementServiceImpl.detail`**(L307-352)在加载 `projectAdvanceWriteOffs`(L323-324)后组装: + +```java +vo.setAdvancePayments(this.listAdvancePayments(sources, vo.getProjectAdvanceWriteOffs())); +``` + +`listAdvancePayments` 私有方法: +1. **进度预付**:`PreSettlementAdvance` 按 `preSettlementId in 来源预结算ids`、`billStatus != 'voided'` 查询(注入 `PreSettlementAdvanceMapper`,与同类 `sourceMapper` 直连模式一致); +2. **项目预付**:遍历核销行,`paymentApplicationMapper.selectBatchIds` 批量取底层付款申请回填已付金额/状态; +3. 合并后按"付款申请创建时间"降序(列表默认排序规范)。 + +尾款记录(`paymentApplications`)查询不动。 + +### 4.7 列表页 N+1 规避(D8/评审#5) + +`selectPage` L209-210 逐行 `toVO`,而 `toVO`(L1827-1837)内部 `calculatePaymentSummary` 已是每行 3-4 次查询,**不能再叠加每行一次核销求和**: + +- 新增重载 `toVO(FormalSettlement entity, Map writeOffAmounts)`,把 map 置入 `PaymentSummaryData`;单参版委托 `toVO(entity, null)`(按需查询,`detail` 等单笔场景不受影响); +- `selectPage`:本页 `payable` 结算单 ids 一次 `sumBySettlementIds` 得 map,`result.convert(item -> this.toVO(item, writeOffMap))`。 + +### 4.8 结算调整单兜底(D7) + +**`SettlementAdjustmentServiceImpl`**(L384-388 直改金额处): +1. 落库前校验:`projectAdvanceWriteOffService.sumBySettlement("formal", formal.getId())` ≤ 新 `settlementAmount`,超出抛"核销合计超过调整后结算金额,请先调减项目预付核销"; +2. 落库后:`formalSettlementService.refreshPaymentSummary(formal.getId())`(注入 `IFormalSettlementService`,当前仅有 `formalMapper`)。 + +### 4.9 后端改动文件清单 + +| 文件 | 改动 | +|---|---| +| `service/impl/FormalSettlementServiceImpl.java` | `calculatePaymentSummary` 计入核销;`PaymentSummaryData` 加组件;`saveDraft` 补刷新 + 转移来源 PRE 行;`cloneFormalSettlement` 克隆/迁移核销行;`voidBill`/`voidByKingdee` 删行;`detail` 补 `advancePayments` + 新私有 `listAdvancePayments`;`toVO` 加 map 重载;`selectPage` 批量供数;注入 `PreSettlementAdvanceMapper` | +| `service/ISettlementProjectAdvanceWriteOffService.java` + `impl` | 新增 `deleteBySettlementIds`、`deleteByPaymentApplicationIds`、`sumBySettlementIds`;`listOptions`/`replaceWriteOffs` 加 `kingdeeStatus='paid'` 条件 | +| `mapper/SettlementProjectAdvanceWriteOffMapper.java` + `.xml` | 新增 `sumWriteOffBySettlementIds`(接口 + XML 成对) | +| `service/impl/PaymentApplicationServiceImpl.java` | `voidBill` 联动删核销行 + 刷新汇总 | +| `service/impl/PreSettlementServiceImpl.java` | `voidBill` 删 PRE 核销行 | +| `service/impl/SettlementAdjustmentServiceImpl.java` | 调整落库前核销合计校验 + 落库后刷新汇总;注入 `IFormalSettlementService` | +| `pojo/vo/SettlementAdvancePaymentVO.java`(新增) | 统一预付付款记录 VO | +| `pojo/vo/FormalSettlementVO.java` | 加 `advancePayments` | + +## 五、前端改动(tms-erp-web) + +### 5.1 结算合计汇总栏 + +**`formal-settlement-editor.vue`**:结算合计 section-card(L99-180)表格下方加汇总条(仅 `form.settlementType === 'payable'` 显示): + +``` +结算合计(原金额):form.settlementAmount +已付金额(含预付、核销):见下 +剩余未付金额:max(结算合计 − 已付, 0) +``` + +- 编辑态:`Σ sources.advancePaidAmount + Σ projectAdvanceWriteOffs.writeOffAmount`(选预结算 `confirmCandidates` 与勾选核销行时实时联动;草稿态无尾款申请,公式完备;D2 下核销行必对应已付预付,计入实付口径成立); +- 只读态:`form.paidAmount`(已含尾款 + 进度预付 + 核销的落库值)。 + +### 5.2 付款记录合并表 + +只读"付款信息"应付表格(L580-612)数据源改为合并计算属性: + +```js +paymentRecords() { + const advance = (this.advancePayments || []).map(r => ({ ...r, typeName: this.recordTypeName(r.recordType) })); + const tail = (this.paymentApplications || []).map(r => ({ ...r, typeName: r.paymentTypeName })); + return [...advance, ...tail]; +} +// recordTypeName: progress_advance→'进度预付',project_advance→'项目预付核销' +``` + +- 列:付款类型(`typeName`,尾款行兼容原 `paymentTypeName`)/ 单据号 / **申请/核销金额**(预付行的"申请金额"实为核销金额,列名合并以免误导)/ 已付款金额 / 单据状态 / 金蝶单据号 / 创建人 / 创建时间(= 付款申请创建时间,见 4.6)。原"付款方式"列预付行无值,显示 `-`; +- 单据状态名映射**提取公共工具**(与 `pre-settlement-editor.vue` L2879 `advanceStatusName` 去重):新建 `src/utils/settlementStatus.js` 导出 `settlementStatusName(value)`(reviewing 审核中、approved 已审批、paid 已付款、returned 已驳回、voided 已作废、draft 草稿),两个编辑器共用,避免状态名不一致; +- `loadFormalDetail`(L1360-1379)回填 `this.advancePayments = data.advancePayments || []`,`readonly` 重置时清空。 + +### 5.3 转正式预填核销行 + +`confirmCandidates`(L1729-1752)选定来源预结算后:对每个新选 `preSettlementId` 调用既有 `preSettlement` detail 接口,取 `projectAdvanceWriteOffs` 预填进本单核销组件(与已有行按 `paymentApplicationId` 去重、金额 Number 化);保存时随 payload 提交,后端按 4.3 转移来源行。 + +### 5.4 前端不动的部分 + +- `project-advance-write-off.vue`:选单过滤、本次核销金额校验链路已完整(L198-288),"已付款"过滤条件由后端 `listOptions` 落实,组件不改; +- 列表页 `formal-settlement.vue`:"申请付款金额(含预付)/已收已付合计(含预付)/剩余可付金额"列直接受益于后端口径修正,不改; +- 尾款申请默认可付金额 `payableOfFormalSettlement` 已扣核销额(`PaymentApplicationServiceImpl` L2115-2122),不改。 + +## 六、边界场景 + +| 场景 | 行为 | +|---|---| +| 草稿保存核销 | 已付/已申请/剩余可付/付款状态立即生效(D2,行必对应已付预付),列表可见 | +| 底层项目预付申请作废 | 联动删核销行 + 批量刷新 → 已付回落(4.4) | +| 底层预付金蝶推送 `failed` / 反审核解锁 | 不删行,核销继续计入(预付申请仍 approved);人工作废后回落 | +| 金蝶付款完成回写(进度预付) | `syncOnePreSettlementAdvance` 刷新来源快照 → `refreshPaymentSummariesForPreSettlement` → 批量版自动计入核销(4.1 供数) | +| 正式结算单作废 / 金蝶删单回调 | 删除本单核销行(4.4),额度释放、详情无残留 | +| 预结算单作废 | 删除 PRE 核销行(4.4) | +| 预结算转正式(尾款结算) | 前端预填 + 后端先删来源 PRE 行再落 FORMAL 行(4.3),额度单份占用 | +| 轻量标记转正式(无在途预付) | PRE 核销行原地保留,待建正式单时转移(4.3) | +| 结算调整重推 | 核销行迁移到新单,原单删行并以新单为准(4.3) | +| 结算调整单调低金额致核销超限 | 落库前拦截,提示先调减核销(4.8) | +| 全额核销后发起调整 | `isSettlementCompleted` 拦截(现状逻辑),业务含义=已结清(D8 待确认) | +| 对账单生成 | 复制的 `paidAmount` 含核销口径(D8 待确认) | +| 核销合计 + 尾款申请超结算金额 | 双向封顶:核销合计 ≤ 结算金额(`replaceWriteOffs` L184-186);尾款默认额已扣核销额;`remainingPayableAmount` min 0 | +| 开票 | `settlementAmount` 全额不变,发票匹配上限(≤结算金额)不受影响(D1) | +| 应收(receivable)结算单 | 走收款认领分支,payable 分支的新增逻辑不触达 | + +## 七、测试要点 + +1. `saveDraft` 带核销行 → `appliedPaymentAmount / paidAmount / remainingPayableAmount / paymentStatus` 正确;编辑重复保存不重复累计(全删重插)。 +2. D2:`approved` 未 `paid` 的项目预付不出现在选单(`listOptions`),手工提交被 `replaceWriteOffs` 拒绝。 +3. 底层预付作废 → 核销行删除、汇总回落、列表金额一致;作废的正式结算单不再出现在批量刷新的已付统计中。 +4. 预结算转正式:预填行保存成功(先删 PRE 行再校验余额的顺序);同一预付在 PRE/FORMAL 侧无双份核销。 +5. 调整重推:新单含核销行且汇总正确(头字段从置零被重算纠正),原单行删除、汇总归零。 +6. 结算调整单:调低金额低于核销合计被拒;合法调整后 `refreshPaymentSummary` 生效。 +7. `detail` 返回 `advancePayments`:进度预付行(排除 voided)+ 项目预付行(回填底层实付),按付款申请创建时间倒序。 +8. 列表页 SQL 次数:本页核销求和恒定 1 次(不随行数增长)。 +9. 应收结算单、无预付结算单回归:汇总与现状一致。 +10. 前端:编辑态汇总栏随选预结算/核销行实时变化;只读态合并付款记录表三类型展示、状态名与预结算页一致;应收侧页面不出现汇总栏。 + +## 八、范围外(明确不做) + +- 预结算(PRE)侧自身收付汇总计入核销(本次仅 FORMAL); +- 金蝶预付抵扣字段 `y_billtype / y_billid / y_settleamt`(文档 TODO,本期不传); +- 应收侧预收/收款认领汇总; +- 存量"approved 未 paid 核销行"的数据修正脚本(如需另出); +- 遗留接口 `/pre-settlement/apply-advance` 等下线评估(见《2026-10-18-预付完整流程梳理》第五节)。 + +## 九、修订记录 + +| 版本 | 日期 | 内容 | +|---|---|---| +| v1 | 2026-10-18 | 初版:汇总计入、作废联动(仅付款申请侧)、detail 预付记录、前后端展示。 | +| v2 | 2026-10-18 | 评审修订:D2 改为"仅已付款可核销"(待确认);新增 D5 核销行流转(预结算转正式预填+转移、调整重推迁移)、D6 作废删行补全(结算单作废/金蝶回调/预结算作废)、D7 调整单校验+刷新、D8 对账与调整完成判定影响面(待确认);列表页 N+1 规避;文件清单补 Mapper 接口;合并表列名"申请/核销金额";状态名公共工具;createTime 统一为付款申请创建时间;点明 saveDraft 双刷新为有意设计。 | diff --git a/doc/docs/业务流程/2026-10-18-预付与正式结算单全链路逻辑梳理(进度预付-项目预付核销·前端到后端各环节).md b/doc/docs/业务流程/2026-10-18-预付与正式结算单全链路逻辑梳理(进度预付-项目预付核销·前端到后端各环节).md new file mode 100644 index 00000000..25cf41f8 --- /dev/null +++ b/doc/docs/业务流程/2026-10-18-预付与正式结算单全链路逻辑梳理(进度预付-项目预付核销·前端到后端各环节).md @@ -0,0 +1,129 @@ +# 改造后全链路逻辑梳理(预付 × 正式结算单) + +> 关联文档:同目录《2026-10-18-预付完整流程梳理(进度预付-项目预付-正式结算单-付款-金蝶回写).md》(改造前现状梳理)、同目录《2026-10-18-正式结算单预付整合设计(进度预付+项目预付核销·金额口径与记录流转).md》(v2 设计与决策) +> 梳理日期:2026-10-18。本文描述**改造落地后**的完整逻辑,前端到后端各环节。 + +## 一、全景 + +``` +【线A 进度预付】付款申请(progress_advance) ─A103-3→ 金蝶付款 ─回写→ 预结算预付汇总 + └→ 尾款结算建正式单:继承预付快照 → 结算合计(全额)+汇总栏(已付含预付) → 审批 ─A102-2→ 财务应付单 + └→ 尾款付款(默认额已扣核销) ─A103-1→ 回写paid → 付款记录合并表 + +【线B 项目预付】付款申请(project_advance) ─A103-3→ 金蝶付款 ─回写→ kingdeeStatus=paid + └→ 正式结算单「核销」(仅paid可选) → 核销计入已付 → 审批 ─A102-2→ 财务应付单(全额) + └→ 尾款付剩余 → 后续与正式结算单流程完全一致 +``` + +**核心口径**(`FormalSettlementServiceImpl.calculatePaymentSummary` payable 分支,全系统唯一): + +``` +结算金额 settlementAmount = Σ 结算合计费用项(全额,不扣预付/核销——开票与金蝶应付基线) +已申请 appliedPaymentAmount = 尾款reviewing(直挂+关系表) + Σ来源预付快照 + Σ核销 +已付 paidAmount = 尾款approved实付 + Σ来源预付快照 + Σ核销 +剩余可付 remainingPayableAmount = max(结算金额 − 已付, 0) +付款状态 paymentStatus = unpaid / partial / paid +``` + +D2 决策:仅**金蝶已付款**(`kingdeeStatus='paid'`)的项目预付可核销,核销额计入已付属实付口径。 + +--- + +## 二、线A:进度预付(挂预结算单) + +### A1 预付与付款(改造前已有) + +- 前端 `pre-settlement.vue`「预付申请」→ `payment-application-form?paymentType=progress_advance` +- 后端 `PaymentApplicationServiceImpl`:审批 → AFTER_COMMIT `KingdeeApprovalSyncListener` → A103-3 直推金蝶 → 提前建档 `PreSettlementAdvance`(advanceNo=FKD 单号) +- 金蝶付款后 `PaymentKingdeeResultTask`(A103-2/A103-4)回写 paid +- 联动:`syncOnePreSettlementAdvance` 刷新预结算预付汇总(`advanceAppliedAmount/advancePaidAmount`)→ 同步刷新引用它的 `FormalSettlementSource` 快照 → `refreshPaymentSummariesForPreSettlement`(正式单已付跟着涨) + +### A2 尾款结算 → 正式结算单表单(前端) + +- `applyInitialSources`(列表跳转带入)/ `confirmCandidates`(弹窗追加):拉每张预结算 detail,合并明细与费用项 → `summaryTotal` → `form.settlementAmount`(**全额费用合计,不扣预付**) +- **汇总栏(新增)**:仅应付显示;编辑态实时 `已付 = Σ来源预结算.advancePaidAmount + Σ核销行`,`剩余 = 原金额 − 已付`;只读态 `已付 = form.paidAmount`(落库值,含尾款) + +### A3 保存 `saveDraft`(后端,同一事务) + +1. 来源合并(同合同同币种校验)→ `FormalSettlementSource` 快照(含预付汇总) +2. `refreshSettlementAmount`:settlementAmount=Σ费用项 → 内部刷一次汇总 +3. **新增**:`deleteBySettlementIds(PRE, 来源预结算ids)`——先转移来源预结算的项目预付核销行(释放额度) +4. `replaceWriteOffs`(服务端复校验:paid、同合同、≤可核销余额、合计≤结算金额)→ **新增** `refreshPaymentSummary`(核销落库后必须再刷一次,第 2 步的刷新覆盖不到核销行) + +### A4 审批 → 同步金蝶 + +- `approve` → `KingdeeApprovalSyncListener` → **A102-2 财务应付单,金额=全额结算金额** → lockBusiData 锁单 + A902 附件 + +### A5 只读详情(付款记录关联显示,本次核心新增) + +- 后端 `detail` 新增 `advancePayments`:进度预付行 = 来源预结算的 `PreSettlementAdvance` 台账(排除 voided;appliedAmount=申请预付、paidAmount=实付;createTime 统一取**付款申请创建时间**,按 advanceNo 反查) +- 前端 `paymentRecords` 合并表:**进度预付 / 项目预付核销 / 结算尾款** 三类型一张表;状态名走公共 `src/utils/settlementStatus.js` + +### A6 尾款付款 + +- 前端 `formal-settlement.vue`「付款申请」→ `referenceAmount` 默认可付 = **结算金额 − 累计尾款申请 − 核销** +- `settlement_payment` → 前置校验已有财务应付单 → A103-1 应付下推 → 回写 paid → `FormalSettlementPayment(final)` + 汇总刷新 → `paymentStatus=paid` + +--- + +## 三、线B:项目预付(挂项目+承运商合同) + +| 环节 | 逻辑 | +|---|---| +| B1 申请 | 付款申请 `project_advance`:选项目+承运商合同、不挂结算单;`validateQuota` 校验项目额度(fundLimit+临时额度)与客户额度(maxCreditLimit) | +| B2 付款 | 审批 → A103-3 直推 → 金蝶付款 → A103-2 回写 **`kingdeeStatus='paid'`**(核销前置条件) | +| B3 核销·选单(编辑器) | 全新单:核销组件「选择预付单」→ `project-advance-write-off-options`(后端过滤:同合同 + `approved` + **`paid`** + 有余额);**预结算转正式入口(新增)**:`confirmCandidates`/`applyInitialSources` 选定来源后自动预填来源预结算已保存的核销行,可增删改 | +| B3 核销·录额(前端) | 手录本次核销金额:>0、≤可核销余额、合计≤结算金额(`project-advance-write-off.vue` 既有校验链) | +| B4 保存 | 同 A3 第 3-4 步:先转移源 PRE 行 → `replaceWriteOffs` 复校验落 FORMAL 行 → 刷新汇总,核销计入已付 | +| B5 审批 | A102-2 按全额结算金额生成财务应付单(金蝶预付抵扣字段 `y_billtype/y_billid/y_settleamt` 本期不传) | +| B6 付款记录 | 合并表显示项目预付核销行:`申请/核销金额`=本次核销额,`已付款金额`=底层付款申请实付(paid 前置下两者相等,同屏一致) | +| B7 尾款 | 默认可付已扣核销 → 已核销部分不会被重复支付;剩余走正常尾款流程 | +| B8 差异 | 后续金额差异按既有规则走**结算调整单**(见横切 5) | + +--- + +## 四、横切环节 + +| # | 环节 | 逻辑 | +|---|---|---| +| 1 | 列表页 | `selectPage` 本页 payable 一次 `sumWriteOffBySettlementIds`(group-by)→ `toVO(map)` 重算 → 列「申请付款金额(含预付)/已收已付合计(含预付)/剩余可付/收付款状态」直接反映核销;SQL 不随行数增长 | +| 2 | 汇总刷新触发点 | 保存、审批、金蝶付款回写、预结算预付快照联动、作废、调整——全部收敛到 `calculatePaymentSummary`,口径唯一 | +| 3 | 作废联动(新增) | 付款申请作废 → 删其核销行 + 刷新受影响正式单汇总(已付回落);正式单作废(`voidBill`)与金蝶删单回调(`voidByKingdee`)→ 删本单核销行(额度释放);预结算作废 → 删 PRE 核销行 | +| 4 | 核销行流转(新增) | 预结算转正式 = 先删源 PRE 行再落 FORMAL 行(同事务,同一预付的核销全链路只存在一份);调整重推 = 核销行克隆到新单 + 删原单行 + `refreshPaymentSummaries(两单)`(原单为调整存档,收付口径以新单为准) | +| 5 | 结算调整单(新增兜底) | 调整落库前校验**核销合计 ≤ 新结算金额**(超出拒绝并提示先调减核销);落库后 `refreshPaymentSummary` 重算 | +| 6 | 对账单 | `copyHeader` 复制 `paidAmount` → 对账单已付口径含核销(已确认接受) | +| 7 | 全额核销=结清 | `paymentStatus=paid` → `isSettlementCompleted` 拦截,不允许再发起结算调整(已确认接受) | +| 8 | 开票 | 始终按**全额 settlementAmount** 匹配(Σ匹配 ≤ 结算金额),预付/核销不影响发票 | +| 9 | 金蝶预付抵扣 | `y_billtype / y_billid / y_settleamt` 本期不传(见《2026-10-18-预付完整流程梳理》TODO) | +| 10 | 历史数据 | 本次不考虑存量"approved 未 paid"核销行的清洗 | + +--- + +## 五、改动文件索引 + +**后端(tms-api)** + +| 文件 | 内容 | +|---|---| +| `FormalSettlementServiceImpl` | 汇总计入核销(单笔/批量/toVO 重载)、saveDraft 转移+刷新、cloneFormalSettlement 迁移、voidBill/voidByKingdee 删行、detail.advancePayments、selectPage 批量供数 | +| `SettlementProjectAdvanceWriteOffServiceImpl` | listOptions/replaceWriteOffs 加 paid 条件;sumBySettlementIds / deleteBySettlementIds / deleteByPaymentApplicationIds | +| `SettlementProjectAdvanceWriteOffMapper.java/.xml` | sumWriteOffBySettlementIds | +| `PaymentApplicationServiceImpl` | voidBill 联动删核销行+刷新 | +| `PreSettlementServiceImpl` | voidBill 删 PRE 核销行 | +| `SettlementAdjustmentServiceImpl` | 调整前核销合计校验 + 调整后刷新汇总 | +| `SettlementAdvancePaymentVO`(新)/ `FormalSettlementVO` | 统一预付付款记录 VO / advancePayments 字段 | + +**前端(tms-erp-web)** + +| 文件 | 内容 | +|---|---| +| `formal-settlement-editor.vue` | 结算合计汇总栏;付款记录合并表;confirmCandidates/applyInitialSources 预填核销行 | +| `src/utils/settlementStatus.js`(新) | 状态名/记录类型名公共映射(预结算编辑器同步使用) | + +--- + +## 六、已知口径与风险 + +1. 尾款默认可付额(`payableOfFormalSettlement`)= 结算金额 − 累计尾款申请 − 核销,**不扣进度预付已付部分**(改造前既有口径,本次未动);进度预付占大头时默认额可能偏大,提交后由审批环节把关。 +2. 正式结算单 `paidAmount` 现含核销,下游消费该字段的报表/对账口径随之变化(对账已确认接受)。 +3. 金蝶侧预付抵扣字段未传,金蝶报表与本系统"已付含核销"存在时间差。 diff --git a/doc/docs/业务流程/2026-10-18-预付完整流程梳理(进度预付-项目预付-正式结算单-付款-金蝶回写).md b/doc/docs/业务流程/2026-10-18-预付完整流程梳理(进度预付-项目预付-正式结算单-付款-金蝶回写).md new file mode 100644 index 00000000..63ac715b --- /dev/null +++ b/doc/docs/业务流程/2026-10-18-预付完整流程梳理(进度预付-项目预付-正式结算单-付款-金蝶回写).md @@ -0,0 +1,160 @@ +# 预付完整流程梳理 + +> 范围:预付(进度预付 / 项目预付)→ 正式结算单 → 付款 → 金蝶回写等后续流程,前端到后端全链路。 +> 依据代码现状梳理(tms-api / tms-erp-web),梳理日期:2026-10-18。 + +## 一、总览 + +预付有**两条线**,最终在金蝶(星瀚 BFI)汇合: + +``` +【进度预付线】 【项目预付线】 +预结算单(审批通过,应付) 付款申请-项目预付(挂项目/合同,不挂结算单) + └─「预付申请」→ 付款申请(progress_advance) └─ 校验项目额度/客户额度 → A103-3 → 金蝶付款 + └─ 审批 → A103-3 直推金蝶付款申请单 └─ 事后在预结算/正式结算单上「项目预付核销」 + └─ 提前建档 PreSettlementAdvance(approved) + └─ 定时回写 A103-2/A103-4 → paid + └─ 预付记录置 paid + 预结算单预付汇总刷新 + └─「尾款结算」→ 正式结算单(继承预付汇总) → 审批 → A102-2 财务应付单 + └─「申请付款」→ 付款申请(settlement_payment) → A103-1 应付下推 + └─ 回写 paid → FormalSettlementPayment + 结算单付款汇总(unpaid→partial→paid) +``` + +### 金蝶接口速查 + +| 接口 | 用途 | 触发方 | +|---|---|---| +| A103-3 | 预付款直接保存金蝶付款申请单(不绑应付单) | 付款申请(progress_advance / project_advance)同步金蝶 | +| A103-1 | 应付下推生成金蝶付款申请单 | 付款申请(settlement_payment)同步金蝶 | +| A103-2 | 付款申请单查询(幂等预检 + 结果回写) | 推送前预检、定时回写任务 | +| A103-4 | 付款处理查询(回填实付款记录) | 定时回写任务 | +| A102-2 | 财务应付单保存 | 正式结算单「同步金蝶」 | +| A102-4 | 财务应付单查询(防重预检 / 反查) | 推送前预检、下推反查付款计划分录 | +| A902 | 附件上传 | 主单推送成功后联带上传 | +| lockBusiData | 金蝶单据锁定 | 推送成功事务提交后 | + +## 二、涉及的主要代码与数据表 + +### 后端(tms-api / blade-transport) + +| 内容 | 位置 | +|---|---| +| 预结算单 | `controller/PreSettlementController.java`、`service/impl/PreSettlementServiceImpl.java` | +| 付款申请 | `controller/PaymentApplicationController.java`、`service/impl/PaymentApplicationServiceImpl.java` | +| 正式结算单 | `controller/FormalSettlementController.java`、`service/impl/FormalSettlementServiceImpl.java` | +| 项目预付核销 | `service/impl/SettlementProjectAdvanceWriteOffServiceImpl.java` | +| 金蝶同步支撑 | `support/KingdeeApBillSyncSupport.java`(A102-2/A102-4)、`service/impl/KingdeeBillLockServiceImpl.java`(锁单)、`KingdeeAttachmentSyncServiceImpl.java`(附件) | +| 审批后自动推送 | `listener/KingdeeApprovalSyncListener.java`(AFTER_COMMIT)+ `event/KingdeeBillApprovedEvent.java` | +| 定时任务 | `task/PaymentKingdeeResultTask.java`(付款结果回写,每 10 分钟)、`task/KingdeeAutoSyncRetryTask.java`(失败重推,每 10 分钟)、`task/KingdeeBillLockRetryTask.java`(补锁) | + +### 前端(tms-erp-web) + +| 页面 | 位置 | +|---|---| +| 预结算单列表/表单 | `src/views/settlement/pre-settlement.vue`、`pre-settlement-form.vue`、`components/pre-settlement-editor.vue` | +| 正式结算单列表/表单 | `src/views/settlement/formal-settlement.vue`、`formal-settlement-form.vue`、`components/formal-settlement-editor.vue` | +| 项目预付核销组件 | `src/views/settlement/components/project-advance-write-off.vue` | +| 付款申请列表/表单 | `src/views/payment/payment-application.vue`、`payment-application-form.vue` | +| API 层 | `src/api/settlement/preSettlement.js`、`src/api/settlement/formalSettlement.js`、`src/api/payment/paymentApplication.js` | + +### 核心数据表(实体) + +| 表/实体 | 作用 | +|---|---| +| `PreSettlement`(预结算单) | 含预付汇总字段 `advanceNo / advanceAppliedAmount / advancePaidAmount`、`formalSettlementNo` | +| `PreSettlementAdvance`(预结算预付记录) | 预付台账:`advanceNo`(=付款申请单号)、`appliedAmount / paidAmount / billStatus(reviewing/approved/paid/voided) / kingdeeAdvanceNo` | +| `PaymentApplication`(付款申请) | `paymentType`:`progress_advance` 进度预付 / `project_advance` 项目预付 / `settlement_payment` 尾款付款;`kingdeeStatus`:`unsynced/synced/paying/paid/closed/failed` | +| `PaymentApplicationSettlement` | 付款申请 ↔ 结算单(正式/预结算)多选关系及分摊金额 | +| `PaymentApplicationRecord` | 付款记录(金蝶 A103-4 回填,`voucherJson` 留档凭证号/银行流水/电子回单) | +| `FormalSettlement`(正式结算单) | 含 `appliedPaymentAmount / paidAmount / remainingPayableAmount / paymentStatus / kingdeeSyncStatus` | +| `FormalSettlementSource` | 正式结算单来源预结算单/明细快照,冗余预付汇总 | +| `FormalSettlementPayment` | 正式结算单付款记录(尾款 `paymentType=final`) | +| `SettlementProjectAdvanceWriteOff` | 项目预付核销记录(`settlementType`:PRE/FORMAL) | + +## 三、阶段详解 + +### 阶段一:前置 —— 预结算单审批通过 + +- 前端:预结算列表新建/编辑 → 保存草稿 → 提交 → 审批通过(`pre-settlement.vue`,API `/blade-transport/pre-settlement/save|submit|approve`)。 +- 后端:`PreSettlementController` → `PreSettlementServiceImpl.saveDraft / submit / approve`。 +- 审批通过(`approved`)后,**应付(payable)**预结算单即可发起预付。 + +### 阶段二:发起预付(进度预付) + +1. **前端入口**:预结算列表勾选 →「预付申请」按钮。前端校验:同一合同、`approved`、`payable`、未转正式结算(`pre-settlement.vue` `handleAdvanceApplication`)。 +2. **跳转付款申请表单** `/payment/payment-application/form?paymentType=progress_advance&preSettlementIds=...`(并非直接建预付单)。 +3. **表单**(`payment-application-form.vue`): + - 进度预付仅可关联预结算单;不展示发票信息与付款记录; + - 可付金额实时调 `/payment-application/reference-amount` = 结算金额 − 累计已申请 − 项目预付核销额。 +4. **后端**(`PaymentApplicationServiceImpl`): + - `saveDraft`:生成 `FKD+日期+序号` 单号;`fillReference` 回填预结算关联;保存 `PaymentApplicationSettlement` 关系表; + - `submit`:`draft/returned → reviewing`(财务审核); + - `approve`:`→ approved`,刷新关联结算单付款汇总,**发布 `KingdeeBillApprovedEvent`**。 + +> 说明:`PreSettlementController` 上的 `/apply-advance`、`/update-advance-paid`、`/void-advance` 为遗留接口,前端已不再调用;`PreSettlementAdvance` 台账现由付款申请流程联动维护(见阶段三/四)。 + +### 阶段三:同步金蝶(A103-3 预付款直推) + +- 审批事务 **AFTER_COMMIT** → `KingdeeApprovalSyncListener.pushPayment`(Nacos 开关 `thirdParty.bfi.autoSync.payment`)→ `paymentApplicationService.syncKingdee`。 +- `doSyncKingdee`:预付款走 **A103-3 直接保存金蝶付款申请单(不绑应付单)**;推送前 A103-2 幂等预检防重复;成功后落 `kingdeeStatus=synced` + `kingdeeBillId` → **lockBusiData 锁单** → A902 附件上传。 +- **提前建档**:`syncPreSettlementAdvance(entity, approved)` —— 按付款申请号在 `PreSettlementAdvance` 生成预付记录(`advanceNo = FKD 单号`),刷新预结算单预付汇总(`advanceNo / advanceAppliedAmount / advancePaidAmount`),并联动刷新关联正式结算单付款汇总(预付信息两边页面即时可见)。 +- **失败兜底**:`markKingdeeSyncFailed` 落 `failed`;`KingdeeAutoSyncRetryTask` 每 10 分钟自动重推;页面「同步金蝶」按钮手动兜底。审批接口通过 `pollSyncFailure()` 将推送失败原因以警告文案带回前端(审批结果不受推送影响)。 + +### 阶段四:金蝶付款 + 结果回写(付款之后) + +定时任务 `PaymentKingdeeResultTask`(每 10 分钟,分布式锁 single-flight)→ `syncKingdeePaymentResult`: + +1. **A103-2 付款申请单查询**:按 `paymentNo`(防重键)分批查询,回填金蝶单号、状态(`synced → paying → paid/closed`)、已付金额(分录 `e_paidamt` 汇总)。 +2. **付款完成(paid)联动**:`syncPreSettlementAdvance(paid, 全额)` 置预付记录为已付。 +3. **A103-4 付款处理查询**:回填付款记录 `PaymentApplicationRecord`——实付金额、付款日期、金蝶付款单号;凭证号/银行流水/电子回单整体存 `voucherJson` 留档追溯。 +4. **异常自愈**:A103-2 连续两轮查不到已推送单据(Redis 计数)→ 判定金蝶侧已反审核删除 → `unlockBill` 置 `failed`、清空金蝶单号/内码、**联动作废未付的预付记录**(已付事实不回退);重推后重新建档。 +5. 前端可手动触发:付款申请页「回写金蝶付款结果」按钮 `/sync-kingdee-result`。 + +### 阶段五:预结算 → 正式结算单 + +- **前端**:预结算列表「尾款结算」→ 校验同项目、`approved` → 跳 `/settlement/formal-settlement/form` 携带预结算数据(`handleFormalSettlement`)。 +- **后端两条路**: + 1. **完整建单**:`FormalSettlementServiceImpl.saveDraft` —— 合并多张预结算单(`FormalSettlementSource`)或应收应付明细;**继承预付汇总**(`advanceAppliedAmount / advancePaidAmount`);支持**项目预付核销** `replaceWriteOffs`(校验:同合同、审批通过、不超可核销余额、合计不超结算单金额);单号 `ZJS...`。 + 2. **轻量标记**:`PreSettlementController /formal-settlement` → `formalSettlement(id)` —— 预结算单回写 `formalSettlementNo`、源明细置 `formal_settled`;**前提:无在途预付申请**(存在 `reviewing/approved` 预付则拦截)。 +- **正式结算单审批**:草稿 → 提交 → `approve` → 事件 → `syncKingdee` → **A102-2 财务应付单**(A102-4 预检/反查防重,一张结算单一张应付单)→ 回写 `kingdeeSyncStatus` → 锁单 + 附件。 +- 列表展示「申请付款金额(含预付)/ 已收已付合计(含预付)」。 + +### 阶段六:尾款付款(正式结算单之后) + +1. **前端**:正式结算单列表勾选(`approved`、`payable`、同合同)→「申请付款」→ 付款申请表单 `paymentType=settlement_payment`,默认取剩余可付金额(`openPaymentDialog`)。 +2. **后端**:`saveDraft` 支持一单挂多张正式结算单(同合同,按可付金额比例分摊);`submit` 时 `validateNoTailPayment` 防重复尾款申请。 +3. **同步金蝶路由不同**:结算付款走 **A103-1 应付下推**——前置校验每张正式结算单已在金蝶生成财务应付单(A102-4 预检,未生成则提示"请先在正式付款单页面同步金蝶"),再下推生成金蝶付款申请单;之后锁单、附件同前。 +4. **回写后**:paid 时 `syncFormalSettlementPayments` 按关系表分摊生成 `FormalSettlementPayment(paymentType=final)` → `refreshPaymentSummary` 重算结算单 `appliedPaymentAmount / paidAmount / remainingPayableAmount / paymentStatus`(unpaid → partial → paid)。金额后续差异走**结算调整单**。 + +### 项目预付线(project_advance) + +- 付款申请页「预付申请」按钮 → `project_advance`:选**项目 + 承运商合同**(仅承运商合同),不挂结算单、无发票/付款记录。 +- `validateQuota` 校验:项目额度(`fundLimit` + 有效期内审批通过的临时额度)、客户额度(`maxCreditLimit`)。 +- 审批 → A103-3 直推 → 金蝶付款 → 回写 paid。 +- **后续核销**:预结算单/正式结算单表单中「项目预付核销」组件 → 保存时 `replaceWriteOffs` 写 `SettlementProjectAdvanceWriteOff`;可付金额计算统一扣减核销额(`referenceAmount`)。 +- 金蝶侧预付抵扣字段(`y_billtype / y_billid / y_settleamt`)**TODO 本期不传**(`KingdeeApBillSyncSupport`,待业务确认后迭代)。 + +## 四、逆向流程 + +| 操作 | 规则 | +|---|---| +| 付款申请作废(仅 approved) | 金蝶锁单中禁止作废(`checkEditable`);汇票付款释放票据余额;**联动作废未付的预付记录**(已付事实不回退);刷新结算单汇总 | +| 预结算单作废 | 存在关联预付申请需先作废预付 | +| 预付记录作废 | 已付款的不能作废 | +| 金蝶反审核(解锁) | A103-2 连续两轮消失自动解锁置 `failed`,人工核实后重推;解锁时原金蝶单号/内码留档清空,重推生成新单后重新关联附件与预付记录 | + +## 五、遗留接口说明 + +`PreSettlementController` 的以下接口前端已不再调用(前端统一走付款申请单流程),`PreSettlementAdvance` 表改由付款申请联动维护,保留兼容: + +- `POST /pre-settlement/apply-advance`(发起预付申请) +- `POST /pre-settlement/update-advance-paid`(回写预付付款金额) +- `POST /pre-settlement/void-advance`(作废预付申请) + +如需下线,需另行评估权限与存量数据兼容性。 + +## 六、风险与未覆盖范围 + +- 应收侧预收/收款认领(ReceiptClaim)流程未在本篇展开。 +- 汇票付款(BillPayment)仅提及票据余额占用/释放联动。 +- 金蝶 A102/A103 接口报文字段细节未逐项列出,参见 `KingdeeApBillSyncSupport`、`PaymentApplicationServiceImpl.pushBfiPayApply / pushBfiPayApplyPush` 源码注释。 diff --git a/doc/docs/业务流程/2026-10-18-预付转正式结算单与项目预付操作手册(纯操作·带状态).md b/doc/docs/业务流程/2026-10-18-预付转正式结算单与项目预付操作手册(纯操作·带状态).md new file mode 100644 index 00000000..2dbb4807 --- /dev/null +++ b/doc/docs/业务流程/2026-10-18-预付转正式结算单与项目预付操作手册(纯操作·带状态).md @@ -0,0 +1,279 @@ +# 预付转正式结算单与项目预付操作手册(纯操作·带状态) + +> 整理日期:2026-10-18 +> 定位:**给操作者和实施/培训用的纯操作手册**。只讲"在哪个页面、点哪个按钮、填什么、会看到什么状态",不含接口、表名、代码。 +> 配套逻辑说明见同目录《2026-10-18-预付转正式结算单与项目预付操作流程说明(分场景·前端后端).md》。 + +--- + +## 一、先认状态(每个场景都对着这几张表看) + +### 1.1 单据审批状态(所有单据通用) + +``` +新建/保存 ──→ 草稿 ──提交──→ 审批中 ──通过──→ 审批通过 + ↑ │ + │ 驳回 + └── 已驳回 ←────┘ (改完再提交) + +草稿 / 审批中 / 审批通过 ──(可作废时)──→ 已作废(终态) +``` + +| 状态 | 能做什么 | +|---|---| +| 草稿 | 编辑 / 删除 / 提交 | +| 已驳回 | 编辑(保存后回到草稿)/ 提交 | +| 审批中 | 通过 / 驳回(审批人操作) | +| 审批通过 | 看后续:同步金蝶、付款申请、开票申请、作废(未锁定时) | +| 已作废 | 终态,只读 | + +### 1.2 付款申请的金蝶状态 + +``` +未同步 ──推送──→ 已同步 ──→ 付款中 ──→ 已付款 + │ │ + └────────── 生成失败(可重推) └──→ 已关闭 +``` + +- **已付款**:钱已付出。**项目预付只有到"已付款"才能在结算单上核销**。 +- **生成失败**:系统每 10 分钟自动重推;也可在列表勾选后点「批量同步」手动重推。 + +### 1.3 正式结算单上的四个状态 + +| 状态位 | 取值 | 看什么 | +|---|---|---| +| 审批状态 | 草稿 / 审批中 / 审批通过 / 已驳回 / 已作废 | 单据走到哪一步 | +| 金蝶同步状态 | 未同步 / 已同步 / 同步失败 | 有没有在金蝶生成应付单(应付)/应收单(应收) | +| 收付款状态 | 未收/付款 / 部分收/付款 / 已收/付款 | 收付进度(**已含预付、核销**) | +| 发票状态 | 应付=未收票/部分收票/已收票;应收=未开票/部分开票/已开票 | 票开/收到哪一步 | + +### 1.4 金蝶锁定状态 + +| 显示 | 含义 | 影响 | +|---|---|---| +| 锁定中 | 正在向金蝶加锁 | 稍等 | +| 已锁定 | 金蝶已锁 | **不可编辑、不可作废** | +| 锁定失败·补偿中 / 已达上限 | 加锁未成功 | 定时自动补偿;必要时联系管理员 | + +### 1.5 开票申请的金蝶状态 + +``` +未同步 ──推送──→ 已同步 ──金蝶回写──→ 开票完成 + │ + └── 生成失败(可重推) +``` + +--- + +## 二、应付侧操作(钱付出去) + +### 场景 1:预结算单直接发起「进度预付」 + +**前提**:预结算单是**应付**、状态**审批通过**、还没转正式结算。 + +1. 进「**预结算单**」列表,勾选预结算单(**同一合同**可多选)。 +2. 点工具栏「**预付申请**」。 +3. 自动跳到「付款申请」页面(类型已固定为进度预付):结算单只读,"结算金额/可付款金额"自动带出。 +4. 填「申请付款金额」(不超过可付款金额)、「付款方式」→「保存」。 +5. 回到「付款申请」列表 → 找到该单 →「**提交**」。 +6. 审批人点「**通过**」。 + +**状态变化**:付款申请 `草稿 → 审批中 → 审批通过`;随后系统自动推金蝶,金蝶状态 `未同步 → 已同步 → 已付款`。 +**同时发生**:预结算单上出现「申请预付金额 / 已预付金额」;预结算单仍然可以继续「尾款结算」。 + +> 若提交/通过后看到黄色警告写着金蝶推送失败:审批结果**不受影响**,稍后自动重推,或到付款申请列表点「批量同步」。 + +--- + +### 场景 2:发起「项目预付」(不挂结算单) + +**前提**:项目已建、**承运商合同**已建;金额不超过项目额度与客户额度。 + +1. 进「**付款申请**」列表 → 点工具栏「**预付申请**」(注意:这个入口是从付款申请页进的,和场景 1 从预结算单进不同)。 +2. 页面类型已固定为项目预付,填写: + - **所属项目**(必选) + - **合同名称**(必选,**只能选承运商合同**,选了项目后才能选) + - 申请付款金额、付款方式、付款方/收款方等 + - 本类型不挂结算单,不显示发票信息与付款记录 +3. 「保存」→ 列表「提交」→ 审批人「通过」。 + +**会被拦截的情况**: +- "申请付款金额不能超过项目额度"(项目额度 = 项目额度 + 有效期内的临时额度) +- "申请付款金额不能超过客户额度" + +**状态变化**:同场景 1。**务必等到金蝶状态「已付款」**,否则下一步核销做不了。 + +--- + +### 场景 3:结算单上「项目预付核销」 + +**前提**:有一笔**已付款**的项目预付(金蝶状态=已付款),且与本次结算单**同一合同**,还有剩余可核销金额。 + +1. 打开结算单(**预结算单**或**正式结算单**都行)的编辑页。 +2. 找到「**项目预付核销**」分区 → 点「**选择**」。 +3. 弹窗「选择项目预付单」里勾选要抵扣的预付单(已选的会灰掉,不能重复选)→「确定」。 +4. 在「本次核销金额」列:默认已按"可核销金额"填满,可以改小。 +5. 「保存」。 + +**会被拦截的情况(提示文案)**: +- "预付单 XXX 本次核销金额必须大于 0" +- "本次核销金额不能超过可核销金额" +- "项目预付核销合计不能超过结算单金额" +- "仅可选择审核通过的项目预付单" +- "预付单 XXX 尚未付款,项目预付须金蝶付款完成后方可核销" +- "同一项目预付单不可重复核销" +- "项目预付单与结算单合同不一致" + +**状态变化**:结算单「**已付金额(含预付、核销)**」立刻增大,「剩余未付金额」减小;列表上"申请付款金额(含预付)/已收已付合计(含预付)/剩余可付/收付款状态"同步变化。 + +--- + +### 场景 4:预结算单转「正式结算单」(容纳预付) + +**前提**:预结算单**审批通过**,且**同一项目**。 + +1. 进「**预结算单**」列表,勾选(可多选,须同项目)→ 点工具栏「**尾款结算**」。 +2. 系统跳到「正式结算单」新增页,把所选预结算单的明细、费用项合并进来。 +3. 检查三处金额(**应付结算单才有**): + - **结算合计(原金额)**:费用合计,**始终全额**,不会因为预付/核销而变小; + - **已付金额(含预付、核销)**:把来源预结算的已付预付 + 本次核销都算进去; + - **剩余未付金额** = 结算合计 − 已付。 +4. 「项目预付核销」区会**自动带出来源预结算上已核销的行**,可增/删/改金额。 +5. 「保存」(草稿)→「提交」→ 审批人「通过」。 + +**状态变化**:正式结算单 `草稿 → 审批中 → 审批通过`;预结算单会被标记为已转正式结算(来源被锁定)。 +**注意**:如果预结算单上还有**审批中或已审批但没结束的预付申请**,走"轻量转正式"会被拦住,需要先把预付处理完。 + +--- + +### 场景 5:正式结算单「同步金蝶」 + +**前提**:结算单**审批通过**。 + +1. 进「**正式结算单**」列表,切到「**应付**」页签。 +2. 勾选 **1 条**(按钮只允许选一条)→ 点工具栏「**同步金蝶**」。 +3. 成功后会提示"同步成功,金蝶单据号:XXX";列表「金蝶同步状态」变为**已同步**,「金蝶锁定状态」变为**已锁定/锁定中**。 + +**生成失败怎么办**:列表金蝶状态显示"同步失败",重新点「同步金蝶」即可。 +**提示"该单附件未全部上传,可重推补传"**:单据已同步成功,附件稍后重推会自动补传。 + +> 审批通过时系统也会自动推一次;若失败,只弹黄色警告,审批照样通过,按上面手动补推。 + +--- + +### 场景 6:正式结算单「付款申请」(付尾款) + +**前提**:正式结算单是**应付**、**审批通过**、**同一合同**、还有剩余可付金额。 + +1. 「正式结算单」列表(应付)→ 勾选 1 条或多条(同合同)→ 工具栏「**付款申请**」。 +2. 弹窗里确认各单的「申请付款金额」(默认已填=剩余可申请金额,**已扣掉核销**)→「提交」。 +3. 到「付款申请」列表「提交」→ 审批人「通过」。 + +**会被拦截的情况**: +- "仅审批通过的应付正式结算单允许发起付款申请" +- "付款申请必须选择同一个合同的正式结算单" +- 提示需要先在正式结算单页面同步金蝶(说明该单在金蝶还没有应付单,尾款是从应付单下推的,**必须先做场景 5**) + +**状态变化**:正式结算单「收付款状态」`未付款 → 部分付款 → 已付款`,金额按付款结果自动重算。 +**金额错了怎么办**:正式结算单**审批通过后不能直接改金额**,走「**结算调整单**」(见《2026-10-17-结算调整单操作流程说明(分场景).md》)。 + +--- + +## 三、应收侧操作(票开出去) + +### 场景 7:应收正式结算单「同步金蝶」 + +1. 「正式结算单」列表切到「**应收**」页签 → 勾选 1 条 → 工具栏「**同步金蝶**」。 +2. 列表「金蝶同步状态」变**已同步**,可见金蝶单据号。 + +**必须做这一步**:正式结算单开票要求先有金蝶应收单,没做这步,开票申请推不出去。 + +--- + +### 场景 8:创建并推送「开票申请」 + +**前提**:有一张**应收**正式结算单,**审批通过**、未作废、发票状态还是「**未开票**」,且已完成场景 7。 + +1. 进「**开票申请**」列表 → 点「**新增**」。 +2. 「开票基本信息」里点「结算单」右侧的「**选择**」→ 弹窗「选择应收正式结算单」→ 搜索并勾选 →「确定」。 + - 「项目」「开票单位」自动带出;「**可开票金额**」= 所选结算单剩余可开票额之和。 + - 受票方开票信息按客商档案自动带出。 +3. 填「发票类型」等;选**电子专票**时,纳税人识别号、开户行、开户账号、注册地址**必填**。 +4. 填「开票内容」(发票张次 + 商品行),系统自动算「**本次开票金额**」。 +5. 在「结算信息」表里给每张结算单填「**分摊开票金额**」(不超过该单剩余可开票金额)。 +6. 「开票明细」里勾选明细 →「保存」→「提交」→ 审批人「通过」。 + +**会被拦截的情况**: +- "只能选择审批通过、未作废、未收票的应收正式结算单" +- "结算单 XXX 的分摊金额超过剩余可开票金额" +- "本次开票金额必须大于 0" +- "请选择受票方有效的开票信息" +- "未找到正式结算单受票方对应的客商档案" + +**状态变化** +| 对象 | 状态变化 | +|---|---| +| 开票申请 | 审批 `草稿 → 审批中 → 审批通过`;金蝶 `未同步 → 已同步 → 开票完成` | +| 来源正式结算单 | 发票状态 `未开票 → 部分开票 → 已开票`(按分摊与回写自动算) | + +**看发票结果**:开票申请详情页的发票信息里能看到发票号、开票日期、价税合计、发票文件。金蝶回写是定时的,没出来可以先等,或在开票申请列表点「**批量同步**」推一把。 + +**限制**:开票申请一旦推到金蝶(已同步/开票完成)就**锁定不能改**;数电票已经开出来的只能走红冲,不能在本系统作废。**预付和核销不影响开票金额**,开票始终按结算单的**全额**来匹配。 + +--- + +## 四、作废与撤销:能做什么、状态怎么变 + +| 我想…… | 前提 | 操作位置 | 状态变化 | +|---|---|---|---| +| 作废付款申请 | 状态=审批通过,且**未被金蝶锁定** | 付款申请列表「作废」 | 单据→已作废;**未付的预付记录联动作废,相关核销行被删除**,受影响结算单的已付金额回落(已付出去的钱不会回退) | +| 作废正式结算单 | 未审批通过,或审批通过但**未锁定** | 正式结算单列表「作废」 | 单据→已作废;本单的核销行被删除,可核销额度释放 | +| 作废预结算单 | 无关联预付申请(有则先作废预付) | 预结算单列表「作废」 | 单据→已作废;预结算上的核销行被删除 | +| 作废开票申请 | 状态=审批通过,且**未锁定**(即还没推金蝶/还没开票) | 开票申请列表「作废」 | 单据→已作废 | +| 删除 | 状态=草稿 | 各列表「删除」 | 草稿被删除 | +| 金蝶侧反审核删单 | — | 金蝶操作后由系统处理 | 付款申请金蝶状态→生成失败,金蝶单号被清空;**未付的预付记录联动作废**;人工核实后重推,重推成功会重新建档;**已核销的行不会自动删**,需要人工作废该预付款才回落 | + +> 一句话记住:**预付一旦真付出去(已付款),作废不会把钱退回**,只会把台账和核销额度清掉。 + +--- + +## 五、操作者常见疑问 + +| 问题 | 答案 | +|---|---| +| 为什么选不到某张预结算单发起预付? | 需要**应付**、**审批通过**、**未转正式结算**、且所选单据**同一合同**。 | +| 为什么核销弹窗里找不到那笔项目预付? | 它必须已**金蝶付款完成(已付款)**,且与结算单**同一合同**、还有剩余可核销额。 | +| 为什么新增开票申请时选不到某张应收结算单? | 需要**审批通过、未作废、发票状态=未开票**;已被别的开票申请占用的会扣掉可开票额。 | +| 开票提示要先去同步金蝶? | 正式结算开票前,必须先在该应收结算单上点「同步金蝶」生成金蝶应收单。 | +| 付款申请审批通过了,但金蝶那边没有单? | 看金蝶状态:若"生成失败",点「批量同步」重推或等自动重推。 | +| 结算单已付金额比我算的多/少? | 已付金额**含**进度预付、项目预付核销;核销金额同时计入"已付"和"剩余可付"的计算,属正常口径。 | +| 结算单金额写错了怎么改? | 审批通过后不能直接改,走「结算调整单」。 | +| 单据上的「作废」是灰的? | 可能已被金蝶锁定,或状态不满足(只有审批通过且未锁定才能作废)。 | + +--- + +## 六、一眼看懂整个流程(操作版) + +``` +【应付·进度预付】 +预结算单(审批通过) →「预付申请」→ 付款申请(提交→通过) → 金蝶付款(已付款) + └ 预结算单出现"已预付金额" + +【应付·项目预付】 +付款申请页「预付申请」→ 选项目+承运商合同 → 提交→通过 → 金蝶付款(已付款) + +【预结算 → 正式结算(容纳预付)】 +预结算单(审批通过·同项目) →「尾款结算」→ 正式结算单 + 结算合计(全额) / 已付(含预付、核销) / 剩余未付 + 项目预付核销自动带入 → 保存 → 提交 → 通过 + └ 「同步金蝶」(应付单已同步) + +【应付·尾款】 +正式结算单(应付·审批通过) →「付款申请」→ 付款申请 → 金蝶应付下推 → 已付款 + └ 结算单收付款状态:未付款 → 部分付款 → 已付款 + +【应收·开票】 +应收正式结算单(审批通过·未开票) →「同步金蝶」(应收单已同步) + →「开票申请」新增 → 选结算单 → 填开票内容与分摊 → 提交 → 通过 + → 金蝶开票(已同步 → 开票完成) → 结算单发票状态:未开票 → 部分开票 → 已开票 +``` diff --git a/doc/docs/业务流程/2026-10-18-预付转正式结算单与项目预付操作流程说明(分场景·前端后端).md b/doc/docs/业务流程/2026-10-18-预付转正式结算单与项目预付操作流程说明(分场景·前端后端).md new file mode 100644 index 00000000..a61724d0 --- /dev/null +++ b/doc/docs/业务流程/2026-10-18-预付转正式结算单与项目预付操作流程说明(分场景·前端后端).md @@ -0,0 +1,363 @@ +# 预付转正式结算单与项目预付操作流程说明(分场景·前端后端) + +> 整理日期:2026-10-18 +> 定位:面向操作者与实施/联调人员的**分场景操作手册 + 逻辑说明**。讲清「谁在哪个页面点什么 → 后端发生什么 → 数据表/金蝶变成什么 → 怎么校验成功」。 +> 依据代码现状梳理(`tms-api` / `tms-erp-web`)。关联文档(同目录): +> - 《2026-10-18-预付完整流程梳理(进度预付-项目预付-正式结算单-付款-金蝶回写).md》——改造前现状 +> - 《2026-10-18-正式结算单预付整合设计(进度预付+项目预付核销·金额口径与记录流转).md》——v2 设计与决策 +> - 《2026-10-18-预付与正式结算单全链路逻辑梳理(进度预付-项目预付核销·前端到后端各环节).md》——改造后全链路 +> - 《2026-09-21-应收结算与开票申请同步金蝶全链路业务流程(A302-2保存-A302-4勾稽-A303-1新增-A303-4预检-A303-5回写).md》——开票链 +> - 《2026-09-29-预结算开票(预开票)金蝶推送方案.md》——预开票(**方案,尚未落地**) + +--- + +## 〇、先看全局:两条预付线 + 一条开票线 + +### 0.1 术语 + +| 术语 | 含义 | +|---|---| +| 预结算单(PreSettlement) | 结算前的费用单据,审批通过后作为预付/尾款结算的依据。有 **应付(payable)/应收(receivable)** 两种。 | +| 进度预付(progress_advance) | 挂在**预结算单**上的预付,属**应付**侧(我方付出去)。 | +| 项目预付(project_advance) | 挂在**项目 + 承运商合同**上的预付,不挂结算单,属**应付**侧。 | +| 核销 | 项目预付金在后续结算单上"抵扣",落 `SettlementProjectAdvanceWriteOff` 行。**仅金蝶已付款(paid)的项目预付可核销**。 | +| 容纳 / 纳入 | 预结算单转正式结算单时,其**预付台账快照**(进度预付)与**核销行**(项目预付)被正式结算单继承,计入正式单的"已付/已申请/剩余可付"。 | +| 尾款 | 正式结算单扣除已付(含预付、核销)后的剩余部分,走单独付款申请(settlement_payment)。 | +| 开票 | **应收**侧动作:正式结算单 → 开票申请 → 金蝶开票单。**应付**侧对应的是**收票**,两者共用 `formal_settlement.invoice_status` 字段,名称按结算方向区分。 | + +### 0.2 全景图 + +``` +【应付线 · 预付】 【应收线 · 开票】 + +预结算单(应付) approved 预结算单(应收) approved + │ │ + ├─ 预付申请(进度预付 progress_advance) └─ 尾款结算 + │ └ 审批 → A103-3 直推金蝶付款 → 回写 paid └→ 应收正式结算单 + │ └ 提前建档 PreSettlementAdvance ├ 审批 → 同步金蝶 A302-2 财务应收单 + │ └ 开票申请(选应收正式结算单) +项目预付(project_advance) ← 付款申请页「预付申请」 └ 审批 → A303-1 开票申请单 + ├ 选 项目 + 承运商合同(校验项目额度/客户额度) └ A303-5 回写发票 → 开票完成 + ├ 审批 → A103-3 → 金蝶付款 → 回写 paid + └ 后续在结算单上「项目预付核销」 + + ▼ 双线在「正式结算单」汇合 + 预结算单(应付) ──「尾款结算」──→ 应付正式结算单(容纳预付快照 + 核销行) + ├ 审批 → 同步金蝶 A102-2 财务应付单(全额 settlementAmount) + └「付款申请」→ 尾款付款 → A103-1 应付下推 +``` + +### 0.3 金蝶接口速查 + +| 接口 | 用途 | 触发方 | +|---|---|---| +| A103-3 | 预付款直接保存金蝶付款申请单(不绑应付单) | 付款申请(progress_advance / project_advance)同步金蝶 | +| A103-1 | 应付下推生成金蝶付款申请单 | 付款申请(settlement_payment)同步金蝶 | +| A103-2 / A103-4 | 付款申请单查询 / 付款处理查询 | 推送前幂等预检、定时回写(每 10 分钟) | +| A102-2 / A102-4 | 财务应付单保存 / 查询(防重预检) | 应付正式结算单「同步金蝶」 | +| A302-2 / A302-4 | 财务应收单保存 / 查询(勾稽) | 应收正式结算单「同步金蝶」 | +| A303-1 / A303-4 / A303-5 | 开票申请单保存 / 幂等预检 / 开票结果查询 | 开票申请「同步金蝶」与定时回写 | +| lockBusiData | 金蝶单据锁定 | 推送成功事务提交后 | +| A902 | 附件上传 | 主单推送成功后联带 | + +--- + +## 一、应付侧:预付转正式结算单(被"容纳") + +### 场景 A1:发起进度预付(挂预结算单) + +**前提**:预结算单 `approvalStatus=approved`、`settlementType=payable`、未转正式结算(`formalSettlementNo` 为空)。 + +**前端操作** +1. 菜单进「预结算单」列表 → 勾选 1 条或多条预结算单(**必须同一合同**)。 +2. 点工具栏「**预付申请**」。 + - 校验不通过时的提示:缺合同 ID / 多合同 / 非 payable / 非 approved / 已转正式。 +3. 跳转「付款申请」表单,页面类型已锁定为**进度预付**:结算单只读、结算金额与可付款金额自动带出。 +4. 填「申请付款金额」「付款方式」→「保存」→ 详情页「提交」→ 列表「通过」。 + +**后端动作** + +| 步骤 | 接口/方法 | 结果 | +|---|---|---| +| 可付金额 | `GET /payment-application/reference-amount` | `可付 = 预结算金额 − 累计已申请 − 项目预付核销额` | +| 保存 | `POST /payment-application/save` → `saveDraft` | 生成 `FKD+日期+序号` 单号;写 `PaymentApplicationSettlement` 关系表 | +| 提交 | `POST /payment-application/submit` | `draft/returned → reviewing` | +| 审批 | `POST /payment-application/approve` | `→ approved`,刷新预结算付款汇总,发布 `KingdeeBillApprovedEvent` | +| 自动推送 | `KingdeeApprovalSyncListener`(AFTER_COMMIT,开关 `thirdParty.bfi.autoSync.payment`) | A103-3 直推金蝶付款申请单;**提前建档** `PreSettlementAdvance`(`advanceNo = FKD 单号`);刷新预结算预付汇总并联动正式结算单 | + +> 说明:`PreSettlementController` 的 `/apply-advance`、`/update-advance-paid`、`/void-advance` 为**遗留接口**,前端已不再调用,台账改由付款申请流程联动维护。 + +**结果校验**:预结算单列表「申请预付金额 / 已预付金额」变化;付款申请 `kingdeeStatus` 从 `unsynced → synced`。 + +### 场景 A2:发起项目预付(挂项目 + 承运商合同) + +**前提**:项目已建、承运商合同已建;不超过项目额度与客户额度。 + +**前端操作** +1. 菜单进「付款申请」列表 → 点工具栏「**预付申请**」(注意:与 A1 的入口不同,本入口从付款申请页进)。 +2. 表单页类型为**项目预付**,填写: + - **所属项目**(必选,下拉) + - **合同名称**(必选,**仅承运商合同**,选项目后才可选) + - 申请付款金额、付款方式、付款方/收款方等 + - 不挂结算单,不展示发票信息与付款记录 +3. 「保存」→「提交」→「通过」。 + +**后端动作** + +| 步骤 | 接口/方法 | 结果 | +|---|---|---| +| 保存 | `saveDraft` → `validateQuota` | 项目额度、客户额度双重校验(见下) | +| 提交/审批 | `submit` / `approve` | 同上,审批后发事件推 A103-3 | +| 推送 | A103-3 | 金蝶生成付款申请单,`kingdeeStatus=synced` | +| 回写 | `PaymentKingdeeResultTask`(A103-2/A103-4,每 10 分钟) | `synced → paying → paid`;回填实付金额与付款记录 | + +**额度校验口径**(`PaymentApplicationServiceImpl.validateQuota`,悲观锁 `FOR UPDATE`) + +``` +项目额度 = (项目 fundLimit + Σ有效期内 approved 临时额度) × 10000 +客户额度 = 客商 maxCreditLimit × 10000 +申请付款金额 > 额度 → 分别报"申请付款金额不能超过项目额度 / 客户额度" +``` + +**结果校验**:付款申请列表 `kingdeeStatus=已付款(paid)`,金蝶单据号已回填。**只有 paid 才能进入后续核销**。 + +### 场景 A3:项目预付核销(在结算单上选单 + 录额) + +**前提**:存在 `approvalStatus=approved` 且 `kingdeeStatus=paid` 的项目预付,与结算单**同一合同**,且有剩余可核销余额。 + +**前端操作** +1. 打开**预结算单编辑器**(或后续的**正式结算单编辑器**)→「**项目预付核销**」分区 → 点「**选择**」。 +2. 弹窗「选择项目预付单」列出可核销单(同合同 + 已审批 + **已付款** + 有余额),已选行不可重复勾选。 +3. 勾选「确定」→ 默认按"可核销金额"预填「本次核销金额」,可手工改小。 +4. 「保存」(或保存草稿)。 + +**校验链(前端 → 后端一致)** + +| 层级 | 规则 | 提示 | +|---|---|---| +| 前端 | 金额 > 0 | 预付单 X 本次核销金额必须大于 0 | +| 前端 | 金额 ≤ 可核销金额 | 本次核销金额不能超过可核销金额 | +| 前端 | 核销合计 ≤ 结算单金额 | 项目预付核销合计不能超过结算单金额 | +| 后端 | 合同一致 | 项目预付单与结算单合同不一致 | +| 后端 | 仅项目预付 | 仅可核销项目预付单 | +| 后端 | approved | 仅可选择审核通过的项目预付单 | +| 后端 | **paid**(本次新增) | 预付单 X 尚未付款,项目预付须金蝶付款完成后方可核销 | +| 后端 | 同一预付单不重复 | 同一项目预付单不可重复核销 | +| 后端 | 有余额 / 合计 ≤ 结算金额 | 超出可核销金额 / 核销合计不能超过结算单金额 | + +**结果校验**:正式/预结算单「已付金额」立即包含核销额(核销行必对应已付款预付,计入实付口径),列表「申请付款金额(含预付)/已收已付合计(含预付)/剩余可付」同步变化。 + +### 场景 A4:预结算转正式结算单(容纳预付) + +**前提**:预结算单 `approved`、**同一项目**;此时其预付(进度预付台账、项目预付核销行)将被正式单继承。 + +**前端操作(两条路径)** +1. 「预结算单」列表勾选 → 工具栏「**尾款结算**」→ 跳「正式结算单」表单,来源预结算明细与费用项自动合并。 +2. 或在正式结算单表单里通过弹窗「追加来源预结算」。 + +**表单上的三处关键呈现(应付侧)** +- **结算合计(原金额)**:Σ 费用项 = `settlementAmount`,**始终全额,不扣预付/核销**(开票匹配上限、金蝶应付单金额基线)。 +- **汇总栏**「已付金额(含预付、核销)/ 剩余未付金额」: + - 编辑态实时算:`已付 = Σ来源预结算.advancePaidAmount + Σ核销行.writeOffAmount`; + - 只读态取落库值 `form.paidAmount`(已含尾款 + 进度预付 + 核销)。 +- **项目预付核销**:从预结算来源带入的核销行会**自动预填**,可增删改。 + +**后端动作(`FormalSettlementServiceImpl.saveDraft`,同一事务)** +1. 来源合并校验(同合同、同币种)→ 写 `FormalSettlementSource` 快照(含预付汇总 `advanceAppliedAmount / advancePaidAmount`)。 +2. `refreshSettlementAmount`:以费用项重算 `settlementAmount`,并刷一次汇总。 +3. **转移来源核销行**:`deleteBySettlementIds(PRE, 来源预结算ids)` —— 先释放来源占用,后续"不超可核销余额"校验才能通过预填金额。 +4. `replaceWriteOffs`:服务端复校验后落 **FORMAL** 核销行。 +5. `refreshPaymentSummary` 再刷一次(核销落库后必须再算,第 2 步覆盖不到核销行)。 + +> 原则:**同一笔预付的核销在链路上只存在一份** —— 预结算转正式 = 删源 PRE 行 + 落 FORMAL 行。 +> 轻量标记路径(`PreSettlementController /formal-settlement`):仅回写正式结算单号,无正式单实体,PRE 核销行原地保留,待真正建单时再转移。**前提:无在途预付申请**(存在 reviewing/approved 预付会拦截)。 + +**结果校验**:正式结算单「申请付款金额(含预付)」「已收已付合计(含预付)」「剩余可付」「收付款状态」四列直接反映预付与核销;同一预付在 PRE / FORMAL 侧无双份核销。 + +### 场景 A5:正式结算单同步金蝶(A102-2 财务应付单) + +**前端操作**:正式结算单列表 → **应付** tab → 勾选 1 条(按钮要求单选)→ 工具栏「**同步金蝶**」。 + +**后端动作**:`POST /formal-settlement/sync-kingdee` → 按结算方向路由: +- 应付 → **A102-2 财务应付单**(A102-4 幂等预检 / 反查,一张结算单一张应付单),金额 = **全额 settlementAmount**; +- 成功后回写 `kingdeeSyncStatus=synced` + 金蝶单据号/内码 → `lockBusiData` 锁单 → A902 附件上传。 + +**结果校验**:列表「金蝶同步状态」= 已同步、「金蝶锁定状态」= 已锁定 / 锁定中;附件未全部上传时页面追加提示"可重推补传"。 + +**审批时自动推送**:正式结算单「通过」后也会 AFTER_COMMIT 自动推;若推送失败,审批结果为 `false` + 具体原因以 warning 提示,审批本身不失败,走「同步金蝶」手动兜底或定时重推。 + +### 场景 A6:尾款付款(正式结算单之后) + +**前提**:正式结算单 `approved`、`payable`、同一合同、有剩余可付金额。 + +**前端操作**:正式结算单(应付)列表勾选 1 条或多条 → 工具栏「**付款申请**」→ 弹窗确认各单申请金额(默认 = 剩余可付,已扣核销)→「提交」。 + +**后端动作** +1. `saveDraft`:可挂多张同合同正式结算单,按可付金额比例分摊;`submit` 时 `validateNoTailPayment` 防重复尾款申请。 +2. **推送路由不同**:尾款走 **A103-1 应付下推**,前置对每张正式结算单做 A102-4 预检——**未在金蝶生成财务应付单则拦截**(提示先在正式结算单页同步金蝶);下推生成金蝶付款申请单,随后锁单 + 附件。 +3. 回写 `paid` → 生成 `FormalSettlementPayment(paymentType=final)` → `refreshPaymentSummary` 重算 `appliedPaymentAmount / paidAmount / remainingPayableAmount / paymentStatus`(unpaid → partial → paid)。 + +**金额口径(全系统唯一定义)** + +``` +settlementAmount = Σ 结算合计费用项(全额,不扣预付/核销) +appliedPaymentAmount = 尾款申请中(reviewing) + Σ来源预结算 advanceAppliedAmount + Σ核销行 +paidAmount = 尾款已付(approved 实付) + Σ来源预结算 advancePaidAmount + Σ核销行 +remainingPayableAmount = max(settlementAmount − paidAmount, 0) +paymentStatus = unpaid / partial / paid +``` + +--- + +## 二、应收侧:正式结算单推送开票 + +> 开票是**应收**方向:给客户开票 → 金蝶开票单。与应付侧的"收票"共用 `invoice_status` 字段,文案按方向区分(未开票 / 部分开票 / 已开票,未收票 / 部分收票 / 已收票)。 + +### 场景 B1:应收正式结算单同步金蝶(A302-2 财务应收单) + +**前端操作**:正式结算单列表切到「**应收**」tab → 勾选 1 条 → 工具栏「**同步金蝶**」。 + +**后端动作**:`POST /formal-settlement/sync-kingdee` → **A302-2 财务应收单**(防重键 `正式结算单号_结算明细单据号`)→ 回写单据号/内码 + `synced` → 锁单 + A902 附件。 +另有定时勾稽 `syncKingdeeArResult`(A302-4):对已同步/生成失败的应收结算单批量反查金蝶应收单并纠偏回填,可手动触发 `POST /formal-settlement/sync-kingdee-ar-result`。 + +**结果校验**:应收正式结算单 `kingdeeSyncStatus=已同步`,列表可见金蝶单据号。 + +> **开票前置**:正式结算开票要求**先有财务应收单**。开票申请推送时按 A302-4 逐结算单预检,未生成则拦截并提示"请先在正式结算单页面同步金蝶"。 + +### 场景 B2:开票申请创建与推送(A303-1 / A303-5) + +**前提**:存在 `approved`、未作废、**未收票/未开票(invoiceStatus=unreceived)** 的**应收**正式结算单。 + +**前端操作** +1. 菜单进「**开票申请**」列表 → 点「**新增**」。 +2. 「开票基本信息」区点「结算单」右侧「**选择**」→ 弹窗「选择应收正式结算单」(可按结算单号、项目、合同搜索)→ 勾选 → 「确定」。 + - 项目、开票单位(= 结算单收款方)自动带出;「**可开票金额**」= Σ 各结算单剩余可开票额;受票方开票信息按受票方客商档案带出。 +3. 填「发票类型」(电子专票时纳税人识别号/开户行/开户账号/注册地址必填)、受票方、邮箱、「开票内容」(发票张次与商品行)→ 系统自动算「本次开票金额」= 行合计。 +4. 「结算信息」表中填各结算单的**分摊开票金额**(≤ 该单剩余可开票金额)。 +5. 「开票明细」勾选明细 → 「保存」(草稿)→ 「提交」→ 列表「通过」。 + +**后端动作** + +| 步骤 | 接口/方法 | 规则 | +|---|---|---| +| 保存 | `POST /invoice-application/save` → `saveDraft` | ① 仅可选择**应收、审批通过、未作废、未收票**的正式结算单;② 结算单需兼容(同项目/开票方等);③ 分摊金额 ≤ 剩余可开票金额;④ 行合计 > 0;⑤ 受票方须有客商档案与有效开票信息;⑥ 电子专票四要素必填;⑦ 保存后刷新来源结算单开票状态,写变更记录 | +| 提交 | `/invoice-application/submit` | `draft/returned → reviewing`,写申请日期 | +| 审批 | `/invoice-application/approve` | `→ approved`,发布事件 → AFTER_COMMIT 推金蝶 | +| 推送 | `syncKingdee` → `doSyncKingdee` | ① **必须关联正式结算单**;② 逐结算单 A302-4 预检应收单已生成;③ A303-4 幂等预检(已生成的张次跳过并回填);④ 按发票张次拆分 `billno = 单号-张次` 逐张推 A303-1;⑤ 成功后锁单 + A902 附件 | +| 回写 | 定时 `syncKingdeeInvoiceResult`(A303-5,按 `serialNo=billno` 分批 ≤50)或手动 `POST /invoice-application/sync-kingdee-invoice-result` | 查到发票 → 落 `InvoiceApplicationInvoice` 发票记录并推进 `invoiced` 终态;同时刷新来源结算单 `invoice_status`(部分开票 → 已开票) | + +**结果校验**:开票申请 `kingdeeStatus=已同步 / 开票完成`,详情页可见发票结果(发票号、开票日期、价税合计、文件);来源正式结算单「发票状态」从未开票 → 部分开票 → 已开票。 + +**边界与限制(现状)** +- 已推送(`synced/invoiced`)**锁单不可改**;仅审批通过且未锁定时可「作废」。 +- 数电票已开出只能红冲(不走本系统作废)。 +- **预付/核销不影响开票**:开票按**全额 `settlementAmount`** 匹配,预付与核销只影响应付侧的已付/剩余可付。 + +### 场景 B3:预开票(预结算单直接开票)——**方案,尚未落地** + +按《2026-09-29-预结算开票(预开票)金蝶推送方案》,预结算(应收)可**只推 A303-1 开票申请单**、不校验应收单,新增 `invoice_source=pre` 与预结算关联表区分。 + +**当前代码状态**:`InvoiceApplicationServiceImpl.doSyncKingdee` 仍硬校验"必须关联正式结算单",创建入口也只允许选应收正式结算单 ⇒ **预开票目前推不出去**,需按上述方案改造后才能操作。本文不列为可执行场景。 + +--- + +## 三、状态机与字段口径 + +### 3.1 付款申请(PaymentApplication) + +| 字段 | 取值 | 说明 | +|---|---|---| +| `paymentType` | `progress_advance` / `project_advance` / `settlement_payment` | 进度预付 / 项目预付 / 尾款付款 | +| `approvalStatus` | `draft / reviewing / approved / returned / voided` | 提交后 reviewing;仅 approved 可作废 | +| `kingdeeStatus` | `unsynced / synced / paying / paid / closed / failed` | **paid 是项目预付可核销的前置** | +| `kingdeeLockStatus` | `locked / …` | 已锁定的单据禁止编辑/作废 | + +### 3.2 正式结算单(FormalSettlement) + +| 字段 | 取值 | +|---|---| +| `approvalStatus` | `draft / reviewing / approved / returned / voided` | +| `paymentStatus` | `unpaid / partial / paid` | +| `kingdeeSyncStatus` | `unsynced / synced / failed` | +| `invoiceStatus` | 应付=收票状态,应收=开票状态:`unreceived / partial / completed` | + +### 3.3 预付记录单据状态名(`src/utils/settlementStatus.js`,两个编辑器共用) + +| 值 | 名称 | +|---|---| +| `reviewing` | 审批中 | +| `approved` | 审批通过 | +| `paid` | 已付款 | +| `returned` | 已驳回 | +| `voided` | 已作废 | +| `draft` | 草稿 | + +### 3.4 正式结算单付款记录合并表(只读) + +一张表三类型,按「付款类型」列区分:**进度预付**(来源预结算 `PreSettlementAdvance` 台账)/ **项目预付核销**(核销行,金额=本次核销额)/ **结算尾款**。列名统一为「**申请/核销金额**」;创建时间统一取**底层付款申请创建时间**。 + +--- + +## 四、逆向与异常场景 + +| 场景 | 触发 | 系统行为 | +|---|---|---| +| 付款申请作废(仅 approved) | 付款申请列表「作废」(非金蝶锁定) | 金蝶锁单中禁止作废;汇票付款释放票据余额;**联动作废未付预付记录 + 删除其核销行并刷新受影响正式结算单汇总**(已付事实不回退) | +| 正式结算单作废 | 结算单列表「作废」 | 删本单 FORMAL 核销行,额度释放、详情无残留 | +| 金蝶删单回调(`voidByKingdee`) | 金蝶侧反审核删除 | 同上,删本单核销行 | +| 预结算单作废 | 预结算列表「作废」 | 存在关联预付申请需先作废预付;删 PRE 核销行 | +| 预付记录作废 | 预付台账 | 已付款的不能作废 | +| 金蝶反审核(解锁) | A103-2 连续两轮查不到已推单据 | 判定金蝶侧已删 → `unlockBill` 置 `failed`、清空金蝶单号/内码、**联动作废未付预付记录**(已付不回退);重推后重新建档。**此场景不删核销行**(预付申请仍 approved,核销继续计入),人工作废后才回落 | +| 推送失败 | A103-2/A102-2/A302-2/A303-1 报错 | 落 `failed` → `KingdeeAutoSyncRetryTask` 每 10 分钟自动重推;页面「同步金蝶」/「批量同步」手动兜底;审批接口把失败原因带回前端 warning,审批结果不受影响 | +| 结算调整(金额变更) | 「结算调整单」 | 调整落库前校验**核销合计 ≤ 新结算金额**,超出拒绝并提示先调减核销;落库后 `refreshPaymentSummary` 重算。调整重推:核销行**迁移到新单**(克隆 + 删原单行 + 批量刷新两单),原单作历史存档,收付口径以新单为准 | +| 全额核销 / 已结清 | `paymentStatus=paid` | `isSettlementCompleted` 拦截,不允许再发起结算调整(已确认"全额抵扣即结清") | + +--- + +## 五、接口与数据表索引 + +### 5.1 后端接口 + +| 模块 | 主要接口(`/blade-transport` 网关前缀) | +|---|---| +| 预结算单 | `GET /pre-settlement/list|detail`;`POST /pre-settlement/save|submit|approve|return|void`;`POST /pre-settlement/formal-settlement`(轻量标记转正式) | +| 付款申请 | `GET /payment-application/list|detail|reference-amount|project-advance-write-off-options`;`POST /payment-application/save|submit|approve|return|void|sync-kingdee|sync-kingdee-batch|sync-kingdee-result` | +| 正式结算单 | `GET /formal-settlement/list|detail|candidate-pre-settlements|contract-options`;`POST /formal-settlement/save|submit|approve|return|void|sync-kingdee|sync-kingdee-ar-result|retry-kingdee-bill-lock|apply-payment|apply-payments|claim-invoices` | +| 开票申请 | `GET /invoice-application/list|detail|settlement-candidates|settlement-details|receiver-information`;`POST /invoice-application/save|remove|submit|approve|return|void|sync-kingdee|sync-kingdee-invoice-result` | + +### 5.2 核心数据表 + +| 表/实体 | 作用 | +|---|---| +| `PreSettlement` | 预结算单;预付汇总 `advanceNo / advanceAppliedAmount / advancePaidAmount`、`formalSettlementNo` | +| `PreSettlementAdvance` | 预结算预付台账;`advanceNo`(=FKD 单号)、`billStatus`、`kingdeeAdvanceNo` | +| `PaymentApplication` | 付款申请(三种 paymentType) | +| `PaymentApplicationSettlement` | 付款申请 ↔ 结算单关系及分摊 | +| `PaymentApplicationRecord` | 付款记录(A103-4 回填,`voucherJson` 留档) | +| `FormalSettlement` | 正式结算单;`settlementAmount / appliedPaymentAmount / paidAmount / remainingPayableAmount / paymentStatus / kingdeeSyncStatus / invoiceStatus` | +| `FormalSettlementSource` | 来源预结算/明细快照,冗余预付汇总 | +| `FormalSettlementPayment` | 正式结算单尾款付款记录(`paymentType=final`) | +| `SettlementProjectAdvanceWriteOff` | 项目预付核销行(`settlementType`:PRE / FORMAL) | +| `InvoiceApplication` / `InvoiceApplicationSettlement` / `InvoiceApplicationSheet` / `InvoiceApplicationLine` / `InvoiceApplicationInvoice` | 开票申请主单 / 结算单关联与分摊 / 发票张次 / 商品行 / 金蝶回写发票 | + +### 5.3 前端页面 + +| 页面 | 路径 | +|---|---| +| 预结算单列表/表单 | `src/views/settlement/pre-settlement.vue`、`pre-settlement-form.vue`、`components/pre-settlement-editor.vue` | +| 正式结算单列表/表单 | `src/views/settlement/formal-settlement.vue`、`formal-settlement-form.vue`、`components/formal-settlement-editor.vue` | +| 项目预付核销组件 | `src/views/settlement/components/project-advance-write-off.vue` | +| 付款申请列表/表单 | `src/views/payment/payment-application.vue`、`payment-application-form.vue` | +| 开票申请列表/表单 | `src/views/payment/invoice-application.vue`、`invoice-application-form.vue` | +| 状态名公共工具 | `src/utils/settlementStatus.js`、`src/option/settlement/formalSettlementSearch.js` | + +--- + +## 六、已知口径与风险 + +1. **尾款默认可付额** = 结算金额 − 累计尾款申请 − 核销,**不扣进度预付已付部分**(既有口径)。进度预付占大头时默认额可能偏大,由审批环节把关。 +2. **正式结算单 `paidAmount` 现含核销**,下游消费该字段的报表/对账单口径随之变化(对账已确认接受)。 +3. **金蝶侧预付抵扣字段**(`y_billtype / y_billid / y_settleamt`)本期**不传**,金蝶报表与本系统"已付含核销"存在时间差(待业务确认后迭代)。 +4. **预开票未落地**:预结算直接开票需先按方案改造 `doSyncKingdee` 门禁与数据模型。 +5. 存量"approved 未 paid"的核销行未做数据修正(`replaceWriteOffs` 只约束新保存),如需清洗另出脚本。 +6. 应收侧预收/收款认领(ReceiptClaim)与收票流程未在本文展开。 diff --git a/doc/docs/业务流程/2026-10-19-开票申请支持结算单重复选择与累计额度控制改造说明.md b/doc/docs/业务流程/2026-10-19-开票申请支持结算单重复选择与累计额度控制改造说明.md new file mode 100644 index 00000000..e3a19b2a --- /dev/null +++ b/doc/docs/业务流程/2026-10-19-开票申请支持结算单重复选择与累计额度控制改造说明.md @@ -0,0 +1,116 @@ +# 开票申请支持结算单重复选择与累计额度控制改造说明 + +> 日期:2026-10-19 +> 模块:收款付账管理 / 开票管理(开票申请) +> 涉及工程:`tms-api`(blade-transport)、`tms-erp-web` +> 关联功能说明:支持多张结算单合并开票、一张结算单拆分多张发票;审批通过后同步金蝶;单据留痕。 + +--- + +## 一、背景与现状问题 + +开票申请原实现中,选单弹窗与保存校验都要求结算单处于"未收票"(`unreceived`)状态: + +1. 前端选单请求写死 `invoiceStatus: 'unreceived'`; +2. 后端候选接口按 `invoice_status = 'unreceived'` 过滤; +3. 保存校验:新建申请时若结算单状态不是 `unreceived` 直接报"只能选择审批通过、未作废、未收票的应收正式结算单"; +4. 开票申请**保存草稿**时即回写结算单发票状态(`refreshSettlementInvoiceStatus`),状态变为"部分开票 / 全额开票"。 + +**结果**:结算单 001、002 在第一张开票申请保存后状态不再是"未收票",开第二张票时弹窗查不到、后端也拦截,**一张结算单无法拆分多次开票**,与功能说明中"一张结算单拆分多张发票"不符。 + +同时原"分摊发票金额"依赖用户手填,且存在口径不一致:单选结算单时自动填满剩余额度(即使本次只开一部分),多选时全部填 0(不占用),累计额度控制实际失效。 + +## 二、改造方案(自动分摊口径) + +核心思路:**放开"未收票"过滤让结算单可重复选;"累计开票金额不超结算单金额"由后端按本次开票金额自动分摊登记占用**,复用既有的剩余额度计算与状态回写链路。 + +### 2.1 核心规则 + +| 规则 | 说明 | +| --- | --- | +| 可开票金额(剩余) | `结算单总金额 − Σ分摊发票金额`,其中分摊金额只统计**未作废、未删除**的开票申请(`availableAmount`,编辑时排除本申请自身) | +| 可选结算单范围 | 审批通过 + 未作废 + 启用 + **剩余可开票金额 > 0** 的应收正式结算单;不再限制"未收票"。开满的(剩余额度为 0)自动从弹窗消失 | +| 自动分摊 | 保存申请(含草稿)时,后端将**本次开票金额**(开票内容商品行含税金额合计)按结算单选择顺序**逐单填满**:每单分摊 `min(该单剩余可开票金额, 尚未摊完的金额)`,分摊结果写入关联表 `allocated_invoice_amount` | +| 整体兜底校验 | 摊不完(本次开票金额 > Σ剩余可开票金额)时保存报错:"本次开票金额不能超过可开票金额",草稿与提交均生效 | +| 结算单发票状态 | 保存/删除/作废申请时自动刷新:占用 ≤ 0 → 未收票;0 < 占用 < 总金额 → 部分开票;占用 = 总金额 → 全额开票(`refreshSettlementInvoiceStatus`,本次未改动) | +| 额度释放 | 删除草稿、作废申请时自动释放占用(既有逻辑,本次未改动) | + +### 2.2 分摊示例 + +结算单 001 总金额 1000: + +| 步骤 | 操作 | 分摊结果 | 001 状态 | 剩余可开票 | +| --- | --- | --- | --- | --- | +| 第 1 张申请 | 选 001,本次开票 600 | 001 分摊 600 | 部分开票 | 400 | +| 第 2 张申请 | 弹窗仍可选 001(显示已申请 600 / 可开票 400),本次开票 400 | 001 分摊 400 | 全额开票 | 0 | +| 第 3 张申请 | 弹窗不再出现 001(剩余额度 0) | — | — | — | +| 第 2 张申请被作废 | 释放 400 | 001 分摊余 600 | 部分开票 | 400 | + +多张结算单(001 剩 1000、002 剩 500)本次开票 1200:001 分摊 1000、002 分摊 200;本次开票 1600 时摊不完,保存报错。 + +## 三、改动明细 + +### 3.1 后端(tms-api / blade-transport) + +| 文件 | 改动 | +| --- | --- | +| `IInvoiceApplicationService.java` | `settlementCandidates` 签名删除 `invoiceStatus` 参数 | +| `InvoiceApplicationController.java` | `/settlement-candidates` 删除 `invoiceStatus` 请求参数(原默认 `unreceived`) | +| `InvoiceApplicationServiceImpl.java` `settlementCandidates` | 删除 `invoice_status = 'unreceived'` 过滤;"剩余可开票金额 > 0"过滤保留,兜住开满的结算单 | +| `InvoiceApplicationServiceImpl.java` `settlementCandidateMap` | 候选行新增返回 `invoicedAmount`(已申请开票金额 = 结算总金额 − 剩余可开票) | +| `InvoiceApplicationServiceImpl.java` `saveDraft` | ① 删除新建时 `unreceived` 状态硬校验,文案改为"只能选择审批通过、未作废的应收正式结算单";② 先计算本次开票金额(`validateSheets`),再按 2.1 规则自动分摊并回写 `SettlementRow.allocatedInvoiceAmount`,摊不完报错。关联表保存(`saveRelations`)直接读取分摊结果,无需感知 | +| `InvoiceApplicationServiceImpl.java` `detail` | 对已选结算单实时重算剩余可开票金额(`availableAmount` 排除本申请),避免编辑时展示保存时点的过期额度;批量查询结算单避免 N+1 | + +### 3.2 前端(tms-erp-web / `src/views/payment/invoice-application-form.vue`) + +| 位置 | 改动 | +| --- | --- | +| `loadSettlementCandidates` | 删除 `invoiceStatus: 'unreceived'` 参数 | +| 选单弹窗 | 在"结算金额"与"可开票金额"之间新增"已申请开票金额"列(`invoicedAmount`);"可开票金额"列原本已有 | +| `applySettlements` | 选单后分摊金额统一默认 0(删除"单选自动填满剩余额度"逻辑),分摊由后端保存时自动计算 | +| 结算信息表格 | "分摊发票金额"列改为只读展示(保存/详情返回的自动分摊结果),不再允许手填 | + +提交前的整体校验"本次开票金额不能超过可开票金额"(`validateAvailableInvoiceAmount`)保留,用于提交时提前拦截;后端保存兜底,草稿同样受限。 + +## 四、操作说明 + +### 4.1 制单人:首张开票(含草稿) + +1. 开票管理 → 开票申请 → 新增; +2. 结算单"选择":弹窗展示审批通过的应收正式结算单,可见**结算金额 / 已申请开票金额 / 可开票金额**三列,可多选合并开票(需同一合同、项目、组织及收付款方); +3. 勾选开票明细、维护开票内容商品行,本次开票金额由商品行含税金额自动求和; +4. 保存/提交:系统自动把本次开票金额按选择顺序分摊到各结算单并登记占用,结算单状态刷新为"部分开票"或"全额开票"。 + +### 4.2 制单人:同一结算单拆分开第二张票(本次新增能力) + +1. 再次新增开票申请,选单弹窗中**已开过票的结算单仍会出现**(只要还有剩余额度); +2. "可开票金额"列显示的即为剩余额度(总金额 − 已申请); +3. 本次开票金额超过剩余额度时,保存/提交均提示"本次开票金额不能超过可开票金额",调低商品行金额后可正常保存; +4. 开满后该结算单自动从弹窗消失。 + +### 4.3 编辑与释放 + +- 编辑已有申请:结算信息中"可开票金额"为实时剩余额度(已排除本申请自身占用),"分摊发票金额"为上次保存的自动分摊结果;再次保存时按新的本次开票金额重新分摊; +- 删除草稿 / 作废申请:占用自动释放,对应结算单恢复可开额度并回刷发票状态。 + +## 五、兼容性与注意事项 + +| 项 | 说明 | +| --- | --- | +| 存量数据 | 历史"多选分摊=0"的申请未登记占用,其结算单会以"全额可开"出现在弹窗。如需严格口径,上线前可做数据订正:按未作废申请的 `invoice_amount` 回填关联表 `allocated_invoice_amount` 并刷新结算单状态;或明确存量不追溯 | +| 草稿即占用 | 保存草稿即登记占用(与原行为一致),长期挂着的草稿会占住额度,需作废/删除释放 | +| 明细不去重 | 同一结算单的运单明细可能被多张申请勾选,本次按金额总额控制,不按明细去重(需求未要求) | +| 手填分摊失效 | `allocatedInvoiceAmount` 请求字段后端不再采信(自动覆盖),前端已改只读;如有第三方调用直接传该字段,行为变化需知悉 | +| 金蝶同步 | 同步链路读取申请主表/商品行,未读取分摊字段,理论无影响;上线后建议回归一次"审批通过 → 同步金蝶"链路 | +| 接口变更 | `/invoice-application/settlement-candidates` 删除 `invoiceStatus` 参数、响应新增 `invoicedAmount`,仅开票申请选单使用 | + +## 六、验证清单 + +- [ ] 新增申请选择 001 开票 600,保存后 001 状态"部分开票"、已申请 600; +- [ ] 第二张申请弹窗可选 001,可开票金额显示 400,已申请开票金额显示 600; +- [ ] 第二张本次开票 500,保存报"本次开票金额不能超过可开票金额";调为 400 保存成功,001 变"全额开票"; +- [ ] 第三张申请弹窗不再出现 001; +- [ ] 作废第二张申请后,001 恢复可开票 400、状态回到"部分开票"; +- [ ] 多结算单合并开票:本次开票金额按顺序分摊到各单,结算信息表格展示各单分摊结果; +- [ ] 编辑申请时"可开票金额"为实时额度(扣除其他申请占用、含本申请原分摊回退); +- [ ] 原有链路回归:审批流转、金蝶同步、批量同步状态、留痕记录。 diff --git a/doc/nacos/third-party-api.yaml b/doc/nacos/third-party-api.yaml index 371214d5..8ca005cc 100644 --- a/doc/nacos/third-party-api.yaml +++ b/doc/nacos/third-party-api.yaml @@ -47,6 +47,11 @@ thirdParty: accountId: ${BFI_ACCOUNT_ID:} # 语言字串,默认 zh_CN language: ${BFI_LANGUAGE:zh_CN} + # BFI 客户端本地 Mock 开关(联调自测用,生产必须 false): + # true 时 IBfiClient 有桩用桩({mockDir}/{接口方法名}.json)、无桩透传真实 Feign 接口,调用侧零改动 + mock-enabled: ${BFI_MOCK_ENABLED:false} + # Mock 桩文件目录:文件名为接口方法名,如 getToken.json、payApplyQuery.json;文件每次调用现读,改文件即生效 + mockDir: ${BFI_MOCK_DIR:./mock/bfi} # 数据来源系统编码(推送金蝶公共参数:客商、付款申请等统一使用),默认 WLTMS(运输TMS) srcSys: ${BFI_SRC_SYS:WLTMS} # 应付/应收/收票单分录物料映射(A102-2/A302-2/A303-2 共用,未配置使用代码默认值): @@ -57,6 +62,7 @@ thirdParty: # 国内运输:运单运输方式 -> 金蝶物料编码(公路运输/铁路运输/水路运输/航空运输四种) domestic: "[公路运输]": ${BFI_MATERIAL_DOMESTIC_ROAD:XDWL11010001} + "[road]": ${BFI_MATERIAL_DOMESTIC_ROAD:XDWL11010001} "[铁路运输]": ${BFI_MATERIAL_DOMESTIC_RAIL:XDWL11010002} "[水路运输]": ${BFI_MATERIAL_DOMESTIC_WATER:XDWL11010003} "[航空运输]": ${BFI_MATERIAL_DOMESTIC_AIR:Item-00001082} @@ -118,6 +124,8 @@ thirdParty: incrementDays: ${BFI_INVOICE_RECEIPT_INCREMENT_DAYS:30} # 同步发票种类白名单(逗号分隔票易通编码,如 26,27 数电普票/专票),为空不限制 invoiceTypes: ${BFI_INVOICE_RECEIPT_INVOICE_TYPES:26,27} + # 客商单位过滤开关:开启后只同步开票单位(销方)与受票单位(购方)税号均已登记在客商发票信息中的发票,前端票池展示查询同用此开关过滤 + customerUnitsOnly: ${BFI_INVOICE_RECEIPT_CUSTOMER_UNITS_ONLY:false} # 补偿绑定单轮限量(未绑定登记按发票号码精确补查条数上限) compensateLimit: ${BFI_INVOICE_RECEIPT_COMPENSATE_LIMIT:200} # single-flight 分布式锁租约秒数,须大于最坏一轮耗时;进程异常退出时锁在租约到期后自动释放 diff --git a/doc/sql/bladex/bladex.mysql.all.create.sql b/doc/sql/bladex/bladex.mysql.all.create.sql index 67ea9959..fed3b1b5 100644 --- a/doc/sql/bladex/bladex.mysql.all.create.sql +++ b/doc/sql/bladex/bladex.mysql.all.create.sql @@ -305,6 +305,7 @@ CREATE TABLE `blade_dept` ( `dept_code` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '组织编码', `kingdee_dept_code` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '金蝶部门代码', `contract_code_prefix` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '合同编码前缀', + `contract_initial_serial` int NULL DEFAULT NULL COMMENT '合同初始流水号', `short_name` varchar(45) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '组织简称', `pinyin_mnemonic` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '拼音助记码', `mnemonic_code` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '助记码', @@ -1324,7 +1325,9 @@ CREATE TABLE `blade_user_dept` ( `oa_id` varchar(64) NULL DEFAULT NULL COMMENT 'OA人员ID', `oa_login_id` varchar(128) NULL DEFAULT NULL COMMENT 'OA登录账号', `sync_time` datetime NULL DEFAULT NULL COMMENT '同步时间', - PRIMARY KEY (`id`) USING BTREE + PRIMARY KEY (`id`) USING BTREE, + INDEX `idx_blade_user_dept_user` (`user_id`, `is_deleted`, `status`) USING BTREE, + INDEX `idx_blade_user_dept_dept` (`dept_id`, `is_deleted`, `status`) USING BTREE ) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '用户部门表'; -- ---------------------------- diff --git a/doc/sql/transport/blade_customer_archive_kingdee_20260917.sql b/doc/sql/transport/blade_customer_archive_kingdee_20260917.sql index 1850aa03..2f9c3a36 100644 --- a/doc/sql/transport/blade_customer_archive_kingdee_20260917.sql +++ b/doc/sql/transport/blade_customer_archive_kingdee_20260917.sql @@ -9,8 +9,16 @@ ALTER TABLE `blade_customer_archive` CREATE INDEX idx_kingdee_status ON blade_customer_archive (kingdee_status); --- 列表「同步金蝶」按钮权限(审批通过后推送失败的重推入口;parent_id 通过菜单 code 关联,避免写死 id) +-- 列表「同步金蝶」按钮权限(审批通过且未推送/推送失败时可点;parent_id 通过菜单 code 关联) INSERT INTO blade_menu (id, parent_id, code, name, alias, path, source, sort, category, action, is_open, component, remark, is_deleted) -SELECT 2075431800000000001, m.id, 'customer_archive_sync', '同步金蝶', 'customer_archive_sync', '', '', 1, 2, 0, 1, '', '', 0 +SELECT 2077600000000000011, m.id, 'customer_archive_sync', '同步金蝶', 'customer_archive_sync', '', '', 10, 2, 0, 1, NULL, '', 0 FROM blade_menu m -WHERE m.code = 'customer_archive' AND m.is_deleted = 0; +WHERE m.code = 'customer_archive' AND m.is_deleted = 0 +LIMIT 1 +ON DUPLICATE KEY UPDATE + parent_id = VALUES(parent_id), + name = VALUES(name), + alias = VALUES(alias), + sort = VALUES(sort), + category = VALUES(category), + is_deleted = 0; diff --git a/doc/sql/transport/blade_customer_archive_sync_menu_fix_20261015.sql b/doc/sql/transport/blade_customer_archive_sync_menu_fix_20261015.sql new file mode 100644 index 00000000..b80c088a --- /dev/null +++ b/doc/sql/transport/blade_customer_archive_sync_menu_fix_20261015.sql @@ -0,0 +1,22 @@ +-- 修复「同步金蝶」按钮菜单:按 code 幂等补齐/纠正,避免权限勾了但不生效 +-- 执行后需:角色重新勾选「同步金蝶」→ 用户重新登录 + +INSERT INTO blade_menu (id, parent_id, code, name, alias, path, source, sort, category, action, is_open, component, remark, is_deleted) +SELECT 2077600000000000011, m.id, 'customer_archive_sync', '同步金蝶', 'customer_archive_sync', '', '', 10, 2, 0, 1, NULL, '', 0 +FROM blade_menu m +WHERE m.code = 'customer_archive' AND m.is_deleted = 0 + AND NOT EXISTS ( + SELECT 1 FROM blade_menu x WHERE x.code = 'customer_archive_sync' AND x.is_deleted = 0 + ) +LIMIT 1; + +UPDATE blade_menu child +INNER JOIN blade_menu parent ON parent.code = 'customer_archive' AND parent.is_deleted = 0 +SET + child.parent_id = parent.id, + child.name = '同步金蝶', + child.alias = 'customer_archive_sync', + child.category = 2, + child.sort = 10, + child.is_deleted = 0 +WHERE child.code = 'customer_archive_sync'; diff --git a/doc/sql/transport/blade_dept_contract_initial_serial_20261020.sql b/doc/sql/transport/blade_dept_contract_initial_serial_20261020.sql new file mode 100644 index 00000000..7b4ac000 --- /dev/null +++ b/doc/sql/transport/blade_dept_contract_initial_serial_20261020.sql @@ -0,0 +1,3 @@ +-- 组织机构新增合同初始流水号:该部门当年首个合同从此流水号起编,后续递增 +ALTER TABLE `blade_dept` + ADD COLUMN `contract_initial_serial` int(11) DEFAULT NULL COMMENT '合同初始流水号' AFTER `contract_code_prefix`; diff --git a/doc/sql/transport/blade_formal_settlement_20260818.sql b/doc/sql/transport/blade_formal_settlement_20260818.sql index 98ccfc06..240eafcb 100644 --- a/doc/sql/transport/blade_formal_settlement_20260818.sql +++ b/doc/sql/transport/blade_formal_settlement_20260818.sql @@ -44,7 +44,9 @@ CREATE TABLE IF NOT EXISTS `blade_formal_settlement_change_record` ( `update_user` bigint(20) DEFAULT NULL, `update_time` datetime DEFAULT NULL, `status` int(11) DEFAULT '1', `is_deleted` int(11) DEFAULT '0', `formal_settlement_id` bigint(20) NOT NULL COMMENT '正式结算单ID', `change_type` varchar(50) NOT NULL COMMENT '变更类型', `line_no` int(11) DEFAULT NULL COMMENT '行号', `operation_type` varchar(30) NOT NULL COMMENT '操作类型', - `change_content` text NOT NULL COMMENT '变更内容', `change_reason` varchar(200) DEFAULT NULL COMMENT '变更原因', + `change_content` text NOT NULL COMMENT '变更内容', + `before_data` longtext COMMENT '变更前数据JSON', `after_data` longtext COMMENT '变更后数据JSON', + `change_reason` varchar(200) DEFAULT NULL COMMENT '变更原因', `operator_name` varchar(100) DEFAULT NULL COMMENT '操作人', `change_time` datetime NOT NULL COMMENT '变更时间', PRIMARY KEY (`id`), KEY `idx_formal_settlement_change_bill` (`formal_settlement_id`), KEY `idx_formal_settlement_change_time` (`change_time`) @@ -123,17 +125,29 @@ CREATE TABLE IF NOT EXISTS `blade_formal_settlement_payment` ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='正式结算付款申请'; INSERT INTO `blade_menu` (`id`,`parent_id`,`code`,`name`,`alias`,`path`,`source`,`sort`,`category`,`action`,`is_open`,`component`,`remark`,`is_deleted`) VALUES -(2090000000000001040,2090000000000001000,'formal_settlement','正式结算单','formal_settlement','/settlement/formal-settlement','iconfont icon-caidanguanli',4,1,0,1,NULL,'',0), -(2090000000000001041,2090000000000001040,'formal_settlement_view','查看','formal_settlement_view','','',1,2,0,1,NULL,'',0), -(2090000000000001042,2090000000000001040,'formal_settlement_add','新增','formal_settlement_add','','',2,2,0,1,NULL,'',0), -(2090000000000001043,2090000000000001040,'formal_settlement_edit','编辑','formal_settlement_edit','','',3,2,0,1,NULL,'',0), -(2090000000000001044,2090000000000001040,'formal_settlement_delete','删除','formal_settlement_delete','','',4,2,0,1,NULL,'',0), -(2090000000000001045,2090000000000001040,'formal_settlement_submit','提交审批','formal_settlement_submit','','',5,2,0,1,NULL,'',0), -(2090000000000001046,2090000000000001040,'formal_settlement_approve','审批','formal_settlement_approve','','',6,2,0,1,NULL,'',0), -(2090000000000001047,2090000000000001040,'formal_settlement_sync','同步金蝶','formal_settlement_sync','','',7,2,0,1,NULL,'',0), -(2090000000000001048,2090000000000001040,'formal_settlement_print','打印','formal_settlement_print','','',8,2,0,1,NULL,'',0), -(2090000000000001049,2090000000000001040,'formal_settlement_export','导出','formal_settlement_export','','',9,2,0,1,NULL,'',0), -(2090000000000001050,2090000000000001040,'formal_settlement_void','作废','formal_settlement_void','','',10,2,0,1,NULL,'',0), -(2090000000000001051,2090000000000001040,'formal_settlement_adjust','明细调整','formal_settlement_adjust','','',11,2,0,1,NULL,'',0), -(2090000000000001052,2090000000000001040,'formal_settlement_payment','付款申请','formal_settlement_payment','','',12,2,0,1,NULL,'',0) +(2090000000000001040,2090000000000001000,'payment_settlement','付款结算单','payment_settlement','/settlement/payment-settlement','iconfont icon-caidanguanli',4,1,0,1,NULL,'',0), +(2090000000000001041,2090000000000001040,'payment_settlement_view','查看','payment_settlement_view','','',1,2,0,1,NULL,'',0), +(2090000000000001042,2090000000000001040,'payment_settlement_add','新增','payment_settlement_add','','',2,2,0,1,NULL,'',0), +(2090000000000001043,2090000000000001040,'payment_settlement_edit','编辑','payment_settlement_edit','','',3,2,0,1,NULL,'',0), +(2090000000000001044,2090000000000001040,'payment_settlement_delete','删除','payment_settlement_delete','','',4,2,0,1,NULL,'',0), +(2090000000000001045,2090000000000001040,'payment_settlement_submit','提交审批','payment_settlement_submit','','',5,2,0,1,NULL,'',0), +(2090000000000001046,2090000000000001040,'payment_settlement_approve','审批','payment_settlement_approve','','',6,2,0,1,NULL,'',0), +(2090000000000001047,2090000000000001040,'payment_settlement_sync','同步金蝶','payment_settlement_sync','','',7,2,0,1,NULL,'',0), +(2090000000000001048,2090000000000001040,'payment_settlement_print','打印','payment_settlement_print','','',8,2,0,1,NULL,'',0), +(2090000000000001049,2090000000000001040,'payment_settlement_export','导出','payment_settlement_export','','',9,2,0,1,NULL,'',0), +(2090000000000001050,2090000000000001040,'payment_settlement_void','作废','payment_settlement_void','','',10,2,0,1,NULL,'',0), +(2090000000000001051,2090000000000001040,'payment_settlement_adjust','明细调整','payment_settlement_adjust','','',11,2,0,1,NULL,'',0), +(2090000000000001052,2090000000000001040,'payment_settlement_payment','付款申请','payment_settlement_payment','','',12,2,0,1,NULL,'',0), +(2090000000000002100,2090000000000001000,'receipt_settlement','收款结算单','receipt_settlement','/settlement/receipt-settlement','iconfont icon-caidanguanli',5,1,0,1,NULL,'',0), +(2090000000000002101,2090000000000002100,'receipt_settlement_view','查看','receipt_settlement_view','','',1,2,0,1,NULL,'',0), +(2090000000000002102,2090000000000002100,'receipt_settlement_add','新增','receipt_settlement_add','','',2,2,0,1,NULL,'',0), +(2090000000000002103,2090000000000002100,'receipt_settlement_edit','编辑','receipt_settlement_edit','','',3,2,0,1,NULL,'',0), +(2090000000000002104,2090000000000002100,'receipt_settlement_delete','删除','receipt_settlement_delete','','',4,2,0,1,NULL,'',0), +(2090000000000002105,2090000000000002100,'receipt_settlement_submit','提交审批','receipt_settlement_submit','','',5,2,0,1,NULL,'',0), +(2090000000000002106,2090000000000002100,'receipt_settlement_approve','审批','receipt_settlement_approve','','',6,2,0,1,NULL,'',0), +(2090000000000002107,2090000000000002100,'receipt_settlement_sync','同步金蝶','receipt_settlement_sync','','',7,2,0,1,NULL,'',0), +(2090000000000002108,2090000000000002100,'receipt_settlement_print','打印','receipt_settlement_print','','',8,2,0,1,NULL,'',0), +(2090000000000002109,2090000000000002100,'receipt_settlement_export','导出','receipt_settlement_export','','',9,2,0,1,NULL,'',0), +(2090000000000002110,2090000000000002100,'receipt_settlement_void','作废','receipt_settlement_void','','',10,2,0,1,NULL,'',0), +(2090000000000002111,2090000000000002100,'receipt_settlement_adjust','明细调整','receipt_settlement_adjust','','',11,2,0,1,NULL,'',0) ON DUPLICATE KEY UPDATE `name`=VALUES(`name`),`path`=VALUES(`path`),`is_deleted`=0; diff --git a/doc/sql/transport/blade_formal_settlement_change_record_20260827.sql b/doc/sql/transport/blade_formal_settlement_change_record_20260827.sql index 08ba660e..bf2abb67 100644 --- a/doc/sql/transport/blade_formal_settlement_change_record_20260827.sql +++ b/doc/sql/transport/blade_formal_settlement_change_record_20260827.sql @@ -14,6 +14,8 @@ CREATE TABLE IF NOT EXISTS `blade_formal_settlement_change_record` ( `line_no` int(11) DEFAULT NULL COMMENT '行号', `operation_type` varchar(30) COLLATE utf8mb4_general_ci NOT NULL COMMENT '操作类型', `change_content` text COLLATE utf8mb4_general_ci NOT NULL COMMENT '变更内容', + `before_data` longtext COLLATE utf8mb4_general_ci COMMENT '变更前数据JSON', + `after_data` longtext COLLATE utf8mb4_general_ci COMMENT '变更后数据JSON', `change_reason` varchar(200) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '变更原因', `operator_name` varchar(100) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '操作人', `change_time` datetime NOT NULL COMMENT '变更时间', diff --git a/doc/sql/transport/blade_formal_settlement_change_record_detail_20261018.sql b/doc/sql/transport/blade_formal_settlement_change_record_detail_20261018.sql new file mode 100644 index 00000000..65eb28e7 --- /dev/null +++ b/doc/sql/transport/blade_formal_settlement_change_record_detail_20261018.sql @@ -0,0 +1,30 @@ +-- 正式结算变更记录补充前后值明细,与预结算变更记录口径一致。 +SET @before_data_exists := ( + SELECT COUNT(1) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_formal_settlement_change_record' + AND COLUMN_NAME = 'before_data' +); +SET @before_data_sql := IF( + @before_data_exists = 0, + 'ALTER TABLE `blade_formal_settlement_change_record` ADD COLUMN `before_data` longtext COLLATE utf8mb4_general_ci COMMENT ''变更前数据JSON'' AFTER `change_content`', + 'SELECT 1' +); +PREPARE before_data_stmt FROM @before_data_sql; +EXECUTE before_data_stmt; +DEALLOCATE PREPARE before_data_stmt; + +SET @after_data_exists := ( + SELECT COUNT(1) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_formal_settlement_change_record' + AND COLUMN_NAME = 'after_data' +); +SET @after_data_sql := IF( + @after_data_exists = 0, + 'ALTER TABLE `blade_formal_settlement_change_record` ADD COLUMN `after_data` longtext COLLATE utf8mb4_general_ci COMMENT ''变更后数据JSON'' AFTER `before_data`', + 'SELECT 1' +); +PREPARE after_data_stmt FROM @after_data_sql; +EXECUTE after_data_stmt; +DEALLOCATE PREPARE after_data_stmt; diff --git a/doc/sql/transport/blade_invoice_receipt_drop_kingdee_fields_20261017.sql b/doc/sql/transport/blade_invoice_receipt_drop_kingdee_fields_20261017.sql new file mode 100644 index 00000000..d606761b --- /dev/null +++ b/doc/sql/transport/blade_invoice_receipt_drop_kingdee_fields_20261017.sql @@ -0,0 +1,30 @@ +-- 收票登记:删除冗余的 kingdee_bill_no / kingdee_status 两列 +-- 这两列由收票登记旧版金蝶同步链路写入,同步功能已下线,代码中已无任何读写。 +-- 收票登记的金蝶状态以 kingdee_push_status 为唯一状态源(未推送/已保存收票单/已指定应付/推送失败); +-- 金蝶单据号可经 kingdee_invoice_pool_id 关联 blade_kingdee_invoice_pool 取 serial_no, +-- 两列的取值集合也与新模型对不上(旧值 pushed / assigned 在新模型里不存在),故不搬运数据。 +-- 基线建表脚本 blade_invoice_receipt_20260821.sql 仍声明这两列, +-- 而 CREATE TABLE IF NOT EXISTS 对已存在的表是空操作,存量库只能靠 ALTER 收敛。 +-- 以下语句可重复执行。 +SET @ddl_sql = ( + SELECT IF(COUNT(*) > 0, + 'ALTER TABLE `blade_invoice_receipt` DROP COLUMN `kingdee_bill_no`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_invoice_receipt' AND COLUMN_NAME = 'kingdee_bill_no' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- 索引 idx_invoice_receipt_kingdee 建在 kingdee_status 上,随该列一并删除,无需单独 DROP INDEX +SET @ddl_sql = ( + SELECT IF(COUNT(*) > 0, + 'ALTER TABLE `blade_invoice_receipt` DROP COLUMN `kingdee_status`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_invoice_receipt' AND COLUMN_NAME = 'kingdee_status' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; diff --git a/doc/sql/transport/blade_payment_application_settlement_pre_20261016.sql b/doc/sql/transport/blade_payment_application_settlement_pre_20261016.sql new file mode 100644 index 00000000..1cc6e9e9 --- /dev/null +++ b/doc/sql/transport/blade_payment_application_settlement_pre_20261016.sql @@ -0,0 +1,82 @@ +-- 付款申请可关联多张预结算单:关联表补充预结算字段,正式结算字段改为可空 + +SET @ddl_sql = ( + SELECT IF(COUNT(*) > 0, + 'ALTER TABLE `blade_payment_application_settlement` MODIFY COLUMN `formal_settlement_id` bigint(20) NULL COMMENT ''正式结算单ID''', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_payment_application_settlement' + AND COLUMN_NAME = 'formal_settlement_id' + AND IS_NULLABLE = 'NO' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +SET @ddl_sql = ( + SELECT IF(COUNT(*) > 0, + 'ALTER TABLE `blade_payment_application_settlement` MODIFY COLUMN `formal_settlement_no` varchar(100) COLLATE utf8mb4_general_ci NULL COMMENT ''正式结算单号''', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_payment_application_settlement' + AND COLUMN_NAME = 'formal_settlement_no' + AND IS_NULLABLE = 'NO' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_payment_application_settlement` ADD COLUMN `pre_settlement_id` bigint(20) DEFAULT NULL COMMENT ''预结算单ID'' AFTER `formal_settlement_no`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_payment_application_settlement' + AND COLUMN_NAME = 'pre_settlement_id' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_payment_application_settlement` ADD COLUMN `pre_settlement_no` varchar(100) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''预结算单号'' AFTER `pre_settlement_id`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_payment_application_settlement' + AND COLUMN_NAME = 'pre_settlement_no' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_payment_application_settlement` ADD KEY `idx_payment_settlement_pre` (`pre_settlement_id`)', + 'SELECT 1') + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_payment_application_settlement' + AND INDEX_NAME = 'idx_payment_settlement_pre' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +SET @ddl_sql = ( + SELECT IF(COUNT(*) > 0, + 'ALTER TABLE `blade_payment_application` MODIFY COLUMN `pre_settlement_no` varchar(500) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''预结算单号''', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_payment_application' + AND COLUMN_NAME = 'pre_settlement_no' + AND CHARACTER_MAXIMUM_LENGTH < 500 +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; diff --git a/doc/sql/transport/blade_pre_settlement_20260818.sql b/doc/sql/transport/blade_pre_settlement_20260818.sql index 2079beb9..6f20b639 100644 --- a/doc/sql/transport/blade_pre_settlement_20260818.sql +++ b/doc/sql/transport/blade_pre_settlement_20260818.sql @@ -200,7 +200,7 @@ CREATE TABLE IF NOT EXISTS `blade_pre_settlement_change_record` ( INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) VALUES -(2090000000000001020, 2090000000000001000, 'pre_settlement', '预结算', 'pre_settlement', '/settlement/pre-settlement', 'iconfont icon-caidanguanli', 3, 1, 0, 1, NULL, '', 0), +(2090000000000001020, 2090000000000001000, 'pre_settlement', '预付结算单', 'pre_settlement', '/settlement/pre-settlement', 'iconfont icon-caidanguanli', 3, 1, 0, 1, NULL, '', 0), (2090000000000001021, 2090000000000001020, 'pre_settlement_view', '查看', 'pre_settlement_view', '', '', 1, 2, 0, 1, NULL, '', 0), (2090000000000001022, 2090000000000001020, 'pre_settlement_add', '新增', 'pre_settlement_add', '', '', 2, 2, 0, 1, NULL, '', 0), (2090000000000001023, 2090000000000001020, 'pre_settlement_edit', '编辑', 'pre_settlement_edit', '', '', 3, 2, 0, 1, NULL, '', 0), diff --git a/doc/sql/transport/blade_project_apply_undertake_dept_multi_20261014.sql b/doc/sql/transport/blade_project_apply_undertake_dept_multi_20261014.sql index b2d5d222..2b96a193 100644 --- a/doc/sql/transport/blade_project_apply_undertake_dept_multi_20261014.sql +++ b/doc/sql/transport/blade_project_apply_undertake_dept_multi_20261014.sql @@ -1,4 +1,64 @@ --- 平台公司支持多选:ID 逗号分隔,名称顿号分隔 -ALTER TABLE `blade_project_apply` - MODIFY COLUMN `undertake_dept_id` varchar(500) DEFAULT NULL COMMENT '平台公司ID,多个逗号分隔', - MODIFY COLUMN `undertake_dept_name` varchar(500) DEFAULT NULL COMMENT '平台公司,多个顿号分隔'; +-- 项目立项平台公司多选:ID 逗号分隔,名称顿号分隔 +-- 原字段:undertake_dept_id bigint、undertake_dept_name varchar(100) +-- 选择 2 个及以上平台公司时,若未扩容会报 Incorrect integer value / Data truncation +-- 整列索引挂在 varchar(500) 上可能因键长超限导致 ALTER 失败,故先删索引再改前缀索引 + +DELIMITER $$ + +DROP PROCEDURE IF EXISTS `upgrade_project_apply_undertake_dept_multi_20261014`$$ +CREATE PROCEDURE `upgrade_project_apply_undertake_dept_multi_20261014`() +BEGIN + DECLARE db_name varchar(128); + DECLARE need_modify tinyint DEFAULT 0; + SET db_name = DATABASE(); + + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = db_name + AND table_name = 'blade_project_apply' + AND column_name = 'undertake_dept_id' + AND ( + data_type IN ('bigint', 'int', 'integer') + OR (data_type IN ('varchar', 'char') AND IFNULL(character_maximum_length, 0) < 500) + ) + ) OR EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = db_name + AND table_name = 'blade_project_apply' + AND column_name = 'undertake_dept_name' + AND data_type IN ('varchar', 'char') + AND IFNULL(character_maximum_length, 0) < 500 + ) THEN + SET need_modify = 1; + END IF; + + IF need_modify = 1 THEN + IF EXISTS ( + SELECT 1 FROM information_schema.statistics + WHERE table_schema = db_name + AND table_name = 'blade_project_apply' + AND index_name = 'idx_project_apply_dept' + ) THEN + ALTER TABLE `blade_project_apply` DROP INDEX `idx_project_apply_dept`; + END IF; + + ALTER TABLE `blade_project_apply` + MODIFY COLUMN `undertake_dept_id` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '平台公司ID,多个逗号分隔', + MODIFY COLUMN `undertake_dept_name` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '平台公司,多个顿号分隔'; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM information_schema.statistics + WHERE table_schema = db_name + AND table_name = 'blade_project_apply' + AND index_name = 'idx_project_apply_dept' + ) THEN + ALTER TABLE `blade_project_apply` + ADD INDEX `idx_project_apply_dept` (`undertake_dept_id`(191)) USING BTREE; + END IF; +END$$ + +CALL `upgrade_project_apply_undertake_dept_multi_20261014`()$$ +DROP PROCEDURE `upgrade_project_apply_undertake_dept_multi_20261014`$$ + +DELIMITER ; diff --git a/doc/sql/transport/blade_project_contract_management.sql b/doc/sql/transport/blade_project_contract_management.sql index 59890b1b..62a9ecd2 100644 --- a/doc/sql/transport/blade_project_contract_management.sql +++ b/doc/sql/transport/blade_project_contract_management.sql @@ -60,7 +60,7 @@ CREATE TABLE `blade_project_apply` ( `void_reason` varchar(500) DEFAULT NULL COMMENT '作废原因', PRIMARY KEY (`id`) USING BTREE, UNIQUE KEY `uk_project_apply_name` (`project_name`, `is_deleted`) USING BTREE, - KEY `idx_project_apply_dept` (`undertake_dept_id`) USING BTREE, + KEY `idx_project_apply_dept` (`undertake_dept_id`(191)) USING BTREE, KEY `idx_project_apply_status` (`approval_status`) USING BTREE, KEY `idx_project_apply_create_time` (`create_time`) USING BTREE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='项目立项'; @@ -80,8 +80,8 @@ CREATE TABLE `blade_temporary_credit_limit` ( `project_id` bigint(20) DEFAULT NULL COMMENT '项目ID', `project_code` varchar(100) DEFAULT NULL COMMENT '项目编号', `project_name` varchar(100) DEFAULT NULL COMMENT '项目名称', - `undertake_dept_id` varchar(500) DEFAULT NULL COMMENT '平台公司ID,多个逗号分隔', - `undertake_dept_name` varchar(500) DEFAULT NULL COMMENT '平台公司,多个顿号分隔', + `undertake_dept_id` bigint(20) DEFAULT NULL COMMENT '承办部门ID', + `undertake_dept_name` varchar(100) DEFAULT NULL COMMENT '承办部门', `project_fund_limit` decimal(18,2) DEFAULT NULL COMMENT '项目资金使用额度(万元)', `used_fund_limit` decimal(18,2) DEFAULT NULL COMMENT '已使用项目资金额度(万元)', `remaining_fund_limit` decimal(18,2) DEFAULT NULL COMMENT '剩余项目资金使用额度(万元)', diff --git a/doc/sql/transport/blade_settlement_adjustment_kingdee_20261016.sql b/doc/sql/transport/blade_settlement_adjustment_kingdee_20261016.sql new file mode 100644 index 00000000..67f5cc02 --- /dev/null +++ b/doc/sql/transport/blade_settlement_adjustment_kingdee_20261016.sql @@ -0,0 +1,61 @@ +-- 结算调整单接入金蝶(未推送自动推 · 已推送手工冲销后重推新单) +-- 整理日期:2026-10-16 + +-- 结算调整单:落金蝶单据号(未推送场景回填原单金蝶单号;已推送重推场景回填新单金蝶单号) +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_settlement_adjustment` ADD COLUMN `kingdee_bill_no` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''金蝶单据号'' AFTER `kingdee_sync_status`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_settlement_adjustment' + AND COLUMN_NAME = 'kingdee_bill_no' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- 正式结算单:来源原单号(重推生成的新单,接续原单来源) +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_formal_settlement` ADD COLUMN `origin_formal_settlement_no` varchar(100) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''来源原正式结算单号(结算调整重推生成)''', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_formal_settlement' + AND COLUMN_NAME = 'origin_formal_settlement_no' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- 正式结算单:调整标记(被调整的原单) +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_formal_settlement` ADD COLUMN `adjust_status` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''调整状态:adjusted=已被结算调整'' AFTER `origin_formal_settlement_no`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_formal_settlement' + AND COLUMN_NAME = 'adjust_status' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- 正式结算单:来源原单号索引(重推幂等按来源原单号查已克隆新单) +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_formal_settlement` ADD KEY `idx_formal_origin_no` (`origin_formal_settlement_no`)', + 'SELECT 1') + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_formal_settlement' + AND INDEX_NAME = 'idx_formal_origin_no' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- 推送金蝶 / 重新推送已合并为单一按钮(按金蝶状态自动分流),复用既有菜单 settlement_adjustment_repush, +-- 菜单已在 blade_settlement_adjustment_20260818.sql 初始化,本脚本无需新增菜单。 diff --git a/doc/sql/transport/blade_settlement_import_batch_20261015.sql b/doc/sql/transport/blade_settlement_import_batch_20261015.sql new file mode 100644 index 00000000..89c7b872 --- /dev/null +++ b/doc/sql/transport/blade_settlement_import_batch_20261015.sql @@ -0,0 +1,68 @@ +-- 导入结算单批次表 + 菜单 +CREATE TABLE IF NOT EXISTS `blade_settlement_import_batch` ( + `id` bigint NOT NULL COMMENT '主键', + `tenant_id` varchar(12) NOT NULL DEFAULT '000000' COMMENT '租户ID', + `batch_no` varchar(64) NOT NULL COMMENT '批次号 DJS+yyyyMMdd+序号', + `project_id` bigint DEFAULT NULL COMMENT '项目ID', + `project_name` varchar(200) DEFAULT NULL COMMENT '项目名称', + `settlement_type` varchar(30) NOT NULL COMMENT 'receivable/payable', + `settlement_bill_type` varchar(20) NOT NULL DEFAULT 'formal' COMMENT '结算单类型 formal/pre', + `customer_contract_id` bigint DEFAULT NULL COMMENT '客户合同ID', + `customer_contract_name` varchar(200) DEFAULT NULL COMMENT '客户合同名称', + `customer_name` varchar(200) DEFAULT NULL COMMENT '客户名称', + `carrier_contract_id` bigint DEFAULT NULL COMMENT '承运商合同ID', + `carrier_contract_name` varchar(200) DEFAULT NULL COMMENT '承运商合同名称', + `carrier_name` varchar(200) DEFAULT NULL COMMENT '承运商名称', + `waybill_mode` varchar(20) DEFAULT 'new' COMMENT '运单模式 new/history', + `import_status` varchar(32) NOT NULL COMMENT 'draft/completed/voided', + `waybill_count` int DEFAULT 0 COMMENT '运单数量', + `detail_count` int DEFAULT 0 COMMENT '明细数量', + `formal_settlement_id` bigint DEFAULT NULL COMMENT '正式结算单ID', + `formal_settlement_no` varchar(100) DEFAULT NULL COMMENT '正式结算单号', + `pre_settlement_id` bigint DEFAULT NULL COMMENT '预结算单ID', + `pre_settlement_no` varchar(100) DEFAULT NULL COMMENT '预结算单号', + `fee_item_names_json` longtext COMMENT '动态费用列名JSON数组', + `rows_json` longtext COMMENT '导入明细行JSON数组', + `remark` varchar(200) DEFAULT NULL COMMENT '备注', + `waybill_import_batch_id` bigint DEFAULT NULL COMMENT '关联底层运单导入批次,可空', + `create_user` bigint DEFAULT NULL COMMENT '创建人', + `create_dept` bigint DEFAULT NULL COMMENT '创建部门', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_user` bigint DEFAULT NULL COMMENT '更新人', + `update_time` datetime DEFAULT NULL COMMENT '更新时间', + `status` int DEFAULT 1 COMMENT '业务状态', + `is_deleted` int DEFAULT 0 COMMENT '是否已删除', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_settlement_import_batch_no` (`tenant_id`,`batch_no`), + KEY `idx_settlement_import_project` (`project_id`), + KEY `idx_settlement_import_formal` (`formal_settlement_id`), + KEY `idx_settlement_import_pre` (`pre_settlement_id`), + KEY `idx_settlement_import_waybill_batch` (`waybill_import_batch_id`), + KEY `idx_settlement_import_create_time` (`create_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='导入结算单批次'; + +-- 已建表环境增量字段(列已存在可忽略报错) +ALTER TABLE `blade_settlement_import_batch` ADD COLUMN `settlement_bill_type` varchar(20) NOT NULL DEFAULT 'formal' COMMENT '结算单类型 formal/pre' AFTER `settlement_type`; +ALTER TABLE `blade_settlement_import_batch` ADD COLUMN `pre_settlement_id` bigint DEFAULT NULL COMMENT '预结算单ID' AFTER `formal_settlement_no`; +ALTER TABLE `blade_settlement_import_batch` ADD COLUMN `pre_settlement_no` varchar(100) DEFAULT NULL COMMENT '预结算单号' AFTER `pre_settlement_id`; +ALTER TABLE `blade_settlement_import_batch` ADD KEY `idx_settlement_import_pre` (`pre_settlement_id`); +ALTER TABLE `blade_settlement_import_batch` ADD COLUMN `rows_json` longtext COMMENT '导入明细行JSON数组' AFTER `fee_item_names_json`; + +-- 运单关联导入结算批次(可选,若列已存在可忽略报错) +ALTER TABLE `blade_waybill` ADD COLUMN `settlement_import_batch_id` bigint DEFAULT NULL COMMENT '导入结算单批次ID'; +ALTER TABLE `blade_waybill` ADD KEY `idx_waybill_settlement_import_batch_id` (`settlement_import_batch_id`); + +INSERT INTO `blade_menu` (`id`,`parent_id`,`code`,`name`,`alias`,`path`,`source`,`sort`,`category`,`action`,`is_open`,`component`,`remark`,`is_deleted`) VALUES +(2090000000000002080,2090000000000001000,'settlement_import','导入结算单','settlement_import','/settlement/settlement-import','iconfont icon-caidanguanli',6,1,0,1,NULL,'',0), +(2090000000000002081,2090000000000002080,'settlement_import_view','查看','settlement_import_view','','',1,2,0,1,NULL,'',0), +(2090000000000002082,2090000000000002080,'settlement_import_add','新增','settlement_import_add','','',2,2,0,1,NULL,'',0), +(2090000000000002083,2090000000000002080,'settlement_import_detail','详情','settlement_import_detail','','',3,2,0,1,NULL,'',0), +(2090000000000002084,2090000000000002080,'settlement_import_void','作废','settlement_import_void','','',4,2,0,1,NULL,'',0), +(2090000000000002085,2090000000000002080,'settlement_import_template','下载模板','settlement_import_template','','',5,2,0,1,NULL,'',0), +(2090000000000002086,2090000000000002080,'settlement_import_delete','删除','settlement_import_delete','','',6,2,0,1,NULL,'',0) +ON DUPLICATE KEY UPDATE `name`=VALUES(`name`),`path`=VALUES(`path`),`is_deleted`=0; + +-- 已建表环境增量菜单(已存在可忽略) +INSERT INTO `blade_menu` (`id`,`parent_id`,`code`,`name`,`alias`,`path`,`source`,`sort`,`category`,`action`,`is_open`,`component`,`remark`,`is_deleted`) VALUES +(2090000000000002086,2090000000000002080,'settlement_import_delete','删除','settlement_import_delete','','',6,2,0,1,NULL,'',0) +ON DUPLICATE KEY UPDATE `name`=VALUES(`name`),`path`=VALUES(`path`),`is_deleted`=0; diff --git a/doc/sql/transport/blade_settlement_import_batch_alter_20261018.sql b/doc/sql/transport/blade_settlement_import_batch_alter_20261018.sql new file mode 100644 index 00000000..218ed51e --- /dev/null +++ b/doc/sql/transport/blade_settlement_import_batch_alter_20261018.sql @@ -0,0 +1,7 @@ +-- 导入结算单:草稿明细落库 + 删除菜单 +ALTER TABLE `blade_settlement_import_batch` + ADD COLUMN `rows_json` longtext COMMENT '导入明细行JSON数组' AFTER `fee_item_names_json`; + +INSERT INTO `blade_menu` (`id`,`parent_id`,`code`,`name`,`alias`,`path`,`source`,`sort`,`category`,`action`,`is_open`,`component`,`remark`,`is_deleted`) VALUES +(2090000000000002086,2090000000000002080,'settlement_import_delete','删除','settlement_import_delete','','',6,2,0,1,NULL,'',0) +ON DUPLICATE KEY UPDATE `name`=VALUES(`name`),`path`=VALUES(`path`),`is_deleted`=0; diff --git a/doc/sql/transport/blade_settlement_menu_split_20261018.sql b/doc/sql/transport/blade_settlement_menu_split_20261018.sql new file mode 100644 index 00000000..27df1058 --- /dev/null +++ b/doc/sql/transport/blade_settlement_menu_split_20261018.sql @@ -0,0 +1,163 @@ +-- 结算菜单调整: +-- 1)预结算 → 预付结算单 +-- 2)正式结算单拆分为:付款结算单(应付)、收款结算单(应收) +-- 执行后需重新登录;已授权「正式结算单/付款结算单」的角色会同步授权「收款结算单」。 + +-- ① 预结算改名 +UPDATE `blade_menu` +SET `name` = '预付结算单' +WHERE `code` = 'pre_settlement' + AND `is_deleted` = 0; + +-- ② 原正式结算单菜单改为「付款结算单」(保留原菜单 id,角色授权不丢) +UPDATE `blade_menu` +SET `code` = 'payment_settlement', + `name` = '付款结算单', + `alias` = 'payment_settlement', + `path` = '/settlement/payment-settlement', + `is_deleted` = 0 +WHERE `code` IN ('formal_settlement', 'payment_settlement') + AND `category` = 1 + AND `is_deleted` = 0; + +-- 付款结算单按钮权限码:formal_settlement_* → payment_settlement_* +UPDATE `blade_menu` child +INNER JOIN `blade_menu` parent + ON parent.`id` = child.`parent_id` + AND parent.`code` = 'payment_settlement' + AND parent.`is_deleted` = 0 +SET child.`code` = REPLACE(child.`code`, 'formal_settlement_', 'payment_settlement_'), + child.`alias` = REPLACE(IFNULL(child.`alias`, child.`code`), 'formal_settlement_', 'payment_settlement_'), + child.`is_deleted` = 0 +WHERE child.`category` = 2 + AND child.`code` LIKE 'formal_settlement_%'; + +-- ③ 新增「收款结算单」菜单 +INSERT INTO `blade_menu` +(`id`,`parent_id`,`code`,`name`,`alias`,`path`,`source`,`sort`,`category`,`action`,`is_open`,`component`,`remark`,`is_deleted`) +SELECT + 2090000000000002100, + parent.`parent_id`, + 'receipt_settlement', + '收款结算单', + 'receipt_settlement', + '/settlement/receipt-settlement', + IFNULL(parent.`source`, 'iconfont icon-caidanguanli'), + IFNULL(parent.`sort`, 4) + 1, + 1, 0, 1, NULL, '', 0 +FROM `blade_menu` parent +WHERE parent.`code` = 'payment_settlement' + AND parent.`is_deleted` = 0 + AND NOT EXISTS ( + SELECT 1 FROM `blade_menu` x WHERE x.`code` = 'receipt_settlement' AND x.`is_deleted` = 0 + ) +LIMIT 1; + +INSERT INTO `blade_menu` +(`id`,`parent_id`,`code`,`name`,`alias`,`path`,`source`,`sort`,`category`,`action`,`is_open`,`component`,`remark`,`is_deleted`) +SELECT 2090000000000002101, p.id, 'receipt_settlement_view', '查看', 'receipt_settlement_view', '', '', 1, 2, 0, 1, NULL, '', 0 +FROM `blade_menu` p WHERE p.code = 'receipt_settlement' AND p.is_deleted = 0 + AND NOT EXISTS (SELECT 1 FROM `blade_menu` x WHERE x.code = 'receipt_settlement_view' AND x.is_deleted = 0) +LIMIT 1; +INSERT INTO `blade_menu` +(`id`,`parent_id`,`code`,`name`,`alias`,`path`,`source`,`sort`,`category`,`action`,`is_open`,`component`,`remark`,`is_deleted`) +SELECT 2090000000000002102, p.id, 'receipt_settlement_add', '新增', 'receipt_settlement_add', '', '', 2, 2, 0, 1, NULL, '', 0 +FROM `blade_menu` p WHERE p.code = 'receipt_settlement' AND p.is_deleted = 0 + AND NOT EXISTS (SELECT 1 FROM `blade_menu` x WHERE x.code = 'receipt_settlement_add' AND x.is_deleted = 0) +LIMIT 1; +INSERT INTO `blade_menu` +(`id`,`parent_id`,`code`,`name`,`alias`,`path`,`source`,`sort`,`category`,`action`,`is_open`,`component`,`remark`,`is_deleted`) +SELECT 2090000000000002103, p.id, 'receipt_settlement_edit', '编辑', 'receipt_settlement_edit', '', '', 3, 2, 0, 1, NULL, '', 0 +FROM `blade_menu` p WHERE p.code = 'receipt_settlement' AND p.is_deleted = 0 + AND NOT EXISTS (SELECT 1 FROM `blade_menu` x WHERE x.code = 'receipt_settlement_edit' AND x.is_deleted = 0) +LIMIT 1; +INSERT INTO `blade_menu` +(`id`,`parent_id`,`code`,`name`,`alias`,`path`,`source`,`sort`,`category`,`action`,`is_open`,`component`,`remark`,`is_deleted`) +SELECT 2090000000000002104, p.id, 'receipt_settlement_delete', '删除', 'receipt_settlement_delete', '', '', 4, 2, 0, 1, NULL, '', 0 +FROM `blade_menu` p WHERE p.code = 'receipt_settlement' AND p.is_deleted = 0 + AND NOT EXISTS (SELECT 1 FROM `blade_menu` x WHERE x.code = 'receipt_settlement_delete' AND x.is_deleted = 0) +LIMIT 1; +INSERT INTO `blade_menu` +(`id`,`parent_id`,`code`,`name`,`alias`,`path`,`source`,`sort`,`category`,`action`,`is_open`,`component`,`remark`,`is_deleted`) +SELECT 2090000000000002105, p.id, 'receipt_settlement_submit', '提交审批', 'receipt_settlement_submit', '', '', 5, 2, 0, 1, NULL, '', 0 +FROM `blade_menu` p WHERE p.code = 'receipt_settlement' AND p.is_deleted = 0 + AND NOT EXISTS (SELECT 1 FROM `blade_menu` x WHERE x.code = 'receipt_settlement_submit' AND x.is_deleted = 0) +LIMIT 1; +INSERT INTO `blade_menu` +(`id`,`parent_id`,`code`,`name`,`alias`,`path`,`source`,`sort`,`category`,`action`,`is_open`,`component`,`remark`,`is_deleted`) +SELECT 2090000000000002106, p.id, 'receipt_settlement_approve', '审批', 'receipt_settlement_approve', '', '', 6, 2, 0, 1, NULL, '', 0 +FROM `blade_menu` p WHERE p.code = 'receipt_settlement' AND p.is_deleted = 0 + AND NOT EXISTS (SELECT 1 FROM `blade_menu` x WHERE x.code = 'receipt_settlement_approve' AND x.is_deleted = 0) +LIMIT 1; +INSERT INTO `blade_menu` +(`id`,`parent_id`,`code`,`name`,`alias`,`path`,`source`,`sort`,`category`,`action`,`is_open`,`component`,`remark`,`is_deleted`) +SELECT 2090000000000002107, p.id, 'receipt_settlement_sync', '同步金蝶', 'receipt_settlement_sync', '', '', 7, 2, 0, 1, NULL, '', 0 +FROM `blade_menu` p WHERE p.code = 'receipt_settlement' AND p.is_deleted = 0 + AND NOT EXISTS (SELECT 1 FROM `blade_menu` x WHERE x.code = 'receipt_settlement_sync' AND x.is_deleted = 0) +LIMIT 1; +INSERT INTO `blade_menu` +(`id`,`parent_id`,`code`,`name`,`alias`,`path`,`source`,`sort`,`category`,`action`,`is_open`,`component`,`remark`,`is_deleted`) +SELECT 2090000000000002108, p.id, 'receipt_settlement_print', '打印', 'receipt_settlement_print', '', '', 8, 2, 0, 1, NULL, '', 0 +FROM `blade_menu` p WHERE p.code = 'receipt_settlement' AND p.is_deleted = 0 + AND NOT EXISTS (SELECT 1 FROM `blade_menu` x WHERE x.code = 'receipt_settlement_print' AND x.is_deleted = 0) +LIMIT 1; +INSERT INTO `blade_menu` +(`id`,`parent_id`,`code`,`name`,`alias`,`path`,`source`,`sort`,`category`,`action`,`is_open`,`component`,`remark`,`is_deleted`) +SELECT 2090000000000002109, p.id, 'receipt_settlement_export', '导出', 'receipt_settlement_export', '', '', 9, 2, 0, 1, NULL, '', 0 +FROM `blade_menu` p WHERE p.code = 'receipt_settlement' AND p.is_deleted = 0 + AND NOT EXISTS (SELECT 1 FROM `blade_menu` x WHERE x.code = 'receipt_settlement_export' AND x.is_deleted = 0) +LIMIT 1; +INSERT INTO `blade_menu` +(`id`,`parent_id`,`code`,`name`,`alias`,`path`,`source`,`sort`,`category`,`action`,`is_open`,`component`,`remark`,`is_deleted`) +SELECT 2090000000000002110, p.id, 'receipt_settlement_void', '作废', 'receipt_settlement_void', '', '', 10, 2, 0, 1, NULL, '', 0 +FROM `blade_menu` p WHERE p.code = 'receipt_settlement' AND p.is_deleted = 0 + AND NOT EXISTS (SELECT 1 FROM `blade_menu` x WHERE x.code = 'receipt_settlement_void' AND x.is_deleted = 0) +LIMIT 1; +INSERT INTO `blade_menu` +(`id`,`parent_id`,`code`,`name`,`alias`,`path`,`source`,`sort`,`category`,`action`,`is_open`,`component`,`remark`,`is_deleted`) +SELECT 2090000000000002111, p.id, 'receipt_settlement_adjust', '明细调整', 'receipt_settlement_adjust', '', '', 11, 2, 0, 1, NULL, '', 0 +FROM `blade_menu` p WHERE p.code = 'receipt_settlement' AND p.is_deleted = 0 + AND NOT EXISTS (SELECT 1 FROM `blade_menu` x WHERE x.code = 'receipt_settlement_adjust' AND x.is_deleted = 0) +LIMIT 1; + +-- ④ 角色授权:持有付款结算单的角色同步收款结算单(含按钮) +INSERT INTO `blade_role_menu` (`id`, `menu_id`, `role_id`) +SELECT + 2091200000000000000 + CAST(rm.`role_id` AS UNSIGNED) % 100000, + receipt.`id`, + rm.`role_id` +FROM `blade_role_menu` rm +JOIN `blade_menu` payment ON payment.`id` = rm.`menu_id` + AND payment.`code` = 'payment_settlement' + AND payment.`is_deleted` = 0 +JOIN `blade_menu` receipt ON receipt.`code` = 'receipt_settlement' + AND receipt.`is_deleted` = 0 +WHERE NOT EXISTS ( + SELECT 1 FROM `blade_role_menu` x + WHERE x.`menu_id` = receipt.`id` AND x.`role_id` = rm.`role_id` +); + +INSERT INTO `blade_role_menu` (`id`, `menu_id`, `role_id`) +SELECT + 2091300000000000000 + + (CAST(rm.`role_id` AS UNSIGNED) % 100000) * 20 + + (CAST(receipt_btn.`id` AS UNSIGNED) % 20), + receipt_btn.`id`, + rm.`role_id` +FROM `blade_role_menu` rm +JOIN `blade_menu` payment_btn ON payment_btn.`id` = rm.`menu_id` + AND payment_btn.`code` LIKE 'payment_settlement_%' + AND payment_btn.`is_deleted` = 0 +JOIN `blade_menu` receipt_btn + ON receipt_btn.`code` = REPLACE(payment_btn.`code`, 'payment_settlement_', 'receipt_settlement_') + AND receipt_btn.`is_deleted` = 0 +WHERE NOT EXISTS ( + SELECT 1 FROM `blade_role_menu` x + WHERE x.`menu_id` = receipt_btn.`id` AND x.`role_id` = rm.`role_id` +); + +-- ⑤ 残留旧正式结算菜单隐藏 +UPDATE `blade_menu` +SET `is_deleted` = 1 +WHERE `code` = 'formal_settlement' + AND `category` = 1; diff --git a/doc/sql/transport/blade_settlement_project_advance_write_off_20261016.sql b/doc/sql/transport/blade_settlement_project_advance_write_off_20261016.sql new file mode 100644 index 00000000..7aba7877 --- /dev/null +++ b/doc/sql/transport/blade_settlement_project_advance_write_off_20261016.sql @@ -0,0 +1,23 @@ +-- 结算单项目预付核销明细 +CREATE TABLE IF NOT EXISTS `blade_settlement_project_advance_write_off` ( + `id` bigint NOT NULL COMMENT '主键', + `tenant_id` varchar(12) NOT NULL DEFAULT '000000' COMMENT '租户ID', + `settlement_type` varchar(20) NOT NULL COMMENT '结算单类型 pre/formal', + `settlement_id` bigint NOT NULL COMMENT '预结算/正式结算单ID', + `payment_application_id` bigint NOT NULL COMMENT '项目预付付款申请ID', + `payment_no` varchar(64) DEFAULT NULL COMMENT '预付单号', + `project_advance_amount` decimal(18,2) DEFAULT 0.00 COMMENT '项目预付金额', + `write_off_amount` decimal(18,2) NOT NULL DEFAULT 0.00 COMMENT '本次核销金额', + `kingdee_bill_no` varchar(100) DEFAULT NULL COMMENT '金蝶预付单号', + `create_user` bigint DEFAULT NULL COMMENT '创建人', + `create_dept` bigint DEFAULT NULL COMMENT '创建部门', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_user` bigint DEFAULT NULL COMMENT '更新人', + `update_time` datetime DEFAULT NULL COMMENT '更新时间', + `status` int DEFAULT 1 COMMENT '业务状态', + `is_deleted` int DEFAULT 0 COMMENT '是否已删除', + PRIMARY KEY (`id`), + KEY `idx_spa_write_off_settlement` (`settlement_type`,`settlement_id`), + KEY `idx_spa_write_off_payment` (`payment_application_id`), + KEY `idx_spa_write_off_create_time` (`create_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='结算单项目预付核销'; diff --git a/doc/sql/transport/blade_user_dept_index_20261019.sql b/doc/sql/transport/blade_user_dept_index_20261019.sql new file mode 100644 index 00000000..5ac5e8c7 --- /dev/null +++ b/doc/sql/transport/blade_user_dept_index_20261019.sql @@ -0,0 +1,6 @@ +-- 用户部门关联表索引:加速人员列表按组织树过滤 +-- 部署时在业务库执行本脚本 + +ALTER TABLE `blade_user_dept` + ADD INDEX `idx_blade_user_dept_user` (`user_id`, `is_deleted`, `status`) USING BTREE, + ADD INDEX `idx_blade_user_dept_dept` (`dept_id`, `is_deleted`, `status`) USING BTREE; diff --git a/doc/sql/transport/blade_user_oa_login_id_20261019.sql b/doc/sql/transport/blade_user_oa_login_id_20261019.sql new file mode 100644 index 00000000..21636521 --- /dev/null +++ b/doc/sql/transport/blade_user_oa_login_id_20261019.sql @@ -0,0 +1,18 @@ +-- 用户表:新增 OA 登录名,支持用户管理后台维护 +-- 排序规则 utf8mb4_general_ci + +ALTER TABLE `blade_user` + ADD COLUMN `oa_login_id` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT 'OA登录名' AFTER `is_oa`; + +-- 从用户部门关联表回填已有 OA 登录账号 +UPDATE `blade_user` bu +INNER JOIN ( + SELECT `user_id`, MAX(`oa_login_id`) AS `oa_login_id` + FROM `blade_user_dept` + WHERE `is_deleted` = 0 + AND `oa_login_id` IS NOT NULL + AND `oa_login_id` <> '' + GROUP BY `user_id` +) bud ON bud.`user_id` = bu.`id` +SET bu.`oa_login_id` = bud.`oa_login_id` +WHERE bu.`oa_login_id` IS NULL OR bu.`oa_login_id` = ''; diff --git a/doc/sql/transport/blade_waybill_dept_backfill_20261018.sql b/doc/sql/transport/blade_waybill_dept_backfill_20261018.sql new file mode 100644 index 00000000..f1a9eaf4 --- /dev/null +++ b/doc/sql/transport/blade_waybill_dept_backfill_20261018.sql @@ -0,0 +1,61 @@ +-- ---------------------------- +-- 调度端运单列表按组织过滤:dept_id 缺失的存量运单订正回它所属的组织 +-- 写业务数据。执行前必须先建备份表(第 0 节),执行后必须跑第 2 节校验。可重复执行。 +-- ---------------------------- +-- +-- 为什么要订正: +-- 调度端(小程序管理端)运单列表的可见范围是「本组织」,超管看全租户。 +-- dept_id 为 NULL 或 -1 的运单不属于任何组织,普通用户再也看不到它们。 +-- 本机库实测 42 张:34 张的 dept_id = -1、8 张为 NULL,全部是 2026-10-15 / 10-16 +-- 由 admin 在 1123598813738675201 下建的(-1 是客户端把「未选组织」当 -1 传上来的哨兵值, +-- 服务端写入前的兜底只判空、拦不住负数)。 +-- +-- 处置口径:取该运单的 create_dept(创建人所属组织)作为它的组织。 +-- 42 张的 create_dept 都是 1123598813738675201,与它们的业务归属一致; +-- 按 create_dept 取而不是写死这个 id,其它环境执行时同样成立。 +-- dept_name 取组织表当前的名字:新建运单写的就是这个名字,不跟随存量行里的历史短名 +-- (「桂物物流集团」这类短名是历史写法,本次只补缺失值,不复位已存在的值)。 +-- 只动 dept_id 为空或非正数的行,且要求 create_dept 为正数:重复执行不再改动任何行。 +-- update_time 不在 SET 里,保留原值。 +-- ---------------------------- +-- 0. 备份(必须在订正之前执行,仅首次需要) +-- ---------------------------- +-- CREATE TABLE blade_waybill_dept_bak AS +-- SELECT id, waybill_no, dept_id, dept_name, create_dept, update_time +-- FROM blade_waybill +-- WHERE is_deleted = 0 AND (dept_id IS NULL OR dept_id <= 0); + +START TRANSACTION; + +-- ---------------------------- +-- 1. 订正组织 +-- ---------------------------- +UPDATE `blade_waybill` +SET `dept_id` = `create_dept`, + `dept_name` = (SELECT `dept_name` FROM `blade_dept` WHERE `id` = `blade_waybill`.`create_dept` LIMIT 1) +WHERE `is_deleted` = 0 + AND (`dept_id` IS NULL OR `dept_id` <= 0) + AND `create_dept` IS NOT NULL + AND `create_dept` > 0; + +COMMIT; + +-- ---------------------------- +-- 2. 校验(执行后必看,两个都必须返回 0) +-- ---------------------------- +-- -- 仍不属于任何组织的可见运单 +-- SELECT COUNT(*) FROM blade_waybill +-- WHERE is_deleted = 0 AND (dept_id IS NULL OR dept_id <= 0) AND create_dept > 0; +-- +-- -- 本次订正过的行,组织 ID 与组织名都必须跟组织表一致 +-- SELECT COUNT(*) FROM blade_waybill w +-- JOIN blade_waybill_dept_bak b ON b.id = w.id +-- JOIN blade_dept d ON d.id = w.dept_id +-- WHERE w.dept_id <> w.create_dept OR w.dept_name <> d.dept_name; + +-- ---------------------------- +-- 3. 回退(订正后发现问题时执行) +-- ---------------------------- +-- UPDATE blade_waybill w +-- JOIN blade_waybill_dept_bak b ON b.id = w.id +-- SET w.dept_id = b.dept_id, w.dept_name = b.dept_name; diff --git a/doc/sql/transport/transport.sql b/doc/sql/transport/transport.sql index c2bc3826..b8152384 100644 --- a/doc/sql/transport/transport.sql +++ b/doc/sql/transport/transport.sql @@ -5487,7 +5487,9 @@ CREATE TABLE `blade_user_dept` ( `oa_id` varchar(64) DEFAULT NULL COMMENT 'OA人员ID', `oa_login_id` varchar(128) DEFAULT NULL COMMENT 'OA登录账号', `sync_time` datetime DEFAULT NULL COMMENT '同步时间', - PRIMARY KEY (`id`) USING BTREE + PRIMARY KEY (`id`) USING BTREE, + INDEX `idx_blade_user_dept_user` (`user_id`, `is_deleted`, `status`) USING BTREE, + INDEX `idx_blade_user_dept_dept` (`dept_id`, `is_deleted`, `status`) USING BTREE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户部门表'; -- ---------------------------- diff --git a/pom.xml b/pom.xml index 650240cc..f9fc2e3d 100644 --- a/pom.xml +++ b/pom.xml @@ -18,6 +18,9 @@ UTF-8 UTF-8 + + true + admin blade