diff --git a/.gitignore b/.gitignore index fd4d5e63..679f73ec 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,7 @@ Thumbs.db .claude /outputs/ /.codex-work/ +.serena/ +.workbuddy/ +/data/ +/output/ diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 00000000..83387607 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,82 @@ +# TMS(运输业务中台)—— 业财集成语境 + +TMS 与集团财务系统(金蝶星瀚)、合同合规系统(HCS)集成,单据在三个系统间流转。 +本表只收录集成语境下易混淆的业务术语,不含实现细节。 + +## Language + +**合同合规系统(HCS)**: +集团合同合规管控系统(桂物数字合同合规),接收 TMS 推送的项目、合同、履约与付款数据并做合规审批。 +_Avoid_: 桂物系统、合同系统、数字合同系统 + +**相对方**: +HCS 对签约主体(甲乙方)的称谓,与 TMS 的"客商"同指一个外部单位。 +_Avoid_: 在 HCS 集成语境用"客户/供应商" + +**来源单据键**: +TMS 单据在 HCS 侧的对账主键(接口字段 `fd_kingdee_id`,字段名沿用金蝶),统一存 TMS 业务单据主键。 +_Avoid_: 外部ID、kingdeeId(泛指金蝶单据号时易混淆) + +**来源标识**: +TMS 推送给 HCS 时声明自身身份的常量(接口字段 `fd_sources`,约定值 "TMS")。 +_Avoid_: XH(那是星瀚侧的来源标识) + +**履约规则**: +合同维度的资金计划阶段(付款/收款、款项性质、计划金额、计划日期、开票规则),由 TMS 维护并同步到 HCS。 +_Avoid_: 履约计划(旧版已废弃接口的叫法)、付款计划 + +**履约阶段**: +履约规则表中的一行;HCS 为其生成阶段ID,后续更新/删除必须凭该ID。 +_Avoid_: 阶段项、plan 行 + +**履约记录**: +付款/收款执行事实在 HCS 的登记(一笔付款申请一条记录,随实际付款推进更新),与履约规则是"计划 vs 执行"的关系。 +_Avoid_: 履约流水 + +**额度校验**: +付款提交时由 HCS 判定申请金额是否超出合同/项目剩余额度的同步检查;超额不阻断提交,转入特批链路。 +_Avoid_: 余额检查 + +**特批流程**: +额度超额(或风险不通过)时在 HCS 发起的审批流程;TMS 持有其流程ID,流程结束由 HCS 经 B103 回传结果。 +_Avoid_: 特殊审批、绿色通道 + +**特批放行**: +特批结果为"通过"后对**本次**付款申请解除推送拦截的动作;只改特批状态,不触发推送,单次有效。 +_Avoid_: 特批通过自动付款、放行推送 + +**锁单(B901)**: +金蝶确认接收 TMS 推送的单据后,经 B901 通知 TMS 将本地源单据置为不可编辑、不可作废、不可重推的状态;金蝶反审核及删除后经 B901 解锁。 +_Avoid_: 冻结、金蝶锁定(那是金蝶侧单据自己的状态) + +**解锁**: +B901 通知金蝶侧单据已反审核及删除后,TMS 解除锁单、同步状态置失败、清金蝶单号留痕;不自动重推,人工核实后重新推送。 +_Avoid_: 反审核(那是金蝶侧的动作)、自动重推 + +**合规附件ID**: +TMS 附件先上传 HCS 附件服务换取的 fdId;同一附件以 TMS 附件元数据为键缓存映射,不重复上传。 +_Avoid_: 附件fdId(指代不明) + +**同步状态**: +TMS 单据对 HCS 的推送生命周期(待同步/已同步/失败),权威状态在 TMS 侧字段,HCS 不回写业务状态。 +_Avoid_: 推送标志 + +**二次认领**: +金蝶司库完成第一轮自动认领(客户维度)后,TMS 把收款流水认领到合同/结算单维度的动作;只在 TMS 本地登记,本身不产生金蝶收款单。 +_Avoid_: 收款认领(泛指整条链路时易与金蝶第一轮自动认领混淆) + +**公告未认领金额**: +金蝶认领公告(A301-2)上尚未被认领的金额(字段 unclaimamount),是 TMS 二次认领的上限和"认领完成"的判定基准;以金蝶侧为准,推送前须重查最新值。 +_Avoid_: 剩余可认领金额(那是本地展示口径)、未认领金额(不指明公告侧/本地侧时指代不明) + +**认领完成**: +一条收款流水的累计有效认领金额等于公告未认领金额的状态;只有认领完成的流水才满足推送金蝶的条件,不认领完就不推。 +_Avoid_: 认领完毕、全部认领(未指明对照基准) + +**认领状态**: +收款流水本地认领进度(未认领/部分认领/认领完成),对照公告未认领金额计算;作废的认领分摊不计入累计。 +_Avoid_: 与推送状态混用 + +**推送状态**: +收款流水对金蝶 A301-3 的推送生命周期(未推送/已推送/推送失败),权威在流水级,认领主单仅冗余展示;已推送的流水不可二次推送。 +_Avoid_: 金蝶状态(那是 A301-1 回写的单据生命周期:已推送/审核通过/已收款) 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-common/src/main/java/org/springblade/common/config/BladeCommonConfiguration.java b/blade-common/src/main/java/org/springblade/common/config/BladeCommonConfiguration.java index f5e9e342..ac8f3c43 100644 --- a/blade-common/src/main/java/org/springblade/common/config/BladeCommonConfiguration.java +++ b/blade-common/src/main/java/org/springblade/common/config/BladeCommonConfiguration.java @@ -32,6 +32,13 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; import org.springframework.context.annotation.Bean; +import feign.Logger; +import lombok.AllArgsConstructor; +import org.springblade.common.feign.CustomFeignLogger; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; /** * 公共封装包配置类 @@ -49,4 +56,11 @@ public class BladeCommonConfiguration { return new OperationApiLogAspect(); } + @Bean + @Primary + @ConditionalOnClass(name = "feign.Logger") + public Logger feignLogger() { + return new CustomFeignLogger(); + } + } diff --git a/blade-common/src/main/java/org/springblade/common/feign/CustomFeignLogger.java b/blade-common/src/main/java/org/springblade/common/feign/CustomFeignLogger.java new file mode 100644 index 00000000..fd2c459c --- /dev/null +++ b/blade-common/src/main/java/org/springblade/common/feign/CustomFeignLogger.java @@ -0,0 +1,242 @@ +/** + * 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.common.feign; + +import feign.Logger; +import feign.Request; +import feign.Response; +import feign.Util; +import lombok.extern.slf4j.Slf4j; + +import java.io.IOException; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 自定义Feign日志记录器,只记录出入参信息 + * + * @author Chill + */ +@Slf4j +public class CustomFeignLogger extends Logger { + + private static final String BINARY_CONTENT_TYPE_PREFIX = "application/octet-stream"; + private static final String IMAGE_CONTENT_TYPE_PREFIX = "image/"; + private static final String AUDIO_CONTENT_TYPE_PREFIX = "audio/"; + private static final String VIDEO_CONTENT_TYPE_PREFIX = "video/"; + private static final String MULTIPART_CONTENT_TYPE_PREFIX = "multipart/"; + + @Override + protected void log(String configKey, String format, Object... args) { + // 不处理默认日志格式 + } + + @Override + protected void logRequest(String configKey, Level logLevel, Request request) { + String url = request.url(); + String method = request.httpMethod().name(); + Map> headers = request.headers(); + byte[] body = request.body(); + + String contentType = getContentType(headers); + String bodyStr; + if (body == null || body.length == 0) { + bodyStr = "[无]"; + } else if (isMultipartContent(contentType)) { + bodyStr = parseMultipartBody(body, contentType); + } else if (isBinaryContent(contentType)) { + bodyStr = "[二进制数据,长度=" + body.length + "字节]"; + } else { + bodyStr = new String(body, StandardCharsets.UTF_8); + } + + String queryParams = extractQueryParams(url); + if ("GET".equalsIgnoreCase(method) && !queryParams.isEmpty()) { + log.info("===> [{}] {} {} | Headers: {} | Params: {} | Body: {}", configKey, method, url, headers, queryParams, flattenBody(bodyStr)); + } else { + log.info("===> [{}] {} {} | Headers: {} | Body: {}", configKey, method, url, headers, flattenBody(bodyStr)); + } + } + + private String extractQueryParams(String url) { + int queryStart = url.indexOf('?'); + if (queryStart < 0 || queryStart >= url.length() - 1) { + return ""; + } + String queryString = url.substring(queryStart + 1); + Map params = new LinkedHashMap<>(); + for (String pair : queryString.split("&")) { + int eqPos = pair.indexOf('='); + if (eqPos > 0) { + String key = URLDecoder.decode(pair.substring(0, eqPos), StandardCharsets.UTF_8); + String value = URLDecoder.decode(pair.substring(eqPos + 1), StandardCharsets.UTF_8); + params.put(key, value); + } else if (!pair.isEmpty()) { + params.put(URLDecoder.decode(pair, StandardCharsets.UTF_8), ""); + } + } + return params.toString(); + } + + @Override + protected Response logAndRebufferResponse(String configKey, Level logLevel, Response response, long millis) throws IOException { + String url = response.request().url(); + int status = response.status(); + Map> headers = response.headers(); + + if (response.body() == null) { + log.info("<==== [{}] {} | Status: {} | Duration: {}ms | Body: [空响应]", configKey, url, status, millis); + return response; + } + + byte[] bodyData = Util.toByteArray(response.body().asInputStream()); + String bodyStr; + if (bodyData.length == 0) { + bodyStr = "[空响应]"; + } else if (isBinaryContent(getContentType(headers))) { + bodyStr = "[二进制数据]"; + } else { + bodyStr = new String(bodyData, StandardCharsets.UTF_8); + } + + log.info("<==== [{}] {} | Status: {} | Duration: {}ms | Body: {}", configKey, url, status, millis, flattenBody(bodyStr)); + + return response.toBuilder().body(bodyData).build(); + } + + /** + * 将报文内容压缩为单行显示(换行、回车、制表符等空白序列替换为单个空格) + */ + private String flattenBody(String bodyStr) { + if (bodyStr == null) { + return null; + } + return bodyStr.replaceAll("\\s+", " ").trim(); + } + + /** + * 获取Content-Type + */ + private String getContentType(Map> headers) { + Collection contentTypes = headers.get("Content-Type"); + if (contentTypes != null && !contentTypes.isEmpty()) { + return contentTypes.iterator().next(); + } + return null; + } + + /** + * 判断是否为二进制内容 + */ + private boolean isBinaryContent(String contentType) { + if (contentType == null || contentType.isEmpty()) { + return false; + } + String lowerContentType = contentType.toLowerCase(); + return lowerContentType.startsWith(BINARY_CONTENT_TYPE_PREFIX) + || lowerContentType.startsWith(IMAGE_CONTENT_TYPE_PREFIX) + || lowerContentType.startsWith(AUDIO_CONTENT_TYPE_PREFIX) + || lowerContentType.startsWith(VIDEO_CONTENT_TYPE_PREFIX); + } + + /** + * 判断是否为multipart内容 + */ + private boolean isMultipartContent(String contentType) { + return contentType != null && contentType.toLowerCase().startsWith(MULTIPART_CONTENT_TYPE_PREFIX); + } + + /** + * 解析multipart请求体,文件部分用占位符替代,文本字段保留打印 + */ + private String parseMultipartBody(byte[] body, String contentType) { + String boundary = extractBoundary(contentType); + if (boundary == null) { + return "[multipart数据,长度=" + body.length + "字节]"; + } + String bodyStr = new String(body, StandardCharsets.UTF_8); + String delimiter = "--" + boundary; + String[] parts = bodyStr.split(delimiter); + + List fields = new ArrayList<>(); + for (String part : parts) { + if (part.isEmpty() || part.startsWith("--") || part.trim().isEmpty()) { + continue; + } + int headerEnd = part.indexOf("\r\n\r\n"); + if (headerEnd < 0) { + continue; + } + String headers = part.substring(0, headerEnd); + String value = part.substring(headerEnd + 4); + if (value.endsWith("\r\n")) { + value = value.substring(0, value.length() - 2); + } + + String name = extractParam(headers, "name"); + + if (headers.contains("filename=")) { + String filename = extractParam(headers, "filename"); + fields.add(name + "=[文件: " + filename + "]"); + } else { + fields.add(name + "=" + value); + } + } + return fields.isEmpty() ? "[multipart数据]" : "{" + String.join(", ", fields) + "}"; + } + + /** + * 从Content-Type中提取boundary + */ + private String extractBoundary(String contentType) { + for (String param : contentType.split(";")) { + param = param.trim(); + if (param.toLowerCase().startsWith("boundary=")) { + return param.substring("boundary=".length()).trim(); + } + } + return null; + } + + /** + * 从headers中提取参数值(如name、filename) + */ + private String extractParam(String headers, String paramName) { + Pattern pattern = Pattern.compile(paramName + "=\"([^\"]*)\""); + Matcher matcher = pattern.matcher(headers); + if (matcher.find()) { + return matcher.group(1); + } + return paramName; + } +} 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 77eeef8d..c1ebf5b5 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 @@ -73,7 +73,9 @@ public interface ISysClient { String REGION = API_PREFIX + "/region"; String FEE_ITEMS = API_PREFIX + "/fee-items"; String CARGO_TYPES = API_PREFIX + "/cargo-types"; + String MEASUREMENT_UNITS = API_PREFIX + "/measurement-units"; String PERMISSIONS = API_PREFIX + "/permissions"; + String INVOICE_ITEMS = API_PREFIX + "/invoice-items"; /** * 获取菜单 @@ -317,6 +319,14 @@ public interface ISysClient { @GetMapping(CARGO_TYPES) R> getCargoTypes(); + /** + * 获取计量单位档案(金蝶推送按计费单位名称匹配取计量单位编码) + * + * @return 计量单位集合 + */ + @GetMapping(MEASUREMENT_UNITS) + R> getMeasurementUnits(); + /** * 获取角色权限标识集合(按钮编号) * @@ -326,4 +336,12 @@ public interface ISysClient { @GetMapping(PERMISSIONS) R> getPermissions(@RequestParam("roleId") String roleId); + /** + * 获取启用的开票项目(含税收分类编码,开票申请推送金蝶按分类+简称匹配取数) + * + * @return 开票项目集合 + */ + @GetMapping(INVOICE_ITEMS) + R> getInvoiceItems(); + } 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 4825a4da..b7704b68 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 @@ -174,9 +174,19 @@ public class ISysClientFallback implements ISysClient { return R.fail("获取数据失败"); } + @Override + public R> getMeasurementUnits() { + return R.fail("获取数据失败"); + } + @Override public R> getPermissions(String roleId) { return R.fail("获取数据失败"); } + @Override + public R> getInvoiceItems() { + return R.fail("获取数据失败"); + } + } diff --git a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/enums/DeptCategory.java b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/enums/DeptCategory.java index cf21c31b..86e522e3 100644 --- a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/enums/DeptCategory.java +++ b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/enums/DeptCategory.java @@ -4,29 +4,34 @@ import lombok.AllArgsConstructor; import lombok.Getter; /** - * 部门类型枚举 + * 组织类型枚举,与业务字典 organization_type 对齐 + * * @author bfhuange * @date 2024/8/26 */ @AllArgsConstructor @Getter public enum DeptCategory { - /** - * 公司 - */ - COMPANY(1, "公司"), - /** - * 部门 - */ - DEPT(2, "部门"), - ; + /** + * 集团 + */ + GROUP(1, "集团"), + /** + * 公司 + */ + COMPANY(2, "公司"), + /** + * 部门 + */ + DEPT(3, "部门"), + ; - /** - * 部门类型编码 - */ - private final Integer code; - /** - * 部门类型名称 - */ - private final String name; + /** + * 组织类型编码 + */ + private final Integer code; + /** + * 组织类型名称 + */ + private final String name; } 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..bd1bd7a4 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,7 @@ public class OaOrgSyncPageVO implements Serializable { @Schema(description = "增量同步使用的 OA modified 起始时间,后续分页需原样回传") private String modifiedSince; + + @Schema(description = "全量同步结束后原始数据层级 JSON 文件路径") + private String dumpFilePath; } 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/InvoiceApplicationSaveRequest.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/InvoiceApplicationSaveRequest.java index 207879ca..bd0130f7 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/InvoiceApplicationSaveRequest.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/InvoiceApplicationSaveRequest.java @@ -71,6 +71,7 @@ public class InvoiceApplicationSaveRequest implements Serializable { @Serial private static final long serialVersionUID = 1L; private String goodsCategory; private String goodsName; + private String taxClassificationCode; private String unit; private BigDecimal quantity; private BigDecimal unitPriceNoTax; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/InvoiceReceiptSaveRequest.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/InvoiceReceiptSaveRequest.java index 5f73b6c9..f7b4c29b 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/InvoiceReceiptSaveRequest.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/InvoiceReceiptSaveRequest.java @@ -42,7 +42,6 @@ import java.util.List; public class InvoiceReceiptSaveRequest implements Serializable { @Serial private static final long serialVersionUID = 1L; private Long id; - private Long kingdeeInvoicePoolId; private String invoiceNo; private LocalDate invoiceDate; private String invoiceType; @@ -54,8 +53,6 @@ public class InvoiceReceiptSaveRequest implements Serializable { private String bankName; private String bankAccount; private String issuingBank; - private String kingdeeBillNo; - private String kingdeeStatus; private String phone; private String customerEmails; private String departmentEmails; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/KingdeeInvoicePoolQueryDTO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/KingdeeInvoicePoolQueryDTO.java new file mode 100644 index 00000000..4e726481 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/KingdeeInvoicePoolQueryDTO.java @@ -0,0 +1,116 @@ +/** + * 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. + *

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

+ * 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 the License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +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 KingdeeInvoicePoolQueryDTO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 当前页 + */ + @Schema(description = "当前页") + private Long current = 1L; + + /** + * 每页条数 + */ + @Schema(description = "每页条数") + private Long size = 10L; + + /** + * 发票号码,模糊 + */ + @Schema(description = "发票号码(模糊)") + private String invoiceNo; + + /** + * 开票日期起 yyyy-MM-dd + */ + @Schema(description = "开票日期起 yyyy-MM-dd") + private String invoiceDateStart; + + /** + * 开票日期止 yyyy-MM-dd + */ + @Schema(description = "开票日期止 yyyy-MM-dd") + private String invoiceDateEnd; + + /** + * 开票单位(销方名称),模糊 + */ + @Schema(description = "开票单位(销方名称,模糊)") + private String salerName; + + /** + * 受票单位(购方名称),模糊 + */ + @Schema(description = "受票单位(购方名称,模糊)") + private String buyerName; + + /** + * 发票类型编码(票易通字典,如 26/27,镜像按 invoice_type_code 精确匹配) + */ + @Schema(description = "发票类型编码(票易通字典,如 26/27)") + private String invoiceType; + + /** + * 销方税号,精确 + */ + @Schema(description = "销方税号") + private String salerTaxNo; + + /** + * 购方税号,精确 + */ + @Schema(description = "购方税号") + private String buyerTaxNo; + + /** + * 认领过滤:true 仅已认领,false 仅未认领,null 不过滤(已认领=存在非作废正式结算单发票行) + */ + @Schema(description = "认领过滤:true已认领/false未认领/null不限") + private Boolean claimed; + + /** + * 登记过滤:true 仅已登记,false 仅未登记,null 不过滤(已登记=存在非作废收票登记) + */ + @Schema(description = "登记过滤:true已登记/false未登记/null不限") + private Boolean registered; +} 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/ReceiptClaimPushResult.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ReceiptClaimPushResult.java new file mode 100644 index 00000000..dd27a8d9 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ReceiptClaimPushResult.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. + *

+ * 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 + * is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for their own commercial purposes. + *

+ * Using this software signifies agreement to this license agreement and terms of use, + * and you expressly agree to be bound by the terms of use of this software. + *

+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED + * WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS + * FOR A PARTICULAR PURPOSE ARE DISCLAIMED. + *

+ * 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; + +/** + * 收款认领推送金蝶结果(A301-3) + *

+ * 推送失败不抛异常跨事务边界(保证失败状态落库不被回滚),以本结果对象向调用方报告成败与原因。 + * + * @author Chill + */ +@Data +@Schema(description = "收款认领推送金蝶结果") +public class ReceiptClaimPushResult implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "是否推送成功") + private boolean success; + + @Schema(description = "金蝶收款单号(成功时返回,可能为空:金蝶响应未返回单号)") + private String kingdeeBillNo; + + @Schema(description = "失败原因(成功时为空)") + private String message; + + public static ReceiptClaimPushResult success(String kingdeeBillNo) { + ReceiptClaimPushResult result = new ReceiptClaimPushResult(); + result.setSuccess(true); + result.setKingdeeBillNo(kingdeeBillNo); + return result; + } + + public static ReceiptClaimPushResult fail(String message) { + ReceiptClaimPushResult result = new ReceiptClaimPushResult(); + result.setSuccess(false); + result.setMessage(message); + return result; + } +} 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/CustomerArchive.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/CustomerArchive.java index fa780bbf..5ea8bec1 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/CustomerArchive.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/CustomerArchive.java @@ -161,4 +161,13 @@ public class CustomerArchive extends TenantEntity { @Schema(description = "审核通过时间") private LocalDateTime approvedTime; + @Schema(description = "金蝶协同状态:none未推送/exist金蝶已有/synced已推送/failed推送失败") + private String kingdeeStatus; + + @Schema(description = "金蝶推送失败原因") + private String kingdeeFailReason; + + @Schema(description = "金蝶推送时间") + private LocalDateTime kingdeeSyncTime; + } 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 107b7a82..0a03304b 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 @@ -21,6 +21,8 @@ import java.time.LocalDateTime; /** * 正式结算单实体类 + *

+ * 结算单主表,明细快照见 {@link FormalSettlementDetail},合计费用见 {@link FormalSettlementSummaryFee} * * @author Chill */ @@ -30,38 +32,144 @@ import java.time.LocalDateTime; @Schema(description = "正式结算单") public class FormalSettlement extends TenantEntity { @Serial private static final long serialVersionUID = 1L; + /** + * 正式结算单号(同租户唯一) + */ private String formalSettlementNo; + /** + * 来源类型:预结算合并/应收应付/混合来源 + */ private String sourceType; + /** + * 结算类型:receivable-应收 payable-应付 + */ private String settlementType; + /** + * 所属项目ID + */ private Long projectId; + /** + * 所属项目名称 + */ private String projectName; + /** + * 所属组织ID + */ private Long deptId; + /** + * 所属组织名称 + */ private String deptName; + /** + * 结算合同ID + */ private Long contractId; + /** + * 结算合同编号 + */ private String contractNo; + /** + * 结算合同名称 + */ private String contractName; + /** + * 付款方名称 + */ private String payerName; + /** + * 收款方名称 + */ private String payeeName; + /** + * 结算币种,默认 RMB + */ private String currency; + /** + * 结算金额(结算币种,含全部合计费用) + */ private BigDecimal settlementAmount; + /** + * 本位币,默认 RMB + */ private String localCurrency; + /** + * 本位币结算金额 = 结算金额 × 结算汇率 + */ private BigDecimal localSettlementAmount; + /** + * 已申请付款金额,含来源预结算的预付 + */ private BigDecimal appliedPaymentAmount; + /** + * 已收/已付金额合计,含来源预结算的预付 + */ private BigDecimal paidAmount; + /** + * 剩余可付金额 = 结算金额 - 已收已付合计,最小为 0 + */ private BigDecimal remainingPayableAmount; + /** + * 已开票/已收票金额,取开票申请与收票登记的分摊合计 + */ private BigDecimal invoiceAmount; + /** + * 汇率日期 + */ private LocalDate exchangeRateDate; + /** + * 结算汇率 + */ private BigDecimal exchangeRate; + /** + * 开票状态(应收为开票、应付为收票):unreceived-未开票 partial-部分开票 completed-已开票 + */ private String invoiceStatus; + /** + * 收付款状态:unpaid-未收付款 partial-部分收付款 paid-已收付款 + */ private String paymentStatus; + /** + * 审核状态:draft-草稿 reviewing-审批中 approved-审批通过 returned-已驳回 voided-已作废 + */ private String approvalStatus; + /** + * 当前审批节点,如「草稿」「财务审核」「审批通过」 + */ private String currentNode; + /** + * 当前处理人姓名 + */ private String currentProcessor; + /** + * 金蝶单据编号 + */ private String kingdeeBillNo; + /** + * 金蝶单据id(星瀚内部主键,A902 附件上传定位用) + */ + private String kingdeeBillId; + /** + * 金蝶同步状态:unsynced-未同步 synced-已同步 failed-同步失败 + */ private String kingdeeSyncStatus; + /** + * 附件JSON + */ private String attachmentsJson; + /** + * 备注 + */ private String remark; + /** + * 审核通过时间 + */ private LocalDateTime approvedTime; + /** + * 同步金蝶时间 + */ private LocalDateTime syncedTime; + /** + * 作废原因,驳回时记录驳回原因 + */ private String voidReason; } 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 bf3cda88..899dffe1 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 @@ -27,37 +27,136 @@ import java.time.LocalDateTime; @TableName("blade_formal_settlement_detail") public class FormalSettlementDetail extends TenantEntity { @Serial private static final long serialVersionUID = 1L; + /** + * 正式结算单ID + */ private Long formalSettlementId; + /** + * 来源预结算单ID + */ private Long sourcePreSettlementId; + /** + * 来源预结算明细ID + */ private Long sourcePreSettlementDetailId; + /** + * 来源应收应付明细ID + */ private Long sourceDetailId; + /** + * 行号 + */ private Integer lineNo; + /** + * 单据编号 + */ private String documentNo; + /** + * 运单ID + */ private Long waybillId; + /** + * 运单号 + */ private String waybillNo; + /** + * 车牌号 + */ private String vehicleNo; + /** + * 发货地址 + */ private String departureAddress; + /** + * 收货地址 + */ private String arrivalAddress; + /** + * 发货联系人 + */ private String departureContact; + /** + * 发货联系方式 + */ private String departurePhone; + /** + * 收货联系人 + */ private String arrivalContact; + /** + * 收货联系方式 + */ private String arrivalPhone; + /** + * 实际发货时间 + */ private LocalDateTime actualDepartureTime; + /** + * 实际完成时间 + */ private LocalDateTime actualCompletionTime; + /** + * 运输类型 + */ private String transportType; + /** + * 货物名称 + */ private String cargoName; + /** + * 货物类型 + */ private String cargoType; + /** + * 运输总量 + */ private BigDecimal transportQuantity; + /** + * 数量单位 + */ private String quantityUnit; + /** + * 里程 + */ private BigDecimal mileage; + /** + * 批次号 + */ private String batchNo; + /** + * 运输单价 + */ private BigDecimal unitPrice; + /** + * 运费 + */ private BigDecimal freightAmount; + /** + * 费用项JSON + */ private String feeItemsJson; + /** + * 原金额 + */ private BigDecimal originalAmount; + /** + * 调整金额 + */ private BigDecimal adjustAmount; + /** + * 结算金额(含税) + */ private BigDecimal settlementAmountTax; + /** + * 结算金额(不含税) + */ private BigDecimal settlementAmountNoTax; + /** + * 币种 + */ private String currency; + /** + * 备注 + */ private String remark; } 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 b7e5892a..c4e62e26 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 @@ -26,20 +26,68 @@ import java.math.BigDecimal; @TableName("blade_formal_settlement_detail_fee") public class FormalSettlementDetailFee extends TenantEntity { @Serial private static final long serialVersionUID = 1L; + /** + * 正式结算明细ID + */ private Long formalSettlementDetailId; + /** + * 源费用行ID + */ private Long sourceFeeId; + /** + * 行号 + */ private String lineNo; + /** + * 货物名称 + */ private String cargoName; + /** + * 货物类型 + */ private String cargoType; + /** + * 运输量 + */ private BigDecimal transportQuantity; + /** + * 数量单位 + */ private String quantityUnit; + /** + * 里程 + */ private BigDecimal mileage; + /** + * 运输单价 + */ private BigDecimal unitPrice; + /** + * 运费 + */ private BigDecimal freightAmount; + /** + * 费用项JSON + */ private String feeItemsJson; + /** + * 原金额 + */ private BigDecimal originalAmount; + /** + * 调整金额 + */ private BigDecimal adjustAmount; + /** + * 结算金额(含税) + */ private BigDecimal settlementAmountTax; + /** + * 结算金额(不含税) + */ private BigDecimal settlementAmountNoTax; + /** + * 备注 + */ private String remark; } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/FormalSettlementPayment.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/FormalSettlementPayment.java index 962f7ad4..fdfb94eb 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/FormalSettlementPayment.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/FormalSettlementPayment.java @@ -26,12 +26,20 @@ import java.math.BigDecimal; @TableName("blade_formal_settlement_payment") public class FormalSettlementPayment extends TenantEntity { @Serial private static final long serialVersionUID = 1L; + /** 正式结算单id */ private Long formalSettlementId; + /** 付款单号(租户内唯一) */ private String paymentNo; + /** 付款类型(final:结算尾款) */ private String paymentType; + /** 申请付款金额 */ private BigDecimal appliedAmount; + /** 已付金额 */ private BigDecimal paidAmount; + /** 单据状态(reviewing:审核中,approved:审核通过,returned:已退回,voided:已作废,paid:已付款) */ private String billStatus; + /** 金蝶(星瀚)单据编号 */ private String kingdeeBillNo; + /** 备注 */ private String remark; } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/FormalSettlementSummaryFee.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/FormalSettlementSummaryFee.java index 9292f68e..acfd797f 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/FormalSettlementSummaryFee.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/FormalSettlementSummaryFee.java @@ -28,13 +28,40 @@ import java.math.BigDecimal; @Schema(description = "正式结算合计费用") public class FormalSettlementSummaryFee extends TenantEntity { @Serial private static final long serialVersionUID = 1L; + /** + * 正式结算单ID + */ private Long formalSettlementId; + /** + * 行号 + */ private Integer lineNo; + /** + * 费用类型 + */ private String feeType; + /** + * 费用项 + */ private String feeItem; + /** + * 原金额 + */ private BigDecimal originalAmount; + /** + * 调整金额 + */ private BigDecimal adjustAmount; + /** + * 结算金额 + */ private BigDecimal settlementAmount; + /** + * 备注 + */ private String remark; + /** + * 是否手工添加 + */ private Integer manualFlag; } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceApplication.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceApplication.java index bf03a3ce..2b89b282 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceApplication.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceApplication.java @@ -47,37 +47,140 @@ import java.time.LocalDateTime; @Schema(description = "开票申请") public class InvoiceApplication extends TenantEntity { @Serial private static final long serialVersionUID = 1L; + /** + * 开票申请单号(同租户唯一,A303-1 billno 幂等候选键) + */ private String applicationNo; + /** + * 所属项目ID(结算单快照带出) + */ private Long projectId; + /** + * 所属项目名称(结算单快照带出) + */ private String projectName; + /** + * 申请部门(所属组织)ID + */ private Long deptId; + /** + * 申请部门(所属组织)名称 + */ private String deptName; + /** + * 开票单位名称(销方=结算单收款方快照,金蝶 salername 取数来源) + */ private String issuerName; + /** + * 受票方客商档案ID + */ private Long receiverCustomerId; + /** + * 受票方单位名称(客商开票信息企业全称快照,金蝶 buyername) + */ private String receiverName; + /** + * 发票类型:electronic_normal-数电普票 / electronic_special-数电专票 + */ private String invoiceType; + /** + * 可开票金额(关联结算单剩余可开票额度合计) + */ private BigDecimal availableInvoiceAmount; + /** + * 本次开票金额(商品行价税合计,金蝶 totalamount) + */ private BigDecimal invoiceAmount; + /** + * 申请日期(提交审批时置当日) + */ private LocalDate applicationDate; + /** + * 申请人姓名 + */ private String applicantName; + /** + * 承办部门ID(结算单所属组织快照) + */ private Long undertakingDeptId; + /** + * 承办部门名称(结算单所属组织快照) + */ private String undertakingDeptName; + /** + * 部门邮箱(发票推送邮箱,多个以分号分隔,最多3个,金蝶 buyeremail) + */ private String departmentEmails; + /** + * 受票方客商开票信息ID(blade_customer_invoice_info) + */ private Long receiverInvoiceInfoId; + /** + * 受票方纳税人识别号(开票信息快照,金蝶 buyertaxno) + */ private String taxpayerNo; + /** + * 受票方开户行(开票信息快照) + */ private String bankName; + /** + * 受票方开户账号(开票信息快照) + */ private String bankAccount; + /** + * 受票方注册地址(开票信息快照) + */ private String registeredAddress; + /** + * 受票方联系人 + */ private String contactName; + /** + * 受票方联系电话 + */ private String contactPhone; + /** + * 受票方邮箱 + */ private String email; + /** + * 审批状态:draft-草稿 reviewing-审批中 approved-审批通过 returned-已驳回 voided-已作废 + */ private String approvalStatus; + /** + * 当前审批节点,如「草稿」「开票审核」「审批通过」 + */ private String currentNode; + /** + * 当前处理人姓名 + */ private String currentProcessor; + /** + * 金蝶开票申请单编号(A303-1 返回,A303-5 查询 serialNo) + */ private String kingdeeBillNo; + /** + * 金蝶开票申请单id(星瀚内部主键,A902 附件上传定位用) + */ + private String kingdeeBillId; + /** + * 金蝶同步状态:unsynced-未同步 synced-已同步 failed-同步失败 invoiced-开票完成(终态) + */ private String kingdeeStatus; + /** + * 同步金蝶时间 + */ private LocalDateTime syncedTime; + /** + * 附件JSON + */ private String attachmentsJson; + /** + * 备注 + */ private String remark; + /** + * 作废原因,驳回时记录驳回原因 + */ private String voidReason; } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceApplicationDetail.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceApplicationDetail.java index 44104935..2d43fc59 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceApplicationDetail.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceApplicationDetail.java @@ -44,25 +44,88 @@ import java.time.LocalDateTime; @TableName("blade_invoice_application_detail") public class InvoiceApplicationDetail extends TenantEntity { @Serial private static final long serialVersionUID = 1L; + /** + * 开票申请ID + */ private Long invoiceApplicationId; + /** + * 正式结算单ID + */ private Long formalSettlementId; + /** + * 来源正式结算明细ID + */ private Long formalSettlementDetailId; + /** + * 行号 + */ private Integer lineNo; + /** + * 结算明细单据号(快照) + */ private String documentNo; + /** + * 运单号(快照) + */ private String waybillNo; + /** + * 车牌号(快照) + */ private String vehicleNo; + /** + * 起运地(快照) + */ private String departureAddress; + /** + * 到达地(快照) + */ private String arrivalAddress; + /** + * 实际发运时间(快照) + */ private LocalDateTime actualDepartureTime; + /** + * 实际完成时间(快照) + */ private LocalDateTime actualCompletionTime; + /** + * 运输方式(快照,字典 transport_type) + */ private String transportType; + /** + * 货物名称(快照) + */ private String cargoName; + /** + * 货物类型(快照) + */ private String cargoType; + /** + * 运输数量(快照) + */ private BigDecimal transportQuantity; + /** + * 数量单位(快照) + */ private String quantityUnit; + /** + * 里程(快照) + */ private BigDecimal mileage; + /** + * 批次号(快照) + */ private String batchNo; + /** + * 运费金额(快照) + */ private BigDecimal freightAmount; + /** + * 费用项明细JSON(快照) + */ private String feeItemsJson; + /** + * 结算金额(含税,快照) + */ private BigDecimal settlementAmountTax; } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceApplicationInvoice.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceApplicationInvoice.java new file mode 100644 index 00000000..8e145070 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceApplicationInvoice.java @@ -0,0 +1,80 @@ +/** + * 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; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 开票申请金蝶发票结果实体类 + *

+ * 定时任务经 A303-5 开票申请发票查询回写的发票明细(发票号唯一键幂等落库), + * 发票文件地址(板式文件/数电PDF/XML/预览图)独立落库供详情页查看下载, + * 原文 JSON 整体留档便于联调与追溯 + * + * @author Chill + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("blade_invoice_application_invoice") +@Schema(description = "开票申请金蝶发票结果") +public class InvoiceApplicationInvoice extends TenantEntity { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "开票申请ID") + private Long invoiceApplicationId; + + @Schema(description = "金蝶开票申请单编号(A303-5 serialNo)") + private String serialNo; + + @Schema(description = "发票号码") + private String invoiceNo; + + @Schema(description = "开票日期") + private LocalDateTime invoiceDate; + + @Schema(description = "价税合计") + private BigDecimal invoiceAmount; + + @Schema(description = "板式文件下载地址(数电票为OFD,航信百望税盘普通电票为PDF,电子专票/UKey电票为OFD)") + private String invoiceFileUrl; + + @Schema(description = "PDF图片预览地址") + private String invoiceImageUrl; + + @Schema(description = "数电PDF下载地址(发票云5.0.025版本返回)") + private String invoicePdfFileUrl; + + @Schema(description = "数电XML下载地址(发票云5.0.025版本返回)") + private String invoiceXmlFileUrl; + + @Schema(description = "A303-5 返回原文JSON留档") + private String rawJson; + +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceApplicationLine.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceApplicationLine.java index 30ded9f6..60f04c39 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceApplicationLine.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceApplicationLine.java @@ -43,18 +43,64 @@ import java.math.BigDecimal; @TableName("blade_invoice_application_line") public class InvoiceApplicationLine extends TenantEntity { @Serial private static final long serialVersionUID = 1L; + /** + * 开票申请ID + */ private Long invoiceApplicationId; + /** + * 所属发票张次ID + */ private Long invoiceSheetId; + /** + * 行号(张内序号) + */ private Integer lineNo; + /** + * 商品和服务分类名称(开票项目主数据选择,金蝶 goodssimplename) + */ private String goodsCategory; + /** + * 货物或服务简称(开票项目主数据选择,金蝶 goodsname) + */ private String goodsName; + /** + * 税收分类编码(选择开票内容时快照,推送金蝶 A303-1 分录直接取该值) + */ + private String taxClassificationCode; + /** + * 单位 + */ private String unit; + /** + * 数量 + */ private BigDecimal quantity; + /** + * 不含税单价 + */ private BigDecimal unitPriceNoTax; + /** + * 不含税金额 + */ private BigDecimal amountNoTax; + /** + * 含税金额(历史兼容字段,与含税合计同值) + */ private BigDecimal amountWithTax; + /** + * 税率(0-100 百分比值,推送金蝶时转小数文本) + */ private BigDecimal taxRate; + /** + * 税额 + */ private BigDecimal taxAmount; + /** + * 价税合计(含税合计,金蝶 taxamount) + */ private BigDecimal totalAmount; + /** + * 行备注 + */ private String remark; } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceApplicationRecord.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceApplicationRecord.java index 8a411bd8..01c9e9b0 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceApplicationRecord.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceApplicationRecord.java @@ -42,12 +42,36 @@ import java.io.Serial; @TableName("blade_invoice_application_record") public class InvoiceApplicationRecord extends TenantEntity { @Serial private static final long serialVersionUID = 1L; + /** + * 开票申请ID + */ private Long invoiceApplicationId; + /** + * 操作类型:create/save/submit/approve/return/void/sync 等 + */ private String actionType; + /** + * 操作名称(展示用,如「提交审批」「同步金蝶」) + */ private String actionName; + /** + * 操作前审批状态 + */ private String fromStatus; + /** + * 操作后审批状态 + */ private String toStatus; + /** + * 操作人姓名 + */ private String operatorName; + /** + * 原因说明(驳回/作废原因等) + */ private String reason; + /** + * 金蝶单据编号(同步类操作记录) + */ private String kingdeeBillNo; } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceApplicationSettlement.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceApplicationSettlement.java index 57a03396..ac6ba4ca 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceApplicationSettlement.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceApplicationSettlement.java @@ -43,10 +43,28 @@ import java.math.BigDecimal; @TableName("blade_invoice_application_settlement") public class InvoiceApplicationSettlement extends TenantEntity { @Serial private static final long serialVersionUID = 1L; + /** + * 开票申请ID + */ private Long invoiceApplicationId; + /** + * 正式结算单ID + */ private Long formalSettlementId; + /** + * 正式结算单号(关联时快照) + */ private String formalSettlementNo; + /** + * 结算金额(关联时快照) + */ private BigDecimal settlementAmount; + /** + * 可开票金额(关联时快照) + */ private BigDecimal availableInvoiceAmount; + /** + * 本申请分摊开票金额(结算单开票状态按此汇总) + */ private BigDecimal allocatedInvoiceAmount; } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceApplicationSheet.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceApplicationSheet.java index 8550946d..bee703e4 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceApplicationSheet.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceApplicationSheet.java @@ -43,7 +43,20 @@ import java.math.BigDecimal; @TableName("blade_invoice_application_sheet") public class InvoiceApplicationSheet extends TenantEntity { @Serial private static final long serialVersionUID = 1L; + /** + * 开票申请ID + */ private Long invoiceApplicationId; + /** + * 发票张序号(从 1 开始) + */ private Integer sheetNo; + /** + * 本张开票金额(张内商品行价税合计) + */ private BigDecimal invoiceAmount; + /** + * 金蝶开票申请单编号(本张发票对应,A303-1 返回,A303-5 查询 serialNo) + */ + private String kingdeeBillNo; } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceReceipt.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceReceipt.java index 67859e1c..9023e07e 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceReceipt.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/InvoiceReceipt.java @@ -36,6 +36,7 @@ import org.springblade.core.tenant.mp.TenantEntity; import java.io.Serial; import java.math.BigDecimal; import java.time.LocalDate; +import java.time.LocalDateTime; /** * 收票登记实体类 @@ -48,36 +49,135 @@ import java.time.LocalDate; @Schema(description = "收票登记") public class InvoiceReceipt extends TenantEntity { @Serial private static final long serialVersionUID = 1L; + /** + * 关联金蝶票据池镜像主键(blade_kingdee_invoice_pool.id) + */ @JsonSerialize(using = ToStringSerializer.class) private Long kingdeeInvoicePoolId; + /** + * 发票号码(同租户唯一,一张发票仅可登记一次) + */ private String invoiceNo; + /** + * 开票日期 + */ private LocalDate invoiceDate; + /** + * 发票类型(如「数电发票(增值税专用发票)」) + */ private String invoiceType; + /** + * 税率(百分数,13 表示 13%) + */ private BigDecimal taxRate; + /** + * 开票金额 + */ private BigDecimal invoiceAmount; + /** + * 税额 + */ private BigDecimal taxAmount; + /** + * 受票单位(购方) + */ private String receiverName; + /** + * 开票单位(销方) + */ private String issuerName; + /** + * 所属项目id + */ @JsonSerialize(using = ToStringSerializer.class) private Long projectId; + /** + * 所属项目名称 + */ private String projectName; + /** + * 所属组织id + */ @JsonSerialize(using = ToStringSerializer.class) private Long deptId; + /** + * 所属组织名称 + */ private String deptName; + /** + * 付款方 + */ private String payerName; + /** + * 收款方 + */ private String payeeName; + /** + * 开票单位开户银行 + */ private String bankName; + /** + * 开票单位银行账号 + */ private String bankAccount; + /** + * 开票单位开户网点 + */ private String issuingBank; + /** + * 联系电话 + */ private String phone; + /** + * 客户邮箱(分号分隔,最多 3 个) + */ private String customerEmails; + /** + * 部门邮箱(分号分隔,最多 3 个) + */ private String departmentEmails; + /** + * 审批状态(draft 草稿 / reviewing 审批中 / approved 审批通过 / returned 已驳回 / voided 已作废) + */ private String approvalStatus; + /** + * 当前审批节点 + */ private String currentNode; + /** + * 当前处理人 + */ private String currentProcessor; - private String kingdeeBillNo; - private String kingdeeStatus; + /** + * 金蝶收票单单据编号(A303-2 批量保存生成/幂等预检命中回填) + */ + private String kingdeeReceivedInvoiceNo; + /** + * 金蝶收票单id(星瀚内部主键,A902 附件上传定位用) + */ + private String kingdeeReceivedInvoiceId; + /** + * 收票单推送状态(unsynced 未推送 / pushed 已保存收票单 / assigned 已指定应付 / failed 推送失败) + */ + private String kingdeePushStatus; + /** + * 收票单推送失败原因(金蝶侧删除/红冲/查验不通过等异常提示人工) + */ + private String kingdeePushMsg; + /** + * 最近一次收票单推送/回写时间 + */ + private LocalDateTime kingdeePushTime; + /** + * 附件信息JSON + */ private String attachmentsJson; + /** + * 备注(最多 200 字) + */ private String remark; + /** + * 作废原因 + */ private String voidReason; } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/KingdeeAttachmentSync.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/KingdeeAttachmentSync.java new file mode 100644 index 00000000..c8c0377d --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/KingdeeAttachmentSync.java @@ -0,0 +1,84 @@ +/** + * 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 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.time.LocalDateTime; + +/** + * 金蝶附件同步实体类(A902 附件上传到指定单据,单附件行级状态) + * + * @author Chill + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("blade_kingdee_attachment_sync") +@Schema(description = "金蝶附件同步") +public class KingdeeAttachmentSync extends TenantEntity { + @Serial + private static final long serialVersionUID = 1L; + /** + * 业务单据类型(pay_application/formal_settlement_ap/formal_settlement_ar/invoice_receipt/invoice_application) + */ + private String bizType; + /** + * 业务单据id + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long bizId; + /** + * 附件幂等键(用户附件=附件url;发票文件=A801-2 的 path) + */ + private String attachmentKey; + /** + * 附件名称(展示/排查用) + */ + private String attachmentName; + /** + * 金蝶侧文件路径(A902 返回 data.path) + */ + private String kingdeePath; + /** + * 同步状态:1 成功 / 0 失败 + */ + private Integer syncStatus; + /** + * 失败原因(超限/下载失败/金蝶报错) + */ + private String failReason; + /** + * 最近上传时间 + */ + private LocalDateTime syncTime; +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/KingdeeBillLock.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/KingdeeBillLock.java new file mode 100644 index 00000000..1817ce2e --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/KingdeeBillLock.java @@ -0,0 +1,87 @@ +/** + * 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.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; +import java.time.LocalDateTime; + +/** + * 金蝶单据锁定记录实体类 + *

+ * 一行 = 一张金蝶目标单:推送成功后 lockBusiData 锁定(FAILED→LOCKED),金蝶经 unLockNotifyBusiData + * 回调解锁(→UNLOCKED,DELETE 时废除源单并还原关联数据);同时承担当前锁状态(编辑守卫/回调定位) + * 与锁定补偿扫表(FAILED+try_count)、锁定/解锁留痕 + * + * @author Chill + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("blade_kingdee_bill_lock") +@Schema(description = "金蝶单据锁定记录") +public class KingdeeBillLock extends TenantEntity { + @Serial private static final long serialVersionUID = 1L; + /** + * WLTMS 单据类型(SETTLEMENT/INVOICE_APPLY,预留 PAY_APPLY/RECEIVED_INVOICE) + */ + private String sourceBillType; + /** + * WLTMS 业务主键 + */ + private Long sourceBillId; + /** + * WLTMS 单号(正式结算单号/开票申请单号) + */ + private String sourceBillNo; + /** + * 金蝶单据类型(ApFin_other_BT_S/arfin_other_BT_S/cdm_paybill_bank_elect_BT_S 等固定值) + */ + private String targetBillType; + /** + * 金蝶单据编号(即业务单 kingdeeBillNo,开票申请按发票张次逐张一行) + */ + private String targetBillNo; + /** + * 锁状态:failed-锁定失败(补偿重试对象)/locked-金蝶锁定中/unlocked-已解锁(终态) + */ + private String lockStatus; + /** + * 最近动作来源操作类型:SAVE-单据新增,DELETE-单据删除(DELETE 回调触发废除还原) + */ + private String operationType; + /** + * 锁定重试次数(补偿任务用,超上限停试) + */ + private Integer tryCount; + /** + * 锁定时间 + */ + private LocalDateTime lockTime; + /** + * 解锁时间 + */ + private LocalDateTime unlockTime; + /** + * 推单发起人(锁定操作人) + */ + private String lockOperator; + /** + * 回调操作人(金蝶侧操作人) + */ + private String notifyOperator; + /** + * 锁定失败原因 + */ + private String failReason; +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/KingdeeInvoicePool.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/KingdeeInvoicePool.java index 261110c3..79eb12e7 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/KingdeeInvoicePool.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/KingdeeInvoicePool.java @@ -47,22 +47,108 @@ import java.time.LocalDateTime; @Schema(description = "金蝶进项发票票据池镜像") public class KingdeeInvoicePool extends TenantEntity { @Serial private static final long serialVersionUID = 1L; + /** + * 发票号码(同租户唯一,A801-1 invoiceNo,镜像 upsert 匹配键) + */ private String invoiceNo; + /** + * 金蝶发票流水号(票池唯一标识,A801-1 serialNo) + */ + private String serialNo; + /** + * 开票日期 + */ private LocalDate invoiceDate; + /** + * 发票类型(票易通编码转名称后落库,如「数电发票(增值税专用发票)」) + */ private String invoiceType; + /** + * 票易通原始发票类型编码(A801-1 invoiceType,如 26/27,A303-2 invoicetypef7_number 取数) + */ + private String invoiceTypeCode; + /** + * 税率(百分数,13 表示 13%) + */ private BigDecimal taxRate; + /** + * 开票金额(A801-1 价税合计 totalAmount) + */ private BigDecimal invoiceAmount; + /** + * 税额 + */ private BigDecimal taxAmount; + /** + * 受票单位(购方名称,A801-1 buyerName) + */ private String receiverName; + /** + * 购方税号(A801-1 buyerTaxNo) + */ + private String buyerTaxNo; + /** + * 开票单位(销方名称,A801-1 salerName) + */ private String issuerName; + /** + * 销方税号(A801-1 salerTaxNo) + */ + private String salerTaxNo; + /** + * 开票单位开户银行 + */ private String bankName; + /** + * 开票单位银行账号 + */ private String bankAccount; + /** + * 开票单位开户网点 + */ private String issuingBank; + /** + * 联系电话 + */ private String phone; + /** + * 客户邮箱(分号分隔,最多 3 个) + */ private String customerEmails; + /** + * 部门邮箱(分号分隔,最多 3 个) + */ private String departmentEmails; + /** + * 金蝶单据号(票池发票取发票流水号 serialNo) + */ private String kingdeeBillNo; + /** + * 金蝶同步状态(unsynced 未同步 / synced 已同步 / failed 同步失败) + */ private String kingdeeStatus; + /** + * 查验状态(A801-1 checkStatus,0 未查验 / 1 通过 / 2 不通过) + */ + private String checkStatus; + /** + * 附件信息JSON(结构 {"source":"piaozone","path":"A801-2下载path","name":"文件名"}) + */ private String attachmentsJson; + /** + * A801-1 发票明细行JSON(BfiInvoicePoolItemVO 列表,票池存档;收票单分录已改与应付单同源结算费用行) + */ + private String itemsJson; + /** + * 票池数据更新时间(A801-1 billCreateTime,镜像排序字段) + */ private LocalDateTime sourceUpdatedTime; + /** + * 剩余可用金额(A801-1 availableAmount,金蝶可用税额口径,选票本地过滤用:>0 才可选) + */ + private BigDecimal availableAmount; + /** + * 金额使用状态(A801-1 amountUseStatus,0 未使用可选 / 2 全部使用;选票本地过滤用) + */ + private String amountUseStatus; } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/KingdeeReceiptFlow.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/KingdeeReceiptFlow.java index 79b75c1b..533394ae 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/KingdeeReceiptFlow.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/KingdeeReceiptFlow.java @@ -26,6 +26,8 @@ package org.springblade.transport.pojo.entity; 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; @@ -49,16 +51,105 @@ public class KingdeeReceiptFlow extends TenantEntity { @Serial private static final long serialVersionUID = 1L; + /** + * 金蝶认领公告id(A301-2 返回 id,认领推送 A301-3 必填) + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long billId; + /** + * 金蝶付款人id(A301-2 公告对应往来单位 id,认领推送 A301-3 用) + */ + private String payerId; + /** + * 公告认领状态(A301-2 claimstatus:0未认领/1已认领) + */ + private String kingdeeClaimStatus; + /** + * 我已认领(A301-2 claimme) + */ + private Boolean claimMe; + /** + * 未认领金额(A301-2 unclaimamount) + */ + private BigDecimal unclaimedAmount; + /** + * 手续费(A301-2 fee) + */ + private BigDecimal fee; + /** + * 收款人(A301-2 recpayee) + */ + private String payeeName; + /** + * 收款通知单号(A301-2 billno) + */ private String receiptNoticeNo; + /** + * 付款人名称(A301-2 recviewpayer) + */ private String payerName; + /** + * 收款金额(A301-2 reamount) + */ private BigDecimal receiptAmount; + /** + * 对方单位名称(A301-2 oppunit) + */ private String counterpartyName; + /** + * 对方银行账号(A301-2 oppbanknumber) + */ private String counterpartyAccount; + /** + * 对方开户行(A301-2 oppbank) + */ private String counterpartyBank; + /** + * 摘要(A301-2 description) + */ private String summary; + /** + * 交易日期(A301-2 tradetime) + */ private LocalDateTime transactionTime; + /** + * 明细流水号(A301-2 detailid,回退 tradedetailno / bankdetailno,幂等键) + */ private String detailSerialNo; + /** + * 已认领金额(本地认领驱动,初始为0) + */ private BigDecimal claimedAmount; + /** + * 认领状态(unclaimed未认领/partial部分认领/claimed认领完成,累计认领与公告未认领金额对表) + */ private String claimStatus; + /** + * 推送状态(none未推送/pushed已推送/failed推送失败;已推送为终态闸门,不允许二次推送) + */ + private String pushStatus; + /** + * 最近一次推送失败原因(成功后清空) + */ + private String pushFailReason; + /** + * 首次推送金蝶成功时间 + */ + private LocalDateTime pushedTime; + /** + * 金蝶推送来源单据编码(分摊结算单号下划线拼接,推送时生成落库;分摊不变则重试编码不变,A301-1 回写匹配键) + */ + private String awrpSrcCode; + /** + * 金蝶收款单号(推送响应回填 / A301-1 回写推进) + */ + private String kingdeeBillNo; + /** + * 金蝶回写状态(none未推送/pushed已推送/approved审核通过/received已收款/failed失败) + */ + private String kingdeeBillStatus; + /** + * 来源更新时间(取交易日期,同步时刷新) + */ private LocalDateTime sourceUpdatedTime; } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/PaymentApplication.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/PaymentApplication.java index 1d6d033c..45ab1f4c 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/PaymentApplication.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/PaymentApplication.java @@ -2,18 +2,24 @@ * 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. + * 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. + * 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. + * 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. + * 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. + * 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. + * 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) */ @@ -34,43 +40,84 @@ import java.time.LocalDate; @TableName("blade_payment_application") public class PaymentApplication extends TenantEntity { @Serial private static final long serialVersionUID = 1L; + /** 付款申请单号 */ private String paymentNo; + /** 付款类型:project_advance 项目预付 / progress_advance 进度预付 / settlement_payment 结算付款 */ private String paymentType; + /** 正式结算单id */ private Long settlementId; + /** 正式结算单号 */ private String settlementNo; + /** 预结算单id */ private Long preSettlementId; + /** 预结算单号 */ private String preSettlementNo; + /** 项目id */ private Long projectId; + /** 项目名称 */ private String projectName; + /** 所属组织id */ private Long deptId; + /** 所属组织名称 */ private String deptName; + /** 合同id */ private Long contractId; + /** 合同编号 */ private String contractNo; + /** 合同名称 */ private String contractName; + /** 付款方 */ private String payerName; + /** 收款方 */ private String payeeName; + /** 结算金额 */ private BigDecimal settlementAmount; + /** 应付金额 */ private BigDecimal payableAmount; + /** 来源单据类型:项目预付 / 正式结算单 / 预结算单 */ private String billType; + /** 付款比例(%) */ private BigDecimal paymentRatio; + /** 申请付款金额 */ private BigDecimal appliedAmount; + /** 付款方式,业务字典 pay_method */ private String paymentMethod; + /** 汇票台账id(票据付款时关联) */ private Long billLedgerId; + /** 票据号码 */ private String billNo; + /** 收款账户id */ private Long receiptAccountId; + /** 收款账户名称 */ private String receiptAccountName; + /** 收款银行名称 */ private String bankName; + /** 收款账号 */ private String bankAccount; + /** 申请人 */ private String applicantName; + /** 申请日期 */ private LocalDate applyDate; + /** 发票匹配状态:unmatched 未匹配 / matched 已匹配 */ private String invoiceStatus; + /** 匹配发票金额 */ private BigDecimal matchedInvoiceAmount; + /** 已付款金额 */ private BigDecimal paidAmount; + /** 审批状态:draft 草稿 / reviewing 审批中 / approved 审批通过 / returned 已驳回 / voided 已作废 */ private String approvalStatus; + /** 当前审批节点 */ private String currentNode; + /** 当前处理人 */ private String currentProcessor; + /** 金蝶单据号(A103-2 查询回填的星瀚单据编号 billno,如 FKSQ-xxxx) */ private String kingdeeBillNo; + /** 金蝶单据id(A103-1/A103-3 推送返回的星瀚内部主键,附件上传等使用) */ + private String kingdeeBillId; + /** 金蝶同步状态:unsynced 未生成 / synced 已生成 / paying 付款中 / paid 已付款 / closed 已关闭 / failed 生成失败 */ private String kingdeeStatus; + /** 附件信息JSON */ private String attachmentsJson; + /** 备注 */ private String remark; } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ProjectApply.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ProjectApply.java index 5526da8e..eb85e2c4 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ProjectApply.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ProjectApply.java @@ -70,10 +70,10 @@ public class ProjectApply extends TenantEntity { @Schema(description = "业务部门") private String businessDeptName; - @Schema(description = "承办部门ID") - private Long undertakeDeptId; + @Schema(description = "平台公司ID,多个逗号分隔") + private String undertakeDeptId; - @Schema(description = "承办部门") + @Schema(description = "平台公司,多个顿号分隔") private String undertakeDeptName; @Schema(description = "项目由来") diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ReceiptClaim.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ReceiptClaim.java index fc7f7862..2294d0a9 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ReceiptClaim.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ReceiptClaim.java @@ -65,7 +65,13 @@ public class ReceiptClaim extends TenantEntity { private String attachmentsJson; private String remark; private String claimStatus; + /** + * 金蝶收款单号(冗余展示:推送成功/A301-1 回写时由流水级联刷新) + */ private String kingdeeBillNo; + /** + * 金蝶回写状态(冗余展示:与流水 kingdee_bill_status 同步刷新) + */ private String kingdeeBillStatus; @JsonSerialize(using = ToStringSerializer.class) private Long voidedBy; 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..88a3bcd4 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/SettlementImportBatch.java @@ -0,0 +1,115 @@ +/** + * 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 = "备注") + 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/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/FormalSettlementVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/FormalSettlementVO.java index 1e507376..dc889541 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 @@ -9,6 +9,7 @@ package org.springblade.transport.pojo.vo; import com.baomidou.mybatisplus.annotation.TableField; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; import org.springblade.transport.pojo.entity.FormalSettlement; @@ -18,10 +19,13 @@ import org.springblade.transport.pojo.entity.FormalSettlementInvoice; 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; /** @@ -31,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; @@ -39,6 +43,21 @@ public class FormalSettlementVO extends FormalSettlement { @TableField(exist = false) private String ids; @TableField(exist = false) private String approvalStatusName; @TableField(exist = false) private String settlementTypeName; + @TableField(exist = false) private Integer attachmentCount; + @TableField(exist = false) private String attachmentSyncStatus; + @TableField(exist = false) private String attachmentSyncStatusName; + @TableField(exist = false) @Schema(description = "金蝶锁状态:locked-锁定中/failed-锁定失败;unlocked为终态不返回,null=无锁或已解锁") + private String kingdeeLockStatus; + @TableField(exist = false) @Schema(description = "金蝶锁失败原因(仅锁定失败时有值)") + 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) private String preSettlementNos; @TableField(exist = false) private String preSettlementNo; @TableField(exist = false) private List sources; @@ -46,6 +65,10 @@ 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 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 664d02b0..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 @@ -30,10 +30,12 @@ import lombok.Data; import lombok.EqualsAndHashCode; import org.springblade.transport.pojo.entity.InvoiceApplication; import org.springblade.transport.pojo.entity.InvoiceApplicationDetail; +import org.springblade.transport.pojo.entity.InvoiceApplicationInvoice; 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; /** @@ -43,14 +45,27 @@ 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; @TableField(exist = false) private String kingdeeStatusName; + @TableField(exist = false) private Integer attachmentCount; + @TableField(exist = false) private String attachmentSyncStatus; + @TableField(exist = false) private String attachmentSyncStatusName; + @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; @TableField(exist = false) private List details; @TableField(exist = false) private List records; + @TableField(exist = false) private List invoices; } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/InvoiceReceiptVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/InvoiceReceiptVO.java index 8be4dcf2..2f344ff1 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/InvoiceReceiptVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/InvoiceReceiptVO.java @@ -48,7 +48,10 @@ public class InvoiceReceiptVO extends InvoiceReceipt { @Serial private static final long serialVersionUID = 1L; @TableField(exist = false) private String settlementNos; @TableField(exist = false) private String approvalStatusName; - @TableField(exist = false) private String kingdeeStatusName; + @TableField(exist = false) private String kingdeePushStatusName; + @TableField(exist = false) private Integer attachmentCount; + @TableField(exist = false) private String attachmentSyncStatus; + @TableField(exist = false) private String attachmentSyncStatusName; @TableField(exist = false) private String createUserName; @TableField(exist = false) private String updateUserName; @TableField(exist = false) private List settlements; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/KingdeeInvoicePoolVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/KingdeeInvoicePoolVO.java new file mode 100644 index 00000000..001bae66 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/KingdeeInvoicePoolVO.java @@ -0,0 +1,157 @@ +/** + * 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; + +/** + * 金蝶票据池镜像统一视图(含本地认领/登记标记;认领口径:可匹配与默认认领金额=价税合计) + * + * @author Chill + */ +@Data +@Schema(description = "金蝶票据池镜像统一视图") +public class KingdeeInvoicePoolVO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** 镜像主键,仅已入镜像(已登记)的票非空 */ + @Schema(description = "镜像主键") + @JsonSerialize(using = ToStringSerializer.class) + private Long id; + + /** 发票号码 */ + @Schema(description = "发票号码") + private String invoiceNo; + + /** 金蝶发票流水号(票池唯一标识) */ + @Schema(description = "金蝶发票流水号") + private String serialNo; + + /** 开票日期 yyyy-MM-dd */ + @Schema(description = "开票日期") + private String invoiceDate; + + /** 发票类型名称(镜像落库时已由编码转名称) */ + @Schema(description = "发票类型名称") + private String invoiceType; + + /** 税率百分数,如 13 */ + @Schema(description = "税率百分数") + private BigDecimal taxRate; + + /** 价税合计 */ + @Schema(description = "发票金额(价税合计)") + private BigDecimal invoiceAmount; + + /** 税额 */ + @Schema(description = "税额") + private BigDecimal taxAmount; + + /** 受票单位(购方名称) */ + @Schema(description = "受票单位") + private String receiverName; + + /** 购方税号 */ + @Schema(description = "购方税号") + private String buyerTaxNo; + + /** 开票单位(销方名称) */ + @Schema(description = "开票单位") + private String issuerName; + + /** 销方税号 */ + @Schema(description = "销方税号") + private String salerTaxNo; + + /** 销方开户行名称 */ + @Schema(description = "销方开户行名称") + private String bankName; + + /** 销方银行账号 */ + @Schema(description = "销方银行账号") + private String bankAccount; + + /** 销方开户行及账号 */ + @Schema(description = "开户行及账号") + private String issuingBank; + + /** 销方电话 */ + @Schema(description = "电话") + private String phone; + + /** 客户邮箱 JSON 串 */ + @Schema(description = "客户邮箱JSON") + private String customerEmails; + + /** 部门邮箱 JSON 串 */ + @Schema(description = "部门邮箱JSON") + private String departmentEmails; + + /** 金蝶流水号(登记侧展示为金蝶单据号) */ + @Schema(description = "金蝶单据号") + private String kingdeeBillNo; + + /** 同步状态 unsynced/synced/failed */ + @Schema(description = "金蝶同步状态") + private String kingdeeStatus; + + /** 查验状态 */ + @Schema(description = "查验状态") + private String checkStatus; + + /** 附件 JSON(票易通下载 path) */ + @Schema(description = "附件信息JSON") + private String attachmentsJson; + + /** 可认领金额,口径同发票金额 */ + @Schema(description = "可认领金额") + private BigDecimal availableInvoiceAmount; + + /** 剩余可用金额(金蝶可用税额口径,与可认领金额的价税合计口径不同) */ + @Schema(description = "剩余可用金额") + private BigDecimal availableTaxAmount; + + /** 默认认领金额,口径同发票金额 */ + @Schema(description = "默认认领金额") + private BigDecimal matchedAmount; + + /** 是否已被正式结算单认领(实时查询返回行的本地装饰标记,仅展示不作过滤) */ + @Schema(description = "是否已认领") + private Boolean claimed; + + /** 是否已存在非作废收票登记(实时查询返回行的本地装饰标记,仅展示不作过滤) */ + @Schema(description = "是否已登记") + private Boolean registered; +} 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/PaymentApplicationVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/PaymentApplicationVO.java index 6e6c183a..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; @@ -43,6 +44,18 @@ public class PaymentApplicationVO extends PaymentApplication { @TableField(exist = false) private String paymentTypeName; @TableField(exist = false) private String approvalStatusName; @TableField(exist = false) private String kingdeeStatusName; + @TableField(exist = false) private Integer attachmentCount; + @TableField(exist = false) private String attachmentSyncStatus; + @TableField(exist = false) private String attachmentSyncStatusName; + @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/ReceiptClaimRecordVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ReceiptClaimRecordVO.java index 9802b661..400af8ae 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ReceiptClaimRecordVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ReceiptClaimRecordVO.java @@ -76,6 +76,11 @@ public class ReceiptClaimRecordVO extends ReceiptClaim { private String claimStatusName; @TableField(exist = false) private String kingdeeBillStatusName; + /** + * 关联流水推送状态(none未推送/pushed已推送/failed推送失败,查询条件) + */ + @TableField(exist = false) + private String pushStatus; @TableField(exist = false) private List settlements; @TableField(exist = false) 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 b282c745..f32a9491 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 @@ -56,6 +56,12 @@ public class ReceiptFlowVO extends KingdeeReceiptFlow { @TableField(exist = false) private String claimStatusName; @TableField(exist = false) + private String kingdeeClaimStatusName; + @TableField(exist = false) + private String pushStatusName; + @TableField(exist = false) + private String kingdeeBillStatusName; + @TableField(exist = false) private BigDecimal remainingAmount; @TableField(exist = false) private String createUserName; @@ -73,6 +79,16 @@ public class ReceiptFlowVO extends KingdeeReceiptFlow { @TableField(exist = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime transactionEndTime; + /** + * 收款金额区间-最小值(查询条件) + */ + @TableField(exist = false) + private BigDecimal receiptAmountStart; + /** + * 收款金额区间-最大值(查询条件) + */ + @TableField(exist = false) + private BigDecimal receiptAmountEnd; @TableField(exist = false) private List claimRecords = new ArrayList<>(); } 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..3f856a62 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/SettlementImportBatchVO.java @@ -0,0 +1,69 @@ +/** + * 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; + +/** + * 导入结算单批次视图 + * + * @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; +} diff --git a/blade-service/blade-file/src/main/java/org/springblade/file/config/FileConfig.java b/blade-service/blade-file/src/main/java/org/springblade/file/config/FileConfig.java index 64632b22..07748388 100644 --- a/blade-service/blade-file/src/main/java/org/springblade/file/config/FileConfig.java +++ b/blade-service/blade-file/src/main/java/org/springblade/file/config/FileConfig.java @@ -3,6 +3,7 @@ package org.springblade.file.config; import com.aliyun.oss.OSSClientBuilder; import com.obs.services.ObsClient; import io.minio.MinioClient; +import lombok.extern.slf4j.Slf4j; import org.springblade.core.log.exception.ServiceException; import org.springblade.core.tool.utils.StringUtil; import org.springblade.file.service.IFileService; @@ -10,6 +11,7 @@ import org.springblade.file.service.impl.LocalFileServiceImpl; import org.springblade.file.service.impl.MinioFileServiceImpl; import org.springblade.file.service.impl.OssFileServiceImpl; import org.springblade.file.service.impl.ObsFileServiceImpl; +import org.springblade.file.util.MinioClientFactory; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -20,6 +22,7 @@ import org.springframework.core.env.Environment; * @author bfhuange * @date 2024/10/21 */ +@Slf4j @Configuration public class FileConfig { @@ -63,10 +66,22 @@ public class FileConfig { private IFileService buildMinioFileService(FileStorageProperties.StoreProperties properties) { validateStoreProperties(STORAGE_MINIO, properties); - MinioClient minioClient = MinioClient.builder() - .endpoint(properties.getEndpoint()) - .credentials(properties.getAccessKeyId(), properties.getAccessKeySecret()) - .build(); + String endpointOrigin = MinioClientFactory.resolveOrigin(properties.getEndpoint()); + String endpointPathPrefix = MinioClientFactory.resolvePathPrefix(properties.getEndpoint()); + if (StringUtil.isNotBlank(endpointPathPrefix)) { + log.warn("MinIO endpoint 含 path,将按 origin={} pathPrefix={} 访问;建议 endpoint 使用内网地址,transform-endpoint 使用外网 HTTPS", + endpointOrigin, endpointPathPrefix); + } + if (StringUtil.isBlank(properties.getTransformEndpoint()) + && properties.getEndpoint().trim().toLowerCase().startsWith("http://")) { + log.warn("MinIO transform-endpoint 未配置,endpoint={}。HTTPS 页面分片直传会被浏览器 Mixed Content 拦截,将回退为服务端代传", + properties.getEndpoint()); + } else { + log.info("MinIO 文件存储已启用 endpoint={} pathPrefix={} transformEndpoint={}", + endpointOrigin, endpointPathPrefix, properties.getTransformEndpoint()); + } + MinioClient minioClient = MinioClientFactory.create( + properties.getEndpoint(), properties.getAccessKeyId(), properties.getAccessKeySecret()); return new MinioFileServiceImpl(properties, minioClient); } @@ -95,7 +110,16 @@ public class FileConfig { FileStorageProperties.StoreProperties mergedProperties = new FileStorageProperties.StoreProperties(); String fileStoragePrefix = "file.storage." + prefix; mergedProperties.setEndpoint(firstNotBlank(properties.getEndpoint(), environment.getProperty(fileStoragePrefix + ".endpoint"), environment.getProperty(prefix + ".endpoint"))); - mergedProperties.setTransformEndpoint(firstNotBlank(properties.getTransformEndpoint(), environment.getProperty(fileStoragePrefix + ".transform-endpoint"), environment.getProperty(prefix + ".transform-endpoint"))); + // 资源管理页 /resource/oss 的外网地址写在库表,不进 Spring Environment;凭证分片必须单独配 file.storage.minio.transform-endpoint + mergedProperties.setTransformEndpoint(firstNotBlank( + properties.getTransformEndpoint(), + environment.getProperty(fileStoragePrefix + ".transform-endpoint"), + environment.getProperty(fileStoragePrefix + ".transformEndpoint"), + environment.getProperty(prefix + ".transform-endpoint"), + environment.getProperty(prefix + ".transformEndpoint"), + environment.getProperty("oss.transform-endpoint"), + environment.getProperty("oss.transformEndpoint") + )); mergedProperties.setAccessKeyId(firstNotBlank(properties.getAccessKeyId(), environment.getProperty(fileStoragePrefix + ".access-key-id"), environment.getProperty(fileStoragePrefix + ".access-key"), environment.getProperty(prefix + ".access-key-id"), environment.getProperty(prefix + ".access-key"))); mergedProperties.setAccessKeySecret(firstNotBlank(properties.getAccessKeySecret(), environment.getProperty(fileStoragePrefix + ".access-key-secret"), environment.getProperty(fileStoragePrefix + ".secret-key"), environment.getProperty(prefix + ".access-key-secret"), environment.getProperty(prefix + ".secret-key"))); mergedProperties.setBucketName(firstNotBlank(properties.getBucketName(), environment.getProperty(fileStoragePrefix + ".bucket-name"), environment.getProperty(prefix + ".bucket-name"))); @@ -107,7 +131,7 @@ public class FileConfig { private String firstNotBlank(String... values) { for (String value : values) { if (StringUtil.isNotBlank(value)) { - return value; + return value.trim(); } } return null; diff --git a/blade-service/blade-file/src/main/java/org/springblade/file/controller/FileTaskController.java b/blade-service/blade-file/src/main/java/org/springblade/file/controller/FileTaskController.java index 62e4dabf..462de6e0 100644 --- a/blade-service/blade-file/src/main/java/org/springblade/file/controller/FileTaskController.java +++ b/blade-service/blade-file/src/main/java/org/springblade/file/controller/FileTaskController.java @@ -111,4 +111,26 @@ public class FileTaskController extends BladeController { String uploadUrl = fileTaskService.getUploadUrl(id, partNumber); return R.data(uploadUrl); } + + @ApiLog("上传任务-服务端代传分片") + @PostMapping(value = "/uploadPart", consumes = org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE) + @Parameters({ + @Parameter(name = "id", description = "文件任务id", in = ParameterIn.QUERY, schema = @Schema(type = "string")), + @Parameter(name = "partNumber", description = "分段编号", in = ParameterIn.QUERY, schema = @Schema(type = "int")) + }) + @Operation(summary = "服务端代传分片", description = "HTTPS 页面无法直连 HTTP MinIO 时,由服务端转发分片到对象存储") + public R uploadPart(Long id, Integer partNumber, org.springframework.web.multipart.MultipartFile file) throws java.io.IOException { + if (file == null || file.isEmpty()) { + return R.fail("分片内容不能为空"); + } + java.io.File tempFile = java.io.File.createTempFile("file-part-", ".bin"); + try { + file.transferTo(tempFile); + return R.data(fileTaskService.uploadPart(id, partNumber, tempFile)); + } finally { + if (tempFile.exists() && !tempFile.delete()) { + tempFile.deleteOnExit(); + } + } + } } diff --git a/blade-service/blade-file/src/main/java/org/springblade/file/service/IFileTaskService.java b/blade-service/blade-file/src/main/java/org/springblade/file/service/IFileTaskService.java index ae394521..f47120a4 100644 --- a/blade-service/blade-file/src/main/java/org/springblade/file/service/IFileTaskService.java +++ b/blade-service/blade-file/src/main/java/org/springblade/file/service/IFileTaskService.java @@ -74,6 +74,16 @@ public interface IFileTaskService extends IService { */ String getUploadUrl(Long id, Integer partNumber); + /** + * 服务端代传分片(HTTPS 页面无法直连 HTTP MinIO 时使用) + * + * @param id 文件任务id + * @param partNumber 分段编号 + * @param partFile 分片临时文件 + * @return ETag + */ + String uploadPart(Long id, Integer partNumber, java.io.File partFile); + /** * 取消文件任务 * @param taskIds diff --git a/blade-service/blade-file/src/main/java/org/springblade/file/service/impl/FileTaskServiceImpl.java b/blade-service/blade-file/src/main/java/org/springblade/file/service/impl/FileTaskServiceImpl.java index b92fc66e..25a46bab 100644 --- a/blade-service/blade-file/src/main/java/org/springblade/file/service/impl/FileTaskServiceImpl.java +++ b/blade-service/blade-file/src/main/java/org/springblade/file/service/impl/FileTaskServiceImpl.java @@ -259,6 +259,33 @@ public class FileTaskServiceImpl extends ServiceImpl i return fileService.getUploadPartUrl(fileTask.getObjectKey(), fileTask.getUploadId(), partNumber, null); } + @Override + public String uploadPart(Long id, Integer partNumber, java.io.File partFile) { + FileTask fileTask = this.getById(id); + Func.requireNotNull(fileTask, "文件任务不存在"); + if (FileTaskStatus.COMPLETED.getCode().equals(fileTask.getStatus())) { + throw new ServiceException("文件任务已完成"); + } + if (FileTaskStatus.PAUSED.getCode().equals(fileTask.getStatus())) { + throw new ServiceException("文件任务已暂停"); + } + if (partFile == null || !partFile.exists()) { + throw new ServiceException("分片文件不存在"); + } + org.springblade.file.pojo.vo.PartEtagVO partEtag = fileService.uploadPart( + fileTask.getObjectKey(), + fileTask.getUploadId(), + partFile, + partNumber, + 0L, + partFile.length() + ); + if (partEtag == null || StringUtil.isBlank(partEtag.getEtag())) { + throw new ServiceException("分片上传未返回 ETag"); + } + return partEtag.getEtag().replace("\"", ""); + } + @Transactional(rollbackFor = Exception.class) @Override public void cancelBatchByIds(List taskIds) { diff --git a/blade-service/blade-file/src/main/java/org/springblade/file/service/impl/MinioFileServiceImpl.java b/blade-service/blade-file/src/main/java/org/springblade/file/service/impl/MinioFileServiceImpl.java index 5214c628..c7d2ed4e 100644 --- a/blade-service/blade-file/src/main/java/org/springblade/file/service/impl/MinioFileServiceImpl.java +++ b/blade-service/blade-file/src/main/java/org/springblade/file/service/impl/MinioFileServiceImpl.java @@ -25,11 +25,12 @@ */ package org.springblade.file.service.impl; +import io.minio.ComposeObjectArgs; +import io.minio.ComposeSource; import io.minio.GetObjectArgs; import io.minio.GetPresignedObjectUrlArgs; import io.minio.MinioClient; -import io.minio.ComposeObjectArgs; -import io.minio.ComposeSource; +import io.minio.ObjectWriteResponse; import io.minio.PutObjectArgs; import io.minio.RemoveObjectArgs; import io.minio.UploadObjectArgs; @@ -39,9 +40,13 @@ import lombok.extern.slf4j.Slf4j; import org.springblade.core.log.exception.ServiceException; import org.springblade.core.tool.utils.StringUtil; import org.springblade.file.config.FileStorageProperties; +import org.springblade.file.util.MinioClientFactory; +import org.springblade.file.pojo.vo.PartEtagVO; import java.io.File; +import java.io.FileInputStream; import java.io.InputStream; +import java.net.URI; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; @@ -61,9 +66,11 @@ import java.util.UUID; public class MinioFileServiceImpl extends AbstractFileService { private static final long PART_SIZE = 10 * 1024 * 1024L; + private static final String DEFAULT_REGION = MinioClientFactory.DEFAULT_REGION; private final FileStorageProperties.StoreProperties properties; private final MinioClient minioClient; + private volatile MinioClient transformPresignClient; @Override public String initiateMultipartUpload(String objectKey) { @@ -73,15 +80,50 @@ public class MinioFileServiceImpl extends AbstractFileService { @Override public String getUploadPartUrl(String objectKey, String uploadId, Integer partNumber, Long expires) { try { - return minioClient.getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder() - .method(Method.PUT).bucket(properties.getBucketName()).object(partObjectKey(objectKey, uploadId, partNumber)) - .expiry(Math.toIntExact(resolveExpires(expires))).build()); + String presignedUrl = resolvePresignClient().getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder() + .method(Method.PUT) + .bucket(properties.getBucketName()) + .object(partObjectKey(objectKey, uploadId, partNumber)) + .region(DEFAULT_REGION) + .expiry(Math.toIntExact(resolveExpires(expires))) + .build()); + return applyTransformPathPrefix(presignedUrl); } catch (Exception exception) { log.error("MinIO获取分片上传链接异常 objectKey:{}", objectKey, exception); throw new ServiceException("获取分片上传链接异常"); } } + @Override + public PartEtagVO uploadPart(String objectKey, String uploadId, File file, int partNumber, Long offset, Long partSize) { + long start = offset == null ? 0L : offset; + long size = partSize == null ? Math.max(0L, file.length() - start) : partSize; + try (FileInputStream inputStream = new FileInputStream(file)) { + long skipped = inputStream.skip(start); + if (skipped < start) { + throw new ServiceException("MinIO分段上传读取文件偏移失败"); + } + ObjectWriteResponse response = minioClient.putObject(PutObjectArgs.builder() + .bucket(properties.getBucketName()) + .object(partObjectKey(objectKey, uploadId, partNumber)) + .stream(inputStream, size, PART_SIZE) + .build()); + String etag = response.etag() == null ? "" : response.etag().replace("\"", ""); + return new PartEtagVO(etag, partNumber); + } catch (ServiceException serviceException) { + throw serviceException; + } catch (Exception exception) { + log.error("MinIO分段上传异常 objectKey:{} partNumber:{}", objectKey, partNumber, exception); + throw new ServiceException("MinIO分段上传异常"); + } + } + + @Override + public void cancelMultipartUpload(String objectKey, String uploadId) { + // MinIO 分片以独立对象模拟,取消时由任务层清理业务状态即可 + log.debug("MinIO取消分段上传 objectKey:{} uploadId:{}", objectKey, uploadId); + } + @Override public void completeMultipartUpload(String objectKey, String uploadId, List partEtags) { try { @@ -143,12 +185,14 @@ public class MinioFileServiceImpl extends AbstractFileService { return null; } try { - return minioClient.getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder() + String presignedUrl = resolvePresignClient().getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder() .method(Method.GET) .bucket(properties.getBucketName()) .object(resolveObjectKey(objectKey)) + .region(DEFAULT_REGION) .expiry(Math.toIntExact(resolveExpires(expires))) .build()); + return applyTransformPathPrefix(presignedUrl); } catch (Exception exception) { log.error("MinIO获取文件链接异常 objectKey:{}", objectKey, exception); throw new ServiceException("MinIO获取文件链接异常"); @@ -170,6 +214,62 @@ public class MinioFileServiceImpl extends AbstractFileService { } } + /** + * 浏览器侧预签名优先使用外网地址,避免 HTTPS 页面访问 HTTP MinIO 被 Mixed Content 拦截。 + * MinIO SDK 不允许 endpoint 带 path,因此外网地址中的 /oss 等前缀在签名后单独拼回。 + */ + private MinioClient resolvePresignClient() { + String transformEndpoint = properties.getTransformEndpoint(); + if (StringUtil.isBlank(transformEndpoint)) { + return minioClient; + } + MinioClient cachedClient = transformPresignClient; + if (cachedClient != null) { + return cachedClient; + } + synchronized (this) { + if (transformPresignClient == null) { + transformPresignClient = MinioClientFactory.create( + transformEndpoint, properties.getAccessKeyId(), properties.getAccessKeySecret()); + } + return transformPresignClient; + } + } + + private String applyTransformPathPrefix(String presignedUrl) { + String transformEndpoint = properties.getTransformEndpoint(); + if (StringUtil.isBlank(transformEndpoint) || StringUtil.isBlank(presignedUrl)) { + return presignedUrl; + } + String pathPrefix = normalizePathPrefix(URI.create(transformEndpoint.trim()).getPath()); + if (StringUtil.isBlank(pathPrefix)) { + return presignedUrl; + } + URI url = URI.create(presignedUrl); + String path = url.getRawPath() == null ? "" : url.getRawPath(); + if (path.equals(pathPrefix) || path.startsWith(pathPrefix + "/")) { + return presignedUrl; + } + StringBuilder result = new StringBuilder(); + result.append(url.getScheme()).append("://").append(url.getHost()); + if (url.getPort() > 0) { + result.append(':').append(url.getPort()); + } + result.append(pathPrefix).append(path); + if (StringUtil.isNotBlank(url.getRawQuery())) { + result.append('?').append(url.getRawQuery()); + } + return result.toString(); + } + + private String normalizePathPrefix(String path) { + if (StringUtil.isBlank(path) || "/".equals(path)) { + return ""; + } + String normalized = path.endsWith("/") ? path.substring(0, path.length() - 1) : path; + return normalized.startsWith("/") ? normalized : "/" + normalized; + } + private String resolveObjectKey(String objectKey) { if (StringUtil.isBlank(properties.getRootDirectory())) { return objectKey; diff --git a/blade-service/blade-file/src/main/java/org/springblade/file/util/MinioClientFactory.java b/blade-service/blade-file/src/main/java/org/springblade/file/util/MinioClientFactory.java new file mode 100644 index 00000000..384dc61e --- /dev/null +++ b/blade-service/blade-file/src/main/java/org/springblade/file/util/MinioClientFactory.java @@ -0,0 +1,121 @@ +/** + * 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.file.util; + +import io.minio.MinioClient; +import okhttp3.HttpUrl; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.core.tool.utils.StringUtil; + +import java.net.URI; +import java.util.concurrent.TimeUnit; + +/** + * MinIO 客户端构建。 + *

+ * SDK 不允许 endpoint 带 path;若配置为 {@code https://host/oss},则: + *

+ */ +public final class MinioClientFactory { + + public static final String DEFAULT_REGION = "us-east-1"; + + private MinioClientFactory() { + } + + public static MinioClient create(String endpoint, String accessKey, String secretKey) { + EndpointParts parts = parseEndpoint(endpoint); + MinioClient.Builder builder = MinioClient.builder() + .endpoint(parts.origin()) + .credentials(accessKey, secretKey) + .region(DEFAULT_REGION); + if (StringUtil.isNotBlank(parts.pathPrefix())) { + builder.httpClient(buildHttpClient(parts.pathPrefix()), true); + } + return builder.build(); + } + + public static String resolveOrigin(String endpoint) { + return parseEndpoint(endpoint).origin(); + } + + public static String resolvePathPrefix(String endpoint) { + return parseEndpoint(endpoint).pathPrefix(); + } + + private static EndpointParts parseEndpoint(String endpoint) { + if (StringUtil.isBlank(endpoint)) { + throw new ServiceException("MinIO endpoint 不能为空"); + } + URI uri = URI.create(endpoint.trim()); + if (StringUtil.isBlank(uri.getScheme()) || StringUtil.isBlank(uri.getHost())) { + throw new ServiceException("MinIO endpoint 配置不正确: " + endpoint); + } + StringBuilder origin = new StringBuilder(); + origin.append(uri.getScheme()).append("://").append(uri.getHost()); + if (uri.getPort() > 0) { + origin.append(':').append(uri.getPort()); + } + return new EndpointParts(origin.toString(), normalizePathPrefix(uri.getPath())); + } + + private static String normalizePathPrefix(String path) { + if (StringUtil.isBlank(path) || "/".equals(path)) { + return ""; + } + String normalized = path.endsWith("/") ? path.substring(0, path.length() - 1) : path; + return normalized.startsWith("/") ? normalized : "/" + normalized; + } + + private static OkHttpClient buildHttpClient(String pathPrefix) { + return new OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .addInterceptor(chain -> { + Request original = chain.request(); + HttpUrl originalUrl = original.url(); + String encodedPath = originalUrl.encodedPath(); + if (encodedPath.startsWith(pathPrefix + "/") || encodedPath.equals(pathPrefix)) { + return chain.proceed(original); + } + HttpUrl newUrl = originalUrl.newBuilder() + .encodedPath(pathPrefix + encodedPath) + .build(); + return chain.proceed(original.newBuilder().url(newUrl).build()); + }) + .build(); + } + + private record EndpointParts(String origin, String pathPrefix) { + } +} 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 f7c1829b..ba5bdf43 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 @@ -44,94 +44,79 @@ public interface DeptConvert { @Mapping(source = "subcompanyname", target = "deptName") @Mapping(source = "subcompanydesc", target = "fullName") - // 父公司id转为父部门编码,要拼接前缀 @Mapping(source = "supsubcomid", target = "parentCode") @Mapping(source = "created", target = "createTime", dateFormat = "yyyy-MM-dd HH:mm:ss") - // OA modified 单独落库,用于增量比对,避免覆盖本系统 updateTime @Mapping(source = "modified", target = "oaModified") Dept baseConvert(OACompanyResponse company); @Mapping(source = "departmentname", target = "deptName") @Mapping(source = "departmentmark", target = "fullName") - // 父部门id转为父部门编码,要拼接前缀 @Mapping(source = "supdepid", target = "parentCode") - // 所属子公司id转为所属公司编码,要拼接前缀 @Mapping(source = "subcompanyid1", target = "belongCompanyCode") @Mapping(source = "created", target = "createTime", dateFormat = "yyyy-MM-dd HH:mm:ss") - // OA modified 单独落库,用于增量比对,避免覆盖本系统 updateTime @Mapping(source = "modified", target = "oaModified") Dept baseConvert(OADepartmentResponse department); /** - * oa公司转本系统部门 - * @param company - * @return + * oa公司转本系统部门。组织编码由同步服务按 GWWL+三位数字 统一分配。 */ default Dept company2dept(OACompanyResponse company) { Dept dept = this.baseConvert(company); - // 部门编码添加前缀 - dept.setDeptCode(OAConvertConstant.COMPANY_OA_PREFIX + company.getId()); + // 编码稍后按规则生成;此处仅落 OA 主键,供按 oaId 匹配与重建层级 + dept.setDeptCode(null); String parentCode = dept.getParentCode(); if (!OAConstant.ROOT_COMPANY_ID.equals(parentCode)) { - // 父部门编码不是0,拼接前缀 - dept.setParentCode(OAConvertConstant.COMPANY_OA_PREFIX + parentCode); - // 父部门id后续处理 + // 非顶级:parentCode 暂存上级 OA 主键,分配编码后再替换为正式组织编码 + dept.setParentCode(parentCode); + dept.setParentId(null); } else { - // 父部门编码是0,父id也设置为0 - dept.setParentId(OAConvertConstant.ROOT_PARENT_ID); + dept.setParentCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE); + dept.setParentId(OAConvertConstant.OA_SYNC_ROOT_DEPT_ID); } - // 公司所属公司编码就是他自己的部门编码 - dept.setBelongCompanyCode(dept.getDeptCode()); - // OA主键与上级OA主键,分批同步结束后用于重建层级 + dept.setBelongCompanyCode(null); dept.setOaId(company.getId()); dept.setOaSupSubComId(company.getSupsubcomid()); - // 显示顺序处理 dept.setSort(OAUtils.parseInt(company.getShoworder())); - // 部门类型,公司 dept.setDeptCategory(DeptCategory.COMPANY.getCode()); dept.setSyncTime(new Date()); - // 是oa的 dept.setIsOa(CommonConstant.YES); return dept; } /** - * oa部门转本系统部门 - * @param oaDept - * @param companyDeptMap 所有公司的部门编码与id map - * @return + * oa部门转本系统部门。组织编码由同步服务按「所属公司编码+三位数字」统一分配。 + * + * @param oaDept OA部门 + * @param companyByOaId 已同步公司(oaId → 本地公司) */ - default Dept dept2dept(OADepartmentResponse oaDept, Map companyDeptMap) { + default Dept dept2dept(OADepartmentResponse oaDept, Map companyByOaId) { Dept dept = this.baseConvert(oaDept); - // 部门编码添加前缀 - dept.setDeptCode(OAConvertConstant.DEPARTMENT_OA_PREFIX + oaDept.getId()); - // OA主键;上级为根部门时记录所属公司OA主键,否则记录上级部门OA主键 + dept.setDeptCode(null); dept.setOaId(oaDept.getId()); if (OAConstant.ROOT_COMPANY_ID.equals(oaDept.getSupdepid())) { dept.setOaSupSubComId(oaDept.getSubcompanyid1()); } else { dept.setOaSupSubComId(oaDept.getSupdepid()); } - // 部门所属公司编码就是他所属公司编码加前缀 - dept.setBelongCompanyCode(OAConvertConstant.COMPANY_OA_PREFIX + dept.getBelongCompanyCode()); - String parentCode = dept.getParentCode(); - if (!OAConstant.ROOT_COMPANY_ID.equals(parentCode)) { - // 父部门编码不是0,拼接前缀 - dept.setParentCode(OAConvertConstant.DEPARTMENT_OA_PREFIX + parentCode); - // 父部门id后续处理 + Dept belongCompany = companyByOaId == null ? null : companyByOaId.get(oaDept.getSubcompanyid1()); + if (belongCompany != null) { + dept.setBelongCompanyCode(belongCompany.getDeptCode()); + dept.setParentId(null); + if (OAConstant.ROOT_COMPANY_ID.equals(oaDept.getSupdepid())) { + dept.setParentCode(belongCompany.getDeptCode()); + dept.setParentId(belongCompany.getId()); + } else { + // 上级为部门:parentCode 暂存上级部门 OA 主键,重建层级时再解析 + dept.setParentCode(oaDept.getSupdepid()); + } } else { - // 父部门编码是0,设置为公司编码 - dept.setParentCode(dept.getBelongCompanyCode()); - // 根据父部门编码获取对应的id - dept.setParentId(companyDeptMap.get(dept.getParentCode())); + // 所属公司尚未解析时,暂存 OA 公司主键,后续补齐正式编码 + dept.setBelongCompanyCode(oaDept.getSubcompanyid1()); + dept.setParentCode(oaDept.getSupdepid()); } - - // 显示顺序处理 dept.setSort(OAUtils.parseInt(oaDept.getShoworder())); - // 部门类型,部门 dept.setDeptCategory(DeptCategory.DEPT.getCode()); dept.setSyncTime(new Date()); - // 是oa的 dept.setIsOa(CommonConstant.YES); return dept; } 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 6decb305..00b2e576 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 @@ -88,6 +88,8 @@ public interface UserConvert { default User person2user(OAPersonResponse person, String defaultPassword) { User user = baseConvert(person); user.setAccount(resolveAccount(person)); + // OA 可能返回多号(如 133.../137...),只保留首个号码再入库加密 + user.setPhone(normalizeMobile(user.getPhone())); // 密码 user.setPassword(defaultPassword); // 性别 @@ -103,6 +105,23 @@ public interface UserConvert { return user; } + /** + * 规范化手机号:去空白,多号时取第一个 + */ + default String normalizeMobile(String mobile) { + if (StringUtils.isBlank(mobile)) { + return null; + } + String trimmed = mobile.trim(); + String[] parts = trimmed.split("[/|,;;,\\s]+"); + for (String part : parts) { + if (StringUtils.isNotBlank(part)) { + return part.trim(); + } + } + return null; + } + /** * 解析本系统登录账号:优先 OA loginid,其次工号,最后手机号 * 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 d08d36a6..edacf506 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 @@ -66,6 +66,8 @@ public class SysClient implements ISysClient { private final IFeeItemService feeItemService; private final ICargoTypeService cargoTypeService; + private final IMeasurementUnitService measurementUnitService; + private final IInvoiceItemService invoiceItemService; @Override @GetMapping(MENU) @@ -231,10 +233,27 @@ public class SysClient implements ISysClient { .orderByAsc(CargoType::getCargoCode))); } + @Override + @GetMapping(MEASUREMENT_UNITS) + public R> getMeasurementUnits() { + return R.data(measurementUnitService.list(Wrappers.lambdaQuery() + .eq(MeasurementUnit::getIsDeleted, 0) + .orderByAsc(MeasurementUnit::getUnitCode))); + } + @Override @GetMapping(PERMISSIONS) public R> getPermissions(String roleId) { return R.data(menuService.permissionCodes(roleId)); } + @Override + @GetMapping(INVOICE_ITEMS) + public R> getInvoiceItems() { + return R.data(invoiceItemService.list(Wrappers.lambdaQuery() + .eq(InvoiceItem::getStatus, 1) + .eq(InvoiceItem::getIsDeleted, 0) + .orderByAsc(InvoiceItem::getCategoryName, InvoiceItem::getShortName))); + } + } 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 group_concat(distinct bd.id) deptIds, diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/rule/tenant/TenantDeptRule.java b/blade-service/blade-system/src/main/java/org/springblade/system/rule/tenant/TenantDeptRule.java index ba893bfd..07a0ca8f 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/rule/tenant/TenantDeptRule.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/rule/tenant/TenantDeptRule.java @@ -30,6 +30,7 @@ import org.springblade.core.literule.core.RuleComponent; import org.springblade.core.tool.constant.BladeConstant; import org.springblade.system.pojo.entity.Dept; import org.springblade.system.pojo.entity.Tenant; +import org.springblade.system.pojo.enums.DeptCategory; import org.springblade.system.rule.context.TenantContext; import static org.springblade.system.rule.constant.TenantRuleConstant.TENANT_DEPT_RULE; @@ -54,7 +55,7 @@ public class TenantDeptRule extends RuleComponent { dept.setAncestors(String.valueOf(BladeConstant.TOP_PARENT_ID)); dept.setDeptName(tenant.getTenantName()); dept.setFullName(tenant.getTenantName()); - dept.setDeptCategory(1); + dept.setDeptCategory(DeptCategory.GROUP.getCode()); dept.setSort(2); dept.setIsDeleted(BladeConstant.DB_NOT_DELETED); diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/IDeptService.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/IDeptService.java index cee1baf1..d9094bf0 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/service/IDeptService.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/IDeptService.java @@ -149,12 +149,19 @@ public interface IDeptService extends IService { boolean removeDept(String ids); /** - * 删除非顶级组织,顶级组织(父级为 0)保留 + * 清除非保留顶级外的组织;保留 id=1 集团锚点,以及名称为「外部组织」的顶级 * * @return 删除数量 */ int clearNonTopDepts(); + /** + * 确保双顶级:id=1 集团(编码 GWWL)+ 独立「外部组织」顶级 + * + * @return 集团顶级组织(id=1) + */ + Dept ensureOaSyncRootDept(); + /** * 新增或修改部门 *

diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/DeptServiceImpl.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/DeptServiceImpl.java index 24f461de..d55f05e6 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/DeptServiceImpl.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/DeptServiceImpl.java @@ -27,12 +27,14 @@ package org.springblade.system.service.impl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.incrementer.DefaultIdentifierGenerator; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springblade.common.constant.CommonConstant; import org.springblade.common.constant.DataStatusEnum; import org.springblade.core.cache.utils.CacheUtil; import org.springblade.core.log.exception.ServiceException; @@ -389,16 +391,256 @@ public class DeptServiceImpl extends ServiceImpl implements ID @Override @Transactional(rollbackFor = Exception.class) public int clearNonTopDepts() { - Long nonTopCount = this.count(Wrappers.lambdaQuery() - .isNotNull(Dept::getParentId) - .ne(Dept::getParentId, BladeConstant.TOP_PARENT_ID)); - if (nonTopCount == null || nonTopCount == 0L) { + // 同步前清理:保留集团锚点 id=1,以及「外部组织」顶级;其余删除 + Dept groupRoot = this.ensureOaSyncRootDept(); + Dept externalRoot = this.ensureExternalOrgTopDept(); + Set keepIds = new HashSet<>(); + if (groupRoot != null && groupRoot.getId() != null) { + keepIds.add(groupRoot.getId()); + } + if (externalRoot != null && externalRoot.getId() != null) { + keepIds.add(externalRoot.getId()); + } + if (keepIds.isEmpty()) { return 0; } - this.remove(Wrappers.lambdaQuery() - .isNotNull(Dept::getParentId) - .ne(Dept::getParentId, BladeConstant.TOP_PARENT_ID)); - return nonTopCount.intValue(); + Long clearCount = this.count(Wrappers.lambdaQuery().notIn(Dept::getId, keepIds)); + if (clearCount == null || clearCount == 0L) { + return 0; + } + this.remove(Wrappers.lambdaQuery().notIn(Dept::getId, keepIds)); + return clearCount.intValue(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Dept ensureOaSyncRootDept() { + this.migrateLegacyOaDeptCategory(); + this.removeLegacyOaSyncRoots(); + this.migrateLegacyGroupRootToIdOne(); + Dept rootDept = this.getById(OAConvertConstant.OA_SYNC_ROOT_DEPT_ID); + String tenantId = AuthUtil.getTenantId(); + if (StringUtil.isBlank(tenantId)) { + tenantId = BladeConstant.ADMIN_TENANT_ID; + } + if (rootDept == null) { + rootDept = new Dept(); + rootDept.setId(OAConvertConstant.OA_SYNC_ROOT_DEPT_ID); + rootDept.setTenantId(tenantId); + rootDept.setParentId(BladeConstant.TOP_PARENT_ID); + rootDept.setAncestors(String.valueOf(BladeConstant.TOP_PARENT_ID)); + rootDept.setDeptName(OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME); + rootDept.setFullName(OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME); + rootDept.setShortName(OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME); + rootDept.setDeptCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE); + rootDept.setParentCode(String.valueOf(BladeConstant.TOP_PARENT_ID)); + rootDept.setBelongCompanyCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE); + rootDept.setDeptCategory(DeptCategory.GROUP.getCode()); + rootDept.setSort(0); + rootDept.setStatus(DataStatusEnum.ENABLE.getCode()); + rootDept.setIsDeleted(BladeConstant.DB_NOT_DELETED); + rootDept.setIsOa(0); + rootDept.setIsPlatformCompany(0); + rootDept.setSyncTime(new Date()); + this.save(rootDept); + log.info("已创建 OA 同步集团顶级 id={} name={} code={}", + OAConvertConstant.OA_SYNC_ROOT_DEPT_ID, + OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME, + OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE); + } else { + boolean wronglyNamedAsExternal = OAConvertConstant.EXTERNAL_ORG_NAME.equals(rootDept.getDeptName()); + boolean legacyNamed = OAConvertConstant.LEGACY_ROOT_DEPT_NAME.equals(rootDept.getDeptName()); + boolean needRename = wronglyNamedAsExternal || legacyNamed || StringUtil.isBlank(rootDept.getDeptName()) + || !OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME.equals(rootDept.getDeptName()); + boolean needUpdate = needRename + || !OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE.equals(rootDept.getDeptCode()) + || !DeptCategory.GROUP.getCode().equals(rootDept.getDeptCategory()) + || !Objects.equals(BladeConstant.TOP_PARENT_ID, rootDept.getParentId()); + if (needUpdate) { + Dept updateParam = new Dept(); + updateParam.setId(rootDept.getId()); + if (needRename) { + updateParam.setDeptName(OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME); + updateParam.setFullName(OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME); + updateParam.setShortName(OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME); + rootDept.setDeptName(OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME); + rootDept.setFullName(OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME); + rootDept.setShortName(OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME); + } + updateParam.setDeptCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE); + updateParam.setBelongCompanyCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE); + updateParam.setDeptCategory(DeptCategory.GROUP.getCode()); + updateParam.setParentId(BladeConstant.TOP_PARENT_ID); + updateParam.setAncestors(String.valueOf(BladeConstant.TOP_PARENT_ID)); + updateParam.setParentCode(String.valueOf(BladeConstant.TOP_PARENT_ID)); + updateParam.setIsOa(0); + this.updateById(updateParam); + rootDept.setDeptCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE); + rootDept.setBelongCompanyCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE); + rootDept.setDeptCategory(DeptCategory.GROUP.getCode()); + rootDept.setParentId(BladeConstant.TOP_PARENT_ID); + rootDept.setAncestors(String.valueOf(BladeConstant.TOP_PARENT_ID)); + rootDept.setParentCode(String.valueOf(BladeConstant.TOP_PARENT_ID)); + rootDept.setIsOa(0); + log.info("已校正 OA 同步集团顶级 id={} name={} code={} category={}", + rootDept.getId(), rootDept.getDeptName(), rootDept.getDeptCode(), rootDept.getDeptCategory()); + } + } + this.ensureExternalOrgTopDept(); + return rootDept; + } + + /** + * 将历史集团伪顶级 id=135 迁移到 id=1(子节点改挂,再删除旧锚点) + */ + private void migrateLegacyGroupRootToIdOne() { + Long legacyRootId = OAConvertConstant.LEGACY_OA_SYNC_ROOT_DEPT_ID; + Long newRootId = OAConvertConstant.OA_SYNC_ROOT_DEPT_ID; + if (Objects.equals(legacyRootId, newRootId)) { + return; + } + Dept legacyRoot = this.getById(legacyRootId); + if (legacyRoot == null) { + return; + } + // 仅迁移历史伪顶级;若 id=135 已是真实 OA 公司记录则跳过删除 + boolean isOaCompanyRecord = StringUtil.isNotBlank(legacyRoot.getOaId()) + && DeptCategory.COMPANY.getCode().equals(legacyRoot.getDeptCategory()) + && (Objects.equals(legacyRoot.getIsOa(), CommonConstant.YES) || Objects.equals(legacyRoot.getIsOa(), 1)); + if (isOaCompanyRecord) { + log.warn("历史 id={} 已是 OA 同步公司(oaId={}),跳过伪顶级迁移", legacyRootId, legacyRoot.getOaId()); + return; + } + Dept newRoot = this.getById(newRootId); + String tenantId = AuthUtil.getTenantId(); + if (StringUtil.isBlank(tenantId)) { + tenantId = BladeConstant.ADMIN_TENANT_ID; + } + if (newRoot == null) { + newRoot = new Dept(); + newRoot.setId(newRootId); + newRoot.setTenantId(StringUtil.isNotBlank(legacyRoot.getTenantId()) ? legacyRoot.getTenantId() : tenantId); + newRoot.setParentId(BladeConstant.TOP_PARENT_ID); + newRoot.setAncestors(String.valueOf(BladeConstant.TOP_PARENT_ID)); + newRoot.setDeptName(OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME); + newRoot.setFullName(OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME); + newRoot.setShortName(OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME); + newRoot.setDeptCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE); + newRoot.setParentCode(String.valueOf(BladeConstant.TOP_PARENT_ID)); + newRoot.setBelongCompanyCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE); + newRoot.setDeptCategory(DeptCategory.GROUP.getCode()); + newRoot.setSort(0); + newRoot.setStatus(DataStatusEnum.ENABLE.getCode()); + newRoot.setIsDeleted(BladeConstant.DB_NOT_DELETED); + newRoot.setIsOa(0); + newRoot.setIsPlatformCompany(0); + newRoot.setSyncTime(new Date()); + this.save(newRoot); + log.info("迁移创建新集团顶级 id={}(来自历史 id={})", newRootId, legacyRootId); + } + this.update(Wrappers.lambdaUpdate() + .set(Dept::getParentId, newRootId) + .set(Dept::getParentCode, OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE) + .eq(Dept::getParentId, legacyRootId)); + this.removeById(legacyRootId); + log.info("已将历史集团顶级 id={} 迁移到 id={},并删除旧锚点", legacyRootId, newRootId); + } + + /** + * 确保存在独立的「外部组织」顶级(与 id=1 集团并存,仅保留一个同名顶级) + */ + private Dept ensureExternalOrgTopDept() { + List externalTops = this.list(Wrappers.lambdaQuery() + .eq(Dept::getDeptName, OAConvertConstant.EXTERNAL_ORG_NAME) + .and(wrapper -> wrapper + .isNull(Dept::getParentId) + .or() + .eq(Dept::getParentId, BladeConstant.TOP_PARENT_ID)) + .ne(Dept::getId, OAConvertConstant.OA_SYNC_ROOT_DEPT_ID) + .orderByAsc(Dept::getId)); + if (CollectionUtil.isNotEmpty(externalTops)) { + Dept keep = externalTops.get(0); + if (externalTops.size() > 1) { + List duplicateIds = externalTops.stream() + .skip(1) + .map(Dept::getId) + .filter(Objects::nonNull) + .toList(); + if (CollectionUtil.isNotEmpty(duplicateIds)) { + this.update(Wrappers.lambdaUpdate() + .set(Dept::getParentId, keep.getId()) + .in(Dept::getParentId, duplicateIds)); + this.removeByIds(duplicateIds); + log.info("已合并重复的外部组织顶级,保留 id={},删除{}", keep.getId(), duplicateIds); + } + } + return keep; + } + String tenantId = AuthUtil.getTenantId(); + if (StringUtil.isBlank(tenantId)) { + tenantId = BladeConstant.ADMIN_TENANT_ID; + } + Dept externalDept = new Dept(); + externalDept.setId(DefaultIdentifierGenerator.getInstance().nextId(null)); + externalDept.setTenantId(tenantId); + externalDept.setParentId(BladeConstant.TOP_PARENT_ID); + externalDept.setAncestors(String.valueOf(BladeConstant.TOP_PARENT_ID)); + externalDept.setDeptName(OAConvertConstant.EXTERNAL_ORG_NAME); + externalDept.setFullName(OAConvertConstant.EXTERNAL_ORG_NAME); + externalDept.setShortName(OAConvertConstant.EXTERNAL_ORG_NAME); + externalDept.setDeptCode(null); + externalDept.setParentCode(String.valueOf(BladeConstant.TOP_PARENT_ID)); + externalDept.setDeptCategory(DeptCategory.GROUP.getCode()); + externalDept.setSort(0); + externalDept.setStatus(DataStatusEnum.ENABLE.getCode()); + externalDept.setIsDeleted(BladeConstant.DB_NOT_DELETED); + externalDept.setIsOa(0); + externalDept.setIsPlatformCompany(0); + externalDept.setSyncTime(new Date()); + this.save(externalDept); + log.info("已创建外部组织顶级 id={}", externalDept.getId()); + return externalDept; + } + + /** + * 清理历史伪顶级(OACROOT);同名旧集团仅清理非 id=1 的顶级 + */ + private void removeLegacyOaSyncRoots() { + List legacyRoots = this.list(Wrappers.lambdaQuery() + .ne(Dept::getId, OAConvertConstant.OA_SYNC_ROOT_DEPT_ID) + .ne(Dept::getId, OAConvertConstant.LEGACY_OA_SYNC_ROOT_DEPT_ID) + .and(wrapper -> wrapper + .eq(Dept::getDeptCode, OAConvertConstant.LEGACY_ROOT_DEPT_CODE) + .or(nameWrapper -> nameWrapper + .eq(Dept::getDeptName, OAConvertConstant.LEGACY_ROOT_DEPT_NAME) + .eq(Dept::getDeptCode, OAConvertConstant.LEGACY_ROOT_DEPT_CODE) + .and(parentWrapper -> parentWrapper + .isNull(Dept::getParentId) + .or() + .eq(Dept::getParentId, BladeConstant.TOP_PARENT_ID))))); + if (CollectionUtil.isEmpty(legacyRoots)) { + return; + } + Set legacyIds = legacyRoots.stream().map(Dept::getId).filter(Objects::nonNull).collect(Collectors.toSet()); + this.update(Wrappers.lambdaUpdate() + .set(Dept::getParentId, OAConvertConstant.OA_SYNC_ROOT_DEPT_ID) + .set(Dept::getParentCode, OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE) + .in(Dept::getParentId, legacyIds)); + this.removeByIds(legacyIds); + log.info("已清理历史伪顶级组织{}条:{}", legacyIds.size(), legacyIds); + } + + /** + * 按编码前缀对齐组织类型:OAC→公司、OAD→部门(覆盖历史 1/2 映射) + */ + private void migrateLegacyOaDeptCategory() { + this.update(Wrappers.lambdaUpdate() + .set(Dept::getDeptCategory, DeptCategory.DEPT.getCode()) + .likeRight(Dept::getDeptCode, OAConvertConstant.DEPARTMENT_OA_PREFIX)); + this.update(Wrappers.lambdaUpdate() + .set(Dept::getDeptCategory, DeptCategory.COMPANY.getCode()) + .likeRight(Dept::getDeptCode, OAConvertConstant.COMPANY_OA_PREFIX) + .ne(Dept::getDeptCode, OAConvertConstant.LEGACY_ROOT_DEPT_CODE)); } @Override @@ -650,9 +892,16 @@ public class DeptServiceImpl extends ServiceImpl implements ID if (StringUtil.isBlank(parent.getDeptCode())) { throw new ServiceException("请选择已配置组织编码的上级组织"); } - String pattern = Pattern.quote(parent.getDeptCode()) + "-\\d+"; - if (!deptCode.matches(pattern)) { - throw new ServiceException("组织编码格式应为:上级编码-分段数字"); + // 公司:GWWL+三位数字;部门:所属公司编码+三位数字;其他:上级编码+三位数字 + String expectedParentCode = parent.getDeptCode(); + if (DeptCategory.COMPANY.getCode().equals(dept.getDeptCategory())) { + expectedParentCode = OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE; + } else if (DeptCategory.DEPT.getCode().equals(dept.getDeptCategory()) + && StringUtil.isNotBlank(dept.getBelongCompanyCode())) { + expectedParentCode = dept.getBelongCompanyCode(); + } + if (!org.springblade.system.util.DeptCodeUtil.matchesChildCode(expectedParentCode, deptCode)) { + throw new ServiceException("组织编码格式应为:" + expectedParentCode + "三位数字(不足补零,中间不加-)"); } this.ensureDeptCodeUnique(dept, deptCode); dept.setDeptCode(deptCode); @@ -724,8 +973,8 @@ public class DeptServiceImpl extends ServiceImpl implements ID log.info("没有记录OA主键的组织,跳过层级重建"); return; } - Map companyByOaId = this.indexByOaId(oaDeptList, DeptCategory.COMPANY.getCode()); - Map departmentByOaId = this.indexByOaId(oaDeptList, DeptCategory.DEPT.getCode()); + Map companyByOaId = this.indexOaCompanies(oaDeptList); + Map departmentByOaId = this.indexOaDepartments(oaDeptList); Map desiredParentIdMap = new LinkedHashMap<>(); Map desiredParentCodeMap = new HashMap<>(); for (Dept dept : oaDeptList) { @@ -778,21 +1027,20 @@ public class DeptServiceImpl extends ServiceImpl implements ID if (CollectionUtil.isEmpty(oaDeptList)) { return 0; } - Map companyByOaId = this.indexByOaId(oaDeptList, DeptCategory.COMPANY.getCode()); - Map departmentByOaId = this.indexByOaId(oaDeptList, DeptCategory.DEPT.getCode()); + Map companyByOaId = this.indexOaCompanies(oaDeptList); + Map departmentByOaId = this.indexOaDepartments(oaDeptList); Set deleteIds = new HashSet<>(); for (Dept dept : oaDeptList) { String parentOaId = dept.getOaSupSubComId(); - // 上级为空或为 0:视为本地锚点/根级,不作为孤儿删除 + // 上级为空或为 0:OA 顶级公司挂本地锚点,不作为孤儿 if (StringUtil.isBlank(parentOaId) || OAConstant.ROOT_COMPANY_ID.equals(parentOaId)) { continue; } - boolean parentExists; - if (DeptCategory.COMPANY.getCode().equals(dept.getDeptCategory())) { - parentExists = companyByOaId.containsKey(parentOaId); - } else { - parentExists = departmentByOaId.containsKey(parentOaId) || companyByOaId.containsKey(parentOaId); - } + boolean isCompany = this.isOaCompany(dept); + boolean parentExists = isCompany + ? companyByOaId.containsKey(parentOaId) + : departmentByOaId.containsKey(parentOaId) || companyByOaId.containsKey(parentOaId); + // 全量数据整理后仍找不到上级:公司、部门一律删除,不改挂保留 if (!parentExists) { deleteIds.add(dept.getId()); } @@ -813,7 +1061,7 @@ public class DeptServiceImpl extends ServiceImpl implements ID if (StringUtil.isBlank(parentOaId) || OAConstant.ROOT_COMPANY_ID.equals(parentOaId)) { continue; } - Dept parent = DeptCategory.COMPANY.getCode().equals(dept.getDeptCategory()) + Dept parent = this.isOaCompany(dept) ? companyByOaId.get(parentOaId) : Optional.ofNullable(departmentByOaId.get(parentOaId)).orElse(companyByOaId.get(parentOaId)); if (parent != null && deleteIds.contains(parent.getId())) { @@ -842,6 +1090,72 @@ public class DeptServiceImpl extends ServiceImpl implements ID return deleteIds.size(); } + /** + * 索引 OA 公司:同时兼容类型迁移前后的公司记录 + */ + private Map indexOaCompanies(List oaDeptList) { + Map oaIdMap = new HashMap<>(); + for (Dept dept : oaDeptList) { + if (!this.isOaCompany(dept) || StringUtil.isBlank(dept.getOaId())) { + continue; + } + Dept previous = oaIdMap.put(dept.getOaId(), dept); + if (previous != null) { + log.warn("OA公司主键重复,层级重建使用后一条 oaId={} id={} previousId={}", + dept.getOaId(), dept.getId(), previous.getId()); + } + } + return oaIdMap; + } + + private boolean isOaCompany(Dept dept) { + if (dept == null) { + return false; + } + if (DeptCategory.COMPANY.getCode().equals(dept.getDeptCategory())) { + return true; + } + if (org.springblade.system.util.DeptCodeUtil.isCompanyCode(dept.getDeptCode())) { + return true; + } + return StringUtil.isNotBlank(dept.getDeptCode()) + && dept.getDeptCode().startsWith(OAConvertConstant.COMPANY_OA_PREFIX) + && !OAConvertConstant.LEGACY_ROOT_DEPT_CODE.equals(dept.getDeptCode()); + } + + private boolean isOaDepartment(Dept dept) { + if (dept == null) { + return false; + } + if (DeptCategory.DEPT.getCode().equals(dept.getDeptCategory())) { + return true; + } + if (StringUtil.isNotBlank(dept.getBelongCompanyCode()) + && org.springblade.system.util.DeptCodeUtil.matchesChildCode(dept.getBelongCompanyCode(), dept.getDeptCode())) { + return true; + } + return StringUtil.isNotBlank(dept.getDeptCode()) + && dept.getDeptCode().startsWith(OAConvertConstant.DEPARTMENT_OA_PREFIX); + } + + /** + * 索引 OA 部门:兼容类型迁移前后 + */ + private Map indexOaDepartments(List oaDeptList) { + Map oaIdMap = new HashMap<>(); + for (Dept dept : oaDeptList) { + if (!this.isOaDepartment(dept) || StringUtil.isBlank(dept.getOaId())) { + continue; + } + Dept previous = oaIdMap.put(dept.getOaId(), dept); + if (previous != null) { + log.warn("OA部门主键重复,层级重建使用后一条 oaId={} id={} previousId={}", + dept.getOaId(), dept.getId(), previous.getId()); + } + } + return oaIdMap; + } + /** * 按机构类型建立 OA 主键索引 */ @@ -866,19 +1180,22 @@ public class DeptServiceImpl extends ServiceImpl implements ID private OaParentLink resolveOaParentLink(Dept dept, Map companyByOaId, Map departmentByOaId) { String parentOaId = dept.getOaSupSubComId(); boolean rootParent = StringUtil.isBlank(parentOaId) || OAConstant.ROOT_COMPANY_ID.equals(parentOaId); - if (DeptCategory.COMPANY.getCode().equals(dept.getDeptCategory())) { + if (this.isOaCompany(dept)) { if (rootParent) { - return new OaParentLink(OAConvertConstant.ROOT_PARENT_ID, OAConstant.ROOT_COMPANY_ID); + // OA 顶级公司统一挂到本地顶级锚点下 + return new OaParentLink(OAConvertConstant.OA_SYNC_ROOT_DEPT_ID, OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE); } Dept parent = companyByOaId.get(parentOaId); if (parent == null || parent.getId() == null || Objects.equals(parent.getId(), dept.getId())) { + // 全量整理后仍无上级:跳过重建,交由孤儿清理直接删除 log.warn("未找到上级公司,跳过层级重建 oaId={} oaSupSubComId={} deptId={}", dept.getOaId(), parentOaId, dept.getId()); return null; } return new OaParentLink(parent.getId(), this.resolveParentCode(parent, OAConvertConstant.COMPANY_OA_PREFIX, parentOaId)); } - if (!DeptCategory.DEPT.getCode().equals(dept.getDeptCategory())) { + if (!DeptCategory.DEPT.getCode().equals(dept.getDeptCategory()) + && !(StringUtil.isNotBlank(dept.getDeptCode()) && dept.getDeptCode().startsWith(OAConvertConstant.DEPARTMENT_OA_PREFIX))) { return null; } if (rootParent) { diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/MeasurementUnitServiceImpl.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/MeasurementUnitServiceImpl.java index 4c43c474..ab0d639a 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/MeasurementUnitServiceImpl.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/MeasurementUnitServiceImpl.java @@ -62,10 +62,42 @@ public class MeasurementUnitServiceImpl extends BaseServiceImpl { + // 0. 确保双顶级:id=1 集团(GWWL) + 独立「外部组织」 + deptService.ensureOaSyncRootDept(); // 1. 同步公司 this.syncCompany(queryStartTime); - // 公司分批入库后,重建层级并清理无法挂载的公司 + // 公司分批入库后:清理白名单外公司、重建层级并清理无法挂载的公司 + this.removeOutsideWhitelistOrgs(); deptService.rebuildOaHierarchy(); deptService.removeOaOrphansWithoutParent(); // 2. 同步部门 this.syncDept(queryStartTime); // 3. 分批写入完成后,按 OA 主键重建组织、部门层级 + this.removeOutsideWhitelistOrgs(); deptService.rebuildOaHierarchy(); deptService.removeOaOrphansWithoutParent(); // 4. 更新部门祖级列表 @@ -290,6 +296,10 @@ public class OASyncServiceImpl implements IOASyncService { private OaPersonSyncPageVO syncPersonFromOaPage(int current, int size, boolean incremental, String modifiedSince) { int pageNo = current < 1 ? 1 : current; int pageSize = size < 1 ? 20 : Math.min(size, 200); + // 全量同步首页开启原始数据收集,结束后按父级 id 写入 JSON + if (!incremental && pageNo == 1) { + oaSyncRawDumpHelper.beginFullDump(); + } IncrementalStart incrementalStart = resolvePersonIncrementalStart(incremental, modifiedSince); OAPersonSearch personSearch = buildPersonSearch(incrementalStart.getStartTime()); personSearch.setCurPage(pageNo); @@ -303,6 +313,9 @@ public class OASyncServiceImpl implements IOASyncService { List oaPersons = responseData.getDataList() == null ? Collections.emptyList() : responseData.getDataList(); long totalSize = responseData.getTotalSize() == null ? 0L : responseData.getTotalSize(); + if (!incremental) { + oaSyncRawDumpHelper.collectPersons(oaPersons); + } OaUserListSyncHelper.OaOrgIndex orgIndex = oaUserListSyncHelper.syncOrgsFromPersons(oaPersons); OaUserListSyncHelper.PersonSyncCount personSyncCount = oaUserListSyncHelper.handlePerson(oaPersons, orgIndex); CacheUtil.clear(USER_CACHE); @@ -320,6 +333,9 @@ public class OASyncServiceImpl implements IOASyncService { || oaPersons.size() < pageSize || (long) pageNo * pageSize >= totalSize; pageVO.setFinished(finished); + if (!incremental && finished) { + pageVO.setDumpFilePath(oaSyncRawDumpHelper.finishFullDump()); + } ComposeLogUtil.getLastLog().info("OA人员分页同步完成 mode={} {}/{},成功{},跳过{}", incremental ? "incremental" : "full", pageNo, totalSize, personSyncCount.getSyncedCount(), personSyncCount.getSkippedCount()); @@ -338,6 +354,13 @@ public class OASyncServiceImpl implements IOASyncService { private OaOrgSyncPageVO syncCompanyFromOaPage(int current, int size, boolean incremental, String modifiedSince) { int pageNo = current < 1 ? 1 : current; int pageSize = size < 1 ? 20 : Math.min(size, 200); + // 首屏确保双顶级:id=1 集团(GWWL) + 独立「外部组织」 + if (pageNo == 1) { + deptService.ensureOaSyncRootDept(); + if (!incremental) { + oaSyncRawDumpHelper.beginOrgFullDump(); + } + } IncrementalStart incrementalStart = resolveIncrementalStart(incremental, modifiedSince, DeptCategory.COMPANY); OACompanySearch companySearch = buildCompanySearch(incrementalStart.getStartTime()); companySearch.setCurPage(pageNo); @@ -351,6 +374,9 @@ public class OASyncServiceImpl implements IOASyncService { List oaCompanies = responseData.getDataList() == null ? Collections.emptyList() : responseData.getDataList(); long totalSize = responseData.getTotalSize() == null ? 0L : responseData.getTotalSize(); + if (!incremental) { + oaSyncRawDumpHelper.collectCompanies(oaCompanies); + } OrgSyncCount syncCount = handleCompany(oaCompanies, true); if (CollectionUtil.isNotEmpty(syncCount.getNotHandledList())) { ComposeLogUtil.getLastLog().warn("同步公司,本页未处理数据:{}", JSON.toJSONString(syncCount.getNotHandledList())); @@ -358,7 +384,8 @@ public class OASyncServiceImpl implements IOASyncService { OaOrgSyncPageVO pageVO = buildOrgSyncPageVO("company", pageNo, pageSize, totalSize, oaCompanies.size(), syncCount); pageVO.setModifiedSince(incrementalStart.getModifiedSince()); if (Boolean.TRUE.equals(pageVO.getFinished())) { - // 公司全部分批入库后:重建层级,并删除上级不存在的数据 + // 公司全部分批入库后:清理白名单外公司、重建层级,并删除上级不存在的数据 + this.removeOutsideWhitelistOrgs(); deptService.rebuildOaHierarchy(); deptService.removeOaOrphansWithoutParent(); } @@ -387,6 +414,9 @@ public class OASyncServiceImpl implements IOASyncService { ComposeLogUtil.getLastLog().warn("未查询到已同步的OA公司,跳过部门同步"); OaOrgSyncPageVO emptyPageVO = buildOrgSyncPageVO("department", pageNo, pageSize, 0L, 0, OrgSyncCount.empty()); emptyPageVO.setFinished(true); + if (!incremental) { + emptyPageVO.setDumpFilePath(oaSyncRawDumpHelper.finishOrgFullDump()); + } return emptyPageVO; } IncrementalStart incrementalStart = resolveIncrementalStart(incremental, modifiedSince, DeptCategory.DEPT); @@ -402,6 +432,9 @@ public class OASyncServiceImpl implements IOASyncService { List oaDepartments = responseData.getDataList() == null ? Collections.emptyList() : responseData.getDataList(); long totalSize = responseData.getTotalSize() == null ? 0L : responseData.getTotalSize(); + if (!incremental) { + oaSyncRawDumpHelper.collectDepartments(oaDepartments); + } OrgSyncCount syncCount = handleDept(oaDepartments, true); if (CollectionUtil.isNotEmpty(syncCount.getNotHandledList())) { ComposeLogUtil.getLastLog().warn("同步部门,本页未处理数据:{}", JSON.toJSONString(syncCount.getNotHandledList())); @@ -409,10 +442,14 @@ public class OASyncServiceImpl implements IOASyncService { OaOrgSyncPageVO pageVO = buildOrgSyncPageVO("department", pageNo, pageSize, totalSize, oaDepartments.size(), syncCount); pageVO.setModifiedSince(incrementalStart.getModifiedSince()); if (Boolean.TRUE.equals(pageVO.getFinished())) { - // 部门分批同步完成后,重建层级、清理无法挂载数据并刷新祖级列表 + // 部门分批同步完成后,清理白名单外组织、重建层级、清理无法挂载数据并刷新祖级列表 + this.removeOutsideWhitelistOrgs(); deptService.rebuildOaHierarchy(); deptService.removeOaOrphansWithoutParent(); deptService.updateAncestors(null); + if (!incremental) { + pageVO.setDumpFilePath(oaSyncRawDumpHelper.finishOrgFullDump()); + } } CacheUtil.clear(SYS_CACHE); CacheUtil.clear(SYS_CACHE, Boolean.FALSE); @@ -437,10 +474,16 @@ public class OASyncServiceImpl implements IOASyncService { ComposeLogUtil.getLastLog().warn("解析回传 modifiedSince 失败:{},重新计算", modifiedSince); } } + String codePrefix = DeptCategory.COMPANY.equals(deptCategory) + ? OAConvertConstant.COMPANY_OA_PREFIX + : OAConvertConstant.DEPARTMENT_OA_PREFIX; Dept latest = deptService.getOne(Wrappers.lambdaQuery() .select(Dept::getOaModified) .eq(Dept::getIsOa, YES) - .eq(Dept::getDeptCategory, deptCategory.getCode()) + .and(wrapper -> wrapper + .eq(Dept::getDeptCategory, deptCategory.getCode()) + .or() + .likeRight(Dept::getDeptCode, codePrefix)) .isNotNull(Dept::getOaModified) .ne(Dept::getOaModified, "") .orderByDesc(Dept::getOaModified) @@ -608,13 +651,9 @@ public class OASyncServiceImpl implements IOASyncService { if (CollectionUtil.isEmpty(oaCompanies)) { return OrgSyncCount.empty(); } - // 获取需要的公司名称 Set companyNames = syncAll ? Collections.emptySet() : getCompanyNames(); - // 转换数据 List allParam = oaCompanies.stream() - // 强制跳过上级公司为 0 的数据 - .filter(company -> !OAConstant.ROOT_COMPANY_ID.equals(company.getSupsubcomid())) - // 非全量同步时,筛选只要配置的公司 + .filter(this::isAllowedOaCompany) .filter(company -> syncAll || companyNames.contains(company.getSubcompanyname())) .map(deptConvert::company2dept) .toList(); @@ -623,10 +662,41 @@ public class OASyncServiceImpl implements IOASyncService { return new OrgSyncCount(0, filteredSkipCount, Collections.emptyList()); } - // 查询已存在公司(含 oaModified),按编码索引 - Map existingDeptMap = loadExistingDeptMap(DeptCategory.COMPANY, null); - Map deptIdByCode = toDeptIdMap(existingDeptMap); - return persistSyncedDepts(allParam, existingDeptMap, deptIdByCode, DeptCategory.COMPANY, filteredSkipCount); + Map existingByOaId = loadExistingByOaId(DeptCategory.COMPANY); + Map codeOwnerMap = loadCompanyCodeOwnerMap(); + Set usedCodes = new HashSet<>(codeOwnerMap.keySet()); + Map batchByOaId = new LinkedHashMap<>(); + for (Dept dept : allParam) { + this.assignCompanyCode(dept, existingByOaId, usedCodes); + batchByOaId.put(dept.getOaId(), dept); + } + // 上级公司编码:OA 上级为 0 → GWWL;否则取上级公司已分配编码 + for (Dept dept : allParam) { + if (OAConstant.ROOT_COMPANY_ID.equals(dept.getOaSupSubComId()) || StringUtils.isBlank(dept.getOaSupSubComId())) { + dept.setParentCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE); + dept.setParentId(OAConvertConstant.OA_SYNC_ROOT_DEPT_ID); + continue; + } + Dept parent = batchByOaId.get(dept.getOaSupSubComId()); + if (parent == null) { + parent = existingByOaId.get(dept.getOaSupSubComId()); + } + if (parent != null && StringUtils.isNotBlank(parent.getDeptCode())) { + dept.setParentCode(parent.getDeptCode()); + dept.setParentId(parent.getId()); + } else { + dept.setParentCode(null); + dept.setParentId(null); + } + } + Map deptIdByCode = new HashMap<>(); + existingByOaId.values().forEach(dept -> { + if (StringUtils.isNotBlank(dept.getDeptCode()) && dept.getId() != null) { + deptIdByCode.put(dept.getDeptCode(), dept.getId()); + } + }); + deptIdByCode.put(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE, OAConvertConstant.OA_SYNC_ROOT_DEPT_ID); + return persistSyncedDepts(allParam, existingByOaId, deptIdByCode, DeptCategory.COMPANY, filteredSkipCount); } /** @@ -639,103 +709,241 @@ public class OASyncServiceImpl implements IOASyncService { if (CollectionUtil.isEmpty(oaDepts)) { return OrgSyncCount.empty(); } - // 查询所有公司的编码和id的map - Map companyDeptMap = getAllCompanyDeptMap(); - // 根公司id + Map companyByOaId = loadCompanyByOaId(); + Set allowedCompanyOaIds = resolveAllowedCompanyOaIds(companyByOaId); String rootCompanyId = getRootCompanyId(); if (!syncAll && rootCompanyId == null) { return new OrgSyncCount(0, oaDepts.size(), Collections.emptyList()); } - // 根公司下要同步的部门名称 Set rootCompanyDeptNames = syncAll ? Collections.emptySet() : getRootCompanyDeptNames(); - // 转换数据 List allParam = oaDepts.stream() - // 全量同步时不过滤;否则:部门的公司不是根公司,或者部门名称在根公司下需要同步的部门列表中,且是根部门 + .filter(oaDept -> allowedCompanyOaIds.contains(oaDept.getSubcompanyid1())) .filter(oaDept -> syncAll || rootCompanyId == null || !rootCompanyId.equals(oaDept.getSubcompanyid1()) || (OAConstant.ROOT_COMPANY_ID.equals(oaDept.getSupdepid()) && rootCompanyDeptNames.contains(oaDept.getDepartmentname()))) - .map(dept -> deptConvert.dept2dept(dept, companyDeptMap)) + .map(dept -> deptConvert.dept2dept(dept, companyByOaId)) .toList(); int filteredSkipCount = oaDepts.size() - allParam.size(); if (CollectionUtil.isEmpty(allParam)) { return new OrgSyncCount(0, filteredSkipCount, Collections.emptyList()); } - Set deptCodes = new HashSet<>(); - allParam.forEach(dept -> { - if (StringUtils.isNotEmpty(dept.getDeptCode())) { - deptCodes.add(dept.getDeptCode()); + + Map existingByOaId = loadExistingByOaId(DeptCategory.DEPT); + Map nextSerialByCompany = new HashMap<>(); + Map> usedCodesByCompany = new HashMap<>(); + // 预载各公司下已有部门编码,保证分段连续 + for (Dept existing : existingByOaId.values()) { + if (StringUtils.isBlank(existing.getBelongCompanyCode()) || StringUtils.isBlank(existing.getDeptCode())) { + continue; } - if (StringUtils.isNotEmpty(dept.getParentCode())) { - deptCodes.add(dept.getParentCode()); + usedCodesByCompany + .computeIfAbsent(existing.getBelongCompanyCode(), key -> new HashSet<>()) + .add(existing.getDeptCode()); + } + Map batchByOaId = new LinkedHashMap<>(); + for (Dept dept : allParam) { + this.assignDepartmentCode(dept, existingByOaId, companyByOaId, usedCodesByCompany, nextSerialByCompany); + batchByOaId.put(dept.getOaId(), dept); + } + // 解析上级:根部门挂公司;子部门挂上级部门(本批或已存在) + Map deptIdByCode = new HashMap<>(); + companyByOaId.values().forEach(company -> { + if (StringUtils.isNotBlank(company.getDeptCode()) && company.getId() != null) { + deptIdByCode.put(company.getDeptCode(), company.getId()); } }); - // 查询数据库已有部门(含 oaModified) - Map existingDeptMap = loadExistingDeptMap(DeptCategory.DEPT, deptCodes); - Map deptIdByCode = toDeptIdMap(existingDeptMap); - // 公司编码也放入 map,根部门挂到公司时才能解析父级 - companyDeptMap.forEach(deptIdByCode::putIfAbsent); - return persistSyncedDepts(allParam, existingDeptMap, deptIdByCode, DeptCategory.DEPT, filteredSkipCount); + existingByOaId.values().forEach(dept -> { + if (StringUtils.isNotBlank(dept.getDeptCode()) && dept.getId() != null) { + deptIdByCode.put(dept.getDeptCode(), dept.getId()); + } + }); + for (Dept dept : allParam) { + if (dept.getParentId() != null && StringUtils.isNotBlank(dept.getParentCode()) + && DeptCodeUtil.matchesChildCode(dept.getBelongCompanyCode(), dept.getDeptCode()) + && dept.getBelongCompanyCode().equals(dept.getParentCode())) { + continue; + } + String parentOaId = dept.getOaSupSubComId(); + Dept belongCompany = companyByOaId.values().stream() + .filter(company -> StringUtils.equals(company.getDeptCode(), dept.getBelongCompanyCode())) + .findFirst() + .orElse(null); + if (belongCompany != null && (OAConstant.ROOT_COMPANY_ID.equals(parentOaId) + || StringUtils.equals(parentOaId, belongCompany.getOaId()))) { + dept.setParentCode(belongCompany.getDeptCode()); + dept.setParentId(belongCompany.getId()); + continue; + } + Dept parentDept = batchByOaId.get(parentOaId); + if (parentDept == null) { + parentDept = existingByOaId.get(parentOaId); + } + if (parentDept != null && StringUtils.isNotBlank(parentDept.getDeptCode())) { + dept.setParentCode(parentDept.getDeptCode()); + dept.setParentId(parentDept.getId()); + } else if (belongCompany != null) { + // 上级部门尚未就绪时先挂所属公司,分批结束后按 OA 主键重建 + dept.setParentCode(belongCompany.getDeptCode()); + dept.setParentId(belongCompany.getId()); + } else { + dept.setParentCode(null); + dept.setParentId(null); + } + } + return persistSyncedDepts(allParam, existingByOaId, deptIdByCode, DeptCategory.DEPT, filteredSkipCount); } /** - * 加载已存在组织,按部门编码索引 - * - * @param deptCategory 组织类型 - * @param deptCodes 限定编码;为空则按类型全量加载 + * 公司编码:GWWL+三位数字;已有合规编码则复用 */ - private Map loadExistingDeptMap(DeptCategory deptCategory, Set deptCodes) { - if (deptCodes != null && deptCodes.isEmpty()) { - return new HashMap<>(); + private void assignCompanyCode(Dept dept, Map existingByOaId, Set usedCodes) { + Dept existing = existingByOaId.get(dept.getOaId()); + if (existing != null && DeptCodeUtil.isCompanyCode(existing.getDeptCode())) { + dept.setDeptCode(existing.getDeptCode()); + dept.setBelongCompanyCode(existing.getDeptCode()); + usedCodes.add(existing.getDeptCode()); + return; } + int serial = DeptCodeUtil.nextSerial(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE, usedCodes); + String deptCode = DeptCodeUtil.formatChildCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE, serial); + while (usedCodes.contains(deptCode)) { + serial++; + deptCode = DeptCodeUtil.formatChildCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE, serial); + } + usedCodes.add(deptCode); + dept.setDeptCode(deptCode); + dept.setBelongCompanyCode(deptCode); + } + + /** + * 部门编码:所属公司编码+三位数字;已有合规编码则复用 + */ + private void assignDepartmentCode(Dept dept, Map existingByOaId, Map companyByOaId, + Map> usedCodesByCompany, Map nextSerialByCompany) { + Dept existing = existingByOaId.get(dept.getOaId()); + Dept belongCompany = null; + if (StringUtils.isNotBlank(dept.getBelongCompanyCode())) { + belongCompany = companyByOaId.values().stream() + .filter(company -> StringUtils.equals(company.getDeptCode(), dept.getBelongCompanyCode()) + || StringUtils.equals(company.getOaId(), dept.getBelongCompanyCode())) + .findFirst() + .orElse(null); + } + if (belongCompany == null && existing != null && StringUtils.isNotBlank(existing.getBelongCompanyCode())) { + String belongCode = existing.getBelongCompanyCode(); + belongCompany = companyByOaId.values().stream() + .filter(company -> StringUtils.equals(company.getDeptCode(), belongCode)) + .findFirst() + .orElse(null); + } + if (belongCompany == null) { + return; + } + final Dept resolvedCompany = belongCompany; + final String companyCode = resolvedCompany.getDeptCode(); + dept.setBelongCompanyCode(companyCode); + if (existing != null && DeptCodeUtil.matchesChildCode(companyCode, existing.getDeptCode())) { + dept.setDeptCode(existing.getDeptCode()); + usedCodesByCompany.computeIfAbsent(companyCode, key -> new HashSet<>()).add(existing.getDeptCode()); + return; + } + Set usedCodes = usedCodesByCompany.computeIfAbsent(companyCode, key -> new HashSet<>()); + int serial = nextSerialByCompany.computeIfAbsent(companyCode, + key -> DeptCodeUtil.nextSerial(companyCode, usedCodes)); + String deptCode = DeptCodeUtil.formatChildCode(companyCode, serial); + while (usedCodes.contains(deptCode)) { + serial++; + deptCode = DeptCodeUtil.formatChildCode(companyCode, serial); + } + nextSerialByCompany.put(companyCode, serial + 1); + usedCodes.add(deptCode); + dept.setDeptCode(deptCode); + } + + /** + * 按 oaId 加载已存在组织 + */ + private Map loadExistingByOaId(DeptCategory deptCategory) { List deptList = deptService.list(Wrappers.lambdaQuery() .eq(Dept::getDeptCategory, deptCategory.getCode()) - .in(deptCodes != null, Dept::getDeptCode, deptCodes) - .select(Dept::getId, Dept::getDeptCode, Dept::getOaModified, Dept::getParentId)); - return deptList.stream() - .filter(dept -> StringUtils.isNotEmpty(dept.getDeptCode())) - .collect(Collectors.toMap(Dept::getDeptCode, dept -> dept, (a, b) -> b)); - } - - private Map toDeptIdMap(Map existingDeptMap) { - Map deptIdByCode = new HashMap<>(); - existingDeptMap.forEach((code, dept) -> deptIdByCode.put(code, dept.getId())); - return deptIdByCode; + .isNotNull(Dept::getOaId) + .ne(Dept::getOaId, "") + .select(Dept::getId, Dept::getDeptCode, Dept::getOaModified, Dept::getParentId, + Dept::getOaId, Dept::getBelongCompanyCode, Dept::getParentCode, Dept::getOaSupSubComId)); + Map byOaId = new HashMap<>(); + for (Dept dept : deptList) { + if (StringUtils.isNotBlank(dept.getOaId())) { + byOaId.put(dept.getOaId(), dept); + } + } + return byOaId; } /** - * 持久化同步数据:不存在则新增;存在且 oaModified 不一致才修改 - * - * @param allParam 待处理数据 - * @param existingDeptMap 已存在组织(同类型) - * @param deptIdByCode 编码→id(含上级公司等) - * @param deptCategory 组织类型 - * @param filteredSkipCount 上游过滤跳过数 - * @return 同步统计 + * 已占用的公司编码(GWWLxxx) */ - private OrgSyncCount persistSyncedDepts(List allParam, Map existingDeptMap, + private Map loadCompanyCodeOwnerMap() { + List companyList = deptService.list(Wrappers.lambdaQuery() + .eq(Dept::getDeptCategory, DeptCategory.COMPANY.getCode()) + .likeRight(Dept::getDeptCode, OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE) + .select(Dept::getId, Dept::getDeptCode, Dept::getOaId)); + Map codeMap = new HashMap<>(); + for (Dept dept : companyList) { + if (DeptCodeUtil.isCompanyCode(dept.getDeptCode())) { + codeMap.put(dept.getDeptCode(), dept); + } + } + return codeMap; + } + + /** + * 已同步公司:oaId → 公司 + */ + private Map loadCompanyByOaId() { + List companyList = deptService.list(Wrappers.lambdaQuery() + .eq(Dept::getDeptCategory, DeptCategory.COMPANY.getCode()) + .isNotNull(Dept::getOaId) + .ne(Dept::getOaId, "")); + Map byOaId = new HashMap<>(); + for (Dept dept : companyList) { + if (StringUtils.isNotBlank(dept.getOaId())) { + byOaId.put(dept.getOaId(), dept); + } + } + return byOaId; + } + + /** + * 持久化同步数据:按 oaId 匹配;不存在则新增;存在且 oaModified 不一致才修改 + */ + private OrgSyncCount persistSyncedDepts(List allParam, Map existingByOaId, Map deptIdByCode, DeptCategory deptCategory, int filteredSkipCount) { - Set existsDeptIds = existingDeptMap.values().stream() + Set existsDeptIds = existingByOaId.values().stream() .map(Dept::getId) .filter(Objects::nonNull) .collect(Collectors.toSet()); allParam.forEach(dept -> { - if (deptIdByCode.containsKey(dept.getDeptCode())) { + Dept existing = existingByOaId.get(dept.getOaId()); + if (existing != null && existing.getId() != null) { + dept.setId(existing.getId()); + } else if (StringUtils.isNotBlank(dept.getDeptCode()) && deptIdByCode.containsKey(dept.getDeptCode())) { dept.setId(deptIdByCode.get(dept.getDeptCode())); } else { dept.setId(DefaultIdentifierGenerator.getInstance().nextId(null)); + } + if (StringUtils.isNotBlank(dept.getDeptCode())) { deptIdByCode.put(dept.getDeptCode(), dept.getId()); } }); - // 重新把没有父id的数据设置一下父id;上级尚未入库时,新增记录先挂到根节点,全部分批完成后重建层级并清理无法挂载的数据 int unresolvedParentCount = 0; for (Dept dept : allParam) { if (dept.getParentId() != null) { continue; } - Long parentId = deptIdByCode.get(dept.getParentCode()); + Long parentId = StringUtils.isNotBlank(dept.getParentCode()) ? deptIdByCode.get(dept.getParentCode()) : null; if (parentId != null) { dept.setParentId(parentId); continue; @@ -750,8 +958,8 @@ public class OASyncServiceImpl implements IOASyncService { deptCategory.getName(), unresolvedParentCount); } - // 父id不为空才新增;已存在的记录即使父id未解析也允许更新(空父id不会覆盖原值) List list = allParam.stream() + .filter(dept -> StringUtils.isNotBlank(dept.getDeptCode())) .filter(dept -> dept.getParentId() != null || existsDeptIds.contains(dept.getId())) .toList(); List addList = list.stream() @@ -761,10 +969,9 @@ public class OASyncServiceImpl implements IOASyncService { ComposeLogUtil.getLastLog().info("批量新增{}:{}", deptCategory.getName(), addList.size()); deptService.saveBatch(addList); } - // 已存在且 oaModified 不一致(或不存在可比对值)才修改 List updateList = list.stream() .filter(dept -> existsDeptIds.contains(dept.getId())) - .filter(dept -> !shouldSkipOaUpdate(existingDeptMap.get(dept.getDeptCode()), dept)) + .filter(dept -> !shouldSkipOaUpdate(existingByOaId.get(dept.getOaId()), dept)) .toList(); if (CollectionUtil.isNotEmpty(updateList)) { ComposeLogUtil.getLastLog().info("批量修改{}:{}", deptCategory.getName(), updateList.size()); @@ -772,10 +979,11 @@ public class OASyncServiceImpl implements IOASyncService { } int unchangedCount = (int) list.stream() .filter(dept -> existsDeptIds.contains(dept.getId())) - .filter(dept -> shouldSkipOaUpdate(existingDeptMap.get(dept.getDeptCode()), dept)) + .filter(dept -> shouldSkipOaUpdate(existingByOaId.get(dept.getOaId()), dept)) .count(); List notHandledList = allParam.stream() - .filter(dept -> dept.getParentId() == null && !existsDeptIds.contains(dept.getId())) + .filter(dept -> StringUtils.isBlank(dept.getDeptCode()) + || (dept.getParentId() == null && !existsDeptIds.contains(dept.getId()))) .toList(); int syncedCount = addList.size() + updateList.size(); int skippedCount = filteredSkipCount + unchangedCount + notHandledList.size(); @@ -786,12 +994,17 @@ public class OASyncServiceImpl implements IOASyncService { } /** - * oaModified 一致则跳过更新;任一侧为空无法确认一致,执行更新以补齐字段 + * oaModified 一致则跳过更新;任一侧为空无法确认一致,执行更新以补齐字段。 + * 组织编码已按新规则重算且与库中不一致时,即使 modified 相同也要更新。 */ private boolean shouldSkipOaUpdate(Dept existing, Dept incoming) { if (existing == null) { return false; } + if (incoming != null && StringUtils.isNotBlank(incoming.getDeptCode()) + && !StringUtils.equals(existing.getDeptCode(), incoming.getDeptCode())) { + return false; + } String localModified = StringUtils.trimToEmpty(existing.getOaModified()); String remoteModified = StringUtils.trimToEmpty(incoming == null ? null : incoming.getOaModified()); if (localModified.isEmpty() || remoteModified.isEmpty()) { @@ -831,6 +1044,106 @@ public class OASyncServiceImpl implements IOASyncService { } } + /** + * 是否允许同步的 OA 公司:二级白名单,或其上级为二级白名单(三级子公司) + */ + private boolean isAllowedOaCompany(OACompanyResponse company) { + if (company == null || StringUtils.isBlank(company.getId())) { + return false; + } + if (OAConvertConstant.OA_SYNC_KEEP_SECOND_LEVEL_COMPANY_IDS.contains(company.getId())) { + return true; + } + return OAConvertConstant.OA_SYNC_KEEP_SECOND_LEVEL_COMPANY_IDS.contains(company.getSupsubcomid()); + } + + /** + * 解析允许同步部门的所属公司 OA 主键:二级白名单 ∪ 已同步的三级子公司 + */ + private Set resolveAllowedCompanyOaIds(Map companyByOaId) { + Set allowed = new HashSet<>(OAConvertConstant.OA_SYNC_KEEP_SECOND_LEVEL_COMPANY_IDS); + if (companyByOaId == null || companyByOaId.isEmpty()) { + return allowed; + } + for (Dept company : companyByOaId.values()) { + if (company == null || StringUtils.isBlank(company.getOaId())) { + continue; + } + if (OAConvertConstant.OA_SYNC_KEEP_SECOND_LEVEL_COMPANY_IDS.contains(company.getOaId()) + || OAConvertConstant.OA_SYNC_KEEP_SECOND_LEVEL_COMPANY_IDS.contains(company.getOaSupSubComId())) { + allowed.add(company.getOaId()); + } + } + return allowed; + } + + /** + * 清理不在白名单树内的 OA 公司及其子树(含部门) + */ + private int removeOutsideWhitelistOrgs() { + List oaCompanyList = deptService.list(Wrappers.lambdaQuery() + .eq(Dept::getDeptCategory, DeptCategory.COMPANY.getCode()) + .isNotNull(Dept::getOaId) + .ne(Dept::getOaId, "")); + Map companyByOaId = new HashMap<>(); + for (Dept company : oaCompanyList) { + if (StringUtils.isNotBlank(company.getOaId())) { + companyByOaId.put(company.getOaId(), company); + } + } + Set allowedCompanyOaIds = resolveAllowedCompanyOaIds(companyByOaId); + Set deleteIds = new HashSet<>(); + for (Dept company : oaCompanyList) { + if (!allowedCompanyOaIds.contains(company.getOaId())) { + deleteIds.add(company.getId()); + } + } + Map companyCodeToOaId = new HashMap<>(); + for (Dept company : oaCompanyList) { + if (StringUtils.isNotBlank(company.getDeptCode()) && StringUtils.isNotBlank(company.getOaId())) { + companyCodeToOaId.put(company.getDeptCode(), company.getOaId()); + } + } + List oaDeptList = deptService.list(Wrappers.lambdaQuery() + .eq(Dept::getDeptCategory, DeptCategory.DEPT.getCode()) + .isNotNull(Dept::getOaId) + .ne(Dept::getOaId, "")); + for (Dept dept : oaDeptList) { + String belongOaId = null; + if (StringUtils.isNotBlank(dept.getBelongCompanyCode())) { + belongOaId = companyCodeToOaId.get(dept.getBelongCompanyCode()); + } + if (StringUtils.isBlank(belongOaId) && StringUtils.isNotBlank(dept.getOaSupSubComId()) + && companyByOaId.containsKey(dept.getOaSupSubComId())) { + // 根部门上级可能直接是公司 OA 主键 + belongOaId = dept.getOaSupSubComId(); + } + if (StringUtils.isBlank(belongOaId) || !allowedCompanyOaIds.contains(belongOaId)) { + deleteIds.add(dept.getId()); + } + } + if (CollectionUtil.isEmpty(deleteIds)) { + return 0; + } + List allDeptList = deptService.list(); + boolean cascadeChanged = true; + while (cascadeChanged) { + cascadeChanged = false; + for (Dept dept : allDeptList) { + if (deleteIds.contains(dept.getId())) { + continue; + } + if (dept.getParentId() != null && deleteIds.contains(dept.getParentId())) { + deleteIds.add(dept.getId()); + cascadeChanged = true; + } + } + } + deptService.removeByIds(deleteIds); + ComposeLogUtil.getLastLog().info("清理白名单外 OA 组织{}条", deleteIds.size()); + return deleteIds.size(); + } + /** * 获取 OA 部门查询使用的子公司 id * @@ -842,6 +1155,8 @@ public class OASyncServiceImpl implements IOASyncService { if (syncAll) { companyList = deptService.list(Wrappers.lambdaQuery() .eq(Dept::getDeptCategory, DeptCategory.COMPANY.getCode()) + .isNotNull(Dept::getOaId) + .ne(Dept::getOaId, "") ); } else { Set companyNames = this.getCompanyNames(); @@ -851,11 +1166,22 @@ public class OASyncServiceImpl implements IOASyncService { companyList = deptService.list(Wrappers.lambdaQuery() .in(Dept::getDeptName, companyNames) .eq(Dept::getDeptCategory, DeptCategory.COMPANY.getCode()) + .isNotNull(Dept::getOaId) + .ne(Dept::getOaId, "") ); } + Map companyByOaId = new HashMap<>(); + for (Dept company : companyList) { + String oaId = this.resolveOaCompanyId(company); + if (StringUtils.isNotBlank(oaId)) { + companyByOaId.put(oaId, company); + } + } + Set allowedCompanyOaIds = resolveAllowedCompanyOaIds(companyByOaId); return companyList.stream() .map(this::resolveOaCompanyId) .filter(StringUtils::isNotBlank) + .filter(allowedCompanyOaIds::contains) .distinct() .collect(Collectors.joining(",")); } @@ -951,13 +1277,20 @@ public class OASyncServiceImpl implements IOASyncService { return Collections.emptySet(); } // 查询父部门编码是根公司的,部门类型的,根公司下部门名称的部门 + Dept rootCompany = deptService.getOne(Wrappers.lambdaQuery() + .eq(Dept::getOaId, rootCompanyId) + .eq(Dept::getDeptCategory, DeptCategory.COMPANY.getCode()) + .last("limit 1"), false); + if (rootCompany == null || StringUtils.isBlank(rootCompany.getDeptCode())) { + return Collections.emptySet(); + } return deptService.list(Wrappers.lambdaQuery() .in(Dept::getDeptName, rootCompanyDeptNames) .eq(Dept::getDeptCategory, DeptCategory.DEPT.getCode()) - .eq(Dept::getParentCode, OAConvertConstant.COMPANY_OA_PREFIX + rootCompanyId) + .eq(Dept::getParentCode, rootCompany.getDeptCode()) ).stream() - .filter(dept -> StringUtils.isNotBlank(dept.getDeptCode())) - .map(dept -> dept.getDeptCode().replace(OAConvertConstant.DEPARTMENT_OA_PREFIX, "")) + .map(Dept::getOaId) + .filter(StringUtils::isNotBlank) .collect(Collectors.toSet()); } @@ -980,11 +1313,11 @@ public class OASyncServiceImpl implements IOASyncService { ComposeLogUtil.getLastLog().warn("未查询到根公司 名称:{}", companyName); return null; } - if (StringUtils.isBlank(dept.getDeptCode())) { + if (StringUtils.isBlank(dept.getOaId()) && StringUtils.isBlank(dept.getDeptCode())) { ComposeLogUtil.getLastLog().warn("根公司编码为空:{}", companyName); return null; } - // 部门编码去掉前缀,就是oa的id - return dept.getDeptCode().replace(OAConvertConstant.COMPANY_OA_PREFIX, ""); + return StringUtils.isNotBlank(dept.getOaId()) ? dept.getOaId() + : dept.getDeptCode().replace(OAConvertConstant.COMPANY_OA_PREFIX, ""); } } diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OaSyncRawDumpHelper.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OaSyncRawDumpHelper.java new file mode 100644 index 00000000..0674f00f --- /dev/null +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OaSyncRawDumpHelper.java @@ -0,0 +1,379 @@ +/** + * 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.system.service.impl; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONWriter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.core.tool.utils.DateUtil; +import org.springblade.system.log.ComposeLogUtil; +import org.springblade.thirdparty.oa.feign.IOAClient; +import org.springblade.thirdparty.oa.pojo.response.OACompanyResponse; +import org.springblade.thirdparty.oa.pojo.response.OADepartmentResponse; +import org.springblade.thirdparty.oa.pojo.response.OAPersonResponse; +import org.springblade.thirdparty.oa.pojo.search.OACompanySearch; +import org.springblade.thirdparty.oa.pojo.search.OADepartmentSearch; +import org.springblade.thirdparty.oa.pojo.search.OASearch; +import org.springblade.thirdparty.oa.util.OAUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; + +/** + * OA 全量同步原始数据落盘:按父级 id 组装层级 JSON + * + * @author Chill + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class OaSyncRawDumpHelper { + + private static final String ORPHAN_COMPANY_ID = "_orphan_company"; + private static final String ORPHAN_DEPT_ID = "_orphan_dept"; + + private final IOAClient oaClient; + + @Value("${third-party.oa.sync-dump-dir:./data/oa-sync}") + private String syncDumpDir; + + /** + * 当前全量同步会话收集到的人员原始数据 + */ + private final List collectingPersons = Collections.synchronizedList(new ArrayList<>()); + private volatile boolean collecting; + + /** + * 机构全量同步会话收集的公司/部门原始数据 + */ + private final List collectingCompanies = Collections.synchronizedList(new ArrayList<>()); + private final List collectingDepartments = Collections.synchronizedList(new ArrayList<>()); + private volatile boolean collectingOrg; + + /** + * 全量同步首页启动收集 + */ + public synchronized void beginFullDump() { + collectingPersons.clear(); + collecting = true; + ComposeLogUtil.getLastLog().info("已开启 OA 全量同步原始数据收集,目录={}", resolveDumpDir()); + } + + /** + * 机构全量同步首页启动收集公司/部门原始数据 + */ + public synchronized void beginOrgFullDump() { + collectingCompanies.clear(); + collectingDepartments.clear(); + collectingOrg = true; + ComposeLogUtil.getLastLog().info("已开启 OA 机构全量同步原始数据收集,目录={}", resolveDumpDir()); + } + + /** + * 收集本页人员原始数据 + */ + public void collectPersons(List oaPersons) { + if (!collecting || oaPersons == null || oaPersons.isEmpty()) { + return; + } + collectingPersons.addAll(oaPersons); + } + + /** + * 收集本页公司原始数据 + */ + public void collectCompanies(List oaCompanies) { + if (!collectingOrg || oaCompanies == null || oaCompanies.isEmpty()) { + return; + } + collectingCompanies.addAll(oaCompanies); + } + + /** + * 收集本页部门原始数据 + */ + public void collectDepartments(List oaDepartments) { + if (!collectingOrg || oaDepartments == null || oaDepartments.isEmpty()) { + return; + } + collectingDepartments.addAll(oaDepartments); + } + + /** + * 全量同步结束:先写入人员层级 JSON,再异步补齐公司/部门接口原始层级数据 + * + * @return 人员原始层级 JSON 文件路径,失败时返回 null + */ + public synchronized String finishFullDump() { + if (!collecting) { + return null; + } + List persons = new ArrayList<>(collectingPersons); + collecting = false; + collectingPersons.clear(); + try { + String stamp = DateUtil.format(new Date(), "yyyyMMddHHmmss"); + Map personPayload = new LinkedHashMap<>(); + personPayload.put("exportTime", DateUtil.format(new Date(), DateUtil.PATTERN_DATETIME)); + personPayload.put("personCount", persons.size()); + personPayload.put("personTree", buildPersonTree(persons)); + personPayload.put("personsRaw", persons); + Path personFile = writeDumpFile("oa-full-sync-person-raw-" + stamp + ".json", personPayload); + ComposeLogUtil.getLastLog().info("OA 人员原始层级数据已写入 {}", personFile.toAbsolutePath()); + CompletableFuture.runAsync(() -> dumpOrgRawAsync(stamp)); + return personFile.toAbsolutePath().toString(); + } catch (Exception exception) { + log.error("写入 OA 全量同步人员原始 JSON 失败", exception); + ComposeLogUtil.getLastLog().error("写入 OA 全量同步人员原始 JSON 失败:{}", exception.getMessage()); + return null; + } + } + + /** + * 机构全量同步结束:写入公司/部门接口原始数据(按父级 id 层级) + * + * @return JSON 文件路径,失败时返回 null + */ + public synchronized String finishOrgFullDump() { + if (!collectingOrg) { + return null; + } + List companies = new ArrayList<>(collectingCompanies); + List departments = new ArrayList<>(collectingDepartments); + collectingOrg = false; + collectingCompanies.clear(); + collectingDepartments.clear(); + try { + String stamp = DateUtil.format(new Date(), "yyyyMMddHHmmss"); + Path orgFile = writeOrgDumpFile(stamp, companies, departments); + ComposeLogUtil.getLastLog().info("OA 机构原始层级数据已写入 {}", orgFile.toAbsolutePath()); + return orgFile.toAbsolutePath().toString(); + } catch (Exception exception) { + log.error("写入 OA 机构全量同步原始 JSON 失败", exception); + ComposeLogUtil.getLastLog().error("写入 OA 机构全量同步原始 JSON 失败:{}", exception.getMessage()); + return null; + } + } + + private void dumpOrgRawAsync(String stamp) { + try { + List companies = fetchAllCompanies(); + List departments = fetchAllDepartments(companies); + Path orgFile = writeOrgDumpFile(stamp, companies, departments); + log.info("OA 公司/部门原始层级数据已写入 {}", orgFile.toAbsolutePath()); + } catch (Exception exception) { + log.error("异步写入 OA 公司/部门原始 JSON 失败", exception); + } + } + + private Path writeOrgDumpFile(String stamp, List companies, + List departments) throws IOException { + Map orgPayload = new LinkedHashMap<>(); + orgPayload.put("exportTime", DateUtil.format(new Date(), DateUtil.PATTERN_DATETIME)); + orgPayload.put("companyCount", companies.size()); + orgPayload.put("departmentCount", departments.size()); + orgPayload.put("companyTree", buildCompanyTree(companies)); + orgPayload.put("departmentTree", buildDepartmentTree(departments)); + orgPayload.put("companiesRaw", companies); + orgPayload.put("departmentsRaw", departments); + return writeDumpFile("oa-full-sync-org-raw-" + stamp + ".json", orgPayload); + } + + private List fetchAllCompanies() { + List companies = new ArrayList<>(); + OACompanySearch companySearch = new OACompanySearch(); + companySearch.setCurPage(1); + companySearch.setPageSize(100); + OAUtils.pageSyncHandler(companySearch, param -> oaClient.queryCompanyPage(new OASearch<>(param)), response -> { + log.error("导出公司原始数据失败 {}", JSON.toJSONString(response)); + return new ServiceException("导出公司原始数据失败"); + }, 10000, log::info).accept(companies::addAll); + return companies; + } + + private List fetchAllDepartments(List companies) { + List departments = new ArrayList<>(); + String subCompanyIds = companies.stream() + .map(OACompanyResponse::getId) + .filter(StringUtils::isNotBlank) + .distinct() + .reduce((left, right) -> left + "," + right) + .orElse(""); + OADepartmentSearch departmentSearch = new OADepartmentSearch(); + departmentSearch.setCurPage(1); + departmentSearch.setPageSize(100); + departmentSearch.setSubcompanyid1(subCompanyIds); + OAUtils.pageSyncHandler(departmentSearch, param -> oaClient.queryDepartmentPage(new OASearch<>(param)), response -> { + log.error("导出部门原始数据失败 {}", JSON.toJSONString(response)); + return new ServiceException("导出部门原始数据失败"); + }, 10000, log::info).accept(departments::addAll); + return departments; + } + + private List> buildCompanyTree(List companies) { + Map> nodeMap = new LinkedHashMap<>(); + for (OACompanyResponse company : companies) { + if (company == null || StringUtils.isBlank(company.getId())) { + continue; + } + Map node = new LinkedHashMap<>(); + node.put("id", company.getId()); + node.put("parentId", blankToRoot(company.getSupsubcomid())); + node.put("type", "company"); + node.put("name", company.getSubcompanyname()); + node.put("raw", company); + node.put("children", new ArrayList>()); + nodeMap.put(company.getId(), node); + } + return linkChildren(nodeMap); + } + + private List> buildDepartmentTree(List departments) { + Map> nodeMap = new LinkedHashMap<>(); + for (OADepartmentResponse department : departments) { + if (department == null || StringUtils.isBlank(department.getId())) { + continue; + } + Map node = new LinkedHashMap<>(); + node.put("id", department.getId()); + node.put("parentId", blankToRoot(department.getSupdepid())); + node.put("companyId", department.getSubcompanyid1()); + node.put("type", "department"); + node.put("name", department.getDepartmentname()); + node.put("raw", department); + node.put("children", new ArrayList>()); + nodeMap.put(department.getId(), node); + } + return linkChildren(nodeMap); + } + + private List> buildPersonTree(List persons) { + Map> companyMap = new LinkedHashMap<>(); + Map> deptMap = new LinkedHashMap<>(); + for (OAPersonResponse person : persons) { + if (person == null) { + continue; + } + String companyId = StringUtils.defaultIfBlank(person.getSubcompanyid1(), ORPHAN_COMPANY_ID); + String deptId = StringUtils.defaultIfBlank(person.getDepartmentid(), ORPHAN_DEPT_ID + "@" + companyId); + Map companyNode = companyMap.computeIfAbsent(companyId, key -> { + Map node = new LinkedHashMap<>(); + node.put("id", key); + node.put("parentId", "0"); + node.put("type", "company"); + node.put("name", StringUtils.defaultIfBlank(person.getSubcompanyname(), key)); + Map raw = new LinkedHashMap<>(); + raw.put("subcompanyid1", person.getSubcompanyid1()); + raw.put("subcompanyname", person.getSubcompanyname()); + raw.put("subcompanycode", person.getSubcompanycode()); + node.put("raw", raw); + node.put("children", new ArrayList>()); + return node; + }); + @SuppressWarnings("unchecked") + List> companyChildren = (List>) companyNode.get("children"); + Map deptNode = deptMap.computeIfAbsent(deptId, key -> { + Map node = new LinkedHashMap<>(); + node.put("id", key); + node.put("parentId", companyId); + node.put("type", "department"); + node.put("name", StringUtils.defaultIfBlank(person.getDepartmentname(), key)); + Map raw = new LinkedHashMap<>(); + raw.put("departmentid", person.getDepartmentid()); + raw.put("departmentname", person.getDepartmentname()); + raw.put("departmentcode", person.getDepartmentcode()); + raw.put("subcompanyid1", person.getSubcompanyid1()); + node.put("raw", raw); + node.put("children", new ArrayList>()); + companyChildren.add(node); + return node; + }); + @SuppressWarnings("unchecked") + List> deptChildren = (List>) deptNode.get("children"); + Map personNode = new LinkedHashMap<>(); + personNode.put("id", StringUtils.defaultIfBlank(person.getId(), person.getLoginid())); + personNode.put("parentId", deptId); + personNode.put("type", "person"); + personNode.put("name", person.getLastname()); + personNode.put("raw", person); + personNode.put("children", Collections.emptyList()); + deptChildren.add(personNode); + } + return new ArrayList<>(companyMap.values()); + } + + @SuppressWarnings("unchecked") + private List> linkChildren(Map> nodeMap) { + List> roots = new ArrayList<>(); + for (Map node : nodeMap.values()) { + String parentId = Objects.toString(node.get("parentId"), "0"); + Map parent = nodeMap.get(parentId); + if (parent != null && !Objects.equals(parent.get("id"), node.get("id"))) { + ((List>) parent.get("children")).add(node); + } else { + roots.add(node); + } + } + return roots; + } + + private String blankToRoot(String parentId) { + if (StringUtils.isBlank(parentId) || "0".equals(parentId) || "-1".equals(parentId)) { + return "0"; + } + return parentId; + } + + private Path writeDumpFile(String fileName, Map payload) throws IOException { + Path dir = resolveDumpDir(); + Files.createDirectories(dir); + Path dumpFile = dir.resolve(fileName); + String json = JSON.toJSONString(payload, JSONWriter.Feature.PrettyFormat, JSONWriter.Feature.WriteMapNullValue); + Files.writeString(dumpFile, json, StandardCharsets.UTF_8); + return dumpFile; + } + + private Path resolveDumpDir() { + return Paths.get(StringUtils.defaultIfBlank(syncDumpDir, "./data/oa-sync")).toAbsolutePath().normalize(); + } + +} diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OaUserListSyncHelper.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OaUserListSyncHelper.java index eba6b8cd..77ba09d0 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OaUserListSyncHelper.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OaUserListSyncHelper.java @@ -86,7 +86,7 @@ public class OaUserListSyncHelper { private final IRoleService roleService; /** - * 从人员数据提取二级公司、三级部门,并挂到「桂物物流集团」下 + * 从人员数据提取二级公司、三级部门,并挂到集团顶级(id=1/GWWL) 下 * * @param oaPersons OA人员 * @return 组织索引 @@ -350,7 +350,20 @@ public class OaUserListSyncHelper { String defaultRoleId = getDefaultRoleId(); List updateUserParams = list.stream() .filter(userDeptIds -> !unchangedUserIds.contains(userDeptIds.getUserId())) - .filter(userDeptIds -> StringUtils.isNotBlank(userDeptIds.getDeptIds()) && userDeptIds.getDeptIds().length() <= MAX_DEPT_ID_LENGTH) + .filter(userDeptIds -> { + if (StringUtils.isBlank(userDeptIds.getDeptIds())) { + return false; + } + int deptIdLength = userDeptIds.getDeptIds().length(); + int deptCodesLength = StringUtils.length(userDeptIds.getDeptCodes()); + if (deptIdLength > MAX_DEPT_ID_LENGTH || deptCodesLength > MAX_DEPT_ID_LENGTH) { + ComposeLogUtil.getLastLog().warn( + "用户部门字段超长已跳过回写 userId={} deptIdLen={} deptCodesLen={} max={}", + userDeptIds.getUserId(), deptIdLength, deptCodesLength, MAX_DEPT_ID_LENGTH); + return false; + } + return true; + }) .map(userDeptIds -> { User user = new User(); user.setId(userDeptIds.getUserId()); @@ -383,33 +396,7 @@ public class OaUserListSyncHelper { } private Dept getOrCreateRootCompany() { - Dept rootCompany = deptService.getOne(Wrappers.lambdaQuery() - .eq(Dept::getDeptName, OAConvertConstant.ROOT_COMPANY_NAME) - .last("limit 1"), false); - if (rootCompany != null) { - return rootCompany; - } - rootCompany = new Dept(); - rootCompany.setId(DefaultIdentifierGenerator.getInstance().nextId(null)); - rootCompany.setTenantId(resolveTenantId()); - rootCompany.setParentId(BladeConstant.TOP_PARENT_ID); - rootCompany.setAncestors(String.valueOf(BladeConstant.TOP_PARENT_ID)); - rootCompany.setDeptName(OAConvertConstant.ROOT_COMPANY_NAME); - rootCompany.setFullName(OAConvertConstant.ROOT_COMPANY_NAME); - rootCompany.setShortName(OAConvertConstant.ROOT_COMPANY_NAME); - rootCompany.setDeptCode("OACROOT"); - rootCompany.setParentCode(String.valueOf(BladeConstant.TOP_PARENT_ID)); - rootCompany.setBelongCompanyCode("OACROOT"); - rootCompany.setDeptCategory(DeptCategory.COMPANY.getCode()); - rootCompany.setSort(0); - rootCompany.setStatus(DataStatusEnum.ENABLE.getCode()); - rootCompany.setIsDeleted(BladeConstant.DB_NOT_DELETED); - rootCompany.setIsOa(YES); - rootCompany.setIsPlatformCompany(0); - rootCompany.setSyncTime(new Date()); - deptService.save(rootCompany); - ComposeLogUtil.getLastLog().info("已创建顶级组织:{}", OAConvertConstant.ROOT_COMPANY_NAME); - return rootCompany; + return deptService.ensureOaSyncRootDept(); } private Dept upsertOrg(String name, String oaCode, Dept existing, Dept parent, String tenantId, diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/UserDeptServiceImpl.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/UserDeptServiceImpl.java index 88b17735..6fc40f70 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/UserDeptServiceImpl.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/UserDeptServiceImpl.java @@ -46,11 +46,18 @@ import java.util.List; @Service public class UserDeptServiceImpl extends ServiceImpl implements IUserDeptService { + /** + * 与 blade_user.dept_id / dept_codes 扩容后的字段上限一致 + */ + private static final int GROUP_CONCAT_MAX_LEN = 2000; + @Override public List queryUserDeptIds(Collection userIds) { if (CollectionUtil.isEmpty(userIds)) { return Collections.emptyList(); } + // 需与后续查询在同一连接中生效(外层事务内调用) + baseMapper.setGroupConcatMaxLen(GROUP_CONCAT_MAX_LEN); return baseMapper.queryUserDeptIds(userIds); } diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/UserServiceImpl.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/UserServiceImpl.java index 3a463c4d..055daa42 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/UserServiceImpl.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/UserServiceImpl.java @@ -1206,9 +1206,36 @@ public class UserServiceImpl extends BaseServiceImpl implement if (user == null) { return; } - if (user.getPhone() != null && user.getPhone().length() > 20) { - user.setPhone(SM2Util.decrypt(user.getPhone(), properties.getPublicKey(), properties.getPrivateKey())); + String phone = user.getPhone(); + // 密文 Hex 通常远长于普通手机号;含 / 等非 Hex 字符的明文(如双号)直接跳过 + if (StringUtil.isBlank(phone) || phone.length() <= 20 || !isSm2HexCipher(phone)) { + return; } + try { + user.setPhone(SM2Util.decrypt(phone, properties.getPublicKey(), properties.getPrivateKey())); + } catch (Exception exception) { + log.warn("手机号 SM2 解密失败,保留原值 length={}", phone.length(), exception); + } + } + + /** + * 判断是否为 SM2 密文 Hex(仅 0-9a-f,且偶数长度) + */ + private boolean isSm2HexCipher(String value) { + int length = value.length(); + if ((length & 1) != 0) { + return false; + } + for (int index = 0; index < length; index++) { + char character = value.charAt(index); + boolean isDigit = character >= '0' && character <= '9'; + boolean isLowerHex = character >= 'a' && character <= 'f'; + boolean isUpperHex = character >= 'A' && character <= 'F'; + if (!isDigit && !isLowerHex && !isUpperHex) { + return false; + } + } + return true; } @Override diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/util/DeptCodeUtil.java b/blade-service/blade-system/src/main/java/org/springblade/system/util/DeptCodeUtil.java new file mode 100644 index 00000000..687a3a46 --- /dev/null +++ b/blade-service/blade-system/src/main/java/org/springblade/system/util/DeptCodeUtil.java @@ -0,0 +1,97 @@ +/** + * 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.system.util; + +import org.apache.commons.lang3.StringUtils; +import org.springblade.thirdparty.oa.constant.OAConvertConstant; + +import java.util.Collection; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 组织编码工具:上级编码直接拼接固定 3 位分段数字(不足补零,中间不加 "-") + * + * @author Chill + */ +public final class DeptCodeUtil { + + /** + * 分段数字位数 + */ + public static final int SEGMENT_WIDTH = 3; + + private DeptCodeUtil() { + } + + /** + * 生成子级编码:上级编码 + 三位数字 + */ + public static String formatChildCode(String parentCode, int serial) { + return parentCode + String.format("%0" + SEGMENT_WIDTH + "d", serial); + } + + /** + * 是否符合「上级编码 + 三位数字」格式(恰好多出 3 位数字) + */ + public static boolean matchesChildCode(String parentCode, String deptCode) { + if (StringUtils.isBlank(parentCode) || StringUtils.isBlank(deptCode)) { + return false; + } + return deptCode.matches("^" + Pattern.quote(parentCode) + "\\d{" + SEGMENT_WIDTH + "}$"); + } + + /** + * 是否为公司编码(GWWL + 三位数字) + */ + public static boolean isCompanyCode(String deptCode) { + return matchesChildCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE, deptCode); + } + + /** + * 在已有编码中取下一分段序号(从 1 起) + */ + public static int nextSerial(String parentCode, Collection existingCodes) { + if (StringUtils.isBlank(parentCode)) { + return 1; + } + Pattern pattern = Pattern.compile("^" + Pattern.quote(parentCode) + "(\\d{" + SEGMENT_WIDTH + "})$"); + int maxSerial = 0; + if (existingCodes != null) { + for (String code : existingCodes) { + if (StringUtils.isBlank(code)) { + continue; + } + Matcher matcher = pattern.matcher(code.trim()); + if (!matcher.matches()) { + continue; + } + maxSerial = Math.max(maxSerial, Integer.parseInt(matcher.group(1))); + } + } + return maxSerial + 1; + } +} diff --git a/blade-service/blade-transport/pom.xml b/blade-service/blade-transport/pom.xml index f55739f3..7f7e843b 100644 --- a/blade-service/blade-transport/pom.xml +++ b/blade-service/blade-transport/pom.xml @@ -47,10 +47,19 @@ org.springblade blade-process-api + + org.springblade + blade-bfi-api + org.springframework.boot spring-boot-starter-amqp + + org.springframework.boot + spring-boot-starter-test + test + io.minio minio diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/TransportApplication.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/TransportApplication.java index 27a73b5b..f5ece848 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/TransportApplication.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/TransportApplication.java @@ -27,12 +27,14 @@ package org.springblade.transport; import org.springblade.core.cloud.client.BladeCloudApplication; import org.springblade.core.launch.BladeApplication; +import org.springframework.scheduling.annotation.EnableScheduling; /** * 运输服务启动器 * * @author Chill */ +@EnableScheduling @BladeCloudApplication public class TransportApplication { diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/config/BfiProperties.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/config/BfiProperties.java new file mode 100644 index 00000000..f2e38a9d --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/config/BfiProperties.java @@ -0,0 +1,421 @@ +/** + * 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.config; + +import lombok.Data; +import org.springblade.thirdparty.bfi.constant.BfiConstant; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 业财融合(BFI)推送金蝶配置属性,统一收口 Nacos 配置 thirdParty.bfi.*: + *

+ * srcSys 为客商、付款申请等各业务推送金蝶的公共参数(外部系统编号); + * customer、payApply 为各业务专属参数;未配置时使用字段默认值。 + *

+ * prefix 须为全小写(thirdparty.bfi),Spring 宽松绑定可匹配 Nacos 中的 camelCase 键(thirdParty.payApply.srcSys 等)。 + * + * @author Chill + */ +@Data +@Component +@ConfigurationProperties(prefix = "thirdparty.bfi") +public class BfiProperties { + + /** + * 数据来源系统编码(公共):客商、付款申请等推送金蝶时统一使用,默认 WLTMS(运输TMS) + */ + private String srcSys = BfiConstant.DATA_SOURCE_WLTMS; + + /** + * 付款申请推送配置(A103-3 付款申请保存) + */ + private PayApply payApply = new PayApply(); + + /** + * 开票申请推送配置(A303-1 开票申请单新增 / A303-5 开票申请发票查询) + */ + private InvoiceApply invoiceApply = new InvoiceApply(); + + /** + * 收票单推送配置(A303-2 收票单批量保存 / A302-6 收票指定应付 / A801-3 收票单查询回写) + */ + private ReceivedInvoice receivedInvoice = new ReceivedInvoice(); + + /** + * 收款认领公告查询配置(A301-2) + */ + private ClaimAnnounce claimAnnounce = new ClaimAnnounce(); + + /** + * 付款结果定时回写配置(A103-2/A103-4,总开关同时控制应收勾稽、开票结果回写) + */ + private PayResult payResult = new PayResult(); + + /** + * 收票单结果定时回写开关(A801-3) + */ + private ReceivedInvoiceResult receivedInvoiceResult = new ReceivedInvoiceResult(); + + /** + * 收款认领推送配置(A301-3) + */ + private ReceiptClaimPush receiptClaimPush = new ReceiptClaimPush(); + + /** + * 全票池发票定时同步配置(A801-1) + */ + private InvoiceReceipt invoiceReceipt = new InvoiceReceipt(); + + /** + * 客商金蝶数据定时同步配置(A902-1/A902-2) + */ + private Customer customer = new Customer(); + + /** + * 金蝶黑名单查询配置 + */ + private Blacklist blacklist = new Blacklist(); + + /** + * 开票结果回写锁配置(A303-5) + */ + private InvoiceResult invoiceResult = new InvoiceResult(); + + /** + * 应收单勾稽回写锁配置(A302-4) + */ + private ArResult arResult = new ArResult(); + + /** + * 金蝶单据锁定补偿配置 + */ + private BillLock billLock = new BillLock(); + + /** + * 审批通过自动同步金蝶配置(结算单/付款申请/开票申请/收票登记) + */ + private AutoSync autoSync = new AutoSync(); + + /** + * 应付/应收/收票单分录物料映射配置(A102-2/A302-2/A303-2 共用): + * 运输类按项目业务模式映射——国内运输按运单运输方式映射、国际运输映射国际货代服务; + * 装卸大类及手动添加的非运费杂费映射装卸物料 + */ + private Material material = new Material(); + + /** + * 付款申请推送配置项 + */ + @Data + public static class PayApply { + + /** + * 单据类型编码:ap_payapply_BT_S 付款申请单、ap_payapply_oth_BT_S 其他付款单,默认其他付款单 + */ + private String billtype = "ap_payapply_oth_BT_S"; + + /** + * 汇率表编码,默认 01 + */ + private String exratetable = "01"; + + /** + * 外部单据类型,默认 0KzXs + */ + private String srcType = "0KzXs"; + } + + /** + * 开票申请推送配置项(A303-1 / A303-5): + * 销方名称取申请单开票单位快照、销方税号取客商档案统一社会信用代码、税收分类编码取商品行开票内容快照,均不走配置 + */ + @Data + public static class InvoiceApply { + + /** + * 分录外部单据类型,默认 0KzXs(与付款申请对齐) + */ + private String srcType = "0KzXs"; + } + + /** + * 收票单推送配置项(A303-2 / A302-6 / A801-3): + * 发票类型编码、税收分类编码、购销方税号地址银行均取票池镜像与明细,不走配置 + */ + @Data + public static class ReceivedInvoice { + + /** + * A303-2 分录物料编码:联调期临时值(金蝶测试档案),发票明细无金蝶物料概念, + * 真实物料口径与金蝶对齐后替换(与应收/应付联调临时值同源) + */ + private String materialNumber = "SP0324"; + + /** + * A801-3 推送幂等预检回看窗口天数(金蝶修改时间窗口,契约约束不超过 1 个月) + */ + private int queryWindowDays = 30; + + /** + * A801-3 定时回写轮询窗口天数(同样受金蝶 1 个月窗口约束) + */ + private int resultWindowDays = 30; + + /** + * A801-3 回写 single-flight 锁租约秒数,须大于最坏一轮耗时(批量远程查询 + 逐单回写) + */ + private long lockLeaseSeconds = 1800; + } + + /** + * 收款认领公告查询配置项(A301-2) + */ + @Data + public static class ClaimAnnounce { + + /** + * 查询时间窗口(月数),默认 6(近半年) + */ + private int months = 6; + + /** + * 认领人(claimusers):配置后公告查询固定使用该值,不再按当前登录用户姓名查询 + */ + private String claimusers; + } + + /** + * 分录物料映射配置项(thirdparty.bfi.material.*):默认值与金蝶测试档案对齐, + * 金蝶正式档案确认后直接在 Nacos 覆盖 + */ + @Data + public static class Material { + + /** + * 国内运输:运单运输方式 -> 金蝶物料编码(公路运输/铁路运输/水路运输/航空运输四种), + * 运输方式未配置映射时直接拦截报错 + */ + private Map domestic = new LinkedHashMap<>() {{ + put("公路运输", "XDWL11010001"); + put("铁路运输", "XDWL11010002"); + put("水路运输", "XDWL11010003"); + put("航空运输", "Item-00001082"); + }}; + + /** + * 国际运输业务模式物料编码(国际货运代理服务) + */ + private String international = "Item-00001081"; + + /** + * 装卸大类、手动添加非运费杂费物料编码(装卸) + */ + private String loading = "XDWL11002004"; + } + + /** + * 付款结果回写配置项(A103-2/A103-4) + */ + @Data + public static class PayResult { + + /** + * 回写任务开关,代码默认关闭(安全兜底),由 Nacos 按环境显式开启 + */ + private boolean syncEnabled = false; + + /** + * 回写 single-flight 锁租约秒数,须大于最坏一轮耗时(分批远程查询 + 逐单回写) + */ + private long lockLeaseSeconds = 1800; + } + + /** + * 收票单结果回写配置项(A801-3) + */ + @Data + public static class ReceivedInvoiceResult { + + /** + * 回写任务开关,默认关闭(联调稳定后开启) + */ + private boolean syncEnabled = false; + } + + /** + * 收款认领推送配置项(A301-3) + */ + @Data + public static class ReceiptClaimPush { + + /** + * 推送任务开关,默认关闭(联调稳定后开启) + */ + private boolean syncEnabled = false; + + /** + * 推送 single-flight 锁租约秒数,须大于最坏一轮耗时(逐认领人远程查公告 + 逐条推送) + */ + private long lockLeaseSeconds = 1800; + } + + /** + * 全票池发票同步配置项(A801-1) + */ + @Data + public static class InvoiceReceipt { + + /** + * 同步任务开关,默认关闭(上线先手动跑一次全窗口铺底) + */ + private boolean syncEnabled = false; + + /** + * 增量窗口天数(按票池更新时间滚动) + */ + private int incrementDays = 30; + + /** + * 同步发票种类白名单(逗号分隔票易通编码,如 26,27 数电普票/专票),为空不限制 + */ + private String invoiceTypes = ""; + + /** + * 同步 single-flight 锁租约秒数,须大于最坏一轮耗时;进程异常退出时锁在租约到期后自动释放 + */ + private long lockLeaseSeconds = 1800; + } + + /** + * 客商金蝶数据同步配置项(A902-1/A902-2) + */ + @Data + public static class Customer { + + /** + * 同步任务开关,默认关闭 + */ + private boolean syncEnabled = false; + + /** + * 同步 single-flight 锁租约秒数,须大于最坏一轮耗时(复查 + 批量推送 + 停用联动) + */ + private long lockLeaseSeconds = 1800; + } + + /** + * 金蝶黑名单查询配置项 + */ + @Data + public static class Blacklist { + + /** + * 客商提审命中风险警示类(B)时是否一并拦截;默认 false 仅放行告警,禁止交易类(A)始终拦截 + */ + private boolean blockRiskWarning = false; + } + + /** + * 开票结果回写锁配置项(A303-5) + */ + @Data + public static class InvoiceResult { + + /** + * 回写 single-flight 锁租约秒数 + */ + private long lockLeaseSeconds = 1800; + } + + /** + * 应收单勾稽回写锁配置项(A302-4) + */ + @Data + public static class ArResult { + + /** + * 勾稽 single-flight 锁租约秒数 + */ + private long lockLeaseSeconds = 1800; + } + + /** + * 金蝶单据锁定补偿配置项 + */ + @Data + public static class BillLock { + + /** + * 锁定/解锁总开关(thirdParty.bfi.billLock.enabled):推送后锁定 lockBusiData、 + * 金蝶解锁回调 unLockNotifyBusiData、锁定补偿重锁三处共用; + * 默认开启保持既有锁定行为,Nacos 置 false 一键停用 + * (推送后不再锁单、解锁回调只应答不处理、补偿跳过),存量锁记录守卫(不可编辑校验)不受影响 + */ + private boolean enabled = true; + + /** + * 锁定补偿开关,默认关闭(安全兜底) + */ + private boolean retryEnabled = false; + } + + /** + * 审批通过自动同步金蝶配置项(结算单 A102-2/A302-2、付款申请 A103-1/A103-3、开票申请 A303-1、收票 A303-2+A302-6) + */ + @Data + public static class AutoSync { + + /** + * 结算单审批后自动推送开关 + */ + private boolean settlement = false; + + /** + * 付款申请审批后自动推送开关 + */ + private boolean payment = false; + + /** + * 开票申请审批后自动推送开关 + */ + private boolean invoiceApply = false; + + /** + * 收票登记审批后自动推送开关 + */ + private boolean receipt = false; + + /** + * failed 单据定时重推开关(仅重推生成失败,不扫 unsynced 存量) + */ + private boolean retryEnabled = false; + } + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/config/VoucherMinioConfig.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/config/VoucherMinioConfig.java index 08ed5e38..269eb1a5 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/config/VoucherMinioConfig.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/config/VoucherMinioConfig.java @@ -5,28 +5,109 @@ package org.springblade.transport.config; import io.minio.MinioClient; +import lombok.extern.slf4j.Slf4j; +import okhttp3.HttpUrl; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.core.tool.utils.StringUtil; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import java.net.URI; +import java.util.concurrent.TimeUnit; + /** * 凭证图片 MinIO 客户端配置。 *

* 连接参数由 Nacos 的 file.storage.minio 配置提供。 - * 未配置 {@code file.storage.minio.endpoint} 时不注册该客户端, - * 以免凭证上传功能缺失配置导致整个 blade-transport 服务无法启动; - * 此时凭证相关接口会在调用时给出明确提示,而非启动即失败。 + *

    + *
  • {@code endpoint}:建议内网地址;若误配为 https://host/oss,会自动剥离 path 并用拦截器补回
  • + *
  • {@code transform-endpoint}:外网地址(可带 /oss),用于浏览器预签名
  • + *
*/ +@Slf4j @Configuration @ConditionalOnProperty(prefix = "file.storage.minio", name = "endpoint") public class VoucherMinioConfig { + private static final String DEFAULT_REGION = "us-east-1"; + @Bean public MinioClient voucherMinioClient( @Value("${file.storage.minio.endpoint:${minio.endpoint:}}") String endpoint, @Value("${file.storage.minio.access-key-id:${minio.access-key:}}") String accessKey, @Value("${file.storage.minio.access-key-secret:${minio.secret-key:}}") String secretKey) { - return MinioClient.builder().endpoint(endpoint).credentials(accessKey, secretKey).build(); + String origin = resolveClientEndpoint(endpoint); + String pathPrefix = resolvePathPrefix(endpoint); + if (StringUtil.isNotBlank(pathPrefix)) { + log.warn("MinIO endpoint 含 path,将按 origin={} pathPrefix={} 访问;建议 endpoint 使用内网地址", + origin, pathPrefix); + } + return create(endpoint, accessKey, secretKey); + } + + public static MinioClient create(String endpoint, String accessKey, String secretKey) { + String origin = resolveClientEndpoint(endpoint); + String pathPrefix = resolvePathPrefix(endpoint); + MinioClient.Builder builder = MinioClient.builder() + .endpoint(origin) + .credentials(accessKey, secretKey) + .region(DEFAULT_REGION); + if (StringUtil.isNotBlank(pathPrefix)) { + builder.httpClient(buildHttpClient(pathPrefix), true); + } + return builder.build(); + } + + /** + * MinIO SDK 不允许 endpoint 带 path(例如 https://host/oss)。 + */ + public static String resolveClientEndpoint(String endpoint) { + if (StringUtil.isBlank(endpoint)) { + throw new ServiceException("MinIO endpoint 不能为空"); + } + URI uri = URI.create(endpoint.trim()); + if (StringUtil.isBlank(uri.getScheme()) || StringUtil.isBlank(uri.getHost())) { + throw new ServiceException("MinIO endpoint 配置不正确: " + endpoint); + } + StringBuilder origin = new StringBuilder(); + origin.append(uri.getScheme()).append("://").append(uri.getHost()); + if (uri.getPort() > 0) { + origin.append(':').append(uri.getPort()); + } + return origin.toString(); + } + + private static String resolvePathPrefix(String endpoint) { + URI uri = URI.create(endpoint.trim()); + String path = uri.getPath(); + if (StringUtil.isBlank(path) || "/".equals(path)) { + return ""; + } + String normalized = path.endsWith("/") ? path.substring(0, path.length() - 1) : path; + return normalized.startsWith("/") ? normalized : "/" + normalized; + } + + private static OkHttpClient buildHttpClient(String pathPrefix) { + return new OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .addInterceptor(chain -> { + Request original = chain.request(); + HttpUrl originalUrl = original.url(); + String encodedPath = originalUrl.encodedPath(); + if (encodedPath.startsWith(pathPrefix + "/") || encodedPath.equals(pathPrefix)) { + return chain.proceed(original); + } + HttpUrl newUrl = originalUrl.newBuilder() + .encodedPath(pathPrefix + encodedPath) + .build(); + return chain.proceed(original.newBuilder().url(newUrl).build()); + }) + .build(); } } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/constant/CustomerBfiConstants.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/constant/CustomerBfiConstants.java new file mode 100644 index 00000000..712643da --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/constant/CustomerBfiConstants.java @@ -0,0 +1,63 @@ +/** + * 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.constant; + +/** + * 客商档案金蝶(星瀚)协同常量 + * + * @author Chill + */ +public class CustomerBfiConstants { + + /** + * 推送金蝶固定值:业务职能全选(1销售,2结算,3付款,4收货) + */ + public static final String BIZ_FUNCTION_ALL = "1,2,3,4"; + + /** + * 客商类型:内部组织(customer_kind 字段值) + */ + public static final String CUSTOMER_KIND_INTERNAL = "internal"; + + /** + * 客商类型:外部客商(customer_kind 字段值) + */ + public static final String CUSTOMER_KIND_EXTERNAL = "external"; + + /** + * 金蝶伙伴类型编码有效范围上界(BfiCustomerSaveDTO.type 全集 1~17) + */ + public static final int PARTNER_TYPE_MAX = 17; + + /** + * 营业期限类型:长期(TMS 表单文本) + */ + public static final String BUSINESS_TERM_LONG = "长期"; + + private CustomerBfiConstants() { + } + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/constant/KingdeeAttachmentConstant.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/constant/KingdeeAttachmentConstant.java new file mode 100644 index 00000000..1355bcc6 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/constant/KingdeeAttachmentConstant.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.transport.constant; + +import java.util.Map; + +import org.springblade.core.log.exception.ServiceException; + +/** + * 金蝶附件同步(A902)常量 + *

entityNumber/controlKey 映射以常量维护,二期客商(资质附件)等金蝶确认后补充条目即可,不改架构

+ * + * @author Chill + */ +public class KingdeeAttachmentConstant { + + // ==================== 业务单据类型(biz_type) ==================== + + /** + * 付款申请单 + */ + public static final String BIZ_PAY_APPLICATION = "pay_application"; + /** + * 正式结算-财务应付单面板 + */ + public static final String BIZ_FORMAL_SETTLEMENT_AP = "formal_settlement_ap"; + /** + * 正式结算-财务应收单面板 + */ + public static final String BIZ_FORMAL_SETTLEMENT_AR = "formal_settlement_ar"; + /** + * 收票单 + */ + public static final String BIZ_INVOICE_RECEIPT = "invoice_receipt"; + /** + * 开票申请单 + */ + public static final String BIZ_INVOICE_APPLICATION = "invoice_application"; + + // ==================== 星瀚 entityNumber(A902 定位单据类型) ==================== + + /** + * 付款申请单 + */ + public static final String ENTITY_PAY_APPLICATION = "ap_payapply"; + /** + * 财务应付单(正式结算应付面板) + */ + public static final String ENTITY_AP_BILL = "ap_finapbill"; + /** + * 财务应收单(正式结算应收面板) + */ + public static final String ENTITY_AR_BILL = "ar_finarbill"; + /** + * 收票单 + */ + public static final String ENTITY_INVOICE = "ap_invoice"; + /** + * 开票申请单 + */ + public static final String ENTITY_INVOICE_APPLICATION = "sim_original_bill"; + + /** + * 业务单据类型 -> 星瀚 entityNumber 映射; + * 客商(资质附件)二期接入:待金蝶确认后补充专用 biz_type 与 entityNumber 条目 + */ + public static final Map ENTITY_NUMBERS = Map.of( + BIZ_PAY_APPLICATION, ENTITY_PAY_APPLICATION, + BIZ_FORMAL_SETTLEMENT_AP, ENTITY_AP_BILL, + BIZ_FORMAL_SETTLEMENT_AR, ENTITY_AR_BILL, + BIZ_INVOICE_RECEIPT, ENTITY_INVOICE, + BIZ_INVOICE_APPLICATION, ENTITY_INVOICE_APPLICATION + ); + + /** + * 星瀚附件面板/附件字段标识(各单据统一) + */ + public static final String CONTROL_KEY = "attachmentpanel"; + + // ==================== 限制 ==================== + + /** + * 单文件软限制 20MB(待金蝶确认后调整);超限记失败原因跳过,不阻塞同单其余附件 + */ + public static final long MAX_FILE_SIZE_BYTES = 20L * 1024 * 1024; + + // ==================== 同步行状态(sync_status) ==================== + + /** + * 同步失败 + */ + public static final int SYNC_STATUS_FAILED = 0; + /** + * 同步成功(终态,幂等跳过) + */ + public static final int SYNC_STATUS_SUCCESS = 1; + + // ==================== 附件同步聚合态(列表展示,运行时聚合非存储字段) ==================== + + /** + * 无附件:单据 attachmentsJson 无可上传附件 + */ + public static final String AGGREGATE_NONE = "none"; + /** + * 未上传:有附件且成功数为 0 + */ + public static final String AGGREGATE_PENDING = "pending"; + /** + * 部分未传:有附件且部分上传成功 + */ + public static final String AGGREGATE_PARTIAL = "partial"; + /** + * 已上传:有附件且全部上传成功 + */ + public static final String AGGREGATE_DONE = "done"; + + /** + * 聚合态 -> 展示名称 + */ + public static final Map AGGREGATE_NAMES = Map.of( + AGGREGATE_NONE, "无附件", + AGGREGATE_PENDING, "未上传", + AGGREGATE_PARTIAL, "部分未传", + AGGREGATE_DONE, "已上传" + ); + + /** + * 业务单据类型对应星瀚 entityNumber + * + * @param bizType 业务单据类型 + * @return entityNumber,未配置时抛业务异常(entityNumber 是硬约束,猜错即传不上) + */ + public static String entityNumber(String bizType) { + String entityNumber = ENTITY_NUMBERS.get(bizType); + if (entityNumber == null) { + throw new ServiceException("金蝶附件上传失败:业务单据类型[" + bizType + "]未配置 entityNumber 映射"); + } + return entityNumber; + } + + /** + * 聚合态展示名称(未知态兜底返回原文) + * + * @param aggregateStatus 聚合态 + * @return 展示名称 + */ + public static String aggregateName(String aggregateStatus) { + return AGGREGATE_NAMES.getOrDefault(aggregateStatus, aggregateStatus); + } + + private KingdeeAttachmentConstant() { + } +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/constant/KingdeeBfiConstant.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/constant/KingdeeBfiConstant.java new file mode 100644 index 00000000..b1c45f44 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/constant/KingdeeBfiConstant.java @@ -0,0 +1,253 @@ +/** + * 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.constant; + +import java.util.List; +import java.util.Map; + +/** + * 付款同步金蝶(星瀚)固定值。 + * 金蝶侧档案编码暂以常量维护,待金蝶侧确认取值后直接修改常量重新发包。 + * + * @author Chill + */ +public final class KingdeeBfiConstant { + + /** 往来类型:供应商(付款申请与财务应付单共用)。 */ + public static final String BFI_SUPPLIER_ASSTACT_TYPE = "bd_supplier"; + + /** 往来类型:客户(财务应付单按合同类别为客户合同时使用)。 */ + public static final String BFI_CUSTOMER_ASSTACT_TYPE = "bd_customer"; + + /** 来源单据类型:上游合同(有合同时传 awrp_src_bt)。 */ + public static final String BFI_CONTRACT_SRC_BT = "awrp_contract_info"; + + /** 无项目时传的项目编码占位值。 */ + public static final String BFI_PROJECT_NUMBER_NONE = "999"; + + /** 联调默认物料编码:金蝶测试档案值,真实货物类型档案编码与金蝶对齐后替换。 */ + public static final String BFI_AP_BILL_MATERIAL_NUMBER_TEST = "SP0324"; + + /** 联调默认计量单位编码:金蝶测试档案值,档案维护计量单位编码后替换。 */ + public static final String BFI_AP_BILL_MEASUREUNIT_NUMBER_TEST = "TON"; + /** 联调默认银行行号:金蝶测试档案值,档案维护银行行号后替换。 */ + public static final String BFI_AP_BILL_BANKACCOUNT_NUMBER_TEST = "001110012009"; + + /** 联调默认项目编码:金蝶测试档案值,项目档案与金蝶对齐后替换。 */ + public static final String BFI_AP_BILL_PROJECT_NUMBER_TEST = "999"; + + /** 付款类型编码:正式付款(结算付款)。 */ + public static final String BFI_PAYMENT_TYPE_FORMAL = "201"; + + /** 付款类型编码:预付款。 */ + public static final String BFI_PAYMENT_TYPE_PREPAY = "202"; + + /** A102-2 默认单据类型名称:服务采购应付(经 AP_BILL_BILLTYPE_MAP 转换为编码)。待确认:具体类型需金蝶侧最终确认,确认后修改此名称即可。 */ + public static final String BFI_AP_BILL_BILLTYPE_NAME = "服务采购应付"; + + /** A102-2 款项性质编码(暂按 2001 材料采购)。待确认:编码需金蝶侧最终确认,确认后如有调整直接改此常量。 */ + public static final String BFI_AP_BILL_PAY_PROPERTY = "2001"; + + /** + * A102-2 付款方式(赊购 CREDIT,允许金蝶侧后续调整付款金额; + */ + public static final String BFI_AP_BILL_PUR_MODE_CREDIT = "CREDIT"; + /** + * A102-2 付款方式 现购 CASH 会由金蝶自动 100% 推付款)。 + */ + public static final String BFI_AP_BILL_PUR_MODE_CASH = "CASH"; + + /** A102-2 必填:外部单据类型(应付单来源标识)。待确认:取值需金蝶侧确认,确认后如有调整直接改此常量。 */ + public static final String BFI_AP_BILL_SRC_TYPE = "TMS财务应付单"; + + /** 财务应付单(ap_finapbill)单据类型映射:中文名称 -> 星瀚编码。 */ + public static final Map AP_BILL_BILLTYPE_MAP = Map.ofEntries( + Map.entry("借贷项调整", "ApFin_borr_BT_S"), + Map.entry("费用采购应付", "ApFin_fee_BT_S"), + Map.entry("其他应付", "ApFin_other_BT_S"), + Map.entry("产品委外应付", "ApFin_product_BT_S"), + Map.entry("标准采购应付", "ApFin_pur_BT_S"), + Map.entry("采购费用应付", "ApFin_purfee_BT_S"), + Map.entry("销售费用应付", "ApFin_salefee_BT_S"), + Map.entry("服务采购应付", "ApFin_service_BT_S"), + Map.entry("资产采购应付", "ap_finapbill_asset_BT_S") + ); + + /** 财务应收单(ar_finarbill)单据类型映射:中文名称 -> 星瀚编码(当前付款链路未启用,应收结算单推送规则待业务确认)。 */ + public static final Map AR_BILL_BILLTYPE_MAP = Map.ofEntries( + Map.entry("借贷项调整", "arfin_borrowar_BT_S"), + Map.entry("其他应收", "arfin_other_BT_S"), + Map.entry("销售费用应收", "arfin_salefee_BT_S"), + Map.entry("服务销售应收", "arfin_sersal_BT_S"), + Map.entry("标准销售应收", "arfin_standard_BT_S") + ); + + /** A302-2 默认单据类型名称:服务销售应收(经 AR_BILL_BILLTYPE_MAP 转换为编码)。待确认:具体类型需金蝶侧最终确认,确认后修改此名称即可。 */ + public static final String BFI_AR_BILL_BILLTYPE_NAME = "服务销售应收"; + + /** A302-2 款项性质编码(暂按 1001 一般销售,与应付 2001 材料采购区分)。待确认:编码需金蝶侧最终确认,确认后如有调整直接改此常量。 */ + public static final String BFI_AR_BILL_PAY_PROPERTY = "1001"; + + /** A302-2 付款方式:现销 CASH(与应付现购对称,由金蝶自动推收款)。待确认:取值需金蝶侧确认。 */ + public static final String BFI_AR_BILL_PAY_MODE_CASH = "CASH"; + + /** A302-2 必填:外部单据类型(应收单来源标识)。待确认:取值需金蝶侧确认,确认后如有调整直接改此常量。 */ + public static final String BFI_AR_BILL_SRC_TYPE = "TMS财务应收单"; + + /** A303-1 上游来源单据类型:财务应收单(awrp_src_bt)。 */ + public static final String BFI_AR_BILL_SRC_BT = "ar_finarbill"; + + /** A301-3 认领推送核心单据类型:其他应付(e_corebilltype)。 */ + public static final String BFI_CLAIM_CORE_BILL_TYPE = "ar_finarbill"; + + /** A303-1 发票种类:数电发票(普通发票)。 */ + public static final String BFI_INVOICE_TYPE_DIGITAL_GENERAL = "10xdp"; + + /** A303-1 发票种类:数电发票(增值税专用发票)。 */ + public static final String BFI_INVOICE_TYPE_DIGITAL_SPECIAL = "08xdp"; + + /** A303-1 特殊票种:货物运输(运费/货物运输类发票)。 */ + public static final String BFI_INVOICE_SPECIAL_TYPE_FREIGHT = "E04"; + + /** A303-1 特殊票种:非特殊票种(其余费用类发票,如其他、非货物运输的运输类)。 */ + public static final String BFI_INVOICE_SPECIAL_TYPE_NONE = "00"; + + /** A303-1 特殊票种货物运输判定关键字:命中商品名称或商品和服务分类简称即视为货物运输类。 */ + public static final List BFI_INVOICE_FREIGHT_KEYWORDS = List.of("货物运输", "运费"); + + /** A303-1 货物运输单据体运输工具种类合法值:字典 transport_type 翻译名称命中则直送,否则兜底其它运输工具。 */ + public static final List BFI_FREIGHT_TRANSPORT_TYPES = List.of("铁路运输", "公路运输", "水路运输", "航空运输", "管道运输", "其它运输工具"); + + /** A303-1 货物运输单据体运输工具种类兜底值。 */ + public static final String BFI_FREIGHT_TRANSPORT_TYPE_OTHER = "其它运输工具"; + + /** 金蝶同步状态:开票完成(A303-5 查到发票,终态,开票申请专用)。 */ + public static final String KINGDEE_STATUS_INVOICED = "invoiced"; + + /** 付款申请单(ap_payapply)单据类型映射:中文名称 -> 星瀚编码(使用规则待业务确认)。 */ + public static final Map PAY_APPLY_BILLTYPE_MAP = Map.of( + "采购付款申请", "ap_payapply_BT_S", + "其他付款申请", "ap_payapply_oth_BT_S" + ); + + /** 金蝶同步状态:未生成(新建单据默认)。 */ + public static final String KINGDEE_STATUS_UNSYNCED = "unsynced"; + + /** 金蝶同步状态:已生成(推送成功,等待付款)。 */ + public static final String KINGDEE_STATUS_SYNCED = "synced"; + + /** 金蝶同步状态:付款中(A103-2 paystatus = Inpayment 部分付款)。 */ + public static final String KINGDEE_STATUS_PAYING = "paying"; + + /** 金蝶同步状态:已付款(A103-2 paystatus = Alreadypay,终态)。 */ + public static final String KINGDEE_STATUS_PAID = "paid"; + + /** 金蝶同步状态:已关闭(A103-2 billstatus = D,终态)。 */ + public static final String KINGDEE_STATUS_CLOSED = "closed"; + + /** 金蝶同步状态:生成失败(推送异常/金蝶返回失败,可重推)。 */ + public static final String KINGDEE_STATUS_FAILED = "failed"; + + /** 收票单推送状态:未推送(新建单据默认,票池绑定层的 kingdee_status 与此无关)。 */ + public static final String RECEIVED_PUSH_UNSYNCED = "unsynced"; + + /** 收票单推送状态:金蝶收票单已保存(A303-2 成功,等待/已完成指定应付)。 */ + public static final String RECEIVED_PUSH_PUSHED = "pushed"; + + /** 收票单推送状态:已指定应付(A302-6 成功,一票一应付,终态;金蝶侧异常回收获占)。 */ + public static final String RECEIVED_PUSH_ASSIGNED = "assigned"; + + /** 收票单推送状态:推送失败(保存/指定异常或金蝶侧删除红冲,人工处理后重推)。 */ + public static final String RECEIVED_PUSH_FAILED = "failed"; + + /** A303-2 业务类型(PUR:采购 / FEE:费用,TMS 服务费进项票暂按采购)。待确认:取值需金蝶侧最终确认。 */ + public static final String BFI_RECEIVED_BIZ_TYPE = "PUR"; + + /** A303-2 必填:外部单据类型(收票单来源标识)。待确认:取值需金蝶侧确认,确认后如有调整直接改此常量。 */ + public static final String BFI_RECEIVED_INVOICE_SRC_TYPE = "TMS收票登记"; + + /** A103-2 付款状态:已付款。 */ + public static final String BFI_PAY_STATUS_ALREADY_PAY = "Alreadypay"; + + /** A103-2 付款状态:部分付款。 */ + public static final String BFI_PAY_STATUS_IN_PAYMENT = "Inpayment"; + + /** A103-2 付款状态:未付款。 */ + public static final String BFI_PAY_STATUS_UNPAID = "Unpaid"; + + /** A103-2 单据状态:已关闭。 */ + public static final String BFI_BILL_STATUS_CLOSED = "D"; + + /** A801-1 查询限定发票状态:0 正常(票易通字典),作废/异常票不进镜像、不可补查 */ + public static final String POOL_INVOICE_STATUS_NORMAL = "0"; + + /** A301-3 认领推送:收款类型.编码(示例 101,待金蝶确认后如有调整直接改此常量)。 */ + public static final String BFI_CLAIM_REC_PAY_TYPE = "999"; + + /** A301-3 认领推送:付款人类型(默认客户 bd_customer,待金蝶确认后如有调整直接改此常量)。 */ + public static final String BFI_CLAIM_PAYMENT_TYPE = "bd_customer"; + + /** 收款流水推送状态:未推送(未满足推送条件或尚未轮到推送)。 */ + public static final String RECEIPT_CLAIM_PUSH_NONE = "none"; + + /** 收款流水推送状态:已推送(终态闸门,不允许二次推送)。 */ + public static final String RECEIPT_CLAIM_PUSH_PUSHED = "pushed"; + + /** 收款流水推送状态:推送失败(本地认领保留,可重新推送,重试复用同一来源编码)。 */ + public static final String RECEIPT_CLAIM_PUSH_FAILED = "failed"; + + /** 收款流水金蝶回写状态:未推送(认领完成推送前的初始状态)。 */ + public static final String RECEIPT_CLAIM_BILL_NONE = "none"; + + /** 金蝶同步状态:认领结果已推送(A301-3 成功,等待金蝶收款单审核/收款,可继续回写)。 */ + public static final String KINGDEE_STATUS_PUSHED = "pushed"; + + /** 金蝶同步状态:收款单已收款(A301-1 billstatus = D,终态)。 */ + public static final String KINGDEE_STATUS_RECEIVED = "received"; + + /** 选票金额使用状态可选值:仅保留剩余可用金额>0 且金额使用状态为 0 的可用票,2-全部使用 */ + public static final String POOL_AMOUNT_USE_STATUS_SELECTABLE = "0"; + + /** 单请求远程补页调用上限:金额可用过滤在拉取后进行,过滤率过低时以此封顶防请求无限拉长 */ + public static final int POOL_MAX_REMOTE_CALLS = 3; + + /** 选票单据使用状态固定为未用(1,票易通字典):仅未使用的票可被认领/登记,前端不提供该筛选条件 */ + public static final String POOL_EXPENSE_STATUS_UNUSED = "1"; + + /** + * 按中文名称从映射字典转换单据类型编码。 + * + * @param billtypeMap 单据类型映射字典 + * @param name 单据类型中文名称 + * @return 星瀚单据类型编码,未维护时返回 null + */ + public static String billtypeBy(Map billtypeMap, String name) { + return billtypeMap.get(name); + } + + private KingdeeBfiConstant() { + } +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/constant/KingdeeLockConstant.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/constant/KingdeeLockConstant.java new file mode 100644 index 00000000..6737bc3e --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/constant/KingdeeLockConstant.java @@ -0,0 +1,91 @@ +/** + * 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.constant; + +import java.util.Map; + +/** + * 金蝶单据锁定/解锁(lockBusiData / unLockNotifyBusiData)固定值。 + *

lockBusiData 由 WLTMS 调金蝶(源系统 WLTMS、目标系统 XH), + * unLockNotifyBusiData 由金蝶调 WLTMS(源系统 XH、目标系统 WLTMS); + * WLTMS 单据类型自定义,金蝶目标单据类型为固定值

+ * + * @author Chill + */ +public final class KingdeeLockConstant { + + /** 锁动作:锁定 */ + public static final String ACTION_LOCK = "LOCK"; + + /** 锁动作:解锁 */ + public static final String ACTION_UNLOCK = "UNLOCK"; + + /** 来源操作类型:单据新增 */ + public static final String OPERATION_SAVE = "SAVE"; + + /** 来源操作类型:单据删除(解锁回调携带 DELETE 时 WLTMS 废除源单并还原关联数据) */ + public static final String OPERATION_DELETE = "DELETE"; + + /** 系统标识:WLTMS(运输管理系统,即本系统) */ + public static final String SYSTEM_WLTMS = "WLTMS"; + + /** 系统标识:金蝶星瀚 XH */ + public static final String SYSTEM_XH = "XH"; + + /** 锁状态:锁定失败(补偿定时任务的重试对象) */ + public static final String STATUS_FAILED = "failed"; + + /** 锁状态:金蝶锁定中(源单冻结,不可编辑/作废) */ + public static final String STATUS_LOCKED = "locked"; + + /** 锁状态:已解锁(终态;同单重推生成新锁记录) */ + public static final String STATUS_UNLOCKED = "unlocked"; + + /** WLTMS 单据类型:正式结算单(应付/应收共用,按结算单 settlementType 区分金蝶目标类型) */ + public static final String SOURCE_SETTLEMENT = "SETTLEMENT"; + + /** WLTMS 单据类型:开票申请单 */ + public static final String SOURCE_INVOICE_APPLY = "INVOICE_APPLY"; + + /** WLTMS 单据类型:付款申请单(映射预留,本期不锁) */ + public static final String SOURCE_PAY_APPLY = "PAY_APPLY"; + + /** WLTMS 单据类型:收票单(映射预留,本期不锁) */ + public static final String SOURCE_RECEIVED_INVOICE = "RECEIVED_INVOICE"; + + /** 金蝶目标单据类型:财务应付单(A102-2,应付结算单推送后锁定) */ + public static final String TARGET_AP_BILL = "ApFin_other_BT_S"; + + /** 金蝶目标单据类型:财务应收单(A302-2,应收结算单推送后锁定) */ + public static final String TARGET_AR_BILL = "arfin_other_BT_S"; + + /** 金蝶目标单据类型:开票单(A303-1 开票申请单,按发票张次逐张一行锁记录) */ + public static final String TARGET_INVOICE_BILL = "cdm_paybill_bank_elect_BT_S"; + + /** 金蝶目标单据类型:付款申请单(映射预留,本期不锁) */ + public static final String TARGET_PAY_APPLY = "ap_payapply_oth_BT_S"; + + /** 金蝶目标单据类型:收票单(映射预留,本期不锁) */ + public static final String TARGET_RECEIVED_INVOICE = "cdm_recbill_bank_BT_S"; + + /** 锁定重试上限:补偿任务超过该次数不再自动重试,人工排查(失败原因见 fail_reason) */ + public static final int RETRY_MAX_COUNT = 5; + + /** 金蝶目标单据类型 -> WLTMS 单据类型反查映射(解锁回调按 targetBillType 分发废除还原用) */ + public static final Map TARGET_TO_SOURCE_MAP = Map.ofEntries( + Map.entry(TARGET_AP_BILL, SOURCE_SETTLEMENT), + Map.entry(TARGET_AR_BILL, SOURCE_SETTLEMENT), + Map.entry(TARGET_INVOICE_BILL, SOURCE_INVOICE_APPLY), + Map.entry(TARGET_PAY_APPLY, SOURCE_PAY_APPLY), + Map.entry(TARGET_RECEIVED_INVOICE, SOURCE_RECEIVED_INVOICE) + ); + + private KingdeeLockConstant() { + } +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/constant/TransportBusiConstant.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/constant/TransportBusiConstant.java new file mode 100644 index 00000000..f27ce70d --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/constant/TransportBusiConstant.java @@ -0,0 +1,66 @@ +/** + * 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.constant; + +/** + * 运输业务固定值。 + * + * @author Chill + */ +public final class TransportBusiConstant { + + /** 付款类型:项目预付。 */ + public static final String PAYMENT_TYPE_PROJECT_ADVANCE = "project_advance"; + + /** 付款类型:进度预付。 */ + public static final String PAYMENT_TYPE_PROGRESS_ADVANCE = "progress_advance"; + + /** 付款类型:结算付款(关联正式结算单的正式付款)。 */ + public static final String PAYMENT_TYPE_SETTLEMENT = "settlement_payment"; + + /** 结算单类型:预结算。 */ + public static final String SETTLEMENT_BILL_PRE = "pre"; + + /** 结算单类型:正式结算。 */ + public static final String SETTLEMENT_BILL_FORMAL = "formal"; + + /** 审批状态:草稿(允许编辑、删除)。 */ + public static final String DRAFT = "draft"; + + /** 审批状态:审批中(审核节点,允许通过/驳回)。 */ + public static final String REVIEWING = "reviewing"; + + /** 审批状态:审批通过。 */ + public static final String APPROVED = "approved"; + + /** 审批状态:已驳回(允许再编辑后重新提交)。 */ + public static final String RETURNED = "returned"; + + /** 审批状态:已作废(终态)。 */ + public static final String VOIDED = "voided"; + + private TransportBusiConstant() { + } +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/BfiCustomerController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/BfiCustomerController.java new file mode 100644 index 00000000..5faea7c8 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/BfiCustomerController.java @@ -0,0 +1,75 @@ +/** + * 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.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.AllArgsConstructor; +import org.springblade.core.boot.ctrl.BladeController; +import org.springblade.core.tool.api.R; +import org.springblade.core.tool.utils.StringUtil; +import org.springblade.thirdparty.bfi.pojo.dto.BfiCustomerQueryDTO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiCustomerVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiPageVO; +import org.springblade.thirdparty.bfi.service.IBfiService; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * 业财融合(BFI)客商查询控制器 + * + * @author Chill + */ +@RestController +@AllArgsConstructor +@RequestMapping("/bfi") +@Tag(name = "BFI客商", description = "业财融合客商查询接口") +public class BfiCustomerController extends BladeController { + + private final IBfiService bfiService; + + @GetMapping("/customer/query-by-credit-code") + @Operation(summary = "按统一社会信用代码查询金蝶客商", description = "新增客商时查询金蝶是否已有该客商,已审核则回填工商信息") + public R queryByCreditCode( + @Parameter(description = "统一社会信用代码", required = true) @RequestParam String creditCode) { + if (StringUtil.isBlank(creditCode)) { + return R.fail("统一社会信用代码不能为空"); + } + BfiCustomerQueryDTO param = new BfiCustomerQueryDTO(); + param.setPageNo(1); + param.setPageSize(1); + param.setSocietycreditcode(List.of(creditCode.trim().toUpperCase())); + BfiPageVO page = bfiService.queryCustomer(param); + List rows = page == null ? null : page.getRows(); + return R.data(rows == null || rows.isEmpty() ? null : rows.get(0)); + } + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/BfiExchangeRateController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/BfiExchangeRateController.java new file mode 100644 index 00000000..ebf87635 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/BfiExchangeRateController.java @@ -0,0 +1,76 @@ +/** + * 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.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.AllArgsConstructor; +import org.springblade.core.boot.ctrl.BladeController; +import org.springblade.core.tool.api.R; +import org.springblade.core.tool.utils.StringUtil; +import org.springblade.thirdparty.bfi.pojo.vo.BfiExchangeRateVO; +import org.springblade.thirdparty.bfi.service.IBfiService; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDate; +import java.util.List; + +/** + * 业财融合(BFI)汇率查询控制器 + * + * @author Chill + */ +@RestController +@AllArgsConstructor +@RequestMapping("/bfi") +@Tag(name = "BFI汇率", description = "业财融合汇率查询接口") +public class BfiExchangeRateController extends BladeController { + + private final IBfiService bfiService; + + @GetMapping("/exchange-rate") + @Operation(summary = "BFI汇率查询", description = "根据结算币种编码和汇率日期查询BFI汇率") + public R getBfiExchangeRate( + @RequestParam String currencyCode, + @RequestParam(required = false) String effectdate) { + LocalDate date = StringUtil.isBlank(effectdate) ? LocalDate.now() : LocalDate.parse(effectdate); + List rates = bfiService.queryExchangeRate(date, date); + if (rates == null || rates.isEmpty()) { + return R.fail("未查询到该日期的BFI汇率数据"); + } + BfiExchangeRateVO matched = rates.stream() + .filter(r -> currencyCode.equalsIgnoreCase(r.getOrgcurNumber())) + .findFirst() + .orElse(null); + if (matched == null) { + return R.fail("未查询到币种 " + currencyCode + " 的汇率数据"); + } + return R.data(matched); + } +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/CustomerArchiveController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/CustomerArchiveController.java index c111098f..b0580c5a 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/CustomerArchiveController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/CustomerArchiveController.java @@ -31,6 +31,7 @@ import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletResponse; import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springblade.core.boot.ctrl.BladeController; import org.springblade.core.excel.util.ExcelUtil; import org.springblade.core.mp.support.Condition; @@ -61,6 +62,7 @@ import java.util.List; * * @author Chill */ +@Slf4j @RestController @AllArgsConstructor @PreAuth(menu = "customer_archive") @@ -134,13 +136,25 @@ public class CustomerArchiveController extends BladeController { } /** - * 审核通过 + * 审核通过(通过后自动推送金蝶,推送失败不影响审批结果,失败原因以警告文案返回) */ @PostMapping("/approve") @ApiOperationSupport(order = 6) @Operation(summary = "审核通过", description = "传入id") public R approve(@Parameter(description = "主键", required = true) @RequestParam Long id) { - return R.status(customerArchiveService.approve(id)); + boolean result = customerArchiveService.approve(id); + if (!result) { + return R.status(false); + } + try { + customerArchiveService.syncKingdee(id); + return R.success("审核通过"); + } catch (Exception exception) { + log.error("客商审批通过后推送金蝶异常,id={}", id, exception); + String reason = exception.getMessage() == null ? "金蝶接口调用异常" : exception.getMessage(); + // 与结算/付款/开票审批口径一致:审批已生效,推送失败原因以 data=false 警告文案返回 + return R.data(Boolean.FALSE, "审核通过,但推送金蝶失败:" + reason); + } } /** @@ -174,6 +188,16 @@ public class CustomerArchiveController extends BladeController { return R.status(customerArchiveService.removeDraft(ids)); } + /** + * 推送金蝶(审批通过后失败重推入口) + */ + @PostMapping("/sync-kingdee") + @ApiOperationSupport(order = 12) + @Operation(summary = "推送金蝶", description = "传入id,仅审批通过的客商可推送,已推送/金蝶已有自动幂等跳过") + public R syncKingdee(@Parameter(description = "主键", required = true) @RequestParam Long id) { + return R.data(customerArchiveService.syncKingdee(id)); + } + /** * 评分明细模板 */ diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/FormalSettlementController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/FormalSettlementController.java index 49535ce5..01a53d18 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/FormalSettlementController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/FormalSettlementController.java @@ -22,17 +22,22 @@ import org.springblade.core.mp.support.Query; import org.springblade.core.secure.annotation.PreAuth; import org.springblade.core.tool.api.R; import org.springblade.core.tool.utils.DateUtil; +import org.springblade.transport.listener.KingdeeApprovalSyncListener; import org.springblade.transport.pojo.dto.FormalSettlementSaveRequest; import org.springblade.transport.pojo.dto.FormalSettlementPaymentRequest; import org.springblade.transport.pojo.dto.FormalSettlementBatchPaymentRequest; import org.springblade.transport.pojo.dto.FormalSettlementStatusRequest; import org.springblade.transport.pojo.dto.FormalSettlementInvoiceClaimRequest; +import org.springblade.transport.pojo.dto.KingdeeInvoicePoolQueryDTO; import org.springblade.transport.pojo.dto.PreSettlementDetailAdjustRequest; import org.springblade.transport.pojo.entity.FormalSettlementDetailFee; +import org.springblade.transport.pojo.vo.KingdeeInvoicePoolVO; import org.springblade.transport.pojo.vo.FormalSettlementVO; import org.springblade.transport.excel.FormalSettlementExcel; import org.springblade.transport.pojo.vo.PreSettlementVO; import org.springblade.transport.service.IFormalSettlementService; +import org.springblade.transport.service.IInvoiceReceiptService; +import org.springblade.transport.service.IKingdeeBillLockService; import org.springblade.transport.service.IPreSettlementService; import org.springblade.transport.service.IReceiptFlowService; import org.springframework.web.bind.annotation.GetMapping; @@ -59,8 +64,10 @@ import java.math.RoundingMode; @Tag(name = "正式结算单", description = "正式结算单管理") public class FormalSettlementController extends BladeController { private final IFormalSettlementService formalSettlementService; + private final IKingdeeBillLockService kingdeeBillLockService; private final IPreSettlementService preSettlementService; private final IReceiptFlowService receiptFlowService; + private final IInvoiceReceiptService invoiceReceiptService; @GetMapping("/list") @ApiOperationSupport(order = 1) @@ -190,7 +197,19 @@ public class FormalSettlementController extends BladeController { @PostMapping("/approve") @ApiOperationSupport(order = 11) @Operation(summary = "审批通过") - public R approve(@RequestBody FormalSettlementStatusRequest request) { formalSettlementService.approve(request); return R.success("审批通过"); } + public R approve(@RequestBody FormalSettlementStatusRequest request) { + formalSettlementService.approve(request); + return approveResponse(); + } + + /** 审批接口统一响应:审批已生效,自动推送失败原因以警告文案返回(data=false 供前端区分提示样式) */ + private R approveResponse() { + String syncFailure = KingdeeApprovalSyncListener.pollSyncFailure(); + if (syncFailure != null) { + return R.data(Boolean.FALSE, "审批通过,但自动同步金蝶失败:" + syncFailure); + } + return R.success("审批通过"); + } @PostMapping("/return") @ApiOperationSupport(order = 12) @@ -204,11 +223,23 @@ public class FormalSettlementController extends BladeController { @PostMapping("/sync-kingdee") @ApiOperationSupport(order = 14) - @Operation(summary = "推送金蝶应付单") + @Operation(summary = "推送金蝶(应付单/应收单)") public R syncKingdee(@RequestParam Long id) { return R.data(formalSettlementService.syncKingdee(id)); } - @GetMapping("/detail-fees") + @PostMapping("/sync-kingdee-ar-result") @ApiOperationSupport(order = 15) + @Operation(summary = "手动勾稽金蝶应收单(A302-4 回写纠偏)") + public R syncKingdeeArResult() { return R.data(formalSettlementService.syncKingdeeArResult()); } + + @PostMapping("/retry-kingdee-bill-lock") + @ApiOperationSupport(order = 17) + @Operation(summary = "手动补偿金蝶单据锁定(扫锁定失败锁记录重调lockBusiData,应付/应收/开票通用)") + public R retryKingdeeBillLock() { + return R.data(kingdeeBillLockService.retryFailedLocks()); + } + + @GetMapping("/detail-fees") + @ApiOperationSupport(order = 16) @Operation(summary = "正式结算货物费用快照") public R> detailFees(@RequestParam Long detailId) { return R.data(formalSettlementService.detailFees(detailId)); @@ -250,4 +281,11 @@ public class FormalSettlementController extends BladeController { public R>> receiptClaims(@RequestParam Long formalSettlementId) { return R.data(receiptFlowService.settlementClaims(formalSettlementId)); } + + @GetMapping("/invoice-pool") + @ApiOperationSupport(order = 21) + @Operation(summary = "金蝶票据池镜像分页查询(认领查询)") + public R> invoicePool(KingdeeInvoicePoolQueryDTO query) { + return R.data(invoiceReceiptService.invoicePoolPage(query)); + } } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/InvoiceApplicationController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/InvoiceApplicationController.java index c3ac9cc3..6d2ab648 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/InvoiceApplicationController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/InvoiceApplicationController.java @@ -35,6 +35,7 @@ import org.springblade.core.mp.support.Condition; import org.springblade.core.mp.support.Query; import org.springblade.core.secure.annotation.PreAuth; import org.springblade.core.tool.api.R; +import org.springblade.transport.listener.KingdeeApprovalSyncListener; import org.springblade.transport.pojo.dto.InvoiceApplicationSaveRequest; import org.springblade.transport.pojo.dto.InvoiceApplicationStatusRequest; import org.springblade.transport.pojo.entity.FormalSettlementDetail; @@ -131,6 +132,15 @@ public class InvoiceApplicationController extends BladeController { @Operation(summary = "审批通过开票申请") public R approve(@RequestBody InvoiceApplicationStatusRequest request) { invoiceApplicationService.approve(request); + return approveResponse(); + } + + /** 审批接口统一响应:审批已生效,自动推送失败原因以警告文案返回(data=false 供前端区分提示样式) */ + private R approveResponse() { + String syncFailure = KingdeeApprovalSyncListener.pollSyncFailure(); + if (syncFailure != null) { + return R.data(Boolean.FALSE, "审批通过,但自动同步金蝶失败:" + syncFailure); + } return R.success("审批通过"); } @@ -156,4 +166,11 @@ public class InvoiceApplicationController extends BladeController { public R syncKingdee(@RequestParam Long id) { return R.data(invoiceApplicationService.syncKingdee(id)); } + + @PostMapping("/sync-kingdee-invoice-result") + @ApiOperationSupport(order = 13) + @Operation(summary = "手动回写金蝶开票结果(A303-5)") + public R syncKingdeeInvoiceResult() { + return R.data(invoiceApplicationService.syncKingdeeInvoiceResult()); + } } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/InvoiceReceiptController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/InvoiceReceiptController.java index 1a01d3c0..59eb30b6 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/InvoiceReceiptController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/InvoiceReceiptController.java @@ -29,16 +29,19 @@ import com.baomidou.mybatisplus.core.metadata.IPage; import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; 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.annotation.PreAuth; import org.springblade.core.tool.api.R; +import org.springblade.transport.listener.KingdeeApprovalSyncListener; import org.springblade.transport.pojo.dto.InvoiceReceiptSaveRequest; import org.springblade.transport.pojo.dto.InvoiceReceiptStatusRequest; -import org.springblade.transport.pojo.entity.KingdeeInvoicePool; +import org.springblade.transport.pojo.dto.KingdeeInvoicePoolQueryDTO; import org.springblade.transport.pojo.vo.InvoiceReceiptVO; +import org.springblade.transport.pojo.vo.KingdeeInvoicePoolVO; import org.springblade.transport.service.IInvoiceReceiptService; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; @@ -80,9 +83,24 @@ public class InvoiceReceiptController extends BladeController { @GetMapping("/invoice-pool") @ApiOperationSupport(order = 3) - @Operation(summary = "查询金蝶进项发票票据池") - public R> invoicePool(@RequestParam(required = false) String keyword) { - return R.data(invoiceReceiptService.invoicePool(keyword)); + @Operation(summary = "金蝶票据池镜像分页查询(含认领/登记标记)") + public R> invoicePool(KingdeeInvoicePoolQueryDTO query) { + return R.data(invoiceReceiptService.invoicePoolPage(query)); + } + + @PostMapping("/sync-invoice-pool") + @ApiOperationSupport(order = 13) + @Operation(summary = "手动触发金蝶全票池发票同步(窗口拉取+镜像全列upsert)") + public R syncInvoicePool() { + return R.data(invoiceReceiptService.syncKingdeeInvoicePool()); + } + + @GetMapping("/invoice-pool/attachment/download") + @ApiOperationSupport(order = 17) + @Operation(summary = "金蝶票池发票文件下载(A801-2 流式回源)", + description = "按发票号定位金蝶侧文件:本地镜像优先,镜像未落库实时回源 A801-1;成功返回文件流,失败返回 JSON 错误") + public void downloadPoolAttachment(@RequestParam String invoiceNo, HttpServletResponse response) { + invoiceReceiptService.downloadPoolAttachment(invoiceNo, response); } @GetMapping("/settlement-candidates") @@ -129,6 +147,15 @@ public class InvoiceReceiptController extends BladeController { @Operation(summary = "审批通过收票登记") public R approve(@RequestBody InvoiceReceiptStatusRequest request) { invoiceReceiptService.approve(request); + return approveResponse(); + } + + /** 审批接口统一响应:审批已生效,自动推送失败原因以警告文案返回(data=false 供前端区分提示样式) */ + private R approveResponse() { + String syncFailure = KingdeeApprovalSyncListener.pollSyncFailure(); + if (syncFailure != null) { + return R.data(Boolean.FALSE, "审批通过,但自动同步金蝶失败:" + syncFailure); + } return R.success("审批通过"); } @@ -148,10 +175,17 @@ public class InvoiceReceiptController extends BladeController { return R.success("作废成功"); } - @PostMapping("/sync-kingdee") - @ApiOperationSupport(order = 12) - @Operation(summary = "同步金蝶发票状态") - public R syncKingdee(@RequestParam Long id) { - return R.data(invoiceReceiptService.syncKingdee(id)); + @PostMapping("/push-kingdee") + @ApiOperationSupport(order = 15) + @Operation(summary = "批量推送金蝶收票单(A303-2 收票单保存 + A302-6 收票指定应付)") + public R pushKingdee(@RequestBody List ids) { + return R.data(invoiceReceiptService.pushKingdeeReceivedInvoice(ids)); + } + + @PostMapping("/sync-kingdee-result") + @ApiOperationSupport(order = 16) + @Operation(summary = "手动触发金蝶收票单结果回写(A801-3 异常回收/指定补偿)") + public R syncKingdeeResult() { + return R.data(invoiceReceiptService.syncKingdeeReceivedInvoiceResult()); } } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/KingdeeBfiPublicController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/KingdeeBfiPublicController.java new file mode 100644 index 00000000..ade6c501 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/KingdeeBfiPublicController.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. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.controller; + +import com.alibaba.fastjson2.JSON; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springblade.core.tenant.annotation.TenantIgnore; +import org.springblade.thirdparty.bfi.pojo.dto.BfiBillLockDTO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiBillLockResultVO; +import org.springblade.transport.service.IKingdeeBillLockService; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 金蝶单据锁定回调 控制器 + *

+ * unLockNotifyBusiData:金蝶(星瀚)侧解锁/删单后回调 WLTMS 送结果, + * 无登录态公开端点(模式同 CustomerArchivePublicController); + * operationType=DELETE 时 WLTMS 废除源单并还原关联数据。 + * 应答与 lockBusiData 出参同构:{updateCount, updateResultList},逐单处理(1-成功,0-未匹配/失败) + * + * @author Chill + */ +@Slf4j +@RestController +@AllArgsConstructor +@TenantIgnore +// @PreAuth(AuthConstant.PERMIT_ALL) +@RequestMapping("kingdee-bfi") +@Tag(name = "金蝶单据锁定回调", description = "金蝶星瀚调用:单据解锁/删单通知") +public class KingdeeBfiPublicController { + + private final IKingdeeBillLockService kingdeeBillLockService; + + /** + * 单据解锁通知(unLockNotifyBusiData):多张单据编号逗号拼接批量回调,逐张处理 + * + * @param param 金蝶回调参数 + * @return 处理结果,与 lockBusiData 出参同构 + */ + @PostMapping("unLockNotifyBusiData") + @Operation(summary = "单据解锁通知", description = "金蝶侧解锁/删单后回调;operationType=DELETE 时废除源单并还原关联数据") + public BfiBillLockResultVO unLockNotifyBusiData(@RequestBody BfiBillLockDTO param) { + log.info("金蝶单据解锁回调:{}", JSON.toJSONString(param)); + try { + return BfiBillLockResultVO.of(kingdeeBillLockService.onUnlockNotify(param)); + } catch (Exception exception) { + log.error("金蝶单据解锁回调处理失败:lockAction={},operationType={},targetBillType={},targetBillNo={}", + param == null ? null : param.getLockAction(), param == null ? null : param.getOperationType(), + param == null ? null : param.getTargetBillType(), param == null ? null : param.getTargetBillNo(), exception); + return BfiBillLockResultVO.of(0); + } + } +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/PaymentApplicationController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/PaymentApplicationController.java index e2c6aafe..ae6f6796 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/PaymentApplicationController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/PaymentApplicationController.java @@ -29,10 +29,12 @@ import org.springblade.core.mp.support.Condition; import org.springblade.core.mp.support.Query; import org.springblade.core.secure.annotation.PreAuth; import org.springblade.core.tool.api.R; +import org.springblade.transport.listener.KingdeeApprovalSyncListener; import org.springblade.transport.pojo.dto.PaymentApplicationSaveRequest; import org.springblade.transport.pojo.dto.PaymentApplicationStatusRequest; import org.springblade.transport.pojo.vo.PaymentApplicationReferenceAmountVO; import org.springblade.transport.pojo.vo.PaymentApplicationVO; +import org.springblade.transport.pojo.vo.ProjectAdvanceWriteOffOptionVO; import org.springblade.transport.service.IPaymentApplicationService; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; @@ -66,6 +68,17 @@ public class PaymentApplicationController extends BladeController { @RequestParam Long referenceId, @RequestParam(required = false) Long excludeId) { return R.data(paymentApplicationService.referenceAmount(paymentType, referenceId, excludeId)); } + + @GetMapping("/project-advance-write-off-options") + @ApiOperationSupport(order = 3) + @Operation(summary = "按合同查询可选项目预付核销列表") + public R> projectAdvanceWriteOffOptions( + @RequestParam Long contractId, + @RequestParam(required = false) String excludeSettlementType, + @RequestParam(required = false) Long excludeSettlementId) { + return R.data(paymentApplicationService.projectAdvanceWriteOffOptions( + contractId, excludeSettlementType, excludeSettlementId)); + } @PostMapping("/save") @ApiOperationSupport(order = 4) @Operation(summary = "保存付款申请") @@ -81,7 +94,19 @@ public class PaymentApplicationController extends BladeController { @PostMapping("/approve") @ApiOperationSupport(order = 7) @Operation(summary = "审批通过付款申请") - public R approve(@RequestBody PaymentApplicationStatusRequest request) { paymentApplicationService.approve(request); return R.success("审批通过"); } + public R approve(@RequestBody PaymentApplicationStatusRequest request) { + paymentApplicationService.approve(request); + return approveResponse(); + } + + /** 审批接口统一响应:审批已生效,自动推送失败原因以警告文案返回(data=false 供前端区分提示样式) */ + private R approveResponse() { + String syncFailure = KingdeeApprovalSyncListener.pollSyncFailure(); + if (syncFailure != null) { + return R.data(Boolean.FALSE, "审批通过,但自动同步金蝶失败:" + syncFailure); + } + return R.success("审批通过"); + } @PostMapping("/return") @ApiOperationSupport(order = 8) @Operation(summary = "驳回付款申请") @@ -100,4 +125,8 @@ public class PaymentApplicationController extends BladeController { public R> syncKingdeeBatch(@RequestBody List ids) { return R.data(paymentApplicationService.syncKingdeeBatch(ids)); } + @PostMapping("/sync-kingdee-result") + @ApiOperationSupport(order = 12) + @Operation(summary = "手动回写金蝶付款结果(A103-2/A103-4)") + public R syncKingdeeResult() { return R.data(paymentApplicationService.syncKingdeePaymentResult()); } } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ReceiptClaimRecordController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ReceiptClaimRecordController.java index 7cfd3f85..3db05ad5 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ReceiptClaimRecordController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ReceiptClaimRecordController.java @@ -84,7 +84,15 @@ public class ReceiptClaimRecordController extends BladeController { @PostMapping("/void") @ApiOperationSupport(order = 4) @Operation(summary = "作废认领记录并生成金蝶认领冲单") - public R voidClaim(@RequestParam Long id) { - return R.data(receiptClaimRecordService.voidClaim(id)); + public R voidClaim(@RequestParam Long id) { + receiptClaimRecordService.voidClaim(id); + return R.success("作废成功"); + } + + @PostMapping("/query-kingdee-result") + @ApiOperationSupport(order = 5) + @Operation(summary = "查询金蝶认领结果(A301-1 收款单查询回写)") + public R queryKingdeeResult() { + return R.data(receiptClaimRecordService.refreshKingdeeResult()); } } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ReceiptFlowController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ReceiptFlowController.java index 4aa3f02b..1bbee361 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ReceiptFlowController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ReceiptFlowController.java @@ -35,8 +35,8 @@ import org.springblade.core.mp.support.Condition; import org.springblade.core.mp.support.Query; import org.springblade.core.secure.annotation.PreAuth; import org.springblade.core.tool.api.R; +import org.springblade.transport.pojo.dto.ReceiptClaimPushResult; import org.springblade.transport.pojo.dto.ReceiptClaimRequest; -import org.springblade.transport.pojo.dto.ReceiptFlowSyncRequest; import org.springblade.transport.pojo.vo.ReceiptFlowVO; import org.springblade.transport.service.IReceiptFlowService; import org.springframework.web.bind.annotation.GetMapping; @@ -95,8 +95,16 @@ public class ReceiptFlowController extends BladeController { @PostMapping("/sync") @ApiOperationSupport(order = 5) - @Operation(summary = "手动同步金蝶收款流水") - public R sync(@RequestBody(required = false) ReceiptFlowSyncRequest request) { - return R.data(receiptFlowService.sync(request)); + @Operation(summary = "手动同步金蝶收款流水(A301-2 收款认领公告查询)") + public R sync() { + return R.data(receiptFlowService.sync()); + } + + @PostMapping("/push-kingdee") + @ApiOperationSupport(order = 6) + @Operation(summary = "推送金蝶收款认领(A301-3 收款认领结果推送,仅认领完成流水可推送)") + public R pushKingdee(@RequestParam Long flowId) { + ReceiptClaimPushResult result = receiptFlowService.pushClaimToKingdee(flowId); + return result.isSuccess() ? R.data(result) : R.fail(result.getMessage()); } } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ReceivablePayableDetailController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ReceivablePayableDetailController.java index 9790e3ee..c8cd804a 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ReceivablePayableDetailController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ReceivablePayableDetailController.java @@ -133,13 +133,15 @@ public class ReceivablePayableDetailController extends BladeController { @ApiOperationSupport(order = 8) @Operation(summary = "转结算候选明细") public R>> transferCandidates(Query query, + @RequestParam(required = false) String documentNo, + @RequestParam(required = false) String contractNo, @RequestParam(required = false) String contractName, @RequestParam(required = false) String batchNo, @RequestParam(required = false) String generateStartDate, @RequestParam(required = false) String generateEndDate, @RequestParam(required = false) String settlementBillType, @RequestParam(required = false) String settlementType) { - return R.data(detailService.transferCandidates(Condition.getPage(query), contractName, batchNo, + return R.data(detailService.transferCandidates(Condition.getPage(query), documentNo, contractNo, contractName, batchNo, generateStartDate, generateEndDate, settlementBillType, settlementType)); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/SettlementImportController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/SettlementImportController.java new file mode 100644 index 00000000..cfc6fe69 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/SettlementImportController.java @@ -0,0 +1,147 @@ +/** + * 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.controller; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; +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.annotation.PreAuth; +import org.springblade.core.tool.api.R; +import org.springblade.transport.pojo.dto.SettlementImportBatchRequest; +import org.springblade.transport.pojo.dto.SettlementImportVoidRequest; +import org.springblade.transport.pojo.vo.SettlementImportBatchVO; +import org.springblade.transport.pojo.vo.WaybillImportValidateResultVO; +import org.springblade.transport.service.ISettlementImportBatchService; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; +import java.util.Map; + +/** + * 导入结算单控制器 + * + * @author Chill + */ +@RestController +@AllArgsConstructor +@PreAuth(menu = "settlement_import") +@RequestMapping("settlement-import") +@Tag(name = "导入结算单", description = "导入结算单管理") +public class SettlementImportController extends BladeController { + + private final ISettlementImportBatchService settlementImportBatchService; + + @GetMapping("/options") + @ApiOperationSupport(order = 1) + @Operation(summary = "筛选项与表单选项") + public R>>> options() { + return R.data(settlementImportBatchService.options()); + } + + @GetMapping("/next-code") + @ApiOperationSupport(order = 2) + @Operation(summary = "下一个批次号") + public R nextCode() { + return R.data(settlementImportBatchService.nextBatchNo()); + } + + @GetMapping("/list") + @ApiOperationSupport(order = 3) + @Operation(summary = "导入结算单批次分页") + public R> list(SettlementImportBatchRequest request, Query query) { + return R.data(settlementImportBatchService.page(Condition.getPage(query), request)); + } + + @GetMapping("/detail") + @ApiOperationSupport(order = 4) + @Operation(summary = "导入结算单批次详情") + public R detail(@RequestParam Long id) { + return R.data(settlementImportBatchService.detail(id)); + } + + @GetMapping("/export-template") + @ApiOperationSupport(order = 5) + @Operation(summary = "导出导入模板") + public void exportTemplate(HttpServletResponse response) { + settlementImportBatchService.exportTemplate(response); + } + + @PostMapping("/parse") + @ApiOperationSupport(order = 6) + @Operation(summary = "解析导入 Excel") + public R> parse(@RequestPart("file") MultipartFile file) { + return R.data(settlementImportBatchService.parse(file)); + } + + @PostMapping("/validate") + @ApiOperationSupport(order = 7) + @Operation(summary = "校验导入数据") + public R validate(@RequestBody SettlementImportBatchRequest request) { + return R.data(settlementImportBatchService.validate(request)); + } + + @PostMapping("/export-failures") + @ApiOperationSupport(order = 8) + @Operation(summary = "导出失败明细") + public void exportFailures(@RequestBody SettlementImportBatchRequest request, HttpServletResponse response) { + settlementImportBatchService.exportFailures(request, null, response); + } + + @PostMapping("/draft") + @ApiOperationSupport(order = 9) + @Operation(summary = "保存草稿") + public R draft(@RequestBody SettlementImportBatchRequest request) { + return R.data(settlementImportBatchService.saveDraft(request)); + } + + @PostMapping("/confirm") + @ApiOperationSupport(order = 10) + @Operation(summary = "确认导入结算单") + public R confirm(@RequestBody SettlementImportBatchRequest request) { + return R.data(settlementImportBatchService.confirm(request)); + } + + @PostMapping("/void") + @ApiOperationSupport(order = 11) + @Operation(summary = "作废导入结算单批次") + public R voidBatch(@RequestBody SettlementImportVoidRequest request) { + settlementImportBatchService.voidBatch(request); + return R.success("作废成功"); + } +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/VoucherManageController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/VoucherManageController.java index b7e1ef2c..69f53eb0 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/VoucherManageController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/VoucherManageController.java @@ -29,10 +29,16 @@ import org.springframework.web.bind.annotation.RestController; import java.util.List; import java.util.Map; +/** + * 对外路径:{@code /blade-transport/voucher-manage/**} + *

+ * 网关 StripPrefix 后落入 {@code /voucher-manage/**}; + * 同时兼容未去前缀直连 {@code /blade-transport/voucher-manage/**},避免 404 落到静态资源。 + */ @RestController @AllArgsConstructor @PreAuth(menu = "voucher_manage") -@RequestMapping("/voucher-manage") +@RequestMapping({"/voucher-manage", "/blade-transport/voucher-manage"}) @Tag(name = "凭证管理", description = "执行凭证批次管理") public class VoucherManageController extends BladeController { private final IVoucherManageService voucherManageService; diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/event/KingdeeBillApprovedEvent.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/event/KingdeeBillApprovedEvent.java new file mode 100644 index 00000000..9652742f --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/event/KingdeeBillApprovedEvent.java @@ -0,0 +1,44 @@ +/** + * 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.event; + +/** + * 单据审批通过事件:审批事务提交后由 {@code KingdeeApprovalSyncListener} 消费, + * 自动推送对应单据至金蝶(星瀚),省掉页面手动点「同步金蝶」。 + *

+ * bizType 复用 MkProcessBizHandlers 既有取值;只在审批状态真正变为 approved 的那次发布, + * 重复回调被 approve 状态校验拒绝,天然不重复推送。 + * + * @author Chill + */ +public record KingdeeBillApprovedEvent(String bizType, Long id) { + + /** 正式结算单(应付 A102-2 / 应收 A302-2) */ + public static final String BIZ_FORMAL_SETTLEMENT = "formal-settlement"; + + /** 付款申请(A103-1 正式 / A103-3 预付) */ + public static final String BIZ_PAYMENT_APPLICATION = "payment-application"; + + /** 开票申请(A303-1) */ + public static final String BIZ_INVOICE_APPLICATION = "invoice-application"; + + /** 收票登记(A303-2 + A302-6) */ + public static final String BIZ_INVOICE_RECEIPT = "invoice-receipt"; +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/CustomerArchiveExcel.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/CustomerArchiveExcel.java index 6632ef44..ebe4eec2 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/CustomerArchiveExcel.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/CustomerArchiveExcel.java @@ -111,4 +111,7 @@ public class CustomerArchiveExcel implements Serializable { @ExcelProperty("审核通过时间") private LocalDateTime approvedTime; + @ExcelProperty("金蝶同步状态") + private String kingdeeStatusName; + } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/SettlementImportFeeReader.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/SettlementImportFeeReader.java new file mode 100644 index 00000000..be771e3c --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/SettlementImportFeeReader.java @@ -0,0 +1,379 @@ +/** + * 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.excel; + +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.DataFormatter; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.ss.usermodel.WorkbookFactory; +import org.springblade.common.excel.ImportCellReadException; +import org.springblade.core.log.exception.ServiceException; +import org.springframework.web.multipart.MultipartFile; + +import java.io.InputStream; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * 导入结算单 Excel 读取器(Apache POI)。 + *

+ * 定位「*运输费」或「运输费」列,其后直到「结算金额」为动态费用列。 + * + * @author Chill + */ +public final class SettlementImportFeeReader { + + private static final DataFormatter DATA_FORMATTER = new DataFormatter(Locale.CHINA); + + private SettlementImportFeeReader() { + } + + /** + * 解析结果 + */ + public static final class ParseResult { + private final List> rows; + private final List feeItemNames; + private final List warnings; + + public ParseResult(List> rows, List feeItemNames, List warnings) { + this.rows = rows; + this.feeItemNames = feeItemNames; + this.warnings = warnings; + } + + public List> getRows() { + return rows; + } + + public List getFeeItemNames() { + return feeItemNames; + } + + public List getWarnings() { + return warnings; + } + } + + public static ParseResult read(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new ServiceException("上传文件不能为空"); + } + try (InputStream inputStream = file.getInputStream(); Workbook workbook = WorkbookFactory.create(inputStream)) { + Sheet sheet = resolveDataSheet(workbook); + Row headerRow = sheet.getRow(sheet.getFirstRowNum()); + if (headerRow == null) { + throw new ServiceException("未识别到表头,请使用标准导入结算单模板"); + } + Map headers = readHeaderMap(headerRow); + int transportFeeColumn = findTransportFeeColumn(headers); + if (transportFeeColumn < 0) { + throw new ServiceException("未找到「*运输费」或「运输费」列"); + } + int settlementAmountColumn = findColumnExact(headers, "结算金额"); + List feeColumns = new ArrayList<>(); + List feeItemNames = new ArrayList<>(); + int endColumn = settlementAmountColumn > transportFeeColumn + ? settlementAmountColumn + : headers.keySet().stream().mapToInt(Integer::intValue).max().orElse(transportFeeColumn) + 1; + for (int column = transportFeeColumn + 1; column < endColumn; column++) { + String name = headers.get(column); + if (name == null || name.isBlank() || "结算金额".equals(name)) { + continue; + } + feeColumns.add(column); + feeItemNames.add(name); + } + + List> rows = new ArrayList<>(); + int firstDataRow = sheet.getFirstRowNum() + 1; + int lastRow = sheet.getLastRowNum(); + for (int rowIndex = firstDataRow; rowIndex <= lastRow; rowIndex++) { + Row row = sheet.getRow(rowIndex); + if (row == null) { + continue; + } + Map mapped = mapRow(headers, feeColumns, row, rowIndex); + if (mapped != null) { + rows.add(mapped); + } + } + return new ParseResult(rows, feeItemNames, List.of()); + } catch (ServiceException exception) { + throw exception; + } catch (Exception exception) { + throw new ServiceException("读取导入结算单 Excel 失败:" + exception.getMessage()); + } + } + + /** 优先取含「*运输费/运输费」表头的工作表,否则取第一个非空表。 */ + private static Sheet resolveDataSheet(Workbook workbook) { + Sheet fallback = null; + for (int index = 0; index < workbook.getNumberOfSheets(); index++) { + Sheet sheet = workbook.getSheetAt(index); + if (sheet == null) { + continue; + } + Row headerRow = sheet.getRow(sheet.getFirstRowNum()); + if (headerRow == null) { + continue; + } + Map headers = readHeaderMap(headerRow); + if (findTransportFeeColumn(headers) >= 0) { + return sheet; + } + if (fallback == null && !headers.isEmpty()) { + fallback = sheet; + } + } + if (fallback != null) { + return fallback; + } + throw new ServiceException("Excel 中未找到有效工作表"); + } + + private static Map readHeaderMap(Row headerRow) { + Map headers = new LinkedHashMap<>(); + short lastCell = headerRow.getLastCellNum(); + for (int column = 0; column < lastCell; column++) { + String value = normalizeHeader(cellString(headerRow.getCell(column))); + if (value != null && !value.isBlank()) { + headers.put(column, value); + } + } + return headers; + } + + /** 去掉 BOM/不可见空白,全角星号转半角,便于匹配「*运输费」。 */ + private static String normalizeHeader(String raw) { + if (raw == null) { + return null; + } + String text = raw.replace("\uFEFF", "") + .replace('\u00A0', ' ') + .replace('*', '*') + .trim(); + return text.isEmpty() ? null : text; + } + + private static Map mapRow(Map headers, List feeColumns, + Row row, int excelRowIndex) { + Map mapped = new LinkedHashMap<>(); + Map feeItems = new LinkedHashMap<>(); + for (Map.Entry entry : headers.entrySet()) { + int column = entry.getKey(); + String trimmedHeader = entry.getValue(); + String cell = cellString(row.getCell(column)); + if (feeColumns.contains(column)) { + feeItems.put(trimmedHeader, decimal(cell, trimmedHeader, excelRowIndex)); + continue; + } + String field = FIXED_HEADER_MAP.get(trimmedHeader); + if (field == null) { + continue; + } + if ("transportFee".equals(field) || "unitPrice".equals(field) + || "quantity".equals(field) || "mileage".equals(field) + || "settlementAmount".equals(field)) { + mapped.put(field, decimal(cell, trimmedHeader, excelRowIndex)); + } else if ("serialNumber".equals(field)) { + mapped.put(field, intValue(cell)); + } else { + mapped.put(field, blankToNull(cell)); + } + mapped.put(trimmedHeader, mapped.get(field)); + } + + boolean hasBusinessValue = mapped.values().stream().anyMatch(value -> { + if (value == null) { + return false; + } + if (value instanceof String text) { + return !text.isBlank(); + } + return true; + }) || feeItems.values().stream().anyMatch(amount -> amount != null && amount.compareTo(BigDecimal.ZERO) != 0); + if (!hasBusinessValue) { + return null; + } + + BigDecimal transportFee = toDecimal(mapped.get("transportFee")); + BigDecimal feeItemTotal = feeItems.values().stream() + .reduce(BigDecimal.ZERO, BigDecimal::add) + .setScale(2, RoundingMode.HALF_UP); + BigDecimal settlementAmount = transportFee.add(feeItemTotal).setScale(2, RoundingMode.HALF_UP); + mapped.put("settlementAmount", settlementAmount); + mapped.put("结算金额", settlementAmount); + mapped.put("feeItems", feeItems); + mapped.put("freight", transportFee); + mapped.put("运费", transportFee); + mapped.put("*运输费", transportFee); + mapped.put("运输费", transportFee); + mapped.put("otherFeeTotal", feeItemTotal); + mapped.put("其他费用合计", feeItemTotal); + mapped.put("freightTotal", settlementAmount); + mapped.put("运费合计", settlementAmount); + if (mapped.get("unitPrice") != null) { + mapped.put("单价", mapped.get("unitPrice")); + mapped.put("运输单价", mapped.get("unitPrice")); + } + return mapped; + } + + private static int findTransportFeeColumn(Map headers) { + int starred = findColumnExact(headers, "*运输费"); + if (starred >= 0) { + return starred; + } + return findColumnExact(headers, "运输费"); + } + + private static int findColumnExact(Map headers, String header) { + return headers.entrySet().stream() + .filter(entry -> header.equals(entry.getValue())) + .mapToInt(Map.Entry::getKey) + .findFirst() + .orElse(-1); + } + + private static String cellString(Cell cell) { + if (cell == null) { + return null; + } + String text = DATA_FORMATTER.formatCellValue(cell); + if (text == null) { + return null; + } + text = text.trim(); + return text.isEmpty() ? null : text; + } + + private static BigDecimal decimal(String cell, String columnName, Integer excelRowIndex) { + if (cell == null || cell.isBlank()) { + return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP); + } + String normalized = cell.trim().replace(",", ""); + try { + return new BigDecimal(normalized).setScale(2, RoundingMode.HALF_UP); + } catch (NumberFormatException exception) { + throw new ImportCellReadException(excelRowIndex, columnName, cell.trim()); + } + } + + private static Integer intValue(String cell) { + if (cell == null || cell.isBlank()) { + return null; + } + try { + return new BigDecimal(cell.trim().replace(",", "")).intValue(); + } catch (NumberFormatException exception) { + return null; + } + } + + private static String blankToNull(String cell) { + if (cell == null) { + return null; + } + String text = cell.trim(); + return text.isEmpty() ? null : text; + } + + private static BigDecimal toDecimal(Object value) { + if (value == null) { + return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP); + } + if (value instanceof BigDecimal bigDecimal) { + return bigDecimal; + } + try { + return new BigDecimal(String.valueOf(value).trim()).setScale(2, RoundingMode.HALF_UP); + } catch (NumberFormatException exception) { + return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP); + } + } + + private static final Map FIXED_HEADER_MAP = buildFixedHeaderMap(); + + private static Map buildFixedHeaderMap() { + Map map = new LinkedHashMap<>(); + map.put("序号", "serialNumber"); + map.put("原始单号", "originalNo"); + map.put("配载标识号", "loadingIdentifier"); + map.put("*车牌号/船班号/船次号/班次号", "vehicleNo"); + map.put("车牌号/船班号/船次号/班次号", "vehicleNo"); + map.put("*车牌号/航班号/船号/班列号", "vehicleNo"); + map.put("车牌号/航班号/船号/班列号", "vehicleNo"); + map.put("*运输方式", "transportType"); + map.put("运输方式", "transportType"); + map.put("运输类型", "transportType"); + map.put("*运输类型", "transportType"); + map.put("司机/船长姓名", "driverName"); + map.put("司机/船长手机号", "driverPhone"); + map.put("*发货地址", "departureAddress"); + map.put("发货地址", "departureAddress"); + map.put("发货联系人", "departureContact"); + map.put("发货联系人电话", "departurePhone"); + map.put("*到货地址", "arrivalAddress"); + map.put("到货地址", "arrivalAddress"); + map.put("收货联系人", "arrivalContact"); + map.put("收货联系人电话", "arrivalPhone"); + map.put("*货物名称", "cargoName"); + map.put("货物名称", "cargoName"); + map.put("*货物类型", "cargoType"); + map.put("货物类型", "cargoType"); + map.put("包装", "packageType"); + map.put("*数量", "quantity"); + map.put("数量", "quantity"); + map.put("*数量单位", "quantityUnit"); + map.put("数量单位", "quantityUnit"); + map.put("规格", "specification"); + map.put("型号", "model"); + map.put("里程(km)", "mileage"); + map.put("里程", "mileage"); + map.put("运输单价", "unitPrice"); + map.put("单价", "unitPrice"); + map.put("*运输费", "transportFee"); + map.put("运输费", "transportFee"); + map.put("结算金额", "settlementAmount"); + map.put("*实际发货时间", "actualStartDate"); + map.put("实际发货时间", "actualStartDate"); + map.put("*实际完成时间", "actualEndDate"); + map.put("实际完成时间", "actualEndDate"); + map.put("预计发货时间", "planStartDate"); + map.put("预计完成时间", "planEndDate"); + map.put("备注", "remark"); + map.put("同一运单标识号", "waybillIdentifier"); + return map; + } +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/SettlementImportTemplateExcel.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/SettlementImportTemplateExcel.java new file mode 100644 index 00000000..a060b15e --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/SettlementImportTemplateExcel.java @@ -0,0 +1,121 @@ +/** + * 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.excel; + +import cn.idev.excel.annotation.ExcelIgnore; +import cn.idev.excel.annotation.ExcelProperty; +import cn.idev.excel.annotation.write.style.ColumnWidth; +import cn.idev.excel.annotation.write.style.ContentRowHeight; +import cn.idev.excel.annotation.write.style.HeadRowHeight; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.math.BigDecimal; + +/** + * 导入结算单 Excel 模板(固定列 + 运输单价 + *运输费 + 示例费用项) + * + * @author Chill + */ +@Data +@ColumnWidth(22) +@HeadRowHeight(28) +@ContentRowHeight(18) +public class SettlementImportTemplateExcel implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @ExcelProperty("序号") + private Integer serialNumber; + @ExcelProperty("原始单号") + private String originalNo; + @ExcelProperty("配载标识号") + private String loadingIdentifier; + @ExcelProperty("*车牌号/航班号/船号/班列号") + private String vehicleNo; + @ExcelProperty("*运输方式") + private String transportType; + @ExcelProperty("司机/船长姓名") + private String driverName; + @ExcelProperty("司机/船长手机号") + private String driverPhone; + @ExcelProperty("*发货地址") + private String departureAddress; + @ExcelProperty("发货联系人") + private String departureContact; + @ExcelProperty("发货联系人电话") + private String departurePhone; + @ExcelProperty("*到货地址") + private String arrivalAddress; + @ExcelProperty("收货联系人") + private String arrivalContact; + @ExcelProperty("收货联系人电话") + private String arrivalPhone; + @ExcelProperty("*货物名称") + private String cargoName; + @ExcelProperty("*货物类型") + private String cargoType; + @ExcelProperty("包装") + private String packageType; + @ExcelProperty("*数量") + private BigDecimal quantity; + @ExcelProperty("*数量单位") + private String quantityUnit; + @ExcelProperty("规格") + private String specification; + @ExcelProperty("型号") + private String model; + @ExcelProperty("里程(km)") + private BigDecimal mileage; + @ExcelProperty("运输单价") + private BigDecimal unitPrice; + @ExcelProperty("*运输费") + private BigDecimal transportFee; + @ExcelProperty("装卸费") + private BigDecimal loadingFee; + @ExcelProperty("过路费") + private BigDecimal tollFee; + @ExcelProperty("结算金额") + private BigDecimal settlementAmount; + @ExcelProperty("*实际发货时间") + private String actualStartDate; + @ExcelProperty("*实际完成时间") + private String actualEndDate; + @ExcelProperty("预计发货时间") + private String planStartDate; + @ExcelProperty("预计完成时间") + private String planEndDate; + @ExcelProperty("备注") + private String remark; + @ExcelProperty("同一运单标识号") + private String waybillIdentifier; + + /** 导入失败原因(不导出到模板,仅用于失败明细) */ + @ExcelIgnore + private String errorMessage; +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/listener/KingdeeApprovalSyncListener.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/listener/KingdeeApprovalSyncListener.java new file mode 100644 index 00000000..185a0c56 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/listener/KingdeeApprovalSyncListener.java @@ -0,0 +1,140 @@ +/** + * 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.listener; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springblade.transport.config.BfiProperties; +import org.springblade.transport.event.KingdeeBillApprovedEvent; +import org.springblade.transport.service.IFormalSettlementService; +import org.springblade.transport.service.IInvoiceApplicationService; +import org.springblade.transport.service.IInvoiceReceiptService; +import org.springblade.transport.service.IPaymentApplicationService; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import java.util.List; + +/** + * 单据审批通过自动同步金蝶监听器:审批事务提交(AFTER_COMMIT)后复用手动按钮同一 Service 方法推送, + * 与客商档案审批后自动推送同线程模式。 + *

+ * 不变式:审批结果不受推送影响——监听器整体 try/catch 吞异常,推送异常只落 failed + 日志; + * 失败单据由 {@code KingdeeAutoSyncRetryTask} 定时重推、手动按钮兜底。 + *

+ * 推送失败原因通过 {@link #pollSyncFailure()} 传递给审批接口响应:AFTER_COMMIT 监听与 approve + * 同线程同请求,Controller 在 approve 返回后取走原因拼进提示文案;OA 回调路径无人取走, + * 由监听入口清空兜底,不会串单。 + *

+ * ponytail: 同步推送占用审批请求线程,若 BFI 网关大面积变慢导致审批接口可感知变慢, + * 再把路由调用提交到守护线程池(MkProcessMessageService 已有同款裸 ScheduledExecutorService 模式)。 + *

+ * 域开关(Nacos thirdParty.bfi.autoSync.*)默认全部关闭,与既有定时任务开关风格一致; + * 关闭某域 = 该域回到现状(审批不自动推送、手动按钮照用)。 + * + * @author Chill + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class KingdeeApprovalSyncListener { + + private final IFormalSettlementService formalSettlementService; + private final IPaymentApplicationService paymentApplicationService; + private final IInvoiceApplicationService invoiceApplicationService; + private final IInvoiceReceiptService invoiceReceiptService; + private final BfiProperties bfiProperties; + + /** 当前审批请求线程的自动推送失败原因,供 Controller 取走提示前端(见类注释线程说明) */ + private static final ThreadLocal SYNC_FAILURE = new ThreadLocal<>(); + + /** + * 取走并清除当前审批请求的自动推送失败原因;null 表示推送成功或未触发推送。 + * 须在 service.approve 返回后立即调用(监听器在审批事务提交时同步执行完毕)。 + */ + public static String pollSyncFailure() { + String failure = SYNC_FAILURE.get(); + SYNC_FAILURE.remove(); + return failure; + } + + /** + * 事务提交后同步推送。fallbackExecution=true 兜底:approve 未开启事务时事件不再被默认丢弃。 + * + * @param event 审批通过事件(bizType + 单据id) + */ + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) + public void onBillApproved(KingdeeBillApprovedEvent event) { + SYNC_FAILURE.remove(); + try { + switch (event.bizType()) { + case KingdeeBillApprovedEvent.BIZ_FORMAL_SETTLEMENT -> pushSettlement(event.id()); + case KingdeeBillApprovedEvent.BIZ_PAYMENT_APPLICATION -> pushPayment(event.id()); + case KingdeeBillApprovedEvent.BIZ_INVOICE_APPLICATION -> pushInvoiceApply(event.id()); + case KingdeeBillApprovedEvent.BIZ_INVOICE_RECEIPT -> pushReceipt(event.id()); + default -> log.warn("审批通过自动同步金蝶跳过:未知业务类型 bizType={} id={}", event.bizType(), event.id()); + } + } catch (Exception exception) { + // 推送失败只记日志(失败状态已在推送方法内独立事务落库),不影响审批结果,交给重推任务与手动按钮兜底; + // 原因暂存线程变量,由审批 Controller 取走提示前端 + log.error("审批通过自动同步金蝶失败 bizType={} id={}", event.bizType(), event.id(), exception); + String reason = exception.getMessage() == null ? "金蝶接口调用异常" : exception.getMessage(); + // 手动按钮同文案已带"推送金蝶失败:"前缀,拼提示时去掉避免重复 + SYNC_FAILURE.set(reason.startsWith("推送金蝶失败:") ? reason.substring("推送金蝶失败:".length()) : reason); + } + } + + private void pushSettlement(Long id) { + if (!bfiProperties.getAutoSync().isSettlement()) { + return; + } + log.info("正式结算单[{}]审批通过自动同步金蝶开始", id); + String billNo = formalSettlementService.syncKingdee(id); + log.info("正式结算单[{}]审批通过自动同步金蝶完成:billNo={}", id, billNo); + } + + private void pushPayment(Long id) { + if (!bfiProperties.getAutoSync().isPayment()) { + return; + } + log.info("付款申请[{}]审批通过自动同步金蝶开始", id); + String kingdeeBillId = paymentApplicationService.syncKingdee(id); + log.info("付款申请[{}]审批通过自动同步金蝶完成:kingdeeBillId={}", id, kingdeeBillId); + } + + private void pushInvoiceApply(Long id) { + if (!bfiProperties.getAutoSync().isInvoiceApply()) { + return; + } + log.info("开票申请[{}]审批通过自动同步金蝶开始", id); + String billNo = invoiceApplicationService.syncKingdee(id); + log.info("开票申请[{}]审批通过自动同步金蝶完成:billNo={}", id, billNo); + } + + private void pushReceipt(Long id) { + if (!bfiProperties.getAutoSync().isReceipt()) { + return; + } + log.info("收票登记[{}]审批通过自动推送金蝶收票单开始", id); + String summary = invoiceReceiptService.pushKingdeeReceivedInvoice(List.of(id), true); + log.info("收票登记[{}]审批通过自动推送金蝶收票单完成:{}", id, summary); + } +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/CustomerArchiveMapper.xml b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/CustomerArchiveMapper.xml index 6da293f2..e6d9996c 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/CustomerArchiveMapper.xml +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/CustomerArchiveMapper.xml @@ -48,6 +48,9 @@ + + + + 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}, '%') + + + AND p.issuer_name LIKE CONCAT('%', #{query.salerName}, '%') + + + AND p.receiver_name LIKE CONCAT('%', #{query.buyerName}, '%') + + + AND p.invoice_type_code = #{query.invoiceType} + + + AND p.saler_tax_no = #{query.salerTaxNo} + + + AND p.buyer_tax_no = #{query.buyerTaxNo} + + + AND p.invoice_date >= #{query.invoiceDateStart} + + + AND p.invoice_date <= #{query.invoiceDateEnd} + + + AND + + + AND NOT + + + AND + + + AND NOT + + ORDER BY p.source_updated_time DESC, p.create_time DESC + + 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 e28a8299..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 @@ -36,6 +36,7 @@ f.summary, f.transaction_time, f.detail_serial_no, + f.push_status, (SELECT GROUP_CONCAT(rcs.formal_settlement_no ORDER BY rcs.id SEPARATOR ',') FROM blade_receipt_claim_settlement rcs WHERE rcs.receipt_claim_id = c.id AND rcs.is_deleted = 0) AS associated_settlement_nos @@ -91,6 +92,9 @@ AND c.kingdee_bill_status = #{query.kingdeeBillStatus} + + AND f.push_status = #{query.pushStatus} + + 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/mk/MkProcessBizHandlers.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/mk/MkProcessBizHandlers.java index 8e25176e..918749f6 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mk/MkProcessBizHandlers.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mk/MkProcessBizHandlers.java @@ -4,8 +4,10 @@ import lombok.RequiredArgsConstructor; import org.springblade.core.tenant.annotation.TenantIgnore; import org.springblade.core.tool.utils.Func; import org.springblade.transport.pojo.dto.BillPaymentStatusRequest; +import org.springblade.transport.pojo.dto.FormalSettlementStatusRequest; import org.springblade.transport.pojo.dto.InvoiceApplicationStatusRequest; import org.springblade.transport.pojo.dto.InvoiceReceiptStatusRequest; +import org.springblade.transport.pojo.dto.PaymentApplicationStatusRequest; import org.springblade.transport.pojo.entity.BillPayment; import org.springblade.transport.pojo.entity.ContractManage; import org.springblade.transport.pojo.entity.FormalSettlement; @@ -311,11 +313,18 @@ public final class MkProcessBizHandlers { if (entity == null || "approved".equals(entity.getApprovalStatus())) { return entity != null; } - entity.setApprovalStatus("approved"); - entity.setCurrentNode("审核通过"); - entity.setCurrentProcessor(MkProcessNodeHelper.text(processorName, "系统")); - entity.setApprovedTime(LocalDateTime.now()); - return service.updateById(entity); + // 统一走 service.approve:与手动审批同一入口(补变更记录),审批事务提交后自动触发金蝶推送事件; + // 仅审批中单据允许通过,重复回调被状态校验拒绝,不重复推送 + FormalSettlementStatusRequest request = new FormalSettlementStatusRequest(); + request.setId(id); + service.approve(request); + FormalSettlement updated = service.getById(id); + if (updated != null) { + updated.setCurrentNode("审核通过"); + updated.setCurrentProcessor(MkProcessNodeHelper.text(processorName, "系统")); + service.updateById(updated); + } + return true; } @Override @@ -374,10 +383,18 @@ public final class MkProcessBizHandlers { if (entity == null || "approved".equals(entity.getApprovalStatus())) { return entity != null; } - entity.setApprovalStatus("approved"); - entity.setCurrentNode("审核通过"); - entity.setCurrentProcessor(MkProcessNodeHelper.text(processorName, "系统")); - return service.updateById(entity); + // 统一走 service.approve:与手动审批同一入口(补票据额度联动 useBillBalance),审批事务提交后自动触发金蝶推送事件; + // 仅审批中单据允许通过,重复回调被状态校验拒绝,不重复推送 + PaymentApplicationStatusRequest request = new PaymentApplicationStatusRequest(); + request.setId(id); + service.approve(request); + PaymentApplication updated = service.getById(id); + if (updated != null) { + updated.setCurrentNode("审核通过"); + updated.setCurrentProcessor(MkProcessNodeHelper.text(processorName, "系统")); + service.updateById(updated); + } + return true; } @Override diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ICustomerArchiveService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ICustomerArchiveService.java index b822a2f0..e50f1a47 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ICustomerArchiveService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ICustomerArchiveService.java @@ -188,6 +188,37 @@ public interface ICustomerArchiveService extends BaseService { */ boolean removeDraft(String ids); + /** + * 推送金蝶(A902-1 复查 + A902-2 保存),幂等:已推送/金蝶已有直接返回 + * + * @param id 主键 + * @return 更新后的客商档案 + */ + CustomerArchive syncKingdee(Long id); + + /** + * 推送失败状态落库(独立事务):推送主事务已回滚,失败状态与原因单独落库供页面展示与手动重推 + * + * @param id 主键 + * @param reason 失败原因 + */ + void markKingdeeSyncFailed(Long id, String reason); + + /** + * 定时同步金蝶客商数据:待推送客商自动推送 + 已同步客商使用状态回写 + * + * @return 同步汇总说明 + */ + String syncKingdeeCustomerData(); + + /** + * 金蝶侧停用联动:停用客商档案并记录变更(仅当前启用的客商生效) + * + * @param id 主键 + * @return 是否执行停用 + */ + boolean disableCustomerFromKingdee(Long id); + /** * 基于启用评分量化表生成评分记录 * 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 cd8557b7..3393983e 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 @@ -18,6 +18,7 @@ import org.springblade.transport.pojo.vo.PreSettlementVO; import org.springblade.transport.pojo.entity.PreSettlement; import org.springblade.transport.pojo.dto.PreSettlementDetailAdjustRequest; import org.springblade.transport.pojo.entity.FormalSettlementDetailFee; +import java.util.Collection; import java.util.List; import org.springblade.transport.pojo.dto.FormalSettlementPaymentRequest; import org.springblade.transport.pojo.dto.FormalSettlementBatchPaymentRequest; @@ -40,9 +41,54 @@ public interface IFormalSettlementService extends BaseService void returnBill(FormalSettlementStatusRequest request); void voidBill(FormalSettlementStatusRequest request); String syncKingdee(Long id); + + /** + * 金蝶删单回调废除(unLockNotifyBusiData DELETE 分发):作废结算单、同步信息还原推送前状态 + * (kingdeeSyncStatus→unsynced,清单据编号/星瀚内码/同步时间),操作人取金蝶回调报文 + * + * @param id 正式结算单id + * @param operator 金蝶侧操作人 + */ + void voidByKingdee(Long id, String operator); + + /** + * 单据级事务执行金蝶推送(由 syncKingdee 经自代理调用): + * A102-2/A302-2 保存并回写结算单单据编号与星瀚内码(kingdeeBillId) + * + * @param id 正式结算单id + * @return 金蝶单据编号 + */ + String doSyncKingdee(Long id); + + /** + * 独立事务落"生成失败"状态并记失败原因(在推送事务回滚、行锁释放后执行,与付款/收票口径统一); + * 仅更新审批通过且未同步的单据,未审批/已同步/已删除单据自动跳过,保持原状态 + * + * @param id 正式结算单id + * @param reason 失败原因(写变更记录留档) + */ + void markKingdeeSyncFailed(Long id, String reason); + + /** + * 重推金蝶生成失败(kingdeeSyncStatus=failed 且审批通过)的正式结算单:逐单独立成败,失败不中断。 + * 只扫 failed 不扫 unsynced——存量"审批通过但从未推送"的历史单不自动补推,仍走手动按钮 + * + * @return 执行摘要 + */ + String retryKingdeeFailed(); + + String syncKingdeeArResult(); List detailFees(Long detailId); void adjustDetail(PreSettlementDetailAdjustRequest request); void refreshPaymentSummary(Long settlementId); + + /** + * 批量刷新收款汇总:固定数次查询完成 N 张结算单的重算,避免逐单刷新的 N+1 + * + * @param settlementIds 结算单id集合 + */ + void refreshPaymentSummaries(Collection settlementIds); + void refreshPaymentSummariesForPreSettlement(Long preSettlementId); String applyPayment(FormalSettlementPaymentRequest request); List applyPayments(FormalSettlementBatchPaymentRequest request); 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 93fe9fa5..aa9faf18 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 @@ -55,4 +55,41 @@ public interface IInvoiceApplicationService extends BaseService { InvoiceReceiptVO detail(Long id); - List invoicePool(String keyword); + /** + * 金蝶票据池镜像统一查询(本地真分页,收票登记选票与正式结算单认领共用)。 + *

仅出可用票(剩余可用金额>0 且金额使用状态未用),认领/登记三态过滤在 SQL 内直出; + * 票号精确查本地未命中时窄查一次 A801-1 回源入库后重查,兜底增量窗口缝隙内的当日新票。 + * + * @param query 统一查询条件 + * @return 镜像统一视图分页 + */ + IPage invoicePoolPage(KingdeeInvoicePoolQueryDTO query); + + /** + * 金蝶全票池定时同步(瘦身版):滚动增量窗口分页拉取 A801-1 + 镜像批量全列 upsert。 + *

无绑定回填/漏绑补偿/白名单(保存即写存根绑定,登记侧无需定时绑定); + * single-flight 锁防多副本并发,手动触发与定时任务共用。 + * + * @return 执行摘要 + */ + String syncKingdeeInvoicePool(); + + /** + * 结算单认领确认闸门:按票号集合逐张重查 A801-1 并按选票同口径复核可用性, + * 选中到确认之间票被认领/使用/红冲在此拦截(与收票保存重查同闸门) + * + * @param invoiceNos 待认领发票号码集合 + */ + void assertInvoicesClaimable(Collection invoiceNos); + + /** + * 金蝶票池发票文件下载(A801-2 流式回源,前端查看/下载代理端点)。 + *

按发票号定位金蝶侧文件:本地镜像优先(票号同租户唯一,租户隔离由 MP 插件保证), + * 镜像未落库或无文件时实时回源 A801-1 取 downloadUrl;经 A801-2 下载后流式透传浏览器, + * 不落盘不进堆。写流前的业务失败以 JSON(R.fail)写入响应,前端按 Content-Type 识别提示。 + * + * @param invoiceNo 发票号码 + * @param response 响应对象(文件流写出口) + */ + void downloadPoolAttachment(String invoiceNo, HttpServletResponse response); List> settlementCandidates(String keyword, Long receiptId); @@ -65,5 +104,40 @@ public interface IInvoiceReceiptService extends BaseService { void voidBill(InvoiceReceiptStatusRequest request); - String syncKingdee(Long id); + /** + * 批量推送金蝶收票单(图05 环节②③:A303-2 收票单批量保存 + A302-6 收票指定应付)。 + *

逐单独立成败:前置校验(审批通过、已绑定镜像、查验通过、恰好分摊一张应付结算单)→ + * A102-4 预检财务应付单已生成 → A801-3 幂等预检(命中跳过保存续推指定)→ A303-2 保存 → + * 立即 A302-6 指定应付;成功置 assigned 并锁单,失败置 failed 提示人工处理后重推。 + * + * @param ids 收票登记id列表 + * @return 执行摘要(逐单失败原因) + */ + String pushKingdeeReceivedInvoice(List ids); + + /** + * 自动推送路径批量推送金蝶收票单(审批通过事件 / 定时重推专用): + * 前置拦截(未绑定镜像/查验未通过/前置应付单未生成等)一并置 failed + 原因,纳入定时重推循环 + * + * @param ids 收票登记id列表 + * @param autoSync 自动推送标记(前置拦截置 failed) + * @return 执行摘要(逐单失败原因) + */ + String pushKingdeeReceivedInvoice(List ids, boolean autoSync); + + /** + * 重推金蝶推送失败(kingdeePushStatus=failed 且审批通过)的收票登记:逐单独立成败,失败不中断。 + * 只扫 failed 不扫 unsynced——存量"审批通过但从未推送"的历史单不自动补推,仍走手动按钮 + * + * @return 执行摘要 + */ + String retryKingdeeFailed(); + + /** + * 定时回写金蝶收票单结果(A801-3):金蝶侧删除/红冲/作废 → 回收为 failed 提示人工(不自动作废登记); + * pushed(保存成功后中断)→ 指定补偿;assigned 仅补登缺失的收票单号 + * + * @return 执行摘要 + */ + String syncKingdeeReceivedInvoiceResult(); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IKingdeeAttachmentSyncService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IKingdeeAttachmentSyncService.java new file mode 100644 index 00000000..758f42ba --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IKingdeeAttachmentSyncService.java @@ -0,0 +1,37 @@ +/** + * 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 org.springblade.core.mp.base.BaseService; +import org.springblade.transport.pojo.entity.KingdeeAttachmentSync; + +/** + * 金蝶附件同步服务 + * + * @author Chill + */ +public interface IKingdeeAttachmentSyncService extends BaseService { +} 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 new file mode 100644 index 00000000..6438f026 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IKingdeeBillLockService.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. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +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; + +/** + * 金蝶单据锁定记录服务 + * + * @author Chill + */ +public interface IKingdeeBillLockService extends IService { + + /** + * 推送金蝶成功后锁单(由各推送链路在主单事务提交后调用): + * 每张金蝶目标单一行锁记录(先落 failed 再调 lockBusiData,成功翻 locked); + * 锁单接口 1对1:逐张调用(单次一个源单号对一个金蝶单号),单张成败互不影响; + * 已 locked 幂等跳过,failed 原地重锁并重置补偿计数; + * 异常独立捕获,不影响主单同步状态 + * + * @param sourceBillType WLTMS 单据类型(SETTLEMENT/INVOICE_APPLY) + * @param sourceBillId WLTMS 业务主键 + * @param sourceBillNo WLTMS 单号 + * @param targetBillType 金蝶目标单据类型 + * @param targetBillNos 金蝶单据编号集合(开票申请为多张) + * @return 锁定失败的金蝶单号摘要(逗号拼接),全部锁定成功返回 null;调用方可拼入页面提示 + */ + String lockAfterPush(String sourceBillType, Long sourceBillId, String sourceBillNo, String targetBillType, List targetBillNos); + + /** + * 金蝶解锁回调(unLockNotifyBusiData):校验来源/目标系统与 lockAction。 + * 注意回调方向与锁定相反:回调 sourceBillNo=金蝶单号(即锁记录 targetBillNo,逗号拼接批量)、 + * targetBillNo=WLTMS 单号(即锁记录 sourceBillNo)、targetBillType 可能为空, + * 故按金蝶单号+我方单号双条件反查最新 locked 锁记录; + * operationType=DELETE 时按单据类型分发废除源单并还原关联数据 + * + * @param param 金蝶回调参数 + * @return 处理单据数(应答 updateCount) + */ + int onUnlockNotify(BfiBillLockDTO param); + + /** + * 编辑守卫:源单存在 locked 锁记录时抛 ServiceException(金蝶锁定中不可修改) + * + * @param sourceBillType WLTMS 单据类型 + * @param sourceBillId WLTMS 业务主键 + */ + void checkEditable(String sourceBillType, Long sourceBillId); + + /** + * 批量查源单当前锁状态(供列表/详情页填充展示标识): + * 每源单取最新一条非终态锁记录(locked/failed),unlocked 为终态不返回(前端视为无锁/已解锁) + * + * @param sourceBillType WLTMS 单据类型 + * @param sourceBillIds 源单主键集合 + * @return sourceBillId -> 最新锁记录,无活跃锁的源单不在 Map 中 + */ + 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:单次一个源单号对一个金蝶单号,单张成败互不影响;定时任务定时触发,亦可人工触发); + * 成功翻 locked,失败 try_count+1 并记失败原因; + * 计数烧满上限后停止自动补偿(人工重推「同步金蝶」会重置计数重新接管)。锁记录落库均为批量更新 + * + * @return 补偿摘要(待补偿/成功张数),供调用方记日志或页面提示 + */ + String retryFailedLocks(); +} 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 26779eda..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 @@ -21,11 +21,14 @@ package org.springblade.transport.service; import com.baomidou.mybatisplus.core.metadata.IPage; import org.springblade.core.mp.base.BaseService; +import org.springblade.thirdparty.bfi.pojo.vo.BfiPayApplyVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiPayBillVO; import org.springblade.transport.pojo.dto.PaymentApplicationSaveRequest; 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; @@ -34,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); @@ -41,5 +49,85 @@ public interface IPaymentApplicationService extends BaseService syncKingdeeBatch(List ids); + + /** + * 单据级事务执行金蝶推送(由 syncKingdee 经自代理调用,保证整批同步不共用大事务) + * + * @param id 付款申请id + * @param context 批量共享上下文(档案缓存) + * @return 金蝶单据id + */ + String doSyncKingdee(Long id, KingdeeSyncContext context); + + /** + * 独立事务落"生成失败"状态(在推送事务回滚、行锁释放后执行); + * 仅更新审批通过的单据,未审批/已删除单据自动跳过,保持原状态 + * + * @param id 付款申请id + */ + void markKingdeeSyncFailed(Long id); + + /** + * 重推金蝶生成失败(kingdeeStatus=failed 且审批通过)的付款申请:逐单独立成败,失败不中断。 + * 只扫 failed 不扫 unsynced——存量"审批通过但从未推送"的历史单不自动补推,仍走手动按钮 + * + * @return 执行摘要 + */ + String retryKingdeeFailed(); + + /** + * 金蝶单据解锁(ADR-0005):金蝶侧单据已被反审核删除时解除本地锁定—— + * 金蝶状态置生成失败、清空金蝶单据内码与编号(原值日志留档),不自动重推,等待人工核实后重新推送。 + * 触发来源:① B901 UNLOCK 回调(待建设);② A103-2 定时回写"已同步单据连续两轮查询消失"兜底。 + * 仅对已生成/付款中单据生效,终态(已付款/已关闭)与生成失败单据自动跳过; + * 重推时 A103-2 勾稽预检兜底,误判场景(金蝶单据实际存在)自动回填不产生重复单据 + * + * @param id 付款申请id + * @param source 解锁来源标识(B901 / A103-2,用于日志追溯) + */ + void unlockBill(Long id, String source); + + /** + * 定时回写金蝶付款结果(A103-2 付款申请单查询 / A103-4 付款处理查询) + * + * @return 执行摘要 + */ + String syncKingdeePaymentResult(); + + /** + * 回写单笔付款申请的金蝶付款申请单查询结果(A103-2),单据级事务 + * + * @param entity 付款申请实体 + * @param payApply A103-2 查询返回的付款申请单 + * @return 是否付款完成(paystatus = Alreadypay) + */ + boolean applyPayApplyResult(PaymentApplication entity, BfiPayApplyVO payApply); + + /** + * 回填单笔付款申请的金蝶付款处理记录(A103-4),单据级事务 + * + * @param entity 付款申请实体 + * @param payBill A103-4 查询返回的付款处理单 + */ + void applyPayBillRecord(PaymentApplication entity, BfiPayBillVO payBill); + + /** + * 付款方式是否为汇票付款(按业务字典 pay_method 条目文本匹配"汇票") + * + * @param paymentMethod 付款方式(字典键或值) + * @return true 表示汇票类付款 + */ + boolean isBillPayment(String paymentMethod); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IReceiptClaimRecordService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IReceiptClaimRecordService.java index 66a8886e..efd68227 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IReceiptClaimRecordService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IReceiptClaimRecordService.java @@ -45,5 +45,12 @@ public interface IReceiptClaimRecordService extends BaseService { void updateAttachments(ReceiptClaimAttachmentsRequest request); - String voidClaim(Long id); + void voidClaim(Long id); + + /** + * A301-1 收款单查询回写认领结果:按来源单据编码匹配金蝶收款单,推进金蝶单据状态 + * + * @return 回写条数 + */ + int refreshKingdeeResult(); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IReceiptFlowService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IReceiptFlowService.java index 4fe29056..89586f79 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IReceiptFlowService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IReceiptFlowService.java @@ -27,8 +27,8 @@ package org.springblade.transport.service; import com.baomidou.mybatisplus.core.metadata.IPage; import org.springblade.core.mp.base.BaseService; +import org.springblade.transport.pojo.dto.ReceiptClaimPushResult; import org.springblade.transport.pojo.dto.ReceiptClaimRequest; -import org.springblade.transport.pojo.dto.ReceiptFlowSyncRequest; import org.springblade.transport.pojo.entity.KingdeeReceiptFlow; import org.springblade.transport.pojo.vo.ReceiptFlowVO; @@ -52,5 +52,26 @@ public interface IReceiptFlowService extends BaseService { Long claim(ReceiptClaimRequest request); - int sync(ReceiptFlowSyncRequest request); + /** + * 手动同步金蝶收款流水:直连 A301-2 收款认领公告查询,按明细流水号幂等 upsert 收款流水表 + * + * @return 本次同步条数 + */ + int sync(); + + /** + * 手动推送金蝶(A301-3 收款认领结果推送):认领完成且未推送过的流水才可推送, + * 推送前以当前用户姓名重查 A301-2 最新公告对账。与定时任务共用同一推送实现。 + * + * @param flowId 收款流水id + * @return 推送结果(失败不抛异常,原因随结果返回) + */ + ReceiptClaimPushResult pushClaimToKingdee(Long flowId); + + /** + * 定时推送金蝶:扫描认领完成且未推送过的流水,按认领人姓名分组重查 A301-2 最新公告后逐条推送。 + * + * @return 执行摘要 + */ + String pushClaimByTask(); } 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 ace2009e..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 @@ -70,7 +70,8 @@ public interface IReceivablePayableDetailService extends BaseService> transferCandidates(IPage page, String contractName, String batchNo, + IPage> transferCandidates(IPage page, String documentNo, String contractNo, + String contractName, String batchNo, String generateStartDate, String generateEndDate, String settlementBillType, String settlementType); @@ -100,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..f045a015 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ISettlementImportBatchService.java @@ -0,0 +1,69 @@ +/** + * 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); +} 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..9c1d7368 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ISettlementProjectAdvanceWriteOffService.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. + *

+ * 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.List; + +/** + * 结算单项目预付核销服务 + * + * @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); + + /** + * 覆盖保存核销明细,并校验不超过可核销金额与结算单金额。 + */ + void replaceWriteOffs(String settlementType, Long settlementId, Long contractId, + BigDecimal settlementAmount, List writeOffs); + + /** + * 物理清理结算单核销明细(草稿删除时)。 + */ + void deleteBySettlement(String settlementType, Long settlementId); + +} 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 new file mode 100644 index 00000000..aeac5967 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/KingdeeSyncContext.java @@ -0,0 +1,56 @@ +/** + * 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 org.springblade.transport.pojo.entity.ContractManage; +import org.springblade.transport.pojo.entity.CustomerReceiptAccount; +import org.springblade.transport.pojo.entity.FormalSettlement; +import org.springblade.transport.pojo.entity.FormalSettlementDetail; +import org.springblade.transport.pojo.entity.ProjectApply; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 金蝶同步上下文:批量同步时全批共享的档案缓存,消除逐单重复的远程/数据库查询(N+1)。 + *

单笔同步使用 {@link #empty()},各缓存为空时自动回退为逐单查询,行为与原逻辑一致。

+ * + * @author Chill + */ +public record KingdeeSyncContext(Map contractCache, + Map customerCodeCache, Map projectCache, + Map apBillNoCache, + Map> settlementDetailCache, + Map settlementCache, + Map receiptAccountCache, + Map feeCategoryByName) { + + /** 空上下文:缓存可写但初始为空,首条记录命中后全批复用 */ + public static KingdeeSyncContext empty() { + return new KingdeeSyncContext(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/BillPaymentServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/BillPaymentServiceImpl.java index 180cb494..f0bd1f55 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/BillPaymentServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/BillPaymentServiceImpl.java @@ -35,6 +35,7 @@ import org.springblade.core.secure.utils.AuthUtil; import org.springblade.core.tool.jackson.JsonUtil; import org.springblade.core.tool.utils.Func; import org.springblade.system.cache.SysCache; +import org.springblade.transport.constant.TransportBusiConstant; import org.springblade.transport.mapper.BillLedgerMapper; import org.springblade.transport.mapper.BillLedgerUsageMapper; import org.springblade.transport.mapper.BillPaymentMapper; @@ -61,11 +62,6 @@ import java.util.Objects; @RequiredArgsConstructor public class BillPaymentServiceImpl extends BaseServiceImpl implements IBillPaymentService { - private static final String DRAFT = "draft"; - private static final String REVIEWING = "reviewing"; - private static final String APPROVED = "approved"; - private static final String RETURNED = "returned"; - private static final String VOIDED = "voided"; private static final String RELEASED = "released"; private final BillLedgerMapper billLedgerMapper; @@ -103,7 +99,7 @@ public class BillPaymentServiceImpl extends BaseServiceImpllambdaQuery() .eq(BillLedgerUsage::getBillPaymentId, entity.getId()) - .eq(BillLedgerUsage::getUsageStatus, APPROVED) + .eq(BillLedgerUsage::getUsageStatus, TransportBusiConstant.APPROVED) .last("FOR UPDATE")); if (exists != null) { throw new ServiceException("该汇票付款已生成使用记录"); @@ -188,11 +184,11 @@ public class BillPaymentServiceImpl extends BaseServiceImpllambdaQuery() .eq(BillLedgerUsage::getBillPaymentId, entity.getId()) - .eq(BillLedgerUsage::getUsageStatus, APPROVED) + .eq(BillLedgerUsage::getUsageStatus, TransportBusiConstant.APPROVED) .last("FOR UPDATE")); if (usage == null) { throw new ServiceException("未找到汇票使用记录,无法作废"); @@ -233,7 +229,7 @@ public class BillPaymentServiceImpl extends BaseServiceImpl implements ICustomerArchiveService { + /** + * 状态:启用 + */ private static final int STATUS_ENABLED = 1; + + /** + * 状态:停用 + */ private static final int STATUS_DISABLED = 2; + + /** + * 准入类型:临时 + */ private static final String ACCESS_TEMPORARY = "temporary"; + + /** + * 准入类型:正式 + */ private static final String ACCESS_FORMAL = "formal"; + + /** + * 审批状态:草稿 + */ private static final String APPROVAL_DRAFT = "draft"; + + /** + * 审批状态:审核中 + */ private static final String APPROVAL_REVIEWING = "reviewing"; + + /** + * 审批状态:审批通过 + */ private static final String APPROVAL_APPROVED = "approved"; + + /** + * 审批状态:审批驳回 + */ private static final String APPROVAL_REJECTED = "rejected"; /** 客商所属组织固定为顶层组织 */ - private static final String FIXED_OWNER_DEPT_NAME = "桂物物流集团"; + private static final String FIXED_OWNER_DEPT_NAME = "广西现代物流集团有限公司"; + + /** + * 金蝶协同状态:未推送(新增客商默认值,审批通过后参与推送) + */ + private static final String KINGDEE_STATUS_NONE = "none"; + + /** + * 金蝶协同状态:金蝶已有档案(A902-1 查到已审核,审批通过后跳过推送,终态) + */ + private static final String KINGDEE_STATUS_EXIST = "exist"; + + /** + * 金蝶协同状态:已推送成功(终态,重推时幂等跳过) + */ + private static final String KINGDEE_STATUS_SYNCED = "synced"; + + /** + * 金蝶协同状态:推送失败 / 金蝶审核中(可列表「同步金蝶」人工重推) + */ + private static final String KINGDEE_STATUS_FAILED = "failed"; + + /** + * 客商编号前缀 + */ private static final String CUSTOMER_CODE_PREFIX = "KS"; + /** + * A902-1 批量查询分批大小 + */ + private static final int KINGDEE_CUSTOMER_QUERY_BATCH = 50; /** * 变更快照忽略字段:明细每次保存会重建主键/审计字段,不能作为“是否有修改”的依据 */ @@ -147,6 +222,13 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl selectCustomerArchivePage(IPage page, CustomerArchiveVO customer) { @@ -222,6 +304,7 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpllambdaUpdate() + .eq(CustomerArchive::getId, id) + .eq(CustomerArchive::getApprovalStatus, APPROVAL_APPROVED) + .and(wrapper -> wrapper.isNull(CustomerArchive::getKingdeeStatus) + .or() + .notIn(CustomerArchive::getKingdeeStatus, List.of(KINGDEE_STATUS_SYNCED, KINGDEE_STATUS_EXIST))) + .set(CustomerArchive::getKingdeeStatus, KINGDEE_STATUS_FAILED) + .set(CustomerArchive::getKingdeeFailReason, failReason)); + if (updated > 0 && before != null) { + // 变更记录留档(推送主事务已回滚,此处独立事务落状态与记录) + CustomerArchive after = copyCustomer(before); + after.setKingdeeStatus(KINGDEE_STATUS_FAILED); + after.setKingdeeFailReason(failReason); + addChangeRecord(id, "推送金蝶失败:" + failReason, before, after); + } + } + + /** + * 组装 A902-2 报文推送金蝶并回写协同状态;detail 由调用方准备, + * 定时同步批量组装报文数据,避免逐条 detail 查询的 N+1 问题; + * 推送失败先落 failed 状态与变更记录,再抛出异常由调用方提示前端(与结算/付款/开票口径一致) + */ + private void pushCustomerToKingdee(Long id, CustomerArchive customer, CustomerArchive after, CustomerArchiveVO detail) { + try { + BfiBillSaveResultVO result = bfiService.saveCustomer(customerBfiConverter.buildSaveDTO(detail)); + BfiBillSaveResultItemVO item = firstSaveItem(result); + if (item != null && Boolean.TRUE.equals(item.getBillStatus())) { + after.setKingdeeStatus(KINGDEE_STATUS_SYNCED); + after.setKingdeeFailReason(null); + addChangeRecord(id, "推送金蝶成功,金蝶客户编码与客商编号一致:" + customer.getCustomerCode(), customer, after); + } else { + after.setKingdeeStatus(KINGDEE_STATUS_FAILED); + after.setKingdeeFailReason(buildSaveFailReason(result, item)); + addChangeRecord(id, "推送金蝶失败:" + after.getKingdeeFailReason(), customer, after); + throw new ServiceException("推送金蝶失败:" + after.getKingdeeFailReason()); + } + } catch (ServiceException exception) { + throw exception; + } catch (Exception exception) { + log.error("客商推送金蝶失败,id={},unifiedCreditCode={}", id, customer.getUnifiedCreditCode(), exception); + after.setKingdeeStatus(KINGDEE_STATUS_FAILED); + after.setKingdeeFailReason(truncateText(exception.getMessage() == null ? "金蝶接口调用异常" : exception.getMessage())); + addChangeRecord(id, "推送金蝶失败:" + after.getKingdeeFailReason(), customer, after); + throw exception instanceof RuntimeException runtimeException + ? runtimeException + : new ServiceException("推送金蝶失败:" + after.getKingdeeFailReason()); + } + } + + /** + * A902-1 按统一社会信用代码查询金蝶客商,无数据返回 null + */ + private BfiCustomerVO queryKingdeeCustomer(String unifiedCreditCode) { + BfiCustomerQueryDTO param = new BfiCustomerQueryDTO(); + param.setPageNo(1); + param.setPageSize(1); + param.setSocietycreditcode(List.of(unifiedCreditCode)); + BfiPageVO page = bfiService.queryCustomer(param); + List rows = page == null ? null : page.getRows(); + return rows == null || rows.isEmpty() ? null : rows.get(0); + } + + private BfiBillSaveResultItemVO firstSaveItem(BfiBillSaveResultVO result) { + if (result == null || result.getResult() == null || result.getResult().isEmpty()) { + return null; + } + return result.getResult().get(0); + } + + private String buildSaveFailReason(BfiBillSaveResultVO result, BfiBillSaveResultItemVO item) { + if (item != null && Func.isNotEmpty(item.getErrors())) { + String messages = item.getErrors().stream() + .map(BfiBillSaveResultErrorVO::getRowMsg) + .filter(Func::isNotEmpty) + .flatMap(List::stream) + .collect(Collectors.joining(";")); + if (Func.isEmpty(messages)) { + messages = item.getErrors().stream() + .map(error -> String.valueOf(error.getEntityKey())) + .collect(Collectors.joining(";")); + } + return truncateText(messages); + } + if (result != null && Func.isNotEmpty(result.getFailCount())) { + return truncateText("金蝶返回失败数量:" + result.getFailCount()); + } + return "金蝶返回结果为空"; + } + + private String truncateText(String text) { + if (text == null) { + return null; + } + return text.length() > 500 ? text.substring(0, 500) : text; + } + + /** + * 定时同步推送报文组装:主表字段 + 批量查询的收款账户,不含联系人/发票/评分等推送无关明细, + * 替代逐条 detail 查询,避免 N+1 + */ + private CustomerArchiveVO buildSyncArchiveVO(CustomerArchive customer, List receiptAccounts) { + CustomerArchiveVO detail = Objects.requireNonNull(BeanUtil.copyProperties(customer, CustomerArchiveVO.class)); + detail.setReceiptAccounts(receiptAccounts); + return detail; + } + + /** + * 定时同步变更集中落库:档案批量更新 + 变更记录批量插入,失败仅记录日志,不影响汇总返回 + */ + private void flushSyncChanges(List updateList, List changeRecords) { + if (!updateList.isEmpty()) { + try { + updateBatchById(updateList); + } catch (Exception exception) { + log.error("定时同步金蝶客商批量更新档案异常,size={}", updateList.size(), exception); + } + } + if (!changeRecords.isEmpty()) { + try { + Db.saveBatch(changeRecords); + } catch (Exception exception) { + log.error("定时同步金蝶客商批量写入变更记录异常,size={}", changeRecords.size(), exception); + } + } + } + + @Override + public String syncKingdeeCustomerData() { + return kingdeeSyncLockSupport.runSingleFlight(CUSTOMER_SYNC_LOCK_PREFIX, bfiProperties.getCustomer().getLockLeaseSeconds(), + "金蝶客商数据同步", this::executeCustomerDataSync); + } + + /** + * 金蝶客商数据同步主体:A902-1 复查 + A902-2 推送 + 金蝶禁用联动停用客商档案, + * 由 {@link #syncKingdeeCustomerData()} 在分布式锁保护下调用 + * + * @return 执行摘要 + */ + private String executeCustomerDataSync() { + log.info("金蝶客商数据定时同步开始"); + // 客商数据量不大,一次查询审批通过客商,内存按金蝶协同状态分组 + List approvedList = list(Wrappers.lambdaQuery() + .eq(CustomerArchive::getApprovalStatus, APPROVAL_APPROVED) + .orderByAsc(CustomerArchive::getId)); + // ① 待推送客商:未推送/推送失败,单条失败不中断批次 + List pendingList = approvedList.stream() + .filter(customer -> KINGDEE_STATUS_NONE.equals(customer.getKingdeeStatus()) + || KINGDEE_STATUS_FAILED.equals(customer.getKingdeeStatus())) + .toList(); + // ② 停用联动候选:已同步/金蝶已有且启用的客商 + List enabledList = approvedList.stream() + .filter(customer -> (KINGDEE_STATUS_SYNCED.equals(customer.getKingdeeStatus()) + || KINGDEE_STATUS_EXIST.equals(customer.getKingdeeStatus())) + && Objects.equals(customer.getStatus(), STATUS_ENABLED)) + .toList(); + // ③ 已推送客商:A902-2 成功仅代表金蝶受理(status=B),复查 status=C 已审核才是最终成功 + List syncedList = approvedList.stream() + .filter(customer -> KINGDEE_STATUS_SYNCED.equals(customer.getKingdeeStatus())) + .toList(); + log.info("金蝶客商数据定时同步查询到审批通过客商 {} 条(待推送 {},已推送 {},可停用联动 {})", + approvedList.size(), pendingList.size(), syncedList.size(), enabledList.size()); + int pushed = 0; + int existed = 0; + int pushFailed = 0; + // 变更集中落库:档案批量更新、变更记录批量插入,避免逐条 update/insert 的 N+1 写入 + List updateList = new ArrayList<>(); + List changeRecords = new ArrayList<>(); + // 批量 A902-1 复查待推送客商:金蝶已审核直接建档(exist)、审核中置 failed,仅不存在金蝶档案的才逐条推送,避免逐条复查的 N+1 调用 + Map pendingKingdeeMap = queryKingdeeCustomerMap(pendingList.stream() + .map(CustomerArchive::getUnifiedCreditCode) + .filter(Func::isNotEmpty) + .map(code -> code.trim().toUpperCase()) + .distinct() + .toList()); + List pushList = new ArrayList<>(); + for (CustomerArchive customer : pendingList) { + BfiCustomerVO kingdeeCustomer = pendingKingdeeMap.get(customer.getUnifiedCreditCode().trim().toUpperCase()); + if (kingdeeCustomer == null) { + pushList.add(customer); + continue; + } + CustomerArchive after = copyCustomer(customer); + after.setKingdeeSyncTime(LocalDateTime.now()); + if (BfiConstant.BILL_STATUS_AUDITED.equals(kingdeeCustomer.getStatus())) { + // 金蝶已存在已审核档案:跳过推送,直接建档 + after.setKingdeeStatus(KINGDEE_STATUS_EXIST); + after.setKingdeeFailReason(null); + existed++; + collectChangeRecord(changeRecords, customer.getId(), "金蝶已存在该客商档案,跳过推送", customer, after); + } else { + // 金蝶侧暂存/已提交:审核中,置失败待重推 + after.setKingdeeStatus(KINGDEE_STATUS_FAILED); + after.setKingdeeFailReason("金蝶侧该客商审核中,请稍后重推"); + pushFailed++; + collectChangeRecord(changeRecords, customer.getId(), "推送金蝶失败:金蝶侧客商审核中", customer, after); + } + log.info("金蝶客商数据定时同步复查处理完成,id={},unifiedCreditCode={},kingdeeStatus={}", + customer.getId(), customer.getUnifiedCreditCode(), after.getKingdeeStatus()); + updateList.add(after); + } + // 金蝶不存在档案的逐条推送(A902-2 保存): + // 报文仅需主表字段 + 收款账户,按客商ID批量一次查询收款账户组装报文,避免逐条 detail 的 N+1 查询 + Map> receiptAccountsMap = loadReceiptAccountsMap(pushList.stream() + .map(CustomerArchive::getId) + .toList()); + for (CustomerArchive customer : pushList) { + CustomerArchive after = copyCustomer(customer); + after.setKingdeeSyncTime(LocalDateTime.now()); + try { + CustomerArchiveVO detail = buildSyncArchiveVO(customer, + receiptAccountsMap.getOrDefault(customer.getId(), new ArrayList<>())); + pushCustomerToKingdee(customer.getId(), customer, after, detail); + updateList.add(after); + if (Objects.equals(after.getKingdeeStatus(), KINGDEE_STATUS_SYNCED)) { + pushed++; + log.info("金蝶客商数据定时同步推送成功,id={},unifiedCreditCode={}", + customer.getId(), customer.getUnifiedCreditCode()); + } else { + pushFailed++; + log.warn("金蝶客商数据定时同步推送失败,id={},unifiedCreditCode={},kingdeeStatus={},failReason={}", + customer.getId(), customer.getUnifiedCreditCode(), after.getKingdeeStatus(), after.getKingdeeFailReason()); + } + } catch (Exception exception) { + pushFailed++; + log.error("定时同步金蝶客商推送异常,id={},unifiedCreditCode={}", + customer.getId(), customer.getUnifiedCreditCode(), exception); + // 失败状态与原因已在推送方法内回填 after,落库供页面展示与重推 + updateList.add(after); + } + } + // ②③ 共用:对已推送(synced)/金蝶已有(exist)客商按信用代码批量复查金蝶档案(审核状态 + 使用状态),仅调用一次 A902-1 + int disabled = 0; + int disableFailed = 0; + int confirmed = 0; + int auditing = 0; + int unknown = 0; + if (!enabledList.isEmpty() || !syncedList.isEmpty()) { + List reviewCreditCodes = Stream.concat(syncedList.stream(), enabledList.stream()) + .map(CustomerArchive::getUnifiedCreditCode) + .filter(Func::isNotEmpty) + .map(code -> code.trim().toUpperCase()) + .distinct() + .toList(); + Map kingdeeMap = reviewCreditCodes.isEmpty() + ? Map.of() + : queryKingdeeCustomerMap(reviewCreditCodes); + // ③ 审核确认:A902-1 复查 status=C 已审核才是最终成功,synced 升级为 exist(终态); + // A/B 审核中保持 synced 等待下轮复查(不重推,避免金蝶侧重复建档) + for (CustomerArchive customer : syncedList) { + BfiCustomerVO kingdeeCustomer = kingdeeMap.get(customer.getUnifiedCreditCode().trim().toUpperCase()); + if (kingdeeCustomer == null) { + unknown++; + log.warn("金蝶客商审核状态复查未查询到档案,id={},unifiedCreditCode={}", + customer.getId(), customer.getUnifiedCreditCode()); + continue; + } + if (!BfiConstant.BILL_STATUS_AUDITED.equals(kingdeeCustomer.getStatus())) { + auditing++; + log.info("金蝶客商审核中,等待下轮复查,id={},unifiedCreditCode={},kingdeeStatus={}", + customer.getId(), customer.getUnifiedCreditCode(), kingdeeCustomer.getStatus()); + continue; + } + confirmed++; + CustomerArchive after = copyCustomer(customer); + after.setKingdeeStatus(KINGDEE_STATUS_EXIST); + after.setKingdeeFailReason(null); + updateList.add(after); + collectChangeRecord(changeRecords, customer.getId(), "金蝶客商已审核,推送最终成功", customer, after); + log.info("金蝶客商已审核,推送最终成功并升级为金蝶已有档案,id={},unifiedCreditCode={}", + customer.getId(), customer.getUnifiedCreditCode()); + } + // ② 停用联动:金蝶禁用(enable=0)→ TMS 自动停用;金蝶恢复可用不自动启用(TMS 人工控制); + // 直接基于已查询档案内存处理,避免逐条自代理调用重复查询档案与权限 + for (CustomerArchive customer : enabledList) { + BfiCustomerVO kingdeeCustomer = kingdeeMap.get(customer.getUnifiedCreditCode().trim().toUpperCase()); + if (kingdeeCustomer == null || !"0".equals(kingdeeCustomer.getEnable())) { + continue; + } + try { + CustomerArchive after = copyCustomer(customer); + after.setStatus(STATUS_DISABLED); + collectChangeRecord(changeRecords, customer.getId(), "金蝶侧客商已停用,同步停用客商档案", customer, after); + updateList.add(after); + disabled++; + log.info("金蝶侧客商已停用,联动停用客商档案成功,id={},unifiedCreditCode={}", + customer.getId(), customer.getUnifiedCreditCode()); + } catch (Exception exception) { + disableFailed++; + log.error("定时同步金蝶客商停用异常,id={},unifiedCreditCode={}", + customer.getId(), customer.getUnifiedCreditCode(), exception); + } + } + } + flushSyncChanges(updateList, changeRecords); + String summary = "待推送 " + pendingList.size() + " 条(推送成功 " + pushed + ",金蝶已有 " + existed + + ",失败 " + pushFailed + "),停用联动 " + disabled + " 条(失败 " + disableFailed + ")" + + ",已推送复查 " + syncedList.size() + " 条(金蝶已审核 " + confirmed + ",审核中 " + auditing + + ",未查询到 " + unknown + ")"; + log.info("金蝶客商数据定时同步结束:{}", summary); + return summary; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean disableCustomerFromKingdee(Long id) { + CustomerArchive customer = ensureCustomerAccessible(id); + if (!Objects.equals(customer.getStatus(), STATUS_ENABLED)) { + return false; + } + CustomerArchive after = copyCustomer(customer); + after.setStatus(STATUS_DISABLED); + addChangeRecord(id, "金蝶侧客商已停用,同步停用客商档案", customer, after); + return updateById(after); + } + + /** + * 按统一社会信用代码分批批量查询金蝶客商,键为大写信用代码; + * societycreditcode 支持数组一次查询多个,单批使用 BfiUtil.queryAllPages 自动翻页取全, + * 避免同一信用代码多条记录时漏查,单批查询异常不中断其余批次 + * + * @param creditCodes 统一社会信用代码集合 + * @return 大写信用代码 -> 金蝶客商 + */ + private Map queryKingdeeCustomerMap(List creditCodes) { + Map result = new HashMap<>(); + for (int start = 0; start < creditCodes.size(); start += KINGDEE_CUSTOMER_QUERY_BATCH) { + List batch = creditCodes.subList(start, Math.min(start + KINGDEE_CUSTOMER_QUERY_BATCH, creditCodes.size())); + if (batch.isEmpty()) { + continue; + } + try { + BfiCustomerQueryDTO param = new BfiCustomerQueryDTO(); + param.setSocietycreditcode(batch); + List rows = BfiUtil.queryAllPages(pageNo -> { + param.setPageNo(pageNo); + param.setPageSize(KINGDEE_CUSTOMER_QUERY_BATCH); + return bfiService.queryCustomer(param); + }); + for (BfiCustomerVO vo : rows) { + if (Func.isNotEmpty(vo.getSocietycreditcode())) { + result.put(vo.getSocietycreditcode().trim().toUpperCase(), vo); + } + } + } catch (Exception exception) { + log.error("定时同步金蝶客商批量查询异常,batchSize={}", batch.size(), exception); + } + } + return result; + } + @Override @Transactional(rollbackFor = Exception.class) public boolean changeStatus(Long id, Integer status) { @@ -485,6 +973,7 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl invoices = customer.getInvoices(); + if (Func.isEmpty(invoices)) { + return; + } + List taxNos = invoices.stream() + .map(CustomerInvoiceInfoVO::getTaxNo) + .filter(Func::isNotEmpty) + .map(taxNo -> taxNo.trim().toUpperCase()) + .distinct() + .toList(); + if (Func.isEmpty(taxNos)) { + return; + } + List blacklist = bfiService.queryBlacklist("1", taxNos); + BfiBlacklistVO hit = blacklist.stream() + .filter(item -> Func.isNotEmpty(item.getNumber()) && taxNos.contains(item.getNumber().trim().toUpperCase())) + .findFirst() + .orElse(null); + if (hit == null) { + return; + } + String category = Func.isEmpty(hit.getAwrpCombofieldTitle()) ? hit.getAwrpCombofield() : hit.getAwrpCombofieldTitle(); + boolean forbidden = "A".equals(hit.getAwrpCombofield()); + boolean riskWarning = "B".equals(hit.getAwrpCombofield()); + if (forbidden || (riskWarning && bfiProperties.getBlacklist().isBlockRiskWarning())) { + throw new ServiceException(String.format("客商【%s】在国资委黑名单中(失信类别:%s,列入事由:%s),不允许提交准入审批", + customer.getFullName(), category, hit.getAwrpTextareafield())); + } + log.warn("客商命中金蝶黑名单风险警示类,放行,fullName={},taxNos={},失信类别={},列入事由={}", + customer.getFullName(), taxNos, category, hit.getAwrpTextareafield()); + } + private void validateUnique(CustomerArchiveVO customer) { LambdaQueryWrapper unifiedCreditCodeQuery = Wrappers.lambdaQuery() .eq(CustomerArchive::getUnifiedCreditCode, customer.getUnifiedCreditCode()) @@ -854,12 +1384,26 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl loadReceiptAccounts(Long customerId) { + return loadReceiptAccountsMap(List.of(customerId)).getOrDefault(customerId, new ArrayList<>()); + } + + /** + * 按客商ID集合批量查询收款账户(键为客商ID),供定时同步批量组装金蝶报文等场景使用,避免逐条查询 + * + * @param customerIds 客商ID集合 + * @return 客商ID -> 收款账户列表(默认一条扣款排序) + */ + private Map> loadReceiptAccountsMap(List customerIds) { + if (Func.isEmpty(customerIds)) { + return Map.of(); + } return receiptAccountMapper.selectList(Wrappers.lambdaQuery() - .eq(CustomerReceiptAccount::getCustomerId, customerId) + .in(CustomerReceiptAccount::getCustomerId, customerIds) .eq(CustomerReceiptAccount::getIsDeleted, 0) .orderByDesc(CustomerReceiptAccount::getIsDefault) .orderByAsc(CustomerReceiptAccount::getCreateTime)) - .stream().map(account -> Objects.requireNonNull(BeanUtil.copyProperties(account, CustomerReceiptAccountVO.class))).toList(); + .stream().map(account -> Objects.requireNonNull(BeanUtil.copyProperties(account, CustomerReceiptAccountVO.class))) + .collect(Collectors.groupingBy(CustomerReceiptAccountVO::getCustomerId)); } private List loadInvoices(Long customerId) { @@ -1306,11 +1850,10 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl beforeSnapshot = customerSnapshot(before); - Map afterSnapshot = customerSnapshot(after); - addDetailSnapshot(beforeSnapshot, null); - addDetailSnapshot(afterSnapshot, null); - writeChangeRecord(customerId, content, beforeSnapshot, afterSnapshot); + CustomerChangeRecord record = buildChangeRecord(customerId, content, before, after); + if (record != null) { + changeRecordMapper.insert(record); + } } private void addChangeRecord(Long customerId, String content, CustomerArchive before, CustomerArchive after, @@ -1319,10 +1862,35 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl afterSnapshot = customerSnapshot(after); addDetailSnapshot(beforeSnapshot, beforeDetail); addDetailSnapshot(afterSnapshot, afterDetail); - writeChangeRecord(customerId, content, beforeSnapshot, afterSnapshot); + CustomerChangeRecord record = buildChangeRecord(customerId, content, beforeSnapshot, afterSnapshot); + if (record != null) { + changeRecordMapper.insert(record); + } } - private void writeChangeRecord(Long customerId, String content, Map beforeSnapshot, + /** + * 收集变更记录供定时同步集中批量插入(无实质变更不收集),避免逐条 insert + */ + private void collectChangeRecord(List records, Long customerId, String content, + CustomerArchive before, CustomerArchive after) { + CustomerChangeRecord record = buildChangeRecord(customerId, content, before, after); + if (record != null) { + records.add(record); + } + } + + /** + * 构建变更记录(无实质变更返回 null),供单条写入与定时同步批量收集共用 + */ + private CustomerChangeRecord buildChangeRecord(Long customerId, String content, CustomerArchive before, CustomerArchive after) { + Map beforeSnapshot = customerSnapshot(before); + Map afterSnapshot = customerSnapshot(after); + addDetailSnapshot(beforeSnapshot, null); + addDetailSnapshot(afterSnapshot, null); + return buildChangeRecord(customerId, content, beforeSnapshot, afterSnapshot); + } + + private CustomerChangeRecord buildChangeRecord(Long customerId, String content, Map beforeSnapshot, Map afterSnapshot) { for (String field : afterSnapshot.keySet()) { beforeSnapshot.putIfAbsent(field, null); @@ -1343,7 +1911,7 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl maxLength) { throw new ServiceException(message); @@ -1624,7 +2210,7 @@ public class CustomerArchiveServiceImpl 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. + *

+ * 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 lombok.RequiredArgsConstructor; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.core.tool.utils.Func; +import org.springblade.system.cache.DictBizCache; +import org.springblade.system.cache.SysCache; +import org.springblade.system.pojo.entity.Dept; +import org.springblade.system.pojo.entity.DictBiz; +import org.springblade.thirdparty.bfi.constant.BfiConstant; +import org.springblade.thirdparty.bfi.pojo.dto.BfiCustomerBankEntryDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiCustomerGroupEntryDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiCustomerLinkmanEntryDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiCustomerSaveDTO; +import org.springblade.transport.config.BfiProperties; +import org.springblade.transport.constant.CustomerBfiConstants; +import org.springblade.transport.pojo.vo.CustomerArchiveVO; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 客商档案金蝶(星瀚)报文转换器 + * + * @author Chill + */ +@Component +@RequiredArgsConstructor +public class CustomerBfiConverter { + + private final BfiProperties bfiProperties; + + /** + * 组装 A902-2 客户保存参数 + * + * @param customer 客商档案 + * @return 客户保存参数 + */ + public BfiCustomerSaveDTO buildSaveDTO(CustomerArchiveVO customer) { + BfiCustomerSaveDTO param = new BfiCustomerSaveDTO(); + // 编码:不传由星瀚生成;金蝶客户编码与 TMS 客商编号保持一致 + param.setNumber(customer.getCustomerCode()); + // 数据状态:固定传 B 已提交,金蝶校验工商信息通过后由机器人自动审核 + // param.setStatus(BfiConstant.BILL_STATUS_SUBMITTED); + // 【必填】名称:客商名称 + param.setName(customer.getFullName()); + // 【必填】统一社会信用代码(候选键):个人为身份证号或手机号 + param.setSocietycreditcode(customer.getUnifiedCreditCode()); + + // 【必填】创建组织:客商所属组织编码对应的金蝶组织编码 + // param.setCreateorgNumber(this.resolveOrgNumber(customer)); + + // 纳税人识别号:与统一社会信用代码一致时推送 + param.setTxRegisterNo(customer.getUnifiedCreditCode()); + // 法人代表:企业法人 + param.setArtificialperson(customer.getLegalPerson()); + // 【必填】伙伴类型:客商性质字典 dictKey 即伙伴类型编码,直接推送,无效编码抛异常 + param.setType(this.resolvePartnerType(customer.getCustomerNature())); + // 详细地址:注册地址(省市区 + 详细地址拼接文本) + param.setBizpartnerAddress(customer.getRegisteredAddress()); + // 经营范围:多选编码 → 字典文本「、」拼接 + param.setBusinessscope(this.buildBusinessScopeText(customer.getBusinessScope())); + // 营业期限:长期传「长期」,固定期限传「至 yyyy-MM-dd」(TMS 未采集起始日) + param.setBusinessterm(this.buildBusinessTerm(customer.getBusinessTermType(), customer.getBusinessEndDate())); + // 联系电话:客商联系电话 + param.setBizpartnerPhone(customer.getContactPhone()); + // 备注:客商备注,空时不传 + param.setFcusbeizhu(customer.getRemark()); + // 业务职能:固定全选(1销售,2结算,3付款,4收货) + param.setBizfunction(CustomerBfiConstants.BIZ_FUNCTION_ALL); + // 数据来源系统:thirdParty.bfi.srcSys(公共参数),默认 WLTMS(运输TMS) + param.setAwrpDatasourcesys(bfiProperties.getSrcSys()); + // 【必填】是否为贸易客商:固定传 A 是 + param.setAwrpCusfortrade(BfiConstant.CUS_FOR_TRADE_YES); + // 【必填】分类标准分录:外部客商 1102、内部组织 1101 + BfiCustomerGroupEntryDTO groupEntry = new BfiCustomerGroupEntryDTO(); + groupEntry.setStandardidNumber(BfiConstant.GROUP_STANDARD_BASIC); + groupEntry.setGroupidNumber(this.resolveGroupId(customer.getCustomerKind())); + param.setEntryGroupstandard(List.of(groupEntry)); + // 银行信息分录:收款账户明细(账户名称传开户行,币种默认 RMB) + param.setEntryBank(this.buildBankEntries(customer)); + // 联系人信息分录:页面填写的客商负责人与联系电话 + param.setEntryLinkman(this.buildLinkmanEntries(customer)); + return param; + } + + /** + * 金蝶创建组织编码:客商所属组织部门编码,与金蝶组织编码一致; + * 所属组织不存在或未维护部门编码时抛出异常,阻断推送 + * + * @param customer 客商档案 + * @return 金蝶创建组织编码 + */ + private String resolveOrgNumber(CustomerArchiveVO customer) { + Dept dept = customer.getDeptId() == null ? null : SysCache.getDept(customer.getDeptId()); + if (dept == null) { + throw new ServiceException("推送金蝶失败,客商所属组织不存在,请检查客商所属组织配置"); + } + if (Func.isBlank(dept.getDeptCode())) { + throw new ServiceException("推送金蝶失败,客商所属组织编码不能为空,请检查组织[" + dept.getDeptName() + "]是否维护部门编码"); + } + return dept.getDeptCode(); + } + + /** + * 解析分类标准分录客户分组编码:内部组织 1101,其余(外部客商)1102 + * + * @param customerKind 客商类型(internal 内部组织 / external 外部客商) + * @return 客户分组编码 + */ + private String resolveGroupId(String customerKind) { + if (CustomerBfiConstants.CUSTOMER_KIND_INTERNAL.equals(customerKind)) { + return BfiConstant.CUSTOMER_GROUP_INTERNAL; + } + return BfiConstant.CUSTOMER_GROUP_EXTERNAL; + } + + /** + * 组装 A902-2 银行信息分录:每条收款账户一行分录,账户名称传开户行、币种默认 RMB,收款账户未填写时返回空分录 + * + * @param customer 客商档案 + * @return 银行信息分录 + */ + private List buildBankEntries(CustomerArchiveVO customer) { + if (Func.isEmpty(customer.getReceiptAccounts())) { + return List.of(); + } + return customer.getReceiptAccounts().stream() + .filter(account -> Func.isNotBlank(account.getBankAccount()) || Func.isNotBlank(account.getBankName())) + .map(account -> { + BfiCustomerBankEntryDTO entry = new BfiCustomerBankEntryDTO(); + // 银行账号 + entry.setBankaccount(account.getBankAccount()); + // 账户名称:传开户行 + entry.setAccountname(account.getBankName()); + // 开户银行.编码(行号) todo + String cnapsCode = account.getCnapsCode(); + // cnapsCode = "001110012009"; + entry.setBankNumber(cnapsCode); + // 币种:人民币(CNY) + entry.setCurrencyNumber(BfiConstant.CURRENCY_CNY); + // 是否默认银行 + entry.setIsdefaultBank(true); + return entry; + }) + .collect(Collectors.toList()); + } + + /** + * 组装 A902-2 联系人信息分录:页面填写的客商负责人与联系电话,未填负责人时返回空分录 + * + * @param customer 客商档案 + * @return 联系人信息分录 + */ + private List buildLinkmanEntries(CustomerArchiveVO customer) { + if (Func.isBlank(customer.getPrincipal())) { + return List.of(); + } + BfiCustomerLinkmanEntryDTO entry = new BfiCustomerLinkmanEntryDTO(); + // 联系人:客商负责人 + entry.setContactperson(customer.getPrincipal()); + // 电话:联系电话 + entry.setPhone(customer.getContactPhone()); + // 默认联系人 + entry.setIsdefaultLinkman(Boolean.TRUE); + return List.of(entry); + } + + /** + * 金蝶伙伴类型编码:客商性质(nature_of_client 字典)—— + * 值为 dictKey(即伙伴类型编码,1~17)时直接使用; + * 值为 dictValue 文本(前端字典下拉存文本,含存量数据,如「我区国企/国企控股」)时按字典反查对应 dictKey; + * 均无法解析时抛出异常,阻断推送 + * + * @param customerNature 客商性质(dictKey 编码或 dictValue 文本) + * @return 金蝶伙伴类型编码 + */ + public String resolvePartnerType(String customerNature) { + String nature = Func.isEmpty(customerNature) ? "" : customerNature.trim(); + if (nature.matches("\\d{1,2}")) { + int partnerType = Integer.parseInt(nature); + if (partnerType >= 1 && partnerType <= CustomerBfiConstants.PARTNER_TYPE_MAX) { + return nature; + } + } + String partnerType = this.resolvePartnerTypeFromDictValue(nature); + if (Func.isBlank(partnerType)) { + throw new ServiceException("客商性质未配置有效的金蝶伙伴类型编码(nature_of_client 字典 dictKey):" + customerNature); + } + return partnerType; + } + + /** + * 按客商性质文本(dictValue)反查 nature_of_client 字典,返回对应 dictKey(即伙伴类型编码) + * + * @param natureValue 客商性质文本 + * @return 金蝶伙伴类型编码,未命中返回 null + */ + private String resolvePartnerTypeFromDictValue(String natureValue) { + if (Func.isEmpty(natureValue)) { + return null; + } + List dictList = DictBizCache.getList("nature_of_client"); + if (dictList == null) { + return null; + } + return dictList.stream() + .filter(dict -> natureValue.equals(dict.getDictValue())) + .map(DictBiz::getDictKey) + .map(key -> Func.isEmpty(key) ? "" : key.trim()) + .filter(key -> { + if (!key.matches("\\d{1,2}")) { + return false; + } + int partnerType = Integer.parseInt(key); + return partnerType >= 1 && partnerType <= CustomerBfiConstants.PARTNER_TYPE_MAX; + }) + .findFirst() + .orElse(null); + } + + /** + * 经营范围多选编码 → 字典文本「、」拼接,字典缺失时回退原编码 + * + * @param businessScope 逗号拼接的经营范围编码 + * @return 金蝶经营范围文本 + */ + public String buildBusinessScopeText(String businessScope) { + if (Func.isEmpty(businessScope)) { + return ""; + } + List dictList = DictBizCache.getList("scope_of_business"); + Map scopeNames = dictList == null ? Map.of() : dictList.stream() + .collect(Collectors.toMap(DictBiz::getDictKey, DictBiz::getDictValue, (first, second) -> first)); + return Func.toStrList(businessScope.replace(",", ",")).stream() + .map(code -> scopeNames.getOrDefault(code, code)) + .collect(Collectors.joining("、")); + } + + /** + * 营业期限:长期传「长期」;固定期限传「至 yyyy-MM-dd」(TMS 未采集起始日) + * + * @param businessTermType 营业期限类型 + * @param businessEndDate 营业期限截止日 + * @return 金蝶营业期限文本 + */ + public String buildBusinessTerm(String businessTermType, LocalDate businessEndDate) { + if (CustomerBfiConstants.BUSINESS_TERM_LONG.equals(businessTermType)) { + return CustomerBfiConstants.BUSINESS_TERM_LONG; + } + return businessEndDate == null ? "" : "至 " + businessEndDate; + } + +} 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 493511f2..c68b021d 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 @@ -12,6 +12,7 @@ import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; 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; @@ -27,6 +28,9 @@ import org.springblade.transport.mapper.FormalSettlementSummaryFeeMapper; import org.springblade.transport.mapper.FormalSettlementPaymentMapper; import org.springblade.transport.mapper.InvoiceReceiptMapper; import org.springblade.transport.mapper.InvoiceReceiptSettlementMapper; +import org.springblade.transport.mapper.InvoiceApplicationInvoiceMapper; +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.PreSettlementMapper; @@ -35,6 +39,12 @@ 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.constant.KingdeeAttachmentConstant; +import org.springblade.transport.config.BfiProperties; +import org.springblade.transport.constant.KingdeeBfiConstant; +import org.springblade.transport.constant.KingdeeLockConstant; +import org.springblade.transport.pojo.entity.KingdeeBillLock; +import org.springblade.transport.constant.TransportBusiConstant; import org.springblade.transport.mapper.ReceivablePayableDetailMapper; import org.springblade.transport.mapper.ReceivablePayableCargoFeeMapper; import org.springblade.transport.mapper.SettlementAdjustmentMapper; @@ -54,6 +64,9 @@ import org.springblade.transport.pojo.entity.FormalSettlementSummaryFee; import org.springblade.transport.pojo.entity.FormalSettlementPayment; import org.springblade.transport.pojo.entity.InvoiceReceipt; import org.springblade.transport.pojo.entity.InvoiceReceiptSettlement; +import org.springblade.transport.pojo.entity.InvoiceApplication; +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.PreSettlementDetail; @@ -71,14 +84,25 @@ import org.springblade.transport.pojo.vo.FormalSettlementVO; import org.springblade.transport.pojo.vo.PreSettlementVO; 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; +import org.springblade.transport.support.KingdeeArBillSyncSupport; +import org.springblade.transport.support.KingdeeSyncLockSupport; +import org.springblade.transport.support.KingdeeAttachmentSyncSupport; import org.springblade.transport.wrapper.PreSettlementWrapper; import org.springblade.core.tool.jackson.JsonUtil; +import org.springblade.transport.event.KingdeeBillApprovedEvent; import org.springblade.transport.wrapper.FormalSettlementWrapper; import org.springblade.transport.wrapper.PaymentApplicationWrapper; import org.springblade.system.cache.UserCache; +import org.springblade.core.tool.utils.SpringUtil; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -88,6 +112,8 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; @@ -104,20 +130,31 @@ import java.util.stream.Collectors; */ @Service @RequiredArgsConstructor +@Slf4j public class FormalSettlementServiceImpl extends BaseServiceImpl implements IFormalSettlementService { - private static final String DRAFT = "draft"; - private static final String REVIEWING = "reviewing"; - private static final String APPROVED = "approved"; - private static final String RETURNED = "returned"; - private static final String VOIDED = "voided"; + /** 金蝶票池认领闸门(收票服务承载 A801-1 重查与可用性口径) */ + private final IInvoiceReceiptService invoiceReceiptService; + + /** 金蝶单据锁定记录服务(lockBusiData 锁定/补偿/解锁守卫) */ + private final IKingdeeBillLockService kingdeeBillLockService; + + /** 金蝶应收单勾稽 single-flight 锁键前缀(多副本定时任务与手动触发共用) */ + private static final String AR_RESULT_SYNC_LOCK_PREFIX = "transport:formal-settlement:kingdee-ar-result-sync:"; + + /** 金蝶应收单勾稽结算明细分批查询单次条数上限(in 条件分批,避免单次过滤条件过长) */ + private static final int AR_RESULT_DETAIL_QUERY_BATCH = 500; + private final FormalSettlementSourceMapper sourceMapper; private final FormalSettlementSummaryFeeMapper summaryFeeMapper; private final FormalSettlementPaymentMapper paymentMapper; private final FormalSettlementInvoiceMapper invoiceMapper; private final InvoiceReceiptMapper invoiceReceiptMapper; private final InvoiceReceiptSettlementMapper invoiceReceiptSettlementMapper; + private final InvoiceApplicationMapper invoiceApplicationMapper; + private final InvoiceApplicationInvoiceMapper invoiceApplicationInvoiceMapper; + private final InvoiceApplicationSettlementMapper invoiceApplicationSettlementMapper; private final FormalSettlementChangeRecordMapper changeRecordMapper; private final FormalSettlementDetailMapper detailMapper; private final FormalSettlementDetailFeeMapper detailFeeMapper; @@ -134,7 +171,14 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl selectPage(IPage page, FormalSettlementVO query) { @@ -163,13 +207,68 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl result = page(page, wrapper.orderByDesc(FormalSettlement::getCreateTime)); - return result.convert(this::toVO); + IPage voPage = result.convert(this::toVO); + fillAttachmentSyncStatus(voPage.getRecords()); + fillKingdeeLockStatus(voPage.getRecords()); + return voPage; + } + + /** + * 填充金蝶锁状态标识(locked-锁定中/failed-锁定失败;unlocked 终态不返回): + * 锁表按本页源单一次批量查询,避免 N+1;locked 时前端可禁用编辑/作废, + * failed 时展示失败原因(tryCount 达上限 5 提示重推同步金蝶) + * + * @param rows 本页 VO 列表 + */ + private void fillKingdeeLockStatus(List rows) { + if (Func.isEmpty(rows)) { + return; + } + Map lockMap = kingdeeBillLockService.mapActiveLocks(KingdeeLockConstant.SOURCE_SETTLEMENT, + rows.stream().map(FormalSettlementVO::getId).toList()); + for (FormalSettlementVO vo : rows) { + KingdeeBillLock lockRecord = lockMap.get(vo.getId()); + if (lockRecord == null) { + continue; + } + vo.setKingdeeLockStatus(lockRecord.getLockStatus()); + vo.setKingdeeLockFailReason(lockRecord.getFailReason()); + vo.setKingdeeTryCount(lockRecord.getTryCount()); + } + // 金蝶删单解锁会作废源单,按 unlocked 锁记录留痕补「金蝶删单」标识,区分正常作废与金蝶删单 + kingdeeBillLockService.fillUnlockMarks(KingdeeLockConstant.SOURCE_SETTLEMENT, rows, lockMap); + } + + /** + * 填充附件同步聚合态(无附件/未上传/部分未传/已上传):按结算类型分业务类型一次 GROUP BY 查询,避免 N+1 + * + * @param rows 本页 VO 列表 + */ + private void fillAttachmentSyncStatus(List rows) { + if (Func.isEmpty(rows)) { + return; + } + Map apSuccessCounts = kingdeeAttachmentSyncSupport.successCountByBizId( + KingdeeAttachmentConstant.BIZ_FORMAL_SETTLEMENT_AP, rows.stream() + .filter(row -> !"receivable".equals(row.getSettlementType())).map(FormalSettlementVO::getId).toList()); + Map arSuccessCounts = kingdeeAttachmentSyncSupport.successCountByBizId( + KingdeeAttachmentConstant.BIZ_FORMAL_SETTLEMENT_AR, rows.stream() + .filter(row -> "receivable".equals(row.getSettlementType())).map(FormalSettlementVO::getId).toList()); + for (FormalSettlementVO vo : rows) { + int attachmentCount = kingdeeAttachmentSyncSupport.countAttachments(vo.getAttachmentsJson()); + Map successCounts = "receivable".equals(vo.getSettlementType()) ? arSuccessCounts : apSuccessCounts; + String aggregateStatus = kingdeeAttachmentSyncSupport.aggregateStatus(attachmentCount, + successCounts.getOrDefault(vo.getId(), 0)); + vo.setAttachmentCount(attachmentCount); + vo.setAttachmentSyncStatus(aggregateStatus); + vo.setAttachmentSyncStatusName(KingdeeAttachmentConstant.aggregateName(aggregateStatus)); + } } @Override public IPage candidatePreSettlements(IPage page, PreSettlementVO query) { IPage result = preSettlementMapper.selectPage(page, Wrappers.lambdaQuery() - .eq(PreSettlement::getApprovalStatus, APPROVED) + .eq(PreSettlement::getApprovalStatus, TransportBusiConstant.APPROVED) .and(w -> w.isNull(PreSettlement::getFormalSettlementNo).or().eq(PreSettlement::getFormalSettlementNo, "")) .eq(query.getContractId() != null, PreSettlement::getContractId, query.getContractId()) .like(Func.isNotEmpty(query.getPreSettlementNo()), PreSettlement::getPreSettlementNo, query.getPreSettlementNo()) @@ -208,6 +307,7 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl sources = sourceMapper.selectList(Wrappers.lambdaQuery() .eq(FormalSettlementSource::getFormalSettlementId, id).orderByAsc(FormalSettlementSource::getCreateTime)); vo.setSources(sources); @@ -219,6 +319,9 @@ public class FormalSettlementServiceImpl extends BaseServiceImpllambdaQuery() .eq(FormalSettlementPayment::getFormalSettlementId, id).orderByDesc(FormalSettlementPayment::getCreateTime))); vo.setInvoices(listInvoices(settlement)); + vo.setIssuedInvoices(listIssuedInvoices(id)); + vo.setProjectAdvanceWriteOffs(projectAdvanceWriteOffService.listBySettlement( + TransportBusiConstant.SETTLEMENT_BILL_FORMAL, id)); Set relationApplicationIds = paymentApplicationSettlementMapper.selectList( Wrappers.lambdaQuery() .select(PaymentApplicationSettlement::getPaymentApplicationId) @@ -233,7 +336,7 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl wrapper.in(PaymentApplication::getId, relationApplicationIds)); } }) - .eq(PaymentApplication::getPaymentType, "settlement_payment") + .eq(PaymentApplication::getPaymentType, TransportBusiConstant.PAYMENT_TYPE_SETTLEMENT) .eq(PaymentApplication::getIsDeleted, 0); vo.setPaymentApplications(paymentApplicationMapper.selectList(paymentApplicationQuery .orderByDesc(PaymentApplication::getCreateTime)).stream() @@ -304,7 +407,7 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl receiptMap = receiptIds.isEmpty() ? Map.of() : invoiceReceiptMapper.selectByIds(receiptIds).stream() - .filter(receipt -> !VOIDED.equals(receipt.getApprovalStatus()) + .filter(receipt -> !TransportBusiConstant.VOIDED.equals(receipt.getApprovalStatus()) && !Objects.equals(receipt.getIsDeleted(), 1)) .collect(Collectors.toMap(InvoiceReceipt::getId, Function.identity())); for (InvoiceReceiptSettlement relation : receiptRelations) { @@ -336,6 +439,31 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl listIssuedInvoices(Long settlementId) { + List applicationIds = invoiceApplicationSettlementMapper.selectList( + Wrappers.lambdaQuery() + .select(InvoiceApplicationSettlement::getInvoiceApplicationId) + .eq(InvoiceApplicationSettlement::getFormalSettlementId, settlementId) + ).stream().map(InvoiceApplicationSettlement::getInvoiceApplicationId) + .filter(Objects::nonNull).distinct().toList(); + if (applicationIds.isEmpty()) return List.of(); + Set activeApplicationIds = invoiceApplicationMapper.selectBatchIds(applicationIds).stream() + .filter(application -> !TransportBusiConstant.VOIDED.equals(application.getApprovalStatus()) + && !Objects.equals(application.getIsDeleted(), 1)) + .map(InvoiceApplication::getId).collect(Collectors.toSet()); + if (activeApplicationIds.isEmpty()) return List.of(); + return invoiceApplicationInvoiceMapper.selectList( + Wrappers.lambdaQuery() + .in(InvoiceApplicationInvoice::getInvoiceApplicationId, activeApplicationIds) + .orderByAsc(InvoiceApplicationInvoice::getInvoiceApplicationId) + .orderByAsc(InvoiceApplicationInvoice::getCreateTime)); + } + @Override @Transactional(rollbackFor = Exception.class) public Long saveDraft(FormalSettlementSaveRequest request) { @@ -387,7 +515,7 @@ 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); } - @Override public void submit(FormalSettlementStatusRequest request) { changeStatus(request.getId(), DRAFT, REVIEWING, "财务审核", null); } - @Override public void returnBill(FormalSettlementStatusRequest request) { changeStatus(request.getId(), REVIEWING, RETURNED, "已驳回", request.getReason()); } + @Override public void submit(FormalSettlementStatusRequest request) { changeStatus(request.getId(), TransportBusiConstant.DRAFT, TransportBusiConstant.REVIEWING, "财务审核", null); } + @Override public void returnBill(FormalSettlementStatusRequest request) { changeStatus(request.getId(), TransportBusiConstant.REVIEWING, TransportBusiConstant.RETURNED, "已驳回", request.getReason()); } @Override + @Transactional(rollbackFor = Exception.class) public void approve(FormalSettlementStatusRequest request) { FormalSettlement settlement = existing(request.getId()); - if (!REVIEWING.equals(settlement.getApprovalStatus())) throw new ServiceException("仅审批中的正式结算单允许审核"); - settlement.setApprovalStatus(APPROVED); + if (!TransportBusiConstant.REVIEWING.equals(settlement.getApprovalStatus())) throw new ServiceException("仅审批中的正式结算单允许审核"); + settlement.setApprovalStatus(TransportBusiConstant.APPROVED); settlement.setCurrentNode("审批通过"); settlement.setCurrentProcessor(AuthUtil.getUserName()); settlement.setApprovedTime(LocalDateTime.now()); updateById(settlement); saveChange(settlement.getId(), "结算单基本信息", null, "调整", "正式结算审批通过", request.getReason()); + // 审批事务提交后自动推送金蝶(AFTER_COMMIT 监听器消费,推送失败不影响审批结果) + eventPublisher.publishEvent(new KingdeeBillApprovedEvent(KingdeeBillApprovedEvent.BIZ_FORMAL_SETTLEMENT, settlement.getId())); } @Override public void voidBill(FormalSettlementStatusRequest request) { FormalSettlement settlement = existing(request.getId()); - if (!APPROVED.equals(settlement.getApprovalStatus())) throw new ServiceException("仅审批通过的正式结算单允许作废"); + if (!TransportBusiConstant.APPROVED.equals(settlement.getApprovalStatus())) throw new ServiceException("仅审批通过的正式结算单允许作废"); if ("synced".equals(settlement.getKingdeeSyncStatus())) throw new ServiceException("已同步金蝶的正式结算单不能直接作废"); - settlement.setApprovalStatus(VOIDED); + settlement.setApprovalStatus(TransportBusiConstant.VOIDED); settlement.setCurrentNode("已作废"); settlement.setVoidReason(required(limit(request.getReason(), 200), "作废原因")); updateById(settlement); @@ -471,17 +609,345 @@ public class FormalSettlementServiceImpl extends BaseServiceImpllambdaUpdate() + .eq(FormalSettlement::getId, id) + .set(FormalSettlement::getApprovalStatus, TransportBusiConstant.VOIDED) + .set(FormalSettlement::getCurrentNode, "已作废") + .set(FormalSettlement::getCurrentProcessor, operator) + .set(FormalSettlement::getVoidReason, "金蝶侧删单回调自动废除") + .set(FormalSettlement::getKingdeeSyncStatus, KingdeeBfiConstant.KINGDEE_STATUS_UNSYNCED) + .set(FormalSettlement::getKingdeeBillNo, null) + .set(FormalSettlement::getKingdeeBillId, null) + .set(FormalSettlement::getSyncedTime, null)); + // 变更流水:回调无登录态,操作人取金蝶报文(兜底 system,与 saveChange 同款) + FormalSettlementChangeRecord record = new FormalSettlementChangeRecord(); + record.setFormalSettlementId(id); + record.setChangeType("结算单基本信息"); + record.setOperationType("调整"); + record.setChangeContent("金蝶删单回调自动废除并还原同步信息(金蝶单据已删除,可还原重推)"); + record.setChangeReason("金蝶操作人:" + Func.toStr(operator)); + record.setOperatorName(Func.isEmpty(operator) ? "system" : operator); + record.setChangeTime(LocalDateTime.now()); + changeRecordMapper.insert(record); + } + + @Override + public String syncKingdee(Long id) { + String billNo; + try { + // 单据级事务经自代理调用:单条失败只回滚自身 + billNo = SpringUtil.getBean(IFormalSettlementService.class).doSyncKingdee(id); + } catch (Exception exception) { + log.error("正式结算单[{}]推送金蝶异常", id, exception); + // 推送事务已回滚、行锁已释放,此处独立事务落"生成失败"状态(与付款/收票口径统一,失败可被重推任务续推) + self().markKingdeeSyncFailed(id, exception.getMessage()); + throw exception; + } + // 主单事务提交后锁定金蝶单据(lockBusiData):失败只留 failed 锁记录由补偿任务重锁,不污染主单同步状态; + // 锁定失败摘要拼入返回值,页面直接可见 + String lockFailSummary = lockKingdeeBillAfterPush(id, billNo); + // 主单事务提交后联带上传附件(A902):失败只记附件同步状态,不回滚不污染主单同步状态,重推自动补传 + syncAttachmentsAfterPush(id); + return Func.isBlank(lockFailSummary) ? billNo + : billNo + ";注意:金蝶单据锁定未成功(" + lockFailSummary + "),系统将自动补偿重锁,重推同步金蝶可立即补锁"; + } + + /** + * 金蝶单据锁定(lockBusiData):A102-2/A302-2 推送成功事务提交后调用,目标单据类型按结算类型路由; + * 锁定异常独立捕获,不影响主单同步结果 + * + * @param id 正式结算单id + * @param billNo 金蝶单据编号 + * @return 锁定失败的金蝶单号摘要,全部锁定成功返回 null + */ + private String lockKingdeeBillAfterPush(Long id, String billNo) { + try { + if (Func.isBlank(billNo)) { + return null; + } + FormalSettlement settlement = getById(id); + if (settlement == null) { + return null; + } + return kingdeeBillLockService.lockAfterPush(KingdeeLockConstant.SOURCE_SETTLEMENT, id, settlement.getFormalSettlementNo(), + "receivable".equals(settlement.getSettlementType()) + ? KingdeeLockConstant.TARGET_AR_BILL : KingdeeLockConstant.TARGET_AP_BILL, + Func.toStrList(billNo)); + } catch (Exception exception) { + log.error("正式结算单[{}]金蝶单据锁定联带动作异常(锁记录已落库待补偿)", id, exception); + return "锁定动作异常,已留档待补偿"; + } + } + + /** 自代理:跨方法调用确保事务注解生效(失败状态独立事务) */ + private IFormalSettlementService self() { + return SpringUtil.getBean(IFormalSettlementService.class); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void markKingdeeSyncFailed(Long id, String reason) { + if (id == null) return; + // 条件更新:仅审批通过且未同步的单据落"生成失败"状态,未审批/已同步/已删除单据自动跳过保持原状态 + int updated = baseMapper.update(null, Wrappers.lambdaUpdate() + .eq(FormalSettlement::getId, id) + .eq(FormalSettlement::getApprovalStatus, TransportBusiConstant.APPROVED) + .ne(FormalSettlement::getKingdeeSyncStatus, KingdeeBfiConstant.KINGDEE_STATUS_SYNCED) + .set(FormalSettlement::getKingdeeSyncStatus, KingdeeBfiConstant.KINGDEE_STATUS_FAILED)); + if (updated > 0) { + // 失败原因写变更记录留档(推送事务已回滚,此处独立事务);原因过长截断留档,不抛错 + String failReason = Func.toStrWithEmpty(reason, "未知异常"); + saveChange(id, "结算单基本信息", null, "调整", + "同步金蝶失败:" + (failReason.length() > 200 ? failReason.substring(0, 200) : failReason), null); + } + } + + @Override + public String retryKingdeeFailed() { + List failedIds = list(Wrappers.lambdaQuery() + .eq(FormalSettlement::getKingdeeSyncStatus, KingdeeBfiConstant.KINGDEE_STATUS_FAILED) + .eq(FormalSettlement::getApprovalStatus, TransportBusiConstant.APPROVED) + .orderByAsc(FormalSettlement::getCreateTime)) + .stream().map(FormalSettlement::getId).toList(); + return executeKingdeeRetry(failedIds); + } + + /** + * 逐单重推失败单据:单条失败独立 try/catch 不中断整批,返回执行摘要。 + * 推送前预检(A102-4/A302-4 幂等回填)保证重推不重复生成金蝶单据。 + * + * @param failedIds 失败单据id集合 + * @return 执行摘要 + */ + private String executeKingdeeRetry(List failedIds) { + if (Func.isEmpty(failedIds)) { + return "无生成失败单据"; + } + int success = 0; + List failures = new ArrayList<>(); + for (Long id : failedIds) { + try { + syncKingdee(id); + success++; + } catch (Exception exception) { + failures.add(id + ":" + exception.getMessage()); + } + } + String summary = "重推生成失败单据:本批 " + failedIds.size() + " 条,成功 " + success + " 条"; + if (!failures.isEmpty()) { + summary = summary + ",失败 " + failures.size() + " 条:" + String.join(";", failures); + } + return summary; + } + + /** + * 附件联带上传(A902):主单推送成功(含幂等预检命中)后调用,以本地 kingdeeBillId 定位金蝶单据; + * 本地缺失(存量数据/幂等跳过路径未带回内码)时经 A102-4/A302-4 按防重键反查回填; + * 附件上传异常独立捕获,绝不影响主单同步结果 + * + * @param id 正式结算单id + */ + private void syncAttachmentsAfterPush(Long id) { + try { + FormalSettlement settlement = getById(id); + if (settlement == null || !"synced".equals(settlement.getKingdeeSyncStatus())) { + return; + } + String billPkId = settlement.getKingdeeBillId(); + if (Func.isBlank(billPkId)) { + billPkId = resolveKingdeeBillId(settlement); + if (Func.isNotBlank(billPkId)) { + log.info("正式结算单[{}]经 A102-4/A302-4 反查回填星瀚单据内码:{}", settlement.getFormalSettlementNo(), billPkId); + FormalSettlement patch = new FormalSettlement(); + patch.setId(settlement.getId()); + patch.setKingdeeBillId(billPkId); + updateById(patch); + } + } + if (Func.isBlank(billPkId)) { + log.warn("正式结算单[{}]金蝶附件联带上传跳过:星瀚单据内码未落库且反查未命中,待重推补传", + settlement.getFormalSettlementNo()); + return; + } + String bizType = "receivable".equals(settlement.getSettlementType()) + ? KingdeeAttachmentConstant.BIZ_FORMAL_SETTLEMENT_AR : KingdeeAttachmentConstant.BIZ_FORMAL_SETTLEMENT_AP; + kingdeeAttachmentSyncSupport.syncUserAttachments(bizType, id, billPkId, settlement.getAttachmentsJson()); + } catch (Exception exception) { + log.error("正式结算单[{}]金蝶附件联带上传异常(不影响主单同步状态,重推自动补传)", id, exception); + } + } + + /** + * 反查星瀚单据内码:按结算类型组装防重键(与 A102-2/A302-2 传参一致)后调用对应查询接口 + * + * @param settlement 正式结算单 + * @return 星瀚单据内码,未查得返回 null + */ + private String resolveKingdeeBillId(FormalSettlement settlement) { + List details = detailMapper.selectList(Wrappers.lambdaQuery() + .eq(FormalSettlementDetail::getFormalSettlementId, settlement.getId()) + .eq(FormalSettlementDetail::getIsDeleted, 0) + .orderByAsc(FormalSettlementDetail::getLineNo)); + if (Func.isEmpty(details)) { + return null; + } + if ("receivable".equals(settlement.getSettlementType())) { + return kingdeeArBillSyncSupport.queryArBillExistId(kingdeeArBillSyncSupport.arBillSrcCode(settlement, details)); + } + return kingdeeApBillSyncSupport.queryApBillExistId(kingdeeApBillSyncSupport.apBillSrcCode(settlement, details)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public String doSyncKingdee(Long id) { + FormalSettlement settlement = existing(id); + if (!TransportBusiConstant.APPROVED.equals(settlement.getApprovalStatus())) throw new ServiceException("仅审批通过的正式结算单允许同步金蝶"); + // 已同步幂等返回(含历史由付款申请同步联动回写的存量已同步数据);星瀚内码由附件补传分支反查回填 if ("synced".equals(settlement.getKingdeeSyncStatus())) return settlement.getKingdeeBillNo(); - String kingdeeNo = "K3AP" + DateTimeFormatter.ofPattern("yyyyMMddHHmmss").format(LocalDateTime.now()); - settlement.setKingdeeBillNo(kingdeeNo); - settlement.setKingdeeSyncStatus("synced"); + // 流程拆分:正式结算单「同步金蝶」负责推送财务应付单(A102-2)并回写结算单单据编号; + // 付款申请「同步金蝶」仅负责付款申请单(A103-1 应付下推),推送前经 A102-4 预检本单应付单已生成 + List details = detailMapper.selectList(Wrappers.lambdaQuery() + .eq(FormalSettlementDetail::getFormalSettlementId, id) + .eq(FormalSettlementDetail::getIsDeleted, 0) + .orderByAsc(FormalSettlementDetail::getLineNo)); + if (Func.isEmpty(details)) { + throw new ServiceException("推送金蝶失败:正式结算单无结算明细,无法解析物料与计量单位"); + } + KingdeeSyncContext context = KingdeeSyncContext.empty(); + // 单据类型路由:应付对应财务应付单(A102-2),应收对应财务应收单(A302-2),两链路同一套幂等预检与推送逻辑 + String billNo; + String billPkId; + String changeDesc; + if ("receivable".equals(settlement.getSettlementType())) { + // 应收结算:财务应收单防重键(awrp_src_code):正式结算单号_结算明细单据号下划线拼接 + String arBillSrcCode = kingdeeArBillSyncSupport.arBillSrcCode(settlement, details); + List entryGroups = kingdeeArBillSyncSupport.resolveArEntryGroups(settlement, details, context); + KingdeeApBillSyncSupport.BfiApBillPushOutcome outcome = kingdeeArBillSyncSupport.pushArBill( + arBillSrcCode, settlement, entryGroups, settlement.getFormalSettlementNo(), context); + billNo = outcome.billNo(); + billPkId = outcome.billId(); + changeDesc = "同步金蝶财务应收单" + billNo; + } else { + // 应付结算:财务应付单防重键,付款申请「同步金蝶」(A103-1 应付下推)推送前按该键经 A102-4 预检本单应付单已生成 + // 付款申请 A103-1 下推前的前置 + String apBillSrcCode = kingdeeApBillSyncSupport.apBillSrcCode(settlement, details); + List entryGroups = kingdeeApBillSyncSupport.resolveEntryGroups(settlement, details, context); + KingdeeApBillSyncSupport.BfiApBillPushOutcome outcome = kingdeeApBillSyncSupport.pushApBill( + apBillSrcCode, settlement, entryGroups, settlement.getFormalSettlementNo(), context); + billNo = outcome.billNo(); + billPkId = outcome.billId(); + changeDesc = "同步金蝶财务应付单" + billNo; + } + // 回写金蝶单据编号(A102-2/A302-2 单据编号)、星瀚单据内码(A902 附件上传定位用)与同步状态; + // 付款申请侧回填的付款申请单编号(A103-2)不是同一单据 + settlement.setKingdeeBillNo(billNo); + settlement.setKingdeeBillId(billPkId); + settlement.setKingdeeSyncStatus(KingdeeBfiConstant.KINGDEE_STATUS_SYNCED); settlement.setSyncedTime(LocalDateTime.now()); updateById(settlement); - saveChange(settlement.getId(), "结算单基本信息", null, "调整", "同步金蝶单据" + kingdeeNo, ""); - return kingdeeNo; + saveChange(settlement.getId(), "结算单基本信息", null, "调整", changeDesc, ""); + log.info("正式结算单[{}]同步金蝶完成:settlementType={},billNo={},billPkId={}", + settlement.getFormalSettlementNo(), settlement.getSettlementType(), billNo, billPkId); + return billNo; + } + + @Override + public String syncKingdeeArResult() { + return kingdeeSyncLockSupport.runSingleFlight(AR_RESULT_SYNC_LOCK_PREFIX, bfiProperties.getArResult().getLockLeaseSeconds(), + "金蝶应收单勾稽", this::executeArResultSync); + } + + /** + * 金蝶应收单勾稽主体:A302-4 批量查询财务应收单回填单据编号、生成失败纠偏为已同步, + * 由 {@link #syncKingdeeArResult()} 在分布式锁保护下调用。 + *

分批与事务说明: + * ① 结算明细按 {@link #AR_RESULT_DETAIL_QUERY_BATCH} 分批 in 查询后内存分组,消除逐单查询 N+1; + * ② 回写聚合后经 {@code updateBatchById} 批量落库——本方法为 this 自调用,MP 自带的 + * {@code @Transactional} 不生效,但其 executeBatch 会独立开启 BATCH SqlSession 并在结束时统一提交、 + * 异常整体回滚,等效单次原子写;③ 整轮任务不包外层事务(远程星瀚查询不占库连接,勾稽按防重键 + * 幂等可重跑),private + lambda 自调用上补加 {@code @Transactional} 会静默失效,禁止添加。 + * + * @return 执行摘要 + */ + private String executeArResultSync() { + long startMillis = System.currentTimeMillis(); + // 待勾稽应收结算单:审批通过 + 结算类型应收 + 金蝶状态为 已同步/生成失败(已同步一并轮询为补齐单号,生成失败纳入勾稽纠偏: + // 金蝶侧已生成说明上次推送实际成功,查到即回填纠正为已同步,避免人工重新推送产生重复单据) + List settlements = list(Wrappers.lambdaQuery() + .eq(FormalSettlement::getApprovalStatus, TransportBusiConstant.APPROVED) + .eq(FormalSettlement::getSettlementType, "receivable") + .in(FormalSettlement::getKingdeeSyncStatus, KingdeeBfiConstant.KINGDEE_STATUS_SYNCED, + KingdeeBfiConstant.KINGDEE_STATUS_FAILED) + .orderByAsc(FormalSettlement::getId)); + if (Func.isEmpty(settlements)) { + log.info("金蝶应收单勾稽:无待勾稽应收结算单"); + return "金蝶应收单勾稽:无待勾稽应收结算单"; + } + long syncedCount = settlements.stream() + .filter(item -> KingdeeBfiConstant.KINGDEE_STATUS_SYNCED.equals(item.getKingdeeSyncStatus())).count(); + log.info("金蝶应收单勾稽开始:待勾稽应收结算单{}笔(已同步{}笔,生成失败{}笔)", + settlements.size(), syncedCount, settlements.size() - syncedCount); + // 结算明细分批一次查全(消除逐单查询 N+1),按结算单 id 分组 + List settlementIds = settlements.stream().map(FormalSettlement::getId).toList(); + Map> detailsBySettlement = new HashMap<>(); + kingdeeApBillSyncSupport.batchForeach(settlementIds, AR_RESULT_DETAIL_QUERY_BATCH, batch -> + detailsBySettlement.putAll(detailMapper.selectList(Wrappers.lambdaQuery() + .in(FormalSettlementDetail::getFormalSettlementId, batch) + .eq(FormalSettlementDetail::getIsDeleted, 0) + .orderByAsc(FormalSettlementDetail::getLineNo)).stream() + .collect(Collectors.groupingBy(FormalSettlementDetail::getFormalSettlementId)))); + // 组装防重键(与 A302-2 保存传参一致),同一防重键对应一张财务应收单 + Map settlementBySrcCode = new LinkedHashMap<>(); + for (FormalSettlement settlement : settlements) { + String srcCode = kingdeeArBillSyncSupport.arBillSrcCode(settlement, + detailsBySettlement.getOrDefault(settlement.getId(), List.of())); + if (Func.isNotBlank(srcCode)) { + settlementBySrcCode.putIfAbsent(srcCode, settlement); + } + } + // A302-4 批量查询:查到 → 回填金蝶单据编号(缺失时)、生成失败纠偏为已同步;查不到 → 状态保持不变 + Map existBillNos = kingdeeArBillSyncSupport.queryArBillExistNos(new ArrayList<>(settlementBySrcCode.keySet())); + // 勾稽回写聚合后经 updateBatchById 批量更新,避免逐单 update + // (this 自调用下 MP 事务注解不生效,executeBatch 自开 BATCH SqlSession 统一提交、异常整体回滚,等效原子写) + List toUpdateSettlements = new ArrayList<>(); + int corrected = 0; + for (Map.Entry entry : settlementBySrcCode.entrySet()) { + String billNo = existBillNos.get(entry.getKey()); + if (Func.isBlank(billNo)) { + continue; + } + FormalSettlement settlement = entry.getValue(); + boolean failed = KingdeeBfiConstant.KINGDEE_STATUS_FAILED.equals(settlement.getKingdeeSyncStatus()); + if (!failed && Func.isNotBlank(settlement.getKingdeeBillNo())) { + continue; + } + if (failed) { + corrected++; + } + if (Func.isBlank(settlement.getKingdeeBillNo())) { + settlement.setKingdeeBillNo(billNo); + } + settlement.setKingdeeSyncStatus(KingdeeBfiConstant.KINGDEE_STATUS_SYNCED); + settlement.setSyncedTime(LocalDateTime.now()); + toUpdateSettlements.add(settlement); + // 生成失败纠偏与缺失单号补齐逐笔留痕,便于对账核查 + log.info("正式结算单[{}]金蝶应收单勾稽回写:{},billNo={}", + settlement.getFormalSettlementNo(), failed ? "生成失败纠偏为已同步" : "补齐单据编号", + settlement.getKingdeeBillNo()); + } + if (Func.isNotEmpty(toUpdateSettlements)) { + updateBatchById(toUpdateSettlements); + log.info("金蝶应收单勾稽批量回写完成:本批更新{}笔", toUpdateSettlements.size()); + } + String summary = "金蝶应收单勾稽完成:待查应收结算单" + settlementBySrcCode.size() + "笔,回写单据信息" + toUpdateSettlements.size() + + "笔(生成失败纠偏" + corrected + "笔)"; + log.info("{},耗时{}ms", summary, System.currentTimeMillis() - startMillis); + return summary; } @Override @@ -533,6 +999,8 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl 0) { throw new ServiceException("发票匹配结算单金额合计不能超过结算金额"); } @@ -594,12 +1062,12 @@ public class FormalSettlementServiceImpl extends BaseServiceImpllambdaQuery() .eq(FormalSettlementPayment::getFormalSettlementId, settlement.getId()) .eq(FormalSettlementPayment::getIsDeleted, 0) - .notIn(FormalSettlementPayment::getBillStatus, RETURNED, VOIDED)); + .notIn(FormalSettlementPayment::getBillStatus, TransportBusiConstant.RETURNED, TransportBusiConstant.VOIDED)); if (activePaymentCount > 0) throw new ServiceException("该结算单正在申请结算尾款,不允许再次提交申请付款"); BigDecimal amount = positive(appliedAmount, "申请付款金额"); BigDecimal available = money(settlement.getSettlementAmount()).subtract(money(settlement.getAppliedPaymentAmount())); @@ -610,7 +1078,7 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl settlementIds) { + List ids = settlementIds == null ? List.of() : settlementIds.stream() + .filter(Objects::nonNull).distinct().toList(); + if (ids.isEmpty()) { + return; + } + // 结算单一次锁行查询替代逐单 selectOne(id 升序加锁,与逐单版加锁顺序一致防死锁) + List settlements = baseMapper.selectList(Wrappers.lambdaQuery() + .in(FormalSettlement::getId, ids) + .orderByAsc(FormalSettlement::getId) + .last("FOR UPDATE")); + if (settlements.isEmpty()) { + return; + } + List settledIds = settlements.stream().map(FormalSettlement::getId).toList(); + boolean hasPayable = settlements.stream().anyMatch(item -> "payable".equals(item.getSettlementType())); + boolean hasReceivable = settlements.stream().anyMatch(item -> "receivable".equals(item.getSettlementType())); + + // 结算-付款申请关系(全量) + Map> relationsBySettlement = paymentApplicationSettlementMapper.selectList( + Wrappers.lambdaQuery() + .in(PaymentApplicationSettlement::getFormalSettlementId, settledIds) + .eq(PaymentApplicationSettlement::getIsDeleted, 0)).stream() + .collect(Collectors.groupingBy(PaymentApplicationSettlement::getFormalSettlementId)); + // 付款申请(按结算单直挂,payment_type=结算) + Map> applicationsBySettlement = paymentApplicationMapper.selectList( + Wrappers.lambdaQuery() + .in(PaymentApplication::getSettlementId, settledIds) + .eq(PaymentApplication::getPaymentType, TransportBusiConstant.PAYMENT_TYPE_SETTLEMENT) + .eq(PaymentApplication::getIsDeleted, 0)).stream() + .collect(Collectors.groupingBy(PaymentApplication::getSettlementId)); + // 关系表引用的付款申请状态(全量一次) + Set relationApplicationIds = relationsBySettlement.values().stream() + .flatMap(List::stream).map(PaymentApplicationSettlement::getPaymentApplicationId) + .filter(Objects::nonNull).collect(Collectors.toSet()); + Map relationStatuses = relationApplicationIds.isEmpty() ? Map.of() + : paymentApplicationMapper.selectList(Wrappers.lambdaQuery() + .select(PaymentApplication::getId, PaymentApplication::getApprovalStatus) + .in(PaymentApplication::getId, relationApplicationIds) + .eq(PaymentApplication::getIsDeleted, 0)).stream() + .collect(Collectors.toMap(PaymentApplication::getId, PaymentApplication::getApprovalStatus, + (first, second) -> first)); + // 预收来源(仅应付结算单需要) + Map> sourcesBySettlement = hasPayable + ? sourceMapper.selectList(Wrappers.lambdaQuery() + .in(FormalSettlementSource::getFormalSettlementId, settledIds) + .eq(FormalSettlementSource::getIsDeleted, 0)).stream() + .collect(Collectors.groupingBy(FormalSettlementSource::getFormalSettlementId)) + : Map.of(); + // 收款认领分摊聚合(仅应收结算单需要) + Map receiptClaimPaidBySettlement = hasReceivable + ? receiptClaimSettlementMapper.selectList(Wrappers.lambdaQuery() + .in(ReceiptClaimSettlement::getFormalSettlementId, settledIds) + .eq(ReceiptClaimSettlement::getStatus, 1)).stream() + .collect(Collectors.groupingBy(ReceiptClaimSettlement::getFormalSettlementId, + Collectors.reducing(BigDecimal.ZERO, + item -> money(item.getAllocatedReceiptAmount()), BigDecimal::add))) + : Map.of(); + + for (FormalSettlement settlement : settlements) { + List relations = relationsBySettlement + .getOrDefault(settlement.getId(), List.of()); + // 直挂付款申请需排除该结算单关系表已引用的申请(与单笔版 notIn 语义一致) + Set ownRelationApplicationIds = relations.stream() + .map(PaymentApplicationSettlement::getPaymentApplicationId) + .filter(Objects::nonNull).collect(Collectors.toSet()); + List directApplications = applicationsBySettlement + .getOrDefault(settlement.getId(), List.of()).stream() + .filter(item -> !ownRelationApplicationIds.contains(item.getId())) + .toList(); + PaymentSummary summary = calculatePaymentSummary(settlement, new PaymentSummaryData( + relations, directApplications, relationStatuses, + sourcesBySettlement.getOrDefault(settlement.getId(), List.of()), + receiptClaimPaidBySettlement.getOrDefault(settlement.getId(), BigDecimal.ZERO))); + settlement.setAppliedPaymentAmount(summary.appliedAmount()); + settlement.setPaidAmount(summary.paidAmount()); + settlement.setRemainingPayableAmount(money(settlement.getSettlementAmount()) + .subtract(summary.paidAmount()).max(BigDecimal.ZERO)); + settlement.setPaymentStatus(paymentStatus(summary.paidAmount(), settlement.getSettlementAmount())); + } + updateBatchById(settlements); + } + @Override @Transactional(rollbackFor = Exception.class) public void refreshPaymentSummariesForPreSettlement(Long preSettlementId) { if (preSettlementId == null) return; - sourceMapper.selectList(Wrappers.lambdaQuery() - .eq(FormalSettlementSource::getPreSettlementId, preSettlementId) - .eq(FormalSettlementSource::getIsDeleted, 0)).stream() - .map(FormalSettlementSource::getFormalSettlementId).distinct() - .forEach(this::refreshPaymentSummary); + List settlementIds = sourceMapper.selectList(Wrappers.lambdaQuery() + .eq(FormalSettlementSource::getPreSettlementId, preSettlementId) + .eq(FormalSettlementSource::getIsDeleted, 0)).stream() + .map(FormalSettlementSource::getFormalSettlementId).filter(Objects::nonNull).distinct().toList(); + refreshPaymentSummaries(settlementIds); } private List listSummaryFees(Long settlementId) { @@ -1056,7 +1613,7 @@ public class FormalSettlementServiceImpl extends BaseServiceImpllambdaUpdate() .eq(PreSettlement::getId, source.getId()) - .eq(PreSettlement::getApprovalStatus, APPROVED) + .eq(PreSettlement::getApprovalStatus, TransportBusiConstant.APPROVED) .and(w -> w.isNull(PreSettlement::getFormalSettlementNo).or().eq(PreSettlement::getFormalSettlementNo, "")) .set(PreSettlement::getFormalSettlementNo, settlement.getFormalSettlementNo()) .set(PreSettlement::getFormalSettledTime, LocalDateTime.now()) @@ -1173,54 +1730,65 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl query = Wrappers.lambdaQuery() - .eq(PaymentApplication::getSettlementId, settlement.getId()) - .eq(PaymentApplication::getPaymentType, "settlement_payment") - .eq(PaymentApplication::getIsDeleted, 0); List relations = paymentApplicationSettlementMapper.selectList( Wrappers.lambdaQuery() .eq(PaymentApplicationSettlement::getFormalSettlementId, settlement.getId()) .eq(PaymentApplicationSettlement::getIsDeleted, 0)); Set relationApplicationIds = relations.stream().map(PaymentApplicationSettlement::getPaymentApplicationId) .filter(Objects::nonNull).collect(Collectors.toSet()); + LambdaQueryWrapper query = Wrappers.lambdaQuery() + .eq(PaymentApplication::getSettlementId, settlement.getId()) + .eq(PaymentApplication::getPaymentType, TransportBusiConstant.PAYMENT_TYPE_SETTLEMENT) + .eq(PaymentApplication::getIsDeleted, 0); if (!relationApplicationIds.isEmpty()) query.notIn(PaymentApplication::getId, relationApplicationIds); List applications = paymentApplicationMapper.selectList(query); - BigDecimal appliedAmount = applications.stream() - .filter(item -> REVIEWING.equals(item.getApprovalStatus())) - .map(PaymentApplication::getAppliedAmount).map(this::money) - .reduce(BigDecimal.ZERO, BigDecimal::add); - BigDecimal paidAmount = applications.stream() - .filter(item -> APPROVED.equals(item.getApprovalStatus())) - .map(PaymentApplication::getPaidAmount).map(this::money) - .reduce(BigDecimal.ZERO, BigDecimal::add); Map relationStatuses = relationApplicationIds.isEmpty() ? Map.of() : paymentApplicationMapper.selectList( Wrappers.lambdaQuery().select(PaymentApplication::getId, PaymentApplication::getApprovalStatus) .in(PaymentApplication::getId, relationApplicationIds).eq(PaymentApplication::getIsDeleted, 0)) .stream().collect(Collectors.toMap(PaymentApplication::getId, PaymentApplication::getApprovalStatus)); - appliedAmount = appliedAmount.add(relations.stream() - .filter(item -> REVIEWING.equals(relationStatuses.get(item.getPaymentApplicationId()))) + return calculatePaymentSummary(settlement, new PaymentSummaryData(relations, applications, + relationStatuses, null, null)); + } + + /** + * 收款汇总纯计算:查询数据由调用方供给(单笔版自行查询;批量版由 refreshPaymentSummaries 一次供给多单)。 + * sources / receiptClaimPaid 为 null 时按需自行查询(仅 payable/receivable 分支使用)。 + */ + private PaymentSummary calculatePaymentSummary(FormalSettlement settlement, PaymentSummaryData data) { + BigDecimal appliedAmount = data.directApplications().stream() + .filter(item -> TransportBusiConstant.REVIEWING.equals(item.getApprovalStatus())) + .map(PaymentApplication::getAppliedAmount).map(this::money) + .reduce(BigDecimal.ZERO, BigDecimal::add); + BigDecimal paidAmount = data.directApplications().stream() + .filter(item -> TransportBusiConstant.APPROVED.equals(item.getApprovalStatus())) + .map(PaymentApplication::getPaidAmount).map(this::money) + .reduce(BigDecimal.ZERO, BigDecimal::add); + appliedAmount = appliedAmount.add(data.relations().stream() + .filter(item -> TransportBusiConstant.REVIEWING.equals(data.relationStatuses().get(item.getPaymentApplicationId()))) .map(PaymentApplicationSettlement::getAppliedAmount).map(this::money) .reduce(BigDecimal.ZERO, BigDecimal::add)); - paidAmount = paidAmount.add(relations.stream() - .filter(item -> APPROVED.equals(relationStatuses.get(item.getPaymentApplicationId()))) + paidAmount = paidAmount.add(data.relations().stream() + .filter(item -> TransportBusiConstant.APPROVED.equals(data.relationStatuses().get(item.getPaymentApplicationId()))) .map(PaymentApplicationSettlement::getPaidAmount).map(this::money) .reduce(BigDecimal.ZERO, BigDecimal::add)); if ("payable".equals(settlement.getSettlementType())) { - List sources = sourceMapper.selectList(Wrappers.lambdaQuery() - .eq(FormalSettlementSource::getFormalSettlementId, settlement.getId()) - .eq(FormalSettlementSource::getIsDeleted, 0)); + List sources = data.sources() != null ? data.sources() + : sourceMapper.selectList(Wrappers.lambdaQuery() + .eq(FormalSettlementSource::getFormalSettlementId, settlement.getId()) + .eq(FormalSettlementSource::getIsDeleted, 0)); appliedAmount = appliedAmount.add(sources.stream().map(FormalSettlementSource::getAdvanceAppliedAmount) .map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add)); paidAmount = paidAmount.add(sources.stream().map(FormalSettlementSource::getAdvancePaidAmount) .map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add)); } if ("receivable".equals(settlement.getSettlementType())) { - paidAmount = receiptClaimSettlementMapper.selectList( - Wrappers.lambdaQuery() - .eq(ReceiptClaimSettlement::getFormalSettlementId, settlement.getId()) - .eq(ReceiptClaimSettlement::getStatus, 1)) - .stream().map(ReceiptClaimSettlement::getAllocatedReceiptAmount).map(this::money) - .reduce(BigDecimal.ZERO, BigDecimal::add); + paidAmount = data.receiptClaimPaid() != null ? data.receiptClaimPaid() + : receiptClaimSettlementMapper.selectList( + Wrappers.lambdaQuery() + .eq(ReceiptClaimSettlement::getFormalSettlementId, settlement.getId()) + .eq(ReceiptClaimSettlement::getStatus, 1)) + .stream().map(ReceiptClaimSettlement::getAllocatedReceiptAmount).map(this::money) + .reduce(BigDecimal.ZERO, BigDecimal::add); } return new PaymentSummary(money(appliedAmount), money(paidAmount)); } @@ -1239,6 +1807,20 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl relations, + List directApplications, Map relationStatuses, + List sources, BigDecimal receiptClaimPaid) { + } + private SettlementAdjustmentVO toAdjustmentVO(SettlementAdjustment entity, String kingdeeBillNo) { SettlementAdjustmentVO vo = Objects.requireNonNull( BeanUtil.copyProperties(entity, SettlementAdjustmentVO.class)); @@ -1250,12 +1832,15 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl "草稿"; - case REVIEWING -> "审批中"; - case APPROVED -> "审批通过"; - case RETURNED -> "已驳回"; - case VOIDED -> "已作废"; + if (value == null) { + return ""; + } + return switch (value) { + case "draft" -> "草稿"; + case "reviewing" -> "审批中"; + case "approved" -> "审批通过"; + case "returned" -> "已驳回"; + case "voided" -> "已作废"; default -> value; }; } @@ -1263,7 +1848,7 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl implements IInvoiceApplicationService { - private static final String DRAFT = "draft"; - private static final String REVIEWING = "reviewing"; - private static final String APPROVED = "approved"; - private static final String RETURNED = "returned"; - private static final String VOIDED = "voided"; + /** A303-5 开票结果批量查询单批单据编号上限(接口限制 50) */ + private static final int INVOICE_RESULT_QUERY_BATCH = 50; + /** 金蝶开票结果回写 single-flight 锁键前缀(多副本定时任务与手动触发共用) */ + private static final String INVOICE_RESULT_SYNC_LOCK_PREFIX = "transport:invoice-application:kingdee-invoice-result-sync:"; private final InvoiceApplicationSettlementMapper settlementRelationMapper; private final InvoiceApplicationSheetMapper sheetMapper; private final WaybillMapper waybillMapper; @@ -118,6 +153,17 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl selectPage(IPage page, InvoiceApplicationVO query) { @@ -128,13 +174,64 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl voPage = page(page, wrapper).convert(this::toListVO); + fillAttachmentSyncStatus(voPage.getRecords()); + fillKingdeeLockStatus(voPage.getRecords()); + return voPage; + } + + /** + * 填充金蝶锁状态标识(locked-锁定中/failed-锁定失败;unlocked 终态不返回): + * 锁表按本页源单一次批量查询,避免 N+1;locked 时前端可禁用编辑/作废, + * failed 时展示失败原因(tryCount 达上限 5 提示重推同步金蝶) + * + * @param rows 本页 VO 列表 + */ + private void fillKingdeeLockStatus(List rows) { + if (Func.isEmpty(rows)) { + return; + } + Map lockMap = kingdeeBillLockService.mapActiveLocks(KingdeeLockConstant.SOURCE_INVOICE_APPLY, + rows.stream().map(InvoiceApplicationVO::getId).toList()); + for (InvoiceApplicationVO vo : rows) { + KingdeeBillLock lockRecord = lockMap.get(vo.getId()); + if (lockRecord == null) { + continue; + } + vo.setKingdeeLockStatus(lockRecord.getLockStatus()); + vo.setKingdeeLockFailReason(lockRecord.getFailReason()); + vo.setKingdeeTryCount(lockRecord.getTryCount()); + } + // 金蝶删单解锁会作废源单,按 unlocked 锁记录留痕补「金蝶删单」标识,区分正常作废与金蝶删单 + kingdeeBillLockService.fillUnlockMarks(KingdeeLockConstant.SOURCE_INVOICE_APPLY, rows, lockMap); + } + + /** + * 填充附件同步聚合态(无附件/未上传/部分未传/已上传):同步表按本页 biz_id 一次 GROUP BY 查询,避免 N+1 + * + * @param rows 本页 VO 列表 + */ + private void fillAttachmentSyncStatus(List rows) { + if (Func.isEmpty(rows)) { + return; + } + Map successCounts = kingdeeAttachmentSyncSupport.successCountByBizId( + KingdeeAttachmentConstant.BIZ_INVOICE_APPLICATION, rows.stream().map(InvoiceApplicationVO::getId).toList()); + for (InvoiceApplicationVO vo : rows) { + int attachmentCount = kingdeeAttachmentSyncSupport.countAttachments(vo.getAttachmentsJson()); + String aggregateStatus = kingdeeAttachmentSyncSupport.aggregateStatus(attachmentCount, + successCounts.getOrDefault(vo.getId(), 0)); + vo.setAttachmentCount(attachmentCount); + vo.setAttachmentSyncStatus(aggregateStatus); + vo.setAttachmentSyncStatusName(KingdeeAttachmentConstant.aggregateName(aggregateStatus)); + } } @Override public InvoiceApplicationVO detail(Long id) { InvoiceApplication entity = existing(id); InvoiceApplicationVO vo = toListVO(entity); + fillKingdeeLockStatus(List.of(vo)); vo.setSettlements(settlementRelationMapper.selectList(Wrappers.lambdaQuery() .eq(InvoiceApplicationSettlement::getInvoiceApplicationId, id) .orderByAsc(InvoiceApplicationSettlement::getCreateTime))); @@ -151,6 +248,9 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpllambdaQuery() .eq(InvoiceApplicationRecord::getInvoiceApplicationId, id) .orderByAsc(InvoiceApplicationRecord::getCreateTime))); + vo.setInvoices(invoiceApplicationInvoiceMapper.selectList(Wrappers.lambdaQuery() + .eq(InvoiceApplicationInvoice::getInvoiceApplicationId, id) + .orderByAsc(InvoiceApplicationInvoice::getCreateTime))); return vo; } @@ -171,7 +271,7 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl settlements = formalSettlementMapper.selectList(Wrappers.lambdaQuery() .eq(FormalSettlement::getSettlementType, candidateSettlementType) .eq(FormalSettlement::getInvoiceStatus, candidateInvoiceStatus) - .eq(FormalSettlement::getApprovalStatus, APPROVED) + .eq(FormalSettlement::getApprovalStatus, TransportBusiConstant.APPROVED) .eq(FormalSettlement::getStatus, 1) .in(Func.isNotEmpty(contractCategory), FormalSettlement::getContractId, contractIds) .and(Func.isNotEmpty(keyword), wrapper -> wrapper.like(FormalSettlement::getFormalSettlementNo, keyword) @@ -210,7 +310,7 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl allocatedAmounts = relations.stream() .filter(relation -> { InvoiceApplication application = applications.get(relation.getInvoiceApplicationId()); - return application != null && !VOIDED.equals(application.getApprovalStatus()) + return application != null && !TransportBusiConstant.VOIDED.equals(application.getApprovalStatus()) && !Objects.equals(application.getIsDeleted(), 1); }) .collect(Collectors.groupingBy(InvoiceApplicationSettlement::getFormalSettlementId, @@ -345,7 +445,7 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpllambdaUpdate() + .eq(InvoiceApplication::getId, id) + .set(InvoiceApplication::getApprovalStatus, TransportBusiConstant.VOIDED) + .set(InvoiceApplication::getCurrentNode, "已作废") + .set(InvoiceApplication::getCurrentProcessor, operator) + .set(InvoiceApplication::getKingdeeStatus, KingdeeBfiConstant.KINGDEE_STATUS_UNSYNCED) + .set(InvoiceApplication::getKingdeeBillNo, null) + .set(InvoiceApplication::getSyncedTime, null)); + // 操作流水:回调无登录态,操作人取金蝶报文(兜底 system,与 record 同款) + InvoiceApplicationRecord voidRecord = new InvoiceApplicationRecord(); + voidRecord.setInvoiceApplicationId(id); + voidRecord.setActionType("kingdee_delete"); + voidRecord.setActionName("金蝶删单回调作废"); + voidRecord.setFromStatus(TransportBusiConstant.APPROVED); + voidRecord.setToStatus(TransportBusiConstant.VOIDED); + voidRecord.setOperatorName(Func.isEmpty(operator) ? "system" : operator); + voidRecord.setReason("金蝶侧删单回调自动作废并还原同步信息(金蝶开票申请单已删除,可还原重推)"); + recordMapper.insert(voidRecord); + // 关联数据还原:关联正式结算单开票状态刷新 + relationSettlementIds(id).forEach(this::refreshSettlementInvoiceStatus); + } + + @Override + public String syncKingdee(Long id) { + String billNo; + try { + // 单据级事务经自代理调用:单条失败只回滚自身 + billNo = SpringUtil.getBean(IInvoiceApplicationService.class).doSyncKingdee(id); + } catch (Exception exception) { + log.error("开票申请[{}]推送金蝶异常", id, exception); + // 推送事务已回滚、行锁已释放,此处独立事务落"生成失败"状态(与付款/收票口径统一,失败可被重推任务续推) + self().markKingdeeSyncFailed(id, exception.getMessage()); + throw exception; + } + // 主单事务提交后锁定金蝶开票单(lockBusiData):每张发票张次一张金蝶单,接口1对1逐张锁定; + // 失败只留 failed 锁记录由补偿任务重锁,不污染主单同步状态;锁定失败摘要拼入返回值,页面直接可见 + String lockFailSummary = lockKingdeeBillAfterPush(id, billNo); + // 主单事务提交后联带上传附件(A902):失败只记附件同步状态,不回滚不污染主单同步状态,重推自动补传 + syncAttachmentsAfterPush(id); + return Func.isBlank(lockFailSummary) ? billNo + : billNo + ";注意:金蝶单据锁定未成功(" + lockFailSummary + "),系统将自动补偿重锁,重推同步金蝶可立即补锁"; + } + + /** + * 金蝶开票单锁定(lockBusiData):A303-1 推送成功事务提交后调用,接口1对1逐张锁定; + * 锁定异常独立捕获,不影响主单同步结果 + * + * @param id 开票申请id + * @param billNo 金蝶开票单编号(多张逗号拼接) + * @return 锁定失败的金蝶单号摘要,全部锁定成功返回 null + */ + private String lockKingdeeBillAfterPush(Long id, String billNo) { + try { + if (Func.isBlank(billNo)) { + return null; + } + InvoiceApplication entity = getById(id); + if (entity == null) { + return null; + } + return kingdeeBillLockService.lockAfterPush(KingdeeLockConstant.SOURCE_INVOICE_APPLY, id, entity.getApplicationNo(), + KingdeeLockConstant.TARGET_INVOICE_BILL, Func.toStrList(billNo)); + } catch (Exception exception) { + log.error("开票申请[{}]金蝶单据锁定联带动作异常(锁记录已落库待补偿)", id, exception); + return "锁定动作异常,已留档待补偿"; + } + } + + /** 自代理:跨方法调用确保事务注解生效(失败状态独立事务) */ + private IInvoiceApplicationService self() { + return SpringUtil.getBean(IInvoiceApplicationService.class); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void markKingdeeSyncFailed(Long id, String reason) { + if (id == null) return; + // 条件更新:仅审批通过且未同步/未开票的单据落"生成失败"状态,其余自动跳过保持原状态 + int updated = baseMapper.update(null, Wrappers.lambdaUpdate() + .eq(InvoiceApplication::getId, id) + .eq(InvoiceApplication::getApprovalStatus, TransportBusiConstant.APPROVED) + .notIn(InvoiceApplication::getKingdeeStatus, KingdeeBfiConstant.KINGDEE_STATUS_SYNCED, + KingdeeBfiConstant.KINGDEE_STATUS_INVOICED) + .set(InvoiceApplication::getKingdeeStatus, KingdeeBfiConstant.KINGDEE_STATUS_FAILED)); + if (updated > 0) { + // 失败原因写操作流水留档(推送事务已回滚,此处独立事务) + record(id, "sync", "同步金蝶失败", TransportBusiConstant.APPROVED, TransportBusiConstant.APPROVED, + truncateReason(reason), null); + } + } + + /** 失败原因截断留档(流水 reason 字段兜底 500 长度) */ + private String truncateReason(String reason) { + String value = Func.toStrWithEmpty(reason, "未知异常"); + return value.length() > 500 ? value.substring(0, 500) : value; + } + + @Override + public String retryKingdeeFailed() { + List failedIds = list(Wrappers.lambdaQuery() + .eq(InvoiceApplication::getKingdeeStatus, KingdeeBfiConstant.KINGDEE_STATUS_FAILED) + .eq(InvoiceApplication::getApprovalStatus, TransportBusiConstant.APPROVED) + .orderByAsc(InvoiceApplication::getCreateTime)) + .stream().map(InvoiceApplication::getId).toList(); + if (Func.isEmpty(failedIds)) { + return "无生成失败单据"; + } + int success = 0; + List failures = new ArrayList<>(); + for (Long id : failedIds) { + try { + syncKingdee(id); + success++; + } catch (Exception exception) { + failures.add(id + ":" + exception.getMessage()); + } + } + String summary = "重推生成失败单据:本批 " + failedIds.size() + " 条,成功 " + success + " 条"; + if (!failures.isEmpty()) { + summary = summary + ",失败 " + failures.size() + " 条:" + String.join(";", failures); + } + return summary; + } + + /** + * 附件联带上传(A902):主单推送成功(含 A303-4 勾稽回填)后调用,以本地 kingdeeBillId 定位金蝶单据; + * 本地缺失(存量数据)跳过待重推补传;附件上传异常独立捕获,绝不影响主单同步结果 + * + * @param id 开票申请id + */ + private void syncAttachmentsAfterPush(Long id) { + try { + InvoiceApplication entity = getById(id); + if (entity == null || Func.isBlank(entity.getKingdeeBillId())) { + log.warn("开票申请[{}]金蝶附件联带上传跳过:星瀚单据内码未落库,待重推补传", entity == null ? id : entity.getApplicationNo()); + return; + } + KingdeeAttachmentSyncSupport.AttachmentSyncSummary summary = kingdeeAttachmentSyncSupport.syncUserAttachments( + KingdeeAttachmentConstant.BIZ_INVOICE_APPLICATION, id, entity.getKingdeeBillId(), entity.getAttachmentsJson()); + if (summary.total() > 0) { + // 附件同步操作流水(Q5 决策:开票申请有 actionType 流水表,追加 attachment_upload 记录) + record(id, "attachment_upload", "附件上传金蝶", entity.getApprovalStatus(), entity.getApprovalStatus(), + summary.failed() > 0 ? "成功" + summary.success() + "个,失败" + summary.failed() + "个:" + String.join(";", summary.failReasons()) + : "成功上传" + summary.success() + "个附件(历史已传跳过" + summary.skipCount() + "个)", + entity.getKingdeeBillNo()); + } + } catch (Exception exception) { + log.error("开票申请[{}]金蝶附件联带上传异常(不影响主单同步状态,重推自动补传)", id, exception); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public String doSyncKingdee(Long id) { + InvoiceApplication entity = existing(id); + if (!TransportBusiConstant.APPROVED.equals(entity.getApprovalStatus())) throw new ServiceException("仅审批通过的开票申请允许同步金蝶"); + // 已同步/已开票幂等返回(开票完成为终态);星瀚内码由附件补传分支经 A303-4 反查回填 + if (KingdeeBfiConstant.KINGDEE_STATUS_SYNCED.equals(entity.getKingdeeStatus()) + || KingdeeBfiConstant.KINGDEE_STATUS_INVOICED.equals(entity.getKingdeeStatus())) { + return entity.getKingdeeBillNo(); + } + if (Func.isBlank(entity.getApplicationNo())) throw new ServiceException("推送金蝶开票申请失败:开票申请单号为空"); + // 前置校验:关联正式结算单的金蝶应收单已生成(正式结算开票须先推财务应收单),经 A302-4 按防重键以金蝶侧实际生成为准 + List relations = settlementRelationMapper.selectList( + Wrappers.lambdaQuery() + .eq(InvoiceApplicationSettlement::getInvoiceApplicationId, id)); + if (Func.isEmpty(relations)) throw new ServiceException("推送金蝶开票申请失败:开票申请未关联正式结算单"); + List arSrcCodes = new ArrayList<>(); + Map settlementNoBySrcCode = new LinkedHashMap<>(); + for (InvoiceApplicationSettlement relation : relations) { + FormalSettlement settlement = formalSettlementMapper.selectById(relation.getFormalSettlementId()); + if (settlement == null || Objects.equals(settlement.getIsDeleted(), 1)) { + throw new ServiceException("推送金蝶开票申请失败:正式结算单[" + Func.toStr(relation.getFormalSettlementNo()) + "]不存在"); + } + List details = formalSettlementDetailMapper.selectList( + Wrappers.lambdaQuery() + .eq(FormalSettlementDetail::getFormalSettlementId, settlement.getId()) + .eq(FormalSettlementDetail::getIsDeleted, 0) + .orderByAsc(FormalSettlementDetail::getLineNo)); + String srcCode = kingdeeArBillSyncSupport.arBillSrcCode(settlement, details); + arSrcCodes.add(srcCode); + settlementNoBySrcCode.put(srcCode, Func.toStr(settlement.getFormalSettlementNo())); + } + Map existArNos = kingdeeArBillSyncSupport.queryArBillExistNos(arSrcCodes); + for (Map.Entry entry : settlementNoBySrcCode.entrySet()) { + if (Func.isBlank(existArNos.get(entry.getKey()))) { + throw new ServiceException("推送金蝶开票申请失败:正式结算单[" + entry.getValue() + + "]财务应收单未在金蝶生成,请先在正式付款单页面同步金蝶"); + } + } + // 上游来源单据号(awrp_src_con_no):首张关联结算单的金蝶应收单防重键 awrp_src_code + // (联调确认金蝶源单校验按防重键匹配,传单据编号会报「源单编号不存在」),多张结算单取第一张 + String firstArSrcCode = arSrcCodes.get(0); + // 加载发票张次与商品行:每张发票张次对应一张金蝶开票申请单(按张次拆分开票) + List sheets = sheetMapper.selectList(Wrappers.lambdaQuery() + .eq(InvoiceApplicationSheet::getInvoiceApplicationId, id).orderByAsc(InvoiceApplicationSheet::getSheetNo)); + if (Func.isEmpty(sheets)) { + throw new ServiceException("推送金蝶开票申请失败:开票申请无发票张次"); + } + Map> linesBySheetId = lineMapper.selectList(Wrappers.lambdaQuery() + .eq(InvoiceApplicationLine::getInvoiceApplicationId, id)).stream() + .collect(Collectors.groupingBy(InvoiceApplicationLine::getInvoiceSheetId)); + // 每张发票的金蝶业务单据编号(A303-1 billno 候选键,须唯一):单张=开票申请单号(兼容存量),多张=开票申请单号-张次序号 + List sheetBillNos = sheets.stream().map(sheet -> this.sheetBillNo(entity, sheet, sheets.size())).toList(); + // A303-4 幂等预检:按全部候选 billno 查询金蝶是否已生成开票申请单(防上次推送成功但本地回滚的断链重推), + // 已生成的张次幂等跳过,未生成的张次重新推送 + BfiInvoiceApplyQueryDTO idempotentQuery = new BfiInvoiceApplyQueryDTO(); + idempotentQuery.setPageNo(1); + idempotentQuery.setPageSize(Math.max(sheets.size(), 10)); + idempotentQuery.setBillno(sheetBillNos); + Set existSheetBillNos = new LinkedHashSet<>(); + BfiPageVO existPage = bfiService.queryInvoiceApply(idempotentQuery); + if (existPage != null && Func.isNotEmpty(existPage.getRows())) { + for (BfiInvoiceApplyVO existApply : existPage.getRows()) { + if (existApply != null && Func.isNotBlank(existApply.getBillno()) + && sheetBillNos.contains(existApply.getBillno())) { + existSheetBillNos.add(existApply.getBillno()); + // 金蝶已生成:直接回填视为同步成功(勾稽纠偏),星瀚内码一并回填(A902 附件上传定位用) + log.info("开票申请[{}]经 A303-4 查得金蝶开票申请单已生成(单据编号[{}],星瀚内码[{}]),跳过重复推送直接回填", + entity.getApplicationNo(), existApply.getBillno(), existApply.getId()); + entity.setKingdeeBillNo(existApply.getBillno()); + entity.setKingdeeBillId(existApply.getId() == null ? null : String.valueOf(existApply.getId())); + entity.setKingdeeStatus(KingdeeBfiConstant.KINGDEE_STATUS_SYNCED); + entity.setSyncedTime(LocalDateTime.now()); + updateById(entity); + record(entity.getId(), "sync", "同步金蝶(勾稽回填)", TransportBusiConstant.APPROVED, TransportBusiConstant.APPROVED, null, existApply.getBillno()); + return existApply.getBillno(); + } + } + } + // 组装 A303-1 开票申请单(字段映射见设计文档 6.3/6.4) + if (Func.isBlank(entity.getIssuerName())) { + throw new ServiceException("推送金蝶开票申请失败:开票单位(销方)为空"); + } + // 销方税号:按开票单位走客商档案取统一社会信用代码(与 A303-5 查询同源) + CustomerArchive seller = findCustomer(entity.getIssuerName()); + if (seller == null || Func.isBlank(seller.getUnifiedCreditCode())) { + throw new ServiceException("推送金蝶开票申请失败:开票单位[" + Func.toStr(entity.getIssuerName()) + + "]未在客商档案中维护统一社会信用代码,请补充后重试"); + } + // 销方地址电话:数电专票金蝶强制校验(联调确认),取开票单位客商档案注册地址+联系电话 + String salerAddr = (Func.toStr(seller.getRegisteredAddress()).trim() + " " + Func.toStr(seller.getContactPhone()).trim()).trim(); + if (Func.isBlank(salerAddr)) { + throw new ServiceException("推送金蝶开票申请失败:开票单位[" + Func.toStr(entity.getIssuerName()) + + "]未在客商档案中维护注册地址与联系电话,请补充后重试"); + } + // 销方开户行及账号:数电专票金蝶强制校验(联调确认),取开票单位客商档案启用开票信息(默认优先)的开户行+银行账号 + String salerBank = activeInvoiceInfoVOs(seller.getId()).stream() + .map(info -> (Func.toStr(info.getBankName()).trim() + " " + Func.toStr(info.getBankAccount()).trim()).trim()) + .filter(Func::isNotBlank).findFirst().orElse(""); + if (Func.isBlank(salerBank)) { + throw new ServiceException("推送金蝶开票申请失败:开票单位[" + Func.toStr(entity.getIssuerName()) + + "]未在客商开票信息中维护开户行与银行账号,请补充后重试"); + } + if (Func.isBlank(entity.getDepartmentEmails())) throw new ServiceException("推送金蝶开票申请失败:部门邮箱(发票推送邮箱)不能为空"); + if (Func.isBlank(entity.getTaxpayerNo())) throw new ServiceException("推送金蝶开票申请失败:购方纳税人识别号不能为空"); + Dept dept = entity.getDeptId() == null ? null : SysCache.getDept(entity.getDeptId()); + if (dept == null || Func.isBlank(dept.getKingdeeDeptCode())) { + throw new ServiceException("推送金蝶开票申请失败:申请部门[" + Func.toStr(entity.getDeptId()) + "]未维护金蝶组织编码,请检查部门数据"); + } + CustomerArchive customer = entity.getReceiverCustomerId() == null ? null : customerArchiveMapper.selectById(entity.getReceiverCustomerId()); + if (customer == null || Func.isBlank(customer.getCustomerCode())) { + throw new ServiceException("推送金蝶开票申请失败:受票方客户未在客商档案中维护客商编码,请补充后重试"); + } + // 逐张发票推送金蝶开票申请单:每张发票张次一张(价税合计=本张金额,分录=本张商品行),已存在的张次幂等跳过 + List savedBillNos = new ArrayList<>(); + List sheetsToUpdate = new ArrayList<>(); + boolean pushedNew = false; + for (InvoiceApplicationSheet sheet : sheets) { + String sheetBillNo = this.sheetBillNo(entity, sheet, sheets.size()); + if (existSheetBillNos.contains(sheetBillNo)) { + // 金蝶已生成:本张幂等回填(勾稽纠偏),不重复推送 + sheet.setKingdeeBillNo(sheetBillNo); + savedBillNos.add(sheetBillNo); + log.info("金蝶开票申请已生成:开票申请[{}]发票张[{}],billno=[{}],幂等跳过推送", + entity.getApplicationNo(), sheet.getSheetNo(), sheetBillNo); + sheetsToUpdate.add(sheet); + continue; + } + // 本张商品行分录:逐「开票内容」商品行一条(金额/税率透传行值,张内行号升序) + List items = linesBySheetId.getOrDefault(sheet.getId(), List.of()).stream() + .sorted(Comparator.comparing(InvoiceApplicationLine::getLineNo, + Comparator.nullsLast(Comparator.naturalOrder()))) + .map(this::buildInvoiceApplyEntry) + .toList(); + if (Func.isEmpty(items)) { + throw new ServiceException("推送金蝶开票申请失败:发票张[" + Func.toStr(sheet.getSheetNo()) + + "]无商品行,无法组装分录"); + } + BfiInvoiceApplySaveDTO param = this.buildInvoiceApplyParam(entity, sheetBillNo, seller, salerAddr, salerBank, + dept, customer, firstArSrcCode, money(sheet.getInvoiceAmount()), items); + log.info("金蝶开票申请推送:开票申请[{}]发票张[{}],billno=[{}],价税合计=[{}],分录{}条", + entity.getApplicationNo(), sheet.getSheetNo(), sheetBillNo, sheet.getInvoiceAmount(), items.size()); + BfiBillSaveResultVO saveResult = bfiService.saveInvoiceApply(param); + kingdeeApBillSyncSupport.requireBfiSaveSuccess(saveResult, + "推送金蝶开票申请发票张[" + Func.toStr(sheet.getSheetNo()) + "]"); + String kingdeeNo = saveResult.getResult().get(0).getNumber(); + sheet.setKingdeeBillNo(kingdeeNo); + savedBillNos.add(kingdeeNo); + pushedNew = true; + sheetsToUpdate.add(sheet); + } + if (Func.isEmpty(savedBillNos)) { + throw new ServiceException("推送金蝶开票申请失败:无发票张次可推送"); + } + // 批量回写每张发票张次的金蝶单号,避免逐张单次更新(N+1) + if (!sheetsToUpdate.isEmpty()) { + Db.updateBatchById(sheetsToUpdate); + } + String joinedBillNos = String.join(",", savedBillNos); + entity.setKingdeeBillNo(joinedBillNos); + entity.setKingdeeStatus(KingdeeBfiConstant.KINGDEE_STATUS_SYNCED); entity.setSyncedTime(LocalDateTime.now()); updateById(entity); - record(entity.getId(), "sync", "同步金蝶", entity.getApprovalStatus(), entity.getApprovalStatus(), null, kingdeeNo); - return kingdeeNo; + record(entity.getId(), "sync", pushedNew ? "同步金蝶" : "同步金蝶(勾稽回填)", + TransportBusiConstant.APPROVED, TransportBusiConstant.APPROVED, null, joinedBillNos); + return joinedBillNos; + } + + /** + * 单张发票的金蝶业务单据编号(A303-1 billno 候选键,须唯一): + * 单张发票=开票申请单号(兼容存量推送口径),多张发票=开票申请单号-张次序号 + * + * @param entity 开票申请 + * @param sheet 发票张次 + * @param sheetCount 发票张次数 + * @return 金蝶业务单据编号 + */ + private String sheetBillNo(InvoiceApplication entity, InvoiceApplicationSheet sheet, int sheetCount) { + if (sheetCount <= 1) { + return entity.getApplicationNo(); + } + return entity.getApplicationNo() + "-" + sheet.getSheetNo(); + } + + /** + * 组装 A303-1 单张开票申请单入参(字段映射见设计文档 6.3/6.4), + * 购销方等主表公共字段随开票申请取数,价税合计与单据体分录取本张发票 + * + * @param firstArSrcCode 上游来源单据号:首张关联结算单的金蝶应收单防重键 awrp_src_code(多张结算单取第一张) + */ + private BfiInvoiceApplySaveDTO buildInvoiceApplyParam(InvoiceApplication entity, String billNo, + CustomerArchive seller, String salerAddr, String salerBank, Dept dept, CustomerArchive customer, + String firstArSrcCode, BigDecimal sheetAmount, List items) { + BfiInvoiceApplySaveDTO param = new BfiInvoiceApplySaveDTO(); + // ==================== 以下为【必填】字段(BfiInvoiceApplySaveDTO 标注),非必填字段先注释保留 ==================== + // 【必填】组织.编码:申请部门 deptId → 金蝶组织编码 + param.setOrgidNumber(dept.getKingdeeDeptCode()); + // 【必填】申请部门.编码:同上 + param.setAwrpOrgfieldNumber(dept.getKingdeeDeptCode()); + // 【必填】业务单据编号(候选键):单张发票候选 billno(多张发票时开票申请单号-张次序号) + param.setBillno(billNo); + // 【必填】单据日期:申请日期 yyyy-MM-dd HH:mm:ss + LocalDate billDate = entity.getApplicationDate() == null ? LocalDate.now() : entity.getApplicationDate(); + param.setBilldate(billDate.atStartOfDay().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); + // 【必填】单据性质:1 正数 + param.setBillproperties("1"); + // 【必填】发票种类:数电普票 10xdp / 数电专票 08xdp + param.setInvoicetype("electronic_special".equals(entity.getInvoiceType()) + ? KingdeeBfiConstant.BFI_INVOICE_TYPE_DIGITAL_SPECIAL : KingdeeBfiConstant.BFI_INVOICE_TYPE_DIGITAL_GENERAL); + // 【必填】征税方式:0 普通征税 + param.setTaxationstyle("0"); + // 【必填】发票推送邮箱:部门邮箱 + param.setBuyeremail(entity.getDepartmentEmails()); + // 【必填】购方企业类型:0 企业 + param.setBuyerproperty("0"); + // 【必填】购方纳税人识别号:受票方开票信息快照 + param.setBuyertaxno(entity.getTaxpayerNo()); + // 【必填】购方名称:受票方单位 + param.setBuyername(entity.getReceiverName()); + // 购方地址及电话:受票方开票信息快照注册地址+联系电话;同步打开票面展示开关 + param.setBuyeraddr((Func.toStr(entity.getRegisteredAddress()).trim() + " " + Func.toStr(entity.getContactPhone()).trim()).trim()); + param.setShowbuyeraddresstel(true); + // 购方开户行及账号:受票方开票信息快照开户行+银行账号;同步打开票面展示开关 + param.setBuyerbank((Func.toStr(entity.getBankName()).trim() + " " + Func.toStr(entity.getBankAccount()).trim()).trim()); + param.setShowbuyerbank(true); + // 【必填】销方名称:开票单位快照(结算单收款方带出) + param.setSalername(entity.getIssuerName()); + // 【必填】销方纳税人识别号:开票单位 → 客商档案统一社会信用代码(A303-5 查询同源) + param.setSalertaxno(seller.getUnifiedCreditCode().trim()); + // 销方地址及电话:数电专票强制校验(联调确认),客商档案注册地址+联系电话;同步打开票面展示开关 + param.setSaleraddr(salerAddr); + param.setShowsaleraddresstel(true); + // 销方开户行及账号:数电专票强制校验(联调确认),客商开票信息开户行+银行账号;同步打开票面展示开关 + param.setSalerbank(salerBank); + param.setShowsalerbank(true); + // 【必填】申请价税合计:本张发票开票金额 + param.setTotalamount(sheetAmount); + // 是否含税:金蝶要求统一按不含税口径送(联调反馈),固定 0 不含税 + param.setHsbz("0"); + // 特殊票种:金蝶要求映射(联调反馈)——本张发票商品行全部为货物运输(运费/货物运输)送 E04,其余(其他、非货物运输的运输类等)送 00 非特殊票种 + param.setSpecialtype(this.specialTypeOf(items)); + // 货物运输单据体:送 E04 时随单填报(车牌/起运地/到达地/货物名称取开票申请结算明细快照),数据为空不阻断推送 + if (KingdeeBfiConstant.BFI_INVOICE_SPECIAL_TYPE_FREIGHT.equals(param.getSpecialtype())) { + List freights = this.buildFreights(entity); + if (Func.isNotEmpty(freights)) { + param.setFreights(freights); + } else { + log.warn("金蝶开票申请推送:开票申请[{}]按货物运输特殊票种(E04)推送但无结算明细快照,货物运输单据体未填报", + entity.getApplicationNo()); + } + } + // 【必填】客户.编码:受票方客商档案客商编码 + param.setCustomnameidNumber(customer.getCustomerCode()); + // 【必填】项目.项目编码(联调期临时传金蝶测试档案值 999) + param.setAwrpProjectNumber(KingdeeBfiConstant.BFI_PROJECT_NUMBER_NONE); + // ==================== 以下为非必填字段,先注释保留,联调确认后按需放开 ==================== + // 申请金额(不含税):分录不含税合计(放开时需按分摊后分录汇总) + // param.setInvoiceamount(invoiceamount); + // 申请税额:分录税额合计(放开时需按分摊后分录汇总) + // param.setTotaltax(totaltax); + // 数据来源:固定 3 + // param.setBillsource("3"); + + // 来源单据:ar_finarbill 财务应收单(金蝶联调确认开票申请须挂上游应收单) + param.setAwrpSrcBt(KingdeeBfiConstant.BFI_AR_BILL_SRC_BT); + // 上游单据号:首张关联结算单的金蝶应收单防重键 awrp_src_code(联调确认金蝶按防重键匹配源单,A302-4 预检已确认存在) + param.setAwrpSrcConNo(firstArSrcCode); + + // 数据来源系统 + param.setSystemsource(bfiProperties.getSrcSys()); + + // 单据体分录:本张发票「开票内容」商品行(联调确认金蝶强校验「单据体分录不能为空」,实际必传) + param.setSimOriginalBillItem(items); + return param; + } + + /** 单条商品行生成一条开票分录:品目三要素、税率、金额、税额全部透传页面「开票内容」行值 */ + private BfiInvoiceApplySaveEntryDTO buildInvoiceApplyEntry(InvoiceApplicationLine line) { + // 税收分类编码:直接取商品行「开票内容」选择的开票项目快照 + if (Func.isBlank(line.getTaxClassificationCode())) { + throw new ServiceException("推送金蝶开票申请失败:商品行[" + Func.toStr(line.getGoodsCategory()) + "/" + Func.toStr(line.getGoodsName()) + + "]缺少税收分类编码,请编辑开票申请重新选择开票内容"); + } + if (line.getTaxRate() == null) { + throw new ServiceException("推送金蝶开票申请失败:商品行[" + Func.toStr(line.getGoodsName()) + + "]缺少税率,请编辑开票申请补全税率后重试"); + } + String taxClassificationCode = line.getTaxClassificationCode().trim(); + // // todo test + // taxClassificationCode = "304060299"; + + // 税率:页面维护的 0-100 百分数转小数(8 -> "0.08") + BigDecimal taxRate = money(line.getTaxRate()).divide(BigDecimal.valueOf(100), 6, RoundingMode.HALF_UP).stripTrailingZeros(); + String taxRateText = taxRate.compareTo(BigDecimal.ZERO) == 0 ? "0.00" : taxRate.toPlainString(); + // 含税金额/不含税金额/税额/数量/单位:取商品行(数量为0兜底1) + BigDecimal taxAmount = money(line.getTotalAmount()); + BigDecimal amountNoTax = money(line.getAmountNoTax()); + BigDecimal tax = money(line.getTaxAmount()); + BigDecimal quantity = line.getQuantity() != null && line.getQuantity().compareTo(BigDecimal.ZERO) > 0 + ? line.getQuantity() : BigDecimal.ONE; + BfiInvoiceApplySaveEntryDTO item = new BfiInvoiceApplySaveEntryDTO(); + // 【必填】行性质:2 商品行(固定;联调校准项) + item.setRowtype("2"); + // 【必填】基准类型:0 数量基准(固定,按数量开票) + item.setBenchmark("0"); + // 【必填】商品名称:商品行「货物或服务简称」 + item.setGoodsname(line.getGoodsName()); + // 【必填】货物或服务简称(商品和服务分类):商品行「商品和服务分类」 + item.setGoodssimplename(line.getGoodsCategory()); + // 税收分类编码.编码 + 税收分类合并编码(联调确认均必传,同取商品行开票内容快照) + item.setTaxratecodeidNumber(taxClassificationCode); + item.setGoodscode(taxClassificationCode); + // 数量/计量单位:商品行数量(兜底1)与单位(空兜底「个」) + item.setNum(quantity); + item.setUnit(Func.isBlank(line.getUnit()) ? "个" : line.getUnit()); + // 单价(不含税):商品行不含税单价;单价(含税):含税金额 ÷ 数量 + item.setUnitprice(money(line.getUnitPriceNoTax())); + // item.setTaxunitprice(taxAmount.divide(quantity, 6, RoundingMode.HALF_UP)); + // 金额(含税)/金额(不含税)/税额:商品行价税合计、不含税金额、税额 + // item.setTaxamount(taxAmount); + item.setAmount(amountNoTax); + item.setTax(tax); + // 税率:商品行税率(0-100 百分数转小数) 蝶税率枚举只有 0.13/0.09/0.06/0.05/0.03/0.01/0 + item.setTaxrate(taxRateText); + // 备注:商品行备注 + item.setRemark(line.getRemark()); + // 规格型号/费用项目.编码/优惠政策/剩余可申请/商品编码/物料编码/明细扣除额/上游分录号:本期不传 + // 【必填】外部系统来源分录标识(候选键):商品行id(雪花id天然唯一) + item.setAwrpEntryCode(String.valueOf(line.getId())); + // 【必填】分录外部单据类型:固定 0KzXs(配置 thirdParty.bfi.invoiceApply.srcType) + item.setAwrpSrcType(bfiProperties.getInvoiceApply().getSrcType()); + log.info("金蝶开票申请分录生成:商品行[{}],分类[{}],简称[{}],税收分类编码[{}],税率[{}],数量[{}],含税金额[{}],不含税金额[{}],税额[{}],分录标识[{}]", + line.getId(), item.getGoodssimplename(), item.getGoodsname(), item.getTaxratecodeidNumber(), taxRateText, + quantity, taxAmount, amountNoTax, tax, item.getAwrpEntryCode()); + return item; + } + + /** + * 特殊票种映射(金蝶联调反馈):本张发票商品行全部为货物运输类送 E04(货物运输),其余送 00(非特殊票种)。 + * 货物运输类按商品名称/商品和服务分类简称命中关键字({@link KingdeeBfiConstant#BFI_INVOICE_FREIGHT_KEYWORDS})判定; + * 开票端已限制运费不得与其他费用合并同张,此处按全张兜底判定,含非运费行的发票一律按非特殊票种送 + * + * @param items 本张发票分录(与 A303-1 报文同源) + * @return 特殊票种编码(E04 货物运输 / 00 非特殊票种) + */ + private String specialTypeOf(List items) { + boolean allFreight = items.stream() + .allMatch(item -> this.isFreightGoods(item.getGoodsname(), item.getGoodssimplename())); + return allFreight ? KingdeeBfiConstant.BFI_INVOICE_SPECIAL_TYPE_FREIGHT : KingdeeBfiConstant.BFI_INVOICE_SPECIAL_TYPE_NONE; + } + + /** + * 商品行是否货物运输类:商品名称或商品和服务分类简称含关键字(运费/货物运输)即视为命中; + * 其他费用及非货物运输的运输类(如按简称“运输”维护的开票项目)不命中,随全张送非特殊票种 00 + * + * @param goodsname 商品名称(开票项目简称,如 运费) + * @param goodssimplename 商品和服务分类简称(开票项目分类名) + * @return true 表示货物运输类 + */ + private boolean isFreightGoods(String goodsname, String goodssimplename) { + String goodsText = Func.toStr(goodsname).trim() + Func.toStr(goodssimplename).trim(); + return KingdeeBfiConstant.BFI_INVOICE_FREIGHT_KEYWORDS.stream().anyMatch(goodsText::contains); + } + + /** + * 组装 A303-1 货物运输单据体(特殊票种 E04 随单报送)。 + *

+ * 金蝶数电货运发票要求随单报送运输工具信息,取数与组装规则: + *

    + *
  • 数据来源:开票申请结算明细快照(blade_invoice_application_detail,制单时自正式结算明细快照,含车牌/线路/货物运输信息)
  • + *
  • 字段映射:vehicleNo→licenseplate(运输工具牌号)、departureAddress→startplace(起运地)、 + * arrivalAddress→endplace(到达地)、cargoName→transportgoods(运输货物名称)、 + * transportType→transporttype(运输工具种类,字典键翻译后映射,见 {@link #kingdeeTransportTypeOf(String)})
  • + *
  • 行归并:结算明细按车次成行,同一「车牌×起运地×到达地×货物×运输工具种类」的多笔明细只报送一条
  • + *
  • 空行剔除:牌号/起运地/到达地/货物名称均为空的明细行不报送(快照缺运输信息),避免金蝶侧空行报错
  • + *
+ * + * @param entity 开票申请 + * @return 货物运输单据体分录(无可用快照数据时返回空列表,由调用方决定是否告警) + */ + private List buildFreights(InvoiceApplication entity) { + List details = applicationDetailMapper.selectList( + Wrappers.lambdaQuery() + .eq(InvoiceApplicationDetail::getInvoiceApplicationId, entity.getId())); + // 按运程归并键去重:结算明细按车次成行,同一运程多笔明细金蝶侧只需一条(LinkedHashMap 保持明细行序) + Map freightByKey = new LinkedHashMap<>(); + int emptyRowSkipCount = 0; + for (InvoiceApplicationDetail detail : details) { + BfiInvoiceApplySaveFreightDTO freight = new BfiInvoiceApplySaveFreightDTO(); + // 运输工具种类:字典 transport_type 键翻译后映射合法枚举,未命中兜底其它运输工具 + freight.setTransporttype(this.kingdeeTransportTypeOf(detail.getTransportType())); + // 运输工具牌号/运输货物名称/起运地/到达地:结算明细快照直传 + freight.setLicenseplate(detail.getVehicleNo()); + freight.setTransportgoods(detail.getCargoName()); + freight.setStartplace(detail.getDepartureAddress()); + freight.setEndplace(detail.getArrivalAddress()); + // 全空明细行(快照缺运输信息)不报送,避免金蝶侧空行报错 + if (Func.isBlank(freight.getLicenseplate()) && Func.isBlank(freight.getStartplace()) + && Func.isBlank(freight.getEndplace()) && Func.isBlank(freight.getTransportgoods())) { + emptyRowSkipCount++; + continue; + } + String dedupeKey = Func.toStr(freight.getLicenseplate()) + "|" + Func.toStr(freight.getStartplace()) + + "|" + Func.toStr(freight.getEndplace()) + "|" + Func.toStr(freight.getTransportgoods()) + + "|" + Func.toStr(freight.getTransporttype()); + freightByKey.putIfAbsent(dedupeKey, freight); + } + List freights = new ArrayList<>(freightByKey.values()); + log.info("金蝶开票申请货物运输单据体组装:开票申请[{}],结算明细快照{}条,剔除空行{}条,去重归并后分录{}条", + entity.getApplicationNo(), details.size(), emptyRowSkipCount, freights.size()); + freights.forEach(freight -> log.info("金蝶开票申请货物运输分录:开票申请[{}],运输工具种类[{}],牌号[{}],货物[{}],起运地[{}],到达地[{}]", + entity.getApplicationNo(), freight.getTransporttype(), freight.getLicenseplate(), + freight.getTransportgoods(), freight.getStartplace(), freight.getEndplace())); + return freights; + } + + /** + * 运输工具种类映射:结算明细快照存字典 transport_type 键,按业务/系统字典翻译为名称 + * (复用运单展示口径 {@link WaybillWrapper#transportTypeName(String)}), + * 翻译名称命中 A303-1 合法枚举(铁路/公路/水路/航空/管道/其它运输工具)直送,否则兜底其它运输工具 + * + * @param transportType 结算明细快照运输方式(字典键) + * @return 金蝶运输工具种类 + */ + private String kingdeeTransportTypeOf(String transportType) { + String name = WaybillWrapper.transportTypeName(transportType); + return KingdeeBfiConstant.BFI_FREIGHT_TRANSPORT_TYPES.contains(name) + ? name : KingdeeBfiConstant.BFI_FREIGHT_TRANSPORT_TYPE_OTHER; + } + + @Override + public String syncKingdeeInvoiceResult() { + return kingdeeSyncLockSupport.runSingleFlight(INVOICE_RESULT_SYNC_LOCK_PREFIX, bfiProperties.getInvoiceResult().getLockLeaseSeconds(), + "金蝶开票结果回写", this::executeInvoiceResultSync); + } + + /** + * 金蝶开票结果回写主体:A303-5 分批查询开票申请发票结果落库并推进开票完成, + * 由 {@link #syncKingdeeInvoiceResult()} 在分布式锁保护下调用 + * + * @return 执行摘要 + */ + private String executeInvoiceResultSync() { + // 待回写开票申请:审批通过 + 已同步(invoiced 为终态不再轮询;failed 无真实金蝶单号不参与查询,纠偏走重推预检) + List applications = list(Wrappers.lambdaQuery() + .eq(InvoiceApplication::getApprovalStatus, TransportBusiConstant.APPROVED) + .eq(InvoiceApplication::getKingdeeStatus, KingdeeBfiConstant.KINGDEE_STATUS_SYNCED) + .isNotNull(InvoiceApplication::getKingdeeBillNo) + .ne(InvoiceApplication::getKingdeeBillNo, "") + .orderByAsc(InvoiceApplication::getId)); + if (Func.isEmpty(applications)) { + return "金蝶开票结果回写:无待回写开票申请"; + } + log.info("金蝶开票结果回写开始:待回写开票申请{}笔", applications.size()); + // 按 serialNo(金蝶开票申请单编号)分批(≤50/批)调 A303-5,销方税号与 A303-1 同源(开票单位→客商档案),按税号分组查询 + Map applicationBySerialNo = new LinkedHashMap<>(); + applications.forEach(application -> { + if (Func.isNotBlank(application.getKingdeeBillNo())) { + // 多张发票时申请单金蝶单号逗号拼接存储,拆分为逐张单号分别归集回写 + for (String serialNo : application.getKingdeeBillNo().split(",")) { + if (Func.isNotBlank(serialNo)) { + applicationBySerialNo.putIfAbsent(serialNo.trim(), application); + } + } + } + }); + Map sellerCache = new LinkedHashMap<>(); + Map> serialNosBySellerTaxNo = new LinkedHashMap<>(); + int unresolved = 0; + for (Map.Entry entry : applicationBySerialNo.entrySet()) { + InvoiceApplication application = entry.getValue(); + String issuerName = Func.toStr(application.getIssuerName()).trim(); + if (Func.isBlank(issuerName)) { + unresolved++; + log.warn("金蝶开票结果回写:开票申请[{}]开票单位为空,跳过", application.getApplicationNo()); + continue; + } + CustomerArchive seller = sellerCache.computeIfAbsent(issuerName, this::findCustomer); + String sellerTaxNo = seller == null ? null : Func.toStr(seller.getUnifiedCreditCode()).trim(); + if (Func.isBlank(sellerTaxNo)) { + unresolved++; + log.warn("金蝶开票结果回写:开票申请[{}]开票单位[{}]未在客商档案维护统一社会信用代码,跳过", + application.getApplicationNo(), issuerName); + continue; + } + serialNosBySellerTaxNo.computeIfAbsent(sellerTaxNo, key -> new ArrayList<>()).add(entry.getKey()); + } + log.info("金蝶开票结果回写:按销方税号分组完成,共{}组,销方税号无法解析跳过{}笔", serialNosBySellerTaxNo.size(), unresolved); + int invoiceWritten = 0; + int failed = 0; + int invoicedCount = 0; + for (Map.Entry> group : serialNosBySellerTaxNo.entrySet()) { + List groupSerialNos = group.getValue(); + for (int from = 0; from < groupSerialNos.size(); from += INVOICE_RESULT_QUERY_BATCH) { + List batch = groupSerialNos.subList(from, Math.min(from + INVOICE_RESULT_QUERY_BATCH, groupSerialNos.size())); + String batchSerialNos = String.join(",", batch); + log.info("金蝶开票结果回写批次查询:销方税号=[{}],单据编号=[{}],数量={}", group.getKey(), batchSerialNos, batch.size()); + try { + BfiInvoiceApplyOpenApiQueryDTO param = new BfiInvoiceApplyOpenApiQueryDTO(); + param.setSerialNo(batchSerialNos); + param.setSellerTaxpayerId(group.getKey()); + BfiOpenApiResultVO> result = bfiService.queryInvoiceApplyInvoice(param); + if (result == null || !result.isSuccess() || result.getData() == null) { + failed++; + log.warn("金蝶开票结果回写批次查询未成功:serialNo=[{}]", batchSerialNos); + continue; + } + log.info("金蝶开票结果回写批次查询成功:serialNo=[{}],返回单据数={}", batchSerialNos, result.getData().size()); + // 批量查询当前批次已落库的发票记录(按发票号索引,用于幂等去重与缺文件地址补齐) + Map existingInvoiceMap = batchQueryExistingInvoices(batch); + log.debug("金蝶开票结果回写:批次已存在发票记录数={}", existingInvoiceMap.size()); + // 收集待插入的发票记录、待补文件地址的已有发票记录和待更新的开票申请 + List toInsertInvoices = new ArrayList<>(); + List toPatchInvoices = new ArrayList<>(); + List toUpdateApplications = new ArrayList<>(); + for (BfiInvoiceApplyOpenApiBillVO bill : result.getData()) { + String billNo = bill.getBillNo(); + InvoiceApplication application = applicationBySerialNo.get(billNo); + if (application == null || bill.getInvoiceList() == null) { + continue; + } + int inserted = 0; + for (BfiInvoiceApplyOpenApiInvoiceVO invoice : bill.getInvoiceList()) { + String invoiceNo = invoice.getInvoiceNumber(); + if (Func.isBlank(invoiceNo)) { + continue; + } + InvoiceApplicationInvoice existing = existingInvoiceMap.get(invoiceNo); + if (existing == null) { + InvoiceApplicationInvoice record = new InvoiceApplicationInvoice(); + record.setInvoiceApplicationId(application.getId()); + record.setSerialNo(billNo); + record.setInvoiceNo(invoiceNo); + record.setInvoiceDate(parseInvoiceDateTimestamp(invoice.getInvoiceDate())); + record.setInvoiceAmount(invoice.getIncludeTaxAmount()); + // 发票文件地址独立落库:板式文件/预览图/数电PDF/数电XML,供正式结算单详情查看下载 + record.setInvoiceFileUrl(invoice.getInvoiceFileUrl()); + record.setInvoiceImageUrl(invoice.getInvoiceImageUrl()); + record.setInvoicePdfFileUrl(invoice.getInvoicePdfFileUrl()); + record.setInvoiceXmlFileUrl(invoice.getInvoiceXmlFileUrl()); + record.setRawJson(JsonUtil.toJson(invoice)); + toInsertInvoices.add(record); + inserted++; + // 标记为已存在,避免同批次重复插入 + existingInvoiceMap.put(invoiceNo, record); + log.info("金蝶开票结果回写:写入发票,申请单号=[{}],单据编号=[{}],发票号码=[{}],含税金额=[{}]", + application.getApplicationNo(), billNo, invoiceNo, invoice.getIncludeTaxAmount()); + } else if (fillMissingInvoiceFileUrls(existing, invoice)) { + // 历史已落库但缺文件地址的记录按本次返回补齐(字段级补空,不覆盖已有值) + toPatchInvoices.add(existing); + log.info("金蝶开票结果回写:补齐发票文件地址,申请单号=[{}],单据编号=[{}],发票号码=[{}]", + application.getApplicationNo(), billNo, invoiceNo); + } + } + if (inserted > 0) { + invoiceWritten += inserted; + // 查到发票:开票申请单推进开票完成(终态) + application.setKingdeeStatus(KingdeeBfiConstant.KINGDEE_STATUS_INVOICED); + toUpdateApplications.add(application); + invoicedCount++; + log.info("金蝶开票结果回写:开票申请[{}]状态推进为invoiced,本次写入发票{}张", application.getApplicationNo(), inserted); + } + } + // 批量插入发票记录 + if (!toInsertInvoices.isEmpty()) { + Db.saveBatch(toInsertInvoices); + log.info("金蝶开票结果回写:批量插入发票记录{}条", toInsertInvoices.size()); + } + // 批量补齐历史记录缺失的发票文件地址 + if (!toPatchInvoices.isEmpty()) { + Db.updateBatchById(toPatchInvoices); + log.info("金蝶开票结果回写:批量补齐发票文件地址{}条", toPatchInvoices.size()); + } + // 批量更新开票申请状态 + if (!toUpdateApplications.isEmpty()) { + updateBatchById(toUpdateApplications); + log.info("金蝶开票结果回写:批量更新开票申请状态{}笔", toUpdateApplications.size()); + } + } catch (Exception exception) { + failed++; + log.error("金蝶开票结果回写批次异常:serialNo=[{}]", batchSerialNos, exception); + } + } + } + log.info("金蝶开票结果回写完成:待查开票申请{}笔(销方税号无法解析跳过{}笔),回写发票{}张(开票完成{}笔,失败批次{}个)", + applications.size(), unresolved, invoiceWritten, invoicedCount, failed); + return "金蝶开票结果回写完成:待查开票申请" + applications.size() + "笔(销方税号无法解析跳过" + unresolved + + "笔),回写发票" + invoiceWritten + "张(开票完成" + invoicedCount + "笔,失败批次" + failed + "个)"; + } + + /** + * 批量查询当前批次已落库的发票记录,按发票号索引 + * + * @param serialNos 单据编号列表 + * @return 发票号 → 已落库发票记录 + */ + private Map batchQueryExistingInvoices(List serialNos) { + List existingList = invoiceApplicationInvoiceMapper.selectList( + Wrappers.lambdaQuery() + .in(InvoiceApplicationInvoice::getSerialNo, serialNos)); + return existingList.stream() + .filter(record -> Func.isNotBlank(record.getInvoiceNo())) + .collect(Collectors.toMap(InvoiceApplicationInvoice::getInvoiceNo, Function.identity(), (left, right) -> left)); + } + + /** + * 将 A303-5 返回的发票文件地址补齐到已落库记录(仅补空字段,不覆盖已有值) + * + * @param record 已落库发票记录 + * @param invoice A303-5 返回发票明细 + * @return 发生补齐时返回 true + */ + private boolean fillMissingInvoiceFileUrls(InvoiceApplicationInvoice record, BfiInvoiceApplyOpenApiInvoiceVO invoice) { + boolean patched = false; + if (Func.isBlank(record.getInvoiceFileUrl()) && Func.isNotBlank(invoice.getInvoiceFileUrl())) { + record.setInvoiceFileUrl(invoice.getInvoiceFileUrl()); + patched = true; + } + if (Func.isBlank(record.getInvoiceImageUrl()) && Func.isNotBlank(invoice.getInvoiceImageUrl())) { + record.setInvoiceImageUrl(invoice.getInvoiceImageUrl()); + patched = true; + } + if (Func.isBlank(record.getInvoicePdfFileUrl()) && Func.isNotBlank(invoice.getInvoicePdfFileUrl())) { + record.setInvoicePdfFileUrl(invoice.getInvoicePdfFileUrl()); + patched = true; + } + if (Func.isBlank(record.getInvoiceXmlFileUrl()) && Func.isNotBlank(invoice.getInvoiceXmlFileUrl())) { + record.setInvoiceXmlFileUrl(invoice.getInvoiceXmlFileUrl()); + patched = true; + } + return patched; + } + + /** 从票易通原文 Map 中按候选键顺序取首个非空文本值 */ + private String firstText(Map map, String... keys) { + for (String key : keys) { + Object value = map.get(key); + if (value != null && Func.isNotBlank(String.valueOf(value))) { + return String.valueOf(value).trim(); + } + } + return null; + } + + /** 宽松解析开票日期文本(支持 yyyy-MM-dd HH:mm:ss / yyyy-MM-dd / ISO),无法解析返回 null */ + private LocalDateTime parseInvoiceDate(String text) { + if (Func.isBlank(text)) { + return null; + } + String value = text.trim(); + List formatters = List.of( + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"), + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"), + DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm:ss"), + DateTimeFormatter.ofPattern("yyyy/MM/dd"), + DateTimeFormatter.ISO_LOCAL_DATE_TIME, + DateTimeFormatter.ISO_LOCAL_DATE); + for (DateTimeFormatter formatter : formatters) { + try { + if (formatter.equals(DateTimeFormatter.ISO_LOCAL_DATE_TIME) || formatter.toString().contains("HH")) { + return LocalDateTime.parse(value, formatter); + } + return LocalDate.parse(value, formatter).atStartOfDay(); + } catch (Exception ignored) { + // 尝试下一种格式 + } + } + log.warn("金蝶开票结果开票日期解析失败:[{}]", text); + return null; + } + + /** 解析时间戳格式的开票日期(毫秒时间戳),无法解析返回 null */ + private LocalDateTime parseInvoiceDateTimestamp(Long timestamp) { + if (timestamp == null) { + return null; + } + try { + return LocalDateTime.ofInstant(java.time.Instant.ofEpochMilli(timestamp), java.time.ZoneId.systemDefault()); + } catch (Exception exception) { + log.warn("金蝶开票结果开票日期时间戳解析失败:[{}]", timestamp); + return null; + } + } + + /** 宽松解析金额文本,无法解析返回 null */ + private BigDecimal parseDecimal(String text) { + if (Func.isBlank(text)) { + return null; + } + try { + return new BigDecimal(text.trim()).setScale(2, RoundingMode.HALF_UP); + } catch (Exception exception) { + log.warn("金蝶开票结果金额解析失败:[{}]", text); + return null; + } } private InvoiceApplicationVO toListVO(InvoiceApplication entity) { @@ -515,7 +1458,7 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl { InvoiceApplication application = applications.get(relation.getInvoiceApplicationId()); - return application != null && !VOIDED.equals(application.getApprovalStatus()) + return application != null && !TransportBusiConstant.VOIDED.equals(application.getApprovalStatus()) && !Objects.equals(application.getIsDeleted(), 1); }).toList(); } @@ -724,7 +1667,7 @@ public class InvoiceApplicationServiceImpl extends BaseServiceImpl implements IInvoiceReceiptService { - private static final String DRAFT = "draft"; - private static final String REVIEWING = "reviewing"; - private static final String APPROVED = "approved"; - private static final String RETURNED = "returned"; - private static final String VOIDED = "voided"; + /** A801-1 rim 契约单页最大条数 */ + private static final int POOL_PAGE_SIZE = 500; + + /** A801-1 增量窗口翻页保护页数(rim 无总数,防御死循环;触顶说明窗口尾部本轮未处理) */ + private static final int MAX_POOL_PAGES = 50; + + /** A801-1 窗口时间参数格式 */ + private static final java.time.format.DateTimeFormatter POOL_TIME_FORMATTER = java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + /** 金蝶全票池同步 single-flight 锁键前缀(多副本定时任务与手动触发共用) */ + private static final String POOL_SYNC_LOCK_PREFIX = "transport:invoice-receipt:kingdee-pool-sync:"; + + /** A801-2 文件下载流式拷贝缓冲区大小 */ + private static final int DOWNLOAD_BUFFER_SIZE = 8 * 1024; private final KingdeeInvoicePoolMapper invoicePoolMapper; private final InvoiceReceiptSettlementMapper settlementRelationMapper; private final InvoiceReceiptRecordMapper recordMapper; private final FormalSettlementMapper formalSettlementMapper; + /** 票池认领关系 Mapper,实时视图的 claimed 装饰标记取数来源 */ + private final FormalSettlementInvoiceMapper formalSettlementInvoiceMapper; + private final FormalSettlementDetailMapper formalSettlementDetailMapper; private final CustomerArchiveMapper customerArchiveMapper; private final CustomerContactMapper customerContactMapper; + private final IBfiService bfiService; + private final BfiProperties bfiProperties; + private final KingdeeApBillSyncSupport kingdeeApBillSyncSupport; + private final KingdeeReceivedInvoiceSyncSupport kingdeeReceivedInvoiceSyncSupport; + private final KingdeeAttachmentSyncSupport kingdeeAttachmentSyncSupport; + private final KingdeeSyncLockSupport kingdeeSyncLockSupport; + private final ApplicationEventPublisher eventPublisher; + + /** 金蝶收票单结果回写 single-flight 锁键前缀(多副本定时任务与手动触发共用) */ + private static final String RECEIVED_RESULT_SYNC_LOCK_PREFIX = "transport:invoice-receipt:kingdee-received-result-sync:"; @Override public IPage selectPage(IPage page, InvoiceReceiptVO query) { @@ -97,10 +166,33 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl voPage = page(page, wrapper).convert(this::toListVO); + fillAttachmentSyncStatus(voPage.getRecords()); + return voPage; + } + + /** + * 填充附件同步聚合态(无附件/未上传/部分未传/已上传):同步表按本页 biz_id 一次 GROUP BY 查询,避免 N+1 + * + * @param rows 本页 VO 列表 + */ + private void fillAttachmentSyncStatus(List rows) { + if (Func.isEmpty(rows)) { + return; + } + Map successCounts = kingdeeAttachmentSyncSupport.successCountByBizId( + KingdeeAttachmentConstant.BIZ_INVOICE_RECEIPT, rows.stream().map(InvoiceReceiptVO::getId).toList()); + for (InvoiceReceiptVO vo : rows) { + int attachmentCount = kingdeeAttachmentSyncSupport.countAttachments(vo.getAttachmentsJson()); + String aggregateStatus = kingdeeAttachmentSyncSupport.aggregateStatus(attachmentCount, + successCounts.getOrDefault(vo.getId(), 0)); + vo.setAttachmentCount(attachmentCount); + vo.setAttachmentSyncStatus(aggregateStatus); + vo.setAttachmentSyncStatusName(KingdeeAttachmentConstant.aggregateName(aggregateStatus)); + } } @Override @@ -126,15 +218,93 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl invoicePool(String keyword) { - return invoicePoolMapper.selectList(Wrappers.lambdaQuery() - .eq(KingdeeInvoicePool::getStatus, 1) - .and(Func.isNotEmpty(keyword), wrapper -> wrapper.like(KingdeeInvoicePool::getInvoiceNo, keyword) - .or().like(KingdeeInvoicePool::getIssuerName, keyword) - .or().like(KingdeeInvoicePool::getReceiverName, keyword)) - .orderByDesc(KingdeeInvoicePool::getSourceUpdatedTime) - .orderByDesc(KingdeeInvoicePool::getCreateTime) - .last("limit 200")); + public IPage invoicePoolPage(KingdeeInvoicePoolQueryDTO query) { + long current = query.getCurrent() == null ? 1L : Math.max(query.getCurrent(), 1L); + long size = query.getSize() == null ? 10L : Math.min(Math.max(query.getSize(), 1L), 100L); + query.setCurrent(current); + query.setSize(size); + query.setInvoiceNo(trimOrNull(query.getInvoiceNo())); + query.setSalerName(trimOrNull(query.getSalerName())); + query.setBuyerName(trimOrNull(query.getBuyerName())); + query.setInvoiceType(trimOrNull(query.getInvoiceType())); + query.setSalerTaxNo(trimOrNull(query.getSalerTaxNo())); + query.setBuyerTaxNo(trimOrNull(query.getBuyerTaxNo())); + query.setInvoiceDateStart(normalizeQueryDate(query.getInvoiceDateStart())); + query.setInvoiceDateEnd(normalizeQueryDate(query.getInvoiceDateEnd())); + IPage page = this.selectLocalPoolPage(query, current, size); + // 票号精确查本地未命中(且无认领/登记过滤干扰)→ 窄查一次 A801-1 回源入库再本地重查: + // 兜底增量窗口缝隙内的当日新票;名称模糊本地为空不回源(远程不支持名称条件,回源救不了) + if (page.getRecords().isEmpty() && query.getInvoiceNo() != null + && query.getClaimed() == null && query.getRegistered() == null + && this.fetchInvoicePoolFromRemote(query.getInvoiceNo())) { + page = this.selectLocalPoolPage(query, current, size); + } + log.info("金蝶票池镜像查询 第{}页 {}条/页 返回{}条 total:{}", current, size, page.getRecords().size(), page.getTotal()); + return page; + } + + /** 本地镜像真分页(可用过滤/认领登记标记均在 SQL 内直出) */ + private IPage selectLocalPoolPage(KingdeeInvoicePoolQueryDTO query, long current, long size) { + return invoicePoolMapper.selectInvoicePoolPage(new Page<>(current, size), query, AuthUtil.getTenantId()); + } + + /** + * 按发票号码窄查 A801-1 回源入库(票号全等防御 + 选票同口径可用过滤,不可用票不入镜像)。 + * + * @param invoiceNo 已 trim 的发票号码 + * @return 是否有可用票入库(false:远程未命中或不可用,调用方维持本地空页) + */ + private boolean fetchInvoicePoolFromRemote(String invoiceNo) { + List poolRows = queryPoolByInvoiceNo(invoiceNo); + BfiInvoicePoolVO poolRow = poolRows.stream() + .filter(row -> StringUtil.isNotBlank(row.getInvoiceNo()) + && invoiceNo.equals(row.getInvoiceNo().trim())) + .findFirst() + .orElse(null); + if (poolRow == null) { + return false; + } + if (!this.isUsablePoolRow(poolRow)) { + log.info("金蝶票池回源命中但不可用,不入镜像,发票号码={}", invoiceNo); + return false; + } + KingdeeInvoicePool existing = invoicePoolMapper.selectOne(Wrappers.lambdaQuery() + .eq(KingdeeInvoicePool::getInvoiceNo, invoiceNo)); + upsertInvoicePool(existing, poolRow); + log.info("金蝶票池回源入库完成,发票号码={}", invoiceNo); + return true; + } + + /** + * 选票可用过滤(选票弹窗与保存落镜像同口径):票号非空 + 剩余可用金额>0 + 金额使用状态为 0(未使用); + * 可用金额空白/解析失败由 parseAmount 按 0 兜底,即无法证明可用视为不可用 + */ + private boolean isUsablePoolRow(BfiInvoicePoolVO row) { + if (row == null || StringUtil.isBlank(row.getInvoiceNo())) { + return false; + } + String amountUseStatus = row.getAmountUseStatus(); + if (!KingdeeBfiConstant.POOL_AMOUNT_USE_STATUS_SELECTABLE.equals(amountUseStatus == null ? null : amountUseStatus.trim())) { + return false; + } + return parseAmount(row.getInvoiceNo(), row.getAvailableAmount()).compareTo(BigDecimal.ZERO) > 0; + } + + /** 空白串归一为 null,便于远程查询条件与 MyBatis-Plus 判空 */ + private String trimOrNull(String text) { + return StringUtil.isBlank(text) ? null : text.trim(); + } + + /** 查询日期清洗:空转 null,格式非法直接报业务异常(远端只认 yyyy-MM-dd,先校验再下发) */ + private String normalizeQueryDate(String text) { + if (StringUtil.isBlank(text)) { + return null; + } + try { + return LocalDate.parse(text.trim()).toString(); + } catch (DateTimeParseException exception) { + throw new ServiceException("开票日期格式应为 yyyy-MM-dd"); + } } @Override @@ -142,7 +312,7 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl settlements = formalSettlementMapper.selectList( Wrappers.lambdaQuery() .eq(FormalSettlement::getSettlementType, "payable") - .eq(FormalSettlement::getApprovalStatus, APPROVED) + .eq(FormalSettlement::getApprovalStatus, TransportBusiConstant.APPROVED) .eq(FormalSettlement::getStatus, 1) .and(Func.isNotEmpty(keyword), wrapper -> wrapper .like(FormalSettlement::getFormalSettlementNo, keyword) @@ -211,8 +381,7 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl oldSettlementIds = entity.getId() == null ? List.of() : relationSettlementIds(entity.getId()); - KingdeeInvoicePool invoice = lockedInvoice(request.getKingdeeInvoicePoolId()); - if (invoice == null) invoice = invoiceSnapshot(request); + KingdeeInvoicePool invoice = persistMirrorFromRemote(request.getInvoiceNo()); assertInvoiceUnused(invoice, entity.getId()); Map requestedRows = distinctSettlementRows( @@ -232,7 +401,7 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl settlementIds = relationSettlementIds(id); @@ -287,32 +456,34 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl + * 编排流程:参数去重校验 → 批量组装推送计划(分摊/镜像/结算单/结算明细一次取回防 N+1)→ + * A102-4 应付单已生成预检 → A801-3 幂等预检(金蝶已有收票单则跳过保存续推指定)→ + * 逐单推送(单笔失败不中断整批)→ 汇总成功/失败笔数返回 + * + * @param ids 收票登记主键集合 + * @return 推送结果汇总(本批笔数、成功/失败笔数及失败原因明细) + */ + @Override + public String pushKingdeeReceivedInvoice(List ids) { + // 手动批量推送:前置拦截保持现状(blockedReason 不动推送状态,逐单报因) + return pushKingdeeReceivedInvoice(ids, false); + } + + /** + * 批量推送收票登记至金蝶(图05 环节②③:A303-2 收票单批量保存 + A302-6 指定应付)。 + *

+ * 编排流程:参数去重校验 → 批量组装推送计划(分摊/镜像/结算单/结算明细一次取回防 N+1)→ + * A102-4 应付单已生成预检 → A801-3 幂等预检(金蝶已有收票单则跳过保存续推指定)→ + * 逐单推送(单笔失败不中断整批)→ 汇总成功/失败笔数返回 + * + * @param ids 收票登记主键集合 + * @param autoSync 自动推送路径(审批通过事件/定时重推):前置拦截置 failed + 原因纳入重推循环; + * 手动批量路径保持现状(拦截不改状态,逐单报因) + * @return 推送结果汇总(本批笔数、成功/失败笔数及失败原因明细) + */ + @Override + public String pushKingdeeReceivedInvoice(List ids, boolean autoSync) { + if (Func.isEmpty(ids)) { + throw new ServiceException("请选择需要推送金蝶的收票登记"); + } + List distinctIds = ids.stream().filter(Objects::nonNull).distinct().toList(); + if (distinctIds.isEmpty()) { + throw new ServiceException("请选择需要推送金蝶的收票登记"); + } + log.info("推送金蝶收票单开始:入参 {} 笔,去重后 {} 笔,收票登记id={}", ids.size(), distinctIds.size(), distinctIds); + List receipts = listByIds(distinctIds); + if (receipts.size() != distinctIds.size()) { + throw new ServiceException("部分收票登记不存在或已删除"); + } + // 批量组装推送计划:分摊关系/镜像/结算单/结算明细一次取回,避免逐单查库 N+1 + // 1) 收票登记与正式结算单的分摊关系(按登记id分组),用于"一票一应付"校验及防重键取数来源 + Map> relationsByReceipt = settlementRelationMapper.selectList( + Wrappers.lambdaQuery() + .in(InvoiceReceiptSettlement::getInvoiceReceiptId, distinctIds) + .orderByAsc(InvoiceReceiptSettlement::getCreateTime)).stream() + .collect(Collectors.groupingBy(InvoiceReceiptSettlement::getInvoiceReceiptId)); + // 2) 票池镜像(A801-1 同步落库的发票数据),提供明细行 itemsJson、票种编码、购方单位等推送必填字段 + List mirrorIds = receipts.stream().map(InvoiceReceipt::getKingdeeInvoicePoolId) + .filter(Objects::nonNull).distinct().toList(); + Map mirrorById = mirrorIds.isEmpty() ? Map.of() + : invoicePoolMapper.selectBatchIds(mirrorIds).stream() + .collect(Collectors.toMap(KingdeeInvoicePool::getId, Function.identity(), (first, second) -> first)); + // 3) 分摊关系指向的正式结算单,后续校验须为审批通过的应付类型结算单 + List settlementIds = relationsByReceipt.values().stream().flatMap(List::stream) + .map(InvoiceReceiptSettlement::getFormalSettlementId).filter(Objects::nonNull).distinct().toList(); + Map settlementById = settlementIds.isEmpty() ? Map.of() + : formalSettlementMapper.selectBatchIds(settlementIds).stream() + .collect(Collectors.toMap(FormalSettlement::getId, Function.identity(), (first, second) -> first)); + // 4) 应付结算单的明细行(按行号升序),用于组装 A302-6 指定应付的防重键 apBillSrcCode + List payableSettlementIds = settlementById.values().stream() + .filter(settlement -> "payable".equals(settlement.getSettlementType())) + .map(FormalSettlement::getId).toList(); + Map> detailsBySettlement = payableSettlementIds.isEmpty() ? Map.of() + : formalSettlementDetailMapper.selectList(Wrappers.lambdaQuery() + .in(FormalSettlementDetail::getFormalSettlementId, payableSettlementIds) + .eq(FormalSettlementDetail::getIsDeleted, 0) + .orderByAsc(FormalSettlementDetail::getLineNo)).stream() + .collect(Collectors.groupingBy(FormalSettlementDetail::getFormalSettlementId)); + log.info("推送金蝶收票单批量取数完成:分摊关系 {} 条,票池镜像 {} 个,结算单 {} 张(应付 {} 张),结算明细 {} 行", + relationsByReceipt.size(), mirrorById.size(), settlementById.size(), payableSettlementIds.size(), + detailsBySettlement.values().stream().mapToInt(List::size).sum()); + // 5) 逐单组装推送计划:状态机/绑定/查验/一票一应付/防重键校验,不满足项写入 blockedReason 不抛异常(逐单报因) + Map planByReceipt = new LinkedHashMap<>(); + for (InvoiceReceipt receipt : receipts) { + planByReceipt.put(receipt.getId(), buildReceivedInvoicePushPlan(receipt, + relationsByReceipt.getOrDefault(receipt.getId(), List.of()), mirrorById, settlementById, detailsBySettlement)); + } + long blockedCount = planByReceipt.values().stream().filter(plan -> plan.blockedReason() != null).count(); + log.info("推送金蝶收票单计划组装完成:本批 {} 笔,前置校验拦截 {} 笔", planByReceipt.size(), blockedCount); + // A102-4 应付单已生成预检:本批防重键一次批量查询,缺单的登记逐单提示先完成付款侧同步 + List apSrcCodes = planByReceipt.values().stream() + .filter(plan -> plan.blockedReason() == null) + .map(ReceivedInvoicePushPlan::apBillSrcCode).filter(Func::isNotBlank).distinct().toList(); + Map apBillNoBySrcCode = apSrcCodes.isEmpty() ? Map.of() + : kingdeeApBillSyncSupport.queryApBillExistNos(apSrcCodes); + log.info("推送金蝶收票单 A102-4 预检:待校验防重键 {} 个,已生成财务应付单 {} 个", apSrcCodes.size(), apBillNoBySrcCode.size()); + // A801-3 幂等预检:按购方单位(接口必填)分组回看窗口翻页,本地按防重键匹配(金蝶已存在则跳过保存续推指定) + Map kingdeeRowByInvoiceNo = queryKingdeeReceivedInvoiceRows(planByReceipt); + log.info("推送金蝶收票单 A801-3 预检:金蝶已存在收票单 {} 笔,命中单据将跳过保存续推指定", kingdeeRowByInvoiceNo.size()); + KingdeeSyncContext context = KingdeeSyncContext.empty(); + int success = 0; + List failures = new ArrayList<>(); + for (InvoiceReceipt receipt : receipts) { + try { + 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); + } + } + String summary = "推送金蝶收票单完成:本批 " + receipts.size() + " 笔,成功 " + success + " 笔"; + if (!failures.isEmpty()) { + summary = summary + ",失败 " + failures.size() + " 笔:" + String.join(";", failures); + } + log.info("推送金蝶收票单返回信息{}", summary); + return summary; } @Override - @Transactional(rollbackFor = Exception.class) - public String syncKingdee(Long id) { - InvoiceReceipt entity = existing(id); - if (!APPROVED.equals(entity.getApprovalStatus())) { - throw new ServiceException("仅审批通过的收票登记允许同步金蝶状态"); + public String retryKingdeeFailed() { + List failedIds = list(Wrappers.lambdaQuery() + .eq(InvoiceReceipt::getKingdeePushStatus, KingdeeBfiConstant.RECEIVED_PUSH_FAILED) + .eq(InvoiceReceipt::getApprovalStatus, TransportBusiConstant.APPROVED) + .orderByAsc(InvoiceReceipt::getCreateTime)) + .stream().map(InvoiceReceipt::getId).toList(); + if (Func.isEmpty(failedIds)) { + return "无推送失败单据"; } - KingdeeInvoicePool invoice = invoicePoolMapper.selectById(entity.getKingdeeInvoicePoolId()); - if (invoice == null || Objects.equals(invoice.getIsDeleted(), 1)) { - throw new ServiceException("金蝶票据池发票不存在"); + int success = 0; + List failures = new ArrayList<>(); + for (Long id : failedIds) { + try { + // 批量推送方法逐单吞异常返回摘要,成败以推送落库状态为准(pushed/assigned 视为成功) + pushKingdeeReceivedInvoice(List.of(id), true); + InvoiceReceipt pushed = getById(id); + String pushStatus = pushed == null ? null : pushed.getKingdeePushStatus(); + if (KingdeeBfiConstant.RECEIVED_PUSH_PUSHED.equals(pushStatus) + || KingdeeBfiConstant.RECEIVED_PUSH_ASSIGNED.equals(pushStatus)) { + success++; + } else { + failures.add(id + ":" + (pushed == null ? "单据不存在" : Func.toStrWithEmpty(pushed.getKingdeePushMsg(), "推送未完成"))); + } + } catch (Exception exception) { + failures.add(id + ":" + exception.getMessage()); + } } - entity.setKingdeeBillNo(invoice.getKingdeeBillNo()); - entity.setKingdeeStatus(normalizeKingdeeStatus(invoice.getKingdeeStatus())); - updateById(entity); - record(entity.getId(), "sync", "同步金蝶状态", entity.getApprovalStatus(), - entity.getApprovalStatus(), null, entity.getKingdeeBillNo()); - return entity.getKingdeeBillNo(); + String summary = "重推推送失败单据:本批 " + failedIds.size() + " 条,成功 " + success + " 条"; + if (!failures.isEmpty()) { + summary = summary + ",失败 " + failures.size() + " 条:" + String.join(";", failures); + } + return summary; + } + + /** + * 单张收票登记推送编排:前置校验 → A102-4 预检 → A801-3 幂等预检 → A303-2 保存 → pushed 落库 → + * A302-6 指定应付 → assigned;保存/指定阶段失败置 failed,断点可凭防重键续推。 + * 自动推送路径(autoSync=true)前置拦截一并置 failed + 原因,纳入定时重推循环; + * 手动批量路径前置拦截保持不动状态(逐单报因),避免人工误触把可自助修复的单据打成失败 + * + * @param plan 预检组装的推送计划 + * @param kingdeeRows A801-3 幂等预检命中的金蝶收票单(发票号码 -> 行) + * @param apBillNoBySrcCode A102-4 已生成的财务应付单(防重键 -> 单据编号) + * @param context 金蝶同步上下文(合同/客商编码缓存) + * @param autoSync 自动推送路径标记(前置拦截置 failed) + * @return 推送结果描述 + */ + private String pushOneReceivedInvoice(ReceivedInvoicePushPlan plan, Map kingdeeRows, + Map apBillNoBySrcCode, KingdeeSyncContext context, boolean autoSync) { + InvoiceReceipt receipt = plan.receipt(); + log.info("收票登记[{}]推送金蝶收票单开始:id={},推送状态={},应付单防重键={}", receipt.getInvoiceNo(), receipt.getId(), + receipt.getKingdeePushStatus(), plan.apBillSrcCode()); + if (Func.isNotBlank(plan.blockedReason())) { + log.warn("收票登记[{}]推送金蝶收票单拦截:{}", receipt.getInvoiceNo(), plan.blockedReason()); + // 自动推送路径:前置不满足置 failed + 原因纳入重推循环("已指定应付"的良性跳过不改状态) + if (autoSync && !KingdeeBfiConstant.RECEIVED_PUSH_ASSIGNED.equals( + Func.toStrWithEmpty(receipt.getKingdeePushStatus(), KingdeeBfiConstant.RECEIVED_PUSH_UNSYNCED))) { + applyReceivedInvoicePushResult(receipt, receipt.getKingdeeReceivedInvoiceNo(), receipt.getKingdeeReceivedInvoiceId(), + KingdeeBfiConstant.RECEIVED_PUSH_FAILED, truncatePushMsg(plan.blockedReason()), true); + } + throw new ServiceException(plan.blockedReason()); + } + String apBillSrcCode = plan.apBillSrcCode(); + if (!apBillNoBySrcCode.containsKey(apBillSrcCode)) { + log.warn("收票登记[{}]推送金蝶收票单拦截:结算单[{}]未生成财务应付单(防重键={})", receipt.getInvoiceNo(), + plan.settlement().getFormalSettlementNo(), apBillSrcCode); + // 自动推送路径:前置应付单未生成置 failed + 原因,上游结算单就绪后由重推任务自动续推 + if (autoSync) { + applyReceivedInvoicePushResult(receipt, receipt.getKingdeeReceivedInvoiceNo(), receipt.getKingdeeReceivedInvoiceId(), + KingdeeBfiConstant.RECEIVED_PUSH_FAILED, + truncatePushMsg("请先同步金蝶应付单:结算单" + plan.settlement().getFormalSettlementNo() + "未生成财务应付单"), true); + } + throw new ServiceException("请先同步金蝶应付单:结算单" + plan.settlement().getFormalSettlementNo() + + "未生成财务应付单"); + } + String billNo; + String billPkId; + BfiReceivedInvoiceVO kingdeeRow = kingdeeRows.get(String.valueOf(receipt.getId())); + if (kingdeeRow != null) { + if (kingdeeReceivedInvoiceSyncSupport.isAbnormalInvoiceStatus(kingdeeRow.getInvoicestatus())) { + String abnormalReason = truncatePushMsg("金蝶侧收票单发票状态异常(" + + Func.toStrWithEmpty(kingdeeRow.getInvoicestatusTitle(), kingdeeRow.getInvoicestatus()) + + "),请先在金蝶处理后再重推"); + log.warn("收票登记[{}]推送金蝶收票单拦截:{},金蝶单据编号={}", receipt.getInvoiceNo(), abnormalReason, + kingdeeRow.getBillno()); + applyReceivedInvoicePushResult(receipt, kingdeeRow.getBillno(), + kingdeeRow.getId() == null ? null : String.valueOf(kingdeeRow.getId()), + KingdeeBfiConstant.RECEIVED_PUSH_FAILED, abnormalReason, true); + throw new ServiceException(abnormalReason); + } + billNo = kingdeeRow.getBillno(); + // 幂等预检命中行携带星瀚收票单内码,回填补齐(A902 附件上传定位用) + billPkId = kingdeeRow.getId() == null ? receipt.getKingdeeReceivedInvoiceId() + : String.valueOf(kingdeeRow.getId()); + log.info("收票登记[{}]金蝶收票单已生成(单据编号[{}]),幂等跳过 A303-2 保存续推指定", receipt.getInvoiceNo(), billNo); + } else { + try { + // 镜像缺票种编码(入镜像早于字段上线或滑出增量窗口):按票号窄查 A801-1 回填补写; + // 明细分录已与应付单同源取自结算货物费用行,票池明细行仅入池存档、不再作为分录取数来源 + if (Func.isBlank(plan.mirror().getInvoiceTypeCode())) { + log.info("收票登记[{}]镜像缺票种编码,按票号回填 A801-1", receipt.getInvoiceNo()); + fetchMirrorItemsFromPool(plan.mirror()); + } + log.info("收票登记[{}]开始 A303-2 推送金蝶收票单", receipt.getInvoiceNo()); + ContractManage contract = kingdeeApBillSyncSupport.requireContract(plan.settlement().getContractId(), context); + // 明细分录与应付单 A102-2 同源同口径:复用同一结算明细解析结果,结算明细行+结算合计费用项各一条 + List entryGroups = + kingdeeApBillSyncSupport.resolveEntryGroups(plan.details(), context); + KingdeeReceivedInvoiceSyncSupport.ReceivedInvoicePushOutcome pushOutcome = + kingdeeReceivedInvoiceSyncSupport.pushReceivedInvoice(receipt, plan.mirror(), contract, entryGroups, context); + billNo = pushOutcome.billNo(); + billPkId = pushOutcome.billId(); + log.info("收票登记[{}]A303-2 推送成功:金蝶收票单[{}],星瀚内码={}", receipt.getInvoiceNo(), billNo, billPkId); + } catch (RuntimeException pushException) { + log.warn("收票登记[{}]A303-2 推送失败:{}", receipt.getInvoiceNo(), pushException.getMessage()); + applyReceivedInvoicePushResult(receipt, receipt.getKingdeeReceivedInvoiceNo(), receipt.getKingdeeReceivedInvoiceId(), + KingdeeBfiConstant.RECEIVED_PUSH_FAILED, + truncatePushMsg("推送金蝶收票单失败:" + pushException.getMessage()), true); + throw pushException; + } + } + // 保存成功先落单号+星瀚内码+pushed(断点恢复凭 A801-3 预检跳过保存),再执行指定应付 + applyReceivedInvoicePushResult(receipt, billNo, billPkId, KingdeeBfiConstant.RECEIVED_PUSH_PUSHED, null, false); + // A303-2 成功即联带上传附件(登记附件 + 镜像发票文件经 A801-2 下载回传): + // 失败只记附件同步状态,不阻塞 A302-6 指定应付,也不阻塞同批其他登记,重推自动补传 + syncAttachmentsAfterPush(receipt, plan, billPkId); + try { + log.info("收票登记[{}]开始 A302-6 指定应付:应付单防重键={}", receipt.getInvoiceNo(), apBillSrcCode); + kingdeeReceivedInvoiceSyncSupport.assignApBill(String.valueOf(receipt.getId()), apBillSrcCode); + } catch (RuntimeException assignException) { + log.warn("收票登记[{}]A302-6 指定应付失败:{}", receipt.getInvoiceNo(), assignException.getMessage()); + applyReceivedInvoicePushResult(receipt, billNo, billPkId, KingdeeBfiConstant.RECEIVED_PUSH_FAILED, + truncatePushMsg("金蝶收票单指定应付失败:" + assignException.getMessage()), true); + throw assignException; + } + applyReceivedInvoicePushResult(receipt, billNo, billPkId, KingdeeBfiConstant.RECEIVED_PUSH_ASSIGNED, null, true); + log.info("收票登记[{}]推送金蝶收票单完成:金蝶收票单[{}]已指定应付单", receipt.getInvoiceNo(), billNo); + return "发票" + receipt.getInvoiceNo() + "推送成功,金蝶收票单[" + billNo + "]已指定应付单"; + } + + /** + * 附件联带上传(A902):A303-2 保存成功(含幂等预检命中)后调用,上传登记附件与镜像发票文件; + * billPkId 缺失跳过待补传;异常独立捕获,绝不影响推送状态与整批进度 + * + * @param receipt 收票登记 + * @param plan 推送计划(镜像附件取数来源) + * @param billPkId 星瀚收票单内码 + */ + private void syncAttachmentsAfterPush(InvoiceReceipt receipt, ReceivedInvoicePushPlan plan, String billPkId) { + try { + if (Func.isBlank(billPkId)) { + log.warn("收票登记[{}]金蝶附件联带上传跳过:星瀚收票单内码未落库,待重推补传", receipt.getInvoiceNo()); + return; + } + List items = new ArrayList<>(); + items.addAll(kingdeeAttachmentSyncSupport.parseUserAttachments(receipt.getAttachmentsJson())); + // 镜像发票文件:幂等键=path,经 A801-2 下载后 A902 回传收票单(path 变化视为新文件自动重传) + items.addAll(kingdeeAttachmentSyncSupport.parseInvoicePoolFile(plan.mirror().getAttachmentsJson())); + KingdeeAttachmentSyncSupport.AttachmentSyncSummary summary = kingdeeAttachmentSyncSupport.syncAttachments( + KingdeeAttachmentConstant.BIZ_INVOICE_RECEIPT, receipt.getId(), billPkId, items); + if (summary.total() > 0 || summary.skipCount() > 0) { + // 附件同步操作流水(Q5 决策:收票登记有 actionType 流水表,追加 attachment_upload 记录) + record(receipt.getId(), "attachment_upload", "附件上传金蝶", receipt.getApprovalStatus(), receipt.getApprovalStatus(), + summary.failed() > 0 ? "成功" + summary.success() + "个,失败" + summary.failed() + "个:" + String.join(";", summary.failReasons()) + : "成功上传" + summary.success() + "个附件(历史已传跳过" + summary.skipCount() + "个)", + receipt.getKingdeeReceivedInvoiceNo()); + } + } catch (Exception exception) { + log.error("收票登记[{}]金蝶附件联带上传异常(不影响推送状态,重推自动补传)", receipt.getInvoiceNo(), exception); + } + } + + /** + * 推送前置组装:状态机、绑定、查验、一票一应付、应付单防重键;不满足项写入 blockedReason(不拦截整批,逐单报因) + */ + private ReceivedInvoicePushPlan buildReceivedInvoicePushPlan(InvoiceReceipt receipt, + List relations, + Map mirrorById, + Map settlementById, + Map> detailsBySettlement) { + String pushStatus = Func.toStrWithEmpty(receipt.getKingdeePushStatus(), KingdeeBfiConstant.RECEIVED_PUSH_UNSYNCED); + if (!TransportBusiConstant.APPROVED.equals(receipt.getApprovalStatus())) { + return new ReceivedInvoicePushPlan(receipt, null, null, null, null, "仅审批通过的收票登记允许推送金蝶"); + } + if (KingdeeBfiConstant.RECEIVED_PUSH_ASSIGNED.equals(pushStatus)) { + return new ReceivedInvoicePushPlan(receipt, null, null, null, null, "收票单已指定应付,无需重复推送"); + } + KingdeeInvoicePool mirror = receipt.getKingdeeInvoicePoolId() == null ? null + : mirrorById.get(receipt.getKingdeeInvoicePoolId()); + if (mirror == null) { + return new ReceivedInvoicePushPlan(receipt, null, null, null, null, "收票登记未绑定票池镜像,请先完成同步绑定"); + } + if ("2".equals(mirror.getCheckStatus())) { + return new ReceivedInvoicePushPlan(receipt, mirror, null, null, null, "金蝶发票查验不通过,请先人工核实票池发票"); + } + if (relations.size() != 1 || relations.get(0).getFormalSettlementId() == null) { + return new ReceivedInvoicePushPlan(receipt, mirror, null, null, null, + "金蝶不支持一张发票指定多张应付单,收票登记须恰好分摊一张应付正式结算单"); + } + FormalSettlement settlement = settlementById.get(relations.get(0).getFormalSettlementId()); + if (settlement == null || Objects.equals(settlement.getIsDeleted(), 1) + || !Objects.equals(settlement.getStatus(), 1) + || !TransportBusiConstant.APPROVED.equals(settlement.getApprovalStatus()) + || !"payable".equals(settlement.getSettlementType())) { + return new ReceivedInvoicePushPlan(receipt, mirror, settlement, null, null, "分摊结算单不是审批通过的应付正式结算单"); + } + List details = detailsBySettlement.getOrDefault(settlement.getId(), List.of()); + if (details.isEmpty()) { + return new ReceivedInvoicePushPlan(receipt, mirror, settlement, null, null, + "结算单" + settlement.getFormalSettlementNo() + "无结算明细,无法组装应付单防重键"); + } + return new ReceivedInvoicePushPlan(receipt, mirror, settlement, details, + kingdeeApBillSyncSupport.apBillSrcCode(settlement, details), null); + } + + /** A801-3 幂等预检批量查询:未终态计划按购方单位分组,窗口内翻页匹配防重键 */ + private Map queryKingdeeReceivedInvoiceRows(Map planByReceipt) { + Map> invoiceNosByBuyer = new LinkedHashMap<>(); + for (ReceivedInvoicePushPlan plan : planByReceipt.values()) { + if (plan.blockedReason() != null) { + continue; + } + invoiceNosByBuyer.computeIfAbsent(Func.toStrWithEmpty(plan.mirror().getReceiverName(), ""), + key -> new LinkedHashSet<>()).add(String.valueOf(plan.receipt().getId())); + } + Map rows = new LinkedHashMap<>(); + LocalDateTime windowEnd = LocalDateTime.now(); + LocalDateTime windowStart = windowEnd.minusDays(Math.max(bfiProperties.getReceivedInvoice().getQueryWindowDays(), 1)); + for (Map.Entry> entry : invoiceNosByBuyer.entrySet()) { + if (StringUtil.isBlank(entry.getKey())) { + continue; + } + rows.putAll(kingdeeReceivedInvoiceSyncSupport.queryReceivedInvoiceBySrcCodes(entry.getKey(), + entry.getValue(), windowStart, windowEnd)); + } + return rows; + } + + /** + * 推送/回写结果落库:回写金蝶收票单号、星瀚收票单内码、推送状态、失败原因与时间;写终态(assigned/failed)时记同步流水 + */ + private void applyReceivedInvoicePushResult(InvoiceReceipt receipt, String receivedInvoiceNo, String receivedInvoiceId, + String pushStatus, String pushMsg, boolean writeRecord) { + receipt.setKingdeeReceivedInvoiceNo(receivedInvoiceNo); + if (Func.isNotBlank(receivedInvoiceId)) { + receipt.setKingdeeReceivedInvoiceId(receivedInvoiceId); + } + receipt.setKingdeePushStatus(pushStatus); + receipt.setKingdeePushMsg(pushMsg); + receipt.setKingdeePushTime(LocalDateTime.now()); + updateById(receipt); + if (writeRecord) { + record(receipt.getId(), "sync", "推送金蝶收票单", receipt.getApprovalStatus(), receipt.getApprovalStatus(), + Func.isNotBlank(pushMsg) ? pushMsg : "金蝶收票单[" + Func.toStr(receivedInvoiceNo) + "]已生成并指定应付", + receipt.getKingdeeReceivedInvoiceNo()); + } + } + + /** 按票号窄查 A801-1 回填镜像明细行与票种编码(仅补写既有镜像字段,不新建镜像) */ + private void fetchMirrorItemsFromPool(KingdeeInvoicePool mirror) { + List poolRows = queryPoolByInvoiceNo(mirror.getInvoiceNo()); + BfiInvoicePoolVO poolRow = poolRows.stream() + .filter(row -> Func.isNotEmpty(row.getInvoiceNo()) + && mirror.getInvoiceNo().equals(row.getInvoiceNo().trim())) + .findFirst().orElse(null); + if (poolRow == null) { + throw new ServiceException("金蝶票池未查询到发票" + mirror.getInvoiceNo() + "明细,请先按票号补查"); + } + upsertInvoicePool(mirror, poolRow); + } + + private String truncatePushMsg(String message) { + if (message == null) { + return null; + } + return message.length() > 500 ? message.substring(0, 500) : message; + } + + /** + * 收票单推送预检计划(blockedReason 非空表示前置拦截,不进入推送流程) + * + * @param receipt 收票登记 + * @param mirror 绑定的票池镜像(可为 null) + * @param settlement 唯一分摊的应付正式结算单(可为 null) + * @param details 结算单结算明细(防重键组装与 A303-2 分录解析共用,可为 null) + * @param apBillSrcCode 财务应付单防重键(无法组装时为 null) + * @param blockedReason 前置拦截原因 + */ + private record ReceivedInvoicePushPlan(InvoiceReceipt receipt, KingdeeInvoicePool mirror, + FormalSettlement settlement, List details, String apBillSrcCode, String blockedReason) { + } + + // ==================== A801-3 收票结果定时回写(异常回收/指定补偿/单号补登) ==================== + + @Override + public String syncKingdeeReceivedInvoiceResult() { + return kingdeeSyncLockSupport.runSingleFlight(RECEIVED_RESULT_SYNC_LOCK_PREFIX, bfiProperties.getReceivedInvoice().getLockLeaseSeconds(), + "金蝶收票单结果回写", this::executeReceivedInvoiceResultSync); + } + + /** + * 金蝶收票单结果回写主体(A801-3):定时轮询推送状态为 pushed/assigned 且未作废的收票登记, + * 按购方单位分组在时间窗口内反查金蝶收票单结果,一体推进三类补偿场景: + * ① 异常回收——金蝶侧发票状态异常时回收推送状态为 failed 并记同步流水,提示人工删除/解除指定后重推; + * ② 指定补偿——pushed(保存成功后中断、指定未完成)按预组装的应付单防重键重发 A302-6 指定应付; + * ③ 单号补登——assigned 终态缺失金蝶收票单号(保存成功但响应丢失的历史数据)时仅补登单号,不改变状态。 + *

匹配与容错说明: + * ① 匹配键为收票登记 id,与 A303-2 落库的 awrp_src_code 同源,按发票号匹配将永远查不到自推单据; + * ② 查询窗口为近 {@code thirdparty.bfi.receivedInvoice.resultWindowDays} 配置天数(至少 1 天),窗口内查不到不判为金蝶侧已删除 + * (金蝶修改时间延迟/滑窗),保持现状由人工重推兜底; + * ③ 单笔回写异常仅记入失败明细不中断整轮,结束时汇总进执行摘要; + * ④ 由 {@link #syncKingdeeReceivedInvoiceResult()} 在分布式锁保护下调用,防多实例并发重跑。 + * + * @return 执行摘要 + */ + private String executeReceivedInvoiceResultSync() { + long startMillis = System.currentTimeMillis(); + List pendingReceipts = list(Wrappers.lambdaQuery() + .in(InvoiceReceipt::getKingdeePushStatus, KingdeeBfiConstant.RECEIVED_PUSH_PUSHED, + KingdeeBfiConstant.RECEIVED_PUSH_ASSIGNED) + .ne(InvoiceReceipt::getApprovalStatus, TransportBusiConstant.VOIDED) + .isNotNull(InvoiceReceipt::getKingdeeInvoicePoolId) + .orderByAsc(InvoiceReceipt::getId)); + if (pendingReceipts.isEmpty()) { + log.info("金蝶收票单结果回写:无待回写收票登记"); + return "金蝶收票单结果回写:无待回写收票登记"; + } + long pushedCount = pendingReceipts.stream() + .filter(receipt -> KingdeeBfiConstant.RECEIVED_PUSH_PUSHED.equals(receipt.getKingdeePushStatus())).count(); + log.info("金蝶收票单结果回写开始:待回写收票登记{}笔(pushed待指定补偿 {} 笔,assigned待单号补登 {} 笔)", + pendingReceipts.size(), pushedCount, pendingReceipts.size() - pushedCount); + List mirrorIds = pendingReceipts.stream().map(InvoiceReceipt::getKingdeeInvoicePoolId) + .filter(Objects::nonNull).distinct().toList(); + Map mirrorById = invoicePoolMapper.selectBatchIds(mirrorIds).stream() + .collect(Collectors.toMap(KingdeeInvoicePool::getId, Function.identity(), (first, second) -> first)); + // pushed(保存成功后中断、指定未完成)集合:批量预组装应付单防重键供指定补偿 + List pushedIds = pendingReceipts.stream() + .filter(receipt -> KingdeeBfiConstant.RECEIVED_PUSH_PUSHED.equals(receipt.getKingdeePushStatus())) + .map(InvoiceReceipt::getId).toList(); + Map apSrcCodeByReceiptId = pushedIds.isEmpty() ? Map.of() + : resolveApBillSrcCodesForWriteBack(pushedIds); + Map> receiptsByBuyer = new LinkedHashMap<>(); + for (InvoiceReceipt receipt : pendingReceipts) { + KingdeeInvoicePool mirror = mirrorById.get(receipt.getKingdeeInvoicePoolId()); + if (mirror == null || StringUtil.isBlank(mirror.getReceiverName())) { + log.warn("金蝶收票单结果回写跳过:收票登记[{}]无镜像购方名称", receipt.getInvoiceNo()); + continue; + } + receiptsByBuyer.computeIfAbsent(mirror.getReceiverName().trim(), key -> new ArrayList<>()).add(receipt); + } + LocalDateTime windowEnd = LocalDateTime.now(); + LocalDateTime windowStart = windowEnd.minusDays(Math.max(bfiProperties.getReceivedInvoice().getResultWindowDays(), 1)); + int recovered = 0; + int reassigned = 0; + int filled = 0; + List failures = new ArrayList<>(); + for (Map.Entry> entry : receiptsByBuyer.entrySet()) { + // 匹配键须与 A303-2 落库的 awrp_src_code 同源(收票登记id),按发票号匹配将永远查不到自推单据 + Set srcCodes = entry.getValue().stream().map(receipt -> String.valueOf(receipt.getId())) + .collect(Collectors.toCollection(LinkedHashSet::new)); + Map matched = kingdeeReceivedInvoiceSyncSupport + .queryReceivedInvoiceBySrcCodes(entry.getKey(), srcCodes, windowStart, windowEnd); + for (InvoiceReceipt receipt : entry.getValue()) { + BfiReceivedInvoiceVO row = matched.get(String.valueOf(receipt.getId())); + // 窗口内查不到不判为删除(金蝶修改时间延迟/滑窗),保持现状由人工重推兜底 + if (row == null) { + continue; + } + try { + if (kingdeeReceivedInvoiceSyncSupport.isAbnormalInvoiceStatus(row.getInvoicestatus())) { + applyReceivedInvoicePushResult(receipt, Func.toStrWithEmpty(row.getBillno(), + receipt.getKingdeeReceivedInvoiceNo()), + row.getId() == null ? receipt.getKingdeeReceivedInvoiceId() : String.valueOf(row.getId()), + KingdeeBfiConstant.RECEIVED_PUSH_FAILED, + truncatePushMsg("金蝶侧收票单发票状态异常(" + Func.toStrWithEmpty(row.getInvoicestatusTitle(), + row.getInvoicestatus()) + "),已回收推送状态,请在金蝶删除/解除指定后重推"), true); + recovered++; + continue; + } + if (KingdeeBfiConstant.RECEIVED_PUSH_PUSHED.equals(receipt.getKingdeePushStatus())) { + reassignPushedReceivedInvoice(receipt, row, apSrcCodeByReceiptId.get(receipt.getId())); + reassigned++; + continue; + } + // assigned 终态仅补登缺失的金蝶收票单号(保存成功但响应丢失的历史数据) + if (StringUtil.isBlank(receipt.getKingdeeReceivedInvoiceNo()) && Func.isNotBlank(row.getBillno())) { + applyReceivedInvoicePushResult(receipt, row.getBillno(), + row.getId() == null ? receipt.getKingdeeReceivedInvoiceId() : String.valueOf(row.getId()), + KingdeeBfiConstant.RECEIVED_PUSH_ASSIGNED, null, false); + filled++; + } + } catch (RuntimeException writeBackException) { + failures.add("发票" + receipt.getInvoiceNo() + ":" + writeBackException.getMessage()); + log.warn("金蝶收票单结果回写失败,收票登记id={}", receipt.getId(), writeBackException); + } + } + } + String summary = "金蝶收票单结果回写完成:轮询 " + pendingReceipts.size() + " 笔,异常回收 " + recovered + + " 笔,指定补偿 " + reassigned + " 笔,单号补登 " + filled + " 笔"; + if (!failures.isEmpty()) { + summary = summary + ",失败 " + failures.size() + " 笔:" + String.join(";", failures); + } + log.info("{},耗时{}ms", summary, System.currentTimeMillis() - startMillis); + return summary; + } + + /** pushed 单据指定补偿:金蝶收票单存在且状态正常,按发票防重键+应付防重键重发 A302-6 */ + private void reassignPushedReceivedInvoice(InvoiceReceipt receipt, BfiReceivedInvoiceVO row, String apBillSrcCode) { + if (StringUtil.isBlank(apBillSrcCode)) { + applyReceivedInvoicePushResult(receipt, row.getBillno(), + row.getId() == null ? receipt.getKingdeeReceivedInvoiceId() : String.valueOf(row.getId()), + KingdeeBfiConstant.RECEIVED_PUSH_FAILED, + truncatePushMsg("收票单指定应付补偿失败:收票登记分摊关系不满足一票一应付,请人工核对"), true); + throw new ServiceException("收票单指定应付补偿失败:分摊关系不满足一票一应付"); + } + try { + kingdeeReceivedInvoiceSyncSupport.assignApBill(String.valueOf(receipt.getId()), apBillSrcCode); + } catch (RuntimeException assignException) { + applyReceivedInvoicePushResult(receipt, row.getBillno(), + row.getId() == null ? receipt.getKingdeeReceivedInvoiceId() : String.valueOf(row.getId()), + KingdeeBfiConstant.RECEIVED_PUSH_FAILED, + truncatePushMsg("收票单指定应付补偿失败:" + assignException.getMessage()), true); + throw assignException; + } + applyReceivedInvoicePushResult(receipt, row.getBillno(), + row.getId() == null ? receipt.getKingdeeReceivedInvoiceId() : String.valueOf(row.getId()), + KingdeeBfiConstant.RECEIVED_PUSH_ASSIGNED, null, true); + } + + /** 回写补偿用:批量组装 pushed 单据的财务应付单防重键(不满足一票一应付的登记不返回该键) */ + private Map resolveApBillSrcCodesForWriteBack(List receiptIds) { + Map> relationsByReceipt = settlementRelationMapper.selectList( + Wrappers.lambdaQuery() + .in(InvoiceReceiptSettlement::getInvoiceReceiptId, receiptIds)).stream() + .collect(Collectors.groupingBy(InvoiceReceiptSettlement::getInvoiceReceiptId)); + List settlementIds = relationsByReceipt.values().stream().flatMap(List::stream) + .map(InvoiceReceiptSettlement::getFormalSettlementId).filter(Objects::nonNull).distinct().toList(); + Map settlementById = settlementIds.isEmpty() ? Map.of() + : formalSettlementMapper.selectBatchIds(settlementIds).stream() + .collect(Collectors.toMap(FormalSettlement::getId, Function.identity(), (first, second) -> first)); + List payableSettlementIds = settlementById.values().stream() + .filter(settlement -> "payable".equals(settlement.getSettlementType())) + .map(FormalSettlement::getId).toList(); + Map> detailsBySettlement = payableSettlementIds.isEmpty() ? Map.of() + : formalSettlementDetailMapper.selectList(Wrappers.lambdaQuery() + .in(FormalSettlementDetail::getFormalSettlementId, payableSettlementIds) + .eq(FormalSettlementDetail::getIsDeleted, 0) + .orderByAsc(FormalSettlementDetail::getLineNo)).stream() + .collect(Collectors.groupingBy(FormalSettlementDetail::getFormalSettlementId)); + Map result = new LinkedHashMap<>(); + for (Long receiptId : receiptIds) { + List relations = relationsByReceipt.getOrDefault(receiptId, List.of()); + if (relations.size() != 1 || relations.get(0).getFormalSettlementId() == null) { + continue; + } + FormalSettlement settlement = settlementById.get(relations.get(0).getFormalSettlementId()); + List details = settlement == null ? List.of() + : detailsBySettlement.getOrDefault(settlement.getId(), List.of()); + if (!details.isEmpty()) { + result.put(receiptId, kingdeeApBillSyncSupport.apBillSrcCode(settlement, details)); + } + } + return result; + } + + /** + * A801-1 按发票号码精确单查(rim 单页契约,命中 0/1 条) + */ + private List queryPoolByInvoiceNo(String invoiceNo) { + BfiInvoicePoolQueryDataDTO data = new BfiInvoicePoolQueryDataDTO(); + data.setPageNo(1); + data.setPageSize(POOL_PAGE_SIZE); + data.setInvoiceNo(invoiceNo.trim()); + // 只认发票状态正常的票,作废/异常票不再进入镜像 + data.setInvoiceStatus(KingdeeBfiConstant.POOL_INVOICE_STATUS_NORMAL); + BfiInvoicePoolQueryDTO param = new BfiInvoicePoolQueryDTO(); + param.setQueryParam(data); + BfiPageVO page = bfiService.queryInvoicePool(param); + return page.getRows() == null ? List.of() : page.getRows(); + } + + /** + * 保存收票登记时按票号重查金蝶并落库镜像:镜像只存被业务选中的发票(登记存根), + * 服务端值权威不采信客户端票面,票被删除/红冲在此拦截 + */ + private KingdeeInvoicePool persistMirrorFromRemote(String invoiceNo) { + if (StringUtil.isBlank(invoiceNo)) { + throw new ServiceException("请填写发票号码"); + } + String trimmedInvoiceNo = invoiceNo.trim(); + KingdeeInvoicePool existing = invoicePoolMapper.selectOne(Wrappers.lambdaQuery() + .eq(KingdeeInvoicePool::getInvoiceNo, trimmedInvoiceNo) + .last("FOR UPDATE")); + BfiInvoicePoolVO poolRow = queryPoolByInvoiceNo(trimmedInvoiceNo).stream() + .filter(row -> StringUtil.isNotBlank(row.getInvoiceNo()) + && trimmedInvoiceNo.equals(row.getInvoiceNo().trim())) + .findFirst() + .orElse(null); + if (poolRow == null) { + throw new ServiceException("金蝶票池未查询到发票 " + trimmedInvoiceNo + ",不能保存"); + } + // 保存防御:选票到保存之间票可能被他人认领/使用,落镜像前按选票同口径复核可用性 + if (!this.isUsablePoolRow(poolRow)) { + throw new ServiceException("发票 " + trimmedInvoiceNo + " 剩余可用金额为0或金额使用状态已变更,不能保存"); + } + return upsertInvoicePool(existing, poolRow); + } + + /** + * A801-1 票池发票单条 upsert 镜像(手动同步路径) + * + * @param mirror 既有镜像记录,null 表示新增 + * @param row A801-1 票池发票 + * @return upsert 后的镜像记录 + */ + private KingdeeInvoicePool upsertInvoicePool(KingdeeInvoicePool mirror, BfiInvoicePoolVO row) { + boolean creating = mirror == null; + mirror = fillInvoicePool(mirror, row); + if (creating) { + invoicePoolMapper.insert(mirror); + } else { + invoicePoolMapper.updateById(mirror); + } + return mirror; + } + + /** + * A801-1 票池发票填充镜像(同租户发票号唯一,票池字段为镜像唯一事实源,不写库) + * + * @param mirror 既有镜像记录,null 表示新增 + * @param row A801-1 票池发票 + * @return 填充后的镜像记录 + */ + private KingdeeInvoicePool fillInvoicePool(KingdeeInvoicePool mirror, BfiInvoicePoolVO row) { + boolean creating = mirror == null; + if (creating) { + mirror = new KingdeeInvoicePool(); + mirror.setInvoiceNo(truncate(row.getInvoiceNo(), 32)); + mirror.setKingdeeStatus(KingdeeBfiConstant.KINGDEE_STATUS_UNSYNCED); + } + // 金蝶流水号作金蝶侧唯一标识(A801-1 无单据号概念) + mirror.setSerialNo(row.getSerialNo()); + mirror.setKingdeeBillNo(row.getSerialNo()); + mirror.setKingdeeStatus(poolKingdeeStatus(row)); + mirror.setInvoiceDate(parseDate(row.getInvoiceNo(), row.getInvoiceDate())); + mirror.setInvoiceType(BfiConstant.INVOICE_TYPE_NAMES.getOrDefault(row.getInvoiceType(), row.getInvoiceType())); + // 票易通原始编码随名称一并落库,A303-2 invoicetypef7_number 直接取用(名称反查不可靠) + mirror.setInvoiceTypeCode(truncate(row.getInvoiceType(), 8)); + mirror.setTaxRate(resolveTaxRate(row)); + mirror.setInvoiceAmount(parseAmount(row.getInvoiceNo(), row.getTotalAmount())); + mirror.setTaxAmount(parseAmount(row.getInvoiceNo(), row.getTaxAmount())); + mirror.setReceiverName(truncate(row.getBuyerName(), 100)); + mirror.setBuyerTaxNo(row.getBuyerTaxNo()); + mirror.setIssuerName(truncate(row.getSalerName(), 100)); + mirror.setSalerTaxNo(row.getSalerTaxNo()); + mirror.setCheckStatus(row.getCheckStatus()); + mirror.setAttachmentsJson(buildAttachmentJson(row)); + // A801-1 明细行原样序列化落库,A303-2 收票单分录取数来源;源侧无明细时维持库中原值(非空字段更新策略) + mirror.setItemsJson(buildItemsJson(row)); + mirror.setSourceUpdatedTime(parseDateTime(row.getInvoiceNo(), row.getBillCreateTime())); + // 选票可用过滤本地化:剩余可用金额与金额使用状态随票池刷新,空金额按 0(不可用)兜底 + mirror.setAvailableAmount(parseAmount(row.getInvoiceNo(), row.getAvailableAmount())); + mirror.setAmountUseStatus(trimOrNull(row.getAmountUseStatus())); + return mirror; + } + + /** 票池作废映射:A801-1 delete=1(作废删除)置 failed,人工确认;正常票 synced */ + private String poolKingdeeStatus(BfiInvoicePoolVO row) { + return "1".equals(row.getDelete()) ? KingdeeBfiConstant.KINGDEE_STATUS_FAILED + : KingdeeBfiConstant.KINGDEE_STATUS_SYNCED; + } + + /** + * A801-1 发票明细行序列化(items 为空返回 null,更新时维持库中原值) + */ + private String buildItemsJson(BfiInvoicePoolVO row) { + if (Func.isEmpty(row.getItems())) { + return null; + } + return JsonUtil.toJson(row.getItems()); + } + + private String buildAttachmentJson(BfiInvoicePoolVO row) { + // 数电票 downloadUrl 可能是 OFD 版式(fileType=4,ZIP 容器),前端按扩展名选预览器。 + // 下载契约:取 A801-1 的 pdfurl(PDF 版式地址)走 A801-2;taxPdfUrl 兜底,都缺才退 downloadUrl 并按 fileType 命名 + String path = Func.isBlank(row.getPdfurl()) ? row.getTaxPdfUrl() : row.getPdfurl(); + boolean fromPdfField = !Func.isBlank(path); + if (!fromPdfField) { + path = row.getDownloadUrl(); + } + if (Func.isBlank(path)) { + return null; + } + boolean ofdOnly = !fromPdfField && "4".equals(row.getFileType()); + Map attachment = new LinkedHashMap<>(); + attachment.put("source", "piaozone"); + attachment.put("path", path); + attachment.put("name", row.getInvoiceNo() + (ofdOnly ? ".ofd" : ".pdf")); + return JsonUtil.toJson(attachment); + } + + // ==================== 金蝶全票池定时同步(瘦身版:窗口拉取 + 全列 upsert,无绑定/补偿/白名单) ==================== + + @Override + public String syncKingdeeInvoicePool() { + return kingdeeSyncLockSupport.runSingleFlight(POOL_SYNC_LOCK_PREFIX, bfiProperties.getInvoiceReceipt().getLockLeaseSeconds(), + "金蝶全票池同步", this::executePoolSync); + } + + /** + * 同步主体(39ffac1 绑定回填口径瘦身版):滚动增量窗口按 updateTime **切片续扫** + 镜像批量 upsert(有变化才写) + * + 未绑定存量登记顺带补绑(同页发票号码批量定位登记,页级事务批量落库)。 + *

切片续扫(ponytail: 单查询窗口有 MAX_POOL_PAGES×500=2.5 万硬上限,窗口数据超限尾部永久漏同步; + * 切片让每片各自独立受翻页保护,总覆盖不受限):把大窗口切成小片独立翻页,某片触顶说明该时间片内 + * 数据 >2.5 万,二分为 [start, mid) [mid, end) 两片递归处理;updateTime 单调,切片边界不重不漏。 + *

发票种类白名单**下推远端**(invoiceType 为单值参数,按白名单编码逐轮查询,每轮独立切片额度), + * 白名单为空退化为单轮全量——只同步数电普票/专票时不把他票种的额度消耗进来。 + *

拉取只认正常票(invoiceStatus=0),不推单据使用状态——在用/已用票随 updateTime 进窗口刷新 + * 金额使用状态,选票可用过滤在本地 SQL 执行;被删除/红冲票不再出现在拉取结果,由保存/认领重查闸门兜底。 + * + * @return 执行摘要 + */ + private String executePoolSync() { + long startMillis = System.currentTimeMillis(); + LocalDateTime windowStart = LocalDateTime.now().minusDays(bfiProperties.getInvoiceReceipt().getIncrementDays()); + LocalDateTime windowEnd = LocalDateTime.now(); + List invoiceTypes = this.poolInvoiceTypeCodes(); + SyncStats stats = new SyncStats(); + log.info("金蝶全票池同步开始,增量窗口 {} ~ {},发票种类:{},单页 {} 条最多 {} 页(触顶自动二分切片)", + windowStart.format(POOL_TIME_FORMATTER), windowEnd.format(POOL_TIME_FORMATTER), + invoiceTypes.isEmpty() ? "不限制" : invoiceTypes, POOL_PAGE_SIZE, MAX_POOL_PAGES); + if (invoiceTypes.isEmpty()) { + this.syncWindowSlice(windowStart, windowEnd, null, stats); + } else { + // 单值参数拆轮:每个票种编码独立完整切片续扫,互不挤占翻页额度 + for (String invoiceType : invoiceTypes) { + this.syncWindowSlice(windowStart, windowEnd, invoiceType, stats); + } + } + String summary = "金蝶全票池同步:翻页" + stats.pageCount + "页切片" + stats.sliceCount + "片拉取" + + stats.pulledCount + "张发票,镜像新增" + stats.created + "更新" + stats.updated + "无变化" + + stats.mirrorUnchanged + ",补绑" + stats.bound + ",登记无变化跳过" + stats.unchanged + + ",失败" + stats.failed + ",耗时" + (System.currentTimeMillis() - startMillis) + "ms"; + log.info("金蝶全票池同步结束,{}", summary); + return summary; + } + + /** 白名单编码集:逗号分隔配置解析 trim,空配置返回空集(不限制) */ + private List poolInvoiceTypeCodes() { + if (StringUtil.isBlank(bfiProperties.getInvoiceReceipt().getInvoiceTypes())) { + return List.of(); + } + return java.util.Arrays.stream(bfiProperties.getInvoiceReceipt().getInvoiceTypes().split(",")) + .map(String::trim) + .filter(StringUtil::isNotBlank) + .distinct() + .toList(); + } + + /** 整轮同步累计统计(各切片共享累加,结束进摘要) */ + private static final class SyncStats { + /** A801-1 原始拉取张数 */ + int pulledCount; + /** 镜像新增条数(票池有、本地无) */ + int created; + /** 镜像有变化并重写条数 */ + int updated; + /** 镜像无变化跳过条数 */ + int mirrorUnchanged; + /** 登记补绑条数(罕见:存量手工数据未绑定时顺带补绑) */ + int bound; + /** 登记无变化跳过条数 */ + int unchanged; + /** 页级批量落库失败条数(整页跳过,下轮幂等重试) */ + int failed; + /** 实际处理的翻页总页数 */ + int pageCount; + /** 实际处理的切片数 */ + int sliceCount; + } + + /** + * 同步单个时间片 [sliceStart, sliceEnd):独立翻页拉取(最多 MAX_POOL_PAGES 页), + * 触顶说明该时间片数据 >2.5 万,从中点二分为两片递归续扫(递归深度受时间粒度自然收敛, + * 单片 500 条以内 1 页即短页终止) + * + * @param sliceStart 切片起点(含) + * @param sliceEnd 切片终点(不含,避免与下一片边界重复拉取) + * @param stats 整轮统计累加器 + */ + private void syncWindowSlice(LocalDateTime sliceStart, LocalDateTime sliceEnd, String invoiceType, SyncStats stats) { + // 二分收敛保护:切片粒度细到 1 秒仍触顶说明远端异常(同秒 >2.5 万条不可能),不再二分直接记漏 + if (!sliceStart.isBefore(sliceEnd)) { + log.error("金蝶全票池同步切片区间为空({} ~ {}),远端 updateTime 粒度异常,放弃该切片", sliceStart, sliceEnd); + return; + } + boolean truncated = false; + for (int pageNo = 1; pageNo <= MAX_POOL_PAGES; pageNo++) { + List rows = this.pullPoolWindowPage(sliceStart, sliceEnd, invoiceType, pageNo); + if (rows.isEmpty()) { + break; + } + stats.pageCount++; + stats.pulledCount += rows.size(); + BatchUpsertResult upsertResult = this.batchUpsertInvoicePool(rows); + stats.created += upsertResult.created(); + stats.updated += upsertResult.updated(); + stats.mirrorUnchanged += upsertResult.unchanged(); + // 已登记票跟随补绑:同页发票号码批量定位登记,绑定关系变更页级事务批量落库 + if (!upsertResult.mirrors().isEmpty()) { + RefreshResult refreshResult = this.refreshBoundReceipts(stats.pageCount, upsertResult.mirrors()); + stats.bound += refreshResult.bound(); + stats.unchanged += refreshResult.unchanged(); + stats.failed += refreshResult.failed(); + } + // 远程短页:该片到底,正常终止 + if (rows.size() < POOL_PAGE_SIZE) { + stats.sliceCount++; + return; + } + truncated = true; + } + // 触顶:该切片数据超过单查询窗口上限,从中点二分续扫(updateTime 落在中点的行归左片 [start, mid), + // 右片 [mid, end) 不含 mid,同 updateTime 行整体归左片,不重不漏) + if (truncated) { + LocalDateTime mid = sliceStart.plus(java.time.Duration.between(sliceStart, sliceEnd).dividedBy(2)); + if (!mid.isAfter(sliceStart)) { + // 切片已细到不足 1 秒仍满页:理论不可能,防御死递归 + log.error("金蝶全票池同步切片 {} ~ {} 已达最小粒度仍满页(>{} 条),该时间片数据异常,跳过", + sliceStart, sliceEnd, MAX_POOL_PAGES * POOL_PAGE_SIZE); + return; + } + log.warn("金蝶全票池同步切片 {} ~ {} 触达翻页保护 {} 页({} 条),二分续扫 [{} ~ {}) + [{} ~ {})", + sliceStart, sliceEnd, MAX_POOL_PAGES, MAX_POOL_PAGES * POOL_PAGE_SIZE, sliceStart, mid, mid, sliceEnd); + this.syncWindowSlice(sliceStart, mid, invoiceType, stats); + this.syncWindowSlice(mid, sliceEnd, invoiceType, stats); + return; + } + stats.sliceCount++; + } + + /** 已登记票跟随刷新结果统计(绑定/刷新/无变化/失败) */ + private record RefreshResult(int bound, int unchanged, int failed) { + } + + /** + * 同页发票号码批量定位收票登记,绑定关系变更页级事务批量落库(39ffac1 口径瘦身版): + * 新架构下登记绑定在保存时完成且登记无金蝶流水号/状态字段(70d5d34 已删), + * 已绑定登记零写入,仅未绑定存量登记(罕见,人工数据)顺带补绑;批量失败整页跳过待下轮幂等重试 + * + * @param pageNo 当前页码(日志定位) + * @param mirrorMap 本页 invoiceNo -> 刚 upsert 的镜像 + * @return 刷新结果统计 + */ + private RefreshResult refreshBoundReceipts(int pageNo, Map mirrorMap) { + Map> receiptMap = list(Wrappers.lambdaQuery() + .in(InvoiceReceipt::getInvoiceNo, mirrorMap.keySet()) + .ne(InvoiceReceipt::getApprovalStatus, TransportBusiConstant.VOIDED)).stream() + .filter(receipt -> !Objects.equals(receipt.getIsDeleted(), 1)) + .collect(Collectors.groupingBy(InvoiceReceipt::getInvoiceNo)); + // 镜像有了却没人登记:本页不产生任何刷新,属正常(票池范围大于我方登记范围) + if (receiptMap.isEmpty()) { + return new RefreshResult(0, 0, 0); + } + List changedReceipts = new ArrayList<>(); + List syncRecords = new ArrayList<>(); + int bound = 0; + int unchanged = 0; + for (Map.Entry> entry : receiptMap.entrySet()) { + KingdeeInvoicePool mirror = mirrorMap.get(entry.getKey()); + for (InvoiceReceipt receipt : entry.getValue()) { + // 新架构登记无 kingdeeBillNo/kingdeeStatus 字段(70d5d34 已删),仅绑定关系可变: + // 已绑定且指向同一镜像的登记零写入;未绑定存量登记(罕见,人工数据)顺带补绑 + boolean unbound = receipt.getKingdeeInvoicePoolId() == null + || !Objects.equals(receipt.getKingdeeInvoicePoolId(), mirror.getId()); + if (!unbound) { + unchanged++; + continue; + } + receipt.setKingdeeInvoicePoolId(mirror.getId()); + changedReceipts.add(receipt); + syncRecords.add(this.buildPoolSyncRecord(receipt, "定时绑定金蝶票池发票")); + bound++; + } + } + if (changedReceipts.isEmpty()) { + return new RefreshResult(0, unchanged, 0); + } + try { + // 页级事务批量落库:绑定/状态批量更新 + 同步流水批量写入(JDBC batch,整页原子生效) + Db.updateBatchById(changedReceipts); + if (!syncRecords.isEmpty()) { + Db.saveBatch(syncRecords); + } + log.info("金蝶全票池同步 第{}页落库完成,补绑 {} 条,同步流水 {} 条", + pageNo, changedReceipts.size(), syncRecords.size()); + return new RefreshResult(bound, unchanged, 0); + } catch (Exception batchException) { + // 批量落库失败:整页跳过计入失败,下轮任务幂等重试,不做逐单降级(保持全批量链路) + log.error("金蝶全票池同步登记刷新批量落库异常,第{}页 {} 条跳过待下轮重试", pageNo, changedReceipts.size(), batchException); + return new RefreshResult(0, unchanged, changedReceipts.size()); + } + } + + /** 构建金蝶票池同步流水(操作人以「定时任务」名义记录) */ + private InvoiceReceiptRecord buildPoolSyncRecord(InvoiceReceipt receipt, String reason) { + InvoiceReceiptRecord syncRecord = new InvoiceReceiptRecord(); + syncRecord.setInvoiceReceiptId(receipt.getId()); + syncRecord.setActionType("sync"); + syncRecord.setActionName(reason); + syncRecord.setFromStatus(receipt.getApprovalStatus()); + syncRecord.setToStatus(receipt.getApprovalStatus()); + syncRecord.setOperatorName(AuthUtil.getUserName() == null ? "定时任务" : AuthUtil.getUserName()); + return syncRecord; + } + + /** A801-1 按票池更新时间窗口分页拉取(只认正常票,不推单据使用状态;invoiceType 单值下推,null 不限) */ + private List pullPoolWindowPage(LocalDateTime windowStart, LocalDateTime windowEnd, + String invoiceType, int pageNo) { + BfiInvoicePoolQueryDataDTO data = new BfiInvoicePoolQueryDataDTO(); + data.setPageNo(pageNo); + data.setPageSize(POOL_PAGE_SIZE); + data.setUpdateTime(windowStart.format(POOL_TIME_FORMATTER)); + data.setUpdateTimeEnd(windowEnd.format(POOL_TIME_FORMATTER)); + data.setInvoiceStatus(KingdeeBfiConstant.POOL_INVOICE_STATUS_NORMAL); + data.setInvoiceType(invoiceType); + BfiInvoicePoolQueryDTO param = new BfiInvoicePoolQueryDTO(); + param.setQueryParam(data); + BfiPageVO poolPage = bfiService.queryInvoicePool(param); + log.info("金蝶全票池同步 第{}页拉取 {} 条", pageNo, poolPage.getRows() == null ? 0 : poolPage.getRows().size()); + return poolPage.getRows() == null ? List.of() : poolPage.getRows(); + } + + /** + * 票池镜像批量 upsert:一次查询同页既有镜像,全列覆盖批量写库(新增批量插入、既有批量更新), + * 避免逐条查改 N+1;MyBatis-Plus 非空字段更新策略下源侧空字段维持库中原值,与手动同步路径同口径 + * + * @param rows A801-1 窗口拉取的票池发票 + * @return 批量 upsert 结果 + */ + private BatchUpsertResult batchUpsertInvoicePool(List rows) { + Map mirrorMap = new LinkedHashMap<>(); + if (rows.isEmpty()) { + return new BatchUpsertResult(mirrorMap, 0, 0, 0); + } + List invoiceNos = rows.stream() + .map(row -> row.getInvoiceNo().trim()) + .distinct() + .toList(); + // 同页发票号码一次批量查询既有镜像 + Map existingMap = invoicePoolMapper.selectList( + Wrappers.lambdaQuery() + .in(KingdeeInvoicePool::getInvoiceNo, invoiceNos)).stream() + .collect(Collectors.toMap(KingdeeInvoicePool::getInvoiceNo, Function.identity(), (first, second) -> first)); + List toInsert = new ArrayList<>(); + List toUpdate = new ArrayList<>(); + int unchangedCount = 0; + for (BfiInvoicePoolVO row : rows) { + if (Func.isEmpty(row.getInvoiceNo())) { + continue; + } + KingdeeInvoicePool existing = existingMap.get(row.getInvoiceNo().trim()); + KingdeeInvoicePool mirror = fillInvoicePool(existing, row); + mirrorMap.put(mirror.getInvoiceNo(), mirror); + if (existing == null) { + toInsert.add(mirror); + } else if (poolMirrorChanged(existing, mirror)) { + toUpdate.add(mirror); + } else { + // 票面无变化跳过重写:整窗重复拉取时消掉每轮 2.5 万行无效写 + unchangedCount++; + } + } + // 批量写库:失败中断本轮,由任务层记录异常、下轮重试(upsert 幂等) + if (!toInsert.isEmpty()) { + Db.saveBatch(toInsert); + } + if (!toUpdate.isEmpty()) { + Db.updateBatchById(toUpdate); + } + log.info("金蝶全票池镜像批量写入完成,新增 {} 条,更新 {} 条,无变化 {} 条", toInsert.size(), toUpdate.size(), unchangedCount); + return new BatchUpsertResult(mirrorMap, toInsert.size(), toUpdate.size(), unchangedCount); + } + + /** + * 镜像票面是否有变化(轻量比对,ponytail: 只比对"会变且影响业务"的字段, + * 不搞人工字段清单——清单漏新增列即静默不同步是旧版指纹的坑): + * 流水号(金蝶侧标识)、价税合计、税额、剩余可用金额、金额使用状态。 + * BigDecimal 比对用 compareTo(13.00 与 13.0 等价);空值视为变化跳过重写(与 MP 非空字段更新策略一致) + * + * @param existing 库中既有镜像 + * @param filled 填充后的镜像 + * @return true 表示有变化需重写 + */ + private boolean poolMirrorChanged(KingdeeInvoicePool existing, KingdeeInvoicePool filled) { + if (!Objects.equals(existing.getSerialNo(), filled.getSerialNo()) + || !Objects.equals(existing.getAmountUseStatus(), filled.getAmountUseStatus())) { + return true; + } + return amountChanged(existing.getInvoiceAmount(), filled.getInvoiceAmount()) + || amountChanged(existing.getTaxAmount(), filled.getTaxAmount()) + || amountChanged(existing.getAvailableAmount(), filled.getAvailableAmount()); + } + + /** 金额变化比对:双方均空视为无变化;一方空一方有视为有变化;否则按数值比较 */ + private boolean amountChanged(BigDecimal before, BigDecimal after) { + if (before == null && after == null) { + return false; + } + if (before == null || after == null) { + return true; + } + return before.compareTo(after) != 0; + } + + /** 票池镜像批量 upsert 结果(invoiceNo -> 镜像,新增条数、更新条数、无变化条数) */ + private record BatchUpsertResult(Map mirrors, int created, int updated, int unchanged) { + } + + // ==================== 结算单认领确认重查闸门 ==================== + + @Override + public void assertInvoicesClaimable(Collection invoiceNos) { + List distinctNos = invoiceNos == null ? List.of() : invoiceNos.stream() + .filter(Func::isNotEmpty) + .map(String::trim) + .distinct() + .toList(); + for (String invoiceNo : distinctNos) { + // 窄查 + 票号全等防御 + 选票同口径可用复核:选中到确认之间票被认领/使用/红冲在此拦截 + BfiInvoicePoolVO poolRow = queryPoolByInvoiceNo(invoiceNo).stream() + .filter(row -> StringUtil.isNotBlank(row.getInvoiceNo()) + && invoiceNo.equals(row.getInvoiceNo().trim())) + .findFirst() + .orElse(null); + if (poolRow == null) { + throw new ServiceException("金蝶票池未查询到发票 " + invoiceNo + ",不能认领"); + } + if (!this.isUsablePoolRow(poolRow)) { + throw new ServiceException("发票 " + invoiceNo + " 剩余可用金额为0或金额使用状态已变更,不能认领"); + } + } + } + + // ==================== 金蝶票池发票文件下载(A801-2 前端代理,见 2026-09-27 设计文档) ==================== + + @Override + public void downloadPoolAttachment(String invoiceNo, HttpServletResponse response) { + String trimmedInvoiceNo = trimOrNull(invoiceNo); + if (StringUtil.isBlank(trimmedInvoiceNo)) { + WebUtil.renderJson(response, R.fail("发票号不能为空")); + return; + } + try { + doDownloadPoolAttachment(trimmedInvoiceNo, response); + } catch (ServiceException exception) { + // 写流前的业务失败统一以 JSON 返回:前端按响应 Content-Type 识别提示,浏览器不落损坏文件 + log.warn("金蝶票池发票文件下载失败:发票[{}]:{}", trimmedInvoiceNo, exception.getMessage()); + WebUtil.renderJson(response, R.fail(exception.getMessage())); + } catch (IOException exception) { + // 传输中断类失败(客户端断开等):响应通道已不可用,仅记录日志 + log.error("金蝶票池发票文件下载传输异常:发票[{}]", trimmedInvoiceNo, exception); + } + } + + /** + * 下载主流程:按发票号定位金蝶侧文件 path(镜像优先、A801-1 实时回源兜底)→ A801-2 文件流 + * → 先校验后写头(失败在前端可识别阶段暴露)→ 8KB 流式透传,不落盘不进堆 + */ + private void doDownloadPoolAttachment(String invoiceNo, HttpServletResponse response) throws IOException { + KingdeeAttachmentSyncSupport.KingdeeAttachmentItem item = resolvePoolAttachment(invoiceNo); + if (item == null || Func.isBlank(item.key())) { + throw new ServiceException("该发票没有金蝶侧文件"); + } + long start = System.currentTimeMillis(); + Response bfiResponse = bfiService.downloadInvoice(item.key()); + try { + String contentType = firstHeader(bfiResponse, "Content-Type"); + // A801-2 失败以 JSON 体返回(如 {"status":false,"errorCode":"FILE_IS_NOT_EXIST","message":"附件不存在。"}),先校验再写流 + if (contentType != null && contentType.contains("application/json")) { + throw new ServiceException("调用BFI文件下载失败:" + readBfiJsonMessage(bfiResponse)); + } + String fileName = Func.isBlank(item.name()) ? invoiceNo + ".pdf" : item.name(); + // BFI 文件流统一回 octet-stream:PDF 版式须显式声明 MIME,否则浏览器 blob 不渲染、查看退化为静默下载 + if (fileName.endsWith(".pdf") && (Func.isBlank(contentType) || contentType.contains("octet-stream"))) { + contentType = "application/pdf"; + } + // 文件名按 RFC 5987 编码(+ 号转 %20),中文/特殊字符不乱码 + String encodedName = URLEncoder.encode(fileName, StandardCharsets.UTF_8).replace("+", "%20"); + response.setContentType(Func.isBlank(contentType) ? "application/pdf" : contentType); + String contentLength = firstHeader(bfiResponse, "Content-Length"); + if (contentLength != null) { + try { + response.setContentLengthLong(Long.parseLong(contentLength)); + } catch (NumberFormatException ignored) { + // 非法长度头按无长度处理,走分块传输 + } + } + response.setHeader("Content-Disposition", + "attachment; filename=\"" + encodedName + "\"; filename*=UTF-8''" + encodedName); + int totalBytes = 0; + try (InputStream inputStream = bfiResponse.body().asInputStream(); + OutputStream outputStream = response.getOutputStream()) { + byte[] buffer = new byte[DOWNLOAD_BUFFER_SIZE]; + int read; + while ((read = inputStream.read(buffer)) != -1) { + outputStream.write(buffer, 0, read); + totalBytes += read; + } + outputStream.flush(); + } + log.info("金蝶票池发票文件下载完成:发票[{}],文件[{}],{}字节,耗时{}ms", + invoiceNo, fileName, totalBytes, System.currentTimeMillis() - start); + } finally { + bfiResponse.close(); + } + } + + /** + * 定位金蝶侧发票文件:本地镜像优先(票号同租户唯一,租户隔离由 MP 插件保证), + * 镜像未落库(新进票池尚未同步)或镜像无文件时实时回源 A801-1 按票号取 downloadUrl; + * 远程票号查询为模糊匹配,命中行需与目标票号全等防御 + */ + private KingdeeAttachmentSyncSupport.KingdeeAttachmentItem resolvePoolAttachment(String invoiceNo) { + List mirrors = invoicePoolMapper.selectList(Wrappers.lambdaQuery() + .eq(KingdeeInvoicePool::getInvoiceNo, invoiceNo)); + if (Func.isNotEmpty(mirrors)) { + Optional mirrorItem = kingdeeAttachmentSyncSupport + .parseInvoicePoolFile(mirrors.get(0).getAttachmentsJson()).stream().findFirst(); + if (mirrorItem.isPresent()) { + return mirrorItem.get(); + } + } + List rows = queryPoolByInvoiceNo(invoiceNo); + for (BfiInvoicePoolVO row : rows) { + if (row.getInvoiceNo() != null && invoiceNo.equals(row.getInvoiceNo().trim())) { + return kingdeeAttachmentSyncSupport.parseInvoicePoolFile(buildAttachmentJson(row)) + .stream().findFirst().orElse(null); + } + } + return null; + } + + /** 读取 A801-2 JSON 错误体提示信息(优先 message,兜底 errorCode) */ + private String readBfiJsonMessage(Response response) { + try (InputStream inputStream = response.body().asInputStream()) { + Object parsed = JsonUtil.parse(new String(inputStream.readAllBytes(), StandardCharsets.UTF_8), Object.class); + if (parsed instanceof Map map) { + String message = firstTextValue(map, "message"); + if (Func.isNotBlank(message)) { + return message; + } + String errorCode = firstTextValue(map, "errorCode"); + if (Func.isNotBlank(errorCode)) { + return errorCode; + } + } + } catch (Exception exception) { + log.warn("解析BFI文件下载错误响应失败", exception); + } + return "响应体为JSON错误"; + } + + /** 大小写不敏感取第一个响应头值 */ + private String firstHeader(Response response, String name) { + for (Map.Entry> entry : response.headers().entrySet()) { + if (name.equalsIgnoreCase(entry.getKey()) && Func.isNotEmpty(entry.getValue())) { + return entry.getValue().iterator().next(); + } + } + return null; + } + + private String firstTextValue(Map map, String key) { + Object value = map.get(key); + return value == null ? null : String.valueOf(value); + } + + /** + * 税率优先取顶层小数换算为百分数(0.13 -> 13),顶层为空回退首条明细,均无效时置 0(镜像列非空约束) + */ + private BigDecimal resolveTaxRate(BfiInvoicePoolVO row) { + String invoiceNo = row.getInvoiceNo(); + BigDecimal taxRate = parsePercent(invoiceNo, row.getTaxRate()); + if (taxRate == null) { + taxRate = Optional.ofNullable(row.getItems()).orElse(List.of()).stream() + .map(BfiInvoicePoolItemVO::getTaxRate) + .filter(StringUtil::isNotBlank) + .findFirst() + .map(value -> parsePercent(invoiceNo, value)) + .orElse(null); + } + return taxRate == null ? BigDecimal.ZERO : taxRate; + } + + private BigDecimal parsePercent(String invoiceNo, String value) { + if (StringUtil.isBlank(value)) { + return null; + } + try { + return new BigDecimal(value.trim()).multiply(BigDecimal.valueOf(100)); + } catch (NumberFormatException exception) { + log.warn("金蝶票池发票税率解析失败,按 0 处理,发票号码={},税率={}", invoiceNo, value); + return null; + } + } + + private BigDecimal parseAmount(String invoiceNo, String value) { + if (StringUtil.isBlank(value)) { + return BigDecimal.ZERO; + } + try { + return new BigDecimal(value.trim()).setScale(2, RoundingMode.HALF_UP); + } catch (NumberFormatException exception) { + log.warn("金蝶票池发票金额解析失败,按 0 处理,发票号码={},金额={}", invoiceNo, value); + return BigDecimal.ZERO; + } + } + + private LocalDate parseDate(String invoiceNo, String value) { + if (StringUtil.isBlank(value)) { + return null; + } + try { + return LocalDate.parse(value.trim().substring(0, 10)); + } catch (Exception exception) { + log.warn("金蝶票池发票开票日期解析失败,置空处理,发票号码={},日期={}", invoiceNo, value); + return null; + } + } + + private LocalDateTime parseDateTime(String invoiceNo, String value) { + if (StringUtil.isBlank(value)) { + return null; + } + try { + return LocalDateTime.parse(value.trim().replace(" ", "T").substring(0, 19)); + } catch (Exception exception) { + log.warn("金蝶票池发票源侧时间解析失败,置空处理,发票号码={},时间={}", invoiceNo, value); + return null; + } + } + + private String truncate(String value, int length) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.length() > length ? trimmed.substring(0, length) : trimmed; } private InvoiceReceiptVO toListVO(InvoiceReceipt entity) { @@ -362,9 +1787,6 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl { InvoiceReceipt receipt = receipts.get(relation.getInvoiceReceiptId()); - return receipt != null && !VOIDED.equals(receipt.getApprovalStatus()) + return receipt != null && !TransportBusiConstant.VOIDED.equals(receipt.getApprovalStatus()) && !Objects.equals(receipt.getIsDeleted(), 1); }).toList(); } @@ -585,15 +1986,6 @@ public class InvoiceReceiptServiceImpl 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. + *

+ * 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 lombok.extern.slf4j.Slf4j; +import org.springblade.core.mp.base.BaseServiceImpl; +import org.springblade.transport.mapper.KingdeeAttachmentSyncMapper; +import org.springblade.transport.pojo.entity.KingdeeAttachmentSync; +import org.springblade.transport.service.IKingdeeAttachmentSyncService; +import org.springframework.stereotype.Service; + +/** + * 金蝶附件同步服务实现 + * + * @author Chill + */ +@Slf4j +@Service +public class KingdeeAttachmentSyncServiceImpl extends BaseServiceImpl + implements IKingdeeAttachmentSyncService { +} 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 new file mode 100644 index 00000000..c39e563c --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/KingdeeBillLockServiceImpl.java @@ -0,0 +1,421 @@ +/** + * 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 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; +import org.springblade.thirdparty.bfi.pojo.dto.BfiBillLockDTO; +import org.springblade.thirdparty.bfi.service.IBfiService; +import org.springblade.transport.config.BfiProperties; +import org.springblade.transport.constant.KingdeeLockConstant; +import org.springblade.transport.constant.TransportBusiConstant; +import org.springblade.transport.mapper.FormalSettlementMapper; +import org.springblade.transport.mapper.InvoiceApplicationMapper; +import org.springblade.transport.mapper.KingdeeBillLockMapper; +import org.springblade.transport.mapper.PaymentApplicationMapper; +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; +import org.springblade.transport.service.IPaymentApplicationService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * 金蝶单据锁定记录服务实现 + *

+ * 锁定:推送链路事务提交后逐张 LOCK(接口 1对1:单次一个源单号对一个金蝶单号,每张先落 failed 行占位再调 + * lockBusiData,成功翻 locked;单张成败互不影响),失败由补偿定时任务重试;落库统一批量更新。 + * 解锁:金蝶 unLockNotifyBusiData 回调(支持逗号拼接批量), + * DELETE 时分发废除源单并还原关联数据。 + * 与结算单/开票申请服务互相依赖,为避免构造器循环依赖,单据服务经 {@code SpringUtil.getBean} 运行时获取 + * (与本工程 doSyncKingdee 自代理同一惯用法) + * + * @author Chill + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class KingdeeBillLockServiceImpl extends ServiceImpl implements IKingdeeBillLockService { + + /** 金蝶各接口统一日期文本格式(与 A102-2/A102-4/A103-1 等一致) */ + private static final DateTimeFormatter BFI_DATE_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + private final IBfiService bfiService; + private final BfiProperties bfiProperties; + private final FormalSettlementMapper formalSettlementMapper; + private final InvoiceApplicationMapper invoiceApplicationMapper; + private final PaymentApplicationMapper paymentApplicationMapper; + + @Override + public String lockAfterPush(String sourceBillType, Long sourceBillId, String sourceBillNo, String targetBillType, List targetBillNos) { + // 总开关关闭:不落锁记录、不调 lockBusiData,返回 null 不影响推送主流程 + if (!bfiProperties.getBillLock().isEnabled()) { + log.info("金蝶单据锁定总开关关闭,跳过锁定:源单[{}:{}]", sourceBillType, sourceBillNo); + return null; + } + if (Func.isEmpty(targetBillNos)) { + return null; + } + String operator = operatorName(); + // 待锁目标单:已 locked 幂等跳过(人工重推「同步金蝶」幂等命中路径不会重复锁); + // 其余(新目标单/failed 重锁/同单重推)先落 failed 行占位,保证补偿扫表无遗漏 + List pendingRecords = new ArrayList<>(); + for (String targetBillNo : targetBillNos.stream().filter(Func::isNotBlank).distinct().toList()) { + KingdeeBillLock lockRecord = latestRecord(sourceBillType, sourceBillId, targetBillNo); + if (lockRecord != null && KingdeeLockConstant.STATUS_LOCKED.equals(lockRecord.getLockStatus())) { + continue; + } + if (lockRecord == null) { + lockRecord = new KingdeeBillLock(); + lockRecord.setSourceBillType(sourceBillType); + lockRecord.setSourceBillId(sourceBillId); + lockRecord.setSourceBillNo(sourceBillNo); + lockRecord.setTargetBillType(targetBillType); + lockRecord.setTargetBillNo(targetBillNo); + lockRecord.setLockStatus(KingdeeLockConstant.STATUS_FAILED); + lockRecord.setOperationType(KingdeeLockConstant.OPERATION_SAVE); + lockRecord.setTryCount(0); + lockRecord.setLockOperator(operator); + this.save(lockRecord); + } else { + // 人工重推触发的补锁:重置补偿计数重新起算(定时补偿烧完上限后,人工动作可重新接管自动补偿) + KingdeeBillLock resetRecord = new KingdeeBillLock(); + resetRecord.setId(lockRecord.getId()); + resetRecord.setTryCount(0); + this.updateById(resetRecord); + lockRecord.setTryCount(0); + } + pendingRecords.add(lockRecord); + } + if (pendingRecords.isEmpty()) { + return null; + } + // 锁单接口 1对1:单次仅一个源单号对一个金蝶单号,逐张调用,单张成败互不影响;成败落库批量更新 + LocalDateTime now = LocalDateTime.now(); + List lockedRecords = new ArrayList<>(); + List failedRecords = new ArrayList<>(); + for (KingdeeBillLock lockRecord : pendingRecords) { + try { + bfiService.lockBusiData(this.buildLockParam(sourceBillType, sourceBillNo, targetBillType, lockRecord.getTargetBillNo(), operator)); + lockRecord.setLockStatus(KingdeeLockConstant.STATUS_LOCKED); + lockRecord.setLockTime(now); + lockRecord.setFailReason(null); + lockedRecords.add(lockRecord); + log.info("金蝶单据锁定成功:源单[{}:{}],金蝶单[{}]", sourceBillType, sourceBillNo, lockRecord.getTargetBillNo()); + } catch (Exception exception) { + String failReason = limitText(exception.getMessage(), 500); + lockRecord.setTryCount((lockRecord.getTryCount() == null ? 0 : lockRecord.getTryCount()) + 1); + lockRecord.setFailReason(failReason); + failedRecords.add(lockRecord); + log.error("金蝶单据锁定失败(待补偿重锁):源单[{}:{}],金蝶单[{}],原因:{}", sourceBillType, sourceBillNo, lockRecord.getTargetBillNo(), failReason, exception); + } + } + this.updateBatchById(lockedRecords); + this.updateBatchById(failedRecords); + if (failedRecords.isEmpty()) { + log.info("金蝶单据锁定全部成功:源单[{}:{}],金蝶单{}张[{}]", sourceBillType, sourceBillNo, lockedRecords.size(), + lockedRecords.stream().map(KingdeeBillLock::getTargetBillNo).collect(Collectors.joining(","))); + return null; + } + String failedNos = failedRecords.stream().map(KingdeeBillLock::getTargetBillNo).collect(Collectors.joining(",")); + log.warn("金蝶单据锁定汇总:源单[{}:{}],成功{}张,失败{}张[{}]", sourceBillType, sourceBillNo, lockedRecords.size(), failedRecords.size(), failedNos); + return failedNos; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public int onUnlockNotify(BfiBillLockDTO param) { + // 总开关关闭:只应答不处理(返回 0 与「未匹配」同构,金蝶侧不会触发重试) + if (!bfiProperties.getBillLock().isEnabled()) { + log.warn("金蝶单据锁定总开关关闭,跳过解锁回调处理:operationType={},金蝶单号={}", + param == null ? null : param.getOperationType(), param == null ? null : param.getSourceBillNo()); + return 0; + } + if (param == null || !KingdeeLockConstant.SYSTEM_XH.equals(param.getSourceSystem()) + || !KingdeeLockConstant.SYSTEM_WLTMS.equals(param.getTargetSystem()) + || !KingdeeLockConstant.ACTION_UNLOCK.equals(param.getLockAction())) { + throw new ServiceException("金蝶解锁回调参数非法:sourceSystem须为XH、targetSystem须为WLTMS、lockAction须为UNLOCK"); + } + if (Func.isBlank(param.getSourceBillNo()) || Func.isBlank(param.getTargetBillNo())) { + throw new ServiceException("金蝶解锁回调参数非法:sourceBillNo、targetBillNo不能为空"); + } + // 回调方向与锁定相反:回调 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("金蝶解锁回调未匹配锁定中的锁记录(幂等跳过):金蝶单号={},回调targetBillNo={}", + kingdeeBillNo, param.getTargetBillNo()); + continue; + } + lockRecord.setLockStatus(KingdeeLockConstant.STATUS_UNLOCKED); + lockRecord.setOperationType(param.getOperationType()); + lockRecord.setUnlockTime(LocalDateTime.now()); + lockRecord.setNotifyOperator(param.getOperator()); + unlockRecords.add(lockRecord); + log.info("金蝶单据[{}:{}]解锁回调落库:operationType={},操作人={}", + lockRecord.getSourceBillType(), lockRecord.getSourceBillNo(), param.getOperationType(), param.getOperator()); + } + this.updateBatchById(unlockRecords); + // DELETE 分发废除还原:按源单逐张执行(各单据类型还原动作为独立业务事务,无法合并) + if (KingdeeLockConstant.OPERATION_DELETE.equals(param.getOperationType())) { + unlockRecords.forEach(lockRecord -> this.restoreOnKingdeeDelete(lockRecord, param.getOperator())); + } + return unlockRecords.size(); + } + + /** + * 金蝶删单(DELETE 回调)分发:废除源单并还原关联数据(还原口径详见各 voidByKingdee 实现) + * + * @param lockRecord 锁记录(携带源单类型与源单主键) + * @param operator 金蝶侧操作人 + */ + private void restoreOnKingdeeDelete(KingdeeBillLock lockRecord, String operator) { + String sourceType = KingdeeLockConstant.TARGET_TO_SOURCE_MAP.get(lockRecord.getTargetBillType()); + switch (sourceType == null ? "" : sourceType) { + case KingdeeLockConstant.SOURCE_SETTLEMENT -> { + FormalSettlement settlement = formalSettlementMapper.selectById(lockRecord.getSourceBillId()); + if (settlement == null || Objects.equals(settlement.getIsDeleted(), 1)) { + log.warn("金蝶删单回调源结算单不存在:id={}", lockRecord.getSourceBillId()); + return; + } + if (TransportBusiConstant.VOIDED.equals(settlement.getApprovalStatus())) { + log.info("金蝶删单回调源结算单[{}]已作废(幂等跳过)", settlement.getFormalSettlementNo()); + return; + } + SpringUtil.getBean(IFormalSettlementService.class).voidByKingdee(settlement.getId(), operator); + } + case KingdeeLockConstant.SOURCE_INVOICE_APPLY -> { + InvoiceApplication application = invoiceApplicationMapper.selectById(lockRecord.getSourceBillId()); + if (application == null || Objects.equals(application.getIsDeleted(), 1)) { + log.warn("金蝶删单回调源开票申请不存在:id={}", lockRecord.getSourceBillId()); + return; + } + if (TransportBusiConstant.VOIDED.equals(application.getApprovalStatus())) { + log.info("金蝶删单回调源开票申请[{}]已作废(幂等跳过)", application.getApplicationNo()); + return; + } + SpringUtil.getBean(IInvoiceApplicationService.class).voidByKingdee(application.getId(), operator); + } + case KingdeeLockConstant.SOURCE_PAY_APPLY -> { + PaymentApplication application = paymentApplicationMapper.selectById(lockRecord.getSourceBillId()); + if (application == null || Objects.equals(application.getIsDeleted(), 1)) { + log.warn("金蝶删单回调源付款申请不存在:id={}", lockRecord.getSourceBillId()); + return; + } + if (TransportBusiConstant.VOIDED.equals(application.getApprovalStatus())) { + log.info("金蝶删单回调源付款申请[{}]已作废(幂等跳过)", application.getPaymentNo()); + return; + } + // 复用 ADR-0005 解锁口径:不作废源单,kingdeeStatus→生成失败、清编号内码(原值留档), + // 不自动重推,人工核实后重新推送(A103-2 勾稽预检防重复推单) + SpringUtil.getBean(IPaymentApplicationService.class).unlockBill(application.getId(), "金蝶删单回调"); + } + default -> log.warn("金蝶删单回调暂不支持的单据类型:targetBillType={}(映射预留)", lockRecord.getTargetBillType()); + } + } + + @Override + public void checkEditable(String sourceBillType, Long sourceBillId) { + long lockedCount = this.count(Wrappers.lambdaQuery() + .eq(KingdeeBillLock::getSourceBillType, sourceBillType) + .eq(KingdeeBillLock::getSourceBillId, sourceBillId) + .eq(KingdeeBillLock::getLockStatus, KingdeeLockConstant.STATUS_LOCKED)); + if (lockedCount > 0) { + throw new ServiceException("该单据已被金蝶锁定,不可修改"); + } + } + + @Override + public Map mapActiveLocks(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) + .in(KingdeeBillLock::getLockStatus, List.of(KingdeeLockConstant.STATUS_LOCKED, KingdeeLockConstant.STATUS_FAILED)) + .orderByAsc(KingdeeBillLock::getId)); + // 升序取回后写覆盖:每源单保留最新一条;unlocked 终态不参与查询 + return lockRecords.stream().collect(Collectors.toMap(KingdeeBillLock::getSourceBillId, Function.identity(), (first, second) -> 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(KingdeeLockConstant.OPERATION_DELETE.equals(unlockRecord.getOperationType()) + ? "金蝶删单" : "金蝶锁单"); + } + } + + /** + * 锁定补偿:扫 failed 且 try_count 未达重试上限(RETRY_MAX_COUNT)的锁记录,逐张重调 lockBusiData + * (接口 1对1:单次一个源单号对一个金蝶单号,单张成败互不影响,由定时任务定时触发,亦可人工触发)。 + *

成功翻 locked(补锁时间、清失败原因);失败 try_count+1 并记失败原因,计数烧满上限后不再自动重试 + * (人工重推「同步金蝶」重置计数重新接管);成败落库均为批量更新(updateBatchById),避免逐张 update + */ + @Override + public String retryFailedLocks() { + // 总开关关闭:定时任务与手动补偿一并跳过 + if (!bfiProperties.getBillLock().isEnabled()) { + return "金蝶单据锁定总开关关闭,跳过补偿"; + } + List failedList = this.list(Wrappers.lambdaQuery() + .eq(KingdeeBillLock::getLockStatus, KingdeeLockConstant.STATUS_FAILED) + .lt(KingdeeBillLock::getTryCount, KingdeeLockConstant.RETRY_MAX_COUNT)); + if (Func.isEmpty(failedList)) { + return "无待补偿锁记录"; + } + log.info("金蝶单据锁定补偿开始:待补偿{}张", failedList.size()); + LocalDateTime now = LocalDateTime.now(); + List lockedRecords = new ArrayList<>(); + List failedRecords = new ArrayList<>(); + for (KingdeeBillLock lockRecord : failedList) { + String operator = Func.isBlank(lockRecord.getLockOperator()) ? "system" : lockRecord.getLockOperator(); + try { + bfiService.lockBusiData(this.buildLockParam(lockRecord.getSourceBillType(), lockRecord.getSourceBillNo(), + lockRecord.getTargetBillType(), lockRecord.getTargetBillNo(), operator)); + lockRecord.setLockStatus(KingdeeLockConstant.STATUS_LOCKED); + lockRecord.setLockTime(now); + lockRecord.setFailReason(null); + lockedRecords.add(lockRecord); + log.info("金蝶单据锁定补偿成功:源单[{}:{}],金蝶单[{}]", + lockRecord.getSourceBillType(), lockRecord.getSourceBillNo(), lockRecord.getTargetBillNo()); + } catch (Exception exception) { + String failReason = limitText(exception.getMessage(), 500); + lockRecord.setTryCount((lockRecord.getTryCount() == null ? 0 : lockRecord.getTryCount()) + 1); + lockRecord.setFailReason(failReason); + failedRecords.add(lockRecord); + log.error("金蝶单据锁定补偿失败(try_count已+1,待下次补偿):源单[{}:{}],金蝶单[{}],原因:{}", + lockRecord.getSourceBillType(), lockRecord.getSourceBillNo(), lockRecord.getTargetBillNo(), failReason, exception); + } + } + this.updateBatchById(lockedRecords); + this.updateBatchById(failedRecords); + log.info("金蝶单据锁定补偿结束:待补偿{}张,成功{}张,仍失败{}张[{}]", failedList.size(), lockedRecords.size(), failedRecords.size(), + failedRecords.stream().map(KingdeeBillLock::getTargetBillNo).collect(Collectors.joining(","))); + return "待补偿" + failedList.size() + "张,成功" + lockedRecords.size() + "张"; + } + + /** + * 组装 lockBusiData 参数:WLTMS 调金蝶,源系统取配置(默认 WLTMS),目标系统 XH, + * 操作类型固定 LOCK/SAVE;接口 1对1:单次仅一个源单号对一个金蝶单号 + * + * @param sourceBillType WLTMS 单据类型 + * @param sourceBillNo WLTMS 单号 + * @param targetBillType 金蝶目标单据类型 + * @param targetBillNo 金蝶单据编号(单张) + * @param operator 操作人 + * @return 锁单参数 + */ + private BfiBillLockDTO buildLockParam(String sourceBillType, String sourceBillNo, String targetBillType, String targetBillNo, String operator) { + BfiBillLockDTO param = new BfiBillLockDTO(); + param.setLockAction(KingdeeLockConstant.ACTION_LOCK); + param.setSourceSystem(Func.isBlank(bfiProperties.getSrcSys()) ? KingdeeLockConstant.SYSTEM_WLTMS : bfiProperties.getSrcSys()); + param.setTargetSystem(KingdeeLockConstant.SYSTEM_XH); + param.setOperationType(KingdeeLockConstant.OPERATION_SAVE); + param.setSourceBillType(sourceBillType); + param.setSourceBillNo(sourceBillNo); + param.setTargetBillType(targetBillType); + param.setTargetBillNo(targetBillNo); + param.setTime(LocalDateTime.now().format(BFI_DATE_TIME)); + param.setOperator(operator); + return param; + } + + /** + * 查同源单同目标单最新一条锁记录 + * + * @param sourceBillType WLTMS 单据类型 + * @param sourceBillId WLTMS 业务主键 + * @param targetBillNo 金蝶单据编号 + * @return 最新锁记录,无则 null + */ + private KingdeeBillLock latestRecord(String sourceBillType, Long sourceBillId, String targetBillNo) { + return this.getOne(Wrappers.lambdaQuery() + .eq(KingdeeBillLock::getSourceBillType, sourceBillType) + .eq(KingdeeBillLock::getSourceBillId, sourceBillId) + .eq(KingdeeBillLock::getTargetBillNo, targetBillNo) + .orderByDesc(KingdeeBillLock::getId) + .last("limit 1"), false); + } + + /** 当前操作人:无登录态(定时任务/回调)时兜底 system(与 saveChange 同款兜底) */ + private String operatorName() { + return Func.isEmpty(AuthUtil.getUserName()) ? "system" : AuthUtil.getUserName(); + } + + /** 失败原因文本截断(fail_reason 列宽 500) */ + private String limitText(String text, int maxLength) { + if (Func.isBlank(text)) { + return null; + } + return text.length() <= maxLength ? text : text.substring(0, maxLength); + } +} 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 05cc4a34..38057c1a 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 @@ -23,53 +23,89 @@ 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.redis.cache.BladeRedis; 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.core.tool.utils.SpringUtil; import org.springblade.system.cache.DictBizCache; import org.springblade.system.pojo.entity.DictBiz; -import org.springblade.transport.mapper.FormalSettlementMapper; -import org.springblade.transport.mapper.FormalSettlementPaymentMapper; +import org.springblade.thirdparty.bfi.constant.BfiConstant; +import org.springblade.thirdparty.bfi.pojo.dto.BfiPayApplyPushDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiPayApplyPushEntryDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiPayApplyQueryDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiPayApplySaveDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiPayApplySaveEntryDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiPayBillQueryDTO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultItemVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiPayApplyEntryVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiPayApplyVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiPayBillVO; +import org.springblade.thirdparty.bfi.service.IBfiService; +import org.springblade.thirdparty.bfi.util.BfiUtil; +import org.springblade.transport.config.BfiProperties; +import org.springblade.transport.constant.KingdeeAttachmentConstant; +import org.springblade.transport.constant.KingdeeBfiConstant; +import org.springblade.transport.constant.KingdeeLockConstant; +import org.springblade.transport.constant.TransportBusiConstant; +import org.springblade.transport.event.KingdeeBillApprovedEvent; import org.springblade.transport.mapper.BillLedgerMapper; import org.springblade.transport.mapper.BillLedgerUsageMapper; -import org.springblade.transport.mapper.PreSettlementMapper; -import org.springblade.transport.mapper.ProjectApplyMapper; -import org.springblade.transport.mapper.CustomerArchiveMapper; import org.springblade.transport.mapper.ContractManageMapper; +import org.springblade.transport.mapper.CustomerArchiveMapper; +import org.springblade.transport.mapper.CustomerReceiptAccountMapper; +import org.springblade.transport.mapper.FormalSettlementDetailMapper; +import org.springblade.transport.mapper.FormalSettlementMapper; +import org.springblade.transport.mapper.FormalSettlementPaymentMapper; +import org.springblade.transport.mapper.FormalSettlementSourceMapper; import org.springblade.transport.mapper.PaymentApplicationInvoiceMapper; import org.springblade.transport.mapper.PaymentApplicationMapper; import org.springblade.transport.mapper.PaymentApplicationRecordMapper; import org.springblade.transport.mapper.PaymentApplicationSettlementMapper; -import org.springblade.transport.mapper.FormalSettlementSourceMapper; import org.springblade.transport.mapper.PreSettlementAdvanceMapper; +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.PaymentApplicationRecordRequest; import org.springblade.transport.pojo.dto.PaymentApplicationSaveRequest; import org.springblade.transport.pojo.dto.PaymentApplicationStatusRequest; -import org.springblade.transport.pojo.entity.FormalSettlement; -import org.springblade.transport.pojo.entity.FormalSettlementPayment; import org.springblade.transport.pojo.entity.BillLedger; import org.springblade.transport.pojo.entity.BillLedgerUsage; -import org.springblade.transport.pojo.entity.PreSettlement; -import org.springblade.transport.pojo.entity.ProjectApply; -import org.springblade.transport.pojo.entity.CustomerArchive; import org.springblade.transport.pojo.entity.ContractManage; +import org.springblade.transport.pojo.entity.CustomerArchive; +import org.springblade.transport.pojo.entity.CustomerReceiptAccount; +import org.springblade.transport.pojo.entity.FormalSettlement; +import org.springblade.transport.pojo.entity.FormalSettlementDetail; +import org.springblade.transport.pojo.entity.FormalSettlementPayment; +import org.springblade.transport.pojo.entity.FormalSettlementSource; +import org.springblade.transport.pojo.entity.KingdeeBillLock; import org.springblade.transport.pojo.entity.PaymentApplication; import org.springblade.transport.pojo.entity.PaymentApplicationInvoice; import org.springblade.transport.pojo.entity.PaymentApplicationRecord; import org.springblade.transport.pojo.entity.PaymentApplicationSettlement; -import org.springblade.transport.pojo.entity.FormalSettlementSource; +import org.springblade.transport.pojo.entity.PreSettlement; import org.springblade.transport.pojo.entity.PreSettlementAdvance; +import org.springblade.transport.pojo.entity.ProjectApply; import org.springblade.transport.pojo.entity.TemporaryCreditLimit; -import org.springblade.transport.pojo.vo.PaymentApplicationVO; import org.springblade.transport.pojo.vo.PaymentApplicationReferenceAmountVO; -import org.springblade.transport.service.IPaymentApplicationService; +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; import org.springblade.transport.wrapper.PaymentApplicationWrapper; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -79,20 +115,35 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Objects; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; import java.util.stream.Collectors; /** 付款申请服务实现。 @author Chill */ +@Slf4j @Service @RequiredArgsConstructor public class PaymentApplicationServiceImpl extends BaseServiceImpl implements IPaymentApplicationService { - private static final String DRAFT = "draft"; - private static final String REVIEWING = "reviewing"; - private static final String APPROVED = "approved"; - private static final String RETURNED = "returned"; - private static final String VOIDED = "voided"; + /** A103-2 按 awrp_src_code 批量查询单次条数上限 */ + private static final int PAY_RESULT_QUERY_BATCH = 50; + /** A103-2 / A103-4 分页查询页大小 */ + private static final int PAY_RESULT_PAGE_SIZE = 200; + /** 历史模拟付款记录单号前缀 */ + private static final String MOCK_PAYMENT_RECORD_PREFIX = "MOCK-PAY-"; + /** 金蝶付款结果回写 single-flight 锁键前缀(多副本定时任务与手动触发共用) */ + private static final String PAY_RESULT_SYNC_LOCK_PREFIX = "transport:payment-application:kingdee-pay-result-sync:"; + /** A103-2 消失轮次 Redis 计数键前缀(金蝶侧疑似反审核删除的兜底判定,ADR-0005) */ + private static final String PAY_RESULT_MISS_KEY_PREFIX = "transport:payment-application:kingdee-pay-miss:"; + /** A103-2 消失解锁轮次阈值:连续两轮未查得即解锁置失败态 */ + private static final int PAY_RESULT_MISS_UNLOCK_ROUNDS = 2; + /** A103-2 消失轮次计数键过期秒数:覆盖三个回写周期(10分钟/轮),超时未续计则重新累计(保守方向) */ + private static final long PAY_RESULT_MISS_KEY_TTL_SECONDS = 2100L; private final PaymentApplicationInvoiceMapper invoiceMapper; private final PaymentApplicationRecordMapper recordMapper; private final PaymentApplicationSettlementMapper settlementRelationMapper; @@ -100,14 +151,25 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl selectPage(IPage page, PaymentApplicationVO query) { @@ -116,24 +178,80 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl w + .like(PaymentApplication::getSettlementNo, query.getSettlementNo()) + .or() + .like(PaymentApplication::getPreSettlementNo, query.getSettlementNo())) .eq(Func.isNotEmpty(query.getPaymentType()), PaymentApplication::getPaymentType, query.getPaymentType()) .eq(Func.isNotEmpty(query.getApprovalStatus()), PaymentApplication::getApprovalStatus, query.getApprovalStatus()) .eq(Func.isNotEmpty(query.getKingdeeStatus()), PaymentApplication::getKingdeeStatus, query.getKingdeeStatus()) .ge(query.getApplyStartDate() != null, PaymentApplication::getApplyDate, query.getApplyStartDate()) .le(query.getApplyEndDate() != null, PaymentApplication::getApplyDate, query.getApplyEndDate()) .orderByDesc(PaymentApplication::getCreateTime); - return page(page, wrapper).convert(item -> { + IPage voPage = page(page, wrapper).convert(item -> { PaymentApplicationVO vo = PaymentApplicationWrapper.build().entityVO(item); - if (!APPROVED.equals(item.getApprovalStatus())) vo.setPaidAmount(BigDecimal.ZERO); + if (!TransportBusiConstant.APPROVED.equals(item.getApprovalStatus())) vo.setPaidAmount(BigDecimal.ZERO); return vo; }); + fillAttachmentSyncStatus(KingdeeAttachmentConstant.BIZ_PAY_APPLICATION, voPage.getRecords()); + fillKingdeeLockStatus(voPage.getRecords()); + return voPage; + } + + /** + * 填充附件同步聚合态(无附件/未上传/部分未传/已上传):同步表按本页 biz_id 一次 GROUP BY 查询,避免 N+1 + * + * @param bizType 业务单据类型 + * @param rows 本页 VO 列表 + */ + private void fillAttachmentSyncStatus(String bizType, List rows) { + if (Func.isEmpty(rows)) { + return; + } + Map successCounts = kingdeeAttachmentSyncSupport.successCountByBizId(bizType, + rows.stream().map(PaymentApplicationVO::getId).toList()); + for (PaymentApplicationVO vo : rows) { + int attachmentCount = kingdeeAttachmentSyncSupport.countAttachments(vo.getAttachmentsJson()); + String aggregateStatus = kingdeeAttachmentSyncSupport.aggregateStatus(attachmentCount, + successCounts.getOrDefault(vo.getId(), 0)); + vo.setAttachmentCount(attachmentCount); + vo.setAttachmentSyncStatus(aggregateStatus); + vo.setAttachmentSyncStatusName(KingdeeAttachmentConstant.aggregateName(aggregateStatus)); + } + } + + /** + * 填充金蝶锁状态标识(locked-锁定中/failed-锁定失败;unlocked 终态不返回): + * 锁表按本页源单一次批量查询,避免 N+1;locked 时前端可禁用编辑/作废, + * failed 时展示失败原因(tryCount 达上限 5 提示重推同步金蝶) + * + * @param rows 本页 VO 列表 + */ + private void fillKingdeeLockStatus(List rows) { + if (Func.isEmpty(rows)) { + return; + } + Map lockMap = kingdeeBillLockService.mapActiveLocks(KingdeeLockConstant.SOURCE_PAY_APPLY, + rows.stream().map(PaymentApplicationVO::getId).toList()); + for (PaymentApplicationVO vo : rows) { + KingdeeBillLock lockRecord = lockMap.get(vo.getId()); + if (lockRecord == null) { + continue; + } + vo.setKingdeeLockStatus(lockRecord.getLockStatus()); + vo.setKingdeeLockFailReason(lockRecord.getFailReason()); + vo.setKingdeeTryCount(lockRecord.getTryCount()); + } + // 金蝶解锁终态标识:付款申请解锁后不作废,按 unlocked 锁记录留痕区分「金蝶删单/解锁」与普通推送失败 + kingdeeBillLockService.fillUnlockMarks(KingdeeLockConstant.SOURCE_PAY_APPLY, rows, lockMap); } @Override public PaymentApplicationVO detail(Long id) { PaymentApplication entity = existing(id); PaymentApplicationVO vo = PaymentApplicationWrapper.build().entityVO(entity); + fillKingdeeLockStatus(List.of(vo)); vo.setInvoices(invoiceMapper.selectList(Wrappers.lambdaQuery() .eq(PaymentApplicationInvoice::getPaymentApplicationId, id).orderByAsc(PaymentApplicationInvoice::getLineNo))); vo.setPaymentRecords(recordMapper.selectList(Wrappers.lambdaQuery() @@ -151,10 +269,10 @@ 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) { @@ -200,9 +329,9 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpllambdaQuery().likeRight(PaymentApplication::getKingdeeBillNo, "FKD" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE))) + 1); - entity.setKingdeeBillNo(no); - entity.setKingdeeStatus("synced"); - entity.setPaidAmount(money(entity.getAppliedAmount()).setScale(2, RoundingMode.HALF_UP)); - mockPaymentRecords(entity); - updateById(entity); - syncPreSettlementAdvance(entity); - syncFormalSettlementPayments(entity); - return no; + return syncKingdee(id, KingdeeSyncContext.empty()); + } + + @Override + public String syncKingdee(Long id, KingdeeSyncContext context) { + String kingdeeBillId; + try { + // 单据级事务经自代理调用:单条失败只回滚自身,避免批量同步共用大事务长持锁 + kingdeeBillId = self().doSyncKingdee(id, context); + } catch (ServiceException exception) { + log.error("付款申请[{}]推送金蝶付款申请单异常", id, exception); + // 推送事务已回滚、行锁已释放,此处独立事务落"生成失败"状态 + self().markKingdeeSyncFailed(id); + throw exception; + } catch (Exception exception) { + log.error("付款申请[{}]推送金蝶付款申请单异常", id, exception); + self().markKingdeeSyncFailed(id); + throw new ServiceException("推送金蝶付款申请失败:" + exception.getMessage()); + } + // 主单事务提交后锁定金蝶付款申请单(lockBusiData):一张付款申请对一张金蝶付款申请单,接口1对1; + // 失败只留 failed 锁记录由补偿任务重锁,不污染主单同步状态;锁定失败摘要拼入返回值,页面直接可见 + String lockFailSummary = lockKingdeeBillAfterPush(id); + // 主单事务提交后联带上传附件(A902):失败只记附件同步状态,不回滚不污染主单同步状态,重推自动补传 + syncAttachmentsAfterPush(id); + return Func.isBlank(lockFailSummary) ? kingdeeBillId + : kingdeeBillId + ";注意:金蝶单据锁定未成功(" + lockFailSummary + "),系统将自动补偿重锁,重推同步金蝶可立即补锁"; + } + + /** + * 金蝶付款申请单锁定(lockBusiData):A103-1/A103-3 推送成功事务提交后调用, + * 一张付款申请对一张金蝶付款申请单;锁定异常独立捕获,不影响主单同步结果 + * + * @param id 付款申请id + * @return 锁定失败的金蝶单号摘要,全部锁定成功返回 null + */ + private String lockKingdeeBillAfterPush(Long id) { + try { + PaymentApplication entity = getById(id); + if (entity == null || Func.isBlank(entity.getKingdeeBillNo())) { + return null; + } + return kingdeeBillLockService.lockAfterPush(KingdeeLockConstant.SOURCE_PAY_APPLY, id, entity.getPaymentNo(), + KingdeeLockConstant.TARGET_PAY_APPLY, Func.toStrList(entity.getKingdeeBillNo())); + } catch (Exception exception) { + log.error("付款申请[{}]金蝶单据锁定联带动作异常(锁记录已落库待补偿)", id, exception); + return "锁定动作异常,已留档待补偿"; + } + } + + /** + * 附件联带上传(A902):主单推送成功(含幂等预检命中)后调用, + * 以本地 kingdeeBillId(星瀚单据内码)定位金蝶单据;本地缺失(存量数据)跳过待重推补传; + * 附件上传异常独立捕获,绝不影响主单同步结果 + * + * @param id 付款申请id + */ + private void syncAttachmentsAfterPush(Long id) { + try { + PaymentApplication entity = getById(id); + if (entity == null || Func.isBlank(entity.getKingdeeBillId())) { + log.warn("付款申请[{}]金蝶附件联带上传跳过:星瀚单据内码未落库,待重推补传", id); + return; + } + kingdeeAttachmentSyncSupport.syncUserAttachments(KingdeeAttachmentConstant.BIZ_PAY_APPLICATION, + id, entity.getKingdeeBillId(), entity.getAttachmentsJson()); + } catch (Exception exception) { + log.error("付款申请[{}]金蝶附件联带上传异常(不影响主单同步状态,重推自动补传)", id, exception); + } } @Override @Transactional(rollbackFor = Exception.class) + public String doSyncKingdee(Long id, KingdeeSyncContext context) { + PaymentApplication entity = lockedPayment(id); + if (!TransportBusiConstant.APPROVED.equals(entity.getApprovalStatus())) throw new ServiceException("仅审批通过的付款申请允许生成金蝶单据"); + if (KingdeeBfiConstant.KINGDEE_STATUS_SYNCED.equals(entity.getKingdeeStatus()) + || KingdeeBfiConstant.KINGDEE_STATUS_PAYING.equals(entity.getKingdeeStatus()) + || KingdeeBfiConstant.KINGDEE_STATUS_PAID.equals(entity.getKingdeeStatus()) + || KingdeeBfiConstant.KINGDEE_STATUS_CLOSED.equals(entity.getKingdeeStatus())) { + // 存量数据金蝶单据id可能仍存于 kingdeeBillNo,兜底返回 + String existBillId = Func.isNotEmpty(entity.getKingdeeBillId()) ? entity.getKingdeeBillId() : entity.getKingdeeBillNo(); + log.info("付款申请[{}]金蝶单据已生成(状态[{}]),跳过重复推送,返回单据id={}", entity.getPaymentNo(), entity.getKingdeeStatus(), existBillId); + return existBillId; + } + log.info("付款申请[{}]开始同步金蝶:paymentType={},申请金额={},kingdeeStatus={}", + entity.getPaymentNo(), entity.getPaymentType(), entity.getAppliedAmount(), entity.getKingdeeStatus()); + // 记录同步前状态:生成失败(上次推送中断)的恢复场景用于触发 A103-2 勾稽预检(预付款路径; + // 正式结算路径统一先经 A103-2 预检,不依赖该状态) + String priorKingdeeStatus = entity.getKingdeeStatus(); + String kingdeeBillId = null; + if (TransportBusiConstant.PAYMENT_TYPE_SETTLEMENT.equals(entity.getPaymentType())) { + // 单据类型路由:按正式结算单结算类型——应付对应财务应付单(A102-2,由正式结算单「同步金蝶」推送), + // 应收对应财务应收单(A302-2,推送规则待业务确认) + requirePayableSettlement(entity); + // 流程拆分:财务应付单(A102-2)在正式付款单页面「同步金蝶」时推送并回写结算单,付款申请仅负责付款申请单。 + // 前置校验:关联的每张正式结算单均已在金蝶生成财务应付单(A102-4 按结算单防重键预检,批量同步时整批已预热缓存) + Map settlementSrcCodes = settlementApBillSrcCodes(entity, context); + for (Map.Entry srcCodeEntry : settlementSrcCodes.entrySet()) { + String existBillNo = context.apBillNoCache().computeIfAbsent(srcCodeEntry.getValue(), + code -> kingdeeApBillSyncSupport.queryApBillExistNos(List.of(code)).getOrDefault(code, "")); + if (Func.isEmpty(existBillNo)) { + FormalSettlement settlement = context.settlementCache().get(srcCodeEntry.getKey()); + throw new ServiceException("推送金蝶付款申请失败:正式结算单[" + + (settlement == null ? srcCodeEntry.getKey() : settlement.getFormalSettlementNo()) + + "]尚未同步金蝶财务应付单(A102-2),请先在正式付款单页面同步金蝶"); + } + } + // A103-1 幂等预检:按付款申请号查询金蝶付款申请单是否已生成(上次下推成功但本地事务回滚未落库的场景), + // 已生成直接回填单据id与编号视为同步成功,未生成才继续 A103-1 下推,下推接口真正报错才算同步失败 + kingdeeBillId = reconcileExistPayApply(entity); + if (Func.isEmpty(kingdeeBillId)) { + log.info("付款申请[{}]正式结算付款:金蝶财务应付单(A102-2)已生成,开始推送金蝶付款申请单(A103-1 应付下推)", + entity.getPaymentNo()); + kingdeeBillId = pushBfiPayApplyPush(entity, context); + log.info("付款申请[{}]金蝶付款申请单(A103-1 应付下推)推送成功:kingdeeBillId={}", entity.getPaymentNo(), kingdeeBillId); + } + } else { + // 预付款:直接推付款申请单(A103-3),不绑应付单。 + // 上次同步失败(生成失败)的恢复场景先经 A103-2 勾稽:金蝶已生成说明上次推送实际成功(如请求超时但远端已落单), + // 直接回填避免重复推送;未生成才继续推送,推送接口真正报错才算同步失败 + if (KingdeeBfiConstant.KINGDEE_STATUS_FAILED.equals(priorKingdeeStatus)) { + kingdeeBillId = reconcileExistPayApply(entity); + } + if (Func.isEmpty(kingdeeBillId)) { + log.info("付款申请[{}]预付款:直接推送金蝶付款申请单(A103-3)", entity.getPaymentNo()); + kingdeeBillId = pushBfiPayApply(entity, context); + log.info("付款申请[{}]金蝶付款申请单(A103-3)推送成功:kingdeeBillId={}", entity.getPaymentNo(), kingdeeBillId); + } + } + // 星瀚单据内部主键落 kingdeeBillId(附件上传等使用); + // 付款申请单编号与付款状态由定时回写任务经 A103-2/A103-4 查询回填(kingdeeBillNo 不再暂存 A102-2 应付单编号) + entity.setKingdeeBillId(kingdeeBillId); + entity.setKingdeeStatus(KingdeeBfiConstant.KINGDEE_STATUS_SYNCED); + // 付款完成模拟逻辑已停用(mockPaymentRecords / 履约付款数据直接置 paid), + // 真实付款结果改由定时回写任务驱动;联调环境需要模拟时恢复以下注释即可 + // entity.setPaidAmount(money(entity.getAppliedAmount()).setScale(2, RoundingMode.HALF_UP)); + // mockPaymentRecords(entity); + // syncPreSettlementAdvance(entity); + // syncFormalSettlementPayments(entity); + updateById(entity); + log.info("付款申请[{}]同步金蝶完成:kingdeeStatus={},kingdeeBillId={},kingdeeBillNo={}", + entity.getPaymentNo(), entity.getKingdeeStatus(), kingdeeBillId, entity.getKingdeeBillNo()); + return kingdeeBillId; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void markKingdeeSyncFailed(Long id) { + if (id == null) return; + // 条件更新:仅审批通过的单据落"生成失败"状态,未审批/已删除单据自动跳过保持原状态 + baseMapper.update(null, Wrappers.lambdaUpdate() + .eq(PaymentApplication::getId, id) + .eq(PaymentApplication::getApprovalStatus, TransportBusiConstant.APPROVED) + .set(PaymentApplication::getKingdeeStatus, KingdeeBfiConstant.KINGDEE_STATUS_FAILED)); + } + + @Override + public String retryKingdeeFailed() { + List failedIds = list(Wrappers.lambdaQuery() + .eq(PaymentApplication::getKingdeeStatus, KingdeeBfiConstant.KINGDEE_STATUS_FAILED) + .eq(PaymentApplication::getApprovalStatus, TransportBusiConstant.APPROVED) + .orderByAsc(PaymentApplication::getCreateTime)) + .stream().map(PaymentApplication::getId).toList(); + if (Func.isEmpty(failedIds)) { + return "无生成失败单据"; + } + int success = 0; + List failures = new ArrayList<>(); + for (Long id : failedIds) { + try { + syncKingdee(id); + success++; + } catch (Exception exception) { + failures.add(id + ":" + exception.getMessage()); + } + } + String summary = "重推生成失败单据:本批 " + failedIds.size() + " 条,成功 " + success + " 条"; + if (!failures.isEmpty()) { + summary = summary + ",失败 " + failures.size() + " 条:" + String.join(";", failures); + } + return summary; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void unlockBill(Long id, String source) { + if (id == null) return; + PaymentApplication entity = getById(id); + if (entity == null) return; + clearPayApplyMissKey(id); + if (!KingdeeBfiConstant.KINGDEE_STATUS_SYNCED.equals(entity.getKingdeeStatus()) + && !KingdeeBfiConstant.KINGDEE_STATUS_PAYING.equals(entity.getKingdeeStatus())) { + // 仅已生成/付款中单据处于"金蝶已确认收单"的锁定态;生成失败/终态无需解锁 + log.info("付款申请[{}]金蝶单据解锁跳过(来源[{}]):kingdeeStatus=[{}]非锁定态", + entity.getPaymentNo(), source, entity.getKingdeeStatus()); + return; + } + // 原金蝶单据编号/内码日志留档后清空:金蝶侧已反审核删除,原值不再有效, + // 附件上传、A103-4 勾稽均依赖单据内码,重推生成新单后自动重新关联 + log.warn("付款申请[{}]金蝶单据解锁(来源[{}]):kingdeeStatus[{}]->[生成失败],原单据编号[{}]/内码[{}]已留档并清空,不自动重推,等待人工核实后重新推送", + entity.getPaymentNo(), source, entity.getKingdeeStatus(), entity.getKingdeeBillNo(), entity.getKingdeeBillId()); + baseMapper.update(null, Wrappers.lambdaUpdate() + .eq(PaymentApplication::getId, id) + .in(PaymentApplication::getKingdeeStatus, KingdeeBfiConstant.KINGDEE_STATUS_SYNCED, KingdeeBfiConstant.KINGDEE_STATUS_PAYING) + .set(PaymentApplication::getKingdeeStatus, KingdeeBfiConstant.KINGDEE_STATUS_FAILED) + .set(PaymentApplication::getKingdeeBillId, null) + .set(PaymentApplication::getKingdeeBillNo, null)); + } + + /** + * A103-2 未查得已推送单据的兜底判定(ADR-0005"连续两轮查询消失"): + * 仅对已生成/付款中单据经 Redis 计数累计消失轮次(生成失败单据金蝶查不到属正常态,等待人工重推,不参与计数), + * 连续达到阈值即解锁置失败态;重推链 A103-2 勾稽预检兜底,误判场景(金蝶单据实际存在)自动回填不产生重复单据 + * + * @param application 本轮未查得的付款申请 + */ + private void handlePayApplyMissed(PaymentApplication application) { + if (Func.isBlank(application.getPaymentNo()) + || (!KingdeeBfiConstant.KINGDEE_STATUS_SYNCED.equals(application.getKingdeeStatus()) + && !KingdeeBfiConstant.KINGDEE_STATUS_PAYING.equals(application.getKingdeeStatus()))) { + return; + } + String missKey = payApplyMissKey(application.getId()); + Long rounds; + try { + rounds = bladeRedis.getStringRedisTemplate().opsForValue().increment(missKey); + bladeRedis.getStringRedisTemplate().expire(missKey, PAY_RESULT_MISS_KEY_TTL_SECONDS, TimeUnit.SECONDS); + } catch (Exception exception) { + // Redis 异常时放弃本轮判定:解锁是破坏性动作(清空金蝶单号),宁可延迟不可误判 + log.warn("付款申请[{}]A103-2 消失轮次累计失败,本轮跳过解锁判定", application.getPaymentNo(), exception); + return; + } + if (rounds != null && rounds >= PAY_RESULT_MISS_UNLOCK_ROUNDS) { + log.warn("付款申请[{}]A103-2 连续[{}]轮未查得金蝶付款申请单,判定金蝶侧已反审核删除,执行解锁", + application.getPaymentNo(), rounds); + self().unlockBill(application.getId(), "A103-2 连续" + rounds + "轮查询消失"); + } else { + log.warn("付款申请[{}]A103-2 未查得金蝶付款申请单(第[{}]轮),金蝶侧可能已反审核删除,下一轮复核", + application.getPaymentNo(), rounds); + } + } + + /** A103-2 消失轮次计数键 */ + private String payApplyMissKey(Long id) { + return PAY_RESULT_MISS_KEY_PREFIX + id; + } + + /** 清除 A103-2 消失轮次计数(金蝶重新查得或单据解锁后调用) */ + private void clearPayApplyMissKey(Long id) { + if (id == null) return; + try { + bladeRedis.getStringRedisTemplate().delete(payApplyMissKey(id)); + } catch (Exception exception) { + log.warn("付款申请[{}]清除 A103-2 消失轮次计数失败", id, exception); + } + } + + @Override public List syncKingdeeBatch(List ids) { if (ids == null || ids.isEmpty()) throw new ServiceException("请至少选择一条审批通过的付款申请"); List distinctIds = ids.stream().filter(Objects::nonNull).distinct().toList(); if (distinctIds.isEmpty()) throw new ServiceException("请至少选择一条审批通过的付款申请"); + // 全批共享档案缓存(货物类型/合同/客商/项目),首单加载后全批复用,消除逐单重复查询(N+1) + KingdeeSyncContext context = KingdeeSyncContext.empty(); + // 批量预检财务应付单是否已生成:整批一次 A102-4 批量查询,结果入缓存供逐单推送复用 + preloadApBillExistNos(distinctIds, context); + IPaymentApplicationService self = SpringUtil.getBean(IPaymentApplicationService.class); List result = new ArrayList<>(); - for (Long id : distinctIds) result.add(syncKingdee(id)); + for (Long id : distinctIds) { + try { + // 经自代理逐条调用:单据级事务,单条失败仅回滚自身且不中断整批 + result.add(self.syncKingdee(id, context)); + } catch (Exception exception) { + result.add("付款申请[" + id + "]同步失败:" + exception.getMessage()); + } + } return result; } + /** 自代理:跨方法调用确保事务注解生效(批量逐条事务、失败状态独立事务) */ + private IPaymentApplicationService self() { + return SpringUtil.getBean(IPaymentApplicationService.class); + } + + @Override + public String syncKingdeePaymentResult() { + return kingdeeSyncLockSupport.runSingleFlight(PAY_RESULT_SYNC_LOCK_PREFIX, bfiProperties.getPayResult().getLockLeaseSeconds(), + "金蝶付款结果回写", this::executePaymentResultSync); + } + + /** + * 金蝶付款结果回写主体:A103-2 付款申请单查询回填 + A103-4 付款处理查询补付款记录, + * 由 {@link #syncKingdeePaymentResult()} 在分布式锁保护下调用 + * + * @return 执行摘要 + */ + private String executePaymentResultSync() { + // ① 待回写付款申请:审批通过且金蝶状态为 已生成/付款中/生成失败(终态已付款/已关闭不再轮询)。 + // 生成失败单据一并纳入勾稽:金蝶侧已生成说明上次推送实际成功(如下推请求超时但远端已落单、本地事务回滚), + // 查到即回填纠正为已生成,避免人工重新推送产生重复单据;未查到则保持生成失败等待异常处理后重新推送 + List applications = list(Wrappers.lambdaQuery() + .eq(PaymentApplication::getApprovalStatus, TransportBusiConstant.APPROVED) + .in(PaymentApplication::getKingdeeStatus, KingdeeBfiConstant.KINGDEE_STATUS_SYNCED, + KingdeeBfiConstant.KINGDEE_STATUS_PAYING, KingdeeBfiConstant.KINGDEE_STATUS_FAILED) + .orderByAsc(PaymentApplication::getId)); + IPaymentApplicationService self = SpringUtil.getBean(IPaymentApplicationService.class); + int updated = 0; + int failed = 0; + int paidCount = 0; + Map payApplyMap = new HashMap<>(); + if (!applications.isEmpty()) { + // ② A103-2 付款申请单查询:按 paymentNo(awrp_src_code)分批查询,同一来源单号取最近修改的一条 + List paymentNos = applications.stream() + .map(PaymentApplication::getPaymentNo).filter(Func::isNotEmpty).distinct().toList(); + batchForeach(paymentNos, PAY_RESULT_QUERY_BATCH, batch -> + payApplyMap.putAll(latestPayApplyBySrcCode(queryPayApplyPages(batch)))); + for (PaymentApplication application : applications) { + BfiPayApplyVO payApply = payApplyMap.get(application.getPaymentNo()); + if (payApply == null) { + // 金蝶侧未查得已推送单据(查询批次其余单据正常返回,排除服务整体故障): + // 累计消失轮次,连续两轮视为金蝶已反审核删除,解锁置失败交人工核实重推 + this.handlePayApplyMissed(application); + continue; + } + clearPayApplyMissKey(application.getId()); + try { + if (self.applyPayApplyResult(application, payApply)) paidCount++; + updated++; + } catch (Exception exception) { + failed++; + log.error("付款申请[{}]回写金蝶付款申请单结果异常", application.getPaymentNo(), exception); + } + } + } + // ③ A103-4 付款处理查询:付款中/已付款单据 + 已付款但尚无付款记录的单据(补偿),回填付款记录 + Map recordCandidates = new LinkedHashMap<>(); + for (PaymentApplication application : applications) { + BfiPayApplyVO payApply = payApplyMap.get(application.getPaymentNo()); + if (payApply != null && !KingdeeBfiConstant.BFI_PAY_STATUS_UNPAID.equals(payApply.getPaystatus())) { + recordCandidates.put(application.getId(), application); + } + } + list(Wrappers.lambdaQuery() + .eq(PaymentApplication::getApprovalStatus, TransportBusiConstant.APPROVED) + .eq(PaymentApplication::getKingdeeStatus, KingdeeBfiConstant.KINGDEE_STATUS_PAID)) + .stream().filter(application -> realRecordCount(application.getId()) == 0) + .forEach(application -> recordCandidates.putIfAbsent(application.getId(), application)); + int recordWritten = 0; + int recordFailed = 0; + if (!recordCandidates.isEmpty()) { + Map> orgGroups = new LinkedHashMap<>(); + for (PaymentApplication application : recordCandidates.values()) { + BfiPayApplyVO payApply = payApplyMap.get(application.getPaymentNo()); + String orgNumber = payApply == null ? null : payApply.getPayorgNumber(); + if (Func.isBlank(orgNumber)) { + try { + // A103-4 付款人(公司)编码必填:优先取 A103-2 返回付款组织编码,兜底按合同所属组织解析 + orgNumber = kingdeeApBillSyncSupport.bfiOrgNumber( + kingdeeApBillSyncSupport.requireContract(application.getContractId(), KingdeeSyncContext.empty())); + } catch (Exception exception) { + log.warn("付款申请[{}]付款处理查询跳过,付款组织编码获取失败:{}", application.getPaymentNo(), exception.getMessage()); + continue; + } + } + orgGroups.computeIfAbsent(orgNumber, key -> new ArrayList<>()).add(application); + } + // 以付款处理单源单id/源单编号建立索引,与本地金蝶单据id/单据编号勾稽 + Map payBillBySourceId = new HashMap<>(); + Map payBillBySourceNumber = new HashMap<>(); + for (String orgNumber : orgGroups.keySet()) { + for (BfiPayBillVO payBill : queryPayBillPages(orgNumber)) { + if (Func.isEmpty(payBill.getBillno())) continue; + if (payBill.getSourcebillid() != null) { + payBillBySourceId.put(String.valueOf(payBill.getSourcebillid()), payBill); + } + if (Func.isNotEmpty(payBill.getSourcebillnumber())) { + payBillBySourceNumber.put(payBill.getSourcebillnumber(), payBill); + } + } + } + for (PaymentApplication application : recordCandidates.values()) { + BfiPayBillVO payBill = matchPayBill(application, payBillBySourceId, payBillBySourceNumber); + if (payBill == null) { + continue; + } + try { + self.applyPayBillRecord(application, payBill); + recordWritten++; + } catch (Exception exception) { + recordFailed++; + log.error("付款申请[{}]回填金蝶付款处理记录异常", application.getPaymentNo(), exception); + } + } + } + if (applications.isEmpty() && recordCandidates.isEmpty()) { + return "金蝶付款结果回写:无待回写付款申请"; + } + return "金蝶付款结果回写完成:待查付款申请" + applications.size() + "笔,回写单据信息" + updated + + "笔(付款完成" + paidCount + "笔,失败" + failed + "笔),回填付款记录" + recordWritten + "条(失败" + recordFailed + "条)"; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean applyPayApplyResult(PaymentApplication entity, BfiPayApplyVO payApply) { + if (Func.isNotEmpty(payApply.getBillno())) { + entity.setKingdeeBillNo(payApply.getBillno()); + } + if (payApply.getId() != null) { + entity.setKingdeeBillId(String.valueOf(payApply.getId())); + } + String kingdeeStatus = kingdeeResultStatus(payApply); + entity.setKingdeeStatus(kingdeeStatus); + // 已付金额:A103-2 分录 e_paidamt 汇总;分录缺失且已付款时兜底取申请金额 + if (Func.isNotEmpty(payApply.getEntry())) { + BigDecimal paidAmount = payApply.getEntry().stream() + .map(BfiPayApplyEntryVO::getEPaidamt).filter(Objects::nonNull) + .reduce(BigDecimal.ZERO, BigDecimal::add); + entity.setPaidAmount(paidAmount.setScale(2, RoundingMode.HALF_UP)); + } else if (KingdeeBfiConstant.KINGDEE_STATUS_PAID.equals(kingdeeStatus)) { + entity.setPaidAmount(money(entity.getAppliedAmount()).setScale(2, RoundingMode.HALF_UP)); + } + updateById(entity); + if (KingdeeBfiConstant.KINGDEE_STATUS_PAID.equals(kingdeeStatus)) { + // 付款完成:联动履约付款数据(预结算预付记录、正式结算付款记录) + syncPreSettlementAdvance(entity); + syncFormalSettlementPayments(entity); + return true; + } + return false; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void applyPayBillRecord(PaymentApplication entity, BfiPayBillVO payBill) { + // 清理历史模拟付款记录,真实付款记录以金蝶付款处理单为准 + recordMapper.delete(Wrappers.lambdaQuery() + .eq(PaymentApplicationRecord::getPaymentApplicationId, entity.getId()) + .likeRight(PaymentApplicationRecord::getPaymentNo, MOCK_PAYMENT_RECORD_PREFIX)); + String billNo = payBill.getBillno(); + PaymentApplicationRecord record = recordMapper.selectList(Wrappers.lambdaQuery() + .eq(PaymentApplicationRecord::getPaymentApplicationId, entity.getId()) + .eq(Func.isNotEmpty(billNo), PaymentApplicationRecord::getPaymentNo, billNo) + .last("limit 1")).stream().findFirst().orElse(null); + boolean isNew = record == null; + if (isNew) { + record = new PaymentApplicationRecord(); + record.setPaymentApplicationId(entity.getId()); + record.setPaymentNo(billNo); + } + if (payBill.getActpayamt() != null) { + record.setPaidAmount(payBill.getActpayamt().setScale(2, RoundingMode.HALF_UP)); + } + record.setPaidDate(parseKingdeeDate(payBill.getPaydate())); + record.setKingdeeBillNo(billNo); + // 付款处理单查询结果整体留档(凭证号、银行流水、电子回单等信息),便于追溯 + record.setVoucherJson(JsonUtil.toJson(payBill)); + if (isNew) { + recordMapper.insert(record); + } else { + recordMapper.updateById(record); + } + } + + /** + * A103-2 返回状态映射本地金蝶同步状态: + * paystatus=Alreadypay 已付款(终态)优先;billstatus=D 已关闭(终态)次之; + * paystatus=Inpayment 付款中;其余(A暂存/B已提交/C已审核 + Unpaid)保持已生成继续轮询 + * + * @param payApply A103-2 查询返回的付款申请单 + * @return 本地金蝶同步状态 + */ + private String kingdeeResultStatus(BfiPayApplyVO payApply) { + if (KingdeeBfiConstant.BFI_PAY_STATUS_ALREADY_PAY.equals(payApply.getPaystatus())) { + return KingdeeBfiConstant.KINGDEE_STATUS_PAID; + } + if (KingdeeBfiConstant.BFI_BILL_STATUS_CLOSED.equals(payApply.getBillstatus())) { + return KingdeeBfiConstant.KINGDEE_STATUS_CLOSED; + } + if (KingdeeBfiConstant.BFI_PAY_STATUS_IN_PAYMENT.equals(payApply.getPaystatus())) { + return KingdeeBfiConstant.KINGDEE_STATUS_PAYING; + } + return KingdeeBfiConstant.KINGDEE_STATUS_SYNCED; + } + + /** + * A103-2 分页查询:来源单据号批量 + 外部系统编号(防重键),自动翻页 + * + * @param awrpSrcCodes 来源单据号(付款申请单号)批次 + * @return 付款申请单列表 + */ + private List queryPayApplyPages(List awrpSrcCodes) { + BfiPayApplyQueryDTO param = new BfiPayApplyQueryDTO(); + param.setAwrpSrcCode(awrpSrcCodes); + param.setAwrpSrcSys(bfiProperties.getSrcSys()); + return BfiUtil.queryAllPages(pageNo -> { + param.setPageNo(pageNo); + param.setPageSize(PAY_RESULT_PAGE_SIZE); + return bfiService.queryPayApply(param); + }); + } + + /** + * A103-2 幂等预检:按来源单号查询金蝶付款申请单是否已生成,同一来源单号取最近修改的一条。 + * 用于正式付款链路恢复场景:财务应付单已生成说明上次同步可能已保存成功而在下推/落库环节中断, + * 下推(A103-1)前先查询,避免重复下推产生重复付款申请单。 + * + * @param paymentNo 来源单号(付款申请单号) + * @return 最近修改的付款申请单,未查询到返回 null + */ + /** + * A103-2 查询结果按来源单号归并:同一 awrp_src_code 取最近修改(modifytime 最大)的一条, + * 定时回写与恢复勾稽共用同一取数规则 + * + * @param payApplies A103-2 分页查询全量结果 + * @return 来源单号 -> 最近修改的付款申请单 + */ + private Map latestPayApplyBySrcCode(List payApplies) { + Map latestBySrcCode = new HashMap<>(); + for (BfiPayApplyVO payApply : payApplies) { + if (Func.isEmpty(payApply.getAwrpSrcCode())) { + continue; + } + BfiPayApplyVO existing = latestBySrcCode.get(payApply.getAwrpSrcCode()); + if (existing == null || strCompare(payApply.getModifytime(), existing.getModifytime()) > 0) { + latestBySrcCode.put(payApply.getAwrpSrcCode(), payApply); + } + } + return latestBySrcCode; + } + + private BfiPayApplyVO queryLatestPayApply(String paymentNo) { + return latestPayApplyBySrcCode(queryPayApplyPages(List.of(paymentNo))).get(paymentNo); + } + + /** + * A103-2 恢复勾稽回填:按付款申请号查询金蝶付款申请单是否已生成, + * 已生成则回填单据id与编号(编号覆盖 kingdeeBillNo,与定时回写任务语义一致)并返回星瀚单据id; + * 未生成返回 null,由调用方继续推送(推送接口真正报错才算同步失败) + * + * @param entity 付款申请实体 + * @return 星瀚付款申请单id,金蝶未生成返回 null + */ + private String reconcileExistPayApply(PaymentApplication entity) { + BfiPayApplyVO existPayApply = queryLatestPayApply(entity.getPaymentNo()); + if (existPayApply == null || existPayApply.getId() == null) { + return null; + } + String kingdeeBillId = String.valueOf(existPayApply.getId()); + if (Func.isNotEmpty(existPayApply.getBillno())) { + entity.setKingdeeBillNo(existPayApply.getBillno()); + } + log.info("付款申请[{}]经 A103-2 查得金蝶付款申请单已生成(单据编号[{}]),跳过重复推送直接回填", + entity.getPaymentNo(), existPayApply.getBillno()); + return kingdeeBillId; + } + + /** + * 列表按批大小切分逐批处理(末批可能不足批大小),远程接口批量入参分批用,避免单次过滤条件过长 + * + * @param items 待处理列表 + * @param batchSize 批大小 + * @param batchConsumer 批处理逻辑 + * @param 元素类型 + */ + private void batchForeach(List items, int batchSize, Consumer> batchConsumer) { + for (int from = 0; from < items.size(); from += batchSize) { + batchConsumer.accept(items.subList(from, Math.min(from + batchSize, items.size()))); + } + } + + /** + * A103-4 分页查询:按付款人(公司)编码 + 最近1个月业务日期窗口(接口限制不超过1个月),自动翻页 + * + * @param orgNumber 付款人(公司)编码 + * @return 付款处理单列表 + */ + private List queryPayBillPages(String orgNumber) { + LocalDate endDate = LocalDate.now(); + // 业务日期窗口控制在不超过1个月 + LocalDate beginDate = endDate.minusMonths(1).plusDays(1); + BfiPayBillQueryDTO param = new BfiPayBillQueryDTO(); + param.setOrgNumber(List.of(orgNumber)); + param.setBeginBizdate(beginDate.format(DateTimeFormatter.ISO_LOCAL_DATE)); + param.setEndBizdate(endDate.format(DateTimeFormatter.ISO_LOCAL_DATE)); + return BfiUtil.queryAllPages(pageNo -> { + param.setPageNo(pageNo); + param.setPageSize(PAY_RESULT_PAGE_SIZE); + return bfiService.queryPayBill(param); + }); + } + + /** + * 以金蝶付款处理单源单id(=付款申请单星瀚id)/源单编号(=付款申请单单据编号)勾稽匹配 + * + * @param application 付款申请实体 + * @param payBillBySourceId 源单id索引 + * @param payBillBySourceNumber 源单编号索引 + * @return 匹配的付款处理单,未匹配返回 null + */ + private BfiPayBillVO matchPayBill(PaymentApplication application, Map payBillBySourceId, + Map payBillBySourceNumber) { + if (Func.isNotEmpty(application.getKingdeeBillId()) + && payBillBySourceId.containsKey(application.getKingdeeBillId())) { + return payBillBySourceId.get(application.getKingdeeBillId()); + } + if (Func.isNotEmpty(application.getKingdeeBillNo())) { + return payBillBySourceNumber.get(application.getKingdeeBillNo()); + } + return null; + } + + /** 统计付款申请的真实付款记录数(剔除历史模拟记录,兼容手工记录付款单号为空的场景) */ + private long realRecordCount(Long applicationId) { + return recordMapper.selectCount(Wrappers.lambdaQuery() + .eq(PaymentApplicationRecord::getPaymentApplicationId, applicationId) + .and(wrapper -> wrapper.notLikeRight(PaymentApplicationRecord::getPaymentNo, MOCK_PAYMENT_RECORD_PREFIX) + .or().isNull(PaymentApplicationRecord::getPaymentNo))); + } + + /** 解析金蝶返回日期(yyyy-MM-dd 或 yyyy-MM-dd HH:mm:ss),解析失败返回 null */ + private LocalDate parseKingdeeDate(String dateText) { + if (Func.isBlank(dateText)) return null; + try { + return LocalDate.parse(dateText.trim().substring(0, 10)); + } catch (Exception exception) { + log.warn("金蝶日期解析失败:{}", dateText); + return null; + } + } + + /** 时间字符串比较(yyyy-MM-dd HH:mm:ss),空值视为最小 */ + private int strCompare(String left, String right) { + if (Objects.equals(left, right)) return 0; + if (left == null) return -1; + if (right == null) return 1; + return left.compareTo(right); + } + + /** + * 金蝶接口日期文本:yyyy-MM-dd HH:mm:ss(申请日期为空时取当天),A103-1/A103-3 接口统一格式 + * + * @param entity 付款申请实体 + * @return 日期文本 + */ + private String bfiDateTimeText(PaymentApplication entity) { + LocalDate applyDate = entity.getApplyDate() == null ? LocalDate.now() : entity.getApplyDate(); + return kingdeeApBillSyncSupport.bfiDateTimeText(applyDate); + } + + /** + * 金蝶汇率日期文本(A103-3 预付款路径):优先取付款申请关联结算单页面维护的汇率日期 + * (正式结算单 settlementId、预结算单 preSettlementId,与 A102-2 统一口径), + * 未关联结算单或结算单未维护汇率日期时取付款申请申请日期兜底。 + * + * @param entity 付款申请实体 + * @param context 金蝶同步上下文(缓存载体) + * @return 汇率日期文本 + */ + private String bfiExrateDateText(PaymentApplication entity, KingdeeSyncContext context) { + if (entity.getSettlementId() != null) { + FormalSettlement settlement = context.settlementCache().computeIfAbsent(entity.getSettlementId(), formalSettlementMapper::selectById); + if (settlement != null && settlement.getExchangeRateDate() != null) { + return kingdeeApBillSyncSupport.bfiDateTimeText(settlement.getExchangeRateDate()); + } + } + if (entity.getPreSettlementId() != null) { + PreSettlement preSettlement = preSettlementMapper.selectById(entity.getPreSettlementId()); + if (preSettlement != null && preSettlement.getExchangeRateDate() != null) { + return kingdeeApBillSyncSupport.bfiDateTimeText(preSettlement.getExchangeRateDate()); + } + } + return bfiDateTimeText(entity); + } + + /** + * 推送金蝶付款申请单(A103-3 付款申请保存,预付款路径),返回金蝶单据id + * + * @param entity 付款申请实体 + * @return 金蝶付款申请单id + */ + private String pushBfiPayApply(PaymentApplication entity, KingdeeSyncContext context) { + ContractManage contract = kingdeeApBillSyncSupport.requireContract(entity.getContractId(), context); + String orgNumber = kingdeeApBillSyncSupport.bfiOrgNumber(contract); + String currency = kingdeeApBillSyncSupport.bfiCurrency(contract); + // 申请日期:yyyy-MM-dd HH:mm:ss(与 A102-2/A103-1 统一,金蝶各接口日期格式要求一致) + String applyDateText = bfiDateTimeText(entity); + BigDecimal applyAmount = money(entity.getAppliedAmount()).setScale(2, RoundingMode.HALF_UP); + BfiPayApplySaveDTO param = new BfiPayApplySaveDTO(); + // 【必填】外部系统来源单据号(防重候选键):付款申请号 + param.setAwrpSrcCode(entity.getPaymentNo()); + // 【必填】外部系统编号 + param.setAwrpSrcSys(bfiProperties.getSrcSys()); + // 结算组织.编码 + param.setSettleorgNumber(orgNumber); + // 部门.编码 + param.setAwrpDepartmentNumber(orgNumber); + // 【必填】单据类型.编码 + param.setBilltypeNumber(bfiProperties.getPayApply().getBilltype()); + // 【必填】申请日期:yyyy-MM-dd HH:mm:ss(各接口统一格式) + param.setApplydate(applyDateText); + // 【必填】申请组织.编码 + param.setApplyorgNumber(orgNumber); + // 【必填】付款组织.编码 + param.setPayorgNumber(orgNumber); + // 采购组织.编码(非必传) + // param.setPurorgNumber(orgNumber); + // 请款事由(非必传) + param.setApplycause(bfiApplyCause(entity.getPaymentType())); + // 【必填】付款币种.编码 + param.setPaycurrencyNumber(currency); + // 【必填】结算币.编码 + param.setSettlecurrencyNumber(currency); + // 【必填】外部单据类型 + param.setAwrpSrcType(bfiProperties.getPayApply().getSrcType()); + // 【必填】汇率表.编码 + param.setExratetableNumber(bfiProperties.getPayApply().getExratetable()); + // 【必填】汇率日期:取付款申请关联结算单页面维护的汇率日期(与 A102-2 统一口径),未关联/未维护时取申请日期 + param.setExratedate(this.bfiExrateDateText(entity, context)); + // 付款渠道(非必传) + param.setAwrpTextfield1(bfiPaymentMethodLabel(entity.getPaymentMethod())); + + // 来源单据 + // param.setAwrpSrcBt(KingdeeBfiConstant.BFI_CONTRACT_SRC_BT); + // 有上游合同才传 awrpSrcConNo + // if (entity.getContractId() != null) { + // param.setAwrpSrcConNo(entity.getContractNo()); + // } + + BfiPayApplySaveEntryDTO entry = new BfiPayApplySaveEntryDTO(); + // 【必填】付款类型.编码:结算付款 201(正式)、预付款 202 + entry.setEPaymenttypeNumber(TransportBusiConstant.PAYMENT_TYPE_SETTLEMENT.equals(entity.getPaymentType()) ? KingdeeBfiConstant.BFI_PAYMENT_TYPE_FORMAL : KingdeeBfiConstant.BFI_PAYMENT_TYPE_PREPAY); + // 【必填】往来类型 + entry.setEAsstacttype(kingdeeApBillSyncSupport.bfiAsstactType(contract)); + // 【必填】往来户.编码:合同乙方关联客商档案客商编码 + entry.setEAsstactNumber(kingdeeApBillSyncSupport.bfiAsstactNumber(contract, context)); + // 【必填】往来账户:乙方(收款方)银行账号 + entry.setEAssacct(entity.getBankAccount()); + // 【必填】往来银行.编码:与 A103-1 统一口径,取付款申请所选客商收款账户联行号(未维护即拦截) + entry.setEBebankNumber(this.bfiBebankNumber(entity, context)); + // 应付金额(非必传) + // entry.setEPayamount(applyAmount); + // 【必填】申请金额:取付款申请填写的申请付款金额 + entry.setEApplyamount(applyAmount); + // 期望付款日(非必传) + // entry.setEExpaydate(applyDateText); + // 【必填】项目.项目编码 + // entry.setProjectNumber(bfiProjectNumber(entity, context)); + entry.setProjectNumber(KingdeeBfiConstant.BFI_AP_BILL_PROJECT_NUMBER_TEST); + // 【必填】对方系统唯一行id(不能重复):付款申请号-1 + entry.setAwrpEntryCode(bfiEntryCode(entity.getPaymentNo(), 1)); + // 备注(非必传) + entry.setERemark(entity.getRemark()); + param.setEntry(List.of(entry)); + + BfiBillSaveResultVO saveResult = bfiService.savePrePayApply(param); + return requireBfiBillIdAndFillNo(entity, saveResult, "推送金蝶付款申请"); + } + + /** + * 校验正式结算付款对应的结算单结算类型:应付(财务应付单 A102-2)直接放行, + * 应收(对应财务应收单 A302-2)推送规则待业务确认,暂拦截报错。 + * + * @param entity 付款申请实体 + */ + private void requirePayableSettlement(PaymentApplication entity) { + if (entity.getSettlementId() == null) { + // 聚合多结算单场景暂按应付处理,TODO 待业务确认后细化 + return; + } + FormalSettlement settlement = formalSettlementMapper.selectById(entity.getSettlementId()); + if (settlement != null && "receivable".equals(settlement.getSettlementType())) { + throw new ServiceException("推送金蝶失败:结算单[" + entity.getSettlementNo() + "]结算类型为应收,对应财务应收单(A302-2),推送规则待业务确认后启用"); + } + } + + /** + * 推送金蝶付款申请(A103-1 应付下推付款申请,正式结算付款),返回星瀚付款申请单id。 + * 付款申请仅推一条分录:申请金额取付款申请填写的申请付款金额; + * awrp_finapbill_entryid 勾稽 A102-2 付款计划分录 awrp_entry_code_p:财务应付单(A102-2)由正式结算单 + * 「同步金蝶」推送且未传付款计划(planentity),统一经 A102-4 按付款申请主结算单的防重键反查 + * 金蝶自动生成的 awrp_entry_code_p(多结算单聚合付款申请按主结算单勾稽);awrp_entry_code 填付款申请号-1。 + * + * @param entity 付款申请实体 + * @param context 金蝶同步上下文(缓存载体) + * @return 星瀚付款申请单id + */ + private String pushBfiPayApplyPush(PaymentApplication entity, KingdeeSyncContext context) { + ContractManage contract = kingdeeApBillSyncSupport.requireContract(entity.getContractId(), context); + String orgNumber = kingdeeApBillSyncSupport.bfiOrgNumber(contract); + // 申请日期:yyyy-MM-dd HH:mm:ss(与 A102-2 单据日期格式统一) + String applyDateText = bfiDateTimeText(entity); + BfiPayApplyPushDTO param = new BfiPayApplyPushDTO(); + // 【必填】外部系统来源单据号(候选键):付款申请号 + param.setAwrpSrcCode(entity.getPaymentNo()); + // 【必填】外部系统编号 + param.setAwrpSrcSys(bfiProperties.getSrcSys()); + // 【必填】申请日期:yyyy-MM-dd HH:mm:ss(各接口统一格式) + param.setApplydate(applyDateText); + // 付款组织.编码(非必传,不传时根据应付单自动带出) + // param.setPayorgNumber(orgNumber); + // 采购组织.编码(非必传,不传时根据应付单自动带出) + // param.setPurorgNumber(orgNumber); + // 请款事由(非必传) + // param.setApplycause(bfiApplyCause(entity.getPaymentType())); + // 汇率表.编码(非必传,不传时根据应付单自动带出) + // param.setExratetableNumber(bfiProperties.getPayApply().getExratetable()); + // 汇率日期(非必传,不传时根据应付单自动带出) + // param.setExratedate(applyDateText); + // 付款渠道(非必传) + // param.setAwrpTextfield1(bfiPaymentMethodLabel(entity.getPaymentMethod())); + // 申请人(非必传) + // param.setCreatorName(entity.getApplicantName()); + + // 付款申请分录与财务应付单付款计划分录勾稽(awrp_finapbill_entryid 关联应付单 awrp_entry_code_p,A102-4 反查) + BfiPayApplyPushEntryDTO entry = new BfiPayApplyPushEntryDTO(); + // 【必填】付款类型.编码:正式付款固定 201 + entry.setEPaymenttypeNumber(KingdeeBfiConstant.BFI_PAYMENT_TYPE_FORMAL); + // 结算方式.编码:不传时金蝶按应付单自动带出,此处按付款方式映射显式送值(汇票付款→金蝶应付票据电子银行承兑汇票 012-02,其余→银行转账 02) + entry.setESettlementtypeNumber(isBillPayment(entity.getPaymentMethod()) + ? BfiConstant.SETTLE_TYPE_PAYABLE_BANK_ELECTRONIC : BfiConstant.SETTLE_TYPE_BANK); + // 【必填】结算单分录id:A102-2 财务应付单付款计划分录的外部系统来源分录标识(awrp_entry_code_p),按付款申请主结算单防重键勾稽 + entry.setAwrpFinapbillEntryid(settlementApBillSrcCode(entity, requireMainSettlementId(entity), context)); + // 【必填】对方系统唯一行id(不能重复):付款申请号-1 + entry.setAwrpEntryCode(bfiEntryCode(entity.getPaymentNo(), 1)); + // 【必填】往来账户:乙方(收款方)银行账号 + entry.setEAssacct(entity.getBankAccount()); + // 【必填】往来银行.编码:取客商收款信息(blade_customer_receipt_account)联行号 + entry.setEBebankNumber(bfiBebankNumber(entity, context)); + // 【必填】申请金额:取付款申请填写的申请付款金额 + entry.setEApplyamount(money(entity.getAppliedAmount()).setScale(2, RoundingMode.HALF_UP)); + // 【必填】项目.项目编码(联调期临时传金蝶测试档案值) + entry.setProjectNumber(KingdeeBfiConstant.BFI_AP_BILL_PROJECT_NUMBER_TEST); + // 期望付款日(非必传,不传时根据应付单自动带出) + // entry.setEExpaydate(applyDateText); + // 备注(非必传) + // entry.setERemark(entity.getRemark()); + // 【必填】外部单据类型 todo + entry.setAwrpSrcType(bfiProperties.getPayApply().getSrcType()); + param.setEntry(List.of(entry)); + + BfiBillSaveResultVO saveResult = bfiService.pushPayApply(param); + return requireBfiBillIdAndFillNo(entity, saveResult, "推送金蝶付款申请(应付下推)"); + } + + /** + * 付款申请勾稽的主结算单id:付款申请仅推一条分录,按主结算单的财务应付单防重键勾稽。 + * 优先取付款申请记录的主结算单id;聚合多结算单场景主结算单id为空时,取关联结算明细中首个结算单id。 + * + * @param entity 付款申请实体 + * @return 主结算单id + */ + private Long requireMainSettlementId(PaymentApplication entity) { + if (entity.getSettlementId() != null) { + return entity.getSettlementId(); + } + Long settlementId = formalSettlementDetails(entity).stream() + .map(FormalSettlementDetail::getFormalSettlementId) + .filter(Objects::nonNull) + .findFirst() + .orElse(null); + if (settlementId == null) { + throw new ServiceException("推送金蝶付款申请失败:付款申请[" + entity.getPaymentNo() + "]未关联正式结算单,无法勾稽财务应付单"); + } + return settlementId; + } + + /** + * 往来银行编码(金蝶 e_bebank_number):取付款申请所选客商收款信息(blade_customer_receipt_account)的联行号。 + * 优先按收款账户id直查,批量同步时经上下文缓存复用; + * 收款账户id缺失时,按合同乙方客商 + 收款账号兜底匹配(默认账户优先)。 + * + * @param entity 付款申请实体 + * @param context 金蝶同步上下文(缓存载体) + * @return 联行号 + */ + private String bfiBebankNumber(PaymentApplication entity, KingdeeSyncContext context) { + CustomerReceiptAccount receiptAccount = receiptAccount(entity, context); + if (receiptAccount == null || Func.isBlank(receiptAccount.getCnapsCode())) { + throw new ServiceException("推送金蝶付款申请失败:付款申请[" + entity.getPaymentNo() + + "]收款账户未维护联行号,请先在客商档案维护收款信息联行号"); + } + return receiptAccount.getCnapsCode().trim(); + } + + /** 付款申请对应的客商收款信息(上下文缓存优先),收款账户id缺失时按合同乙方客商 + 收款账号兜底匹配 */ + private CustomerReceiptAccount receiptAccount(PaymentApplication entity, KingdeeSyncContext context) { + if (entity.getReceiptAccountId() != null) { + return context.receiptAccountCache().computeIfAbsent(entity.getReceiptAccountId(), receiptAccountId -> { + CustomerReceiptAccount receiptAccount = receiptAccountMapper.selectById(receiptAccountId); + if (receiptAccount == null || Objects.equals(receiptAccount.getIsDeleted(), 1)) { + throw new ServiceException("推送金蝶付款申请失败:付款申请[" + entity.getPaymentNo() + + "]收款账户[" + receiptAccountId + "]不存在或已删除"); + } + return receiptAccount; + }); + } + ContractManage contract = kingdeeApBillSyncSupport.requireContract(entity.getContractId(), context); + if (Func.isBlank(contract.getPartyB())) { + return null; + } + CustomerArchive customer = customerArchiveMapper.selectOne(Wrappers.lambdaQuery() + .and(wrapper -> wrapper.eq(CustomerArchive::getFullName, contract.getPartyB()) + .or().eq(CustomerArchive::getShortName, contract.getPartyB())) + .eq(CustomerArchive::getIsDeleted, 0).last("limit 1")); + if (customer == null) { + return null; + } + List receiptAccounts = receiptAccountMapper.selectList(Wrappers.lambdaQuery() + .eq(CustomerReceiptAccount::getCustomerId, customer.getId()) + .eq(Func.isNotBlank(entity.getBankAccount()), CustomerReceiptAccount::getBankAccount, entity.getBankAccount()) + .eq(CustomerReceiptAccount::getIsDeleted, 0) + .orderByDesc(CustomerReceiptAccount::getIsDefault) + .orderByAsc(CustomerReceiptAccount::getCreateTime)); + CustomerReceiptAccount receiptAccount = receiptAccounts.isEmpty() ? null : receiptAccounts.get(0); + if (receiptAccount != null && receiptAccount.getId() != null) { + context.receiptAccountCache().putIfAbsent(receiptAccount.getId(), receiptAccount); + } + return receiptAccount; + } + + /** + * 校验金蝶保存/下推返回结果,取星瀚单据id并即时回填付款申请单编号(kingdeeBillNo): + * 推送结果携带金蝶单据编号(number),同步完成列表立即可见,无需等待 A103-2 定时回写; + * 后续付款申请单编号与付款状态仍由 A103-2/A103-4 定时回写任务覆盖校准 + * + * @param entity 付款申请实体 + * @param saveResult 金蝶返回结果 + * @param action 业务动作描述,用于异常提示 + * @return 星瀚单据id + */ + private String requireBfiBillIdAndFillNo(PaymentApplication entity, BfiBillSaveResultVO saveResult, String action) { + kingdeeApBillSyncSupport.requireBfiSaveSuccess(saveResult, action); + BfiBillSaveResultItemVO item = saveResult.getResult().get(0); + if (Func.isNotEmpty(item.getNumber())) { + entity.setKingdeeBillNo(item.getNumber()); + } + if (item.getId() == null) throw new ServiceException(action + "失败,金蝶未返回单据id"); + return String.valueOf(item.getId()); + } + + /** 结算明细(上下文缓存优先):批量预检加载过的明细推送时直接复用,消除逐单重复查询 */ + private List settlementDetails(PaymentApplication entity, KingdeeSyncContext context) { + return context.settlementDetailCache().computeIfAbsent(entity.getId(), key -> formalSettlementDetails(entity)); + } + + /** + * 付款申请关联各正式结算单的财务应付单防重键集合(结算单id -> awrp_src_code), + * 防重键规则:正式结算单号_结算明细单据号下划线拼接(与正式结算单「同步金蝶」推送 A102-2 传参一致) + * + * @param entity 付款申请实体 + * @param context 金蝶同步上下文(缓存载体) + * @return 结算单id -> 财务应付单防重键 + */ + private Map settlementApBillSrcCodes(PaymentApplication entity, KingdeeSyncContext context) { + List settlementIds = settlementDetails(entity, context).stream() + .map(FormalSettlementDetail::getFormalSettlementId).filter(Objects::nonNull).distinct().toList(); + Map srcCodes = new LinkedHashMap<>(); + for (Long settlementId : settlementIds) { + srcCodes.put(settlementId, settlementApBillSrcCode(entity, settlementId, context)); + } + return srcCodes; + } + + /** + * 单张正式结算单的财务应付单防重键:正式结算单号_结算明细单据号下划线拼接, + * 结算单号经上下文缓存复用,结算明细取付款申请关联明细按结算单过滤 + * + * @param entity 付款申请实体 + * @param formalSettlementId 正式结算单id + * @param context 金蝶同步上下文(缓存载体) + * @return 财务应付单防重键 + */ + private String settlementApBillSrcCode(PaymentApplication entity, Long formalSettlementId, KingdeeSyncContext context) { + FormalSettlement settlement = context.settlementCache().computeIfAbsent(formalSettlementId, id -> { + FormalSettlement found = formalSettlementMapper.selectById(id); + if (found == null) throw new ServiceException("推送金蝶付款申请失败:正式结算单[" + id + "]不存在"); + return found; + }); + List ownDetails = settlementDetails(entity, context).stream() + .filter(detail -> Objects.equals(formalSettlementId, detail.getFormalSettlementId())).toList(); + return kingdeeApBillSyncSupport.apBillSrcCode(settlement, ownDetails); + } + + /** + * 金蝶分录标识:付款申请号-分录序号(1 起)。 + * 现仅 A103-3 预付款直接保存路径使用(序号固定 1);正式结算链路 A102-2 明细 awrp_entry_code 与 + * A103-1 awrp_entry_code 同键(结算明细行单据号/结算合计费用项拼接,KingdeeApBillSyncSupport.resolveEntryGroups 统一解析), + * A103-1 awrp_finapbill_entryid 改为财务应付单付款计划 awrp_entry_code_p(A102-4 反查),禁止各接口自行拼接 + * + * @param paymentNo 付款申请号 + * @param seq 分录序号(1 起) + * @return 分录标识 + */ + private String bfiEntryCode(String paymentNo, int seq) { + return paymentNo + "-" + seq; + } + + /** + * 批量同步前 A102-4 预检:一次批量查询整批付款申请关联正式结算单的财务应付单是否已生成, + * 结果(含"未生成"标记)写入上下文缓存,供逐单前置校验与 A103-1 反查复用,消除逐单远程查询(N+1)。 + * 仅正式结算付款会走到前置校验(审批通过且未生成/生成失败),已生成/付款中等状态在 + * doSyncKingdee 入口即返回,无需预检 + * + * @param ids 待同步付款申请id批次 + * @param context 金蝶同步上下文(缓存载体) + */ + private void preloadApBillExistNos(List ids, KingdeeSyncContext context) { + List applications = list(Wrappers.lambdaQuery() + .in(PaymentApplication::getId, ids) + .eq(PaymentApplication::getApprovalStatus, TransportBusiConstant.APPROVED) + .notIn(PaymentApplication::getKingdeeStatus, KingdeeBfiConstant.KINGDEE_STATUS_SYNCED, + KingdeeBfiConstant.KINGDEE_STATUS_PAYING, KingdeeBfiConstant.KINGDEE_STATUS_PAID, KingdeeBfiConstant.KINGDEE_STATUS_CLOSED)); + List srcCodes = new ArrayList<>(); + for (PaymentApplication entity : applications) { + // 仅正式结算付款走财务应付单链路,预付款跳过 + if (!TransportBusiConstant.PAYMENT_TYPE_SETTLEMENT.equals(entity.getPaymentType())) { + continue; + } + srcCodes.addAll(settlementApBillSrcCodes(entity, context).values()); + } + // 仅查询缓存未命中的编码,已命中的(同批重复/已预热)直接复用 + List pendingCodes = srcCodes.stream() + .filter(Func::isNotBlank) + .filter(code -> !context.apBillNoCache().containsKey(code)) + .distinct() + .toList(); + if (pendingCodes.isEmpty()) { + return; + } + context.apBillNoCache().putAll(kingdeeApBillSyncSupport.queryApBillExistNos(pendingCodes)); + // 未命中编码补"未生成"标记(空串),避免逐单前置校验时重复远程查询 + pendingCodes.forEach(code -> context.apBillNoCache().putIfAbsent(code, "")); + } + + /** 付款申请关联的正式结算明细(关联表优先,兼容仅有 settlementId 的存量数据) */ + private List formalSettlementDetails(PaymentApplication entity) { + List settlementIds = settlementRelationMapper.selectList(Wrappers.lambdaQuery() + .eq(PaymentApplicationSettlement::getPaymentApplicationId, entity.getId()) + .eq(PaymentApplicationSettlement::getIsDeleted, 0)) + .stream().map(PaymentApplicationSettlement::getFormalSettlementId).filter(Objects::nonNull).distinct().toList(); + if (Func.isEmpty(settlementIds) && entity.getSettlementId() != null) { + settlementIds = List.of(entity.getSettlementId()); + } + if (Func.isEmpty(settlementIds)) { + throw new ServiceException("推送金蝶财务应付单失败:付款申请未关联正式结算单,无法解析结算明细"); + } + return formalSettlementDetailMapper.selectList(Wrappers.lambdaQuery() + .in(FormalSettlementDetail::getFormalSettlementId, settlementIds) + .eq(FormalSettlementDetail::getIsDeleted, 0) + .orderByAsc(FormalSettlementDetail::getLineNo)); + } + + private String bfiApplyCause(String paymentType) { + return switch (paymentType == null ? "" : paymentType) { + case "project_advance" -> "项目预付款申请"; + case "progress_advance" -> "进度预付款申请"; + case TransportBusiConstant.PAYMENT_TYPE_SETTLEMENT -> "结算付款申请"; + default -> "付款申请"; + }; + } + + private String bfiPaymentMethodLabel(String paymentMethod) { + if (Func.isEmpty(paymentMethod)) return null; + return DictBizCache.getList("pay_method").stream() + .filter(item -> paymentMethod.equals(item.getDictKey()) || paymentMethod.equals(item.getDictValue())) + .map(DictBiz::getDictValue).findFirst().orElse(paymentMethod); + } + + private String bfiProjectNumber(PaymentApplication entity, KingdeeSyncContext context) { + if (entity.getProjectId() == null) return KingdeeBfiConstant.BFI_PROJECT_NUMBER_NONE; + // 批量同步时按项目id缓存复用(同一项目只查一次),单笔同步退化为直接查询 + ProjectApply project = context.projectCache().computeIfAbsent(entity.getProjectId(), projectApplyMapper::selectById); + return project == null || Func.isEmpty(project.getProjectCode()) ? KingdeeBfiConstant.BFI_PROJECT_NUMBER_NONE : project.getProjectCode(); + } + + /** + * 付款完成模拟(已停用):原在同步成功后模拟付款完成数据(paidAmount = 申请金额、MOCK-PAY- 前缀付款记录)。 + * 真实付款结果现由定时回写任务 syncKingdeePaymentResult 经 A103-2/A103-4 查询回填驱动, + * 暂保留本方法以备联调环境无金蝶付款数据时模拟使用。 + * + * @param entity 付款申请实体 + */ private void mockPaymentRecords(PaymentApplication entity) { recordMapper.delete(Wrappers.lambdaQuery() .eq(PaymentApplicationRecord::getPaymentApplicationId, entity.getId())); @@ -373,14 +1562,14 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpllambdaQuery() .eq(PreSettlementAdvance::getPreSettlementId, entity.getPreSettlementId()) .eq(PreSettlementAdvance::getIsDeleted, 0) - .ne(PreSettlementAdvance::getBillStatus, VOIDED) + .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::getIsDeleted, 0) - .ne(PreSettlementAdvance::getBillStatus, VOIDED) + .ne(PreSettlementAdvance::getBillStatus, TransportBusiConstant.VOIDED) .eq(PreSettlementAdvance::getPaidAmount, BigDecimal.ZERO) .eq(PreSettlementAdvance::getAppliedAmount, money(entity.getAppliedAmount())) .orderByDesc(PreSettlementAdvance::getCreateTime).last("limit 1")).stream().findFirst().orElse(null); @@ -411,7 +1600,7 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpllambdaQuery() .eq(PreSettlementAdvance::getPreSettlementId, preSettlementId) .eq(PreSettlementAdvance::getIsDeleted, 0) - .ne(PreSettlementAdvance::getBillStatus, VOIDED)).stream() + .ne(PreSettlementAdvance::getBillStatus, TransportBusiConstant.VOIDED)).stream() .map(PreSettlementAdvance::getPaidAmount).map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add); } @@ -419,7 +1608,7 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpllambdaQuery() .eq(PreSettlementAdvance::getPreSettlementId, preSettlementId) .eq(PreSettlementAdvance::getIsDeleted, 0) - .ne(PreSettlementAdvance::getBillStatus, VOIDED)).stream() + .ne(PreSettlementAdvance::getBillStatus, TransportBusiConstant.VOIDED)).stream() .map(PreSettlementAdvance::getAppliedAmount).map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add); } @@ -427,7 +1616,7 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl advances = preSettlementAdvanceMapper.selectList(Wrappers.lambdaQuery() .eq(PreSettlementAdvance::getPreSettlementId, preSettlementId) .eq(PreSettlementAdvance::getIsDeleted, 0) - .ne(PreSettlementAdvance::getBillStatus, VOIDED)); + .ne(PreSettlementAdvance::getBillStatus, TransportBusiConstant.VOIDED)); PreSettlement settlement = preSettlementMapper.selectById(preSettlementId); if (settlement == null) throw new ServiceException("预结算单不存在"); settlement.setAdvanceNo(advances.stream().map(PreSettlementAdvance::getAdvanceNo) @@ -440,7 +1629,7 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl relations = settlementRelationMapper.selectList( Wrappers.lambdaQuery() .eq(PaymentApplicationSettlement::getPaymentApplicationId, entity.getId()) @@ -488,7 +1677,7 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl 0)) throw new ServiceException("付款比例必须在0-100之间"); @@ -497,7 +1686,7 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl resolveFormalSettlements(PaymentApplicationSaveRequest request, PaymentApplication entity) { - if (!"settlement_payment".equals(request.getPaymentType())) return List.of(); + if (!TransportBusiConstant.PAYMENT_TYPE_SETTLEMENT.equals(request.getPaymentType())) return List.of(); List ids = request.getSettlementIds() == null ? List.of() : request.getSettlementIds(); if (ids.isEmpty() && request.getSettlementId() != null) ids = List.of(request.getSettlementId()); ids = ids.stream().filter(Objects::nonNull).distinct().toList(); if (ids.isEmpty()) return List.of(); List settlements = ids.stream().map(id -> formalSettlementMapper.selectById(id)).toList(); if (settlements.stream().anyMatch(item -> item == null || Objects.equals(item.getIsDeleted(), 1) - || !APPROVED.equals(item.getApprovalStatus()) || !"payable".equals(item.getSettlementType()))) { + || !TransportBusiConstant.APPROVED.equals(item.getApprovalStatus()) || !"payable".equals(item.getSettlementType()))) { throw new ServiceException("只能选择审批通过的应付正式结算单"); } Long contractId = settlements.get(0).getContractId(); @@ -599,7 +1788,7 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl 1; } @@ -607,7 +1796,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); @@ -621,15 +1810,14 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpllambdaQuery() .eq(PaymentApplicationSettlement::getPaymentApplicationId, entity.getId())); - BigDecimal totalBase = settlements.stream().map(item -> money(item.getSettlementAmount()).subtract( - cumulativeFormalSettlementApplied(item.getId(), entity.getId())).max(BigDecimal.ZERO)) + 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()); 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 base = payableOfFormalSettlement(settlement.getId(), settlement.getSettlementAmount(), entity.getId()); BigDecimal applied = index == settlements.size() - 1 ? remainingApplied : money(entity.getAppliedAmount()).multiply(base).divide(totalBase, 2, RoundingMode.DOWN); BigDecimal paid = index == settlements.size() - 1 ? remainingPaid @@ -669,7 +1857,7 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpllambdaQuery() .eq(TemporaryCreditLimit::getProjectId, project.getId()) - .eq(TemporaryCreditLimit::getApprovalStatus, APPROVED) + .eq(TemporaryCreditLimit::getApprovalStatus, TransportBusiConstant.APPROVED) .ge(TemporaryCreditLimit::getValidUntil, LocalDate.now()) .eq(TemporaryCreditLimit::getStatus, 1) .eq(TemporaryCreditLimit::getIsDeleted, 0)).stream() @@ -679,7 +1867,7 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl wrapper) { - return list(wrapper.ne(PaymentApplication::getApprovalStatus, VOIDED)).stream() + return list(wrapper.ne(PaymentApplication::getApprovalStatus, TransportBusiConstant.VOIDED)).stream() .map(PaymentApplication::getAppliedAmount).map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add); } @@ -706,6 +1894,24 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpllambdaQuery() .eq(BillLedgerUsage::getPaymentApplicationId, entity.getId()) - .eq(BillLedgerUsage::getUsageStatus, APPROVED).last("FOR UPDATE")); + .eq(BillLedgerUsage::getUsageStatus, TransportBusiConstant.APPROVED).last("FOR UPDATE")); if (usage == null) throw new ServiceException("未找到对应的汇票使用记录,无法作废"); BillLedger ledger = lockedBill(usage.getBillLedgerId()); ledger.setAvailableBalance(money(ledger.getAvailableBalance()).add(money(usage.getUsedAmount())) @@ -794,7 +2000,8 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl paymentMethods = DictBizCache.getList("pay_method"); if (Func.isEmpty(paymentMethods)) return false; @@ -815,7 +2022,7 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl formalSettlementIds = new java.util.ArrayList<>(); if (entity.getSettlementId() != null) formalSettlementIds.add(entity.getSettlementId()); formalSettlementIds.addAll(settlementRelationMapper.selectList(Wrappers.lambdaQuery() @@ -828,13 +2035,13 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpllambdaQuery() .eq(FormalSettlementPayment::getIsDeleted, 0) .in(FormalSettlementPayment::getFormalSettlementId, formalSettlementIds) - .notIn(FormalSettlementPayment::getBillStatus, RETURNED, VOIDED)); + .notIn(FormalSettlementPayment::getBillStatus, TransportBusiConstant.RETURNED, TransportBusiConstant.VOIDED)); if (activeTailPaymentCount > 0) { throw new ServiceException("该结算单正在申请结算尾款,不允许再次提交申请付款"); } } private PaymentApplication existing(Long id) { PaymentApplication entity = getById(id); if (entity == null || Objects.equals(entity.getIsDeleted(), 1)) throw new ServiceException("付款申请不存在"); return entity; } - private PaymentApplication editable(Long id) { PaymentApplication entity = existing(id); if (!List.of(DRAFT, RETURNED).contains(entity.getApprovalStatus())) throw new ServiceException("当前状态不可编辑"); return entity; } + private PaymentApplication editable(Long id) { PaymentApplication entity = existing(id); if (!List.of(TransportBusiConstant.DRAFT, TransportBusiConstant.RETURNED).contains(entity.getApprovalStatus())) throw new ServiceException("当前状态不可编辑"); return entity; } private String nextNo() { String prefix = "FKD" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); return prefix + String.format("%05d", count(Wrappers.lambdaQuery().likeRight(PaymentApplication::getPaymentNo, prefix)) + 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; } 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 6e9dd27e..de0b160e 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; @@ -124,6 +126,7 @@ public class PreSettlementServiceImpl extends BaseServiceImpl selectPage(IPage page, PreSettlementVO query) { @@ -198,6 +201,8 @@ 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) @@ -241,6 +246,7 @@ public class PreSettlementServiceImpl extends BaseServiceImpl queryWrapper = Wrappers.lambdaQuery().eq(ProjectApply::getIsDeleted, 0); if (TransportBusinessSupport.isAdministrator()) { - // 超管默认可查看全部未删除数据;仅在明确指定承办组织时按组织精确筛选。 + // 超管默认可查看全部未删除数据;仅在明确指定平台公司时按包含匹配筛选。 if (Objects.equals(projectApply.getAllDept(), 1) && Func.isNotEmpty(projectApply.getUndertakeDeptId())) { - queryWrapper.eq(ProjectApply::getUndertakeDeptId, projectApply.getUndertakeDeptId()); + applyUndertakeDeptContains(queryWrapper, projectApply.getUndertakeDeptId()); } } else if (!Objects.equals(projectApply.getAllDept(), 1)) { Long currentDeptId = TransportBusinessSupport.currentDeptId("项目立项"); Long currentUserId = AuthUtil.getUserId(); - queryWrapper.and(wrapper -> wrapper.eq(ProjectApply::getUndertakeDeptId, currentDeptId) - .or().eq(ProjectApply::getCreateUser, currentUserId) - .or().eq(ProjectApply::getHandlerUserId, currentUserId) - .or().eq(ProjectApply::getPrincipalUserId, currentUserId)); + queryWrapper.and(wrapper -> { + if (currentDeptId != null) { + wrapper.apply("FIND_IN_SET({0}, IFNULL(undertake_dept_id,''))", String.valueOf(currentDeptId)); + } + wrapper.or().eq(ProjectApply::getCreateUser, currentUserId) + .or().eq(ProjectApply::getHandlerUserId, currentUserId) + .or().eq(ProjectApply::getPrincipalUserId, currentUserId); + }); } else if (Func.isNotEmpty(projectApply.getUndertakeDeptId())) { - queryWrapper.eq(ProjectApply::getUndertakeDeptId, projectApply.getUndertakeDeptId()); + applyUndertakeDeptContains(queryWrapper, projectApply.getUndertakeDeptId()); } queryWrapper.like(Func.isNotEmpty(projectApply.getApplyNo()), ProjectApply::getApplyNo, projectApply.getApplyNo()) .like(Func.isNotEmpty(projectApply.getProjectCode()), ProjectApply::getProjectCode, projectApply.getProjectCode()) @@ -464,6 +468,7 @@ public class ProjectApplyServiceImpl extends BaseServiceImpl queryWrapper, String undertakeDeptId) { + if (Func.isBlank(undertakeDeptId) || queryWrapper == null) { + return; + } + List deptIds = Arrays.stream(undertakeDeptId.split("[,,]")) + .map(String::trim) + .filter(Func::isNotEmpty) + .distinct() + .toList(); + if (deptIds.isEmpty()) { + return; + } + queryWrapper.and(wrapper -> { + boolean first = true; + for (String deptId : deptIds) { + if (first) { + wrapper.apply("FIND_IN_SET({0}, IFNULL(undertake_dept_id,''))", deptId); + first = false; + } else { + wrapper.or().apply("FIND_IN_SET({0}, IFNULL(undertake_dept_id,''))", deptId); + } + } + }); + } + + private boolean persistProjectApply(ProjectApply projectApply) { + try { + return saveOrUpdate(projectApply); + } catch (RuntimeException exception) { + throw translateUndertakeDeptPersistError(exception, projectApply); + } + } + + /** + * 多选平台公司写入失败时,提示执行字段扩容脚本(原列为 bigint / varchar(100))。 + */ + private RuntimeException translateUndertakeDeptPersistError(RuntimeException exception, ProjectApply projectApply) { + String message = Func.toStr(exception.getMessage()); + Throwable cause = exception.getCause(); + while (cause != null) { + message = message + " " + Func.toStr(cause.getMessage()); + cause = cause.getCause(); + } + String lowerMessage = message.toLowerCase(); + boolean multiUndertake = Func.isNotEmpty(projectApply.getUndertakeDeptId()) + && (projectApply.getUndertakeDeptId().contains(",") || projectApply.getUndertakeDeptId().contains(",")); + boolean columnHint = lowerMessage.contains("undertake_dept") + || lowerMessage.contains("incorrect integer") + || lowerMessage.contains("data truncation") + || lowerMessage.contains("data too long"); + if (multiUndertake && columnHint) { + return new ServiceException("平台公司多选保存失败,请先执行脚本 blade_project_apply_undertake_dept_multi_20261014.sql 扩容字段后重试"); + } + return exception; + } + + private void normalizeUndertakeDept(ProjectApply projectApply) { + if (projectApply == null) { + return; + } + List deptIds = Arrays.stream(Func.toStr(projectApply.getUndertakeDeptId()).split("[,,]")) + .map(String::trim) + .filter(Func::isNotEmpty) + .filter(id -> !"-1".equals(id)) + .distinct() + .toList(); + projectApply.setUndertakeDeptId(deptIds.isEmpty() ? null : String.join(",", deptIds)); + List deptNames = Arrays.stream(Func.toStr(projectApply.getUndertakeDeptName()).split("[,,、]")) + .map(String::trim) + .filter(Func::isNotEmpty) + .distinct() + .toList(); + projectApply.setUndertakeDeptName(deptNames.isEmpty() ? null : String.join("、", deptNames)); + } + + private Long firstUndertakeDeptId(String undertakeDeptId) { + return Arrays.stream(Func.toStr(undertakeDeptId).split("[,,]")) + .map(String::trim) + .filter(Func::isNotEmpty) + .filter(id -> !"-1".equals(id)) + .map(Func::toLong) + .filter(id -> id != null && id > 0L) + .findFirst() + .orElse(null); + } + + private String firstUndertakeDeptName(String undertakeDeptName) { + return Arrays.stream(Func.toStr(undertakeDeptName).split("[,,、]")) + .map(String::trim) + .filter(Func::isNotEmpty) + .findFirst() + .orElse(null); + } + + private boolean containsUndertakeDept(String undertakeDeptId, Long deptId) { + if (deptId == null || Func.isBlank(undertakeDeptId)) { + return false; + } + String target = String.valueOf(deptId); + return Arrays.stream(undertakeDeptId.split("[,,]")) + .map(String::trim) + .anyMatch(target::equals); + } + } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceiptClaimRecordServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceiptClaimRecordServiceImpl.java index 3affe442..8d0116e6 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceiptClaimRecordServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceiptClaimRecordServiceImpl.java @@ -28,10 +28,17 @@ package org.springblade.transport.service.impl; 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.thirdparty.bfi.constant.BfiConstant; +import org.springblade.thirdparty.bfi.pojo.dto.BfiRecBillQueryDTO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiRecBillVO; +import org.springblade.thirdparty.bfi.service.IBfiService; +import org.springblade.thirdparty.bfi.util.BfiUtil; +import org.springblade.transport.constant.KingdeeBfiConstant; import org.springblade.transport.mapper.FormalSettlementMapper; import org.springblade.transport.mapper.KingdeeReceiptFlowMapper; import org.springblade.transport.mapper.ReceiptClaimMapper; @@ -51,6 +58,7 @@ import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; import java.math.RoundingMode; +import java.time.LocalDate; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.Comparator; @@ -58,12 +66,16 @@ import java.util.HashMap; 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; /** * 认领记录服务实现类 * * @author Chill */ +@Slf4j @Service @RequiredArgsConstructor public class ReceiptClaimRecordServiceImpl extends BaseServiceImpl @@ -72,12 +84,21 @@ public class ReceiptClaimRecordServiceImpl extends BaseServiceImpl selectPage(IPage page, @@ -163,7 +184,7 @@ public class ReceiptClaimRecordServiceImpl extends BaseServiceImpllambdaQuery() .eq(ReceiptClaim::getId, id) .eq(ReceiptClaim::getClaimerId, AuthUtil.getUserId()) @@ -182,6 +203,10 @@ public class ReceiptClaimRecordServiceImpl extends BaseServiceImpl relations = claimSettlementMapper.selectList( Wrappers.lambdaQuery() @@ -194,29 +219,47 @@ public class ReceiptClaimRecordServiceImpl extends BaseServiceImpl orderedRelations = relations.stream() .sorted(Comparator.comparing(ReceiptClaimSettlement::getFormalSettlementId)) .toList(); - for (ReceiptClaimSettlement relation : orderedRelations) { - FormalSettlement settlement = formalSettlementMapper.selectOne( + // 批量锁行关联结算单(一次 IN 查询替代逐单 selectOne,id 升序加锁防死锁) + List settlementIds = orderedRelations.stream() + .map(ReceiptClaimSettlement::getFormalSettlementId) + .filter(Objects::nonNull) + .distinct() + .toList(); + if (settlementIds.isEmpty()) { + throw new ServiceException("关联结算单不存在"); + } + Map settlements = formalSettlementMapper.selectList( Wrappers.lambdaQuery() - .eq(FormalSettlement::getId, relation.getFormalSettlementId()) - .last("FOR UPDATE")); + .in(FormalSettlement::getId, settlementIds) + .orderByAsc(FormalSettlement::getId) + .last("FOR UPDATE")).stream() + .collect(Collectors.toMap(FormalSettlement::getId, Function.identity())); + for (ReceiptClaimSettlement relation : orderedRelations) { + FormalSettlement settlement = settlements.get(relation.getFormalSettlementId()); if (settlement == null || Objects.equals(settlement.getIsDeleted(), 1)) { throw new ServiceException("关联结算单不存在"); } - relation.setStatus(0); - claimSettlementMapper.updateById(relation); - formalSettlementService.refreshPaymentSummary(settlement.getId()); } + // 分摊行批量置无效(单条 UPDATE 替代逐行 updateById,实体携带 status 保留审计字段填充) + ReceiptClaimSettlement voidTemplate = new ReceiptClaimSettlement(); + voidTemplate.setStatus(0); + claimSettlementMapper.update(voidTemplate, Wrappers.lambdaUpdate() + .in(ReceiptClaimSettlement::getId, relations.stream() + .map(ReceiptClaimSettlement::getId).toList())); + // 批量刷新结算单收款汇总(固定数次查询,避免逐单 N+1) + formalSettlementService.refreshPaymentSummaries(settlementIds); BigDecimal flowClaimedAfter = money(flow.getClaimedAmount()) .subtract(money(claim.getClaimAmount())).max(BigDecimal.ZERO); flow.setClaimedAmount(flowClaimedAfter); - flow.setClaimStatus(amountClaimStatus(flowClaimedAfter, flow.getReceiptAmount())); + // 认领状态对照公告未认领金额重算(作废分摊不计入累计) + flow.setClaimStatus(amountClaimStatus(flowClaimedAfter, flow.getUnclaimedAmount())); receiptFlowMapper.updateById(flow); - String kingdeeBillNo = buildKingdeeBillNo(claim.getId()); + // 作废不生成金蝶认领冲单:金蝶单号/状态保留原值(未推送为空,已推送为真实收款单信息), + // 金蝶侧冲销/红冲由人工处置;冲单逻辑后续迭代如需恢复,放开下行调用即可 + // String kingdeeBillNo = buildKingdeeBillNo(claim.getId()); claim.setClaimStatus(VOIDED); - claim.setKingdeeBillNo(kingdeeBillNo); - claim.setKingdeeBillStatus(APPROVED); claim.setVoidedBy(AuthUtil.getUserId()); claim.setVoidedByName(Func.isEmpty(AuthUtil.getUserName()) ? claim.getClaimerName() : AuthUtil.getUserName()); @@ -233,9 +276,110 @@ public class ReceiptClaimRecordServiceImpl extends BaseServiceImpl + * 回写口径:A301-1 契约本身不支持按来源单号过滤,故按近 30 天业务日期窗口全量翻页后, + * 以金蝶收款单 sourcebillnumber == 流水 awrp_src_code(A301-3 推送时按流水生成、重试复用)在本地匹配; + * 仅推进金蝶回写状态与补登收款单号,不改本地认领金额/分摊;名下未作废认领主单冗余展示字段级联刷新。 + * + * @return 回写条数 + */ + @Override + public int refreshKingdeeResult() { + List pending = receiptFlowMapper.selectList( + Wrappers.lambdaQuery() + .eq(KingdeeReceiptFlow::getPushStatus, KingdeeBfiConstant.RECEIPT_CLAIM_PUSH_PUSHED) + .in(KingdeeReceiptFlow::getKingdeeBillStatus, + List.of(KingdeeBfiConstant.KINGDEE_STATUS_PUSHED, APPROVED)) + .eq(KingdeeReceiptFlow::getStatus, 1) + .orderByAsc(KingdeeReceiptFlow::getId)); + if (pending.isEmpty()) { + log.info("金蝶认领结果回写:无待回写流水(已推送/已审核且未终态)"); + return 0; + } + Set srcCodes = pending.stream() + .map(KingdeeReceiptFlow::getAwrpSrcCode) + .filter(Func::isNotEmpty) + .collect(Collectors.toSet()); + if (srcCodes.isEmpty()) { + return 0; + } + log.info("金蝶认领结果回写:待回写流水 {} 条", pending.size()); + + LocalDate end = LocalDate.now(); + LocalDate begin = end.minusDays(RESULT_WINDOW_DAYS); + long start = System.currentTimeMillis(); + log.info("调用BFI接口 收款单查询开始,业务日期窗口 {} ~ {}", begin, end); + List rows = BfiUtil.queryAllPages(pageNo -> { + BfiRecBillQueryDTO dto = new BfiRecBillQueryDTO(); + dto.setPageNo(pageNo); + dto.setPageSize(RESULT_PAGE_SIZE); + dto.setBeginBizdate(begin.format(DateTimeFormatter.ISO_LOCAL_DATE)); + dto.setEndBizdate(end.format(DateTimeFormatter.ISO_LOCAL_DATE)); + return bfiService.queryRecBill(dto); + }); + log.info("调用BFI接口 收款单查询结束 耗时:{}ms,拉取 {} 条", System.currentTimeMillis() - start, rows.size()); + + // 窗口内收款单按 sourcebillnumber 匹配待回写来源编码,同键多单取首条 + Map matched = rows.stream() + .filter(row -> Func.isNotEmpty(row.getSourcebillnumber())) + .filter(row -> srcCodes.contains(row.getSourcebillnumber())) + .collect(Collectors.toMap(BfiRecBillVO::getSourcebillnumber, Function.identity(), + (first, second) -> first, HashMap::new)); + int updated = 0; + for (KingdeeReceiptFlow flow : pending) { + BfiRecBillVO bill = matched.get(flow.getAwrpSrcCode()); + if (bill == null) { + // 窗口内未查到:金蝶回单延迟/状态未同步,不改状态,由下次回写或人工处置兜底 + continue; + } + // 推送成功时金蝶收款单号先以来源编码占位(A301-3 响应 number 为空,取回执 keys.awrp_src_code), + // 回写命中后以金蝶真实收款单号覆盖 + if (Func.isNotEmpty(bill.getBillno())) { + flow.setKingdeeBillNo(bill.getBillno()); + } + flow.setKingdeeBillStatus(billStatusOf(bill.getBillstatus())); + receiptFlowMapper.updateById(flow); + // 级联刷新名下未作废认领主单的冗余展示字段 + baseMapper.update(null, Wrappers.lambdaUpdate() + .eq(ReceiptClaim::getReceiptFlowId, flow.getId()) + .eq(ReceiptClaim::getClaimStatus, CLAIMED) + .set(ReceiptClaim::getKingdeeBillNo, flow.getKingdeeBillNo()) + .set(ReceiptClaim::getKingdeeBillStatus, flow.getKingdeeBillStatus())); + updated++; + log.info("金蝶认领结果回写 flowId:{},awrpSrcCode:{},金蝶收款单号:{},状态:{} → {}", + flow.getId(), flow.getAwrpSrcCode(), bill.getBillno(), bill.getBillstatus(), + flow.getKingdeeBillStatus()); + } + log.info("金蝶认领结果回写完成:匹配 {} 条,更新 {} 条", matched.size(), updated); + return updated; + } + + /** + * 金蝶收款单单据状态 → 本地认领金蝶状态 + * + * @param billStatus 金蝶 billstatus(A暂存/B已提交/C已审核/D已收款/G已退单) + * @return 本地状态 + */ + private String billStatusOf(String billStatus) { + if (BfiConstant.BILL_STATUS_RECEIVED.equals(billStatus)) { + return KingdeeBfiConstant.KINGDEE_STATUS_RECEIVED; + } + if (BfiConstant.BILL_STATUS_AUDITED.equals(billStatus)) { + return APPROVED; + } + if (BfiConstant.BILL_STATUS_DRAFT.equals(billStatus) + || BfiConstant.BILL_STATUS_SUBMITTED.equals(billStatus)) { + return KingdeeBfiConstant.KINGDEE_STATUS_PUSHED; + } + // 其余状态(含 G 已退单)按处理失败回收,提示人工在金蝶核查 + log.warn("金蝶收款单状态[{}]未匹配映射,认领回写按 failed 处理", billStatus); + return KingdeeBfiConstant.KINGDEE_STATUS_FAILED; } private void fillStatusNames(ReceiptClaimRecordVO record) { @@ -244,6 +388,8 @@ public class ReceiptClaimRecordServiceImpl extends BaseServiceImpl "审核通过"; + case "received" -> "已收款"; + case "pushed" -> "已推送"; case "failed" -> "处理失败"; default -> "未生成"; }); @@ -253,17 +399,25 @@ public class ReceiptClaimRecordServiceImpl extends BaseServiceImpl= 0 ? "claimed" : "partial"; + return claimedAmount.compareTo(money(unclaimedAmount)) >= 0 ? "claimed" : "partial"; } 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 c78f80f4..fe427db1 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 @@ -28,7 +28,9 @@ 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.toolkit.Db; 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; @@ -36,31 +38,45 @@ 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.thirdparty.bfi.pojo.dto.BfiClaimAnnounceQueryDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiClaimPushDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiClaimPushEntryDTO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultItemVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiClaimAnnounceVO; +import org.springblade.thirdparty.bfi.service.IBfiService; +import org.springblade.thirdparty.bfi.util.BfiUtil; +import org.springblade.transport.config.BfiProperties; +import org.springblade.transport.constant.KingdeeBfiConstant; import org.springblade.transport.mapper.FormalSettlementMapper; import org.springblade.transport.mapper.KingdeeReceiptFlowMapper; import org.springblade.transport.mapper.ReceiptClaimMapper; import org.springblade.transport.mapper.ReceiptClaimSettlementMapper; import org.springblade.transport.mapper.ReceiptFlowRecordMapper; +import org.springblade.transport.pojo.dto.ReceiptClaimPushResult; import org.springblade.transport.pojo.dto.ReceiptClaimRequest; -import org.springblade.transport.pojo.dto.ReceiptFlowSyncRequest; import org.springblade.transport.pojo.entity.FormalSettlement; import org.springblade.transport.pojo.entity.KingdeeReceiptFlow; import org.springblade.transport.pojo.entity.ReceiptClaim; import org.springblade.transport.pojo.entity.ReceiptClaimSettlement; import org.springblade.transport.pojo.entity.ReceiptFlowRecord; import org.springblade.transport.pojo.vo.ReceiptFlowVO; -import org.springblade.transport.service.IReceiptFlowService; import org.springblade.transport.service.IFormalSettlementService; +import org.springblade.transport.service.IReceiptFlowService; +import org.springblade.transport.support.KingdeeSyncLockSupport; import org.springblade.transport.support.TransportBusinessSupport; import org.springblade.transport.wrapper.ReceiptFlowWrapper; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; import java.math.BigDecimal; import java.math.RoundingMode; import java.time.LocalDate; 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.List; @@ -75,22 +91,45 @@ import java.util.stream.Collectors; * * @author Chill */ +@Slf4j @Service @RequiredArgsConstructor public class ReceiptFlowServiceImpl extends BaseServiceImpl implements IReceiptFlowService { + // 未认领 private static final String UNCLAIMED = "unclaimed"; + // 部分认领 private static final String PARTIAL = "partial"; + // 已认领 private static final String CLAIMED = "claimed"; + // 已审批 private static final String APPROVED = "approved"; + // 应收款 private static final String RECEIVABLE = "receivable"; + /** + * A301-2 认领公告同步分页大小 + */ + private static final int SYNC_PAGE_SIZE = 200; + /** + * A301-2 交易时间格式 + */ + private static final DateTimeFormatter BFI_DATE_TIME_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + /** + * 定时推送收款认领 single-flight 锁键前缀(多副本定时任务共用) + */ + private static final String RECEIPT_CLAIM_PUSH_LOCK_PREFIX = "transport:receipt-flow:kingdee-claim-push:"; private final ReceiptClaimMapper claimMapper; private final ReceiptClaimSettlementMapper claimSettlementMapper; private final ReceiptFlowRecordMapper recordMapper; private final FormalSettlementMapper formalSettlementMapper; private final IFormalSettlementService formalSettlementService; + private final IBfiService bfiService; + private final BfiProperties bfiProperties; + private final TransactionTemplate transactionTemplate; + private final KingdeeSyncLockSupport kingdeeSyncLockSupport; @Override public IPage selectPage(IPage page, ReceiptFlowVO query) { @@ -104,8 +143,18 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl claimedFlowIds = claimMapper.selectList(Wrappers.lambdaQuery() + .select(ReceiptClaim::getReceiptFlowId) + .like(ReceiptClaim::getClaimerName, query.getClaimerName()) + .eq(ReceiptClaim::getClaimStatus, CLAIMED)).stream() + .map(ReceiptClaim::getReceiptFlowId) + .filter(Objects::nonNull) + .distinct() + .toList(); + if (claimedFlowIds.isEmpty()) { + page.setRecords(List.of()); + page.setTotal(0); + return page.convert(ReceiptFlowWrapper.build()::entityVO); + } + wrapper.in(KingdeeReceiptFlow::getId, claimedFlowIds); + } return page(page, wrapper).convert(ReceiptFlowWrapper.build()::entityVO); } @@ -126,7 +192,7 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpllambdaQuery() .eq(ReceiptFlowRecord::getReceiptFlowId, id) - .eq(ReceiptFlowRecord::getActionType, "claim") + .in(ReceiptFlowRecord::getActionType, List.of("claim", "push")) .orderByDesc(ReceiptFlowRecord::getCreateTime))); return vo; } @@ -145,8 +211,12 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl claimedMap = receiptClaimedAmounts(settlements.stream() + .map(FormalSettlement::getId).toList()); return settlements.stream() - .map(this::candidateRow) + .map(settlement -> candidateRow(settlement, + claimedMap.getOrDefault(settlement.getId(), BigDecimal.ZERO))) .filter(row -> ((BigDecimal) row.get("remainingReceiptAmount")).compareTo(BigDecimal.ZERO) > 0) .toList(); } @@ -212,125 +282,271 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl allocationMap = allocationMap(request.getSettlements()); + // 结算单id列表(升序) List settlementIds = allocationMap.keySet().stream().sorted().toList(); + // 结算单id → 结算单实体 Map settlementMap = lockSettlements(settlementIds); + // 分摊的结算单列表 List settlements = settlementIds.stream().map(settlementMap::get).toList(); assertCompatible(settlements); - Map previousClaimedMap = new LinkedHashMap<>(); + // 结算单id → 认领前已认领金额(一次聚合查询,避免逐单 N+1) + Map previousClaimedMap = receiptClaimedAmounts(settlementIds); + // 本次分摊总金额 BigDecimal allocatedTotal = BigDecimal.ZERO; for (FormalSettlement settlement : settlements) { + // 本次分摊金额 BigDecimal allocated = allocationMap.get(settlement.getId()); - BigDecimal previousClaimed = settlementClaimedAmount(settlement.getId()); + // 认领前已认领金额 + BigDecimal previousClaimed = previousClaimedMap.getOrDefault(settlement.getId(), BigDecimal.ZERO); + // 结算总金额 BigDecimal settlementAmount = positiveMoney(settlement.getSettlementAmount(), "结算总金额"); if (previousClaimed.add(allocated).compareTo(settlementAmount) > 0) { throw new ServiceException("结算单" + settlement.getFormalSettlementNo() + "的累计认领金额不能超过结算总应收含税金额"); } - previousClaimedMap.put(settlement.getId(), previousClaimed); allocatedTotal = allocatedTotal.add(allocated); } - BigDecimal receiptAmount = positiveMoney(flow.getReceiptAmount(), "收款金额"); + // 公告未认领金额(认领上限与认领完成判定基准,A301-2 口径) + if (flow.getUnclaimedAmount() == null) { + throw new ServiceException("公告未认领金额缺失,请先手动同步金蝶收款流水"); + } + BigDecimal unclaimedAmount = money(flow.getUnclaimedAmount()); + if (unclaimedAmount.compareTo(BigDecimal.ZERO) <= 0) { + throw new ServiceException("该流水公告未认领金额为0,无需认领"); + } + // 流水认领前已认领金额 BigDecimal previousFlowClaimed = flowClaimedAmount(flow.getId()); - if (previousFlowClaimed.add(allocatedTotal).compareTo(receiptAmount) > 0) { - throw new ServiceException("本次分摊金额不能超过流水剩余可认领金额"); + if (previousFlowClaimed.add(allocatedTotal).compareTo(unclaimedAmount) > 0) { + throw new ServiceException("累计认领金额不能超过公告未认领金额,剩余可认领 " + + unclaimedAmount.subtract(previousFlowClaimed).max(BigDecimal.ZERO)); } + // 当前用户部门 Dept dept = TransportBusinessSupport.currentDept("收款流水认领"); + // 认领主单 ReceiptClaim claim = new ReceiptClaim(); + // 收款流水id claim.setReceiptFlowId(flow.getId()); + // 认领金额 claim.setClaimAmount(allocatedTotal); + // 认领人id claim.setClaimerId(AuthUtil.getUserId()); + // 认领人姓名 claim.setClaimerName(UserCache.getUserRealName(AuthUtil.getUserId())); + // 认领人部门id claim.setClaimerDeptId(dept.getId()); + // 认领人部门名称 claim.setClaimerDeptName(dept.getDeptName()); + // 认领日期 claim.setClaimDate(LocalDate.now()); + // 附件json claim.setAttachmentsJson(request.getAttachmentsJson()); + // 备注 claim.setRemark(trimToNull(request.getRemark())); + // 认领状态 claim.setClaimStatus(CLAIMED); - claim.setKingdeeBillStatus("none"); + // 金蝶回写状态(待推送) + claim.setKingdeeBillStatus(KingdeeBfiConstant.RECEIPT_CLAIM_BILL_NONE); claimMapper.insert(claim); for (FormalSettlement settlement : settlements) { - BigDecimal previousClaimed = previousClaimedMap.get(settlement.getId()); + // 认领前已认领金额 + BigDecimal previousClaimed = previousClaimedMap.getOrDefault(settlement.getId(), BigDecimal.ZERO); + // 本次分摊金额 BigDecimal allocated = allocationMap.get(settlement.getId()); - BigDecimal claimedAfter = previousClaimed.add(allocated); + // 认领结算单分摊关系 ReceiptClaimSettlement relation = new ReceiptClaimSettlement(); + // 认领主单id relation.setReceiptClaimId(claim.getId()); + // 收款流水id relation.setReceiptFlowId(flow.getId()); + // 结算单id relation.setFormalSettlementId(settlement.getId()); + // 结算单号 relation.setFormalSettlementNo(settlement.getFormalSettlementNo()); + // 结算总金额 relation.setSettlementAmount(money(settlement.getSettlementAmount())); + // 认领前已认领金额 relation.setClaimedReceiptAmount(previousClaimed); + // 本次分摊金额 relation.setAllocatedReceiptAmount(allocated); claimSettlementMapper.insert(relation); - - formalSettlementService.refreshPaymentSummary(settlement.getId()); } + // 批量刷新结算单收款汇总(固定数次查询,避免逐单 N+1) + formalSettlementService.refreshPaymentSummaries(settlementIds); + + // 认领前状态 String fromStatus = normalizeClaimStatus(flow.getClaimStatus()); + // 认领后累计金额 BigDecimal claimedAfter = previousFlowClaimed.add(allocatedTotal); - String toStatus = amountClaimStatus(claimedAfter, receiptAmount); + // 认领后状态(对照公告未认领金额) + String toStatus = amountClaimStatus(claimedAfter, unclaimedAmount); + // 更新流水已认领金额 flow.setClaimedAmount(claimedAfter); + // 更新流水认领状态 flow.setClaimStatus(toStatus); updateById(flow); + // 记录操作日志 record(flow.getId(), claim.getId(), "claim", "认领收款流水", fromStatus, toStatus, allocatedTotal, "关联" + settlements.size() + "张应收正式结算单"); + + // 认领只登记本地,不推送金蝶;推送由定时任务/手动按钮在认领完成后触发 return claim.getId(); } @Override @Transactional(rollbackFor = Exception.class) - public int sync(ReceiptFlowSyncRequest request) { - List rows = request == null || request.getFlows() == null - ? List.of() : request.getFlows(); - if (rows.isEmpty()) { + public int sync() { + String claimusers = UserCache.getUserRealName(AuthUtil.getUserId()); + if (Func.isEmpty(claimusers)) { + throw new ServiceException("当前用户无姓名,无法同步金蝶认领公告"); + } + List announces = queryClaimAnnounces(claimusers); + + if (announces.isEmpty()) { record(null, null, "sync", "手动同步流水", null, null, BigDecimal.ZERO, - "未接收到金蝶流水数据"); + "金蝶认领公告未查询到数据"); return 0; } - Set serialNumbers = new HashSet<>(); - int syncedCount = 0; - for (ReceiptFlowSyncRequest.FlowRow row : rows) { - validateSyncRow(row); - if (!serialNumbers.add(row.getDetailSerialNo().trim())) { - throw new ServiceException("明细流水号" + row.getDetailSerialNo() + "重复"); + // 同步口径:A301-2 按参与认领用户(当前登录用户真实姓名)拉取全量认领公告, + // 以金蝶明细流水号为幂等键 upsert 收款流水表;本地已认领金额/状态(claimedAmount/claimStatus) + // 由认领、作废驱动,不被公告顺带覆盖,公告侧状态单独存 kingdee_claim_status/unclaimed_amount。 + + // 第一遍:数据校验与过滤,收集有效的 detailSerialNo + log.info("收款流水同步开始数据校验,共 {} 条认领公告", announces.size()); + Map validAnnounces = new LinkedHashMap<>(); + int skippedCount = 0; + for (BfiClaimAnnounceVO announce : announces) { + // 明细流水号优先级:detailid > tradedetailno > bankdetailno + String detailSerialNo = firstNonBlank(announce.getDetailid(), + announce.getTradedetailno(), announce.getBankdetailno()); + if (Func.isEmpty(detailSerialNo)) { + log.warn("收款流水同步跳过:认领公告[{}]缺少明细流水号", announce.getBillno()); + skippedCount++; + continue; } - KingdeeReceiptFlow entity = baseMapper.selectOne(Wrappers.lambdaQuery() - .eq(KingdeeReceiptFlow::getDetailSerialNo, row.getDetailSerialNo().trim()) - .last("FOR UPDATE")); - boolean created = entity == null; + detailSerialNo = detailSerialNo.trim(); + if (validAnnounces.containsKey(detailSerialNo)) { + throw new ServiceException("明细流水号" + detailSerialNo + "重复"); + } + BigDecimal receiptAmount = money(announce.getReamount()); + if (receiptAmount.compareTo(BigDecimal.ZERO) <= 0) { + log.warn("收款流水同步跳过:认领公告[{}]收款金额为空或小于0", announce.getBillno()); + skippedCount++; + continue; + } + validAnnounces.put(detailSerialNo, announce); + } + log.info("收款流水数据校验完成:有效 {} 条,跳过 {} 条", validAnnounces.size(), skippedCount); + + if (validAnnounces.isEmpty()) { + log.info("收款流水同步无有效数据,记录操作日志后返回"); + record(null, null, "sync", "手动同步流水", null, null, BigDecimal.ZERO, + "金蝶认领公告无有效数据"); + return 0; + } + + // 批量查询已存在的记录(FOR UPDATE 防并发) + log.info("收款流水同步开始批量查询已存在记录,查询条件:detailSerialNo IN {} 条", validAnnounces.size()); + List existingEntities = baseMapper.selectList( + Wrappers.lambdaQuery() + .in(KingdeeReceiptFlow::getDetailSerialNo, validAnnounces.keySet()) + .last("FOR UPDATE") + ); + Map existingMap = existingEntities.stream() + .collect(Collectors.toMap(KingdeeReceiptFlow::getDetailSerialNo, e -> e)); + log.info("收款流水批量查询完成:已存在 {} 条,待新增 {} 条", + existingEntities.size(), validAnnounces.size() - existingEntities.size()); + + // 第二遍:构建新增和更新列表 + log.info("收款流水同步开始构建新增/更新列表"); + List toInsert = new ArrayList<>(); + List toUpdate = new ArrayList<>(); + int createdCount = 0; + int updatedCount = 0; + + for (Map.Entry entry : validAnnounces.entrySet()) { + String detailSerialNo = entry.getKey(); + BfiClaimAnnounceVO announce = entry.getValue(); + BigDecimal receiptAmount = money(announce.getReamount()); + + // 判断是新增还是更新 + KingdeeReceiptFlow existing = existingMap.get(detailSerialNo); + boolean created = (existing == null); + + KingdeeReceiptFlow entity; if (created) { + // 新增:初始化本地认领金额和状态 entity = new KingdeeReceiptFlow(); entity.setClaimedAmount(BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP)); entity.setClaimStatus(UNCLAIMED); - } - BigDecimal receiptAmount = positiveMoney(row.getReceiptAmount(), "收款金额"); - BigDecimal claimedAmount = money(entity.getClaimedAmount()); - if (claimedAmount.compareTo(receiptAmount) > 0) { - throw new ServiceException("流水" + row.getDetailSerialNo() + "同步金额不能小于已认领金额"); - } - copySyncRow(entity, row, receiptAmount); - entity.setClaimStatus(amountClaimStatus(claimedAmount, receiptAmount)); - if (created) { - baseMapper.insert(entity); } else { - baseMapper.updateById(entity); + // 更新:保留本地已认领金额和状态 + entity = existing; + } + + // 复制公告数据到实体(不覆盖本地认领字段) + copyAnnounceRow(entity, announce, detailSerialNo, receiptAmount); + // 根据本地累计认领金额与最新公告未认领金额重新计算认领状态 + entity.setClaimStatus(amountClaimStatus(money(entity.getClaimedAmount()), + entity.getUnclaimedAmount())); + + if (created) { + toInsert.add(entity); + createdCount++; + } else { + toUpdate.add(entity); + updatedCount++; } - record(entity.getId(), null, "sync", created ? "新增金蝶收款流水" : "更新金蝶收款流水", - entity.getClaimStatus(), entity.getClaimStatus(), BigDecimal.ZERO, entity.getDetailSerialNo()); - syncedCount++; } - return syncedCount; + log.info("收款流水列表构建完成:待新增 {} 条,待更新 {} 条", createdCount, updatedCount); + + // 批量插入和更新 + if (!toInsert.isEmpty()) { + log.info("收款流水同步开始批量插入 {} 条", toInsert.size()); + saveBatch(toInsert); + log.info("收款流水批量插入完成"); + } + if (!toUpdate.isEmpty()) { + log.info("收款流水同步开始批量更新 {} 条", toUpdate.size()); + updateBatchById(toUpdate); + log.info("收款流水批量更新完成"); + } + + // 记录操作日志(批量插入,避免逐行 N+1) + log.info("收款流水同步开始记录操作日志,共 {} 条", toInsert.size() + toUpdate.size()); + List records = new ArrayList<>(toInsert.size() + toUpdate.size()); + for (KingdeeReceiptFlow entity : toInsert) { + records.add(buildRecord(entity.getId(), null, "sync", "新增金蝶收款流水", + entity.getClaimStatus(), entity.getClaimStatus(), BigDecimal.ZERO, entity.getDetailSerialNo())); + } + for (KingdeeReceiptFlow entity : toUpdate) { + records.add(buildRecord(entity.getId(), null, "sync", "更新金蝶收款流水", + entity.getClaimStatus(), entity.getClaimStatus(), BigDecimal.ZERO, entity.getDetailSerialNo())); + } + if (!records.isEmpty()) { + Db.saveBatch(records); + } + log.info("收款流水操作日志记录完成"); + + log.info("金蝶收款流水同步完成 claimusers:{},拉取 {} 条,新增 {} 条,更新 {} 条", + claimusers, announces.size(), createdCount, updatedCount); + return createdCount + updatedCount; } - private Map candidateRow(FormalSettlement settlement) { - BigDecimal claimedAmount = settlementClaimedAmount(settlement.getId()); + private Map candidateRow(FormalSettlement settlement, BigDecimal claimedAmount) { BigDecimal settlementAmount = money(settlement.getSettlementAmount()); Map row = new LinkedHashMap<>(); row.put("id", settlement.getId()); @@ -366,16 +582,20 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl lockSettlements(List settlementIds) { - List settlements = new ArrayList<>(); + // 一次锁行查询替代逐单 selectOne(id 升序加锁,与逐单版加锁顺序一致防死锁) + List settlements = formalSettlementMapper.selectList( + Wrappers.lambdaQuery() + .in(FormalSettlement::getId, settlementIds) + .orderByAsc(FormalSettlement::getId) + .last("FOR UPDATE")); + Map settlementMap = new LinkedHashMap<>(); for (Long settlementId : settlementIds) { - FormalSettlement settlement = formalSettlementMapper.selectOne( - Wrappers.lambdaQuery() - .eq(FormalSettlement::getId, settlementId) - .last("FOR UPDATE")); - settlements.add(availableSettlement(settlement)); + FormalSettlement settlement = settlements.stream() + .filter(item -> settlementId.equals(item.getId())) + .findFirst().orElse(null); + settlementMap.put(settlementId, availableSettlement(settlement)); } - return settlements.stream().collect(Collectors.toMap(FormalSettlement::getId, - Function.identity(), (first, second) -> first, LinkedHashMap::new)); + return settlementMap; } private FormalSettlement availableSettlement(FormalSettlement settlement) { @@ -401,13 +621,20 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl receiptClaimedAmounts(List settlementIds) { + List ids = settlementIds.stream().filter(Objects::nonNull).distinct().toList(); + if (ids.isEmpty()) { + return Map.of(); + } return claimSettlementMapper.selectList(Wrappers.lambdaQuery() - .eq(ReceiptClaimSettlement::getFormalSettlementId, settlementId) - .eq(ReceiptClaimSettlement::getStatus, 1)).stream() - .map(ReceiptClaimSettlement::getAllocatedReceiptAmount) - .map(this::money) - .reduce(BigDecimal.ZERO, BigDecimal::add); + .in(ReceiptClaimSettlement::getFormalSettlementId, ids) + .eq(ReceiptClaimSettlement::getStatus, 1)).stream() + .collect(Collectors.groupingBy(ReceiptClaimSettlement::getFormalSettlementId, + Collectors.reducing(BigDecimal.ZERO, + item -> money(item.getAllocatedReceiptAmount()), BigDecimal::add))); } private BigDecimal flowClaimedAmount(Long flowId) { @@ -440,49 +667,421 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl + * 仅认领完成且未推送过的流水可推送;推送前以当前用户姓名重查 A301-2 最新公告对账。 + * 推送失败不抛异常跨事务边界(保证失败状态落库),原因随结果对象返回。 + */ + @Override + public ReceiptClaimPushResult pushClaimToKingdee(Long flowId) { + if (flowId == null) { + throw new ServiceException("收款流水ID不能为空"); } - required(row.getReceiptNoticeNo(), "认领通知单"); - required(row.getPayerName(), "付款人"); - required(row.getCounterpartyName(), "对方户名"); - required(row.getCounterpartyAccount(), "对方账号"); - required(row.getCounterpartyBank(), "对方开户行"); - required(row.getDetailSerialNo(), "明细流水号"); - if (row.getTransactionTime() == null) { - throw new ServiceException("交易时间不能为空"); + KingdeeReceiptFlow flow = existing(flowId); + if (flow.getBillId() == null) { + return ReceiptClaimPushResult.fail("认领公告id缺失,请先手动同步金蝶收款流水"); } - validateLength(row.getReceiptNoticeNo(), 100, "认领通知单不能超过100字"); - validateLength(row.getPayerName(), 200, "付款人不能超过200字"); - validateLength(row.getCounterpartyName(), 200, "对方户名不能超过200字"); - validateLength(row.getCounterpartyAccount(), 100, "对方账号不能超过100字"); - validateLength(row.getCounterpartyBank(), 200, "对方开户行不能超过200字"); - validateLength(row.getSummary(), 500, "摘要不能超过500字"); - validateLength(row.getDetailSerialNo(), 100, "明细流水号不能超过100字"); - positiveMoney(row.getReceiptAmount(), "收款金额"); + String claimusers = UserCache.getUserRealName(AuthUtil.getUserId()); + if (Func.isEmpty(claimusers)) { + throw new ServiceException("当前用户无姓名,无法推送金蝶收款认领"); + } + // 推送前对账:以当前用户身份重查 A301-2 拿最新公告(手动推送者须在公告认领范围内) + BfiClaimAnnounceVO announce = queryClaimAnnounces(claimusers).stream() + .filter(item -> flow.getBillId().equals(item.getId())) + .findFirst().orElse(null); + if (announce == null) { + return ReceiptClaimPushResult.fail("金蝶侧未查询到该认领公告,当前用户可能不在公告认领范围内,请重新同步后重试"); + } + return pushClaimLocked(flow.getId(), announce); } - private void copySyncRow(KingdeeReceiptFlow target, ReceiptFlowSyncRequest.FlowRow source, - BigDecimal receiptAmount) { - target.setReceiptNoticeNo(source.getReceiptNoticeNo().trim()); - target.setPayerName(source.getPayerName().trim()); + /** + * 定时推送金蝶:扫描认领完成且未推送过的流水,按认领人姓名分组重查 A301-2 最新公告后逐条推送。 + * 多副本部署下由 single-flight 分布式锁保证同一轮全局最多一份在跑,已有轮次进行中则本轮跳过。 + * + * @return 执行摘要 + */ + @Override + public String pushClaimByTask() { + return kingdeeSyncLockSupport.runSingleFlight(RECEIPT_CLAIM_PUSH_LOCK_PREFIX, bfiProperties.getReceiptClaimPush().getLockLeaseSeconds(), + "定时推送收款认领", this::executeReceiptClaimPush); + } + + /** + * 定时推送收款认领主体:按认领人姓名串行处理—— + * 先重查该认领人的 A301-2 最新公告并推完其名下全部流水,再到下一个认领人。 + * A301-2 的 claimusers 必填且为单个用户姓名,定时任务无登录人上下文, + * 故按认领人逐人查询公告,按公告id(bill_id)匹配流水。 + * 单条流水推送失败独立消化,不影响同批其他流水。 + * + * @return 执行摘要 + */ + private String executeReceiptClaimPush() { + List candidates = list(Wrappers.lambdaQuery() + .isNotNull(KingdeeReceiptFlow::getBillId) + .in(KingdeeReceiptFlow::getPushStatus, List.of( + KingdeeBfiConstant.RECEIPT_CLAIM_PUSH_NONE, KingdeeBfiConstant.RECEIPT_CLAIM_PUSH_FAILED)) + .eq(KingdeeReceiptFlow::getClaimStatus, CLAIMED) + .eq(KingdeeReceiptFlow::getStatus, 1) + .orderByAsc(KingdeeReceiptFlow::getId)); + if (candidates.isEmpty()) { + return "无待推送流水"; + } + Map candidateById = candidates.stream() + .collect(Collectors.toMap(KingdeeReceiptFlow::getId, Function.identity(), + (first, second) -> first, LinkedHashMap::new)); + // 认领人 → 其有效认领的流水id + Map> claimedFlowIdsByClaimer = claimMapper.selectList( + Wrappers.lambdaQuery() + .in(ReceiptClaim::getReceiptFlowId, candidateById.keySet()) + .eq(ReceiptClaim::getClaimStatus, CLAIMED)).stream() + .filter(claim -> Func.isNotEmpty(claim.getClaimerName()) && claim.getReceiptFlowId() != null) + .collect(Collectors.groupingBy(ReceiptClaim::getClaimerName, + Collectors.mapping(ReceiptClaim::getReceiptFlowId, Collectors.toSet()))); + if (claimedFlowIdsByClaimer.isEmpty()) { + return "待推送流水 " + candidates.size() + " 条,但无有效认领人,跳过"; + } + int successCount = 0; + int failedCount = 0; + int skippedCount = 0; + // 本轮已推送成功的流水:同一流水被多个认领人认领时,只在首个覆盖它的认领人处推送一次 + Set pushedFlowIds = new HashSet<>(); + // 按认领人逐人串行:查该认领人公告 → 推完其名下流水 → 下一个认领人 + for (String claimerName : claimedFlowIdsByClaimer.keySet().stream().sorted().toList()) { + Set claimerFlowIds = claimedFlowIdsByClaimer.get(claimerName); + List claimerFlows = candidateById.values().stream() + .filter(flow -> claimerFlowIds.contains(flow.getId())) + .filter(flow -> !pushedFlowIds.contains(flow.getId())) + .toList(); + if (claimerFlows.isEmpty()) { + continue; + } + // 该认领人的公告一次查询,按公告id归集 + Map announceByBillId = new HashMap<>(); + try { + for (BfiClaimAnnounceVO announce : queryClaimAnnounces(claimerName)) { + if (announce.getId() != null) { + announceByBillId.putIfAbsent(announce.getId(), announce); + } + } + } catch (Exception exception) { + log.error("定时推送收款认领:认领人[{}]公告查询失败,其名下流水全部跳过", claimerName, exception); + skippedCount += claimerFlows.size(); + continue; + } + for (KingdeeReceiptFlow flow : claimerFlows) { + BfiClaimAnnounceVO announce = announceByBillId.get(flow.getBillId()); + if (announce == null) { + skippedCount++; + log.info("定时推送收款认领:认领人[{}]流水[{}]未在公告查询结果中命中,跳过", + claimerName, flow.getId()); + continue; + } + try { + ReceiptClaimPushResult result = pushClaimLocked(flow.getId(), announce); + if (result.isSuccess()) { + successCount++; + pushedFlowIds.add(flow.getId()); + } else { + failedCount++; + } + } catch (Exception exception) { + failedCount++; + log.error("定时推送收款认领:认领人[{}]流水[{}]推送异常", claimerName, flow.getId(), exception); + } + } + } + String summary = "扫描 " + candidates.size() + " 条,推送成功 " + successCount + + " 条,推送失败 " + failedCount + " 条,公告未命中跳过 " + skippedCount + " 条"; + log.info("定时推送收款认领完成:{}", summary); + return summary; + } + + /** + * 事务内推送:流水行锁覆盖闸门校验、对账、推送与状态落库全过程, + * 防定时任务与手动按钮并发对同一流水双推 + */ + private ReceiptClaimPushResult pushClaimLocked(Long flowId, BfiClaimAnnounceVO announce) { + return transactionTemplate.execute(status -> doPushClaimToKingdee(flowId, announce)); + } + + /** + * 推送单条流水:静态闸门 → 最新公告对账 → 按流水归集分摊行推送 → 成败状态落库 + */ + private ReceiptClaimPushResult doPushClaimToKingdee(Long flowId, BfiClaimAnnounceVO announce) { + KingdeeReceiptFlow flow = lockedFlow(flowId); + if (flow.getBillId() == null) { + return ReceiptClaimPushResult.fail("认领公告id缺失,请先手动同步金蝶收款流水"); + } + // 终态闸门:已推送流水不允许二次推送(作废重认领使等式再满足也不推,金蝶侧人工处置) + if (KingdeeBfiConstant.RECEIPT_CLAIM_PUSH_PUSHED.equals(flow.getPushStatus())) { + return ReceiptClaimPushResult.fail("该流水已推送金蝶,不允许重复推送"); + } + // 推送前对账:以最新公告刷新公告侧未认领金额并重算认领状态(快照落库,页面口径同步修正) + flow.setUnclaimedAmount(money(announce.getUnclaimamount())); + flow.setKingdeeClaimStatus(trimToNull(announce.getClaimstatus())); + flow.setClaimMe(announce.getClaimme()); + BigDecimal claimedTotal = flowClaimedAmount(flow.getId()); + flow.setClaimedAmount(claimedTotal); + String claimStatus = amountClaimStatus(claimedTotal, flow.getUnclaimedAmount()); + flow.setClaimStatus(claimStatus); + updateById(flow); + if (!CLAIMED.equals(claimStatus)) { + return ReceiptClaimPushResult.fail("累计认领金额(" + claimedTotal + ")与公告未认领金额(" + + money(flow.getUnclaimedAmount()) + ")不一致,不满足推送条件"); + } + // 分录取该流水全部有效分摊行(结算单id升序,跨认领主单归集) + List allocations = claimSettlementMapper.selectList( + Wrappers.lambdaQuery() + .eq(ReceiptClaimSettlement::getReceiptFlowId, flow.getId()) + .eq(ReceiptClaimSettlement::getStatus, 1) + .orderByAsc(ReceiptClaimSettlement::getFormalSettlementId)); + if (allocations.isEmpty()) { + return ReceiptClaimPushResult.fail("该流水无有效认领分摊,无法推送"); + } + // 核心单据编号取第一张结算单同步金蝶回写的星瀚内码(应收/应付单金蝶id),TMS本地id金蝶无法识别 + FormalSettlement firstSettlement = formalSettlementMapper.selectById(allocations.get(0).getFormalSettlementId()); + if (firstSettlement == null || Func.isBlank(firstSettlement.getKingdeeBillId())) { + return ReceiptClaimPushResult.fail("结算单" + allocations.get(0).getFormalSettlementNo() + + "未同步金蝶(缺少金蝶单据id),不满足推送条件"); + } + // 推送来源编码:分摊结算单号下划线拼接(A301-3 契约口径;分摊不变则重试编码不变, + // 金蝶按 awrp_src_sys + awrp_src_code 幂等防重试双单) + String awrpSrcCode = allocations.stream() + .map(ReceiptClaimSettlement::getFormalSettlementNo) + .filter(Func::isNotEmpty) + .distinct() + .collect(Collectors.joining("_")); + flow.setAwrpSrcCode(awrpSrcCode); + + BfiClaimPushDTO param = buildClaimPushDTO(flow, claimedTotal, allocations, firstSettlement.getKingdeeBillId()); + log.info("调用BFI接口 收款认领结果推送开始 flowId:{},billId:{},awrpSrcCode:{},分摊明细 {} 行,认领金额 {}", + flow.getId(), flow.getBillId(), flow.getAwrpSrcCode(), allocations.size(), claimedTotal); + try { + BfiBillSaveResultVO result = bfiService.pushClaimResult(param); + return applyClaimPushSuccess(flow, extractClaimPushBillNo(result)); + } catch (Exception exception) { + return applyClaimPushFailure(flow, exception); + } + } + + /** + * A301-3 推送报文组装:billid=公告id,单条分录、金额=全部未推送金额(一笔流水一张金蝶收款单), + * 核心单据编号=第一张结算单的金蝶单据id(星瀚内码) + */ + private BfiClaimPushDTO buildClaimPushDTO(KingdeeReceiptFlow flow, BigDecimal claimedTotal, + List allocations, String coreBillId) { + BfiClaimPushDTO param = new BfiClaimPushDTO(); + // 【必填】认领公告id + param.setBillid(flow.getBillId()); + // 【必填】来源系统编码 + param.setAwrpSrcSys(bfiProperties.getSrcSys()); + // 【必填】来源系统单据编码(分摊结算单号下划线拼接) + param.setAwrpSrcCode(flow.getAwrpSrcCode()); + // 【必填】收款类型.编码 + param.setRecpaytypeNumber(KingdeeBfiConstant.BFI_CLAIM_REC_PAY_TYPE); + // 【必填】付款人类型 + param.setPaymenttype(KingdeeBfiConstant.BFI_CLAIM_PAYMENT_TYPE); + // 【必填】付款人名称 + param.setRecviewpayer(flow.getPayerName()); + // 【必填】付款人id + param.setRecpayer(flow.getPayerId()); + // 【必填】单据体分录:仅一条,应收金额=全部未推送金额(累计有效认领) + BfiClaimPushEntryDTO entry = new BfiClaimPushEntryDTO(); + // 【必填】应收金额(全部未推送金额) + entry.setEReceivableamt(claimedTotal); + // 分录行序号(第一张结算单id,分摊已按结算单id升序,多张取最小) + entry.setSeq(allocations.get(0).getFormalSettlementId()); + // 核心单据类型 + // entry.setECorebilltype(KingdeeBfiConstant.BFI_CLAIM_CORE_BILL_TYPE); + // 核心单据编号(第一张结算单的金蝶单据id,星瀚内码) + entry.setECorebillno(coreBillId); + param.setEntryentity(List.of(entry)); + return param; + } + + /** + * 解析 A301-3 推送结果:全项成功返回单据号,否则抛出汇总错误文本(由失败路径落库)。 + * 成功响应的 number 为空,金蝶收款单号取回执 keys 中的 awrp_src_code(来源编码)。 + */ + private String extractClaimPushBillNo(BfiBillSaveResultVO result) { + if (result == null || result.getResult() == null || result.getResult().isEmpty()) { + throw new ServiceException("金蝶认领结果推送失败,返回结果为空"); + } + List items = result.getResult(); + boolean allSuccess = items.stream().allMatch(item -> Boolean.TRUE.equals(item.getBillStatus())); + if (!allSuccess) { + String errorText = items.stream() + .map(BfiBillSaveResultItemVO::getErrorText) + .filter(text -> !text.isEmpty()) + .collect(Collectors.joining(";")); + throw new ServiceException("金蝶认领结果推送失败" + + (errorText.isEmpty() ? "" : ":" + errorText)); + } + return items.stream() + .map(this::claimPushBillNoOf) + .filter(Func::isNotEmpty) + .findFirst().orElse(null); + } + + /** + * 单项推送结果 → 单据号:A301-3 成功响应 number 为空,取回执键值中的 awrp_src_code + */ + private String claimPushBillNoOf(BfiBillSaveResultItemVO item) { + if (Func.isNotEmpty(item.getNumber())) { + return item.getNumber(); + } + return item.getKeys() == null ? null : item.getKeys().get("awrp_src_code"); + } + + /** + * 推送成功:流水置已推送并回填金蝶收款单号,级联刷新名下未作废认领主单的冗余展示字段 + */ + private ReceiptClaimPushResult applyClaimPushSuccess(KingdeeReceiptFlow flow, String kingdeeBillNo) { + log.info("调用BFI接口 收款认领结果推送成功 flowId:{},金蝶收款单号:{}", + flow.getId(), Func.isEmpty(kingdeeBillNo) ? "响应未返回" : kingdeeBillNo); + flow.setPushStatus(KingdeeBfiConstant.RECEIPT_CLAIM_PUSH_PUSHED); + flow.setPushedTime(LocalDateTime.now()); + flow.setKingdeeBillNo(kingdeeBillNo); + flow.setKingdeeBillStatus(KingdeeBfiConstant.KINGDEE_STATUS_PUSHED); + // 失败原因需显式置空(实体 update 忽略 null 字段,走 lambdaUpdate) + this.update(Wrappers.lambdaUpdate() + .eq(KingdeeReceiptFlow::getId, flow.getId()) + .set(KingdeeReceiptFlow::getPushStatus, flow.getPushStatus()) + .set(KingdeeReceiptFlow::getPushFailReason, null) + .set(KingdeeReceiptFlow::getPushedTime, flow.getPushedTime()) + .set(KingdeeReceiptFlow::getAwrpSrcCode, flow.getAwrpSrcCode()) + .set(KingdeeReceiptFlow::getKingdeeBillNo, kingdeeBillNo) + .set(KingdeeReceiptFlow::getKingdeeBillStatus, flow.getKingdeeBillStatus())); + // 级联刷新名下未作废认领主单的冗余展示字段 + claimMapper.update(null, Wrappers.lambdaUpdate() + .eq(ReceiptClaim::getReceiptFlowId, flow.getId()) + .eq(ReceiptClaim::getClaimStatus, CLAIMED) + .set(ReceiptClaim::getKingdeeBillNo, kingdeeBillNo) + .set(ReceiptClaim::getKingdeeBillStatus, KingdeeBfiConstant.KINGDEE_STATUS_PUSHED)); + record(flow.getId(), null, "push", "推送金蝶收款认领", CLAIMED, CLAIMED, flow.getClaimedAmount(), + "金蝶收款单号:" + (Func.isEmpty(kingdeeBillNo) ? "响应未返回" : kingdeeBillNo)); + return ReceiptClaimPushResult.success(kingdeeBillNo); + } + + /** + * 推送失败:流水置推送失败并记录原因,本地认领数据保留,可重新推送(重试复用同一来源编码) + */ + private ReceiptClaimPushResult applyClaimPushFailure(KingdeeReceiptFlow flow, Exception exception) { + String reason = Func.isEmpty(exception.getMessage()) + ? exception.getClass().getSimpleName() : exception.getMessage(); + reason = truncateText(reason, 1000); + log.error("调用BFI接口 收款认领结果推送失败 flowId:{},billId:{},awrpSrcCode:{}", + flow.getId(), flow.getBillId(), flow.getAwrpSrcCode(), exception); + this.update(Wrappers.lambdaUpdate() + .eq(KingdeeReceiptFlow::getId, flow.getId()) + .set(KingdeeReceiptFlow::getPushStatus, KingdeeBfiConstant.RECEIPT_CLAIM_PUSH_FAILED) + .set(KingdeeReceiptFlow::getPushFailReason, reason) + .set(KingdeeReceiptFlow::getAwrpSrcCode, flow.getAwrpSrcCode())); + record(flow.getId(), null, "push", "推送金蝶收款认领", CLAIMED, CLAIMED, flow.getClaimedAmount(), + "推送失败:" + truncateText(reason, 200)); + return ReceiptClaimPushResult.fail("金蝶认领结果推送失败:" + reason); + } + + /** + * A301-2 收款认领公告查询:按认领人姓名全量翻页,时间窗口取 Nacos 配置(默认近半年)。 + * 认领公告同步、手动推送对账与定时任务对账共用。 + */ + private List queryClaimAnnounces(String claimusers) { + int months = bfiProperties.getClaimAnnounce().getMonths(); + // 认领人直接取 Nacos 配置值 + String effectiveClaimusers = bfiProperties.getClaimAnnounce().getClaimusers(); + LocalDateTime endDate = LocalDateTime.now(); + LocalDateTime startDate = endDate.minusMonths(months); + long start = System.currentTimeMillis(); + log.info("调用BFI接口 收款认领公告查询开始 claimusers:{},时间窗口:{} ~ {}(近{}个月)", + effectiveClaimusers, startDate.format(BFI_DATE_TIME_FORMATTER), endDate.format(BFI_DATE_TIME_FORMATTER), months); + BfiClaimAnnounceQueryDTO queryDTO = new BfiClaimAnnounceQueryDTO(); + queryDTO.setClaimusers(effectiveClaimusers); + queryDTO.setModifytimeS(startDate.format(BFI_DATE_TIME_FORMATTER)); + queryDTO.setModifytimeE(endDate.format(BFI_DATE_TIME_FORMATTER)); + queryDTO.setPageSize(SYNC_PAGE_SIZE); + List announces = BfiUtil.queryAllPages(pageNo -> { + queryDTO.setPageNo(pageNo); + return bfiService.queryClaimAnnounce(queryDTO); + }); + log.info("调用BFI接口 收款认领公告查询结束 claimusers:{},耗时:{}ms,拉取 {} 条", + effectiveClaimusers, System.currentTimeMillis() - start, announces.size()); + return announces; + } + + /** + * A301-2 认领公告 → 收款流水表字段拷贝(仅覆盖金蝶侧来源字段,不覆盖本地认领金额与状态) + */ + private void copyAnnounceRow(KingdeeReceiptFlow target, BfiClaimAnnounceVO source, + String detailSerialNo, BigDecimal receiptAmount) { + // 金蝶认领公告id + target.setBillId(source.getId()); + // 收款通知单号 + target.setReceiptNoticeNo(trimToEmpty(source.getBillno())); + // 付款人名称(取自对方户名) + target.setPayerName(trimToEmpty(source.getOppunit())); + // 付款人id(取自对方账号) + target.setPayerId(trimToEmpty(source.getOppbanknumber())); + // 收款人 + target.setPayeeName(trimToNull(source.getRecpayee())); + // 收款金额 target.setReceiptAmount(receiptAmount); - target.setCounterpartyName(source.getCounterpartyName().trim()); - target.setCounterpartyAccount(source.getCounterpartyAccount().trim()); - target.setCounterpartyBank(source.getCounterpartyBank().trim()); - target.setSummary(trimToNull(source.getSummary())); - target.setTransactionTime(source.getTransactionTime()); - target.setDetailSerialNo(source.getDetailSerialNo().trim()); - target.setSourceUpdatedTime(source.getSourceUpdatedTime() == null - ? LocalDateTime.now() : source.getSourceUpdatedTime()); + // 对方单位名称 + target.setCounterpartyName(trimToEmpty(source.getOppunit())); + // 对方银行账号 + target.setCounterpartyAccount(trimToEmpty(source.getOppbanknumber())); + // 对方开户行 + target.setCounterpartyBank(trimToEmpty(source.getOppbank())); + // 摘要 + target.setSummary(trimToNull(source.getDescription())); + // 交易日期 + target.setTransactionTime(parseDateTime(source.getTradetime())); + // 明细流水号 + target.setDetailSerialNo(detailSerialNo); + // 未认领金额 + target.setUnclaimedAmount(money(source.getUnclaimamount())); + // 手续费 + target.setFee(source.getFee() == null ? null : source.getFee().setScale(2, RoundingMode.HALF_UP)); + // 公告认领状态 + target.setKingdeeClaimStatus(trimToNull(source.getClaimstatus())); + // 我已认领 + target.setClaimMe(source.getClaimme()); + // 来源更新时间 + target.setSourceUpdatedTime(target.getTransactionTime() == null + ? LocalDateTime.now() : target.getTransactionTime()); } - private String amountClaimStatus(BigDecimal claimedAmount, BigDecimal receiptAmount) { + private String firstNonBlank(String... values) { + for (String value : values) { + if (Func.isNotEmpty(value)) { + return value.trim(); + } + } + return null; + } + + private LocalDateTime parseDateTime(String value) { + if (Func.isEmpty(value)) { + return null; + } + try { + return LocalDateTime.parse(value.trim(), BFI_DATE_TIME_FORMATTER); + } catch (Exception e) { + log.warn("解析金蝶收款流水时间失败,原始值:{}", value, e); + return null; + } + } + + /** + * 累计认领金额 → 流水认领状态:对照公告未认领金额判定(claimed 即「认领完成」,唯一可推送状态) + */ + private String amountClaimStatus(BigDecimal claimedAmount, BigDecimal unclaimedAmount) { if (claimedAmount.compareTo(BigDecimal.ZERO) <= 0) { return UNCLAIMED; } - return claimedAmount.compareTo(money(receiptAmount)) >= 0 ? CLAIMED : PARTIAL; + return claimedAmount.compareTo(money(unclaimedAmount)) >= 0 ? CLAIMED : PARTIAL; } private String amountStatus(BigDecimal claimedAmount, BigDecimal settlementAmount) { @@ -511,25 +1110,34 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl maxLength) { throw new ServiceException(message); } } + private String truncateText(String value, int maxLength) { + if (value == null || value.length() <= maxLength) { + return value; + } + return value.substring(0, maxLength); + } + private void record(Long flowId, Long claimId, String actionType, String actionName, + String fromStatus, String toStatus, BigDecimal operationAmount, String content) { + recordMapper.insert(buildRecord(flowId, claimId, actionType, actionName, + fromStatus, toStatus, operationAmount, content)); + } + + private ReceiptFlowRecord buildRecord(Long flowId, Long claimId, String actionType, String actionName, String fromStatus, String toStatus, BigDecimal operationAmount, String content) { ReceiptFlowRecord record = new ReceiptFlowRecord(); record.setReceiptFlowId(flowId); @@ -541,6 +1149,6 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl changeRecords(IPage page, Long detailId) { - LambdaQueryWrapper wrapper = Wrappers.lambdaQuery() - .eq(ReceivablePayableChangeRecord::getDetailId, detailId) - .eq(ReceivablePayableChangeRecord::getIsDeleted, 0) - .orderByDesc(ReceivablePayableChangeRecord::getAdjustTime); - IPage entityPage = changeRecordMapper.selectPage(new Page<>(page.getCurrent(), page.getSize()), wrapper); - Page voPage = new Page<>(entityPage.getCurrent(), entityPage.getSize(), entityPage.getTotal()); - voPage.setRecords(entityPage.getRecords().stream().map(record -> Objects.requireNonNull( + List meaningfulRecords = changeRecordMapper.selectList( + Wrappers.lambdaQuery() + .eq(ReceivablePayableChangeRecord::getDetailId, detailId) + .eq(ReceivablePayableChangeRecord::getIsDeleted, 0) + .orderByDesc(ReceivablePayableChangeRecord::getAdjustTime)) + .stream() + .filter(record -> hasMeaningfulChange(record.getChangeContent())) + .toList(); + long current = page.getCurrent(); + long size = page.getSize(); + int fromIndex = (int) Math.max(0L, (current - 1L) * size); + int toIndex = (int) Math.min(meaningfulRecords.size(), fromIndex + size); + List pageRecords = fromIndex >= meaningfulRecords.size() + ? List.of() + : meaningfulRecords.subList(fromIndex, toIndex); + Page voPage = new Page<>(current, size, meaningfulRecords.size()); + voPage.setRecords(pageRecords.stream().map(record -> Objects.requireNonNull( BeanUtil.copyProperties(record, ReceivablePayableChangeRecordVO.class))).toList()); return voPage; } @@ -471,8 +481,10 @@ public class ReceivablePayableDetailServiceImpl } List afterRows = activeCargoFees(detail.getId()); List changes = reconcileUpdatedFeeAmounts(beforeRows, afterRows); + LocalDateTime adjustTime = LocalDateTime.now(); for (int index = 0; index < changes.size(); index++) { - saveChangeRecord(detail, changes.get(index), request.getAdjustReason(), String.format("%04d", index + 1)); + saveChangeRecord(detail, changes.get(index), request.getAdjustReason(), + String.format("%04d", index + 1), adjustTime); } } } @@ -613,14 +625,8 @@ public class ReceivablePayableDetailServiceImpl appendChange(changes, "运输单价", existing.getUnitPrice(), adjusted.getUnitPrice()); appendChange(changes, "里程", existing.getMileage(), mileage); appendChange(changes, "运输费", existing.getFreightAmount(), freightAmount); - if (!Objects.equals(existing.getRemark(), adjusted.getRemark())) { - changes.add("【备注】从[" + Objects.toString(existing.getRemark(), "") + "]调整为[" - + Objects.toString(adjusted.getRemark(), "") + "]"); - } - if (!Objects.equals(existing.getChangeReason(), adjusted.getChangeReason())) { - changes.add("【变更原因】从[" + Objects.toString(existing.getChangeReason(), "") + "]调整为[" - + Objects.toString(adjusted.getChangeReason(), "") + "]"); - } + appendChange(changes, "备注", existing.getRemark(), adjusted.getRemark()); + appendChange(changes, "变更原因", existing.getChangeReason(), adjusted.getChangeReason()); Map oldFeeItems = parseMap(existing.getFeeItemsJson()); for (String name : allowedFeeItems) { appendChange(changes, name, decimal(oldFeeItems.get(name)), money(effectiveFeeItems.get(name))); @@ -646,8 +652,9 @@ public class ReceivablePayableDetailServiceImpl if (changes.isEmpty()) { throw new ServiceException("未修改任何费用数据"); } + LocalDateTime adjustTime = LocalDateTime.now(); for (int i = 0; i < changes.size(); i++) { - saveChangeRecord(detail, changes.get(i), changeReasons.get(i), String.format("%04d", i + 1)); + saveChangeRecord(detail, changes.get(i), changeReasons.get(i), String.format("%04d", i + 1), adjustTime); } refreshAdjustedDetail(detail, allRows); } @@ -738,10 +745,13 @@ public class ReceivablePayableDetailServiceImpl } @Override - public IPage> transferCandidates(IPage page, String contractName, String batchNo, + public IPage> transferCandidates(IPage page, String documentNo, String contractNo, + String contractName, String batchNo, String generateStartDate, String generateEndDate, String settlementBillType, String settlementType) { ReceivablePayableDetailVO query = new ReceivablePayableDetailVO(); + query.setDocumentNo(documentNo); + query.setContractNo(contractNo); query.setContractName(contractName); query.setBatchNo(batchNo); query.setSettlementStatus("pending"); @@ -837,6 +847,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) @@ -2367,12 +2557,56 @@ public class ReceivablePayableDetailServiceImpl } private void appendChange(List changes, String field, String before, String after) { - if (!Objects.equals(before, after)) { - changes.add("【" + field + "】从[" + Objects.toString(before, "") + "]调整为[" - + Objects.toString(after, "") + "]"); + String oldValue = normalizeChangeText(before); + String newValue = normalizeChangeText(after); + if (!Objects.equals(oldValue, newValue)) { + changes.add("【" + field + "】从[" + oldValue + "]调整为[" + newValue + "]"); } } + /** + * 变更文本归一化:null / 空白视为相同,避免出现「从[]调整为[]」无效记录 + */ + private String normalizeChangeText(String value) { + return value == null ? "" : value.trim(); + } + + /** + * 是否存在实际前后差异;整段内容均无变化时不展示 + */ + private boolean hasMeaningfulChange(String content) { + if (Func.isBlank(content)) { + return false; + } + String text = content.trim(); + int suffixStart = text.lastIndexOf('('); + if (suffixStart > 0 && text.endsWith(")")) { + text = text.substring(0, suffixStart); + } + boolean hasPart = false; + for (String part : text.split(";")) { + if (Func.isBlank(part)) { + continue; + } + hasPart = true; + if (isMeaningfulChangePart(part.trim())) { + return true; + } + } + return !hasPart; + } + + private boolean isMeaningfulChangePart(String part) { + int fromIdx = part.indexOf("从["); + int toIdx = part.indexOf("]调整为["); + if (fromIdx < 0 || toIdx < fromIdx || !part.endsWith("]")) { + return true; + } + String before = part.substring(fromIdx + 2, toIdx); + String after = part.substring(toIdx + 5, part.length() - 1); + return !Objects.equals(before, after); + } + private String formatValue(BigDecimal value) { return money(value).stripTrailingZeros().toPlainString(); } @@ -2613,10 +2847,18 @@ public class ReceivablePayableDetailServiceImpl } private void saveChangeRecord(ReceivablePayableDetail detail, String content, String reason) { - saveChangeRecord(detail, content, reason, "0001"); + saveChangeRecord(detail, content, reason, "0001", LocalDateTime.now()); } private void saveChangeRecord(ReceivablePayableDetail detail, String content, String reason, String lineNo) { + saveChangeRecord(detail, content, reason, lineNo, LocalDateTime.now()); + } + + private void saveChangeRecord(ReceivablePayableDetail detail, String content, String reason, String lineNo, + LocalDateTime adjustTime) { + if (!hasMeaningfulChange(content)) { + return; + } ReceivablePayableChangeRecord record = new ReceivablePayableChangeRecord(); record.setDetailId(detail.getId()); record.setLineNo(lineNo); @@ -2625,7 +2867,7 @@ public class ReceivablePayableDetailServiceImpl record.setAdjustUser(AuthUtil.getUserId()); record.setAdjustUserName(AuthUtil.getUserName()); record.setAdjustReason(reason); - record.setAdjustTime(LocalDateTime.now()); + record.setAdjustTime(adjustTime == null ? LocalDateTime.now() : adjustTime); changeRecordMapper.insert(record); } @@ -2979,6 +3221,11 @@ public class ReceivablePayableDetailServiceImpl } private String formatQuantity(BigDecimal quantity, String unit) { + // 无有效运输量(空/0/-1)时不展示数值与单位 + if (quantity == null || quantity.compareTo(BigDecimal.ZERO) == 0 + || quantity.compareTo(BigDecimal.valueOf(-1)) == 0) { + return "-"; + } return money(quantity).stripTrailingZeros().toPlainString() + (unit == null ? "" : " " + unit); } 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 f43bfae2..8dda1c1c 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 @@ -9,6 +9,7 @@ import org.springblade.core.mp.base.BaseServiceImpl; import org.springblade.core.secure.utils.AuthUtil; import org.springblade.core.tool.utils.Func; import org.springblade.system.cache.UserCache; +import org.springblade.transport.constant.TransportBusiConstant; import org.springblade.transport.mapper.FormalSettlementDetailFeeMapper; import org.springblade.transport.mapper.FormalSettlementDetailMapper; import org.springblade.transport.mapper.FormalSettlementChangeRecordMapper; @@ -52,11 +53,6 @@ import java.util.Set; @RequiredArgsConstructor public class SettlementAdjustmentServiceImpl extends BaseServiceImpl implements ISettlementAdjustmentService { - private static final String DRAFT = "draft"; - private static final String REVIEWING = "reviewing"; - private static final String APPROVED = "approved"; - private static final String RETURNED = "returned"; - private static final String VOIDED = "voided"; private static final String PAID = "paid"; private final SettlementAdjustmentDetailMapper detailMapper; private final FormalSettlementMapper formalMapper; @@ -101,7 +97,7 @@ public class SettlementAdjustmentServiceImpl extends BaseServiceImpl> candidateFormalSettlements(String keyword) { List rows = formalMapper.selectList(Wrappers.lambdaQuery() - .eq(FormalSettlement::getApprovalStatus, APPROVED) + .eq(FormalSettlement::getApprovalStatus, TransportBusiConstant.APPROVED) .and(wrapper -> wrapper.isNull(FormalSettlement::getPaymentStatus) .or().ne(FormalSettlement::getPaymentStatus, PAID)) .like(Func.isNotEmpty(keyword), FormalSettlement::getFormalSettlementNo, keyword) @@ -152,7 +148,7 @@ public class SettlementAdjustmentServiceImpl extends BaseServiceImpl details = detailMapper.selectList( Wrappers.lambdaQuery() @@ -204,15 +200,15 @@ public class SettlementAdjustmentServiceImpl extends BaseServiceImpllambdaQuery().eq(SettlementAdjustmentDetail::getAdjustmentId, adjustment.getId()))) { if (money(item.getAdjustmentAmountTax()).signum() == 0) continue; @@ -261,7 +257,7 @@ public class SettlementAdjustmentServiceImpl extends BaseServiceImpllambdaQuery() .eq(FormalSettlementPayment::getIsDeleted, 0) .in(FormalSettlementPayment::getFormalSettlementId, formalSettlementIds) - .notIn(FormalSettlementPayment::getBillStatus, RETURNED, VOIDED)).stream() + .notIn(FormalSettlementPayment::getBillStatus, TransportBusiConstant.RETURNED, TransportBusiConstant.VOIDED)).stream() .map(FormalSettlementPayment::getFormalSettlementId) .filter(Objects::nonNull).toList()); result.addAll(paymentApplicationMapper.selectList(Wrappers.lambdaQuery() .select(PaymentApplication::getSettlementId) .in(PaymentApplication::getSettlementId, formalSettlementIds) - .eq(PaymentApplication::getPaymentType, "settlement_payment") + .eq(PaymentApplication::getPaymentType, TransportBusiConstant.PAYMENT_TYPE_SETTLEMENT) .eq(PaymentApplication::getIsDeleted, 0) - .notIn(PaymentApplication::getApprovalStatus, RETURNED, VOIDED)).stream() + .notIn(PaymentApplication::getApprovalStatus, TransportBusiConstant.RETURNED, TransportBusiConstant.VOIDED)).stream() .map(PaymentApplication::getSettlementId).filter(Objects::nonNull).toList()); List relations = paymentApplicationSettlementMapper.selectList( Wrappers.lambdaQuery() @@ -355,9 +351,9 @@ public class SettlementAdjustmentServiceImpl extends BaseServiceImpllambdaQuery() .select(PaymentApplication::getId) .in(PaymentApplication::getId, applicationIds) - .eq(PaymentApplication::getPaymentType, "settlement_payment") + .eq(PaymentApplication::getPaymentType, TransportBusiConstant.PAYMENT_TYPE_SETTLEMENT) .eq(PaymentApplication::getIsDeleted, 0) - .notIn(PaymentApplication::getApprovalStatus, RETURNED, VOIDED)).stream() + .notIn(PaymentApplication::getApprovalStatus, TransportBusiConstant.RETURNED, TransportBusiConstant.VOIDED)).stream() .map(PaymentApplication::getId).filter(Objects::nonNull).toList()); relations.stream().filter(relation -> activeApplicationIds.contains(relation.getPaymentApplicationId())) .map(PaymentApplicationSettlement::getFormalSettlementId).filter(Objects::nonNull) @@ -394,7 +390,7 @@ public class SettlementAdjustmentServiceImpl extends BaseServiceImpllambdaQuery().likeRight(SettlementAdjustment::getAdjustmentNo, prefix)); return prefix + String.format("%04d", count + 1); } private BigDecimal money(BigDecimal value) { return value == null ? BigDecimal.ZERO : 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..34ae5a9b --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementImportBatchServiceImpl.java @@ -0,0 +1,1144 @@ +/** + * 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.excel.util.ExcelUtil; +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.UserCache; +import org.springblade.system.feign.ISysClient; +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.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 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()) + .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("导入结算单批次不存在"); + } + return toVO(batch); + } + + @Override + public void exportTemplate(HttpServletResponse response) { + List sample = new ArrayList<>(); + ExcelUtil.export(response, "导入结算单模板", "导入结算单模板", sample, SettlementImportTemplateExcel.class); + } + + @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("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()) { + 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) { + 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()); + } + batch.setWaybillCount(0); + batch.setDetailCount(0); + saveOrUpdate(batch); + return toVO(batch); + } + + @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))); + updateById(batch); + return toVO(batch); + } + + @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) + .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) { + List matches = waybillService.list(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) + .orderByDesc(Waybill::getCreateTime) + .last("limit 2")); + if (Func.isEmpty(matches)) { + throw new ServiceException("未匹配到运单号「" + waybillNo + "」对应的系统已有运单"); + } + if (matches.size() > 1) { + throw new ServiceException("运单号「" + waybillNo + "」匹配到多条运单,请核对后重试"); + } + return matches.get(0); + } + + 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; + } + 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 = transportFee.add(feeItemTotal).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 feeItemCatalog = loadFeeItemNames(); + 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 (String name : feeItems.keySet()) { + if (!feeItemCatalog.contains(name)) { + errors.add("费用项「" + name + "」未在费用项主数据中匹配"); + } + } + for (String name : feeItemNames) { + if (Func.isNotEmpty(name) && !feeItemCatalog.contains(name)) { + errors.add("费用项「" + name + "」未在费用项主数据中匹配"); + } + } + if (!errors.isEmpty()) { + errorMap.put(index, String.join("; ", errors)); + } + } + return errorMap; + } + + private Set loadFeeItemNames() { + R> response = sysClient.getFeeItems(); + if (response == null || !response.isSuccess() || response.getData() == null) { + throw new ServiceException("费用项基础档案读取失败,请稍后重试"); + } + return response.getData().stream() + .map(FeeItem::getName) + .filter(Func::isNotEmpty) + .collect(Collectors.toCollection(HashSet::new)); + } + + 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(stringValue(row, "vehicleNo", "*车牌号/船班号/船次号/班次号")); + 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", "*运输费", "运输费")); + Map feeItems = extractFeeItems(row, List.of("装卸费", "过路费")); + excel.setLoadingFee(feeItems.get("装卸费")); + excel.setTollFee(feeItems.get("过路费")); + 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; + } + + 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); + } +} 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..52e7dbc4 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementProjectAdvanceWriteOffServiceImpl.java @@ -0,0 +1,211 @@ +/** + * 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.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.HashSet; +import java.util.List; +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::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 + @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 (!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)); + } + + 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/TransportVehicleServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportVehicleServiceImpl.java index 467fb555..e28d7329 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 @@ -225,6 +225,11 @@ public class TransportVehicleServiceImpl extends BaseServiceImpl minioClientProvider; private final String minioBucketName; private final String minioRootDirectory; + private final String minioTransformEndpoint; + private final String minioAccessKey; + private final String minioSecretKey; + private volatile MinioClient transformPresignClient; public VoucherManageServiceImpl(VoucherWaybillBatchMapper voucherWaybillBatchMapper, IProjectApplyService projectApplyService, IWaybillService waybillService, @@ -95,7 +99,10 @@ public class VoucherManageServiceImpl extends BaseServiceImpl minioClientProvider, @Value("${file.storage.minio.bucket-name:${minio.bucket-name:}}") String minioBucketName, - @Value("${file.storage.minio.root-directory:${minio.root-directory:}}") String minioRootDirectory) { + @Value("${file.storage.minio.root-directory:${minio.root-directory:}}") String minioRootDirectory, + @Value("${file.storage.minio.transform-endpoint:${minio.transform-endpoint:}}") String minioTransformEndpoint, + @Value("${file.storage.minio.access-key-id:${minio.access-key:}}") String minioAccessKey, + @Value("${file.storage.minio.access-key-secret:${minio.secret-key:}}") String minioSecretKey) { this.voucherWaybillBatchMapper = voucherWaybillBatchMapper; this.projectApplyService = projectApplyService; this.waybillService = waybillService; @@ -106,6 +113,9 @@ public class VoucherManageServiceImpl extends BaseServiceImpl 0) { + result.append(':').append(url.getPort()); + } + result.append(pathPrefix).append(path); + if (Func.isNotEmpty(url.getRawQuery())) { + result.append('?').append(url.getRawQuery()); + } + return result.toString(); + } + + private String normalizePathPrefix(String path) { + if (Func.isEmpty(path) || "/".equals(path)) { + return ""; + } + String normalized = path.endsWith("/") ? path.substring(0, path.length() - 1) : path; + return normalized.startsWith("/") ? normalized : "/" + normalized; + } + private String normalizePlateNo(String plateNo) { return plateNo == null ? null : plateNo.replaceAll("[\\s-]", "").toUpperCase(); } 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 1a585643..5a40decd 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 @@ -342,10 +342,11 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl queryWrapper, Long carrierId) { + Long normalizedCarrierId = normalizeOptionalId(carrierId); + if (normalizedCarrierId == null) { + return; + } + CustomerArchive carrier = customerArchiveService.getById(normalizedCarrierId); + String carrierName = carrier == null ? null : Func.toStr(carrier.getFullName()).trim(); + queryWrapper.and(wrapper -> { + wrapper.apply("FIND_IN_SET({0}, IFNULL(carrier_ids,''))", String.valueOf(normalizedCarrierId)); + if (Func.isNotEmpty(carrierName)) { + wrapper.or().eq(WaybillImportBatch::getCarrierName, carrierName); + } + }); + } + @Override @Transactional(rollbackFor = Exception.class) public BusinessRemoveResultVO removeBatches(String ids) { @@ -405,8 +424,9 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl carrierIds = resolveCarrierIds(request); + batch.setCarrierIds(joinIds(carrierIds)); + batch.setCarrierName(resolveCarrierNames(carrierIds, request.getCarrierName())); batch.setImportStatus(importStatus); batch.setImportType(IMPORT_TYPE_SETTLEMENT.equals(request.getImportType()) ? IMPORT_TYPE_SETTLEMENT : IMPORT_TYPE_WAYBILL); ProjectApply project = projectApplyService.getById(request.getProjectId()); @@ -586,6 +606,38 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl resolveCarrierIds(WaybillImportBatchRequest request) { + if (request == null) { + return List.of(); + } + List carrierIds = Func.isEmpty(request.getCarrierIds()) + ? new ArrayList<>() + : request.getCarrierIds().stream() + .map(this::normalizeOptionalId) + .filter(Objects::nonNull) + .distinct() + .collect(Collectors.toCollection(ArrayList::new)); + if (Func.isNotEmpty(carrierIds)) { + return carrierIds; + } + Long carrierId = normalizeOptionalId(request.getCarrierId()); + if (carrierId != null) { + return List.of(carrierId); + } + String carrierName = Func.toStr(request.getCarrierName()).trim(); + if (Func.isEmpty(carrierName)) { + return List.of(); + } + CustomerArchive carrier = customerArchiveService.getOne(Wrappers.lambdaQuery() + .eq(CustomerArchive::getFullName, carrierName) + .eq(CustomerArchive::getIsDeleted, 0) + .last("limit 1")); + return carrier == null || carrier.getId() == null ? List.of() : List.of(carrier.getId()); + } + private String resolveCarrierNames(List carrierIds, String carrierName) { if (Func.isNotEmpty(carrierName)) return carrierName; if (Func.isEmpty(carrierIds)) return null; diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java index 64c4837e..fe4a5160 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java @@ -1211,15 +1211,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 { @@ -1344,21 +1343,15 @@ public class WaybillServiceImpl extends BaseServiceImpl target.setFreightJson(source.getFreightJson()); target.setAttachmentsJson(source.getAttachmentsJson()); target.setRemark(source.getRemark()); - // 复制后业务状态与原运单保持一致 - String sourceBusinessStatus = source.getBusinessStatus(); - target.setBusinessStatus(sourceBusinessStatus); + // 复制新建固定为草稿,不沿用原运单业务状态 + target.setBusinessStatus(STATUS_DRAFT); target.setWaybillNo(nextCode()); clearDriverAcceptRecord(target); fillProjectProcessConfig(target); prepare(target); - applyDriverAcceptBusinessStatus(target); - if (Func.isNotEmpty(sourceBusinessStatus)) { - target.setBusinessStatus(sourceBusinessStatus); - } + target.setBusinessStatus(STATUS_DRAFT); validate(target); save(target); - // 原运单已完成时,复制后按合同系统计费规则生成应收应付明细 - generateReceivablePayableIfCompleted(target); return detail(target.getId()); } @@ -1610,12 +1603,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()); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/ExternalSrcCodeSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/ExternalSrcCodeSupport.java new file mode 100644 index 00000000..9cd5135a --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/ExternalSrcCodeSupport.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.transport.support; + +import org.springblade.core.tool.utils.Func; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +/** + * 外部系统来源单据号组装工具 + *

+ * 统一外部单据号(如金蝶 awrp_src_code)的拼接规则:主单据号 + 子单据号列表按下划线拼接; + * 自动去空白、去重,便于后续各链路通用编码与扩展处理 + * + * @author Chill + */ +public class ExternalSrcCodeSupport { + + /** + * 外部单据号拼接分隔符 + */ + public static final String SEPARATOR = "_"; + + private ExternalSrcCodeSupport() { + } + + /** + * 组装外部系统来源单据号:主单据号 + 子单据号按下划线拼接 + *

各部分自动去首尾空格、过滤空白并去重;主单据号为空时仅拼接子单据号

+ * + * @param mainCode 主单据号(如付款申请号) + * @param partCodes 子单据号列表(如结算明细单据号) + * @return 拼接后的外部来源单据号,无有效值时返回空串 + */ + public static String buildSrcCode(String mainCode, Collection partCodes) { + List parts = new ArrayList<>(); + if (Func.isNotBlank(mainCode)) { + parts.add(mainCode.trim()); + } + if (Func.isNotEmpty(partCodes)) { + partCodes.stream().filter(Func::isNotBlank).map(String::trim).distinct().forEach(parts::add); + } + return String.join(SEPARATOR, parts); + } +} 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 new file mode 100644 index 00000000..f091b00f --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeApBillSyncSupport.java @@ -0,0 +1,1047 @@ +/** + * 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.support; + +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.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; +import org.springblade.thirdparty.bfi.pojo.dto.BfiApBillSaveEntryDTO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiApBillVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultItemVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultVO; +import org.springblade.thirdparty.bfi.service.IBfiService; +import org.springblade.thirdparty.bfi.util.BfiUtil; +import org.springblade.transport.config.BfiProperties; +import org.springblade.transport.constant.KingdeeBfiConstant; +import org.springblade.transport.mapper.ContractManageMapper; +import org.springblade.transport.mapper.CustomerArchiveMapper; +import org.springblade.transport.mapper.FormalSettlementDetailFeeMapper; +import org.springblade.transport.mapper.FormalSettlementMapper; +import org.springblade.transport.mapper.FormalSettlementSummaryFeeMapper; +import org.springblade.transport.mapper.ProjectApplyMapper; +import org.springblade.transport.pojo.entity.ContractManage; +import org.springblade.transport.pojo.entity.CustomerArchive; +import org.springblade.transport.pojo.entity.FormalSettlement; +import org.springblade.transport.pojo.entity.FormalSettlementDetail; +import org.springblade.transport.pojo.entity.FormalSettlementDetailFee; +import org.springblade.transport.pojo.entity.FormalSettlementSummaryFee; +import org.springblade.transport.pojo.entity.ProjectApply; +import org.springblade.transport.service.KingdeeSyncContext; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +/** + * 金蝶财务应付单(A102-2)同步支撑组件。 + *

流程拆分后:财务应付单(A102-2)由正式结算单「同步金蝶」推送并回写结算单; + * 付款申请「同步金蝶」仅负责付款申请单(A103-1 应付下推 / A103-3 直接保存), + * 推送前经 A102-4 预检应付单已生成、下推时经 A102-4 反查付款计划分录 awrp_entry_code_p。 + * 两侧共用的防重键组装、A102-4 查询/反查、A102-2 保存、结算明细分录解析与档案取数统一收口在本组件; + * 财务应收单(A302-2)同步组件 {@link KingdeeArBillSyncSupport} 亦复用本组件的结算明细分录解析与档案取数方法(public 成员)。

+ *

明细分录口径:取正式结算单页面结算明细(每行一个运单,按 货物 → 费用大类 → 税率 拆分多行) + * + 结算合计费用项(手动添加的每行一条); + * 物料编码按装卸大类/项目业务模式/运单运输方式映射,编码维护在 Nacos 配置 thirdparty.bfi.material.*(见 {@link #resolveMaterialNumber}); + * 运输类分录数量取运输量(按税率拆行时每行都取运输量)、装卸类默认 1;单价按结算金额/数量反算。

+ * + * @author Chill + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class KingdeeApBillSyncSupport { + + /** A102-4 防重键批量分批查询单次条数上限(与 A103-2 批量查询一致) */ + private static final int PAY_RESULT_QUERY_BATCH = 50; + /** A102-4 分页查询页大小 */ + 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"; + + private final IBfiService bfiService; + private final ContractManageMapper contractManageMapper; + private final CustomerArchiveMapper customerArchiveMapper; + private final FormalSettlementDetailFeeMapper formalSettlementDetailFeeMapper; + private final FormalSettlementSummaryFeeMapper formalSettlementSummaryFeeMapper; + private final FormalSettlementMapper formalSettlementMapper; + private final ProjectApplyMapper projectApplyMapper; + private final ISysClient sysClient; + private final BfiProperties bfiProperties; + + // ==================== 财务应付单防重键(awrp_src_code) ==================== + + /** + * 结算明细单据号去重收集(保持行序),用于拼接财务应付单外部系统来源单据号 + * + * @param settlementDetails 结算明细列表 + * @return 单据号列表(去空白、去重、保持行序) + */ + public List settlementDocumentNos(List settlementDetails) { + return settlementDetails.stream() + .map(FormalSettlementDetail::getDocumentNo) + .filter(Func::isNotBlank) + .map(String::trim) + .distinct() + .toList(); + } + + /** + * 财务应付单防重键(awrp_src_code):正式结算单号 + 结算明细单据号下划线拼接(与 A102-2 保存传参一致)。 + * 流程拆分后 A102-2 由正式结算单推送,防重键以正式结算单号为主,一张结算单只生成一张应付单, + * 同一结算单下多笔付款申请(A103-1)共享该应付单 + * + * @param settlement 正式结算单 + * @param settlementDetails 该结算单下的结算明细 + * @return 防重键 + */ + public String apBillSrcCode(FormalSettlement settlement, List settlementDetails) { + return ExternalSrcCodeSupport.buildSrcCode(settlement.getFormalSettlementNo(), settlementDocumentNos(settlementDetails)); + } + + // ==================== A102-4 财务应付单查询 ==================== + + /** + * A102-4 批量查询已生成财务应付单:来源单据号批量 + 外部系统编号(防重键),自动翻页, + * 用于推送前幂等预检与应付单是否已生成前置校验(awrp_src_code 支持多值,整批一次查询消除逐单远程查询 N+1) + * + * @param awrpSrcCodes 来源单据号(正式结算单号_结算明细单据号拼接)批次 + * @return 已生成财务应付单的 来源单据号->单据编号 映射 + */ + public Map queryApBillExistNos(List awrpSrcCodes) { + Map existBillNos = new LinkedHashMap<>(); + // 防重键批量分批查询,避免单次过滤条件过长 + batchForeach(awrpSrcCodes, PAY_RESULT_QUERY_BATCH, batch -> { + BfiApBillQueryDTO param = new BfiApBillQueryDTO(); + param.setAwrpSrcCode(batch); + param.setAwrpSrcSys(bfiProperties.getSrcSys()); + for (BfiApBillVO apBill : BfiUtil.queryAllPages(pageNo -> { + param.setPageNo(pageNo); + param.setPageSize(PAY_RESULT_PAGE_SIZE); + return bfiService.queryApBill(param); + })) { + if (Func.isNotEmpty(apBill.getAwrpSrcCode())) { + existBillNos.put(apBill.getAwrpSrcCode(), apBill.getBillno()); + } + } + }); + return existBillNos; + } + + /** + * A102-4 单防重键查询已生成财务应付单的星瀚内码(A902 附件上传定位用): + * 附件补传时本地 kingdee_bill_id 缺失(存量数据/幂等跳过路径未带回 id)的场景回填 + * + * @param awrpSrcCode 财务应付单防重键 + * @return 星瀚应付单内码,未生成返回 null + */ + public String queryApBillExistId(String awrpSrcCode) { + if (Func.isBlank(awrpSrcCode)) { + return null; + } + 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()) && apBill.getId() != null) { + return String.valueOf(apBill.getId()); + } + } + return null; + } + + // ==================== A102-2 财务应付单保存 ==================== + + /** + * 推送金蝶财务应付单(A102-2 财务应付单保存,由正式结算单「同步金蝶」触发)。 + * 推送前先经 A102-4 按防重键(awrp_src_sys + awrp_src_code)查询:金蝶已存在财务应付单时 + * 幂等跳过保存并返回已有单据编号(说明上次同步已保存成功、在后续环节中断,应续推而非拦截报错)。 + * 明细分录取页面结算明细(每行按 货物→费用大类→税率 拆分)+ 结算合计费用项(每行一条),结算金额逐行直送; + * 汇率日期/汇率取结算单页面维护值;付款计划 planentity 暂不传(启用方案见方法内注释), + * 当前由金蝶按明细自动汇总生成 + * + * @param apBillSrcCode 财务应付单防重键(正式结算单号_结算明细单据号拼接) + * @param settlement 正式结算单(合同/汇率日期/汇率取数来源) + * @param entryGroups 结算明细+结算合计费用项解析出的应付单分录 + * @param subject 业务单据号(正式结算单号,日志与异常提示用) + * @param context 金蝶同步上下文(缓存载体) + * @return 财务应付单推送结果(单据编号 + 是否金蝶已存在幂等跳过) + */ + public BfiApBillPushOutcome pushApBill(String apBillSrcCode, FormalSettlement settlement, List entryGroups, + String subject, KingdeeSyncContext context) { + // A102-4 幂等预检:按防重键查询金蝶是否已生成财务应付单(批量同步时整批已预热缓存,此处仅懒加载未命中编码), + // 已存在则幂等跳过保存,直接返回已有单据编号 + String existBillNo = context.apBillNoCache().computeIfAbsent(apBillSrcCode, code -> + queryApBillExistNos(List.of(code)).getOrDefault(code, "")); + if (Func.isNotEmpty(existBillNo)) { + log.info("正式结算单[{}]金蝶财务应付单已生成(单据编号[{}]),幂等跳过 A102-2 保存", subject, existBillNo); + return new BfiApBillPushOutcome(existBillNo, null, true); + } + ContractManage contract = requireContract(settlement.getContractId(), context); + // 单据类型按映射字典转换:默认服务采购应付,金蝶确认其他类型后修改 BFI_AP_BILL_BILLTYPE_NAME 即可 + String billtypeNumber = KingdeeBfiConstant.billtypeBy(KingdeeBfiConstant.AP_BILL_BILLTYPE_MAP, KingdeeBfiConstant.BFI_AP_BILL_BILLTYPE_NAME); + if (Func.isBlank(billtypeNumber)) { + throw new ServiceException("推送金蝶财务应付单失败:单据类型[" + KingdeeBfiConstant.BFI_AP_BILL_BILLTYPE_NAME + "]未在映射字典中维护"); + } + String orgNumber = bfiOrgNumber(contract); + // 币种取合同结算币种 + String currency = bfiCurrency(contract); + // 单据日期:yyyy-MM-dd HH:mm:ss(各接口统一格式),流程拆分后以同步当日作为应付单据日期 + String billDateText = bfiDateTimeText(LocalDate.now()); + BfiApBillSaveDTO param = new BfiApBillSaveDTO(); + // 【必填】单据类型.编码 + param.setBilltypeidNumber(billtypeNumber); + // 【必填】结算组织.编码 + param.setOrgNumber(orgNumber); + // 【必填】单据日期 + param.setBizdate(billDateText); + // 【必填】往来类型:合同类别为“客户合同”时为 bd_customer(客户),否则 bd_supplier(供应商) + param.setAsstacttype(bfiAsstactType(contract)); + // 【必填】往来户.编码:合同乙方关联客商档案客商编码 + param.setAsstactNumber(bfiAsstactNumber(contract, context)); + // 【必填】付款方式(CASH:现购, CREDIT:赊购) + param.setPurmode(KingdeeBfiConstant.BFI_AP_BILL_PUR_MODE_CASH); + // 【必填】结算币别.货币代码 + param.setCurrencyNumber(currency); + // 【必填】付款组织.编码 + param.setPayorgNumber(orgNumber); + // 采购组织.编码(非必传) + // param.setPurorgNumber(orgNumber); + // 【必填】款项性质.编码 + param.setPaypropertyNumber(KingdeeBfiConstant.BFI_AP_BILL_PAY_PROPERTY); + // 结算方式.编码(settlementtype_number)不在应付单送:按付款方式映射的结算方式仅 A103-1 付款申请(应付下推)分录需要; + // 税率编码(taxrateid_number)仍待金蝶侧确认后补充 + // 录入含税价(非必传) + param.setIsincludetax(true); + // 录入总价,为 true 后可不录入单价(非必传) + param.setIspricetotal(true); + param.setIsintax(false); + // 汇率表.编码(非必传) + // param.setExratetableNumber(bfiProperties.getPayApply().getExratetable()); + // 汇率日期:正式结算单页面维护的汇率日期(非必传,未维护不送) + if (settlement.getExchangeRateDate() != null) { + param.setExratedate(bfiDateTimeText(settlement.getExchangeRateDate())); + } + // 汇率:正式结算单页面维护的结算汇率(非必传,未维护不送) + if (settlement.getExchangeRate() != null) { + param.setExchangerate(settlement.getExchangeRate()); + } + // 往来单位名称(非必传) + // param.setAsstactname(settlement.getPayeeName()); + // 备注(非必传) + // param.setRemark(subject); + // 【必填】外部系统编号(候选键) + param.setAwrpSrcSys(bfiProperties.getSrcSys()); + // 【必填】外部系统来源单据号(候选键):正式结算单号_结算明细单据号1_单据号2...(复用预检已组装的防重键) + param.setAwrpSrcCode(apBillSrcCode); + // 来源单据(非必传,有上游合同时传 awrp_contract_info) + // param.setAwrpSrcBt(KingdeeBfiConstant.BFI_CONTRACT_SRC_BT); + // 上游外部系统单据号/上游合同号(非必传) + // if (Func.isNotEmpty(contract.getContractNo())) { + // param.setAwrpSrcConNo(contract.getContractNo()); + // } + // 【必填】外部单据类型 + param.setAwrpSrcType(KingdeeBfiConstant.BFI_AP_BILL_SRC_TYPE); + + // 明细分录:结算明细按 货物→费用大类→税率 拆分行 + 手动添加的结算合计费用项每行一条 + List detailentry = new ArrayList<>(); + for (BfiEntryGroup entryGroup : entryGroups) { + BfiApBillSaveEntryDTO entry = new BfiApBillSaveEntryDTO(); + // 【必填】物料编码.编码:按装卸大类/项目业务模式/运单运输方式映射(thirdparty.bfi.material.* 配置) + entry.setMaterialNumber(entryGroup.materialNumber()); + // 运输货物(金蝶接口新字段):结算明细货物名称,结算合计费用项行不传 + entry.setAwrpTransport(entryGroup.transportGoods()); + // 车号(金蝶接口新字段):结算明细车号,结算合计费用项行不传 + entry.setAwrpLpn(entryGroup.vehicleNo()); + // 数量:运输类取运输量,装卸类/其它默认 1 + entry.setQuantity(entryGroup.quantity().compareTo(BigDecimal.ZERO) > 0 ? entryGroup.quantity() : BigDecimal.ONE); + // 明细.单价:结算金额/数量反算(单据头 ispricetotal=true 录入总价,单价仅作参考) + entry.setPrice(entryGroup.price()); + // 应付金额(含税):结算明细行取结算金额(含税),结算合计费用项行取合计行结算金额 + entry.setEPricetaxtotal(entryGroup.amount()); + // 金额(不含税):结算明细行结算金额(不含税),结算合计费用项行无不含税金额不传 + entry.setEAmount(entryGroup.amountNoTax()); + // 【必填】项目.项目编码(联调期临时传金蝶测试档案值) + entry.setProjectNumber(KingdeeBfiConstant.BFI_AP_BILL_PROJECT_NUMBER_TEST); + // 【必填】外部系统来源分录标识(候选键):结算明细行取单据号,结算合计费用项行取结算单号-费用类型-费用项拼接 + entry.setAwrpEntryCode(entryGroup.entryCode()); + // 分录备注(非必传) + // entry.setERemark(subject); + + detailentry.add(entry); + } + param.setDetailentry(detailentry); + + // 付款计划 planentity 暂不传(联调待确认后启用):启用后送正式结算单下绑定、单据状态为审核通过(approved) + // 的付款信息,每条付款信息一行,awrp_entry_code_p 取付款单号(A103-1 下推 awrp_finapbill_entryid 勾稽此值); + // 当前不传由金蝶按明细自动汇总生成,A103-1 推送前经 A102-4 反查金蝶生成的 awrp_entry_code_p + // TODO 待业务确认:存在预付款抵扣时是否需传预付信息 preentry(y_billtype/y_billid/y_settleamt),本期不传、后续迭代 + + // 推送入参摘要(联调排查:防重键、分录条数、页面维护的汇率口径) + log.info("推送金蝶财务应付单:正式结算单[{}],防重键[{}],明细分录{}条,汇率日期[{}],汇率[{}]", + subject, apBillSrcCode, detailentry.size(), param.getExratedate(), param.getExchangerate()); + + BfiBillSaveResultVO saveResult = bfiService.saveApBill(param); + // 应付单id不落库,后续如需可经 A102-4 按防重键(awrp_src_sys + awrp_src_code)查询 + requireBfiSaveSuccess(saveResult, "推送金蝶财务应付单"); + String billNo = saveResult.getResult().get(0).getNumber(); + log.info("金蝶财务应付单保存成功:正式结算单[{}],单据编号[{}],明细分录{}条", subject, billNo, detailentry.size()); + // 星瀚应付单内码随结果返回(A902 附件上传定位用) + BfiBillSaveResultItemVO savedItem = saveResult.getResult().get(0); + return new BfiApBillPushOutcome(savedItem.getNumber(), savedItem.getId() == null ? null : String.valueOf(savedItem.getId()), false); + } + + // ==================== 结算明细分录解析(结算明细行 + 结算合计费用项) ==================== + + /** + * 解析正式结算明细为应付单分录(不聚合):按所属正式结算单分组,逐结算单取页面结算明细 + * (每行一条分录)与结算合计费用项(每行一条分录);分录携带所属正式结算单id + * (多结算单付款申请 A103-1 下推时按结算单分组勾稽各自应付单付款计划分录)。 + * 应收单(A302-2)、收票单(A303-2)分录与本解析同源同口径 + * + * @param settlementDetails 正式结算明细列表(可能跨多张结算单) + * @param context 金蝶同步上下文(缓存载体) + * @return 应付单分录列表(保持行序) + */ + public List resolveEntryGroups(List settlementDetails, KingdeeSyncContext context) { + if (Func.isEmpty(settlementDetails)) { + throw new ServiceException("推送金蝶失败:正式结算单无结算明细,无法解析分录"); + } + Map> detailsBySettlementId = new LinkedHashMap<>(); + for (FormalSettlementDetail settlementDetail : settlementDetails) { + if (settlementDetail.getFormalSettlementId() == null) { + throw new ServiceException("推送金蝶失败:结算明细[" + Func.toStr(settlementDetail.getDocumentNo()) + "]未关联正式结算单"); + } + detailsBySettlementId.computeIfAbsent(settlementDetail.getFormalSettlementId(), key -> new ArrayList<>()).add(settlementDetail); + } + List entryGroups = new ArrayList<>(); + detailsBySettlementId.forEach((settlementId, details) -> + entryGroups.addAll(this.resolveEntryGroups(requireSettlement(settlementId, context), details, context))); + return entryGroups; + } + + /** + * 解析单张正式结算单分录:页面结算明细每行(一个运单)按 货物 → 费用大类 → 税率 拆分多行 + * + 手动添加的结算合计费用项每行一条(系统自动汇总行是明细金额的再汇总不送)。 + *

取数口径: + * 结算明细拆行——一车多货(存在货物费用行)先按货物拆;费用项名称含“装卸”归装卸大类、其余归运输大类, + * 各大类内按合同计费方案规则税率分组、不同税率分行,运输类每行数量取运单运输量、装卸类每行数量默认 1, + * 费用项金额合计与结算金额的残差并入运输类无税率组保证勾稽; + * 物料编码按装卸大类/项目业务模式/运单运输方式映射(thirdparty.bfi.material.* 配置); + * 单价按结算金额/数量反算; + * 结算金额:结算明细行取结算金额(含税)、结算合计费用项行取合计行结算金额; + * 运输货物/车号仅结算明细拆分行传(货物名称/车号);分录标识:结算明细拆分行取单据号-货物序号-大类-税率、 + * 结算合计费用项行取结算单号-费用类型-费用项拼接。

+ * + * @param settlement 正式结算单 + * @param settlementDetails 该结算单下的结算明细 + * @param context 金蝶同步上下文(缓存载体) + * @return 应付单分录列表(保持行序) + */ + public List resolveEntryGroups(FormalSettlement settlement, List settlementDetails, + KingdeeSyncContext context) { + if (Func.isEmpty(settlementDetails)) { + throw new ServiceException("推送金蝶失败:正式结算单[" + Func.toStr(settlement.getFormalSettlementNo()) + "]无结算明细,无法解析分录"); + } + ProjectApply project = requireProject(settlement.getProjectId(), context); + ContractManage contract = requireContract(settlement.getContractId(), context); + // 合同计费方案规则:费用类型/费用项 -> 税率(拆行分组用) + Map taxRateByRule = taxRateByRule(contract); + // 费用项档案:费用项名称 -> 费用类型名称(费用大类判定,字典 fee_category 翻译) + Map feeCategoryByName = feeCategoryByName(context); + EntryResolveContext resolveContext = new EntryResolveContext(settlement, project, taxRateByRule, feeCategoryByName); + // 结算明细费用行:一车多货按货物拆分的费用快照行 + Map> feesByDetailId = detailFeesByDetailId(settlementDetails); + List entryGroups = new ArrayList<>(); + for (FormalSettlementDetail settlementDetail : settlementDetails) { + appendDetailEntries(entryGroups, resolveContext, settlementDetail, + feesByDetailId.getOrDefault(settlementDetail.getId(), List.of())); + } + // 结算合计只取手工添加的费用项(manualFlag=1);系统自动汇总行是结算明细金额的再汇总,再送会与明细重复 + List manualSummaryFees = listSummaryFees(settlement.getId()).stream() + .filter(summaryFee -> Integer.valueOf(1).equals(summaryFee.getManualFlag())).toList(); + for (FormalSettlementSummaryFee summaryFee : manualSummaryFees) { + appendSummaryEntry(entryGroups, resolveContext, summaryFee, + settlementDetails.get(0).getTransportType()); + } + if (entryGroups.isEmpty()) { + throw new ServiceException("推送金蝶失败:正式结算单[" + Func.toStr(settlement.getFormalSettlementNo()) + + "]无结算明细与结算合计费用项,无法解析分录"); + } + // 分录解析摘要(联调排查:分录数=结算明细拆分行+手动添加的结算合计费用项,物料/分录标识逐条见下方分录生成日志) + log.info("金蝶应付单分录解析完成:正式结算单[{}],结算明细{}条 + 手工结算合计{}条 = 共{}条分录", + settlement.getFormalSettlementNo(), settlementDetails.size(), manualSummaryFees.size(), entryGroups.size()); + return entryGroups; + } + + /** 单张结算单分录解析上下文:结算单/项目档案与合同计费方案税率、费用项档案分类映射 */ + private record EntryResolveContext(FormalSettlement settlement, ProjectApply project, + Map taxRateByRule, Map feeCategoryByName) { + } + + /** + * 单条结算明细行(一个运单)按 货物 → 费用大类 → 税率 拆分多条分录: + * 一车多货(存在货物费用行)先按货物拆分,每个货物费用行再按费用大类/税率拆行; + * 无货物费用行时结算明细行本身即唯一货物 + */ + private void appendDetailEntries(List entryGroups, EntryResolveContext context, + FormalSettlementDetail detail, List detailFees) { + if (Func.isEmpty(detailFees)) { + appendCargoLines(entryGroups, context, detail, detail.getCargoName(), detail.getFeeItemsJson(), + detail.getTransportQuantity(), money(detail.getSettlementAmountTax()), detail.getSettlementAmountNoTax(), 1); + return; + } + int cargoSeq = 0; + for (FormalSettlementDetailFee cargoFee : detailFees) { + appendCargoLines(entryGroups, context, detail, cargoFee.getCargoName(), cargoFee.getFeeItemsJson(), + cargoFee.getTransportQuantity(), money(cargoFee.getSettlementAmountTax()), cargoFee.getSettlementAmountNoTax(), ++cargoSeq); + } + } + + /** + * 单个货物费用行按费用大类/税率拆分生成分录: + * 费用项名称含“装卸”归装卸大类、其余归运输大类,各大类内按合同计费方案税率分组、不同税率分行; + * 运输类每行数量取运单运输量、装卸类每行数量默认 1; + * 运输类首行携带行不含税金额(拆分行无分类不含税金额,仅保证不含税合计勾稽); + * 运输货物取货物名称、车号取结算明细车号(同一车牌号就是一个车) + */ + private void appendCargoLines(List entryGroups, EntryResolveContext context, + FormalSettlementDetail detail, String cargoName, String feeItemsJson, + BigDecimal transportQuantity, BigDecimal rowAmount, BigDecimal rowAmountNoTax, int cargoSeq) { + String documentNo = entryDocumentNo(detail); + String sourceDesc = "结算明细[" + documentNo + "-" + (Func.isBlank(cargoName) ? String.valueOf(cargoSeq) : cargoName) + "]"; + List splits = splitFeeItems(parseFeeItems(feeItemsJson), context.feeCategoryByName(), + context.taxRateByRule(), rowAmount); + if (splits.isEmpty()) { + // 0元货物行(费用项与结算金额均为0)跳过不生成0元分录 + log.info("金蝶应付单分录生成:来源[{}],费用项与结算金额均为0,跳过该货物行", sourceDesc); + return; + } + // 运输类数量:取运单运输量(未维护兜底 1) + BigDecimal freightQuantity = transportQuantity != null && transportQuantity.compareTo(BigDecimal.ZERO) > 0 + ? money(transportQuantity) : BigDecimal.ONE; + String vehicleNo = detail.getVehicleNo(); + // 分录唯一标识前缀(awrp_entry_code):单据号-货物序号,后续拼接费用大类与税率 + String entryCodePrefix = documentNo + "-" + cargoSeq; + boolean amountNoTaxSent = false; + for (FeeLineSplit split : splits) { + String category = split.isLoading() ? "装卸" : "运输费"; + // 物料:装卸大类映射装卸物料;运输类按项目业务模式/运单运输方式映射 + String materialNumber = resolveMaterialNumber(split.isLoading(), context.project(), detail.getTransportType(), sourceDesc); + // 数量:运输类取运输量,装卸类默认 1 + BigDecimal quantity = split.isLoading() ? BigDecimal.ONE : freightQuantity; + BigDecimal amount = money(split.getAmount()); + BigDecimal price = entryPrice(amount, quantity); + // 不含税金额:仅运输类首行携带,保证不含税合计与结算明细勾稽 + BigDecimal amountNoTax = null; + if (!split.isLoading() && !amountNoTaxSent && rowAmountNoTax != null) { + amountNoTax = money(rowAmountNoTax); + amountNoTaxSent = true; + } + // 分录唯一标识:单据号-货物序号-费用大类-税率(税率未配置不拼) + String entryCode = entryCodePrefix + "-" + category + split.taxRateSuffix(); + entryGroups.add(new BfiEntryGroup(materialNumber, entryCode, quantity, price, amount, + amountNoTax, cargoName, vehicleNo, context.settlement().getId())); + log.info("金蝶应付单分录生成:来源[{}],分录标识[{}],物料编码[{}],数量[{}],单价[{}],含税金额[{}],不含税金额[{}]", + sourceDesc, entryCode, materialNumber, quantity, price, amount, amountNoTax); + } + } + + /** 结算明细分录标识单据号:单据号为空时兜底明细行id,避免候选键为空被金蝶拦截 */ + private String entryDocumentNo(FormalSettlementDetail detail) { + return Func.isBlank(detail.getDocumentNo()) ? String.valueOf(detail.getId()) : detail.getDocumentNo().trim(); + } + + /** + * 单条手动添加的结算合计费用项生成一条分录:非运费类(装卸/仓储等杂费)映射装卸物料, + * 运费类按项目业务模式/运输方式映射(运输方式取结算明细首行); + * 非运费类数量默认 1,金额取合计行结算金额,运输货物/车号不传,分录标识取结算单号-费用类型-费用项拼接 + */ + private void appendSummaryEntry(List entryGroups, EntryResolveContext context, + FormalSettlementSummaryFee summaryFee, String fallbackTransportType) { + FormalSettlement settlement = context.settlement(); + String feeType = Func.toStr(summaryFee.getFeeType()).trim(); + String feeItem = Func.toStr(summaryFee.getFeeItem()).trim(); + String sourceDesc = "结算合计[" + feeType + "-" + feeItem + "]"; + // 非运费类(费用项档案分类/费用类型/费用项名称均不含“运费/运输费”)统一映射装卸物料 + boolean loadingFee = !isFreightCategory(feeType, feeItem, context.feeCategoryByName()); + String materialNumber = resolveMaterialNumber(loadingFee, context.project(), fallbackTransportType, sourceDesc); + // 结算合计费用项行无运输量,数量默认 1 + BigDecimal quantity = BigDecimal.ONE; + BigDecimal amount = money(summaryFee.getSettlementAmount()); + BigDecimal price = entryPrice(amount, quantity); + // 分录唯一标识(awrp_entry_code):结算单号-费用类型-费用项(费用项为空时仅拼结算单号-费用类型) + String entryCode = settlement.getFormalSettlementNo() + "-" + + (Func.isBlank(feeItem) ? feeType : feeType + "-" + feeItem); + entryGroups.add(new BfiEntryGroup(materialNumber, entryCode, quantity, price, amount, + null, null, null, settlement.getId())); + log.info("金蝶应付单分录生成:来源[{}],分录标识[{}],物料编码[{}],数量[{}],单价[{}],含税金额[{}]", + sourceDesc, entryCode, materialNumber, quantity, price, amount); + } + + /** + * 物料编码映射(Nacos 配置 thirdparty.bfi.material.*): + * 装卸大类(及手动添加的非运费杂费)映射装卸物料 loading;业务模式/项目业务类型含“国际”(国际运输、 + * 国际货物运输代理服务)映射国际货代服务 international;国内运输按运单运输方式查 domestic 映射 + * (公路运输/铁路运输/水路运输/航空运输),运输方式未配置映射时直接拦截报错 + * + * @param loadingFee 该行是否属于装卸大类(或非运费类杂费) + * @param project 结算单关联项目(业务类型/业务模式取数来源) + * @param transportType 运单运输方式(结算合计费用项行取结算明细首行运输方式) + * @param sourceDesc 来源行描述,用于异常提示 + * @return 金蝶物料编码 + */ + private String resolveMaterialNumber(boolean loadingFee, ProjectApply project, String transportType, String sourceDesc) { + BfiProperties.Material material = bfiProperties.getMaterial(); + String materialNumber; + if (loadingFee) { + materialNumber = material.getLoading(); + } else if (isInternationalBusiness(project)) { + materialNumber = material.getInternational(); + } else { + if (Func.isBlank(transportType)) { + throw new ServiceException("推送金蝶失败:" + sourceDesc + "运单运输方式为空,无法匹配国内运输物料,请先维护运输方式"); + } + materialNumber = material.getDomestic().get(transportType.trim()); + if (Func.isBlank(materialNumber)) { + throw new ServiceException("推送金蝶失败:" + sourceDesc + "运输方式[" + transportType.trim() + + "]未在配置 thirdparty.bfi.material.domestic 中维护物料编码,请补充后重试"); + } + } + if (Func.isBlank(materialNumber)) { + throw new ServiceException("推送金蝶失败:" + sourceDesc + "未在配置 thirdparty.bfi.material 中维护对应物料编码,请补充后重试"); + } + // 物料映射判定依据(联调排查:装卸大类/业务模式/项目业务类型/运输方式 -> 物料) + log.info("金蝶物料映射:来源[{}],装卸大类[{}],业务模式[{}],项目业务类型[{}],运单运输方式[{}] -> 物料编码[{}]", + sourceDesc, loadingFee ? "是" : "否", + project == null ? null : project.getBusinessMode(), project == null ? null : project.getBusinessType(), + transportType, materialNumber); + return materialNumber; + } + + /** 项目业务模式/业务类型含“国际”即视为国际运输(映射国际货代服务物料);项目缺失时拦截 */ + private boolean isInternationalBusiness(ProjectApply project) { + if (project == null) { + throw new ServiceException("推送金蝶失败:结算单未关联项目,无法按项目业务类型/业务模式匹配物料"); + } + return containsInternational(project.getBusinessMode()) || containsInternational(project.getBusinessType()); + } + + private boolean containsInternational(String text) { + return text != null && text.contains("国际"); + } + + /** + * 合同计费方案规则税率解析:费用类型/费用项 -> 税率(取首个配置了税率的规则, + * 计费方案 JSON 解析失败按未配置处理,由税率分组处兜底为无税率组) + * + * @param contract 结算合同 + * @return 费用类型/费用项 -> 税率(百分数)映射 + */ + private Map taxRateByRule(ContractManage contract) { + Map taxRateByRule = new HashMap<>(); + if (Func.isBlank(contract.getBillingPlanJson())) { + return taxRateByRule; + } + try { + Object parsed = JsonUtil.parse(contract.getBillingPlanJson(), List.class); + if (parsed instanceof List plans) { + for (Object planValue : plans) { + if (!(planValue instanceof Map plan) || !(plan.get("rules") instanceof List rules)) { + continue; + } + for (Object ruleValue : rules) { + if (!(ruleValue instanceof Map rule)) { + continue; + } + BigDecimal taxRate = taxRateValue(rule.get("taxRate")); + if (taxRate == null) { + continue; + } + String feeItem = textValue(rule.get("feeItem")); + String feeType = textValue(rule.get("feeType")); + if (Func.isNotBlank(feeItem)) { + taxRateByRule.putIfAbsent(feeItem, taxRate); + } + if (Func.isNotBlank(feeType)) { + taxRateByRule.putIfAbsent(feeType, taxRate); + } + } + } + } + } catch (RuntimeException ignored) { + // 计费方案 JSON 兼容历史数据,解析失败按未配置处理 + } + log.info("合同[{}]计费方案解析:{}项费用类型/费用项配置税率{}", contract.getContractNo(), taxRateByRule.size(), taxRateByRule); + return taxRateByRule; + } + + /** 合同计费方案规则税率:百分数文本转数值(如 9 表示 9%),格式非法按未配置处理 */ + private BigDecimal taxRateValue(Object value) { + if (value == null) { + return null; + } + try { + return new BigDecimal(String.valueOf(value).trim()); + } catch (NumberFormatException exception) { + return null; + } + } + + /** + * 费用项按费用大类/税率拆分(纯函数): + * 费用项经费用项档案/业务字典判为装卸大类(名称含“装卸”的费用类型)、其余归运输大类; + * 各大类内按计费方案规则税率分组(未配置税率的费用项归入无税率组), + * 费用项金额合计与结算金额的残差并入运输类无税率组,保证拆分后各行金额合计与结算金额勾稽 + * + * @param feeItems 费用项名称 -> 金额(费用项JSON解析结果,保持行序) + * @param feeCategoryByName 费用项档案:费用项名称 -> 费用类型名称(空 Map 时回退名称关键字判定) + * @param taxRateByRule 合同计费方案:费用类型/费用项 -> 税率(百分数) + * @param rowAmount 货物费用行结算金额(含税),残差勾稽基准 + * @return 拆分行列表(保持费用项出现顺序) + */ + public static List splitFeeItems(Map feeItems, Map feeCategoryByName, + Map taxRateByRule, BigDecimal rowAmount) { + Map grouped = new LinkedHashMap<>(); + for (Map.Entry item : feeItems.entrySet()) { + if (Func.isBlank(item.getKey())) { + continue; + } + BigDecimal amount = item.getValue() == null ? BigDecimal.ZERO : item.getValue(); + // 0元费用项跳过,不生成0元分录(金额缺失由运输类无税率组残差兜底) + if (amount.signum() == 0) { + continue; + } + boolean loading = isLoadingCategory(item.getKey(), feeCategoryByName); + BigDecimal taxRate = taxRateByRule == null ? null : taxRateByRule.get(item.getKey()); + FeeLineSplit split = grouped.get(splitGroupKey(loading, taxRate)); + if (split == null) { + grouped.put(splitGroupKey(loading, taxRate), new FeeLineSplit(loading, taxRate, amount, item.getKey())); + } else { + split.addAmount(amount); + } + } + // 残差(结算金额 - 费用项合计,如费用项JSON未含运输费的运费金额)并入运输类无税率组,保证合计勾稽 + BigDecimal residual = (rowAmount == null ? BigDecimal.ZERO : rowAmount).subtract(grouped.values().stream() + .map(FeeLineSplit::getAmount).reduce(BigDecimal.ZERO, BigDecimal::add)); + if (residual.signum() != 0) { + FeeLineSplit freightDefault = grouped.computeIfAbsent(splitGroupKey(false, null), + key -> new FeeLineSplit(false, null, BigDecimal.ZERO, FREIGHT_FEE_ITEM)); + freightDefault.addAmount(residual); + } + return new ArrayList<>(grouped.values()); + } + + /** 费用大类/税率分组键(装卸大类与运输大类、税率去重分组) */ + private static String splitGroupKey(boolean loading, BigDecimal taxRate) { + return (loading ? "装卸" : "运输费") + "@" + (taxRate == null ? "" : taxRate.stripTrailingZeros().toPlainString()); + } + + /** 装卸大类费用项判定(关键字兜底):费用项名称含“装卸” */ + static boolean isLoadingFeeItem(String feeItem) { + return feeItem != null && feeItem.contains("装卸"); + } + + /** + * 装卸大类判定:费用项在费用项档案(blade_fee_item)中的费用类型名称(业务字典 fee_category 翻译) + * 含“装卸”归装卸大类;档案未维护该费用项时回退费用项名称关键字判定 + */ + static boolean isLoadingCategory(String feeItemName, Map feeCategoryByName) { + String categoryName = feeItemName == null || feeCategoryByName == null ? null : feeCategoryByName.get(feeItemName); + if (Func.isNotBlank(categoryName)) { + return categoryName.contains("装卸"); + } + return isLoadingFeeItem(feeItemName); + } + + /** 运费类费用判定(关键字兜底):名称含“运费”或“运输费” */ + static boolean isFreightFeeText(String name) { + return name != null && (name.contains("运费") || name.contains("运输费")); + } + + /** + * 运费类判定(手动添加费用行物料映射用):费用项档案分类名称、费用类型、费用项名称任一 + * 含“运费/运输费”即归运费类映射运输物料;档案未维护时退化为名称关键字判定(与原口径一致) + */ + 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); + } + + /** + * 费用项JSON解析:费用项名称 -> 金额(保持行序,解析失败/非法金额按空处理, + * 金额缺失由运输类无税率组残差兜底) + */ + private Map parseFeeItems(String feeItemsJson) { + Map feeItems = new LinkedHashMap<>(); + if (Func.isEmpty(feeItemsJson)) { + return feeItems; + } + try { + Object parsed = JsonUtil.parse(feeItemsJson, Map.class); + if (parsed instanceof Map map) { + for (Map.Entry item : map.entrySet()) { + if (item.getKey() == null || item.getValue() == null) { + continue; + } + try { + feeItems.put(String.valueOf(item.getKey()), new BigDecimal(String.valueOf(item.getValue()).trim())); + } catch (NumberFormatException ignored) { + // 兼容历史费用JSON的非法金额 + } + } + } + } catch (RuntimeException ignored) { + // 兼容历史费用JSON + } + return feeItems; + } + + /** + * 费用大类/税率拆分行:装卸大类 loading(true=装卸)、税率 taxRate(百分数,未配置为 null)、 + * 组内金额合计 amount、组内首个费用项 firstFeeItem + */ + public static final class FeeLineSplit { + private final boolean loading; + private final BigDecimal taxRate; + private final String firstFeeItem; + private BigDecimal amount; + + FeeLineSplit(boolean loading, BigDecimal taxRate, BigDecimal amount, String firstFeeItem) { + this.loading = loading; + this.taxRate = taxRate; + this.amount = amount; + this.firstFeeItem = firstFeeItem; + } + + void addAmount(BigDecimal value) { + amount = amount.add(value); + } + + public boolean isLoading() { + return loading; + } + + public BigDecimal getTaxRate() { + return taxRate; + } + + public BigDecimal getAmount() { + return amount; + } + + public String getFirstFeeItem() { + return firstFeeItem; + } + + /** 分录标识税率后缀:税率未配置返回空串,配置返回“-税率”(如 -9、-6.5) */ + public String taxRateSuffix() { + return taxRate == null ? "" : "-" + taxRate.stripTrailingZeros().toPlainString(); + } + } + + private String textValue(Object value) { + return value == null ? null : String.valueOf(value).trim(); + } + + /** 结算明细id -> 结算明细费用行(一车多货按货物拆分的费用快照行) */ + private Map> detailFeesByDetailId(List settlementDetails) { + List detailIds = settlementDetails.stream().map(FormalSettlementDetail::getId) + .filter(Objects::nonNull).distinct().toList(); + if (Func.isEmpty(detailIds)) { + return Map.of(); + } + return formalSettlementDetailFeeMapper.selectList(Wrappers.lambdaQuery() + .in(FormalSettlementDetailFee::getFormalSettlementDetailId, detailIds) + .eq(FormalSettlementDetailFee::getIsDeleted, 0) + .orderByAsc(FormalSettlementDetailFee::getLineNo)) + .stream().collect(Collectors.groupingBy(FormalSettlementDetailFee::getFormalSettlementDetailId)); + } + + /** 正式结算合计费用项(页面结算合计,保持行序) */ + public List listSummaryFees(Long settlementId) { + return formalSettlementSummaryFeeMapper.selectList(Wrappers.lambdaQuery() + .eq(FormalSettlementSummaryFee::getFormalSettlementId, settlementId) + .eq(FormalSettlementSummaryFee::getIsDeleted, 0) + .orderByAsc(FormalSettlementSummaryFee::getLineNo)); + } + + /** + * 费用项档案分类:费用项名称 -> 费用类型名称(blade_fee_item.fee_category 编码经业务字典 + * {@link #FEE_CATEGORY_DICT} 翻译;批量同步时全批共享一次加载结果),费用大类判定用; + * 档案读取失败直接拦截 + */ + public Map feeCategoryByName(KingdeeSyncContext context) { + Map cache = context.feeCategoryByName(); + 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(); + if (response == null || !response.isSuccess() || Func.isEmpty(response.getData())) { + throw new ServiceException("推送金蝶失败:费用项基础档案读取失败,请稍后重试"); + } + Map loaded = new HashMap<>(); + for (FeeItem feeItem : response.getData()) { + 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())); + } + log.info("费用项档案加载:{}项(费用项名称 -> 费用类型名称)", loaded.size()); + cache.putAll(loaded); + return cache; + } + + // ==================== 金蝶公共校验与档案取数 ==================== + + /** + * 校验金蝶保存/下推返回结果(单据级失败以 errors 明细抛出) + * + * @param saveResult 金蝶返回结果 + * @param action 业务动作描述,用于异常提示 + */ + public void requireBfiSaveSuccess(BfiBillSaveResultVO saveResult, String action) { + if (saveResult == null || Func.isEmpty(saveResult.getResult())) { + throw new ServiceException(action + "失败,金蝶未返回单据结果"); + } + 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); + } + } + + /** + * 校验金蝶保存/下推返回结果并取星瀚单据id(单据级失败以 errors 明细抛出) + * + * @param saveResult 金蝶返回结果 + * @param action 业务动作描述,用于异常提示 + * @return 星瀚单据id + */ + public String requireBfiBillId(BfiBillSaveResultVO saveResult, String action) { + requireBfiSaveSuccess(saveResult, action); + Object billId = saveResult.getResult().get(0).getId(); + if (billId == null) throw new ServiceException(action + "失败,金蝶未返回单据id"); + return String.valueOf(billId); + } + + /** + * 校验并获取单据关联合同(批量同步时按合同id缓存复用,单笔同步退化为直接查询) + * + * @param contractId 合同id + * @param context 金蝶同步上下文(缓存载体) + * @return 合同实体 + */ + public ContractManage requireContract(Long contractId, KingdeeSyncContext context) { + if (contractId == null) throw new ServiceException("推送金蝶失败,单据未关联合同"); + ContractManage contract = context.contractCache().computeIfAbsent(contractId, contractManageMapper::selectById); + if (contract == null || Objects.equals(contract.getIsDeleted(), 1)) throw new ServiceException("推送金蝶失败,关联合同不存在"); + return contract; + } + + /** 校验并获取结算明细所属正式结算单(批量同步时按结算单id缓存复用;应收单同步组件复用) */ + public FormalSettlement requireSettlement(Long settlementId, KingdeeSyncContext context) { + FormalSettlement settlement = context.settlementCache().computeIfAbsent(settlementId, formalSettlementMapper::selectById); + if (settlement == null || Objects.equals(settlement.getIsDeleted(), 1)) { + throw new ServiceException("推送金蝶失败:正式结算单[" + settlementId + "]不存在"); + } + return settlement; + } + + /** 校验并获取结算单关联项目(项目业务类型/业务模式决定物料映射;应收单同步组件复用) */ + public ProjectApply requireProject(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 + "]不存在,无法匹配物料"); + } + return project; + } + + /** + * 金蝶接口日期文本:yyyy-MM-dd HH:mm:ss(A102-2/A103-1/A103-3 各接口统一格式) + * + * @param date 业务日期 + * @return 日期文本 + */ + public String bfiDateTimeText(LocalDate date) { + LocalDate effectiveDate = date == null ? LocalDate.now() : date; + return effectiveDate.atStartOfDay().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); + } + + /** + * 合同所属组织编码 + * + * @param contract 合同实体 + * @return 组织编码 + */ + public String bfiOrgNumber(ContractManage contract) { + Dept dept = contract.getOrganizationId() == null ? null : SysCache.getDept(contract.getOrganizationId()); + // dept为空,直接报错 + if (dept == null) { + throw new ServiceException("推送金蝶失败,合同所属组织不存在,请检查合同所属组织配置"); + } + // kingdeeDeptCode为空,直接报错 + if (Func.isBlank(dept.getKingdeeDeptCode())) { + throw new ServiceException("推送金蝶失败,合同所属组织编码不能为空,请检查组织[" + dept.getDeptName() + "]是否维护金蝶组织编码"); + } + return dept.getKingdeeDeptCode(); + } + + /** + * 合同结算币种转金蝶币别代码(RMB 统一映射 CNY) + * + * @param contract 合同实体 + * @return 币别代码 + */ + public String bfiCurrency(ContractManage contract) { + if (Func.isBlank(contract.getSettlementCurrency())) throw new ServiceException("推送金蝶失败,合同结算币种不能为空"); + String currency = contract.getSettlementCurrency(); + return "RMB".equalsIgnoreCase(currency) ? "CNY" : currency; + } + + /** 财务应付单往来类型:合同类别为“客户合同”时为 bd_customer(客户),否则 bd_supplier(供应商) */ + public String bfiAsstactType(ContractManage contract) { + return KingdeeBfiConstant.BFI_CUSTOMER_ASSTACT_TYPE; + /*return "客户合同".equals(contract.getContractCategory()) + ? KingdeeBfiConstant.BFI_CUSTOMER_ASSTACT_TYPE : KingdeeBfiConstant.BFI_SUPPLIER_ASSTACT_TYPE;*/ + } + + /** 财务应付单往来户.编码:合同乙方关联客商档案取客商编码,未维护时拦截 */ + public String bfiAsstactNumber(ContractManage contract, KingdeeSyncContext context) { + if (Func.isBlank(contract.getPartyB())) { + throw new ServiceException("推送金蝶财务应付单失败,合同乙方不能为空,请检查合同[" + Func.toStr(contract.getContractNo()) + "]乙方信息"); + } + // 批量同步时按乙方名称缓存客商编码(同批同一乙方只查一次),单笔同步退化为直接查询 + String customerCode = context.customerCodeCache().computeIfAbsent(contract.getPartyB(), this::queryCustomerCode); + if (Func.isBlank(customerCode)) { + throw new ServiceException("推送金蝶财务应付单失败,合同乙方[" + contract.getPartyB() + "]未在客商档案中维护客商编码,请补充后重试"); + } + return customerCode; + } + + /** 按乙方名称(全称或简称)查询客商编码,未维护返回 null(应收单同步组件复用:按结算单付款方查询) */ + public String queryCustomerCode(String partyB) { + CustomerArchive customer = customerArchiveMapper.selectOne(Wrappers.lambdaQuery() + .and(wrapper -> wrapper.eq(CustomerArchive::getFullName, partyB).or().eq(CustomerArchive::getShortName, partyB)) + .eq(CustomerArchive::getIsDeleted, 0).last("limit 1")); + return customer == null ? null : customer.getCustomerCode(); + } + + /** + * 列表按批大小切分逐批处理(末批可能不足批大小),远程接口批量入参分批用,避免单次过滤条件过长 + * + * @param items 待处理列表 + * @param batchSize 批大小 + * @param batchConsumer 批处理逻辑 + * @param 元素类型(应收单同步组件复用) + */ + public void batchForeach(List items, int batchSize, Consumer> batchConsumer) { + for (int from = 0; from < items.size(); from += batchSize) { + batchConsumer.accept(items.subList(from, Math.min(from + batchSize, items.size()))); + } + } + + /** 金额空值兜底并保留 2 位小数(应收单同步组件复用) */ + public BigDecimal money(BigDecimal value) { + return (value == null ? BigDecimal.ZERO : value).setScale(2, RoundingMode.HALF_UP); + } + + /** + * 分录单价反算:结算金额/数量(保持单价×数量=金额勾稽一致);数量无效时返回 0 + * (应付/应收/收票单分录共用,应收单同步组件复用) + * + * @param amount 该行结算金额(含税) + * @param quantity 该行分录数量 + * @return 反算单价(保留 2 位小数) + */ + public BigDecimal entryPrice(BigDecimal amount, BigDecimal quantity) { + if (quantity == null || quantity.compareTo(BigDecimal.ZERO) <= 0) { + return BigDecimal.ZERO; + } + return money(amount).divide(quantity, 2, RoundingMode.HALF_UP); + } + + /** + * 财务应付单/付款申请分录:结算明细按 货物→费用大类→税率 拆分行 + 结算合计费用项(手动添加的每行一条) + * + * @param materialNumber 物料编码(按装卸大类/项目业务模式/运单运输方式映射,thirdparty.bfi.material.* 配置) + * @param entryCode 分录唯一标识(awrp_entry_code):结算明细拆分行取单据号-货物序号-大类-税率,结算合计费用项行取结算单号-费用类型-费用项 + * @param quantity 该行分录数量(运输类取运输量,装卸类/其它默认 1) + * @param price 该行分录单价(结算金额/数量反算) + * @param amount 该行金额(含税),对应明细应付金额 e_pricetaxtotal + * @param amountNoTax 该行金额(不含税),对应明细金额 e_amount;结算合计费用项行无不含税金额为 null 不传 + * @param transportGoods 运输货物(金蝶接口新字段 awrp_transport):结算明细货物名称,结算合计费用项行为 null 不传 + * @param vehicleNo 车号(金蝶接口新字段 awrp_lpn):结算明细车号,结算合计费用项行为 null 不传 + * @param formalSettlementId 分录所属正式结算单id(多结算单付款申请 A103-1 按结算单勾稽各自应付单) + */ + public record BfiEntryGroup(String materialNumber, String entryCode, + BigDecimal quantity, BigDecimal price, BigDecimal amount, BigDecimal amountNoTax, + String transportGoods, String vehicleNo, Long formalSettlementId) { + } + + /** + * 财务应付单(A102-2)推送结果 + * + * @param billNo 财务应付单单据编号(金蝶已存在时返回已有单据编号) + * @param billId 财务应付单星瀚内码(A902 附件上传定位用;金蝶已存在幂等跳过时为空,经 A102-4 查询回填) + * @param alreadyExist 金蝶是否已存在该防重键的财务应付单,true 表示本次幂等跳过保存(上次同步已在后续环节中断) + */ + public record BfiApBillPushOutcome(String billNo, String billId, boolean alreadyExist) { + } +} 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 new file mode 100644 index 00000000..34bf1de3 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeArBillSyncSupport.java @@ -0,0 +1,291 @@ +/** + * 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.support; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.core.tool.utils.Func; +import org.springblade.thirdparty.bfi.pojo.dto.BfiArBillQueryDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiArBillSaveDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiArBillSaveEntryDTO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiArBillVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultItemVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultVO; +import org.springblade.thirdparty.bfi.service.IBfiService; +import org.springblade.thirdparty.bfi.util.BfiUtil; +import org.springblade.transport.config.BfiProperties; +import org.springblade.transport.constant.KingdeeBfiConstant; +import org.springblade.transport.pojo.entity.ContractManage; +import org.springblade.transport.pojo.entity.FormalSettlement; +import org.springblade.transport.pojo.entity.FormalSettlementDetail; +import org.springblade.transport.service.KingdeeSyncContext; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 金蝶财务应收单(A302-2)同步支撑组件。 + *

应收(receivable)正式结算单「同步金蝶」推送财务应收单(A302-2),推送前经 A302-4 按防重键幂等预检, + * 与应付链路(A102-2/A102-4,{@link KingdeeApBillSyncSupport})同一套逻辑;防重键组装、A302-4 查询、 + * A302-2 组装保存与应收分录解析收口在本组件,档案取数(合同/组织/币种/客商/货物类型/不含税回链等) + * 复用应付支撑组件的公共方法。

+ * + * @author Chill + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class KingdeeArBillSyncSupport { + + /** A302-4 防重键批量分批查询单次条数上限(与 A102-4 一致) */ + private static final int AR_RESULT_QUERY_BATCH = 50; + /** A302-4 分页查询页大小 */ + private static final int AR_RESULT_PAGE_SIZE = 200; + + private final IBfiService bfiService; + private final KingdeeApBillSyncSupport kingdeeApBillSyncSupport; + private final BfiProperties bfiProperties; + + // ==================== 财务应收单防重键(awrp_src_code) ==================== + + /** + * 财务应收单防重键(awrp_src_code):正式结算单号 + 结算明细单据号下划线拼接(与 A302-2 保存传参一致), + * 规则与财务应付单(A102-2)同源,一张应收结算单只生成一张金蝶财务应收单 + * + * @param settlement 正式结算单 + * @param settlementDetails 该结算单下的结算明细 + * @return 防重键 + */ + public String arBillSrcCode(FormalSettlement settlement, List settlementDetails) { + return ExternalSrcCodeSupport.buildSrcCode(settlement.getFormalSettlementNo(), + kingdeeApBillSyncSupport.settlementDocumentNos(settlementDetails)); + } + + // ==================== A302-4 财务应收单查询 ==================== + + /** + * A302-4 批量查询已生成财务应收单:来源单据号批量 + 外部系统编号(防重键),自动翻页, + * 用于推送前幂等预检与定时任务应收勾稽纠偏(awrp_src_code 支持多值,整批一次查询消除逐单远程查询 N+1) + * + * @param awrpSrcCodes 来源单据号(正式结算单号_结算明细单据号拼接)批次 + * @return 已生成财务应收单的 来源单据号->单据编号 映射 + */ + public Map queryArBillExistNos(List awrpSrcCodes) { + Map existBillNos = new LinkedHashMap<>(); + kingdeeApBillSyncSupport.batchForeach(awrpSrcCodes, AR_RESULT_QUERY_BATCH, batch -> { + BfiArBillQueryDTO param = new BfiArBillQueryDTO(); + param.setAwrpSrcCode(batch); + param.setAwrpSrcSys(bfiProperties.getSrcSys()); + for (BfiArBillVO arBill : BfiUtil.queryAllPages(pageNo -> { + param.setPageNo(pageNo); + param.setPageSize(AR_RESULT_PAGE_SIZE); + return bfiService.queryArBill(param); + })) { + if (Func.isNotEmpty(arBill.getAwrpSrcCode())) { + existBillNos.put(arBill.getAwrpSrcCode(), arBill.getBillno()); + } + } + }); + return existBillNos; + } + + /** + * A302-4 单防重键查询已生成财务应收单的星瀚内码(A902 附件上传定位用): + * 附件补传时本地 kingdee_bill_id 缺失(存量数据/幂等跳过路径未带回 id)的场景回填 + * + * @param arBillSrcCode 财务应收单防重键 + * @return 星瀚应收单内码,未生成返回 null + */ + public String queryArBillExistId(String arBillSrcCode) { + if (Func.isBlank(arBillSrcCode)) { + return null; + } + 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()) && arBill.getId() != null) { + return String.valueOf(arBill.getId()); + } + } + return null; + } + + // ==================== A302-2 财务应收单保存 ==================== + + /** + * 推送金蝶财务应收单(A302-2 财务应收单保存,由应收正式结算单「同步金蝶」触发)。 + * 推送前先经 A302-4 按防重键(awrp_src_sys + awrp_src_code)查询:金蝶已存在财务应收单时 + * 幂等跳过保存并返回已有单据编号(说明上次同步已保存成功、在后续环节中断,应续推而非拦截报错)。 + * 明细分录与应付单(A102-2)同源同口径:页面结算明细按 货物→费用大类→税率 拆分行 + 手动添加的结算合计费用项(每行一条); + * 收款计划 planentity 暂不传,由金蝶按明细自动汇总生成 + * + * @param arBillSrcCode 财务应收单防重键(正式结算单号_结算明细单据号拼接) + * @param settlement 正式结算单(合同/付款方取数来源) + * @param entryGroups 结算明细+结算合计费用项解析出的应收单分录 + * @param subject 业务单据号(正式结算单号,日志与异常提示用) + * @param context 金蝶同步上下文(缓存载体,预检命中与应付共用防重键缓存) + * @return 财务应收单推送结果(单据编号 + 是否金蝶已存在幂等跳过) + */ + public KingdeeApBillSyncSupport.BfiApBillPushOutcome pushArBill(String arBillSrcCode, FormalSettlement settlement, + List entryGroups, + String subject, KingdeeSyncContext context) { + // A302-4 幂等预检:按防重键查询金蝶是否已生成财务应收单,已存在则幂等跳过保存,直接返回已有单据编号 + String existBillNo = context.apBillNoCache().computeIfAbsent(arBillSrcCode, code -> + queryArBillExistNos(List.of(code)).getOrDefault(code, "")); + if (Func.isNotEmpty(existBillNo)) { + log.info("正式结算单[{}]金蝶财务应收单已生成(单据编号[{}]),幂等跳过 A302-2 保存", subject, existBillNo); + return new KingdeeApBillSyncSupport.BfiApBillPushOutcome(existBillNo, null, true); + } + ContractManage contract = kingdeeApBillSyncSupport.requireContract(settlement.getContractId(), context); + // 单据类型按映射字典转换:默认服务销售应收,金蝶确认其他类型后修改 BFI_AR_BILL_BILLTYPE_NAME 即可 + String billtypeNumber = KingdeeBfiConstant.billtypeBy(KingdeeBfiConstant.AR_BILL_BILLTYPE_MAP, KingdeeBfiConstant.BFI_AR_BILL_BILLTYPE_NAME); + if (Func.isBlank(billtypeNumber)) { + throw new ServiceException("推送金蝶财务应收单失败:单据类型[" + KingdeeBfiConstant.BFI_AR_BILL_BILLTYPE_NAME + "]未在映射字典中维护"); + } + String orgNumber = kingdeeApBillSyncSupport.bfiOrgNumber(contract); + // 币种取合同结算币种 + String currency = kingdeeApBillSyncSupport.bfiCurrency(contract); + // 单据日期:yyyy-MM-dd HH:mm:ss(各接口统一格式),以同步当日作为应收单据日期 + String billDateText = kingdeeApBillSyncSupport.bfiDateTimeText(LocalDate.now()); + // 往来户.编码:应收结算单付款方(客户)关联客商档案客商编码 + String payerName = settlement.getPayerName(); + if (Func.isBlank(payerName)) { + throw new ServiceException("推送金蝶财务应收单失败,结算单付款方不能为空,请检查正式结算单[" + Func.toStr(subject) + "]付款方信息"); + } + String customerCode = context.customerCodeCache().computeIfAbsent(payerName.trim(), kingdeeApBillSyncSupport::queryCustomerCode); + if (Func.isBlank(customerCode)) { + throw new ServiceException("推送金蝶财务应收单失败,结算单付款方[" + payerName + "]未在客商档案中维护客商编码,请补充后重试"); + } + BfiArBillSaveDTO param = new BfiArBillSaveDTO(); + // 【必填】单据类型.编码 + param.setBilltypeNumber(billtypeNumber); + // 【必填】结算组织.编码 + param.setOrgNumber(orgNumber); + // 收款组织.编码:同结算组织 + param.setRecorgNumber(orgNumber); + // 【必填】单据日期 + param.setBizdate(billDateText); + // 【必填】往来类型:应收固定客户 + param.setAsstacttype(KingdeeBfiConstant.BFI_CUSTOMER_ASSTACT_TYPE); + // 【必填】往来户.编码:结算单付款方(客户) + param.setAsstactNumber(customerCode); + // 【必填】付款方式(CASH:现销, CREDIT:赊销),现销由金蝶自动推收款 + param.setPaymode(KingdeeBfiConstant.BFI_AR_BILL_PAY_MODE_CASH); + // 【必填】结算币别.货币代码 + param.setCurrencyNumber(currency); + // 【必填】款项性质.编码 + param.setPaypropertyNumber(KingdeeBfiConstant.BFI_AR_BILL_PAY_PROPERTY); + // TODO 待确认:结算方式编码(settlementtype_number)与税率编码(taxrateid_number)、到期日(duedate)需金蝶侧确认后补充,当前不传 + // 录入含税价(非必传) + param.setIsincludetax(true); + // 录入总价,为 true 后可不录入单价(非必传) + param.setIspricetotal(true); + // 【必填】外部系统编号(候选键) + param.setAwrpSrcSys(bfiProperties.getSrcSys()); + // 【必填】外部系统来源单据号(候选键):正式结算单号_结算明细单据号1_单据号2...(复用预检已组装的防重键) + param.setAwrpSrcCode(arBillSrcCode); + // 来源单据(非必传,有上游合同时传 awrp_contract_info),当前与应付单保持一致不传 + // 【必填】外部单据类型 + param.setAwrpSrcType(KingdeeBfiConstant.BFI_AR_BILL_SRC_TYPE); + + // 汇率日期:正式结算单页面维护的汇率日期(非必传,未维护不送) + if (settlement.getExchangeRateDate() != null) { + param.setExratedate(kingdeeApBillSyncSupport.bfiDateTimeText(settlement.getExchangeRateDate())); + } + // 汇率:正式结算单页面维护的结算汇率(非必传,未维护不送) + if (settlement.getExchangeRate() != null) { + param.setExchangerate(settlement.getExchangeRate()); + } + + // 明细分录:与应付单同源同口径——结算明细按 货物→费用大类→税率 拆分行 + 手动添加的结算合计费用项每行一条,无吨位时数量兜底 1 + List entry = new ArrayList<>(); + for (KingdeeApBillSyncSupport.BfiEntryGroup entryGroup : entryGroups) { + BfiArBillSaveEntryDTO row = new BfiArBillSaveEntryDTO(); + + // 【必填】物料编码.编码:按装卸大类/项目业务模式/运单运输方式映射(thirdparty.bfi.material.* 配置) + row.setEMaterialNumber(entryGroup.materialNumber()); + // 数量:运输类取运输量,装卸类/其它默认 1 + row.setEQuantity(entryGroup.quantity().compareTo(BigDecimal.ZERO) > 0 ? entryGroup.quantity() : BigDecimal.ONE); + // 运输货物(金蝶接口新字段):结算明细货物名称,结算合计费用项行不传 + row.setAwrpTransport(entryGroup.transportGoods()); + // 车号(金蝶接口新字段):结算明细车号,结算合计费用项行不传 + row.setAwrpLpn(entryGroup.vehicleNo()); + + // 明细.单价:结算金额/数量反算(单据头 ispricetotal=true 录入总价,单价仅作参考) + row.setEUnitprice(entryGroup.price()); + // 应收金额(含税):结算明细行结算金额(含税)/结算合计费用项行结算金额 + row.setERecamount(entryGroup.amount()); + // 金额(不含税):结算明细行结算金额(不含税),结算合计费用项行无不含税金额不传 + row.setEAmount(entryGroup.amountNoTax()); + // 【必填】项目.项目编码(联调期临时传金蝶测试档案值) + row.setProjectNumber(KingdeeBfiConstant.BFI_AP_BILL_PROJECT_NUMBER_TEST); + // 【必填】外部系统来源分录标识(候选键):结算明细行取单据号,结算合计费用项行取结算单号-费用类型-费用项拼接 + row.setAwrpEntryCode(entryGroup.entryCode()); + // 分录备注(非必传) + // row.setERemark(subject); + + entry.add(row); + } + param.setEntry(entry); + + // 收款计划 planentity 不传:由金蝶按明细自动汇总生成(启用方案待业务确认后迭代) + + log.info("推送金蝶财务应收单:正式结算单[{}],防重键[{}],明细分录{}条", subject, arBillSrcCode, entry.size()); + BfiBillSaveResultVO saveResult = bfiService.saveArBill(param); + // 应收单id不落库,后续如需可经 A302-4 按防重键(awrp_src_sys + awrp_src_code)查询 + kingdeeApBillSyncSupport.requireBfiSaveSuccess(saveResult, "推送金蝶财务应收单"); + String billNo = saveResult.getResult().get(0).getNumber(); + log.info("金蝶财务应收单保存成功:正式结算单[{}],单据编号[{}],明细分录{}条", subject, billNo, entry.size()); + // 星瀚应收单内码随结果返回(A902 附件上传定位用) + BfiBillSaveResultItemVO savedItem = saveResult.getResult().get(0); + return new KingdeeApBillSyncSupport.BfiApBillPushOutcome(savedItem.getNumber(), + savedItem.getId() == null ? null : String.valueOf(savedItem.getId()), false); + } + + // ==================== 结算明细分录解析 ==================== + + /** + * 解析应收正式结算明细为应收单分录:与应付单(A102-2)分录解析同源同口径—— + * 页面结算明细按 货物→费用大类→税率 拆分行 + 手工添加的结算合计费用项每行一条(系统自动汇总行是明细金额的再汇总不送), + * 物料/计量单位/数量/单价/金额/分录标识取数规则一致, + * 直接复用应付支撑组件解析(应收分录不含税金额取结算明细结算金额(不含税)) + * + * @param settlement 正式结算单 + * @param settlementDetails 该结算单下的结算明细 + * @param context 金蝶同步上下文(缓存载体) + * @return 应收单分录列表(保持行序) + */ + public List resolveArEntryGroups(FormalSettlement settlement, + List settlementDetails, + KingdeeSyncContext context) { + return kingdeeApBillSyncSupport.resolveEntryGroups(settlement, settlementDetails, context); + } +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeAttachmentSyncSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeAttachmentSyncSupport.java new file mode 100644 index 00000000..dd20ddde --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeAttachmentSyncSupport.java @@ -0,0 +1,463 @@ +/** + * 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.support; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import feign.Response; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springblade.core.tool.jackson.JsonUtil; +import org.springblade.core.tool.utils.Func; +import org.springblade.thirdparty.bfi.pojo.vo.BfiAttachmentUploadVO; +import org.springblade.thirdparty.bfi.service.IBfiService; +import org.springblade.transport.constant.KingdeeAttachmentConstant; +import org.springblade.transport.mapper.KingdeeAttachmentSyncMapper; +import org.springblade.transport.pojo.entity.KingdeeAttachmentSync; +import org.springframework.stereotype.Component; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 金蝶附件同步支撑组件(A902 附件上传到指定单据)。 + *

设计不变式: + * 1. 附件上传晚于主单推送成功、拿到并落库 billPkId 之后(由各模块接入点保证,本组件在事务提交后调用); + * 2. 附件失败永不回滚、不污染主单同步状态(逐附件独立 try/catch); + * 3. 同 (biz_type, biz_id, attachment_key) 最多一条成功记录(唯一键 + 成功集合幂等跳过); + * 4. 不自动重试,重推主单时自动补传;5. 单文件软限制 20MB,超限/下载失败记原因跳过。

+ *

附件来源两类:用户附件(attachmentsJson 数组项,按 url 下载,幂等键=url)与 + * 票池镜像发票文件(attachmentsJson 对象 {source,path,name},经 A801-2 下载回传,幂等键=path, + * path 变化(红冲重开)视为新文件自动触发重传)。

+ * + * @author Chill + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class KingdeeAttachmentSyncSupport { + + /** 用户附件下载连接/请求超时 */ + private static final Duration DOWNLOAD_TIMEOUT = Duration.ofSeconds(60); + + private final IBfiService bfiService; + private final KingdeeAttachmentSyncMapper attachmentSyncMapper; + + /** 附件下载客户端(无状态,进程内复用) */ + private final HttpClient downloadClient = HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NORMAL) + .connectTimeout(Duration.ofSeconds(10)) + .build(); + + /** + * 待同步附件项 + * + * @param name 附件名称(含扩展名,展示/金蝶侧文件名) + * @param key 幂等键:用户附件=附件 url;发票文件=A801-2 的 path + * @param size 附件大小(字节,可空:空时下载后按实际字节校验) + * @param kingdeePathSource true 表示 key 为星瀚侧文件 path(经 A801-2 下载);false 表示 http(s) 附件 url + */ + public record KingdeeAttachmentItem(String name, String key, Long size, boolean kingdeePathSource) { + } + + /** + * 单据附件同步结果(各模块用于追加操作流水与前端提示) + * + * @param total 参与同步的附件数(不含已成功跳过) + * @param success 本次上传成功数 + * @param failed 失败数 + * @param skipCount 已成功幂等跳过数 + * @param failReasons 失败原因明细(附件名:原因) + */ + public record AttachmentSyncSummary(int total, int success, int failed, int skipCount, List failReasons) { + } + + // ==================== 附件同步主流程 ==================== + + /** + * 同步单据附件至金蝶对应单据附件面板(A902,逐附件独立 try/catch:成功落成功记录、失败落失败记录)。 + * billPkId 为空(星瀚单据内码未落库)时整单跳过并记日志,由后续重推补传。 + * + * @param bizType 业务单据类型(KingdeeAttachmentConstant.BIZ_*) + * @param bizId 业务单据id + * @param billPkId 星瀚单据内码 + * @param items 待同步附件列表 + * @return 同步结果汇总 + */ + public AttachmentSyncSummary syncAttachments(String bizType, Long bizId, String billPkId, List items) { + if (Func.isEmpty(items)) { + return new AttachmentSyncSummary(0, 0, 0, 0, List.of()); + } + if (Func.isBlank(billPkId)) { + log.warn("金蝶附件同步跳过:业务单据[{}:{}]星瀚单据内码为空,待重推补传", bizType, bizId); + return new AttachmentSyncSummary(items.size(), 0, 0, 0, List.of()); + } + // 载入已成功集合:命中即幂等跳过(重推主单时 A902 零调用) + Set successKeys = successKeys(bizType, bizId); + // 载入既有同步记录(含失败记录,重试成功时覆盖为成功) + Map existingByKey = existingByKey(bizType, bizId); + String entityNumber = KingdeeAttachmentConstant.entityNumber(bizType); + log.info("金蝶附件同步开始:业务单据[{}:{}],星瀚内码={},entityNumber={},待同步附件 {} 个,历史已成功 {} 个", + bizType, bizId, billPkId, entityNumber, items.size(), successKeys.size()); + int success = 0; + int skipCount = 0; + List failReasons = new ArrayList<>(); + List pendingRecords = new ArrayList<>(); + for (KingdeeAttachmentItem item : items) { + if (Func.isBlank(item.key())) { + continue; + } + if (successKeys.contains(item.key())) { + skipCount++; + continue; + } + try { + // 声明大小超限直接拦截,不发起下载 + if (item.size() != null && item.size() > KingdeeAttachmentConstant.MAX_FILE_SIZE_BYTES) { + throw new IllegalArgumentException("附件超过20MB大小限制(" + + formatSize(item.size()) + "),请压缩后重试"); + } + byte[] content = fetchContent(item); + if (content.length > KingdeeAttachmentConstant.MAX_FILE_SIZE_BYTES) { + throw new IllegalArgumentException("附件超过20MB大小限制(" + + formatSize((long) content.length) + "),请压缩后重试"); + } + String fileName = Func.isBlank(item.name()) ? "attachment" : item.name(); + BfiAttachmentUploadVO uploadVO = bfiService.uploadAttachment(entityNumber, billPkId, + KingdeeAttachmentConstant.CONTROL_KEY, fileName, content); + log.info("金蝶附件上传成功:业务单据[{}:{}],附件[{}],kingdeePath={}", bizType, bizId, item.name(), uploadVO.getPath()); + pendingRecords.add(buildRecord(bizType, bizId, item, KingdeeAttachmentConstant.SYNC_STATUS_SUCCESS, + null, uploadVO.getPath(), existingByKey.get(item.key()))); + success++; + } catch (Exception exception) { + String reason = exception.getMessage() == null ? exception.getClass().getSimpleName() : exception.getMessage(); + log.warn("金蝶附件同步失败:业务单据[{}:{}],附件[{}]", bizType, bizId, item.name(), exception); + failReasons.add(Func.toStr(item.name()) + ":" + reason); + pendingRecords.add(buildRecord(bizType, bizId, item, KingdeeAttachmentConstant.SYNC_STATUS_FAILED, + truncateReason(reason), null, existingByKey.get(item.key()))); + } + } + for (KingdeeAttachmentSync record : pendingRecords) { + if (record.getId() == null) { + attachmentSyncMapper.insert(record); + } else { + // updateById 默认忽略 null 字段,失败转成功时 fail_reason 须显式置空,避免残留历史失败原因 + attachmentSyncMapper.update(record, Wrappers.lambdaUpdate() + .eq(KingdeeAttachmentSync::getId, record.getId()) + .set(KingdeeAttachmentSync::getFailReason, record.getFailReason())); + } + } + int total = success + failReasons.size(); + log.info("金蝶附件同步完成:业务单据[{}:{}],本次待传 {},成功 {},失败 {},历史已传跳过 {}", + bizType, bizId, total, success, failReasons.size(), skipCount); + return new AttachmentSyncSummary(total, success, failReasons.size(), skipCount, failReasons); + } + + /** + * 同步用户附件(单据 attachmentsJson):解析后逐个上传 + * + * @param bizType 业务单据类型 + * @param bizId 业务单据id + * @param billPkId 星瀚单据内码 + * @param attachmentsJson 单据附件JSON(数组,容忍单对象) + * @return 同步结果汇总 + */ + public AttachmentSyncSummary syncUserAttachments(String bizType, Long bizId, String billPkId, String attachmentsJson) { + return syncAttachments(bizType, bizId, billPkId, parseUserAttachments(attachmentsJson)); + } + + // ==================== 附件解析 ==================== + + /** + * 解析单据用户附件(attachmentsJson):取 url/name/size,无 url 的项忽略。 + * 前端附件行字段容忍取值:名称 originalName/name/fileName,地址 url/link/fileUrl/downloadUrl/domain/src + * + * @param attachmentsJson 附件JSON + * @return 附件项列表 + */ + public List parseUserAttachments(String attachmentsJson) { + Object parsed = parseJson(attachmentsJson); + if (parsed == null) { + return List.of(); + } + List rows = parsed instanceof List list ? list : List.of(parsed); + List items = new ArrayList<>(); + for (Object row : rows) { + if (!(row instanceof Map map)) { + continue; + } + String url = firstText(map, "url", "link", "fileUrl", "downloadUrl", "domain", "src"); + if (Func.isBlank(url)) { + continue; + } + String name = firstText(map, "originalName", "name", "fileName"); + Long size = firstLong(map, "size"); + items.add(new KingdeeAttachmentItem(name, url, size, false)); + } + return items; + } + + /** + * 解析票池镜像发票文件(attachmentsJson 单对象 {source,path,name}):幂等键=path(经 A801-2 下载), + * path 变化(红冲重开)自动视为新文件触发重传 + * + * @param attachmentsJson 镜像附件JSON + * @return 附件项列表(无发票文件时为空) + */ + public List parseInvoicePoolFile(String attachmentsJson) { + Object parsed = parseJson(attachmentsJson); + if (parsed == null) { + return List.of(); + } + List rows = parsed instanceof List list ? list : List.of(parsed); + List items = new ArrayList<>(); + for (Object row : rows) { + if (!(row instanceof Map map)) { + continue; + } + String path = firstText(map, "path"); + if (Func.isBlank(path)) { + continue; + } + String name = firstText(map, "name"); + items.add(new KingdeeAttachmentItem(Func.isBlank(name) ? "发票文件" : name, path, null, true)); + } + return items; + } + + // ==================== 列表聚合态 ==================== + + /** + * 单据附件数量(列表聚合态判定用):解析失败按 0 处理 + * + * @param attachmentsJson 单据附件JSON + * @return 附件数量 + */ + public int countAttachments(String attachmentsJson) { + return parseUserAttachments(attachmentsJson).size(); + } + + /** + * 各业务单据已成功上传附件数(一次 GROUP BY 查询,避免 N+1) + * + * @param bizType 业务单据类型 + * @param bizIds 业务单据id集合 + * @return 业务单据id -> 已成功附件数 + */ + public Map successCountByBizId(String bizType, Collection bizIds) { + if (Func.isEmpty(bizIds)) { + return Map.of(); + } + QueryWrapper wrapper = new QueryWrapper() + .select("biz_id", "count(*) AS success_count") + .eq("biz_type", bizType) + .eq("sync_status", KingdeeAttachmentConstant.SYNC_STATUS_SUCCESS) + .in("biz_id", bizIds) + .groupBy("biz_id"); + Map result = new HashMap<>(); + for (Map row : attachmentSyncMapper.selectMaps(wrapper)) { + Object bizId = row.get("biz_id"); + Object count = row.get("success_count"); + if (bizId != null && count != null) { + result.put(Long.valueOf(String.valueOf(bizId)), Integer.parseInt(String.valueOf(count))); + } + } + return result; + } + + /** + * 聚合态判定:无附件 / 未上传 / 部分未传 / 已上传(运行时聚合,非存储字段) + * + * @param attachmentCount 单据附件数量 + * @param successCount 已成功上传数量 + * @return 聚合态(KingdeeAttachmentConstant.AGGREGATE_*) + */ + public String aggregateStatus(int attachmentCount, int successCount) { + if (attachmentCount <= 0) { + return KingdeeAttachmentConstant.AGGREGATE_NONE; + } + if (successCount <= 0) { + return KingdeeAttachmentConstant.AGGREGATE_PENDING; + } + return successCount >= attachmentCount + ? KingdeeAttachmentConstant.AGGREGATE_DONE : KingdeeAttachmentConstant.AGGREGATE_PARTIAL; + } + + // ==================== 内部实现 ==================== + + /** 已成功附件幂等键集合 */ + private Set successKeys(String bizType, Long bizId) { + return attachmentSyncMapper.selectList(Wrappers.lambdaQuery() + .select(KingdeeAttachmentSync::getAttachmentKey) + .eq(KingdeeAttachmentSync::getBizType, bizType) + .eq(KingdeeAttachmentSync::getBizId, bizId) + .eq(KingdeeAttachmentSync::getSyncStatus, KingdeeAttachmentConstant.SYNC_STATUS_SUCCESS)) + .stream().map(KingdeeAttachmentSync::getAttachmentKey).collect(Collectors.toSet()); + } + + /** 既有同步记录(含失败),key -> 记录(重试成功时覆盖为成功) */ + private Map existingByKey(String bizType, Long bizId) { + Map result = new LinkedHashMap<>(); + for (KingdeeAttachmentSync record : attachmentSyncMapper.selectList(Wrappers.lambdaQuery() + .eq(KingdeeAttachmentSync::getBizType, bizType) + .eq(KingdeeAttachmentSync::getBizId, bizId))) { + result.putIfAbsent(record.getAttachmentKey(), record); + } + return result; + } + + /** + * 组装同步记录:有既有记录时复用主键覆盖更新(失败转成功/成功后清理失败原因),否则新增 + */ + private KingdeeAttachmentSync buildRecord(String bizType, Long bizId, KingdeeAttachmentItem item, int syncStatus, + String failReason, String kingdeePath, KingdeeAttachmentSync existing) { + KingdeeAttachmentSync record = existing == null ? new KingdeeAttachmentSync() : existing; + record.setBizType(bizType); + record.setBizId(bizId); + record.setAttachmentKey(item.key()); + record.setAttachmentName(truncate(item.name(), 250)); + record.setKingdeePath(kingdeePath); + record.setSyncStatus(syncStatus); + record.setFailReason(failReason); + record.setSyncTime(LocalDateTime.now()); + return record; + } + + /** + * 拉取附件内容:星瀚侧文件经 A801-2 下载(流式读取,防大文件全量进内存失控); + * 用户附件按 http(s) url 下载(流式读取)。调用方负责按 20MB 上限校验 + */ + private byte[] fetchContent(KingdeeAttachmentItem item) throws Exception { + if (item.kingdeePathSource()) { + Response response = bfiService.downloadInvoice(item.key()); + try (InputStream inputStream = response.body().asInputStream()) { + return readCapped(inputStream); + } finally { + response.close(); + } + } + HttpRequest request = HttpRequest.newBuilder(URI.create(item.key())) + .timeout(DOWNLOAD_TIMEOUT) + .GET() + .build(); + HttpResponse response = downloadClient.send(request, HttpResponse.BodyHandlers.ofInputStream()); + if (response.statusCode() < 200 || response.statusCode() >= 300) { + throw new IllegalStateException("附件下载失败,HTTP状态码 " + response.statusCode()); + } + try (InputStream inputStream = response.body()) { + return readCapped(inputStream); + } + } + + /** 流式读取并封顶(超出软限制即中断读取,避免大文件占满内存) */ + private byte[] readCapped(InputStream inputStream) throws IOException { + int bufferSize = 64 * 1024; + long limit = KingdeeAttachmentConstant.MAX_FILE_SIZE_BYTES; + ByteArrayOutputStream buffer = new ByteArrayOutputStream(bufferSize); + byte[] chunk = new byte[bufferSize]; + long total = 0; + int read; + while ((read = inputStream.read(chunk)) != -1) { + if (total + read > limit) { + throw new IllegalArgumentException("附件超过20MB大小限制,请压缩后重试"); + } + buffer.write(chunk, 0, read); + total += read; + } + return buffer.toByteArray(); + } + + private Object parseJson(String json) { + if (Func.isBlank(json)) { + return null; + } + try { + return JsonUtil.parse(json.trim(), Object.class); + } catch (Exception exception) { + log.warn("附件JSON解析失败,按无附件处理:{}", exception.getMessage()); + return null; + } + } + + /** 附件行字段容忍取值(多个候选键取第一个非空值) */ + private String firstText(Map map, String... keys) { + for (String key : keys) { + Object value = map.get(key); + if (value != null && Func.isNotBlank(String.valueOf(value))) { + return String.valueOf(value).trim(); + } + } + return null; + } + + private Long firstLong(Map map, String key) { + Object value = map.get(key); + if (value instanceof Number number) { + return number.longValue(); + } + if (value != null) { + try { + return Long.parseLong(String.valueOf(value)); + } catch (NumberFormatException ignored) { + return null; + } + } + return null; + } + + private String truncate(String text, int maxLength) { + if (text == null || text.length() <= maxLength) { + return text; + } + return text.substring(0, maxLength); + } + + private String truncateReason(String reason) { + return truncate(reason, 490); + } + + private String formatSize(Long bytes) { + if (bytes == null) { + return "未知"; + } + return String.format("%.1fMB", bytes / 1024.0 / 1024.0); + } +} 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 new file mode 100644 index 00000000..cd80fd69 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeReceivedInvoiceSyncSupport.java @@ -0,0 +1,294 @@ +/** + * 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.support; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.core.tool.utils.Func; +import org.springblade.thirdparty.bfi.constant.BfiConstant; +import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceAssignItemDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceAssignRefDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiReceivedInvoiceQueryDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiReceivedInvoiceSaveDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiReceivedInvoiceSaveEntryDTO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultItemVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiReceivedInvoiceVO; +import org.springblade.thirdparty.bfi.service.IBfiService; +import org.springblade.thirdparty.bfi.util.BfiUtil; +import org.springblade.transport.config.BfiProperties; +import org.springblade.transport.constant.KingdeeBfiConstant; +import org.springblade.transport.pojo.entity.ContractManage; +import org.springblade.transport.pojo.entity.InvoiceReceipt; +import org.springblade.transport.pojo.entity.KingdeeInvoicePool; +import org.springblade.transport.service.KingdeeSyncContext; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 金蝶收票单同步支撑组件(图05 环节②③:A303-2 收票单批量保存 + A302-6 收票指定应付 + A801-3 收票单查询)。 + *

收票登记「推送金蝶」由 {@link org.springblade.transport.service.IInvoiceReceiptService} 编排前置校验 + * (审批通过、已绑定镜像、一票一应付、A102-4 应付单已生成预检、A801-3 幂等预检)后调用本组件; + * 报文组装取数:主表组织/币种/往来户复用应付支撑组件的合同派生方法,票面字段取自票池镜像 + * (invoice_type_code 等,A801-1 落库),明细分录与财务应付单 A102-2 同源同口径 + * (结算明细行+结算合计费用项各一条,{@link KingdeeApBillSyncSupport.BfiEntryGroup},分录标识同键)。 + * 防重键:收票单 awrp_src_code=收票登记id,awrp_entry_code=结算明细行单据号/结算合计费用项拼接 + * (与应付单分录同键,便于金蝶侧逐行勾稽);A302-6 按「收票单防重键 ↔ 财务应付单防重键」一票一应付指定。 + * 财务应付单侧(A102-2/A102-4)取数与校验复用 {@link KingdeeApBillSyncSupport}。

+ * + * @author Chill + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class KingdeeReceivedInvoiceSyncSupport { + + /** A801-3 分页查询页大小 */ + private static final int RECEIVED_QUERY_PAGE_SIZE = 200; + /** A801-3/A303-2 统一时间格式 */ + private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + /** A801-3 发票状态:作废(2)/红冲(3)/部分红冲(7)/全部红冲(8) 视为金蝶侧异常,回收推送状态 */ + private static final List ABNORMAL_INVOICE_STATUS = List.of("2", "3", "7", "8"); + + private final IBfiService bfiService; + private final KingdeeApBillSyncSupport kingdeeApBillSyncSupport; + private final BfiProperties bfiProperties; + + // ==================== A801-3 收票单查询(幂等预检/定时回写共用) ==================== + + /** + * A801-3 按购方名称+修改时间窗口分页拉取收票单,本地按外部系统编号+来源单据号(防重键)过滤。 + * 契约无来源单据号查询参数,防重键匹配在返回行上进行;查询失败中断本轮,由任务层记录异常 + * + * @param buyerName 购方名称(接口必填,取镜像受票单位) + * @param srcCodes 待匹配的收票单防重键(收票登记id,与 A303-2 的 awrp_src_code 同源)集合 + * @param windowStart 修改时间-开始(窗口不超一个月,调用方控制) + * @param windowEnd 修改时间-结束 + * @return 防重键(收票登记id) -> 收票单行(同键多行取修改时间最新) + */ + public Map queryReceivedInvoiceBySrcCodes(String buyerName, Collection srcCodes, + LocalDateTime windowStart, LocalDateTime windowEnd) { + Map matched = new LinkedHashMap<>(); + if (Func.isBlank(buyerName) || Func.isEmpty(srcCodes)) { + return matched; + } + BfiReceivedInvoiceQueryDTO param = new BfiReceivedInvoiceQueryDTO(); + param.setBuyername(buyerName.trim()); + param.setModifytimeS(windowStart.format(TIME_FORMATTER)); + param.setModifytimeE(windowEnd.format(TIME_FORMATTER)); + List rows = BfiUtil.queryAllPages(pageNo -> { + param.setPageNo(pageNo); + param.setPageSize(RECEIVED_QUERY_PAGE_SIZE); + return bfiService.queryReceivedInvoice(param); + }); + for (BfiReceivedInvoiceVO row : rows) { + if (!bfiProperties.getSrcSys().equals(row.getAwrpSrcSys()) || Func.isBlank(row.getAwrpSrcCode()) + || !srcCodes.contains(row.getAwrpSrcCode())) { + continue; + } + BfiReceivedInvoiceVO exist = matched.get(row.getAwrpSrcCode()); + // 同防重键多行(删除重建等)取修改时间最新一行 + if (exist == null || compareModifyTime(row, exist) >= 0) { + matched.put(row.getAwrpSrcCode(), row); + } + } + return matched; + } + + /** + * 金蝶侧发票状态是否异常(作废/红冲/部分红冲/全部红冲) + * + * @param invoiceStatus A801-3 invoicestatus + * @return true 表示须回收推送状态、提示人工 + */ + public boolean isAbnormalInvoiceStatus(String invoiceStatus) { + return Func.isNotBlank(invoiceStatus) && ABNORMAL_INVOICE_STATUS.contains(invoiceStatus.trim()); + } + + private int compareModifyTime(BfiReceivedInvoiceVO left, BfiReceivedInvoiceVO right) { + String leftTime = left.getModifytime() == null ? "" : left.getModifytime(); + String rightTime = right.getModifytime() == null ? "" : right.getModifytime(); + return leftTime.compareTo(rightTime); + } + + // ==================== A303-2 收票单批量保存 ==================== + + /** + * 推送金蝶收票单(A303-2 批量保存,调用方已完成 A801-3 幂等预检)。 + * 主表:组织/币种/往来户编码按分摊结算单关联合同派生(与应付单同源),销方=开票单位、购方=受票单位; + * 明细分录与财务应付单 A102-2 同源同口径:结算明细按 货物→费用大类→税率 拆分行+手动添加结算合计费用项每行一条,物料/数量/单价/金额取自 + * 与应付单分录同一解析结果({@link KingdeeApBillSyncSupport.BfiEntryGroup}),分录标识同键。 + * 联调口径沿用应付侧:先只送 DTO 标注【必填】字段与票面核心字段(发票号码), + * 非必填的税号/地址/电话/开户行等 setter 注释保留,联调确认后放开 + * + * @param receipt 收票登记(审批通过、已绑定镜像) + * @param mirror 票池镜像(票面主表字段取数来源) + * @param contract 分摊应付结算单关联合同(组织/币种/往来户派生) + * @param entryGroups 分摊结算单解析出的分录(与应付单 A102-2 同一解析结果) + * @param context 金蝶同步上下文(缓存载体) + * @return 推送结果(单据编号 + 星瀚收票单内码,A902 附件上传定位用) + */ + public ReceivedInvoicePushOutcome pushReceivedInvoice(InvoiceReceipt receipt, KingdeeInvoicePool mirror, ContractManage contract, + List entryGroups, KingdeeSyncContext context) { + if (Func.isBlank(mirror.getInvoiceTypeCode())) { + throw new ServiceException("推送金蝶收票单失败:镜像缺少票易通发票类型编码,请先重新绑定票池发票"); + } + BfiReceivedInvoiceSaveDTO param = new BfiReceivedInvoiceSaveDTO(); + // 【必填】外部系统编号(候选键) + param.setAwrpSrcSys(bfiProperties.getSrcSys()); + // 【必填】外部系统来源单据号(候选键):发票号码(一票一登记天然唯一,重推同键幂等) + param.setAwrpSrcCode(String.valueOf(receipt.getId())); + // 发票号码(非必填但为收票单核心业务字段,必送) + param.setInvoiceno(receipt.getInvoiceNo()); + // 【必填】收票日期:以推送当日为收票日期 + param.setReceivedate(LocalDate.now().atStartOfDay().format(TIME_FORMATTER)); + // 【必填】开票日期:镜像票面开票日期 + param.setIssuedate(kingdeeApBillSyncSupport.bfiDateTimeText(mirror.getInvoiceDate())); + // 【必填】业务类型(PUR 采购,待金蝶确认) + param.setBiztype(KingdeeBfiConstant.BFI_RECEIVED_BIZ_TYPE); + // 【必填】发票类型.编码:票易通原始编码(26/27 等) + param.setInvoicetypef7Number(mirror.getInvoiceTypeCode()); + // 【必填】付款方式:(与应付单一致,不允许金蝶自动推付款) + param.setPaymenttype(BfiConstant.PAY_MODE_CASH); + // 【必填】往来类型:进项收票固定供应商 + param.setAsstacttype(BfiConstant.ASSTACT_TYPE_SUPPLIER); + // 【必填】结算组织.编码:合同所属组织(与应付单同源) + param.setOrgNumber(kingdeeApBillSyncSupport.bfiOrgNumber(contract)); + // 【必填】结算币别:合同结算币种 + param.setCurrencyNumber(kingdeeApBillSyncSupport.bfiCurrency(contract)); + // 【必填】往来户.编码:合同乙方(开票供应商)客商编码 + param.setReceivablessuppNumber(kingdeeApBillSyncSupport.bfiAsstactNumber(contract, context)); + // 【必填】汇率表.编码 + param.setExratetableNumber(BfiConstant.EXRATE_TABLE_DEFAULT); + // 【必填】销方名称:镜像开票单位 + param.setAsstactname(mirror.getIssuerName()); + // 【必填】购方名称:镜像受票单位 + param.setBuyername(mirror.getReceiverName()); + // 录入价税合计(非必传,与应付/应收侧口径一致传 true) + param.setIsincludetax(true); + // 销方税号(非必传,联调确认后放开) + param.setSellertin(mirror.getSalerTaxNo()); + // 购方税号(非必传,联调确认后放开) + param.setBuyertin(mirror.getBuyerTaxNo()); + // 销方地址/电话/开户行/账号(非必传,被金蝶强校验时从镜像 bank_name/bank_account/issuing_bank/phone 补齐) + // param.setSelleraddr(null); + param.setSellertel(mirror.getPhone()); + param.setSellerbank(mirror.getBankName()); + param.setSelleracct(mirror.getBankAccount()); + // 备注(非必传) + // param.setRemark(receipt.getRemark()); + // 【必填】外部单据类型 + param.setAwrpSrcType(KingdeeBfiConstant.BFI_RECEIVED_INVOICE_SRC_TYPE); + // 【必填】明细分录:与应付单 A102-2 同源同口径,结算明细行+结算合计费用项各一条(不聚合) + param.setEntry(buildEntries(entryGroups)); + + BfiBillSaveResultVO saveResult = bfiService.saveReceivedInvoice(param); + kingdeeApBillSyncSupport.requireBfiSaveSuccess(saveResult, "推送金蝶收票单"); + BfiBillSaveResultItemVO savedItem = saveResult.getResult().get(0); + return new ReceivedInvoicePushOutcome(savedItem.getNumber(), + savedItem.getId() == null ? null : String.valueOf(savedItem.getId())); + } + + /** + * A303-2 收票单推送结果 + * + * @param billNo 金蝶收票单单据编号 + * @param billId 星瀚收票单内码(A902 附件上传定位用) + */ + public record ReceivedInvoicePushOutcome(String billNo, String billId) { + } + + /** + * 分录组装与应付单 A102-2 明细同源同口径:结算明细按 货物→费用大类→税率 拆分行 + 手动添加的结算合计费用项每行一条, + * 物料/数量/单价/含税/不含税金额 + * 取自 {@link KingdeeApBillSyncSupport.BfiEntryGroup}(与应付单分录同一解析结果),分录标识同键, + * 便于金蝶侧收票单明细与应付单明细逐行勾稽;数量无效时兜底 1; + * 票面明细行(items_json)不再作为分录取数来源,税收分类/税率/税额随应付口径暂不送(联调强校验时再补) + * + * @param entryGroups 分摊结算单解析出的分录(与应付单 A102-2 同一解析结果) + * @return A303-2 明细分录 + */ + private List buildEntries(List entryGroups) { + List entries = new ArrayList<>(); + for (KingdeeApBillSyncSupport.BfiEntryGroup entryGroup : entryGroups) { + BfiReceivedInvoiceSaveEntryDTO entry = new BfiReceivedInvoiceSaveEntryDTO(); + // 【必填】价税合计:该行含税金额(与应付单 e_pricetaxtotal 同源取数) + entry.setEPricetaxtotal(kingdeeApBillSyncSupport.money(entryGroup.amount())); + // 【必填】数量:该行运输数量,无吨位数据时兜底 1(与应付单口径一致) + entry.setQuantity(entryGroup.quantity().compareTo(BigDecimal.ZERO) > 0 ? entryGroup.quantity() : BigDecimal.ONE); + // 【必填】单价:该行运输单价(与应付单同源;单据头 isincludetax=true 录入价税合计,单价仅作参考) + entry.setPrice(entryGroup.price()); + // 【必填】物料编码.编码:按装卸大类/项目业务模式/运单运输方式映射(thirdparty.bfi.material.* 配置,与应付单同源) + entry.setMaterialNumber(entryGroup.materialNumber()); + // 金额(不含税):该行结算金额(不含税)(与应付单 e_amount 同源取数;结算合计费用项行无不含税金额不传) + if (entryGroup.amountNoTax() != null) { + entry.setEAmount(kingdeeApBillSyncSupport.money(entryGroup.amountNoTax())); + } + // 【必填】外部系统来源分录标识(候选键):与应付单分录同键(结算明细行单据号/结算合计费用项拼接),便于逐行勾稽 + entry.setAwrpEntryCode(entryGroup.entryCode()); + + entries.add(entry); + } + if (entries.isEmpty()) { + throw new ServiceException("推送金蝶收票单失败:分摊结算单无结算明细与结算合计费用项,无法组装收票单明细"); + } + return entries; + } + + // ==================== A302-6 收票指定应付 ==================== + + /** + * 收票单指定财务应付单(A302-6,一票一应付):按「收票单防重键(收票登记ID)↔ 财务应付单防重键 + * (正式结算单号_结算明细单据号拼接)」建立绑定;金蝶侧解除指定(unassign)不自动执行,人工处理后重推 + * + * @param invoiceSrcCode 收票单防重键(收票登记ID,即 A303-2 的 awrp_src_code = receipt.getId()) + * @param apBillSrcCode 财务应付单防重键(即 A102-2 的 awrp_src_code = 正式结算单号_结算明细单据号拼接) + */ + public void assignApBill(String invoiceSrcCode, String apBillSrcCode) { + BfiInvoiceAssignItemDTO item = new BfiInvoiceAssignItemDTO(); + item.setApInvoice(List.of(buildAssignRef(invoiceSrcCode))); + item.setApFinapbill(List.of(buildAssignRef(apBillSrcCode))); + BfiBillSaveResultVO assignResult = bfiService.invoiceAssign("assign", List.of(item)); + kingdeeApBillSyncSupport.requireBfiSaveSuccess(assignResult, "金蝶收票单指定应付"); + } + + private BfiInvoiceAssignRefDTO buildAssignRef(String awrpSrcCode) { + BfiInvoiceAssignRefDTO ref = new BfiInvoiceAssignRefDTO(); + ref.setAwrpSrcCode(awrpSrcCode); + ref.setAwrpSrcSys(bfiProperties.getSrcSys()); + return ref; + } +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeSyncLockSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeSyncLockSupport.java new file mode 100644 index 00000000..684068a2 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeSyncLockSupport.java @@ -0,0 +1,93 @@ +/** + * 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.support; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.core.redis.lock.LockType; +import org.springblade.core.redis.lock.RedisLockClient; +import org.springblade.core.secure.utils.AuthUtil; +import org.springblade.core.tool.utils.Func; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.stereotype.Component; + +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; + +/** + * 金蝶定时同步 single-flight 分布式锁支撑组件(模式同全票池同步 syncKingdeeInvoicePool): + * 多副本部署的定时任务与手动触发端点共用一道闸,同一轮同步全局最多一份在跑。 + *

零等待抢锁:已有轮次进行中则立刻放弃本轮并返回跳过摘要(定时任务下轮幂等重试,手动触发稍后再试); + * 租约须大于最坏一轮耗时(含翻页远程查询),否则租约到期后另一副本会并发起跑;进程异常退出时锁在租约到期后自动释放; + * 未启用 blade.lock.enabled 时降级为直接执行(多副本各自起跑,联调环境可接受,生产须开启)。 + * + * @author Chill + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class KingdeeSyncLockSupport { + + /** 分布式锁客户端,仅在 blade.lock.enabled=true 时存在,缺失时同步降级为无 single-flight 保护 */ + private final ObjectProvider redisLockClientProvider; + + /** + * 在 single-flight 分布式锁保护下执行一轮同步 + * + * @param lockPrefix 锁键前缀(业务侧隔离,组件内自动追加租户标识) + * @param leaseSeconds 锁租约秒数,须大于最坏一轮耗时 + * @param taskName 同步任务名,用于日志与跳过摘要 + * @param action 同步主体 + * @return 同步摘要;已有轮次进行中时返回跳过摘要 + */ + public String runSingleFlight(String lockPrefix, long leaseSeconds, String taskName, Supplier action) { + RedisLockClient redisLockClient = redisLockClientProvider.getIfAvailable(); + if (redisLockClient == null) { + // 未启用 blade.lock.enabled:多副本部署会各自起跑同一轮同步,联调环境可接受,生产须开启 + log.warn("未启用分布式锁(blade.lock.enabled=false),{}无 single-flight 保护", taskName); + return action.get(); + } + String lockKey = lockPrefix + currentTenant(); + boolean locked = false; + try { + // 零等待抢锁:已有轮次在跑就立刻放弃本轮,多副本与手动触发共用这一道闸 + locked = redisLockClient.tryLock(lockKey, LockType.REENTRANT, 0, leaseSeconds, TimeUnit.SECONDS); + if (!locked) { + log.warn("{}已有轮次进行中,本轮跳过,锁键={}", taskName, lockKey); + return taskName + "进行中,本轮跳过"; + } + return action.get(); + } catch (InterruptedException interruptedException) { + Thread.currentThread().interrupt(); + throw new ServiceException("等待" + taskName + "分布式锁被中断"); + } finally { + if (locked) { + redisLockClient.unLock(lockKey, LockType.REENTRANT); + } + } + } + + /** 租户标识:定时任务上下文无登录用户时以 default 加锁,锁粒度与数据隔离边界一致 */ + private String currentTenant() { + String tenantId = AuthUtil.getTenantId(); + return Func.isEmpty(tenantId) ? "default" : tenantId; + } +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/task/CustomerKingdeeSyncTask.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/task/CustomerKingdeeSyncTask.java new file mode 100644 index 00000000..24d41f7e --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/task/CustomerKingdeeSyncTask.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. + *

+ * 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.config.BfiProperties; +import org.springblade.transport.service.ICustomerArchiveService; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * 金蝶客商数据定时同步任务 + *

+ * 对应业财融合「客商数据定时同步子流程」(图01,ERP 泳道): + * ① 待推送客商(审批通过且未推送/推送失败)自动执行 A902-1 复查 + A902-2 推送; + * ② 已同步客商回写金蝶使用状态,金蝶禁用(enable=0)联动停用客商档案。 + * A901-1 供应商查询待金蝶 SDK(blade-bfi-api)提供能力后扩展。 + * 业务逻辑收敛在 {@link ICustomerArchiveService#syncKingdeeCustomerData()},便于手动触发联调。 + *

+ * 调度配置(Nacos thirdParty.bfi.customer.*):syncEnabled 开关、cron 表达式。 + * + * @author Chill + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class CustomerKingdeeSyncTask { + + private final ICustomerArchiveService customerArchiveService; + private final BfiProperties bfiProperties; + + /** 客商金蝶数据同步调度,默认每天 02:00 执行 */ + @Scheduled(cron = "${thirdParty.bfi.customer.cron:0 0 2 * * ?}") + public void syncKingdeeCustomerData() { + if (!bfiProperties.getCustomer().isSyncEnabled()) { + return; + } + try { + String summary = customerArchiveService.syncKingdeeCustomerData(); + log.info("定时同步金蝶客商数据:{}", summary); + } catch (Exception exception) { + log.error("定时同步金蝶客商数据异常", exception); + } + } + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/task/InvoiceReceiptKingdeeSyncTask.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/task/InvoiceReceiptKingdeeSyncTask.java new file mode 100644 index 00000000..91c6ed0f --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/task/InvoiceReceiptKingdeeSyncTask.java @@ -0,0 +1,66 @@ +/** + * 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. + *

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

+ * 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 the License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package org.springblade.transport.task; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springblade.transport.config.BfiProperties; +import org.springblade.transport.service.IInvoiceReceiptService; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * 收票管理金蝶全票池发票定时同步任务(瘦身版) + *

+ * 定时按增量窗口分页拉取金蝶发票云全票池发票数据(A801-1,经 BFI 网关 rim 契约透传), + * 镜像表 blade_kingdee_invoice_pool 全列批量 upsert,供选票统一查本地(真分页/名称模糊/认领登记过滤)。 + * 业务逻辑收敛在 {@link IInvoiceReceiptService#syncKingdeeInvoicePool()},任务类只做开关与调度,便于手动触发联调。 + *

+ * 调度配置(Nacos thirdParty.bfi.invoiceReceipt.*):syncEnabled 开关、cron 表达式、incrementDays 增量窗口。 + * + * @author Chill + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class InvoiceReceiptKingdeeSyncTask { + + private final IInvoiceReceiptService invoiceReceiptService; + private final BfiProperties bfiProperties; + + /** 金蝶全票池同步调度,默认每30分钟执行一次 */ + @Scheduled(cron = "${thirdParty.bfi.invoiceReceipt.cron:0 16/30 * * * ?}") + public void syncKingdeeInvoicePool() { + if (!bfiProperties.getInvoiceReceipt().isSyncEnabled()) { + return; + } + try { + String summary = invoiceReceiptService.syncKingdeeInvoicePool(); + log.info("定时同步金蝶全票池发票:{}", summary); + } catch (Exception exception) { + log.error("定时同步金蝶全票池发票异常", exception); + } + } +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/task/KingdeeAutoSyncRetryTask.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/task/KingdeeAutoSyncRetryTask.java new file mode 100644 index 00000000..2ea845b7 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/task/KingdeeAutoSyncRetryTask.java @@ -0,0 +1,92 @@ +/** + * 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.config.BfiProperties; +import org.springblade.transport.service.IFormalSettlementService; +import org.springblade.transport.service.IInvoiceApplicationService; +import org.springblade.transport.service.IInvoiceReceiptService; +import org.springblade.transport.service.IPaymentApplicationService; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * 金蝶自动同步失败定时重推任务(兜底):仅重推各域 failed 单据,不扫描 unsynced—— + * 存量"审批通过但从未推送"的历史单不自动补推(避免上线即批量推历史数据),仍走手动按钮。 + *

+ * 前置未就绪(如上游应付单未生成)→ 预检拦截 → 再次 failed → 下轮继续; + * 前置就绪 → 自动续推成功(节点硬顺序由既有 A102-4/A302-4/A303-4/A801-3 预检保证,不做级联)。 + *

+ * ponytail: 重推无次数上限,永久性坏数据(如联行号未维护)会每轮失败一次; + * 噪音可接受后,再加 retry_count 上限列。 + *

+ * 调度配置(Nacos thirdParty.bfi.autoSync.*):retryEnabled 开关、retryCron 表达式。 + * + * @author Chill + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class KingdeeAutoSyncRetryTask { + + private final IPaymentApplicationService paymentApplicationService; + private final IFormalSettlementService formalSettlementService; + private final IInvoiceApplicationService invoiceApplicationService; + private final IInvoiceReceiptService invoiceReceiptService; + private final BfiProperties bfiProperties; + + /** failed 定时重推调度,默认每10分钟执行一次 */ + @Scheduled(cron = "${thirdParty.bfi.autoSync.retryCron:0 5/10 * * * ?}") + public void retryKingdeeFailed() { + if (!bfiProperties.getAutoSync().isRetryEnabled()) { + return; + } + // ① 结算单(A102-2/A302-2) + try { + String summary = formalSettlementService.retryKingdeeFailed(); + log.info("定时重推金蝶生成失败结算单:{}", summary); + } catch (Exception exception) { + log.error("定时重推金蝶生成失败结算单异常", exception); + } + // ② 付款申请(A103-1/A103-3) + try { + String summary = paymentApplicationService.retryKingdeeFailed(); + log.info("定时重推金蝶生成失败付款申请:{}", summary); + } catch (Exception exception) { + log.error("定时重推金蝶生成失败付款申请异常", exception); + } + // ③ 开票申请(A303-1) + try { + String summary = invoiceApplicationService.retryKingdeeFailed(); + log.info("定时重推金蝶生成失败开票申请:{}", summary); + } catch (Exception exception) { + log.error("定时重推金蝶生成失败开票申请异常", exception); + } + // ④ 收票登记(A303-2+A302-6) + try { + String summary = invoiceReceiptService.retryKingdeeFailed(); + log.info("定时重推金蝶推送失败收票登记:{}", summary); + } catch (Exception exception) { + log.error("定时重推金蝶推送失败收票登记异常", exception); + } + } +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/task/KingdeeBillLockRetryTask.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/task/KingdeeBillLockRetryTask.java new file mode 100644 index 00000000..d306ed92 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/task/KingdeeBillLockRetryTask.java @@ -0,0 +1,50 @@ +/** + * 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.task; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springblade.transport.config.BfiProperties; +import org.springblade.transport.service.IKingdeeBillLockService; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * 金蝶单据锁定补偿定时任务 + *

+ * 扫描锁定失败的锁记录(lock_status=failed 且未超重试上限)重调 lockBusiData, + * 补偿推送成功但锁单失败的单据,不依赖人工重推「同步金蝶」。 + * 业务逻辑收敛在 {@link IKingdeeBillLockService#retryFailedLocks()},任务类只做开关与调度(模式同 PaymentKingdeeResultTask)。 + *

+ * 调度配置(Nacos thirdParty.bfi.billLock.*):retryEnabled 开关、retryCron 表达式。 + * + * @author Chill + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class KingdeeBillLockRetryTask { + + private final IKingdeeBillLockService kingdeeBillLockService; + private final BfiProperties bfiProperties; + + /** 锁定补偿调度,默认每5分钟执行一次 */ + @Scheduled(cron = "${thirdParty.bfi.billLock.retryCron:0 2/5 * * * ?}") + public void retryFailedBillLocks() { + if (!bfiProperties.getBillLock().isRetryEnabled()) { + return; + } + try { + String summary = kingdeeBillLockService.retryFailedLocks(); + log.info("定时补偿金蝶单据锁定:{}", summary); + } catch (Exception exception) { + log.error("定时补偿金蝶单据锁定异常", exception); + } + } +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/task/PaymentKingdeeResultTask.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/task/PaymentKingdeeResultTask.java new file mode 100644 index 00000000..29f0e88a --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/task/PaymentKingdeeResultTask.java @@ -0,0 +1,101 @@ +/** + * 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.config.BfiProperties; +import org.springblade.transport.service.IFormalSettlementService; +import org.springblade.transport.service.IInvoiceApplicationService; +import org.springblade.transport.service.IInvoiceReceiptService; +import org.springblade.transport.service.IPaymentApplicationService; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * 金蝶结果定时回写任务 + *

+ * 定时查询金蝶(星瀚)结果并回写 TMS 单据,编排三个子流程(各自独立 try/catch,互不影响): + * ① A103-2/A103-4 付款结果:回填付款申请单据编号、单据状态与付款记录; + * ② A302-4 应收勾稽纠偏:应收结算单回填金蝶应收单编号、生成失败纠偏为已同步; + * ③ A303-5 开票结果回写:开票申请发票结果落库并推进开票完成。 + * 各子流程业务逻辑收敛在对应服务方法,便于手动触发联调。 + *

+ * 另有金蝶收票单结果回写(A801-3,图05 环节②③异常回收/指定补偿)独立调度, + * 配置 thirdParty.bfi.receivedInvoiceResult.*。 + *

+ * 调度配置(Nacos thirdParty.bfi.payResult.*):syncEnabled 开关、cron 表达式。 + * + * @author Chill + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class PaymentKingdeeResultTask { + + private final IPaymentApplicationService paymentApplicationService; + private final IFormalSettlementService formalSettlementService; + private final IInvoiceApplicationService invoiceApplicationService; + private final IInvoiceReceiptService invoiceReceiptService; + private final BfiProperties bfiProperties; + + /** 金蝶结果回写调度,默认每10分钟执行一次 */ + @Scheduled(cron = "${thirdParty.bfi.payResult.cron:0 1/10 * * * ?}") + public void syncKingdeePaymentResult() { + if (!bfiProperties.getPayResult().isSyncEnabled()) { + return; + } + // ① A103-2/A103-4 付款结果回写 + try { + String summary = paymentApplicationService.syncKingdeePaymentResult(); + log.info("定时回写金蝶付款结果:{}", summary); + } catch (Exception exception) { + log.error("定时回写金蝶付款结果异常", exception); + } + // ② A302-4 应收勾稽纠偏 + try { + String summary = formalSettlementService.syncKingdeeArResult(); + log.info("定时勾稽金蝶应收单:{}", summary); + } catch (Exception exception) { + log.error("定时勾稽金蝶应收单异常", exception); + } + // ③ A303-5 开票结果回写 + try { + String summary = invoiceApplicationService.syncKingdeeInvoiceResult(); + log.info("定时回写金蝶开票结果:{}", summary); + } catch (Exception exception) { + log.error("定时回写金蝶开票结果异常", exception); + } + } + + /** 金蝶收票单结果回写调度(异常回收/指定补偿/单号补登),默认每10分钟执行一次 */ + @Scheduled(cron = "${thirdParty.bfi.receivedInvoiceResult.cron:0 8/10 * * * ?}") + public void syncKingdeeReceivedInvoiceResult() { + if (!bfiProperties.getReceivedInvoiceResult().isSyncEnabled()) { + return; + } + try { + String summary = invoiceReceiptService.syncKingdeeReceivedInvoiceResult(); + log.info("定时回写金蝶收票单结果:{}", summary); + } catch (Exception exception) { + log.error("定时回写金蝶收票单结果异常", exception); + } + } +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/task/ReceiptClaimPushTask.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/task/ReceiptClaimPushTask.java new file mode 100644 index 00000000..ec681cf7 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/task/ReceiptClaimPushTask.java @@ -0,0 +1,67 @@ +/** + * 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 + * is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for their own commercial purposes. + *

+ * Using this software signifies agreement to this license agreement and terms of use, + * and you expressly agree to be bound by the terms of use of this software. + *

+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED + * WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS + * FOR A PARTICULAR PURPOSE ARE DISCLAIMED. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.task; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springblade.transport.config.BfiProperties; +import org.springblade.transport.service.IReceiptFlowService; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * 收款认领推送金蝶定时任务(A301-3 收款认领结果推送) + *

+ * 认领动作只在本地登记,本任务扫描「认领完成且未推送过」的收款流水自动推送金蝶, + * 与收款流水页「推送金蝶」手动按钮共用同一服务方法(推送前按认领人重查 A301-2 最新公告对账)。 + * 单条流水推送失败独立消化、可重试,不影响同批其他流水。 + *

+ * 调度配置(Nacos thirdParty.bfi.receiptClaimPush.*):syncEnabled 开关、cron 表达式。 + * + * @author Chill + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ReceiptClaimPushTask { + + private final IReceiptFlowService receiptFlowService; + private final BfiProperties bfiProperties; + + /** 收款认领推送调度,默认每10分钟执行一次 */ + @Scheduled(cron = "${thirdParty.bfi.receiptClaimPush.cron:0 4/10 * * * ?}") + public void pushClaimToKingdee() { + if (!bfiProperties.getReceiptClaimPush().isSyncEnabled()) { + return; + } + try { + String summary = receiptFlowService.pushClaimByTask(); + log.info("定时推送收款认领:{}", summary); + } catch (Exception exception) { + log.error("定时推送收款认领异常", exception); + } + } +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/InvoiceApplicationWrapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/InvoiceApplicationWrapper.java index 5528198b..0a7fc051 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/InvoiceApplicationWrapper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/InvoiceApplicationWrapper.java @@ -58,6 +58,7 @@ public class InvoiceApplicationWrapper extends BaseEntityWrapper "未同步"; case "synced" -> "已同步"; + case "invoiced" -> "开票完成"; case "failed" -> "同步失败"; default -> entity.getKingdeeStatus(); }); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/InvoiceReceiptWrapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/InvoiceReceiptWrapper.java index e1039f0b..1c981d58 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/InvoiceReceiptWrapper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/InvoiceReceiptWrapper.java @@ -57,10 +57,11 @@ public class InvoiceReceiptWrapper extends BaseEntityWrapper "已作废"; default -> entity.getApprovalStatus(); }); - vo.setKingdeeStatusName(switch (entity.getKingdeeStatus() == null ? "" : entity.getKingdeeStatus()) { - case "synced" -> "已同步"; - case "failed" -> "同步失败"; - default -> "未同步"; + vo.setKingdeePushStatusName(switch (entity.getKingdeePushStatus() == null ? "" : entity.getKingdeePushStatus()) { + case "pushed" -> "收票单已保存"; + case "assigned" -> "已指定应付"; + case "failed" -> "推送失败"; + default -> "未推送"; }); return vo; } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/PaymentApplicationWrapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/PaymentApplicationWrapper.java index 2e0aa1f8..96b0ca82 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/PaymentApplicationWrapper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/PaymentApplicationWrapper.java @@ -22,6 +22,8 @@ package org.springblade.transport.wrapper; import org.springblade.core.mp.support.BaseEntityWrapper; import org.springblade.core.tool.utils.BeanUtil; import org.springblade.system.cache.UserCache; +import org.springblade.transport.constant.KingdeeBfiConstant; +import org.springblade.transport.constant.TransportBusiConstant; import org.springblade.transport.pojo.entity.PaymentApplication; import org.springblade.transport.pojo.vo.PaymentApplicationVO; @@ -38,7 +40,7 @@ public class PaymentApplicationWrapper extends BaseEntityWrapper "项目预付"; case "progress_advance" -> "进度预付"; - case "settlement_payment" -> "尾款付款"; + case TransportBusiConstant.PAYMENT_TYPE_SETTLEMENT -> "尾款付款"; default -> entity.getPaymentType(); }); vo.setApprovalStatusName(switch (entity.getApprovalStatus() == null ? "" : entity.getApprovalStatus()) { @@ -50,8 +52,11 @@ public class PaymentApplicationWrapper extends BaseEntityWrapper entity.getApprovalStatus(); }); vo.setKingdeeStatusName(switch (entity.getKingdeeStatus() == null ? "" : entity.getKingdeeStatus()) { - case "synced" -> "已生成"; - case "failed" -> "生成失败"; + case KingdeeBfiConstant.KINGDEE_STATUS_SYNCED -> "已生成"; + case KingdeeBfiConstant.KINGDEE_STATUS_PAYING -> "付款中"; + case KingdeeBfiConstant.KINGDEE_STATUS_PAID -> "已付款"; + case KingdeeBfiConstant.KINGDEE_STATUS_CLOSED -> "已关闭"; + case KingdeeBfiConstant.KINGDEE_STATUS_FAILED -> "生成失败"; default -> "未生成"; }); 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 1aba22da..b4212704 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 @@ -53,9 +53,27 @@ public class ReceiptFlowWrapper extends BaseEntityWrapper "认领完成"; default -> "未认领"; }); - BigDecimal receiptAmount = money(entity.getReceiptAmount()); + vo.setKingdeeClaimStatusName(switch (Objects.toString(entity.getKingdeeClaimStatus(), "")) { + case "1" -> "已认领"; + case "0" -> "未认领"; + default -> Objects.toString(entity.getKingdeeClaimStatus(), ""); + }); + vo.setPushStatusName(switch (Objects.toString(entity.getPushStatus(), "")) { + case "pushed" -> "已推送"; + case "failed" -> "推送失败"; + default -> "未推送"; + }); + vo.setKingdeeBillStatusName(switch (Objects.toString(entity.getKingdeeBillStatus(), "")) { + case "pushed" -> "已推送"; + case "approved" -> "审核通过"; + case "received" -> "已收款"; + case "failed" -> "处理失败"; + default -> "未推送"; + }); + // 剩余可认领金额 = 公告未认领金额 − 累计认领金额(认领上限为公告口径) + BigDecimal unclaimedAmount = money(entity.getUnclaimedAmount()); BigDecimal claimedAmount = money(entity.getClaimedAmount()); - vo.setRemainingAmount(receiptAmount.subtract(claimedAmount).max(BigDecimal.ZERO)); + vo.setRemainingAmount(unclaimedAmount.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/main/java/org/springblade/transport/wrapper/TransportVehicleWrapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/TransportVehicleWrapper.java index cf77a825..05e77a5b 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/TransportVehicleWrapper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/TransportVehicleWrapper.java @@ -48,7 +48,19 @@ public class TransportVehicleWrapper extends BaseEntityWrapper + * 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; + +import org.junit.jupiter.api.Test; +import org.springblade.transport.service.ICustomerArchiveService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +/** + * 客商档案金蝶客商数据定时同步测试:手动触发 syncKingdeeCustomerData 定时任务逻辑 + *

+ * 依赖本地环境(Nacos / 数据库 / Redis),需先启动对应中间件再执行; + * 该逻辑与定时任务 CustomerKingdeeSyncTask 共用,开关默认关闭时可通过本测试手动触发联调。 + * + * @author Chill + */ +@SpringBootTest(classes = TransportApplication.class, properties = { + "spring.profiles.active=dev", + "spring.main.allow-bean-definition-overriding=true" +}) +class CustomerArchiveSyncKingdeeDataTest { + + @Autowired + private ICustomerArchiveService customerArchiveService; + + @Test + void syncKingdeeCustomerData() { + String result = customerArchiveService.syncKingdeeCustomerData(); + System.out.println("金蝶客商数据定时同步执行结果:" + result); + } +} 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 new file mode 100644 index 00000000..9b0575b1 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/KingdeeEntrySplitTest.java @@ -0,0 +1,169 @@ +/** + * 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 the 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; + +import org.junit.jupiter.api.Test; +import org.springblade.transport.support.KingdeeApBillSyncSupport; +import org.springblade.transport.support.KingdeeApBillSyncSupport.FeeLineSplit; + +import java.math.BigDecimal; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +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; + +/** + * 金蝶应付/应收单分录拆分逻辑测试(纯函数,无环境依赖): + * 结算明细费用项按 费用大类(运输/装卸)→ 税率 拆分行,验证三个联调场景与残差勾稽 + * + * @author Chill + */ +class KingdeeEntrySplitTest { + + /** 场景1:同费用大类(运输)、多税率 → 按税率拆两行 */ + @Test + void splitFreightByTaxRate() { + Map feeItems = new LinkedHashMap<>(); + feeItems.put("运输费", new BigDecimal("2000")); + feeItems.put("附加运输补贴", new BigDecimal("1120")); + Map taxRateByRule = Map.of( + "运输费", new BigDecimal("9"), + "附加运输补贴", new BigDecimal("6")); + List splits = KingdeeApBillSyncSupport.splitFeeItems(feeItems, Map.of(), taxRateByRule, new BigDecimal("3120")); + assertEquals(2, splits.size()); + assertFalse(splits.get(0).isLoading()); + assertEquals(new BigDecimal("9"), splits.get(0).getTaxRate()); + assertEquals(new BigDecimal("2000"), splits.get(0).getAmount()); + assertEquals("-9", splits.get(0).taxRateSuffix()); + assertFalse(splits.get(1).isLoading()); + assertEquals(new BigDecimal("6"), splits.get(1).getTaxRate()); + assertEquals(new BigDecimal("1120"), splits.get(1).getAmount()); + assertEquals("-6", splits.get(1).taxRateSuffix()); + } + + /** 场景2:运输费 + 装卸费 → 运输行 + 装卸行(装卸行税率独立) */ + @Test + void splitFreightAndLoading() { + Map feeItems = new LinkedHashMap<>(); + feeItems.put("运输费", new BigDecimal("3120")); + feeItems.put("装卸费", new BigDecimal("800")); + Map taxRateByRule = Map.of( + "运输费", new BigDecimal("9"), + "装卸费", new BigDecimal("6")); + List splits = KingdeeApBillSyncSupport.splitFeeItems(feeItems, Map.of(), taxRateByRule, new BigDecimal("3920")); + assertEquals(2, splits.size()); + assertFalse(splits.get(0).isLoading()); + assertEquals(new BigDecimal("3120"), splits.get(0).getAmount()); + assertTrue(splits.get(1).isLoading()); + assertEquals(new BigDecimal("6"), splits.get(1).getTaxRate()); + assertEquals(new BigDecimal("800"), splits.get(1).getAmount()); + } + + /** 费用项档案分类判定:名称不含“装卸”的费用项,档案费用类型名称含“装卸”即归装卸大类 */ + @Test + void loadingCategoryFromFeeItemArchive() { + Map feeItems = Map.of("吊装作业", new BigDecimal("500")); + Map feeCategoryByName = Map.of("吊装作业", "装卸"); + List splits = KingdeeApBillSyncSupport.splitFeeItems(feeItems, feeCategoryByName, Map.of(), new BigDecimal("500")); + assertEquals(1, splits.size()); + assertTrue(splits.get(0).isLoading()); + } + + /** 残差勾稽:费用项JSON未含运费(历史数据)时,结算金额与费用项合计的差额并入运输类无税率组 */ + @Test + void residualGoesToFreightDefaultGroup() { + Map feeItems = new LinkedHashMap<>(); + feeItems.put("运输费", new BigDecimal("2000")); + List splits = KingdeeApBillSyncSupport.splitFeeItems(feeItems, Map.of(), Map.of("运输费", new BigDecimal("9")), new BigDecimal("2020")); + assertEquals(2, splits.size()); + assertEquals(new BigDecimal("2000"), splits.get(0).getAmount()); + assertFalse(splits.get(1).isLoading()); + assertNull(splits.get(1).getTaxRate()); + assertEquals(new BigDecimal("20"), splits.get(1).getAmount()); + assertEquals("", splits.get(1).taxRateSuffix()); + } + + /** 费用项JSON为空:整行结算金额归运输类无税率组单行 */ + @Test + void emptyFeeItemsFallbackToFreightLine() { + List splits = KingdeeApBillSyncSupport.splitFeeItems(Map.of(), Map.of(), Map.of(), new BigDecimal("3120")); + assertEquals(1, splits.size()); + assertFalse(splits.get(0).isLoading()); + assertNull(splits.get(0).getTaxRate()); + assertEquals(new BigDecimal("3120"), splits.get(0).getAmount()); + } + + /** 税率未配置的费用项归入无税率组,同大类同税率费用项金额合并 */ + @Test + void mergeSameCategorySameRate() { + Map feeItems = new LinkedHashMap<>(); + feeItems.put("装卸费", new BigDecimal("500")); + feeItems.put("装卸吊装费", new BigDecimal("300")); + feeItems.put("其他费用", new BigDecimal("100")); + List splits = KingdeeApBillSyncSupport.splitFeeItems(feeItems, Map.of(), Map.of(), new BigDecimal("900")); + assertEquals(2, splits.size()); + assertTrue(splits.get(0).isLoading()); + assertEquals(new BigDecimal("800"), splits.get(0).getAmount()); + assertNull(splits.get(0).getTaxRate()); + assertEquals("装卸费", splits.get(0).getFirstFeeItem()); + assertFalse(splits.get(1).isLoading()); + assertEquals(new BigDecimal("100"), splits.get(1).getAmount()); + } + + /** 0元费用项跳过:不生成0元分录(FJS2026101300005 结算单8条分录问题的回归用例) */ + @Test + void zeroAmountFeeItemsSkipped() { + Map feeItems = new LinkedHashMap<>(); + feeItems.put("运输费", new BigDecimal("3120")); + feeItems.put("附加运输补贴", BigDecimal.ZERO); + feeItems.put("装卸费", new BigDecimal("800")); + feeItems.put("仓储服务费", BigDecimal.ZERO); + Map taxRateByRule = Map.of( + "运输费", new BigDecimal("9"), + "附加运输补贴", new BigDecimal("6"), + "装卸费", new BigDecimal("6")); + List splits = KingdeeApBillSyncSupport.splitFeeItems(feeItems, Map.of(), taxRateByRule, new BigDecimal("3920")); + assertEquals(2, splits.size()); + assertFalse(splits.get(0).isLoading()); + assertEquals(new BigDecimal("3120"), splits.get(0).getAmount()); + assertTrue(splits.get(1).isLoading()); + assertEquals(new BigDecimal("800"), splits.get(1).getAmount()); + } + + /** 全部费用项为0且结算金额为0:拆分结果为空(调用方跳过该0元货物行) */ + @Test + void allZeroFeeItemsYieldEmptySplits() { + Map feeItems = new LinkedHashMap<>(); + feeItems.put("运输费", BigDecimal.ZERO); + feeItems.put("装卸费", BigDecimal.ZERO); + List splits = KingdeeApBillSyncSupport.splitFeeItems(feeItems, Map.of(), Map.of(), BigDecimal.ZERO); + assertTrue(splits.isEmpty()); + } +} 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/support/OcrFieldSupportTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/OcrFieldSupportTest.java index 52530a56..f20619d8 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 单位也要能取到数字"); @@ -160,7 +160,7 @@ class OcrFieldSupportTest { { "words_result": { "道路运输证号": {"words": "桂交运管桂字450100123456号"}, - "业户名称": {"words": "桂物物流集团"}, + "业户名称": {"words": "广西现代物流集团有限公司"}, "有效期": {"words": "2020-01-01 至 2030-01-01"} } } @@ -169,7 +169,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-third-party-api/blade-bfi-api/pom.xml b/blade-third-party-api/blade-bfi-api/pom.xml new file mode 100644 index 00000000..853c361e --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/pom.xml @@ -0,0 +1,38 @@ + + + 4.0.0 + + org.springblade + blade-third-party-api + ${revision} + + + blade-bfi-api + ${project.artifactId} + jar + BladeX 业财融合(BFI)API 契约模块 + + + + org.springblade + blade-starter-redis + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + + + + diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/config/BfiFeignClientConfig.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/config/BfiFeignClientConfig.java new file mode 100644 index 00000000..535935c7 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/config/BfiFeignClientConfig.java @@ -0,0 +1,74 @@ +/** + * 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 feign.Request; +import feign.RequestInterceptor; +import feign.codec.Encoder; +import feign.form.spring.SpringFormEncoder; +import org.springblade.thirdparty.bfi.interceptor.BfiRequestInterceptor; +import org.springframework.beans.factory.ObjectFactory; +import org.springframework.boot.autoconfigure.http.HttpMessageConverters; +import org.springframework.cloud.openfeign.support.SpringEncoder; +import org.springframework.context.annotation.Bean; + +import java.util.concurrent.TimeUnit; + +/** + * 业财融合(BFI)Feign 客户端配置 + *

+ * 仅通过 {@link org.springframework.cloud.openfeign.FeignClient#configuration} 引用,避免拦截器全局生效 + * + * @author Chill + */ +public class BfiFeignClientConfig { + + @Bean + public RequestInterceptor bladeFeignRequestInterceptor() { + // 空实现覆盖全局 BladeFeignRequestInterceptor,防止其覆盖 BFI 的 Authorization header + return template -> {}; + } + + @Bean + public RequestInterceptor bfiRequestInterceptor(BfiProperties properties) { + return new BfiRequestInterceptor(properties); + } + + @Bean + public feign.Logger.Level feignLoggerLevel() { + return feign.Logger.Level.FULL; + } + + @Bean + public Request.Options options() { + return new Request.Options(10, TimeUnit.SECONDS, 60, TimeUnit.SECONDS, true); + } + + @Bean + public Encoder feignFormEncoder(ObjectFactory messageConverters) { + return new SpringFormEncoder(new SpringEncoder(messageConverters)); + } +} 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 new file mode 100644 index 00000000..dd979537 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/config/BfiProperties.java @@ -0,0 +1,71 @@ +/** + * 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 lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * 业财融合(BFI)配置类 + * + * @author Chill + */ +@Data +@ConfigurationProperties(prefix = "third-party.bfi") +public class BfiProperties { + /** + * 业财融合(BFI)接口基础地址,如 http://ip:port/gwzh/BFI + */ + private String baseUrl; + /** + * 网关认证请求头 Authorization 的值 + */ + private String authorization; + /** + * 第三方应用系统编码,即 appId + */ + private String clientId; + /** + * 第三方应用 AccessToken 认证密钥,即 appSecret + */ + private String clientSecret; + /** + * 访问用户名 + */ + private String username; + /** + * 数据中心ID + */ + private String accountId; + /** + * 语言字串,默认 zh_CN + */ + private String language = "zh_CN"; + /** + * 是否启用 mock 模式(开发调试用) + */ + private boolean mockEnabled = false; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/config/ThirdPartyBfiAutoConfiguration.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/config/ThirdPartyBfiAutoConfiguration.java new file mode 100644 index 00000000..2f32dc10 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/config/ThirdPartyBfiAutoConfiguration.java @@ -0,0 +1,41 @@ +/** + * 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.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; + +/** + * 第三方API 业财融合(BFI)配置类 + * + * @author Chill + */ +@ComponentScan("org.springblade.thirdparty.bfi") +@EnableConfigurationProperties(BfiProperties.class) +@Configuration +public class ThirdPartyBfiAutoConfiguration { +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/constant/BfiConstant.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/constant/BfiConstant.java new file mode 100644 index 00000000..608c39b2 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/constant/BfiConstant.java @@ -0,0 +1,539 @@ +/** + * 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.constant; + +import java.util.Map; + +/** + * 业财融合(BFI)常量类 + * + * @author Chill + */ +public class BfiConstant { + + /** + * 接口调用成功错误码 + */ + public static final String SUCCESS_CODE = "0"; + + /** + * 收票 rim/message 契约调用成功错误码 + */ + public static final String RIM_SUCCESS_CODE = "0000"; + + /** + * A801-1 查询全票池发票 消息类型,固定值 + */ + public static final String MESSAGE_TYPE_INVOICE_QUERY = "invoiceQuery"; + + /** + * 网关认证请求头名称 + */ + public static final String HEADER_AUTHORIZATION = "Authorization"; + + /** + * 访问令牌请求头名称 + */ + public static final String HEADER_ACCESS_TOKEN = "access_token"; + + /** + * 令牌类型 hint:access_token + */ + public static final String TOKEN_TYPE_HINT_ACCESS = "access_token"; + + /** + * 令牌类型 hint:id_token + */ + public static final String TOKEN_TYPE_HINT_ID = "id_token"; + + /** + * 默认语言 + */ + public static final String LANGUAGE_ZH_CN = "zh_CN"; + + /** + * BFI access_token 缓存key + */ + public static final String BFI_TOKEN_CACHE_KEY = "blade:third:bfi:token"; + + /** + * token 提前失效缓冲时长(毫秒),防止刚好过期 + */ + public static final long TOKEN_EXPIRE_BUFFER_MILLIS = 120_000L; + + /** + * A801-5 黑名单查询页大小上限,取值范围[1, 2000]的正整数 + */ + public static final int BLACKLIST_QUERY_PAGE_SIZE = 2000; + + /** + * A303-5 开票申请发票查询 接口编码,固定值 + */ + public static final String INVOICE_APPLY_OPEN_API_CODE = "BILL.INVOICE.QUERY"; + + // ==================== 单据类型 ==================== + + /** + * 财务应付单单据类型:标准采购应付 + */ + public static final String AP_BILL_TYPE_PURCHASE = "ApFin_pur_BT_S"; + + /** + * 财务应付单单据类型:费用采购应付 + */ + public static final String AP_BILL_TYPE_FEE = "ApFin_fee_BT_S"; + + /** + * 财务应付单单据类型:采购费用应付 + */ + public static final String AP_BILL_TYPE_PUR_FEE = "ApFin_purfee_BT_S"; + + /** + * 财务应付单单据类型:资产采购应付 + */ + public static final String AP_BILL_TYPE_ASSET = "ap_finapbill_asset_BT_S"; + + /** + * 财务应付单单据类型:服务采购应付 + */ + public static final String AP_BILL_TYPE_SERVICE = "ApFin_service_BT_S"; + + /** + * 财务应付单单据类型:产品委外应付 + */ + public static final String AP_BILL_TYPE_PRODUCT = "ApFin_product_BT_S"; + + /** + * 财务应付单单据类型:其他应付 + */ + public static final String AP_BILL_TYPE_OTHER = "ApFin_other_BT_S"; + + /** + * 财务应付单单据类型:借贷项调整 + */ + public static final String AP_BILL_TYPE_BORROW = "ApFin_borr_BT_S"; + + /** + * 财务应收单单据类型:标准销售应收 + */ + public static final String AR_BILL_TYPE_STANDARD = "arfin_standard_BT_S"; + + /** + * 财务应收单单据类型:服务销售应收 + */ + public static final String AR_BILL_TYPE_SERVICE = "arfin_sersal_BT_S"; + + /** + * 财务应收单单据类型:销售费用应收 + */ + public static final String AR_BILL_TYPE_SALE_FEE = "arfin_salefee_BT_S"; + + /** + * 财务应收单单据类型:其他应收 + */ + public static final String AR_BILL_TYPE_OTHER = "arfin_other_BT_S"; + + /** + * 财务应收单单据类型:借贷项调整 + */ + public static final String AR_BILL_TYPE_BORROW = "arfin_borrowar_BT_S"; + + /** + * 付款申请单据类型:采购付款申请 + */ + public static final String PAY_APPLY_TYPE_PURCHASE = "ap_payapply_BT_S"; + + /** + * 付款申请单据类型:其他付款申请 + */ + public static final String PAY_APPLY_TYPE_OTHER = "ap_payapply_oth_BT_S"; + + // ==================== 往来与付款 ==================== + + /** + * 往来类型:供应商 + */ + public static final String ASSTACT_TYPE_SUPPLIER = "bd_supplier"; + + /** + * 往来类型:人员 + */ + public static final String ASSTACT_TYPE_USER = "bos_user"; + + /** + * 往来类型:客户 + */ + public static final String ASSTACT_TYPE_CUSTOMER = "bd_customer"; + + /** + * 往来类型:公司 + */ + public static final String ASSTACT_TYPE_ORG = "bos_org"; + + /** + * 往来类型:其他 + */ + public static final String ASSTACT_TYPE_OTHER = "other"; + + /** + * 付款方式:现购/现销,系统自动100%推付款 + */ + public static final String PAY_MODE_CASH = "CASH"; + + /** + * 付款方式:赊购/赊销,可自由调整后续付款金额 + */ + public static final String PAY_MODE_CREDIT = "CREDIT"; + + /** + * 付款类型:正式付款(采购付款) + */ + public static final String PAYMENT_TYPE_FORMAL = "201"; + + /** + * 付款类型:预付款 + */ + public static final String PAYMENT_TYPE_PREPAY = "202"; + + // ==================== 单据状态 ==================== + + /** + * 数据状态:暂存 + */ + public static final String BILL_STATUS_DRAFT = "A"; + + /** + * 数据状态:已提交 + */ + public static final String BILL_STATUS_SUBMITTED = "B"; + + /** + * 数据状态:已审核 + */ + public static final String BILL_STATUS_AUDITED = "C"; + + /** + * 数据状态:已收款/已付款 + */ + public static final String BILL_STATUS_RECEIVED = "D"; + + /** + * 数据状态:变更中 + */ + public static final String BILL_STATUS_CHANGING = "E"; + + /** + * 数据状态:已退单 + */ + public static final String BILL_STATUS_RETURNED = "G"; + + /** + * 结算状态:未结算 + */ + public static final String SETTLE_STATUS_UNSETTLE = "unsettle"; + + /** + * 结算状态:部分结算 + */ + public static final String SETTLE_STATUS_PART = "partsettle"; + + /** + * 结算状态:全部结算 + */ + public static final String SETTLE_STATUS_SETTLED = "settled"; + + // ==================== 组织与基础资料 ==================== + + /** + * 数据来源系统:运输TMS + */ + public static final String DATA_SOURCE_WLTMS = "WLTMS"; + + /** + * 数据来源系统:星瀚 + */ + public static final String DATA_SOURCE_XH = "XH"; + + /** + * 数据来源系统:城矿ERP + */ + public static final String DATA_SOURCE_WCCK = "WCCK"; + + /** + * 是否为贸易客商:是 + */ + public static final String CUS_FOR_TRADE_YES = "A"; + + /** + * 是否为贸易客商:否 + */ + public static final String CUS_FOR_TRADE_NO = "B"; + + /** + * 客户分类标准:基本分类标准 + */ + public static final String GROUP_STANDARD_BASIC = "JBFLBZ"; + + /** + * 客户分类:外部客户 + */ + public static final String CUSTOMER_GROUP_EXTERNAL = "1102"; + + /** + * 客户分类:内部组织 + */ + public static final String CUSTOMER_GROUP_INTERNAL = "1101"; + + /** + * 币种:人民币 + */ + public static final String CURRENCY_CNY = "CNY"; + + /** + * 汇率表默认编码 + */ + public static final String EXRATE_TABLE_DEFAULT = "01"; + + /** + * 税率编码:13% + */ + public static final String TAX_RATE_V13 = "V13"; + + /** + * 结算方式:银行转账 + */ + public static final String SETTLE_TYPE_BANK = "02"; + + /** + * 结算方式:现金 + */ + public static final String SETTLE_TYPE_CASH = "01"; + + /** + * 结算方式:应收票据(纸质银行承兑汇票) + */ + public static final String SETTLE_TYPE_RECEIVABLE_BANK_PAPER = "010-01"; + + /** + * 结算方式:应收票据(电子银行承兑汇票) + */ + public static final String SETTLE_TYPE_RECEIVABLE_BANK_ELECTRONIC = "010-02"; + + /** + * 结算方式:应收票据(纸质商业承兑汇票) + */ + public static final String SETTLE_TYPE_RECEIVABLE_COMMERCIAL_PAPER = "011-01"; + + /** + * 结算方式:应收票据(电子商业承兑汇票) + */ + public static final String SETTLE_TYPE_RECEIVABLE_COMMERCIAL_ELECTRONIC = "011-02"; + + /** + * 结算方式:应付票据(纸质银行承兑汇票) + */ + public static final String SETTLE_TYPE_PAYABLE_BANK_PAPER = "012-01"; + + /** + * 结算方式:应付票据(电子银行承兑汇票) + */ + public static final String SETTLE_TYPE_PAYABLE_BANK_ELECTRONIC = "012-02"; + + /** + * 结算方式:应付票据(纸质商业承兑汇票) + */ + public static final String SETTLE_TYPE_PAYABLE_COMMERCIAL_PAPER = "013-01"; + + /** + * 结算方式:应付票据(电子商业承兑汇票) + */ + public static final String SETTLE_TYPE_PAYABLE_COMMERCIAL_ELECTRONIC = "013-02"; + + /** + * 结算方式:纸质转账支票 + */ + public static final String SETTLE_TYPE_TRANSFER_CHEQUE_PAPER = "014-01"; + + /** + * 结算方式:电子转账支票 + */ + public static final String SETTLE_TYPE_TRANSFER_CHEQUE_ELECTRONIC = "014-02"; + + /** + * 结算方式:纸质现金支票 + */ + public static final String SETTLE_TYPE_CASH_CHEQUE_PAPER = "014-03"; + + /** + * 结算方式:应收票据(国际信用证) + */ + public static final String SETTLE_TYPE_RECEIVABLE_LC_INTERNATIONAL = "015-01"; + + /** + * 结算方式:应收票据(国内信用证) + */ + public static final String SETTLE_TYPE_RECEIVABLE_LC_DOMESTIC = "015-02"; + + /** + * 结算方式:信用证开立 + */ + public static final String SETTLE_TYPE_LC_OPEN = "016-00"; + + /** + * 结算方式:应付票据(进口信用证) + */ + public static final String SETTLE_TYPE_PAYABLE_LC_IMPORT = "016-01"; + + /** + * 结算方式:应付票据(国内信用证) + */ + public static final String SETTLE_TYPE_PAYABLE_LC_DOMESTIC = "016-02"; + + /** + * 结算方式:银行存款-金蝶银企 + */ + public static final String SETTLE_TYPE_BANK_ENTERPRISE = "03"; + + /** + * 结算方式:数币钱包 + */ + public static final String SETTLE_TYPE_DIGITAL_WALLET = "09-01"; + + /** + * 结算方式:支付宝 + */ + public static final String SETTLE_TYPE_ALIPAY = "09-02"; + + /** + * 结算方式:微信 + */ + public static final String SETTLE_TYPE_WECHAT = "09-03"; + + /** + * 款项性质:材料采购 + */ + public static final String PAY_PROPERTY_MATERIAL = "2001"; + + /** + * 款项性质:一般销售 + */ + public static final String PAY_PROPERTY_SALE = "1001"; + + /** + * 项目兜底编码:不涉及项目时传999 + */ + public static final String PROJECT_NOT_APPLICABLE = "999"; + + /** + * 外部单据类型:各保存接口示例固定值 + */ + public static final String AWRP_SRC_TYPE_EXTERNAL = "0KzXs"; + + // ==================== 票据 ==================== + + /** + * 收票业务类型:采购 + */ + public static final String RECEIVED_BIZ_TYPE_PUR = "PUR"; + + /** + * 收票业务类型:费用 + */ + public static final String RECEIVED_BIZ_TYPE_FEE = "FEE"; + + /** + * 收票发票类型:数电发票(普通发票) + */ + public static final String RECEIVED_INVOICE_TYPE_DIGITAL_GENERAL = "26"; + + /** + * 收票发票类型:数电发票(增值税专用发票) + */ + public static final String RECEIVED_INVOICE_TYPE_DIGITAL_SPECIAL = "27"; + + /** + * 发票种类:数电发票(普通发票) + */ + public static final String INVOICE_TYPE_DIGITAL_GENERAL = "10xdp"; + + /** + * 发票种类:数电发票(增值税专用发票) + */ + public static final String INVOICE_TYPE_DIGITAL_SPECIAL = "08xdp"; + + /** + * 开票申请数据来源:固定3 + */ + public static final String INVOICE_BILL_SOURCE = "3"; + + /** + * 预付信息单据类型:付款处理 + */ + public static final String PREPAY_BILL_TYPE_PAY_BILL = "cas_paybill"; + + /** + * 预付信息单据类型:期初预付单 + */ + public static final String PREPAY_BILL_TYPE_INIT_PAID = "ap_paidbill"; + + /** + * 发票类型编码 -> 名称,编码明细参考票易通文档《发票数据结构》 + */ + public static final Map INVOICE_TYPE_NAMES = Map.ofEntries( + Map.entry("1", "增值税电子普通发票"), + Map.entry("2", "增值税电子专用发票"), + Map.entry("3", "增值税普通发票"), + Map.entry("4", "增值税专用发票"), + Map.entry("5", "增值税普通发票(卷票)"), + Map.entry("7", "通用机打发票"), + Map.entry("8", "出租车票"), + Map.entry("9", "火车票"), + Map.entry("10", "飞机票"), + Map.entry("11", "其他票"), + Map.entry("12", "机动车销售统一发票"), + Map.entry("13", "二手车销售统一发票"), + Map.entry("14", "定额发票"), + Map.entry("15", "增值税电子普通发票(通行费)"), + Map.entry("16", "汽车票"), + Map.entry("17", "过路(过桥)费发票"), + Map.entry("19", "税收完税证明"), + Map.entry("20", "轮船票"), + Map.entry("21", "海关缴款书"), + Map.entry("23", "通用机打电子发票"), + Map.entry("24", "火车票退票凭证"), + Map.entry("25", "财政电子票据"), + Map.entry("26", "数电发票(普通发票)"), + Map.entry("27", "数电发票(增值税专用发票)"), + Map.entry("28", "数电发票(航空运输电子客票行程单)"), + Map.entry("29", "数电发票(铁路电子客票)"), + Map.entry("30", "海外发票"), + Map.entry("31", "海外收据"), + Map.entry("47", "增值税代扣代缴完税凭证"), + Map.entry("72", "数电发票(通行费发票)"), + Map.entry("83", "数电发票(机动车销售统一发票)"), + Map.entry("84", "数电发票(二手车销售统一发票)") + ); +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/exception/BfiException.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/exception/BfiException.java new file mode 100644 index 00000000..8a9a7e0f --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/exception/BfiException.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.thirdparty.bfi.exception; + +/** + * 业财融合(BFI)接口异常 + * + * @author Chill + */ +public class BfiException extends RuntimeException { + + public BfiException(String message) { + super(message); + } + + public BfiException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/exception/BfiExceptionTranslator.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/exception/BfiExceptionTranslator.java new file mode 100644 index 00000000..76844d04 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/exception/BfiExceptionTranslator.java @@ -0,0 +1,54 @@ +/** + * 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.exception; + +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springblade.core.tool.api.R; +import org.springblade.core.tool.api.ResultCode; +import org.springblade.core.tool.utils.Func; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +/** + * 业财融合(BFI)异常转换器 + * + * @author Chill + */ +@AllArgsConstructor +@Slf4j +@RestControllerAdvice +public class BfiExceptionTranslator { + + @ExceptionHandler(BfiException.class) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public R handleError(BfiException e) { + log.error("业财融合接口异常", e); + return R.fail(ResultCode.INTERNAL_SERVER_ERROR, (Func.isEmpty(e.getMessage()) ? ResultCode.INTERNAL_SERVER_ERROR.getMessage() : e.getMessage())); + } +} 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 new file mode 100644 index 00000000..fea916a7 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/feign/IBfiClient.java @@ -0,0 +1,506 @@ +/** + * 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.feign; + +import feign.Response; +import org.springblade.thirdparty.bfi.config.BfiFeignClientConfig; +import org.springblade.thirdparty.bfi.constant.BfiConstant; +import org.springblade.thirdparty.bfi.pojo.dto.BfiApBillSaveBodyDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiArBillSaveBodyDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiBillLockDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiBlacklistQueryDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiClaimPushBodyDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiCustomerSaveBodyDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiExchangeRateQueryDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceApplyOpenApiBodyDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceApplySaveBodyDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceAssignBodyDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceDownloadDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoicePoolQueryDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiPayApplyPushBodyDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiPayApplySaveBodyDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiReceivedInvoiceQueryDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiReceivedInvoiceSaveBodyDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiTokenDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiVerifyTokenDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiWithdrawTokenDTO; +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.BfiBillSaveResultVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiBlacklistVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiClaimAnnounceVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiCustomerVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiExchangeRateVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiInvoiceApplyVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiInvoicePoolVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiOpenApiResultVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiPageVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiPayApplyVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiPayBillVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiRecBillVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiReceivedInvoiceVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiResultVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiTokenVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiVerifyTokenVO; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.cloud.openfeign.SpringQueryMap; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +/** + * 业财融合(BFI)接口 + * + * @author Chill + */ +@FeignClient(name = "BFI", url = "${thirdParty.bfi.baseUrl}", configuration = BfiFeignClientConfig.class) +public interface IBfiClient { + + String GET_TOKEN_URL = "${thirdParty.bfi.getTokenUrl:/ierp/kapi/oauth2/getToken}"; + String VERIFY_TOKEN_URL = "${thirdParty.bfi.verifyTokenUrl:/ierp/kapi/oauth2/verifyToken}"; + String WITHDRAW_TOKEN_URL = "${thirdParty.bfi.withdrawTokenUrl:/ierp/kapi/oauth2/withdrawToken}"; + String FOREX_QUERY_URL = "${thirdParty.bfi.forexQueryUrl:/ierp/kapi/v2/awrp/base/bd_exrate_tree/Forexquery}"; + String PAY_APPLY_PUSH_URL = "${thirdParty.bfi.payApplyPushUrl:/ierp/kapi/v2/awrp/ap/ap/ap_finapbill/push}"; + String PAY_APPLY_QUERY_URL = "${thirdParty.bfi.payApplyQueryUrl:/ierp/kapi/v2/awrp/ap/ap_payapply/query}"; + String PRE_PAY_APPLY_SAVE_URL = "${thirdParty.bfi.prePayApplySaveUrl:/ierp/kapi/v2/awrp/ap/ap/ap_payapply/save}"; + String PAY_BILL_QUERY_URL = "${thirdParty.bfi.payBillQueryUrl:/ierp/kapi/v2/awrp/cas/cas_paybill/query}"; + String CUSTOMER_QUERY_URL = "${thirdParty.bfi.customerQueryUrl:/ierp/kapi/v2/awrp/basedata/bd_customer/query}"; + String CUSTOMER_SAVE_URL = "${thirdParty.bfi.customerSaveUrl:/ierp/kapi/v2/awrp/basedata/bd_customer/save}"; + String AP_BILL_SAVE_URL = "${thirdParty.bfi.apBillSaveUrl:/ierp/kapi/v2/awrp/ap/ap/ap_finapbill/save}"; + String AP_BILL_QUERY_URL = "${thirdParty.bfi.apBillQueryUrl:/ierp/kapi/v2/awrp/ap/ap_finapbill/query}"; + String AR_BILL_SAVE_URL = "${thirdParty.bfi.arBillSaveUrl:/ierp/kapi/v2/awrp/ar/ar/ar_finarbill/save}"; + String AR_BILL_QUERY_URL = "${thirdParty.bfi.arBillQueryUrl:/ierp/kapi/v2/awrp/ar/ar_finarbill/query}"; + String INVOICE_ASSIGN_URL = "${thirdParty.bfi.invoiceAssignUrl:/ierp/kapi/v2/awrp/ap/ap/ap_invoice/assign}"; + String INVOICE_APPLY_SAVE_URL = "${thirdParty.bfi.invoiceApplySaveUrl:/ierp/kapi/v2/awrp/sim/sim_original_bill/save}"; + String INVOICE_APPLY_QUERY_URL = "${thirdParty.bfi.invoiceApplyQueryUrl:/ierp/kapi/v2/awrp/sim/sim_original_bill/query}"; + String INVOICE_APPLY_OPEN_API_URL = "${thirdParty.bfi.invoiceApplyOpenApiUrl:/ierp/kapi/app/sim/openApi}"; + String RECEIVED_INVOICE_SAVE_URL = "${thirdParty.bfi.receivedInvoiceSaveUrl:/ierp/kapi/v2/awrp/ap/ap_invoice/cusBatchSave}"; + String RECEIVED_INVOICE_QUERY_URL = "${thirdParty.bfi.receivedInvoiceQueryUrl:/ierp/kapi/v2/awrp/ap/ap_invoice/apinvoicequery}"; + String REC_BILL_QUERY_URL = "${thirdParty.bfi.recBillQueryUrl:/ierp/kapi/v2/awrp/cas/cas_recbill/queryRecBill}"; + String CLAIM_ANNOUNCE_QUERY_URL = "${thirdParty.bfi.claimAnnounceQueryUrl:/ierp/kapi/v2/awrp/cas/cas_claimannounce/query}"; + String CLAIM_BILL_PUSH_URL = "${thirdParty.bfi.claimBillPushUrl:/ierp/kapi/v2/awrp/cas/cas/cas_claimbill/claimbillPush}"; + String INVOICE_POOL_QUERY_URL = "${thirdParty.bfi.invoicePoolQueryUrl:/ierp/kapi/v2/awrp/rim/rim_invoice/query}"; + String INVOICE_DOWNLOAD_URL = "${thirdParty.bfi.invoiceDownloadUrl:/ierp/kapi/v2/awrp/bosext/attachment/download}"; + String ATTACHMENT_UPLOAD_URL = "${thirdParty.bfi.attachmentUploadUrl:/ierp/kapi/v2/frame/attachment/uploadFile}"; + String BLACKLIST_QUERY_URL = "${thirdParty.bfi.blacklistQueryUrl:/ierp/kapi/v2/awrp/bdm/bdm_blacklist_config/blacklistquery}"; + String LOCK_BILL_URL = "${thirdParty.bfi.billLockUrl:/API_lockAction_Router}"; + + /** + * A100-1 获取令牌 + * + * @param param 请求参数 + * @return 令牌信息 + */ + @PostMapping(GET_TOKEN_URL) + BfiResultVO getToken(@RequestBody BfiTokenDTO param); + + /** + * A100-2 校验令牌 + * + * @param param 请求参数 + * @return 校验结果 + */ + @PostMapping(VERIFY_TOKEN_URL) + BfiResultVO verifyToken(@RequestBody BfiVerifyTokenDTO param); + + /** + * A100-3 撤回令牌 + * + * @param param 请求参数 + * @return 撤回结果 + */ + @PostMapping(WITHDRAW_TOKEN_URL) + BfiResultVO withdrawToken(@RequestBody BfiWithdrawTokenDTO param); + + /** + * A801-4 汇率查询 + * + * @param accessToken 访问令牌,通过 getToken 获取 + * @param param 请求参数 + * @return 汇率列表 + */ + @GetMapping(FOREX_QUERY_URL) + BfiResultVO> forexQuery(@RequestHeader(BfiConstant.HEADER_ACCESS_TOKEN) String accessToken, @SpringQueryMap BfiExchangeRateQueryDTO param); + + /** + * A801-5 黑名单查询(分页,pageSize 取值范围[1, 2000]的正整数) + * + * @param accessToken 访问令牌,通过 getToken 获取 + * @param param 请求参数 + * @return 黑名单分页数据 + */ + @GetMapping(BLACKLIST_QUERY_URL) + BfiResultVO> blacklistQuery(@RequestHeader(BfiConstant.HEADER_ACCESS_TOKEN) String accessToken, @SpringQueryMap BfiBlacklistQueryDTO param); + + /** + * A103-1 付款申请保存(根据星瀚应付下推付款申请) + * + * @param accessToken 访问令牌,通过 getToken 获取 + * @param param 请求参数 + * @return 保存结果 + */ + @PostMapping(PAY_APPLY_PUSH_URL) + BfiResultVO payApplyPush(@RequestHeader(BfiConstant.HEADER_ACCESS_TOKEN) String accessToken, @RequestBody BfiPayApplyPushBodyDTO param); + + /** + * A103-2 付款申请单查询 + * + * @param accessToken 访问令牌,通过 getToken 获取 + * @param pageNo 页码,从1开始 + * @param pageSize 页数 + * @param awrpSrcCode 来源单据号 + * @param awrpSrcSys 系统编号 + * @param modifytimeS 修改时间-开始 + * @param modifytimeE 修改时间-结束 + * @return 付款申请单分页数据 + */ + @GetMapping(PAY_APPLY_QUERY_URL) + BfiResultVO> payApplyQuery(@RequestHeader(BfiConstant.HEADER_ACCESS_TOKEN) String accessToken, + @RequestParam("pageNo") Integer pageNo, + @RequestParam("pageSize") Integer pageSize, + @RequestParam(value = "awrp_src_code", required = false) List awrpSrcCode, + @RequestParam(value = "awrp_src_sys", required = false) String awrpSrcSys, + @RequestParam(value = "modifytimeS", required = false) String modifytimeS, + @RequestParam(value = "modifytimeE", required = false) String modifytimeE); + + /** + * A103-3 预付款申请(付款申请保存) + * + * @param accessToken 访问令牌,通过 getToken 获取 + * @param param 请求参数 + * @return 保存结果 + */ + @PostMapping(PRE_PAY_APPLY_SAVE_URL) + BfiResultVO prePayApplySave(@RequestHeader(BfiConstant.HEADER_ACCESS_TOKEN) String accessToken, @RequestBody BfiPayApplySaveBodyDTO param); + + /** + * A103-4 付款处理查询 + * + * @param accessToken 访问令牌,通过 getToken 获取 + * @param pageNo 页码,从1开始 + * @param pageSize 页数 + * @param beginBizdate 开始业务日期 + * @param endBizdate 结束业务日期 + * @param billno 单据编号 + * @param orgNumber 付款人(公司).编码 + * @return 付款处理分页数据 + */ + @GetMapping(PAY_BILL_QUERY_URL) + BfiResultVO> payBillQuery(@RequestHeader(BfiConstant.HEADER_ACCESS_TOKEN) String accessToken, + @RequestParam("pageNo") Integer pageNo, + @RequestParam("pageSize") Integer pageSize, + @RequestParam("begin_bizdate") String beginBizdate, + @RequestParam("end_bizdate") String endBizdate, + @RequestParam(value = "billno", required = false) List billno, + @RequestParam("org_number") List orgNumber); + + /** + * A902-1 客户查询 + * + * @param accessToken 访问令牌,通过 getToken 获取 + * @param pageNo 页码,从1开始 + * @param pageSize 页数 + * @param enable 使用状态 + * @param status 数据状态 + * @param number 编码 + * @param createorgNumber 创建组织.编码 + * @param societycreditcode 统一社会信用代码 + * @param idno 身份证号 + * @param modifytimeS 修改时间-开始 + * @param modifytimeE 修改时间-结束 + * @return 客户分页数据 + */ + @GetMapping(CUSTOMER_QUERY_URL) + BfiResultVO> customerQuery(@RequestHeader(BfiConstant.HEADER_ACCESS_TOKEN) String accessToken, + @RequestParam("pageNo") Integer pageNo, + @RequestParam("pageSize") Integer pageSize, + @RequestParam(value = "enable", required = false) String enable, + @RequestParam(value = "status", required = false) String status, + @RequestParam(value = "number", required = false) List number, + @RequestParam(value = "createorg_number", required = false) String createorgNumber, + @RequestParam(value = "societycreditcode", required = false) List societycreditcode, + @RequestParam(value = "idno", required = false) List idno, + @RequestParam(value = "modifytimeS", required = false) String modifytimeS, + @RequestParam(value = "modifytimeE", required = false) String modifytimeE); + + /** + * A902-2 客户保存 + * + * @param accessToken 访问令牌,通过 getToken 获取 + * @param param 请求参数 + * @return 保存结果 + */ + @PostMapping(CUSTOMER_SAVE_URL) + BfiResultVO customerSave(@RequestHeader(BfiConstant.HEADER_ACCESS_TOKEN) String accessToken, @RequestBody BfiCustomerSaveBodyDTO param); + + /** + * A102-2 财务应付单保存 + * + * @param accessToken 访问令牌,通过 getToken 获取 + * @param param 请求参数 + * @return 保存结果 + */ + @PostMapping(AP_BILL_SAVE_URL) + BfiResultVO apBillSave(@RequestHeader(BfiConstant.HEADER_ACCESS_TOKEN) String accessToken, @RequestBody BfiApBillSaveBodyDTO param); + + /** + * A102-4 财务应付单查询 + * + * @param accessToken 访问令牌,通过 getToken 获取 + * @param pageNo 页码,从1开始 + * @param pageSize 页数 + * @param awrpSrcCode 来源单据号 + * @param awrpSrcSys 系统编号 + * @param modifytimeS 修改时间-开始 + * @param modifytimeE 修改时间-结束 + * @param billstatus 单据状态 + * @param settlestatus 结算状态 + * @return 财务应付单分页数据 + */ + @GetMapping(AP_BILL_QUERY_URL) + BfiResultVO> apBillQuery(@RequestHeader(BfiConstant.HEADER_ACCESS_TOKEN) String accessToken, + @RequestParam("pageNo") Integer pageNo, + @RequestParam("pageSize") Integer pageSize, + @RequestParam(value = "awrp_src_code", required = false) List awrpSrcCode, + @RequestParam(value = "awrp_src_sys", required = false) String awrpSrcSys, + @RequestParam(value = "modifytimeS", required = false) String modifytimeS, + @RequestParam(value = "modifytimeE", required = false) String modifytimeE, + @RequestParam(value = "billstatus", required = false) List billstatus, + @RequestParam(value = "settlestatus", required = false) List settlestatus); + + /** + * A302-2 财务应收单保存 + * + * @param accessToken 访问令牌,通过 getToken 获取 + * @param param 请求参数 + * @return 保存结果 + */ + @PostMapping(AR_BILL_SAVE_URL) + BfiResultVO arBillSave(@RequestHeader(BfiConstant.HEADER_ACCESS_TOKEN) String accessToken, @RequestBody BfiArBillSaveBodyDTO param); + + /** + * A302-4 财务应收单查询 + * + * @param accessToken 访问令牌,通过 getToken 获取 + * @param pageNo 页码,从1开始 + * @param pageSize 页数 + * @param awrpSrcCode 来源单据号 + * @param awrpSrcSys 系统编号 + * @param modifytimeS 修改时间-开始 + * @param modifytimeE 修改时间-结束 + * @param billstatus 单据状态 + * @param settlestatus 结算状态 + * @return 财务应收单分页数据 + */ + @GetMapping(AR_BILL_QUERY_URL) + BfiResultVO> arBillQuery(@RequestHeader(BfiConstant.HEADER_ACCESS_TOKEN) String accessToken, + @RequestParam("pageNo") Integer pageNo, + @RequestParam("pageSize") Integer pageSize, + @RequestParam(value = "awrp_src_code", required = false) List awrpSrcCode, + @RequestParam(value = "awrp_src_sys", required = false) String awrpSrcSys, + @RequestParam(value = "modifytimeS", required = false) String modifytimeS, + @RequestParam(value = "modifytimeE", required = false) String modifytimeE, + @RequestParam(value = "billstatus", required = false) List billstatus, + @RequestParam(value = "settlestatus", required = false) List settlestatus); + + /** + * A302-6 收票指定应付 + * + * @param accessToken 访问令牌,通过 getToken 获取 + * @param param 请求参数 + * @return 指定结果 + */ + @PostMapping(INVOICE_ASSIGN_URL) + BfiResultVO invoiceAssign(@RequestHeader(BfiConstant.HEADER_ACCESS_TOKEN) String accessToken, @RequestBody BfiInvoiceAssignBodyDTO param); + + /** + * A303-1 开票申请单新增 + * + * @param accessToken 访问令牌,通过 getToken 获取 + * @param param 请求参数 + * @return 保存结果 + */ + @PostMapping(INVOICE_APPLY_SAVE_URL) + BfiResultVO invoiceApplySave(@RequestHeader(BfiConstant.HEADER_ACCESS_TOKEN) String accessToken, @RequestBody BfiInvoiceApplySaveBodyDTO param); + + /** + * A303-4 开票申请单查询 + * + * @param accessToken 访问令牌,通过 getToken 获取 + * @param pageNo 页码,从1开始 + * @param pageSize 页数 + * @param billno 业务单据编号 + * @param systemsource 数据来源系统 + * @param billdateS 单据日期-开始 + * @param billdateE 单据日期-结束 + * @return 开票申请单分页数据 + */ + @GetMapping(INVOICE_APPLY_QUERY_URL) + BfiResultVO> invoiceApplyQuery(@RequestHeader(BfiConstant.HEADER_ACCESS_TOKEN) String accessToken, + @RequestParam("pageNo") Integer pageNo, + @RequestParam("pageSize") Integer pageSize, + @RequestParam(value = "billno", required = false) List billno, + @RequestParam(value = "systemsource", required = false) String systemsource, + @RequestParam(value = "billdateS", required = false) String billdateS, + @RequestParam(value = "billdateE", required = false) String billdateE); + + /** + * A303-5 开票申请发票查询 + * + * @param accessToken 访问令牌,通过 getToken 获取 + * @param param 请求参数,data 为原文 JSON 的 Base64 编码 + * @return 发票查询结果,data 为 Base64 编码字符串 + */ + @PostMapping(INVOICE_APPLY_OPEN_API_URL) + BfiOpenApiResultVO invoiceApplyOpenApi(@RequestHeader(BfiConstant.HEADER_ACCESS_TOKEN) String accessToken, @RequestBody BfiInvoiceApplyOpenApiBodyDTO param); + + /** + * A303-2 收票单新增(批量保存) + * + * @param accessToken 访问令牌,通过 getToken 获取 + * @param param 请求参数 + * @return 保存结果 + */ + @PostMapping(RECEIVED_INVOICE_SAVE_URL) + BfiResultVO receivedInvoiceSave(@RequestHeader(BfiConstant.HEADER_ACCESS_TOKEN) String accessToken, @RequestBody BfiReceivedInvoiceSaveBodyDTO param); + + /** + * A801-3 收票单查询 + * + * @param accessToken 访问令牌,通过 getToken 获取 + * @param param 查询参数 + * @return 收票单分页数据 + */ + @GetMapping(RECEIVED_INVOICE_QUERY_URL) + BfiResultVO> receivedInvoiceQuery(@RequestHeader(BfiConstant.HEADER_ACCESS_TOKEN) String accessToken, @SpringQueryMap BfiReceivedInvoiceQueryDTO param); + + /** + * A301-1 收款单查询(收款处理查询) + * + * @param accessToken 访问令牌,通过 getToken 获取 + * @param pageNo 页码,从1开始 + * @param pageSize 页数 + * @param beginBizdate 开始业务日期 + * @param endBizdate 结束业务日期 + * @param billno 单据编号 + * @param orgNumber 收款人(公司).编码 + * @return 收款处理分页数据 + */ + @GetMapping(REC_BILL_QUERY_URL) + BfiResultVO> recBillQuery(@RequestHeader(BfiConstant.HEADER_ACCESS_TOKEN) String accessToken, + @RequestParam("pageNo") Integer pageNo, + @RequestParam("pageSize") Integer pageSize, + @RequestParam("begin_bizdate") String beginBizdate, + @RequestParam("end_bizdate") String endBizdate, + @RequestParam(value = "billno", required = false) List billno, + @RequestParam(value = "org_number", required = false) List orgNumber); + + /** + * A301-2 收款认领公告查询 + * + * @param accessToken 访问令牌,通过 getToken 获取 + * @param claimusers 参与认领用户名 + * @param billstatus 单据状态(0-未认领,1-已认领) + * @param modifytimeS 修改时间-开始时间,格式:yyyy-MM-dd + * @param modifytimeE 修改时间-结束时间,格式:yyyy-MM-dd + * @param pageNo 页码,从1开始 + * @param pageSize 页数 + * @return 收款认领公告分页数据 + */ + @GetMapping(CLAIM_ANNOUNCE_QUERY_URL) + BfiResultVO> claimAnnounceQuery(@RequestHeader(BfiConstant.HEADER_ACCESS_TOKEN) String accessToken, + @RequestParam("claimusers") String claimusers, + @RequestParam(value = "billstatus", required = false) String billstatus, + @RequestParam("modifytimeS") String modifytimeS, + @RequestParam("modifytimeE") String modifytimeE, + @RequestParam("pageNo") Integer pageNo, + @RequestParam("pageSize") Integer pageSize); + + /** + * A301-3 收款认领结果推送 + * + * @param accessToken 访问令牌,通过 getToken 获取 + * @param param 请求参数 + * @return 推送结果 + */ + @PostMapping(CLAIM_BILL_PUSH_URL) + BfiResultVO claimBillPush(@RequestHeader(BfiConstant.HEADER_ACCESS_TOKEN) String accessToken, @RequestBody BfiClaimPushBodyDTO param); + + /** + * A801-1 查询全票池发票数据 + * + * @param accessToken 访问令牌,通过 getToken 获取 + * @param param 查询参数 + * @return 票池发票数据列表 + */ + @PostMapping(INVOICE_POOL_QUERY_URL) + BfiResultVO> invoicePoolQuery(@RequestHeader(BfiConstant.HEADER_ACCESS_TOKEN) String accessToken, @RequestBody BfiInvoicePoolQueryDTO param); + + /** + * A801-2 进项发票下载(文件下载),返回文件流 + * + * @param accessToken 访问令牌,通过 getToken 获取 + * @param param 请求参数 + * @return 文件流响应 + */ + @PostMapping(INVOICE_DOWNLOAD_URL) + Response invoiceDownload(@RequestHeader(BfiConstant.HEADER_ACCESS_TOKEN) String accessToken, @RequestBody BfiInvoiceDownloadDTO param); + + /** + * A902 附件上传到指定单据(multipart/form-data) + * + * @param accessToken 访问令牌,通过 getToken 获取 + * @param file 附件文件 + * @param attachmentUploadFileArgs 上传参数 JSON 字符串:{"entityNumber":"ap_payapply","billPkId":2386455983883835392,"controlKey":"attachmentpanel"} + * @return 上传结果,data.path 即金蝶侧文件路径 + */ + @PostMapping(value = ATTACHMENT_UPLOAD_URL, consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + BfiResultVO uploadAttachment(@RequestHeader(BfiConstant.HEADER_ACCESS_TOKEN) String accessToken, + @RequestPart("file") MultipartFile file, + @RequestPart("attachmentUploadFileArgs") String attachmentUploadFileArgs); + + /** + * 单据锁定(lockBusiData):推送金蝶成功后 WLTMS 调用锁定目标单据; + * 接口 1对1:单次仅一个源单号(sourceBillNo)对一个金蝶单号(targetBillNo), + * 应答为 {updateCount, updateResultList} 裸结构(非 BfiResultVO 包装); + * 解锁结果由金蝶经 unLockNotifyBusiData 回调 WLTMS 通知(见 transport 侧公开端点) + * + * @param accessToken 访问令牌,通过 getToken 获取 + * @param param 锁单参数 + * @return 锁单处理结果 + */ + @PostMapping(LOCK_BILL_URL) + BfiBillLockResultVO lockBusiData(@RequestHeader(BfiConstant.HEADER_ACCESS_TOKEN) String accessToken, @RequestBody BfiBillLockDTO param); +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/interceptor/BfiRequestInterceptor.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/interceptor/BfiRequestInterceptor.java new file mode 100644 index 00000000..a9b3390e --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/interceptor/BfiRequestInterceptor.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. + *

+ * 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.interceptor; + +import feign.RequestInterceptor; +import feign.RequestTemplate; +import lombok.AllArgsConstructor; +import org.springblade.thirdparty.bfi.config.BfiProperties; +import org.springblade.thirdparty.bfi.constant.BfiConstant; + +/** + * 业财融合(BFI)请求拦截器,统一注入网关认证请求头 + * + * @author Chill + */ +@AllArgsConstructor +public class BfiRequestInterceptor implements RequestInterceptor { + + private final BfiProperties properties; + + @Override + public void apply(RequestTemplate template) { + template.header(BfiConstant.HEADER_AUTHORIZATION, properties.getAuthorization()); + } +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiApBillQueryDTO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiApBillQueryDTO.java new file mode 100644 index 00000000..e5ee8582 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiApBillQueryDTO.java @@ -0,0 +1,82 @@ +/** + * 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.pojo.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; + +/** + * 业财融合(BFI)A102-4 财务应付单查询参数 + *

+ * 请求示例: + *

{@code
+ * GET /ierp/kapi/v2/awrp/ap/ap_finapbill/query?awrp_src_sys=YC&pageSize=10&pageNo=1
+ * }
+ * + * @author Chill + */ +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BfiApBillQueryDTO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 【必填】页码,从1开始 + */ + private Integer pageNo; + /** + * 【必填】页数 + */ + private Integer pageSize; + /** + * 来源单据号,业务系统对应的单据号,高频查询需要推送该字段 + */ + private List awrpSrcCode; + /** + * 系统编号 + */ + private String awrpSrcSys; + /** + * 修改时间-开始,格式:yyyy-MM-dd HH:mm:ss,限定时间范围不可超过1个月 + */ + private String modifytimeS; + /** + * 修改时间-结束,格式:yyyy-MM-dd HH:mm:ss + */ + private String modifytimeE; + /** + * 单据状态[A:暂存, B:已提交, C:已审核](BfiConstant.BILL_STATUS_DRAFT / BILL_STATUS_SUBMITTED / BILL_STATUS_AUDITED) + */ + private List billstatus; + /** + * 结算状态[unsettle:未结算, partsettle:部分结算, settled:全部结算](BfiConstant.SETTLE_STATUS_UNSETTLE / SETTLE_STATUS_PART / SETTLE_STATUS_SETTLED) + */ + private List settlestatus; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiApBillSaveBodyDTO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiApBillSaveBodyDTO.java new file mode 100644 index 00000000..e27d69d4 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiApBillSaveBodyDTO.java @@ -0,0 +1,49 @@ +/** + * 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.pojo.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; + +/** + * 业财融合(BFI)A102-2 财务应付单保存请求体,结构为 data 数组 + * + * @author Chill + */ +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BfiApBillSaveBodyDTO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 财务应付单数据 + */ + private List data; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiApBillSaveDTO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiApBillSaveDTO.java new file mode 100644 index 00000000..77c73840 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiApBillSaveDTO.java @@ -0,0 +1,319 @@ +/** + * 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.pojo.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.List; + +/** + * 业财融合(BFI)A102-2 财务应付单保存参数 + *

+ * 请求示例: + *

{@code
+ * {
+ *   "data": [
+ *     {
+ *       "billno": "test-0210-m1",
+ *       "billtypeid_number": "ApFin_pur_BT_S",
+ *       "org_number": "01.00",
+ *       "bizdate": "2026-01-10 00:00:00",
+ *       "asstacttype": "bd_supplier",
+ *       "asstact_number": "G01.01.04.02-10-0017",
+ *       "purmode": "CREDIT",
+ *       "currency_number": "CNY",
+ *       "payorg_number": "01.00",
+ *       "payproperty_number": "2001",
+ *       "settlementtype_number": "02",
+ *       "isincludetax": true,
+ *       "purorg_number": "01.00",
+ *       "remark": "ceshi",
+ *       "awrp_src_sys": "YC",
+ *       "awrp_src_code": "test-0210-m1",
+ *       "awrp_src_bt": "awrp_contract_info",
+ *       "awrp_src_con_no": "1",
+ *       "detailentry": [
+ *         {
+ *           "material_number": "BYWL02.0141",
+ *           "measureunit_number": "GE",
+ *           "quantity": 2.00,
+ *           "pricetax": 5.0,
+ *           "taxrateid_number": "V13",
+ *           "e_remark": "test",
+ *           "awrp_entry_code": "ee1",
+ *           "awrp_src_con_eno": "e1",
+ *           "project_number": "XDWL-ZSLXM-2026-0002"
+ *         }
+ *       ],
+ *       "preentry": [
+ *         {
+ *           "y_billtype": "cas_paybill",
+ *           "y_billid": "2350830337921133568",
+ *           "y_settleamt": 2
+ *         }
+ *       ]
+ *     }
+ *   ]
+ * }
+ * }
+ * + * @author Chill + */ +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BfiApBillSaveDTO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 单据编号,业务不传,由星瀚生成(如 AP-202601-001394) + */ + private String billno; + /** + * 【必填】单据类型.编码[ApFin_pur_BT_S:标准采购应付, ApFin_fee_BT_S:费用采购应付, ApFin_purfee_BT_S:采购费用应付, ap_finapbill_asset_BT_S:资产采购应付, ApFin_service_BT_S:服务采购应付, ApFin_product_BT_S:产品委外应付, ApFin_other_BT_S:其他应付, ApFin_borr_BT_S:借贷项调整],城矿常用 ApFin_pur_BT_S(BfiConstant.AP_BILL_TYPE_PURCHASE) + */ + @JsonProperty("billtypeid_number") + private String billtypeidNumber; + /** + * 【必填】结算组织.编码 + */ + @JsonProperty("org_number") + private String orgNumber; + /** + * 【必填】单据日期,格式:yyyy-MM-dd HH:mm:ss,城矿传创建时间 + */ + private String bizdate; + /** + * 到期日,格式:yyyy-MM-dd HH:mm:ss + */ + private String duedate; + /** + * 记账日期,格式:yyyy-MM-dd + */ + private String bookdate; + /** + * 【必填】往来类型[bd_supplier:供应商, bos_user:人员, bd_customer:客户],城矿传供应商(BfiConstant.ASSTACT_TYPE_SUPPLIER) + */ + private String asstacttype; + /** + * 【必填】往来户.编码 + */ + @JsonProperty("asstact_number") + private String asstactNumber; + /** + * 【必填】付款方式[CASH:现购, CREDIT:赊购],现购代表系统自动100%推付款,赊购可自由调整后续付款金额(BfiConstant.PAY_MODE_CASH / PAY_MODE_CREDIT) + */ + private String purmode; + /** + * 【必填】结算币别.货币代码,如 CNY(BfiConstant.CURRENCY_CNY) + */ + @JsonProperty("currency_number") + private String currencyNumber; + /** + * 汇率 + */ + private BigDecimal exchangerate; + /** + * 汇率表.编码,如 01(BfiConstant.EXRATE_TABLE_DEFAULT) + */ + @JsonProperty("exratetable_number") + private String exratetableNumber; + /** + * 【必填】付款组织.编码 + */ + @JsonProperty("payorg_number") + private String payorgNumber; + /** + * 【必填】款项性质.编码,如 2001 材料采购(BfiConstant.PAY_PROPERTY_MATERIAL) + */ + @JsonProperty("payproperty_number") + private String paypropertyNumber; + /** + * 付款条件.编码 + */ + @JsonProperty("paycond_number") + private String paycondNumber; + /** + * 结算方式.编码,02 银行转账(BfiConstant.SETTLE_TYPE_BANK) + */ + @JsonProperty("settlementtype_number") + private String settlementtypeNumber; + /** + * 是否期初 + */ + private Boolean isperiod; + /** + * 录入含税价 + */ + private Boolean isincludetax; + /** + * 部门.编码 + */ + @JsonProperty("department_number") + private String departmentNumber; + /** + * 汇率日期,格式:yyyy-MM-dd HH:mm:ss + */ + private String exratedate; + /** + * 录入总价,为 true 后可不录入单价 + */ + private Boolean ispricetotal; + /** + * 采购组织.编码 + */ + @JsonProperty("purorg_number") + private String purorgNumber; + /** + * 采购组.编码 + */ + @JsonProperty("purdept_number") + private String purdeptNumber; + /** + * 采购员.业务员编码 + */ + @JsonProperty("purchaser_operatornumber") + private String purchaserOperatornumber; + /** + * 质保金 + */ + private Boolean ispremium; + /** + * 质保金比例(%) + */ + private BigDecimal premiumrate; + /** + * 质保金金额 + */ + private BigDecimal premiumamt; + /** + * 质保金到期日,格式:yyyy-MM-dd + */ + private String premduedate; + /** + * 往来单位名称 + */ + private String asstactname; + /** + * 换算方式[0:直接汇率, 1:间接汇率] + */ + private String quotation; + /** + * 微调 + */ + private Boolean isintertaxfx; + /** + * 税额不计入成本 + */ + private Boolean istaxdeduction; + /** + * 按比例分配 + */ + private Boolean allocatebyper; + /** + * 按应付金额分配 + */ + private Boolean isintax; + /** + * 收款账号,城矿不传 + */ + private String payeebanknum; + /** + * 收款银行.编码,城矿不传 + */ + @JsonProperty("bebank_number") + private String bebankNumber; + /** + * 备注 + */ + private String remark; + /** + * 微调金额 + */ + private Boolean isfx; + /** + * 微调应付金额 + */ + private Boolean isfxpricetaxtotal; + /** + * 源单id + */ + private Long sourcebillid; + /** + * 源单类型,如 ap_invoice + */ + private String sourcebilltype; + /** + * 源单编码 + */ + private String sourcebillno; + /** + * 单据来源类型[0:手工新增, 1:导入生成, 2:后台生成, 3:从总账导入, 4:协同调差生成, fromeas:EAS集成, 21:跨租户同步] + */ + private String billsrctype; + /** + * 【必填】外部系统编号,候选键 + */ + @JsonProperty("awrp_src_sys") + private String awrpSrcSys; + /** + * 【必填】外部系统来源单据号,候选键,建议拼接 + */ + @JsonProperty("awrp_src_code") + private String awrpSrcCode; + /** + * 来源单据,有上游合同则传 awrp_contract_info + */ + @JsonProperty("awrp_src_bt") + private String awrpSrcBt; + /** + * 上游外部系统单据号,即上游合同号 + */ + @JsonProperty("awrp_src_con_no") + private String awrpSrcConNo; + /** + * 【必填】外部单据类型 + */ + @JsonProperty("awrp_src_type") + private String awrpSrcType; + /** + * 【必填】明细分录 + */ + @JsonProperty("detailentry") + private List detailentry; + /** + * 付款计划分录,不传则自动汇总生成 + */ + private List planentity; + /** + * 预付信息分录 + */ + private List preentry; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiApBillSaveEntryDTO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiApBillSaveEntryDTO.java new file mode 100644 index 00000000..7810ec0e --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiApBillSaveEntryDTO.java @@ -0,0 +1,145 @@ +/** + * 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.pojo.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.math.BigDecimal; + +/** + * 业财融合(BFI)A102-2 财务应付单保存 明细分录 + * + * @author Chill + */ +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BfiApBillSaveEntryDTO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 【必填】物料编码.编码,城矿对应物料(如 BYWL02.0141 废钢、WCY01.0001) + */ + @JsonProperty("material_number") + private String materialNumber; + /** + * 运输货物(金蝶接口新字段),TMS 传结算明细货物名称,费用合计行不传 + */ + @JsonProperty("awrp_transport") + private String awrpTransport; + /** + * 车号(金蝶接口新字段),TMS 传结算明细车号,费用合计行不传 + */ + @JsonProperty("awrp_lpn") + private String awrpLpn; + /** + * 费用项目.编码 + */ + @JsonProperty("expenseitem_number") + private String expenseitemNumber; + /** + * 【必填】项目.项目编码,项目字段必填、若不涉及项目传编码999(BfiConstant.PROJECT_NOT_APPLICABLE) + */ + @JsonProperty("project_number") + private String projectNumber; + /** + * 【必填】计量单位.编码,城矿传吨/千克 + */ + @JsonProperty("measureunit_number") + private String measureunitNumber; + /** + * 【必填】数量,城矿传重量 + */ + private BigDecimal quantity; + /** + * 单价,城矿对应付款单价 + */ + private BigDecimal price; + /** + * 含税单价 + */ + private BigDecimal pricetax; + /** + * 折扣方式[PERCENT:折扣率(%), PERUNIT:单位折扣额, NULL:无, TOTAL:固定折扣额],城矿默认无 + */ + private String discountmode; + /** + * 单位折扣(率) + */ + private BigDecimal discountrate; + /** + * 赠品,默认 false + */ + private Boolean ispresent = Boolean.FALSE; + /** + * 税率.编码,城矿传 V13(BfiConstant.TAX_RATE_V13) + */ + @JsonProperty("taxrateid_number") + private String taxrateidNumber; + /** + * 核心单据行号 + */ + @JsonProperty("corebillentryseq") + private Integer corebillentryseq; + /** + * 核心单据类型[sctm_scpo:委外采购订单, pm_om_purorderbill:简单委外订单, conm_purcontract:采购合同, ec_paymentapply:付款单申请单, pm_purorderbill:采购订单] + */ + @JsonProperty("corebilltype") + private String corebilltype; + /** + * 备注 + */ + @JsonProperty("e_remark") + private String eRemark; + /** + * 应付金额,城矿对应挂账金额 + */ + @JsonProperty("e_pricetaxtotal") + private BigDecimal ePricetaxtotal; + /** + * 金额,城矿对应实付金额 + */ + @JsonProperty("e_amount") + private BigDecimal eAmount; + /** + * 税额 + */ + @JsonProperty("e_tax") + private BigDecimal eTax; + /** + * 【必填】外部系统来源分录标识,候选键,传明细行行ID或行编码 + */ + @JsonProperty("awrp_entry_code") + private String awrpEntryCode; + /** + * 上游分录号,即上游合同分录号 + */ + @JsonProperty("awrp_src_con_eno") + private String awrpSrcConEno; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiApBillSavePlanDTO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiApBillSavePlanDTO.java new file mode 100644 index 00000000..3a52ca57 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiApBillSavePlanDTO.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.thirdparty.bfi.pojo.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.math.BigDecimal; + +/** + * 业财融合(BFI)A102-2 财务应付单保存 付款计划分录 + * + * @author Chill + */ +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BfiApBillSavePlanDTO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 到期日,格式:yyyy-MM-dd + */ + private String planduedate; + /** + * 应付金额 + */ + private BigDecimal planpricetax; + /** + * 备注 + */ + private String planremark; + /** + * 合同 + */ + private String plancontract; + /** + * 外部系统来源分录标识,候选键,不传自动取外部系统单据号 + */ + @JsonProperty("awrp_entry_code_p") + private String awrpEntryCodeP; + /** + * 利润中心.编码 + */ + @JsonProperty("planprofitcenter_number") + private String planprofitcenterNumber; + /** + * 对方利润中心.编码 + */ + @JsonProperty("plancprofitcenter_number") + private String plancprofitcenterNumber; + /** + * 结算方式.编码 + */ + @JsonProperty("plansettletype_number") + private String plansettletypeNumber; + /** + * 物料编码.编码 + */ + @JsonProperty("planmaterial_number") + private String planmaterialNumber; + /** + * 费用项目.编码 + */ + @JsonProperty("planexpenseitem_number") + private String planexpenseitemNumber; + /** + * 【必填】项目.项目编码,项目字段必填、若不涉及项目传编码999(BfiConstant.PROJECT_NOT_APPLICABLE) + */ + @JsonProperty("planproject_number") + private String planprojectNumber; + /** + * 成本中心.编码 + */ + @JsonProperty("plancostcenter_number") + private String plancostcenterNumber; + /** + * 付款比例(%) + */ + @JsonProperty("p_payrate") + private BigDecimal pPayrate; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiApBillSavePreEntryDTO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiApBillSavePreEntryDTO.java new file mode 100644 index 00000000..bdc6eb9a --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiApBillSavePreEntryDTO.java @@ -0,0 +1,61 @@ +/** + * 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.pojo.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.math.BigDecimal; + +/** + * 业财融合(BFI)A102-2 财务应付单保存 预付信息分录 + * + * @author Chill + */ +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BfiApBillSavePreEntryDTO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 【必填】单据类型[cas_paybill:付款处理, ap_paidbill:期初预付单](BfiConstant.PREPAY_BILL_TYPE_PAY_BILL / PREPAY_BILL_TYPE_INIT_PAID) + */ + @JsonProperty("y_billtype") + private String yBilltype; + /** + * 【必填】预付款单id,对应星瀚单据的id + */ + @JsonProperty("y_billid") + private String yBillid; + /** + * 本次结算金额 + */ + @JsonProperty("y_settleamt") + private BigDecimal ySettleamt; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiArBillQueryDTO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiArBillQueryDTO.java new file mode 100644 index 00000000..3be9493a --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiArBillQueryDTO.java @@ -0,0 +1,82 @@ +/** + * 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.pojo.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; + +/** + * 业财融合(BFI)A302-4 财务应收单查询参数 + *

+ * 请求示例: + *

{@code
+ * GET /ierp/kapi/v2/awrp/ar/ar_finarbill/query?awrp_src_sys=YC&pageSize=10&pageNo=1
+ * }
+ * + * @author Chill + */ +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BfiArBillQueryDTO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 【必填】页码,从1开始 + */ + private Integer pageNo; + /** + * 【必填】页数 + */ + private Integer pageSize; + /** + * 来源单据号,业务系统对应的单据号,高频查询需要推送该字段 + */ + private List awrpSrcCode; + /** + * 系统编号 + */ + private String awrpSrcSys; + /** + * 修改时间-开始,格式:yyyy-MM-dd HH:mm:ss,限定时间范围不可超过1个月 + */ + private String modifytimeS; + /** + * 修改时间-结束,格式:yyyy-MM-dd HH:mm:ss + */ + private String modifytimeE; + /** + * 单据状态[A:暂存, B:已提交, C:已审核](BfiConstant.BILL_STATUS_DRAFT / BILL_STATUS_SUBMITTED / BILL_STATUS_AUDITED) + */ + private List billstatus; + /** + * 结算状态[unsettle:未结算, partsettle:部分结算, settled:全部结算](BfiConstant.SETTLE_STATUS_UNSETTLE / SETTLE_STATUS_PART / SETTLE_STATUS_SETTLED) + */ + private List settlestatus; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiArBillSaveBodyDTO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiArBillSaveBodyDTO.java new file mode 100644 index 00000000..ccabb1a4 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiArBillSaveBodyDTO.java @@ -0,0 +1,49 @@ +/** + * 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.pojo.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; + +/** + * 业财融合(BFI)A302-2 财务应收单保存请求体,结构为 data 数组 + * + * @author Chill + */ +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BfiArBillSaveBodyDTO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 财务应收单数据 + */ + private List data; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiArBillSaveDTO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiArBillSaveDTO.java new file mode 100644 index 00000000..cee40e45 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiArBillSaveDTO.java @@ -0,0 +1,286 @@ +/** + * 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.pojo.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.List; + +/** + * 业财融合(BFI)A302-2 财务应收单保存参数 + *

+ * 请求示例: + *

{@code
+ * {
+ *   "data": [
+ *     {
+ *       "billno": "AR-260113-008444",
+ *       "billtype_number": "arfin_standard_BT_S",
+ *       "org_number": "01.00",
+ *       "bizdate": "2026-01-13 00:00:00",
+ *       "asstacttype": "bd_customer",
+ *       "asstact_number": "K01.02.11.00-20-0102",
+ *       "paymode": "CREDIT",
+ *       "payproperty_number": "1001",
+ *       "currency_number": "CNY",
+ *       "duedate": "2026-01-13 00:00:00",
+ *       "paycond_number": "01",
+ *       "settlementtype_number": "02",
+ *       "isincludetax": true,
+ *       "awrp_src_sys": "YC",
+ *       "awrp_src_code": "1",
+ *       "awrp_src_bt": "awrp_contract_info",
+ *       "awrp_src_con_no": "11",
+ *       "entry": [
+ *         {
+ *           "e_material_number": "BYWL02.0140",
+ *           "e_measureunit_number": "GE",
+ *           "e_quantity": 2.00,
+ *           "e_taxunitprice": 5.0,
+ *           "taxrateid_number": "V13",
+ *           "e_remark": "分录备注",
+ *           "awrp_src_con_eno": "e1",
+ *           "awrp_entry_code": "ee1"
+ *         }
+ *       ],
+ *       "planentity": [
+ *         {
+ *           "planduedate": "2026-01-13 00:00:00",
+ *           "planpricetax": 10.00,
+ *           "planremark": "",
+ *           "plansettletype_number": "02",
+ *           "awrp_entry_code_p": "OBHqh",
+ *           "plancontract": "nVYpM"
+ *         }
+ *       ]
+ *     }
+ *   ]
+ * }
+ * }
+ * + * @author Chill + */ +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BfiArBillSaveDTO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 单据编号,业务不传,由星瀚生成(如 AR-260113-008444) + */ + private String billno; + /** + * 【必填】单据类型.编码[arfin_standard_BT_S:标准销售应收, arfin_sersal_BT_S:服务销售应收, arfin_salefee_BT_S:销售费用应收, arfin_other_BT_S:其他应收, arfin_borrowar_BT_S:借贷项调整](BfiConstant.AR_BILL_TYPE_STANDARD) + */ + @JsonProperty("billtype_number") + private String billtypeNumber; + /** + * 【必填】结算组织.编码,城矿传 WCCK + */ + @JsonProperty("org_number") + private String orgNumber; + /** + * 【必填】单据日期,格式:yyyy-MM-dd HH:mm:ss,城矿传创建时间 + */ + private String bizdate; + /** + * 到期日,格式:yyyy-MM-dd HH:mm:ss + */ + private String duedate; + /** + * 记账日期,格式:yyyy-MM-dd + */ + private String bookdate; + /** + * 【必填】往来类型[bd_supplier:供应商, bos_user:人员, bd_customer:客户],城矿传客户(BfiConstant.ASSTACT_TYPE_CUSTOMER) + */ + private String asstacttype; + /** + * 【必填】往来户.编码 + */ + @JsonProperty("asstact_number") + private String asstactNumber; + /** + * 收款组织.编码 + */ + @JsonProperty("recorg_number") + private String recorgNumber; + /** + * 【必填】付款方式[CASH:现销, CREDIT:赊销](BfiConstant.PAY_MODE_CASH / PAY_MODE_CREDIT) + */ + private String paymode; + /** + * 【必填】款项性质.编码,如 1001 一般销售(BfiConstant.PAY_PROPERTY_SALE) + */ + @JsonProperty("payproperty_number") + private String paypropertyNumber; + /** + * 【必填】结算币别.货币代码,如 CNY(BfiConstant.CURRENCY_CNY) + */ + @JsonProperty("currency_number") + private String currencyNumber; + /** + * 汇率表.编码,如 01(BfiConstant.EXRATE_TABLE_DEFAULT) + */ + @JsonProperty("exratetable_number") + private String exratetableNumber; + /** + * 汇率 + */ + private BigDecimal exchangerate; + /** + * 汇率日期,格式:yyyy-MM-dd HH:mm:ss + */ + private String exratedate; + /** + * 收款条件.编码,如 01 + */ + @JsonProperty("paycond_number") + private String paycondNumber; + /** + * 结算方式.编码,如 02(BfiConstant.SETTLE_TYPE_BANK) + */ + @JsonProperty("settlementtype_number") + private String settlementtypeNumber; + /** + * 部门.编码 + */ + @JsonProperty("department_number") + private String departmentNumber; + /** + * 销售组织.编码 + */ + @JsonProperty("salesorg_number") + private String salesorgNumber; + /** + * 销售组.编码 + */ + @JsonProperty("salesgroup_number") + private String salesgroupNumber; + /** + * 销售员.业务员编码 + */ + @JsonProperty("salesman_operatornumber") + private String salesmanOperatornumber; + /** + * 是否期初 + */ + private Boolean isperiod; + /** + * 录入含税价,传 true 含税,传 false 系统会再算税 + */ + private Boolean isincludetax; + /** + * 录入总价 + */ + private Boolean ispricetotal; + /** + * 备注 + */ + private String remark; + /** + * 微调金额 + */ + private Boolean isfx; + /** + * 微调应收金额 + */ + private Boolean isfxpricetaxtotal; + /** + * 业务唯一码 + */ + private String bizfingerprint; + /** + * 质保金到期日,格式:yyyy-MM-dd HH:mm:ss + */ + private String premduedate; + /** + * 质保金比例(%) + */ + private BigDecimal premiumrate; + /** + * 质保金金额 + */ + private BigDecimal premiumamt; + /** + * 质保金 + */ + private Boolean ispremium; + /** + * 源单ID + */ + private String sourcebillid; + /** + * 源单类型,如 ar_invoice + */ + private String sourcebilltype; + /** + * 源单编码 + */ + private String sourcebillno; + /** + * 单据来源类型[0:手工新增, 1:导入生成, 2:后台生成, 3:从总账导入, 4:协同调差生成, fromeas:EAS集成, 21:跨租户同步] + */ + private String billsrctype; + /** + * 【必填】外部系统编号,候选键 + */ + @JsonProperty("awrp_src_sys") + private String awrpSrcSys; + /** + * 【必填】外部系统来源单据号,候选键 + */ + @JsonProperty("awrp_src_code") + private String awrpSrcCode; + /** + * 来源单据,有上游合同则传 awrp_contract_info + */ + @JsonProperty("awrp_src_bt") + private String awrpSrcBt; + /** + * 上游外部系统单据号,即上游合同号 + */ + @JsonProperty("awrp_src_con_no") + private String awrpSrcConNo; + /** + * 【必填】外部单据类型 + */ + @JsonProperty("awrp_src_type") + private String awrpSrcType; + /** + * 【必填】明细分录 + */ + private List entry; + /** + * 收款计划分录,不传则自动汇总成一行 + */ + private List planentity; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiArBillSaveEntryDTO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiArBillSaveEntryDTO.java new file mode 100644 index 00000000..6b2235f0 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiArBillSaveEntryDTO.java @@ -0,0 +1,156 @@ +/** + * 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.pojo.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.math.BigDecimal; + +/** + * 业财融合(BFI)A302-2 财务应收单保存 明细分录 + * + * @author Chill + */ +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BfiArBillSaveEntryDTO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 【必填】物料编码.编码 + */ + @JsonProperty("e_material_number") + private String eMaterialNumber; + /** + * 费用项目.编码 + */ + @JsonProperty("e_expenseitem_number") + private String eExpenseitemNumber; + /** + * 【必填】项目.项目编码,项目字段必填、若不涉及项目传编码999(BfiConstant.PROJECT_NOT_APPLICABLE) + */ + @JsonProperty("project_number") + private String projectNumber; + /** + * 【必填】计量单位.编码 + */ + @JsonProperty("e_measureunit_number") + private String eMeasureunitNumber; + /** + * 【必填】数量 + */ + @JsonProperty("e_quantity") + private BigDecimal eQuantity; + /** + * 运输货物(金蝶接口新字段),TMS 传结算明细货物名称,费用合计行不传 + */ + @JsonProperty("awrp_transport") + private String awrpTransport; + /** + * 车号(金蝶接口新字段),TMS 传结算明细车号,费用合计行不传 + */ + @JsonProperty("awrp_lpn") + private String awrpLpn; + /** + * 单价 + */ + @JsonProperty("e_unitprice") + private BigDecimal eUnitprice; + /** + * 含税单价 + */ + @JsonProperty("e_taxunitprice") + private BigDecimal eTaxunitprice; + /** + * 折扣方式[PERCENT:折扣率(%), PERUNIT:单位折扣额, NULL:无, TOTAL:固定折扣额] + */ + @JsonProperty("e_discountmode") + private String eDiscountmode; + /** + * 单位折扣(率) + */ + @JsonProperty("e_discountrate") + private BigDecimal eDiscountrate; + /** + * 税率.编码,如 V13 + */ + @JsonProperty("taxrateid_number") + private String taxrateidNumber; + /** + * 赠品 + */ + @JsonProperty("e_ispresent") + private Boolean eIspresent; + /** + * 核心单据类型[ec_incomeapply:请款单, conm_salcontract:销售合同, sm_salorder:销售订单] + */ + @JsonProperty("e_corebilltype") + private String eCorebilltype; + /** + * 核心单据号 + */ + @JsonProperty("e_corebillno") + private String eCorebillno; + /** + * 核心单据行号 + */ + @JsonProperty("e_corebillentryseq") + private Integer eCorebillentryseq; + /** + * 备注 + */ + @JsonProperty("e_remark") + private String eRemark; + /** + * 金额 + */ + @JsonProperty("e_amount") + private BigDecimal eAmount; + /** + * 税额 + */ + @JsonProperty("e_tax") + private BigDecimal eTax; + /** + * 应收金额 + */ + @JsonProperty("e_recamount") + private BigDecimal eRecamount; + /** + * 上游分录号,即上游合同分录号 + */ + @JsonProperty("awrp_src_con_eno") + private String awrpSrcConEno; + /** + * 【必填】外部系统来源分录标识,候选键 + */ + @JsonProperty("awrp_entry_code") + private String awrpEntryCode; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiArBillSavePlanDTO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiArBillSavePlanDTO.java new file mode 100644 index 00000000..5c4545cd --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiArBillSavePlanDTO.java @@ -0,0 +1,102 @@ +/** + * 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.pojo.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.math.BigDecimal; + +/** + * 业财融合(BFI)A302-2 财务应收单保存 收款计划分录 + * + * @author Chill + */ +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BfiArBillSavePlanDTO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 备注 + */ + private String planremark; + /** + * 结算方式.编码,如 02 + */ + @JsonProperty("plansettletype_number") + private String plansettletypeNumber; + /** + * 外部系统来源分录标识,候选键,不传自动取外部系统单据号 + */ + @JsonProperty("awrp_entry_code_p") + private String awrpEntryCodeP; + /** + * 利润中心.编码 + */ + @JsonProperty("planprofitcenter_number") + private String planprofitcenterNumber; + /** + * 物料编码.编码 + */ + @JsonProperty("planmaterial_number") + private String planmaterialNumber; + /** + * 费用项目.编码 + */ + @JsonProperty("planexpenseitem_number") + private String planexpenseitemNumber; + /** + * 【必填】项目.项目编码,项目字段必填、若不涉及项目传编码999(BfiConstant.PROJECT_NOT_APPLICABLE) + */ + @JsonProperty("planproject_number") + private String planprojectNumber; + /** + * 合同 + */ + private String plancontract; + /** + * 对方利润中心.编码 + */ + @JsonProperty("plancprofitcenter_number") + private String plancprofitcenterNumber; + /** + * 应收金额 + */ + private BigDecimal planpricetax; + /** + * 收款比例(%) + */ + @JsonProperty("p_recrate") + private BigDecimal pRecrate; + /** + * 到期日,格式:yyyy-MM-dd HH:mm:ss + */ + private String planduedate; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiBaseDTO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiBaseDTO.java new file mode 100644 index 00000000..39fa3007 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiBaseDTO.java @@ -0,0 +1,56 @@ +/** + * 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.pojo.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 业财融合(BFI)请求基础参数,统一携带随机数与时间戳 + * + * @author Chill + */ +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BfiBaseDTO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 随机数,不可以重复 + */ + private String nonce; + /** + * 时间戳,格式:yyyy-MM-dd HH:mm:ss,服务端校验当前时间前后5分钟 + */ + private String timestamp; + /** + * 语言字串:zh_CN、zh_TW、en_US等,默认 zh_CN + */ + private String language; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiBillLockDTO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiBillLockDTO.java new file mode 100644 index 00000000..e38b4ef5 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiBillLockDTO.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 + * of 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.pojo.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.io.Serial; + +/** + * 业财融合(BFI)单据锁定/解锁通知参数(lockBusiData 与 unLockNotifyBusiData 出入参共用)。 + *

lockBusiData:WLTMS 调金蝶,sourceSystem=WLTMS、targetSystem=XH、lockAction=LOCK; + * unLockNotifyBusiData:金蝶调 WLTMS,sourceSystem=XH、targetSystem=WLTMS、lockAction=UNLOCK, + * operationType=DELETE 时 WLTMS 侧需废除源单并还原关联数据。 + * 传参约定:接口 1对1,单次仅一个源单号(sourceBillNo)对一个金蝶单号(targetBillNo), + * time/operator 按接口文档为选传字段(示例报文未携带),非空时才送

+ * + * @author Chill + */ +@Data +@EqualsAndHashCode(callSuper = true) +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BfiBillLockDTO extends BfiBaseDTO { + @Serial + private static final long serialVersionUID = 1L; + /** + * 【必填】操作类型:LOCK-锁定,UNLOCK-解锁 + */ + private String lockAction; + /** + * 【必填】源系统标识,即接口调用方:WLTMS / XH + */ + private String sourceSystem; + /** + * 【必填】目标系统标识,即接口目标接收方:WLTMS / XH + */ + private String targetSystem; + /** + * 【必填】来源操作类型:SAVE-单据新增,DELETE-单据删除 + */ + private String operationType; + /** + * 【必填】源单据类型:调用方单据类型(WLTMS 自定义:SETTLEMENT/INVOICE_APPLY,金蝶固定:ApFin_other_BT_S 等) + */ + private String sourceBillType; + /** + * 【必填】源单据编号,与 targetBillNo 一对一 + */ + private String sourceBillNo; + /** + * 【必填】目标单据类型:接收方单据类型(金蝶固定:ApFin_other_BT_S 等,WLTMS 自定义:SETTLEMENT 等) + */ + private String targetBillType; + /** + * 【必填】目标单据编号,与 sourceBillNo 一对一 + */ + private String targetBillNo; + /** + * 操作时间,格式:yyyy-MM-dd HH:mm:ss(非空才送) + */ + private String time; + /** + * 操作人名字:推单用发起人,删除用操作人(非空才送) + */ + private String operator; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiBlacklistQueryDTO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiBlacklistQueryDTO.java new file mode 100644 index 00000000..21947099 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiBlacklistQueryDTO.java @@ -0,0 +1,69 @@ +/** + * 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.pojo.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; + +/** + * 业财融合(BFI)A801-5 黑名单查询参数 + * + * @author Chill + */ +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BfiBlacklistQueryDTO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + + /** + * 使用状态[1:可用, 0:禁用] + */ + private String enable; + + /** + * 企业税号,数组 + */ + private List number; + /** + * 企业名称,数组 + */ + private List name; + + /** + * 【必填】页码,从1开始 + */ + private Integer pageNo; + /** + * 【必填】页大小 + */ + private Integer pageSize; + +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiClaimAnnounceQueryDTO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiClaimAnnounceQueryDTO.java new file mode 100644 index 00000000..dbd2b2ff --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiClaimAnnounceQueryDTO.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.thirdparty.bfi.pojo.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 业财融合(BFI)A301-2 收款认领公告查询参数 + *

+ * 请求示例: + *

{@code
+ * GET /ierp/kapi/v2/awrp/cas/cas_claimannounce/query?claimusers=苏浩晖&billstatus=0&modifytimeS=2024-01-01&modifytimeE=2024-06-30&pageNo=1&pageSize=10
+ * }
+ * + * @author Chill + */ +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BfiClaimAnnounceQueryDTO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 【必填】参与认领用户名 + */ + private String claimusers; + /** + * 单据状态(0-未认领,1-已认领) + */ + private String billstatus; + /** + * 【必填】修改时间-开始时间,格式:yyyy-MM-dd + */ + private String modifytimeS; + /** + * 【必填】修改时间-结束时间,格式:yyyy-MM-dd + */ + private String modifytimeE; + /** + * 【必填】页码,从1开始 + */ + private Integer pageNo; + /** + * 【必填】页数 + */ + private Integer pageSize; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiClaimPushBodyDTO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiClaimPushBodyDTO.java new file mode 100644 index 00000000..c2ce15da --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiClaimPushBodyDTO.java @@ -0,0 +1,49 @@ +/** + * 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.pojo.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; + +/** + * 业财融合(BFI)A301-3 收款认领结果推送请求体,结构为 data 数组 + * + * @author Chill + */ +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BfiClaimPushBodyDTO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 认领结果数据 + */ + private List data; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiClaimPushDTO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiClaimPushDTO.java new file mode 100644 index 00000000..262275d8 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiClaimPushDTO.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. + *

+ * 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.pojo.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.List; + +/** + * 业财融合(BFI)A301-3 收款认领结果推送参数 + *

+ * 请求示例: + *

{@code
+ * {
+ *   "data": [
+ *     {
+ *       "awrp_src_sys": "WCCKTEST",
+ *       "awrp_src_code": "RLCLTEST01",
+ *       "billid": 2398805822369971200,
+ *       "recpaytype_number": "101",
+ *       "paymenttype": "bd_supplier",
+ *       "recviewpayer": "柳州卓尔汽车服务有限公司",
+ *       "recpayer": "1398800445421388800",
+ *       "entryentity": [
+ *         {
+ *           "e_receivableamt": 10.0
+ *         }
+ *       ]
+ *     }
+ *   ]
+ * }
+ * }
+ * + * @author Chill + */ +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BfiClaimPushDTO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 【必填】认领公告id + */ + private Long billid; + /** + * 【必填】来源系统编码 + */ + @JsonProperty("awrp_src_sys") + private String awrpSrcSys; + /** + * 【必填】来源系统单据编码 + */ + @JsonProperty("awrp_src_code") + private String awrpSrcCode; + /** + * 【必填】收款类型.编码,如 101 + */ + @JsonProperty("recpaytype_number") + private String recpaytypeNumber; + /** + * 【必填】付款人类型,如 bd_supplier(BfiConstant.ASSTACT_TYPE_SUPPLIER) + */ + private String paymenttype; + /** + * 【必填】付款人名称 + */ + private String recviewpayer; + /** + * 【必填】付款人id + */ + private String recpayer; + /** + * 【必填】单据体分录 + */ + private List entryentity; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiClaimPushEntryDTO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiClaimPushEntryDTO.java new file mode 100644 index 00000000..fdd221db --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiClaimPushEntryDTO.java @@ -0,0 +1,74 @@ +/** + * 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.pojo.dto; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.math.BigDecimal; + +/** + * 业财融合(BFI)A301-3 收款认领结果推送 单据体分录 + * + * @author Chill + */ +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY, + getterVisibility = JsonAutoDetect.Visibility.NONE, + isGetterVisibility = JsonAutoDetect.Visibility.NONE) +public class BfiClaimPushEntryDTO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 【必填】应收金额 + */ + @JsonProperty("e_receivableamt") + private BigDecimal eReceivableamt; + /** + * 核心单据类型[ar_finarbill:财务应收单, sm_salorder:销售订单, conm_salcontract:销售合同, fr_glreim_paybill:总账付款单, fr_glreim_recbill:总账收款单, mscon_performplanin:收入履约单, cas_agentpaybill:代发处理, cas_paybill:付款处理, cdm_drafttradebill:业务处理] + */ + @JsonProperty("e_corebilltype") + private String eCorebilltype; + /** + * 核心单据编号 + */ + @JsonProperty("e_corebillno") + private String eCorebillno; + /** + * 分录行号 + */ + private Long seq; + /** + * 收款类型.编码 + */ + @JsonProperty("e_receivingtype_number") + private String eReceivingtypeNumber; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiCustomerBankEntryDTO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiCustomerBankEntryDTO.java new file mode 100644 index 00000000..9409c582 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiCustomerBankEntryDTO.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.thirdparty.bfi.pojo.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 业财融合(BFI)A902-2 客户保存 银行信息分录 + * + * @author Chill + */ +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BfiCustomerBankEntryDTO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 【必填】银行账号,候选键,城矿对应 b_customer_info.bank_account + */ + private String bankaccount; + /** + * 【必填】账户名称,城矿对应 b_customer_info.bank_name + */ + private String accountname; + /** + * 默认 + */ + @JsonProperty("isdefault_bank") + private Boolean isdefaultBank; + /** + * 账户性质[0:公司, 1:个人],城矿传 0 + */ + @JsonProperty("account_property") + private String accountProperty; + /** + * 国际银行账户号码 + */ + private String iban; + /** + * 【必填】币种.货币代码,候选键,城矿传 CNY(BfiConstant.CURRENCY_CNY) + */ + @JsonProperty("currency_number") + private String currencyNumber; + /** + * 【必填】开户银行.编码(行号),候选键 + */ + @JsonProperty("bank_number") + private String bankNumber; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiCustomerGroupEntryDTO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiCustomerGroupEntryDTO.java new file mode 100644 index 00000000..edb17198 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiCustomerGroupEntryDTO.java @@ -0,0 +1,57 @@ +/** + * 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.pojo.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 业财融合(BFI)A902-2 客户保存 分类标准分录 + *

+ * 城矿固定传 standardid_number=JBFLBZ、groupid_number=1102(外部客户) + * + * @author Chill + */ +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BfiCustomerGroupEntryDTO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 【必填】分类标准.编码, + */ + @JsonProperty("standardid_number") + private String standardidNumber; + /** + * 【必填】分类.编码(BfiConstant.CUSTOMER_GROUP_EXTERNAL) + */ + @JsonProperty("groupid_number") + private String groupidNumber; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiCustomerLinkmanEntryDTO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiCustomerLinkmanEntryDTO.java new file mode 100644 index 00000000..5e5b3d1e --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiCustomerLinkmanEntryDTO.java @@ -0,0 +1,66 @@ +/** + * 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.pojo.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 业财融合(BFI)A902-2 客户保存 联系人信息分录 + * + * @author Chill + */ +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BfiCustomerLinkmanEntryDTO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 名称,候选键 + */ + private String contactperson; + /** + * 职务 + */ + private String contactpersonpost; + /** + * 部门 + */ + private String dept; + /** + * 联系电话,候选键 + */ + private String phone; + /** + * 默认 + */ + @JsonProperty("isdefault_linkman") + private Boolean isdefaultLinkman; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiCustomerQueryDTO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiCustomerQueryDTO.java new file mode 100644 index 00000000..fdb21b8d --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiCustomerQueryDTO.java @@ -0,0 +1,87 @@ +/** + * 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.pojo.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; + +/** + * 业财融合(BFI)A902-1 客户查询参数 + * + * @author Chill + */ +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BfiCustomerQueryDTO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 【必填】页码,从1开始 + */ + private Integer pageNo; + /** + * 【必填】页数 + */ + private Integer pageSize; + /** + * 使用状态[0:禁用, 1:可用] + */ + private String enable; + /** + * 数据状态[A:暂存, B:已提交, C:已审核] + */ + private String status; + /** + * 编码 + */ + private List number; + /** + * 创建组织.编码,城矿传 WCCK(BfiConstant.DATA_SOURCE_WCCK) + */ + @JsonProperty("createorg_number") + private String createorgNumber; + /** + * 统一社会信用代码(个人填写身份证号或手机号) + */ + private List societycreditcode; + /** + * 身份证号 + */ + private List idno; + /** + * 修改时间-开始,格式:yyyy-MM-dd HH:mm:ss,限定时间范围不可超过6个月 + */ + private String modifytimeS; + /** + * 修改时间-结束,格式:yyyy-MM-dd HH:mm:ss + */ + private String modifytimeE; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiCustomerSaveBodyDTO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiCustomerSaveBodyDTO.java new file mode 100644 index 00000000..0dba631c --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiCustomerSaveBodyDTO.java @@ -0,0 +1,49 @@ +/** + * 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.pojo.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; + +/** + * 业财融合(BFI)A902-2 客户保存请求体,结构为 data 数组 + * + * @author Chill + */ +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BfiCustomerSaveBodyDTO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 客户数据 + */ + private List data; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiCustomerSaveDTO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiCustomerSaveDTO.java new file mode 100644 index 00000000..102fd1a9 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiCustomerSaveDTO.java @@ -0,0 +1,206 @@ +/** + * 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.pojo.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; + +/** + * 业财融合(BFI)A902-2 客户保存参数 + *

+ * 请求示例: + *

{@code
+ * {
+ *   "data": [
+ *     {
+ *       "bizpartner_phone": "",
+ *       "societycreditcode": "3253252",
+ *       "awrp_cusfortrade": "A",
+ *       "bankaccount": "test111114",
+ *       "bizpartner_address": "test111114",
+ *       "linkman": "",
+ *       "bizfunction": "1,2,3,4",
+ *       "artificialperson": "test111114",
+ *       "accountname": "test111114",
+ *       "fcusbeizhu": "",
+ *       "name": "test111114",
+ *       "postal_code": "",
+ *       "type": "6",
+ *       "entry_groupstandard": [
+ *         {
+ *           "standardid_number": "JBFLBZ",
+ *           "groupid_number": "1102"
+ *         }
+ *       ]
+ *     }
+ *   ]
+ * }
+ * }
+ * + * @author Chill + */ +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BfiCustomerSaveDTO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 编码,不传由星瀚生成(如 Cus-20251230-0001) + */ + private String number; + /** + * 【必填】名称,城矿对应 b_customer_info.customer_name + */ + private String name; + /** + * 数据状态[A:暂存, B:已提交, C:已审核],城矿传 B(BfiConstant.BILL_STATUS_SUBMITTED) + */ + private String status; + /** + * 【必填】伙伴类型[1:法人企业, 2:非法人企业, 3:非企业单位, 4:个人, 5:个体户, 6:民营企业, 7:非我区国有企业, 8:我区国有企业, 9:政府事业单位等, 10:农民专业合作社, 11:民政部门, 12:其他, 13:境外客商, 14:工会, 15:司法行政, 16:虚拟客商, 17:集体经济组织],需与财务确认,城矿常用 4/6/8/9/16 + */ + private String type; + /** + * 详细地址,城矿对应 b_customer_info.registration_address + */ + @JsonProperty("bizpartner_address") + private String bizpartnerAddress; + /** + * 联系人,城矿对应 b_customer_info.contact_person + */ + private String linkman; + /** + * 联系电话,城矿对应 b_customer_info.contact_phone + */ + @JsonProperty("bizpartner_phone") + private String bizpartnerPhone; + /** + * 传真 + */ + @JsonProperty("bizpartner_fax") + private String bizpartnerFax; + /** + * 【必填】统一社会信用代码(个人填写身份证号或手机号),候选键,城矿对应 b_customer_info.registration_code + */ + private String societycreditcode; + /** + * 纳税人识别号,统一社会信用代码存在时需推送 + */ + @JsonProperty("tx_register_no") + private String txRegisterNo; + /** + * 法人代表,城矿对应 b_customer_info.legal_representative + */ + private String artificialperson; + /** + * 营业期限 + */ + private String businessterm; + /** + * 经营范围 + */ + private String businessscope; + /** + * 成立日期,格式:yyyy-MM-dd HH:mm:ss + */ + private String establishdate; + /** + * 业务职能,多选英文逗号隔开[1:销售, 2:结算, 3:付款, 4:收货],城矿全传 1,2,3,4 + */ + private String bizfunction; + /** + * 电子邮箱,城矿对应 b_customer_info.email + */ + @JsonProperty("postal_code") + private String postalCode; + /** + * 身份证号,候选键 + */ + private String idno; + /** + * 备注,城矿对应 b_customer_info.remarks + */ + private String fcusbeizhu; + /** + * 数据来源系统[XH:星瀚, WCY:物产云, JCZX:检测中心, NBERP:南北ERP, WCCK:城矿ERP, WY:维云(服务), CMS:合同系统, DTP:数据中台],城矿传 WCCK(BfiConstant.DATA_SOURCE_WCCK) + */ + @JsonProperty("awrp_datasourcesys") + private String awrpDatasourcesys; + /** + * 【必填】是否为贸易客商[A:是, B:否],城矿传 A(BfiConstant.CUS_FOR_TRADE_YES) + */ + @JsonProperty("awrp_cusfortrade") + private String awrpCusfortrade; + /** + * 供应商编码 + */ + @JsonProperty("awrp_supplierno") + private String awrpSupplierno; + /** + * 新客户编码 + */ + @JsonProperty("awrp_newnumber") + private String awrpNewnumber; + /** + * 客户分组.编码,如 1102 + */ + @JsonProperty("group_number") + private String groupNumber; + /** + * 创建组织.编码,城矿传 WCCK + */ + @JsonProperty("createorg_number") + private String createorgNumber; + /** + * 内部客户 + */ + private Boolean chkisinternalcompany; + /** + * 内部业务单元.编码 + */ + @JsonProperty("internal_company_number") + private String internalCompanyNumber; + /** + * 【必填】分类标准分录,城矿固定传 standardid_number=JBFLBZ、groupid_number=1102(外部客户)(BfiConstant.GROUP_STANDARD_BASIC / CUSTOMER_GROUP_EXTERNAL) + */ + @JsonProperty("entry_groupstandard") + private List entryGroupstandard; + /** + * 银行信息分录,可不传 + */ + @JsonProperty("entry_bank") + private List entryBank; + /** + * 联系人信息分录,可不传 + */ + @JsonProperty("entry_linkman") + private List entryLinkman; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiExchangeRateQueryDTO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiExchangeRateQueryDTO.java new file mode 100644 index 00000000..b07e8e24 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiExchangeRateQueryDTO.java @@ -0,0 +1,56 @@ +/** + * 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.pojo.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 业财融合(BFI)A801-4 汇率查询参数 + * + * @author Chill + */ +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BfiExchangeRateQueryDTO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + + + /** + * 生效日期-开始,格式:yyyy-MM-dd + */ + private String effectdateS; + /** + * 生效日期-结束,格式:yyyy-MM-dd + */ + private String effectdateE; + + private String pageSize; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiInvoiceApplyOpenApiBodyDTO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiInvoiceApplyOpenApiBodyDTO.java new file mode 100644 index 00000000..5f10fa6d --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiInvoiceApplyOpenApiBodyDTO.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.thirdparty.bfi.pojo.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 业财融合(BFI)A303-5 开票申请发票查询请求体 + *

+ * 请求示例(data 为原文 {"serialNo":"KPSQ-JCZX-202509-002270,KPSQ-JCZX-202509-002276","sellerTaxpayerId":"91450000MA5Q1G1M59"} Base64 编码后的字符串): + *

{@code
+ * {
+ *   "businessSystemCode": "XDWL02",
+ *   "interfaceCode": "BILL.INVOICE.QUERY",
+ *   "data": "ewogICAgICAgICJzZXJpYWxObyI6ICJLUFNRLUpDWlgtMjAyNTA5LTAwMjI3MCxLUFNRLUpDWlgtMjAyNTA5LTAwMjI3NiIsCiAgICAgICAgInNlbGxlclRheHBheWVySWQiOiAiOTE0NTAwMDBNQTVRMUcxTTU5IgogICAgfQ=="
+ * }
+ * }
+ * + * @author Chill + */ +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BfiInvoiceApplyOpenApiBodyDTO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 请求id + */ + private String requestId; + /** + * 【必填】来源系统,城矿传 WCCK + */ + @JsonProperty("businessSystemCode") + private String businessSystemCode; + /** + * 【必填】接口编码,固定为 BILL.INVOICE.QUERY + */ + @JsonProperty("interfaceCode") + private String interfaceCode; + /** + * 【必填】请求 data,内容需加密成 base64 字符串 + */ + private String data; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiInvoiceApplyOpenApiQueryDTO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiInvoiceApplyOpenApiQueryDTO.java new file mode 100644 index 00000000..d30e7442 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiInvoiceApplyOpenApiQueryDTO.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.thirdparty.bfi.pojo.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 业财融合(BFI)A303-5 开票申请发票查询 data 原文参数(需序列化为 JSON 后 Base64 编码放入请求体 data 字段) + * + * @author Chill + */ +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BfiInvoiceApplyOpenApiQueryDTO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 【必填】单据编号,即开票申请单的 billno 字段,多个用英文逗号隔开,上限50个 + */ + private String serialNo; + /** + * 【必填】销方税号,即开票申请单的 salertaxno 字段 + */ + private String sellerTaxpayerId; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiInvoiceApplyQueryDTO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiInvoiceApplyQueryDTO.java new file mode 100644 index 00000000..99953850 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiInvoiceApplyQueryDTO.java @@ -0,0 +1,74 @@ +/** + * 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.pojo.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; + +/** + * 业财融合(BFI)A303-4 开票申请单查询参数 + *

+ * 请求示例: + *

{@code
+ * GET /ierp/kapi/v2/awrp/sim/sim_original_bill/query?systemsource=WCCK&pageSize=10&pageNo=1
+ * }
+ * + * @author Chill + */ +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BfiInvoiceApplyQueryDTO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 【必填】页码,从1开始 + */ + private Integer pageNo; + /** + * 【必填】页数 + */ + private Integer pageSize; + /** + * 业务单据编号,高频查询需要推送该字段 + */ + private List billno; + /** + * 数据来源系统,城矿传 WCCK + */ + private String systemsource; + /** + * 单据日期-开始,格式:yyyy-MM-dd HH:mm:ss,限定时间范围不可超过1个月 + */ + private String billdateS; + /** + * 单据日期-结束,格式:yyyy-MM-dd HH:mm:ss + */ + private String billdateE; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiInvoiceApplySaveBodyDTO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiInvoiceApplySaveBodyDTO.java new file mode 100644 index 00000000..d430026c --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiInvoiceApplySaveBodyDTO.java @@ -0,0 +1,49 @@ +/** + * 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.pojo.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; + +/** + * 业财融合(BFI)A303-1 开票申请单新增请求体,结构为 data 数组 + * + * @author Chill + */ +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BfiInvoiceApplySaveBodyDTO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 开票申请单数据 + */ + private List data; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiInvoiceApplySaveDTO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiInvoiceApplySaveDTO.java new file mode 100644 index 00000000..7b1ec53a --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiInvoiceApplySaveDTO.java @@ -0,0 +1,322 @@ +/** + * 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.pojo.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.List; + +/** + * 业财融合(BFI)A303-1 开票申请单新增参数 + *

+ * 请求示例: + *

{@code
+ * {
+ *   "data": [
+ *     {
+ *       "orgid_number": "01.01.21.01",
+ *       "awrp_orgfield_number": "YS.01.04.09",
+ *       "billno": "test-0204-m1",
+ *       "billdate": "2025-09-08 00:00:00",
+ *       "billproperties": "1",
+ *       "invoicetype": "08xdp",
+ *       "taxationstyle": "0",
+ *       "buyeremail": "11@qq.com",
+ *       "buyerproperty": "0",
+ *       "buyertaxno": "91450881MA7BFT2629",
+ *       "buyername": "广西贵港建设集团有限公司桂平分公司",
+ *       "salername": "广西壮族自治区建筑工程质量检测中心有限公司",
+ *       "salertaxno": "91450000MA5Q1G1M59",
+ *       "totalamount": 250.0,
+ *       "systemsource": "WCCK",
+ *       "customnameid_number": "K01.00-10-0037",
+ *       "awrp_project_number": "XDWL-GC-2025-0070",
+ *       "sim_original_bill_item": [
+ *         {
+ *           "awrp_src_con_eno": "0204-m1",
+ *           "rowtype": "2",
+ *           "taxratecodeid_number": "304060299",
+ *           "unit": "项",
+ *           "num": 1.0,
+ *           "taxunitprice": 250.0,
+ *           "taxamount": 250.0,
+ *           "taxrate": "0.06",
+ *           "goodsname": "检测费",
+ *           "goodssimplename": "鉴证咨询服务",
+ *           "benchmark": "0",
+ *           "awrp_src_type": "0KzXs"
+ *         }
+ *       ]
+ *     }
+ *   ]
+ * }
+ * }
+ * + * @author Chill + */ +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BfiInvoiceApplySaveDTO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 【必填】组织.编码,候选键,如 01.01.21.01 + */ + @JsonProperty("orgid_number") + private String orgidNumber; + /** + * 【必填】业务单据编号,候选键,如 KPSQ-JCZX-202509-002283 + */ + private String billno; + /** + * 【必填】单据日期,格式:yyyy-MM-dd HH:mm:ss + */ + private String billdate; + /** + * 【必填】单据性质[-1:负数, 1:正数] + */ + private String billproperties; + /** + * 【必填】发票种类[10xdp:数电发票(普通发票), 08xdp:数电发票(增值税专用发票)](BfiConstant.INVOICE_TYPE_DIGITAL_GENERAL / INVOICE_TYPE_DIGITAL_SPECIAL) + */ + private String invoicetype; + /** + * 【必填】征税方式[0:普通征税, 1:减按计征, 2:差额征税, 01:差额征税-全额开票, 02:差额征税-差额开票, 3:减按征税] + */ + private String taxationstyle; + /** + * 开票人,留空不传 + */ + private String drawer; + /** + * 收款人 + */ + private String payee; + /** + * 复核人 + */ + private String reviewer; + /** + * 【必填】发票推送邮箱,邮箱必填 + */ + private String buyeremail; + /** + * 发票备注,即发票上的备注信息 + */ + private String invoiceremark; + /** + * 交付手机,手机非必填(按集团要求手机交付取消) + */ + private String buyerphone; + /** + * 【必填】购方企业类型[0:企业, 1:个人, 2:非企业单位] + */ + private String buyerproperty; + /** + * 【必填】购方纳税人识别号 + */ + private String buyertaxno; + /** + * 展示买方地址电话 + */ + private Boolean showbuyeraddresstel; + /** + * 购方地址及电话 + */ + private String buyeraddr; + /** + * 展示买方银行 + */ + private Boolean showbuyerbank; + /** + * 购方开户行及账号 + */ + private String buyerbank; + /** + * 【必填】销方名称 + */ + private String salername; + /** + * 【必填】销方纳税人识别号 + */ + private String salertaxno; + /** + * 展示卖方地址电话 + */ + private Boolean showsaleraddresstel; + /** + * 销方地址及电话 + */ + private String saleraddr; + /** + * 展示卖方银行 + */ + private Boolean showsalerbank; + /** + * 销方开户行及账号 + */ + private String salerbank; + /** + * 【必填】申请价税合计 + */ + private BigDecimal totalamount; + /** + * 申请金额 + */ + private BigDecimal invoiceamount; + /** + * 申请税额 + */ + private BigDecimal totaltax; + /** + * 特殊票种[00:非特殊票种, 02:收购, 06:抵扣通行费, 07:不抵扣通行费, 08:成品油, 11:卷烟, 18:机动车, E06:不动产租赁, E03:建筑服务, E05:不动产销售, E04:货物运输, E01:成品油(数电发票), E18:卷烟(数电发票), E12:自产农产品销售, E09:旅客运输, E14:机动车(数电发票), E07:代收车船税] + */ + private String specialtype; + /** + * 【必填】购方名称 + */ + private String buyername; + /** + * 原始价税合计 + */ + private BigDecimal oldtotalamount; + /** + * 剩余可开金额 + */ + private BigDecimal surplusamount; + /** + * 剩余可开税额 + */ + private BigDecimal surplustax; + /** + * 数据来源,固定3(BfiConstant.INVOICE_BILL_SOURCE) + */ + private String billsource; + /** + * 数据来源系统,城矿传 WCCK + */ + private String systemsource; + /** + * 单据完整[0:完整, 1:待补充] + */ + private String billcomplete; + /** + * 是否含税[0:不含税, 1:含税] + */ + private String hsbz; + /** + * 商品类型[1:开票项, 2:物料, 3:费用项目] + */ + private String producttype; + /** + * 税额微调 + */ + private Boolean taxadjust; + /** + * 【必填】客户.编码 + */ + @JsonProperty("customnameid_number") + private String customnameidNumber; + /** + * 合同日期,格式:yyyy-MM-dd + */ + private String contractdate; + /** + * 合同编号 + */ + private String contractno; + /** + * 合同金额 + */ + private BigDecimal contractamount; + /** + * 【必填】项目.项目编码 + */ + @JsonProperty("awrp_project_number") + private String awrpProjectNumber; + /** + * 销售组织.编码 + */ + @JsonProperty("salesorgbase_number") + private String salesorgbaseNumber; + /** + * 结算组织.编码 + */ + @JsonProperty("settlementorgbase_number") + private String settlementorgbaseNumber; + /** + * 资金组织.编码 + */ + @JsonProperty("capitalorgbase_number") + private String capitalorgbaseNumber; + /** + * 经办人.经办人编码 + */ + @JsonProperty("operator_number") + private String operatorNumber; + /** + * 开票业务管控.编码 + */ + @JsonProperty("bizcontroltype_number") + private String bizcontroltypeNumber; + /** + * 【必填】申请部门.编码 + */ + @JsonProperty("awrp_orgfield_number") + private String awrpOrgfieldNumber; + /** + * 来源单据[ar_finarbill:财务应收单] + */ + @JsonProperty("awrp_src_bt") + private String awrpSrcBt; + /** + * 上游外部系统单据号 + */ + @JsonProperty("awrp_src_con_no") + private String awrpSrcConNo; + /** + * 单据体分录 + */ + @JsonProperty("sim_original_bill_item") + private List simOriginalBillItem; + /** + * 货物运输单据体(特殊票种 E04 货物运输时填写) + */ + private List freights; + /** + * 旅客运输单据体(特殊票种 E09 旅客运输时填写) + */ + private List travelers; + /** + * 代收车船税单据体(特殊票种 E07 代收车船税时填写) + */ + private List vehichevesselships; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiInvoiceApplySaveEntryDTO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiInvoiceApplySaveEntryDTO.java new file mode 100644 index 00000000..4ea5c855 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiInvoiceApplySaveEntryDTO.java @@ -0,0 +1,165 @@ +/** + * 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.pojo.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.math.BigDecimal; + +/** + * 业财融合(BFI)A303-1 开票申请单新增 单据体分录 + * + * @author Chill + */ +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BfiInvoiceApplySaveEntryDTO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 费用项目.编码 + */ + @JsonProperty("expenseitem_number") + private String expenseitemNumber; + /** + * 【必填】税收分类编码.编码,如 304060299 + */ + @JsonProperty("taxratecodeid_number") + private String taxratecodeidNumber; + /** + * 规格型号 + */ + private String specification; + /** + * 计量单位,如 项 + */ + private String unit; + /** + * 数量,为折扣行时填0,其他按实际填写 + */ + private BigDecimal num; + /** + * 单价(含税) + */ + private BigDecimal taxunitprice; + /** + * 金额(含税) + */ + private BigDecimal taxamount; + /** + * 税率[0:0%, 0.01:1%, 0.015:减按1.5%, 0.03:3%, 0.04:4%, 0.05:5%, 0.06:6%, 0.09:9%, 0.10:10%, 0.11:11%, 0.13:13%, 0.16:16%, 0.17:17%] + */ + private String taxrate; + /** + * 税额 + */ + private BigDecimal tax; + /** + * 是否享受优惠[0:不享受, 1:享受] + */ + private String policylogo; + /** + * 金额(不含税) + */ + private BigDecimal amount; + /** + * 单价(不含税) + */ + private BigDecimal unitprice; + /** + * 税收分类合并编码 + */ + private String goodscode; + /** + * 【必填】商品名称 + */ + private String goodsname; + /** + * 【必填】简称,如 鉴证咨询服务 + */ + private String goodssimplename; + /** + * 【必填】基准类型[0:数量基准, 1:金额基准] + */ + private String benchmark; + /** + * 优惠政策内容[免税, 不征税, 简易征收, 按3%简易征收, 按5%简易征收, 按5%简易征收减按1.5%计征, 稀土产品, 先征后退, 50%先征后退, 100%先征后退, 即征即退30%, 即征即退50%, 即征即退70%, 即征即退100%, 超税负3%即征即退, 超税负6%即征即退, 超税负8%即征即退, 超税负12%即征即退] + */ + private String policycontants; + /** + * 剩余可申请数量,为折扣行时填0,其他按实际填写 + */ + private BigDecimal remainvalidnum; + /** + * 剩余可申请金额,根据单据含税标记(hsbz)赋值:含税时=含税金额,不含税时=不含税金额 + */ + private BigDecimal remainvalidamount; + /** + * 剩余可申请税额 + */ + private BigDecimal remainvalidtax; + /** + * 商品编码.商品编码,根据 producttype 字段判断是否填写 + */ + @JsonProperty("goodsid_number") + private String goodsidNumber; + /** + * 物料编码.编码,根据 producttype 字段判断是否填写 + */ + @JsonProperty("materielfield_number") + private String materielfieldNumber; + /** + * 【必填】行性质[1:折扣行, 2:商品行],折扣行需紧跟被折扣行后面 + */ + private String rowtype; + /** + * 备注 + */ + private String remark; + /** + * 明细扣除额 + */ + private BigDecimal itemdeduction; + /** + * 上游分录号 + */ + @JsonProperty("awrp_src_con_eno") + private String awrpSrcConEno; + /** + * 外部系统来源分录标识 + */ + @JsonProperty("awrp_entry_code") + private String awrpEntryCode; + /** + * 【必填】外部单据类型,固定 0KzXs(BfiConstant.AWRP_SRC_TYPE_EXTERNAL) + */ + @JsonProperty("awrp_src_type") + private String awrpSrcType; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiInvoiceApplySaveFreightDTO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiInvoiceApplySaveFreightDTO.java new file mode 100644 index 00000000..390c49f3 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiInvoiceApplySaveFreightDTO.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.thirdparty.bfi.pojo.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 业财融合(BFI)A303-1 开票申请单新增 货物运输单据体(特殊票种 E04 货物运输时填写) + * + * @author Chill + */ +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BfiInvoiceApplySaveFreightDTO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 星瀚内码 + */ + private Long id; + /** + * 分录行号 + */ + private Integer seq; + /** + * 运输工具种类[铁路运输:铁路运输, 公路运输:公路运输, 水路运输:水路运输, 航空运输:航空运输, 管道运输:管道运输, 其它运输工具:其它运输工具] + */ + private String transporttype; + /** + * 运输工具牌号 + */ + private String licenseplate; + /** + * 运输货物名称 + */ + private String transportgoods; + /** + * 起运地 + */ + private String startplace; + /** + * 到达地 + */ + private String endplace; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiInvoiceApplySaveTravelerDTO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiInvoiceApplySaveTravelerDTO.java new file mode 100644 index 00000000..a0d2cd48 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiInvoiceApplySaveTravelerDTO.java @@ -0,0 +1,88 @@ +/** + * 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.pojo.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 业财融合(BFI)A303-1 开票申请单新增 旅客运输单据体(特殊票种 E09 旅客运输时填写) + * + * @author Chill + */ +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BfiInvoiceApplySaveTravelerDTO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 星瀚内码 + */ + private Long id; + /** + * 分录行号 + */ + private Integer seq; + /** + * 出行人 + */ + private String traveler; + /** + * 出行人证件类型 + */ + private String travelercardtype; + /** + * 有效身份证件号 + */ + private String travelercardno; + /** + * 出行日期,格式:yyyy-MM-dd + */ + private String traveldate; + /** + * 旅客出发地 + */ + private String travelerstartplace; + /** + * 旅客到达地 + */ + private String travelerendplace; + /** + * 交通工具类型[1:飞机, 2:火车, 3:长途汽车, 4:公共交通, 5:出租车, 6:汽车, 7:船舶, 9:其他] + */ + private String travelertransporttype; + /** + * 等级[公务舱:飞机-公务舱, 头等舱:飞机-头等舱, 经济舱:飞机-经济舱, 一等舱:船舶-一等舱, 二等舱:船舶-二等舱, 三等舱:船舶-三等舱, 一等座:火车-一等座, 二等座:火车-二等座, 软席(软座、软卧):火车-软席(软座、软卧), 硬席(硬座、硬卧):火车-硬席(硬座、硬卧)] + */ + private String travelerseatclass; + /** + * 等级(自定义文本) + */ + private String travelerseatclassstr; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiInvoiceApplySaveVehicheVesselShipDTO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiInvoiceApplySaveVehicheVesselShipDTO.java new file mode 100644 index 00000000..e1114bae --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiInvoiceApplySaveVehicheVesselShipDTO.java @@ -0,0 +1,89 @@ +/** + * 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.pojo.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.math.BigDecimal; + +/** + * 业财融合(BFI)A303-1 开票申请单新增 代收车船税单据体(特殊票种 E07 代收车船税时填写) + * + * @author Chill + */ +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BfiInvoiceApplySaveVehicheVesselShipDTO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 星瀚内码 + */ + private Long id; + /** + * 分录行号 + */ + private Integer seq; + /** + * 保险单号 + */ + private String policyno; + /** + * 车牌号/船舶登记号 + */ + private String shipsno; + /** + * 税款所属期 + */ + private String perioddate; + /** + * 车辆识别代码/车架号码 + */ + private String vehiclecode; + /** + * 代收车船税金额 + */ + private BigDecimal vehiclevesselamount; + /** + * 滞纳金金额 + */ + private BigDecimal vehiclelateamount; + /** + * 金额合计 + */ + private BigDecimal vehicletotalamount; + /** + * 税款所属期起,格式:yyyy-MM-dd + */ + private String periodstartdate; + /** + * 税款所属期止,格式:yyyy-MM-dd + */ + private String periodenddate; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiInvoiceAssignBodyDTO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiInvoiceAssignBodyDTO.java new file mode 100644 index 00000000..8a5df05a --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiInvoiceAssignBodyDTO.java @@ -0,0 +1,71 @@ +/** + * 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.pojo.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; + +/** + * 业财融合(BFI)A302-6 收票指定应付请求体 + *

+ * 请求示例: + *

{@code
+ * {
+ *   "data": [
+ *     {
+ *       "ap_invoice": [
+ *         { "awrp_src_code": "test-m", "awrp_src_sys": "WCCK" }
+ *       ],
+ *       "ap_finapbill": [
+ *         { "awrp_src_code": "test-ap-m", "awrp_src_sys": "WCCK" }
+ *       ]
+ *     }
+ *   ],
+ *   "op": "unassign"
+ * }
+ * }
+ * + * @author Chill + */ +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BfiInvoiceAssignBodyDTO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 【必填】指定关系数据 + */ + private List data; + /** + * 【必填】操作类型[assign:指定, unassign:取消指定] + */ + private String op; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiInvoiceAssignItemDTO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiInvoiceAssignItemDTO.java new file mode 100644 index 00000000..6818170a --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiInvoiceAssignItemDTO.java @@ -0,0 +1,56 @@ +/** + * 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.pojo.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; + +/** + * 业财融合(BFI)A302-6 收票指定应付 指定关系项 + * + * @author Chill + */ +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BfiInvoiceAssignItemDTO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 【必填】收票单(来源系统单号+来源系统编码) + */ + @JsonProperty("ap_invoice") + private List apInvoice; + /** + * 【必填】财务应付单(来源系统单号+来源系统编码),op 为 assign 时必录 + */ + @JsonProperty("ap_finapbill") + private List apFinapbill; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiInvoiceAssignRefDTO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiInvoiceAssignRefDTO.java new file mode 100644 index 00000000..954b7b7f --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiInvoiceAssignRefDTO.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. + *

+ * 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.pojo.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 业财融合(BFI)A302-6 收票指定应付 单据引用(来源系统编码+单号) + * + * @author Chill + */ +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BfiInvoiceAssignRefDTO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 【必填】来源系统单号 + */ + @JsonProperty("awrp_src_code") + private String awrpSrcCode; + /** + * 【必填】来源系统编码 + */ + @JsonProperty("awrp_src_sys") + private String awrpSrcSys; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiInvoiceDownloadDTO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiInvoiceDownloadDTO.java new file mode 100644 index 00000000..d1f3165e --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiInvoiceDownloadDTO.java @@ -0,0 +1,56 @@ +/** + * 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.pojo.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 业财融合(BFI)A801-2 进项发票下载(文件下载)参数 + *

+ * 请求示例: + *

{@code
+ * {
+ *   "path": "234e244b2c803400"
+ * }
+ * }
+ * + * @author Chill + */ +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BfiInvoiceDownloadDTO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 【必填】文件url,如票池发票的 downloadUrl/imageUrl + */ + private String path; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiInvoicePoolQueryDTO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiInvoicePoolQueryDTO.java new file mode 100644 index 00000000..c7737e12 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiInvoicePoolQueryDTO.java @@ -0,0 +1,58 @@ +/** + * 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.pojo.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 业财融合(BFI)A801-1 查询全票池发票数据参数 + *

+ * 收票 rim/message 契约请求体,字段明细参考票易通文档 https://open.piaozone.com/api-145421056 + * + * @author Chill + */ +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BfiInvoicePoolQueryDTO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 请求类型,固定值 invoiceQuery,由服务端填充 + */ + private String messageType; + /** + * 请求id,唯一标识,由服务端填充 + */ + private String messageId; + /** + * 【必填】查询参数主体 + */ + private BfiInvoicePoolQueryDataDTO queryParam; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiInvoicePoolQueryDataDTO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiInvoicePoolQueryDataDTO.java new file mode 100644 index 00000000..dde54423 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiInvoicePoolQueryDataDTO.java @@ -0,0 +1,139 @@ +/** + * 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.pojo.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; + +/** + * 业财融合(BFI)A801-1 查询全票池发票数据 查询条件主体 + *

+ * 字段明细参考票易通文档 https://open.piaozone.com/api-145421056 + * + * @author Chill + */ +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BfiInvoicePoolQueryDataDTO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 页码,从1开始 + */ + private Integer pageNo; + /** + * 每页数量,最大500 + */ + private Integer pageSize; + /** + * 开票日期起 yyyy-MM-dd + */ + private String invoiceDateStart; + /** + * 开票日期止 yyyy-MM-dd + */ + private String invoiceDateEnd; + /** + * 发票类型编码 + */ + private String invoiceType; + /** + * 增值税发票状态 + */ + private String invoiceStatus; + /** + * 发票代码 + */ + private String invoiceCode; + /** + * 发票号码 + */ + private String invoiceNo; + /** + * 增值税用途勾选状态[0未勾选,1已勾选,2勾选认证,3扫描认证,4预勾选] + */ + private String authenticateFlag; + /** + * 单据使用状态,多个用英文逗号分隔[1未用,30在用,60已用,65已入账] + */ + private String expenseStatus; + /** + * 核算组织编码数组 + */ + private List orgNumber; + /** + * 采集时间起 yyyy-MM-dd + */ + private String createTimeStart; + /** + * 采集时间止 yyyy-MM-dd(票开放平台原始拼写 creatTimeEnd) + */ + private String creatTimeEnd; + /** + * 采集用户编码 + */ + private String creatorNo; + /** + * 购方税号 + */ + private String buyerTaxNo; + /** + * 销方税号 + */ + private String salerTaxNo; + /** + * 发票流水号数组 + */ + private List serialNos; + /** + * 勾选时间起 yyyy-MM-dd + */ + private String selectTimeStart; + /** + * 勾选时间止 yyyy-MM-dd + */ + private String selectTimeEnd; + /** + * 抵扣税期 YYYY-MM + */ + private String taxPeriod; + /** + * 更新时间起 yyyy-MM-dd HH:mm:ss + */ + private String updateTime; + /** + * 更新时间止 yyyy-MM-dd HH:mm:ss + */ + private String updateTimeEnd; + /** + * 是否查询发票附件[1是,0否] + */ + private String queryAttach; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiPayApplyPushBodyDTO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiPayApplyPushBodyDTO.java new file mode 100644 index 00000000..06f30c23 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiPayApplyPushBodyDTO.java @@ -0,0 +1,49 @@ +/** + * 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.pojo.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; + +/** + * 业财融合(BFI)A103-1 付款申请保存(根据星瀚应付下推付款申请)请求体,结构为 data 数组 + * + * @author Chill + */ +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BfiPayApplyPushBodyDTO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 单据数据 + */ + private List data; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiPayApplyPushDTO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiPayApplyPushDTO.java new file mode 100644 index 00000000..fe00d2f2 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiPayApplyPushDTO.java @@ -0,0 +1,112 @@ +/** + * 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.pojo.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; + +/** + * 业财融合(BFI)A103-1 付款申请保存(根据星瀚应付下推付款申请)参数 + * + * @author Chill + */ +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BfiPayApplyPushDTO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 【必填】外部系统来源单据号,与 awrp_src_sys 组成候选键用于防重,不能重复 + */ + @JsonProperty("awrp_src_code") + private String awrpSrcCode; + /** + * 【必填】外部系统编号 + */ + @JsonProperty("awrp_src_sys") + private String awrpSrcSys; + /** + * 【必填】申请日期,格式:yyyy-MM-dd + */ + private String applydate; + /** + * 付款组织.编码,不传时根据应付单自动带出 + */ + @JsonProperty("payorg_number") + private String payorgNumber; + /** + * 采购组织.编码,不传时根据应付单自动带出 + */ + @JsonProperty("purorg_number") + private String purorgNumber; + /** + * 采购组.编码,可不传 + */ + @JsonProperty("purdept_number") + private String purdeptNumber; + /** + * 部门.编码 + */ + @JsonProperty("awrp_department_number") + private String awrpDepartmentNumber; + /** + * 请款事由 + */ + private String applycause; + /** + * 汇率表.编码,不传时根据应付单自动带出 + */ + @JsonProperty("exratetable_number") + private String exratetableNumber; + /** + * 付款渠道 + */ + @JsonProperty("awrp_textfield1") + private String awrpTextfield1; + /** + * 申请人工号 + */ + @JsonProperty("creator_number") + private String creatorNumber; + /** + * 申请人 + */ + @JsonProperty("creator_name") + private String creatorName; + /** + * 汇率日期,格式:yyyy-MM-dd,不传时根据应付单自动带出 + */ + private String exratedate; + /** + * 付款申请分录 + */ + private List entry; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiPayApplyPushEntryDTO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiPayApplyPushEntryDTO.java new file mode 100644 index 00000000..19b565e0 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiPayApplyPushEntryDTO.java @@ -0,0 +1,117 @@ +/** + * 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.pojo.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.math.BigDecimal; + +/** + * 业财融合(BFI)A103-1 付款申请保存(根据星瀚应付下推付款申请)分录参数 + * + * @author Chill + */ +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BfiPayApplyPushEntryDTO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 【必填】付款类型.编码,如 201 采购付款(BfiConstant.PAYMENT_TYPE_FORMAL) + */ + @JsonProperty("e_paymenttype_number") + private String ePaymenttypeNumber; + /** + * 【必填】结算单分录id,传来源系统结算单分录id,用于关联星瀚对应的应付单行, + * 对应财务应付单传的 awrp_entry_code 字段 + */ + @JsonProperty("awrp_finapbill_entryid") + private String awrpFinapbillEntryid; + /** + * 【必填】对方系统唯一行id,不能重复 + */ + @JsonProperty("awrp_entry_code") + private String awrpEntryCode; + /** + * 【必填】往来账户 + */ + @JsonProperty("e_assacct") + private String eAssacct; + /** + * 【必填】往来银行.编码 + */ + @JsonProperty("e_bebank_number") + private String eBebankNumber; + /** + * 【必填】申请金额 + */ + @JsonProperty("e_applyamount") + private BigDecimal eApplyamount; + /** + * 期望付款日,格式:yyyy-MM-dd,不传时根据应付单自动带出 + */ + @JsonProperty("e_expaydate") + private String eExpaydate; + /** + * 到期日,格式:yyyy-MM-dd,不传时根据应付单自动带出 + */ + @JsonProperty("e_duedate") + private String eDuedate; + /** + * 结算方式.编码,不传时根据应付单自动带出 + */ + @JsonProperty("e_settlementtype_number") + private String eSettlementtypeNumber; + /** + * 【必填】项目.项目编码,必填,若不涉及项目传编码 999(BfiConstant.PROJECT_NOT_APPLICABLE) + */ + @JsonProperty("project_number") + private String projectNumber; + /** + * 成本中心.编码,不传时根据应付单自动带出 + */ + @JsonProperty("e_costcenter_number") + private String eCostcenterNumber; + /** + * 备注 + */ + @JsonProperty("e_remark") + private String eRemark; + /** + * 商品类别.编码 + */ + @JsonProperty("awrp_splb_number") + private String awrpSplbNumber; + /** + * 【必填】外部单据类型,固定 0KzXs(BfiConstant.AWRP_SRC_TYPE_EXTERNAL) + */ + @JsonProperty("awrp_src_type") + private String awrpSrcType; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiPayApplyQueryDTO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiPayApplyQueryDTO.java new file mode 100644 index 00000000..a56e7766 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiPayApplyQueryDTO.java @@ -0,0 +1,69 @@ +/** + * 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.pojo.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; + +/** + * 业财融合(BFI)A103-2 付款申请单查询参数 + * + * @author Chill + */ +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BfiPayApplyQueryDTO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + *【必填】 页码,从1开始 + */ + private Integer pageNo; + /** + *【必填】 页数 + */ + private Integer pageSize; + /** + * 来源单据号,业务系统对应的单据号,高频查询需要推送该字段 + */ + private List awrpSrcCode; + /** + * 系统编号 + */ + private String awrpSrcSys; + /** + * 修改时间-开始,格式:yyyy-MM-dd HH:mm:ss,限定时间范围不可超过1个月 + */ + private String modifytimeS; + /** + * 修改时间-结束,格式:yyyy-MM-dd HH:mm:ss,限定时间范围不可超过1个月 + */ + private String modifytimeE; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiPayApplySaveBodyDTO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiPayApplySaveBodyDTO.java new file mode 100644 index 00000000..b3a4e0d5 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiPayApplySaveBodyDTO.java @@ -0,0 +1,49 @@ +/** + * 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.pojo.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; + +/** + * 业财融合(BFI)A103-3 预付款申请(付款申请保存)请求体,结构为 data 数组 + * + * @author Chill + */ +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BfiPayApplySaveBodyDTO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 单据数据 + */ + private List data; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiPayApplySaveDTO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiPayApplySaveDTO.java new file mode 100644 index 00000000..d632b4f4 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiPayApplySaveDTO.java @@ -0,0 +1,142 @@ +/** + * 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.pojo.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; + +/** + * 业财融合(BFI)A103-3 预付款申请(付款申请保存)参数 + * + * @author Chill + */ +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BfiPayApplySaveDTO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + *【必填】 外部系统来源单据号,与 awrp_src_sys 组成候选键用于防重 + */ + @JsonProperty("awrp_src_code") + private String awrpSrcCode; + /** + *【必填】 外部系统编号 + */ + @JsonProperty("awrp_src_sys") + private String awrpSrcSys; + /** + * 结算组织.编码 传组织编码 + */ + @JsonProperty("settleorg_number") + private String settleorgNumber; + /** + *【必填】 单据类型.编码:ap_payapply_BT_S 采购付款申请、ap_payapply_oth_BT_S 其他付款申请(BfiConstant.PAY_APPLY_TYPE_PURCHASE / PAY_APPLY_TYPE_OTHER) + */ + @JsonProperty("billtype_number") + private String billtypeNumber; + /** + *【必填】 申请日期,格式:yyyy-MM-dd + */ + private String applydate; + /** + *【必填】 申请组织.编码 传组织编码 + */ + @JsonProperty("applyorg_number") + private String applyorgNumber; + /** + *【必填】 付款组织.编码 传组织编码 + */ + @JsonProperty("payorg_number") + private String payorgNumber; + /** + *【必填】 采购组织.编码 传组织编码 + */ + @JsonProperty("purorg_number") + private String purorgNumber; + /** + * 采购组.编码,可不传 + */ + @JsonProperty("purdept_number") + private String purdeptNumber; + /** + * 部门.编码 + */ + @JsonProperty("awrp_department_number") + private String awrpDepartmentNumber; + /** + * 请款事由 + */ + private String applycause; + /** + *【必填】 付款币种.编码,如 CNY(BfiConstant.CURRENCY_CNY) + */ + @JsonProperty("paycurrency_number") + private String paycurrencyNumber; + /** + *【必填】 结算币.编码,如 CNY(BfiConstant.CURRENCY_CNY) + */ + @JsonProperty("settlecurrency_number") + private String settlecurrencyNumber; + /** + *【必填】 外部单据类型 0KzXs,固定 0KzXs(BfiConstant.AWRP_SRC_TYPE_EXTERNAL) + */ + @JsonProperty("awrp_src_type") + private String awrpSrcType; + /** + *【必填】 汇率表.编码,如 01(BfiConstant.EXRATE_TABLE_DEFAULT) + */ + @JsonProperty("exratetable_number") + private String exratetableNumber; + /** + *【必填】 汇率日期,格式:yyyy-MM-dd + */ + private String exratedate; + /** + * 付款渠道 + */ + @JsonProperty("awrp_textfield1") + private String awrpTextfield1; + /** + * 来源单据,有上游合同时传 awrp_contract_info + */ + @JsonProperty("awrp_src_bt") + private String awrpSrcBt; + /** + * 上游外部系统单据号,即上游合同号 + */ + @JsonProperty("awrp_src_con_no") + private String awrpSrcConNo; + /** + * 付款申请分录 + */ + private List entry; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiPayApplySaveEntryDTO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiPayApplySaveEntryDTO.java new file mode 100644 index 00000000..40094b5a --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiPayApplySaveEntryDTO.java @@ -0,0 +1,126 @@ +/** + * 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.pojo.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.math.BigDecimal; + +/** + * 业财融合(BFI)A103-3 预付款申请(付款申请保存)分录参数 + * + * @author Chill + */ +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BfiPayApplySaveEntryDTO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + *【必填】 付款类型.编码,预付款传 202(BfiConstant.PAYMENT_TYPE_PREPAY) + */ + @JsonProperty("e_paymenttype_number") + private String ePaymenttypeNumber; + /** + *【必填】 往来类型:bd_supplier 供应商、bos_user 人员、bos_org 公司、bd_customer 客户(BfiConstant.ASSTACT_TYPE_SUPPLIER) + */ + @JsonProperty("e_asstacttype") + private String eAsstacttype; + /** + *【必填】 往来户.编码,根据所选往来类型传对应编码 + */ + @JsonProperty("e_asstact_number") + private String eAsstactNumber; + /** + *【必填】 往来账户 + */ + @JsonProperty("e_assacct") + private String eAssacct; + /** + *【必填】 往来银行.编码 + */ + @JsonProperty("e_bebank_number") + private String eBebankNumber; + /** + * 应付金额 + */ + @JsonProperty("e_payamount") + private BigDecimal ePayamount; + /** + *【必填】 申请金额 + */ + @JsonProperty("e_applyamount") + private BigDecimal eApplyamount; + /** + * 期望付款日,格式:yyyy-MM-dd + */ + @JsonProperty("e_expaydate") + private String eExpaydate; + /** + * 到期日,格式:yyyy-MM-dd + */ + @JsonProperty("e_duedate") + private String eDuedate; + /** + *【必填】 项目.项目编码,必填,若不涉及项目传编码 999 + */ + @JsonProperty("project_number") + private String projectNumber; + /** + * 成本中心.编码 + */ + @JsonProperty("e_costcenter_number") + private String eCostcenterNumber; + /** + * 结算方式.编码 + */ + @JsonProperty("e_settlementtype_number") + private String eSettlementtypeNumber; + /** + * 明细.上游分录号 + */ + @JsonProperty("awrp_src_con_eno") + private String awrpSrcConEno; + /** + *【必填】 对方系统唯一行id,不能重复 + */ + @JsonProperty("awrp_entry_code") + private String awrpEntryCode; + /** + * 商品类别.编码 + */ + @JsonProperty("awrp_splb_number") + private String awrpSplbNumber; + /** + * 备注 + */ + @JsonProperty("e_remark") + private String eRemark; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiPayBillQueryDTO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiPayBillQueryDTO.java new file mode 100644 index 00000000..07a88861 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiPayBillQueryDTO.java @@ -0,0 +1,69 @@ +/** + * 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.pojo.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; + +/** + * 业财融合(BFI)A103-4 付款处理查询参数 + * + * @author Chill + */ +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BfiPayBillQueryDTO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 页码,从1开始 + */ + private Integer pageNo; + /** + * 页数 + */ + private Integer pageSize; + /** + * 开始业务日期,格式:yyyy-MM-dd,限定时间范围不可超过1个月 + */ + private String beginBizdate; + /** + * 结束业务日期,格式:yyyy-MM-dd,限定时间范围不可超过1个月 + */ + private String endBizdate; + /** + * 单据编号 + */ + private List billno; + /** + * 付款人(公司).编码 + */ + private List orgNumber; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiRecBillQueryDTO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiRecBillQueryDTO.java new file mode 100644 index 00000000..90ed5056 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiRecBillQueryDTO.java @@ -0,0 +1,78 @@ +/** + * 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.pojo.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; + +/** + * 业财融合(BFI)A301-1 收款单查询(收款处理查询)参数 + *

+ * 请求示例: + *

{@code
+ * GET /ierp/kapi/v2/awrp/cas/cas_recbill/queryRecBill?begin_bizdate=2021-09-08&end_bizdate=2026-09-08&org_number=01.01.20.02.04.01&pageSize=10&pageNo=1
+ * }
+ * + * @author Chill + */ +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BfiRecBillQueryDTO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 【必填】页码,从1开始 + */ + private Integer pageNo; + /** + * 【必填】页数 + */ + private Integer pageSize; + /** + * 【必填】开始业务日期,格式:yyyy-MM-dd,限定时间范围不可超过1个月 + */ + @JsonProperty("begin_bizdate") + private String beginBizdate; + /** + * 【必填】结束业务日期,格式:yyyy-MM-dd + */ + @JsonProperty("end_bizdate") + private String endBizdate; + /** + * 单据编号 + */ + private List billno; + /** + * 收款人(公司).编码 + */ + @JsonProperty("org_number") + private List orgNumber; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiReceivedInvoiceQueryDTO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiReceivedInvoiceQueryDTO.java new file mode 100644 index 00000000..604d0ee2 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiReceivedInvoiceQueryDTO.java @@ -0,0 +1,85 @@ +/** + * 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.pojo.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 业财融合(BFI)A801-3 收票单查询参数 + *

+ * 请求示例: + *

{@code
+ * GET /ierp/kapi/v2/awrp/ap/ap_invoice/apinvoicequery?billstatus=C&modifytime=2026-09-08 10:36:29&modifytimeE=2026-09-08 10:39:36&pageNo=1&pageSize=10
+ * }
+ * + * @author Chill + */ +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BfiReceivedInvoiceQueryDTO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 页码,从1开始 + */ + private Integer pageNo; + /** + * 页数 + */ + private Integer pageSize; + /** + * 单据编号 + */ + private String billno; + /** + * 单据状态[A:暂存, B:已提交, C:已审核] + */ + private String billstatus; + /** + * 【必填】修改时间-开始,格式:yyyy-MM-dd HH:mm:ss + */ + private String modifytimeS; + /** + * 【必填】修改时间-结束,格式:yyyy-MM-dd HH:mm:ss + */ + private String modifytimeE; + /** + * 创建时间,格式:yyyy-MM-dd HH:mm:ss + */ + private String createtime; + /** + * 销方名称 + */ + private String asstactname; + /** + * 【必填】购方名称 + */ + private String buyername; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiReceivedInvoiceSaveBodyDTO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiReceivedInvoiceSaveBodyDTO.java new file mode 100644 index 00000000..7eaa42cb --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiReceivedInvoiceSaveBodyDTO.java @@ -0,0 +1,49 @@ +/** + * 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.pojo.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; + +/** + * 业财融合(BFI)A303-2 收票单新增(批量保存)请求体,结构为 data 数组 + * + * @author Chill + */ +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BfiReceivedInvoiceSaveBodyDTO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 收票单数据 + */ + private List data; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiReceivedInvoiceSaveDTO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiReceivedInvoiceSaveDTO.java new file mode 100644 index 00000000..294c9aab --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiReceivedInvoiceSaveDTO.java @@ -0,0 +1,247 @@ +/** + * 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.pojo.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.List; + +/** + * 业财融合(BFI)A303-2 收票单新增(批量保存)参数 + *

+ * 请求示例: + *

{@code
+ * {
+ *   "data": [
+ *     {
+ *       "awrp_src_sys": "YC",
+ *       "awrp_src_code": "ar_1",
+ *       "invoiceno": "NuSQA",
+ *       "invoicecode": "hhma9",
+ *       "receivedate": "2026-01-29 00:00:00",
+ *       "issuedate": "2026-01-29 00:00:00",
+ *       "biztype": "PUR",
+ *       "invoicetypef7_number": "27",
+ *       "paymenttype": "CREDIT",
+ *       "asstacttype": "bd_supplier",
+ *       "org_number": "01.00",
+ *       "currency_number": "CNY",
+ *       "receivablessupp_number": "G01.01.04.02-10-0012",
+ *       "exratetable_number": "01",
+ *       "exchangerate": 1.0,
+ *       "buyername": "购方名称",
+ *       "asstactname": "销方名称",
+ *       "isincludetax": true,
+ *       "entry": [
+ *         {
+ *           "e_pricetaxtotal": 100.0,
+ *           "quantity": 1.0,
+ *           "price": 88.4955752212,
+ *           "material_number": "BYWL02.0139",
+ *           "e_taxclass_number": "Di8b9",
+ *           "taxrateid_number": "V13",
+ *           "e_amount": 88.5,
+ *           "e_tax": 11.5,
+ *           "awrp_entry_code": "ar_e1"
+ *         }
+ *       ],
+ *       "isfx": false
+ *     }
+ *   ]
+ * }
+ * }
+ * + * @author Chill + */ +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BfiReceivedInvoiceSaveDTO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * id + */ + private Long id; + /** + * 单据编号,由金蝶系统流水号生成(如 AVT-202601-000039),业务不传 + */ + private String billno; + /** + * 【必填】外部系统编号,候选键 + */ + @JsonProperty("awrp_src_sys") + private String awrpSrcSys; + /** + * 【必填】外部系统来源单据号,候选键 + */ + @JsonProperty("awrp_src_code") + private String awrpSrcCode; + /** + * 发票号码 + */ + private String invoiceno; + /** + * 发票代码 + */ + private String invoicecode; + /** + * 【必填】收票日期,格式:yyyy-MM-dd HH:mm:ss + */ + private String receivedate; + /** + * 【必填】开票日期,格式:yyyy-MM-dd HH:mm:ss + */ + private String issuedate; + /** + * 【必填】业务类型[PUR:采购, FEE:费用](BfiConstant.RECEIVED_BIZ_TYPE_PUR / RECEIVED_BIZ_TYPE_FEE) + */ + private String biztype; + /** + * 【必填】发票类型.编码[1:增值税电子普通发票, 2:增值税电子专用发票, 10:飞机票, 11:其他发票, 12:机动车销售统一发票, 13:二手车销售统一发票, 14:定额发票, 15:道路通行费电子普通发票, 16:汽车票, 17:过路(过桥)费发票, 19:税收完税证明, 20:轮船票, 21:海关缴款书, 23:通用机打电子发票, 24:火车票退票凭证, 25:财政电子票据, 26:数电发票(普通发票), 27:数电发票(增值税专用发票), 28:数电发票(航空运输电子客票行程单), 29:数电发票(铁路电子客票)],城矿数电票常用 26/27(BfiConstant.RECEIVED_INVOICE_TYPE_DIGITAL_GENERAL / RECEIVED_INVOICE_TYPE_DIGITAL_SPECIAL) + */ + @JsonProperty("invoicetypef7_number") + private String invoicetypef7Number; + /** + * 备注 + */ + private String remark; + /** + * 【必填】付款方式[CASH:现购, CREDIT:赊购](BfiConstant.PAY_MODE_CASH / PAY_MODE_CREDIT) + */ + private String paymenttype; + /** + * 【必填】往来类型[bd_supplier:供应商, bos_user:人员, bd_customer:客户](BfiConstant.ASSTACT_TYPE_SUPPLIER) + */ + private String asstacttype; + /** + * 【必填】结算组织.编码 + */ + @JsonProperty("org_number") + private String orgNumber; + /** + * 【必填】结算币别.货币代码,如 CNY(BfiConstant.CURRENCY_CNY) + */ + @JsonProperty("currency_number") + private String currencyNumber; + /** + * 【必填】往来户.编码 + */ + @JsonProperty("receivablessupp_number") + private String receivablessuppNumber; + /** + * 【必填】汇率表.编码,如 01(BfiConstant.EXRATE_TABLE_DEFAULT) + */ + @JsonProperty("exratetable_number") + private String exratetableNumber; + /** + * 汇率 + */ + private BigDecimal exchangerate; + /** + * 购方纳税人识别号 + */ + private String buyertin; + /** + * 购方地址 + */ + private String buyeraddr; + /** + * 购方电话 + */ + private String buyertel; + /** + * 购方账号 + */ + private String buyeracct; + /** + * 销方纳税人识别号 + */ + private String sellertin; + /** + * 销方地址 + */ + private String selleraddr; + /** + * 销方电话 + */ + private String sellertel; + /** + * 销方开户行 + */ + private String sellerbank; + /** + * 销方账号 + */ + private String selleracct; + /** + * 【必填】销方名称 + */ + private String asstactname; + /** + * 最迟付款日期,格式:yyyy-MM-dd HH:mm:ss + */ + private String lastpaydate; + /** + * 录入价税合计 + */ + private Boolean isincludetax; + /** + * 销售方名称.编码 + */ + @JsonProperty("asstact_number") + private String asstactNumber; + /** + * 购方开户行 + */ + private String buyerbank; + /** + * 【必填】购方名称 + */ + private String buyername; + /** + * 购买方名称.编码 + */ + @JsonProperty("buyer_number") + private String buyerNumber; + /** + * 【必填】外部单据类型 + */ + @JsonProperty("awrp_src_type") + private String awrpSrcType; + /** + * 【必填】明细分录 + */ + private List entry; + /** + * 微调 + */ + private Boolean isfx; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiReceivedInvoiceSaveEntryDTO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiReceivedInvoiceSaveEntryDTO.java new file mode 100644 index 00000000..8495b03b --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiReceivedInvoiceSaveEntryDTO.java @@ -0,0 +1,93 @@ +/** + * 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.pojo.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.math.BigDecimal; + +/** + * 业财融合(BFI)A303-2 收票单新增(批量保存)明细分录 + * + * @author Chill + */ +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BfiReceivedInvoiceSaveEntryDTO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * id + */ + private Long id; + /** + * 【必填】价税合计 + */ + @JsonProperty("e_pricetaxtotal") + private BigDecimal ePricetaxtotal; + /** + * 【必填】数量 + */ + private BigDecimal quantity; + /** + * 【必填】单价 + */ + private BigDecimal price; + /** + * 【必填】物料编码.编码 + */ + @JsonProperty("material_number") + private String materialNumber; + /** + * 【必填】税收分类.编码 + */ + @JsonProperty("e_taxclass_number") + private String eTaxclassNumber; + /** + * 【必填】税率.编码,如 V13 + */ + @JsonProperty("taxrateid_number") + private String taxrateidNumber; + /** + * 金额 + */ + @JsonProperty("e_amount") + private BigDecimal eAmount; + /** + * 税额 + */ + @JsonProperty("e_tax") + private BigDecimal eTax; + /** + * 【必填】外部系统来源分录标识,候选键 + */ + @JsonProperty("awrp_entry_code") + private String awrpEntryCode; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiTokenDTO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiTokenDTO.java new file mode 100644 index 00000000..6e9780ce --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiTokenDTO.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. + *

+ * 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.pojo.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 业财融合(BFI)A100-1 获取令牌参数 + * + * @author Chill + */ +@Data +@EqualsAndHashCode(callSuper = true) +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BfiTokenDTO extends BfiBaseDTO { + @Serial + private static final long serialVersionUID = 1L; + /** + * 第三方应用系统编码,即 appId + */ + @JsonProperty("client_id") + private String clientId; + /** + * 第三方应用 AccessToken 认证密钥,即 appSecret + */ + @JsonProperty("client_secret") + private String clientSecret; + /** + * 访问用户名 + */ + private String username; + /** + * 数据中心ID + */ + private String accountId; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiVerifyTokenDTO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiVerifyTokenDTO.java new file mode 100644 index 00000000..7fa44874 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiVerifyTokenDTO.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. + *

+ * 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.pojo.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 业财融合(BFI)A100-2 校验令牌参数 + * + * @author Chill + */ +@Data +@EqualsAndHashCode(callSuper = true) +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BfiVerifyTokenDTO extends BfiBaseDTO { + @Serial + private static final long serialVersionUID = 1L; + /** + * 第三方应用系统编码,即 appId + */ + @JsonProperty("client_id") + private String clientId; + /** + * 令牌类型,access_token/id_token + */ + @JsonProperty("token_type_hint") + private String tokenTypeHint; + /** + * 令牌值 + */ + private String token; + /** + * 数据中心ID + */ + private String accountId; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiWithdrawTokenDTO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiWithdrawTokenDTO.java new file mode 100644 index 00000000..fdfa6b49 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/dto/BfiWithdrawTokenDTO.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. + *

+ * 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.pojo.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 业财融合(BFI)A100-3 撤回令牌参数 + * + * @author Chill + */ +@Data +@EqualsAndHashCode(callSuper = true) +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BfiWithdrawTokenDTO extends BfiBaseDTO { + @Serial + private static final long serialVersionUID = 1L; + /** + * 第三方应用系统编码,即 appId + */ + @JsonProperty("client_id") + private String clientId; + /** + * 第三方应用 AccessToken 认证密钥,即 appSecret + */ + @JsonProperty("client_secret") + private String clientSecret; + /** + * 令牌类型,access_token/id_token + */ + @JsonProperty("token_type_hint") + private String tokenTypeHint; + /** + * 令牌值 + */ + private String token; + /** + * 数据中心ID + */ + private String accountId; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiApBillEntryVO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiApBillEntryVO.java new file mode 100644 index 00000000..bce175f4 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiApBillEntryVO.java @@ -0,0 +1,210 @@ +/** + * 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.pojo.vo; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.math.BigDecimal; + +/** + * 业财融合(BFI)A102-4 财务应付单查询 明细分录 + * + * @author Chill + */ +@Data +public class BfiApBillEntryVO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * id + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long id; + /** + * 分录行号 + */ + private Integer seq; + /** + * 应付金额 + */ + @JsonProperty("e_pricetaxtotal") + private BigDecimal ePricetaxtotal; + /** + * 金额 + */ + @JsonProperty("e_amount") + private BigDecimal eAmount; + /** + * 税额 + */ + @JsonProperty("e_tax") + private BigDecimal eTax; + /** + * 税率(%) + */ + private BigDecimal taxrate; + /** + * 数量 + */ + private BigDecimal quantity; + /** + * 单价 + */ + private BigDecimal price; + /** + * 赠品 + */ + private Boolean ispresent; + /** + * 折扣方式[PERCENT:折扣率(%), PERUNIT:单位折扣额, TOTAL:折扣额, NULL:无] + */ + private String discountmode; + /** + * 单位折扣(率) + */ + private BigDecimal discountrate; + /** + * 实际单价 + */ + private BigDecimal actprice; + /** + * 已核销金额 + */ + private BigDecimal verifyamount; + /** + * 未核销金额 + */ + @JsonProperty("e_unverifyamount") + private BigDecimal eUnverifyamount; + /** + * 已锁定金额 + */ + private BigDecimal lockedamt; + /** + * 未锁定金额 + */ + private BigDecimal unlockamt; + /** + * 已结算金额 + */ + private BigDecimal settledamt; + /** + * 未结算金额 + */ + private BigDecimal unsettleamt; + /** + * 规格型号 + */ + private String spectype; + /** + * 折扣额 + */ + private BigDecimal discountamount; + /** + * 备注 + */ + @JsonProperty("e_remark") + private String eRemark; + /** + * 含税单价 + */ + private BigDecimal pricetax; + /** + * 基本数量 + */ + @JsonProperty("e_baseunitqty") + private BigDecimal eBaseunitqty; + /** + * 合同 + */ + @JsonProperty("e_contract") + private String eContract; + /** + * 外部系统来源分录标识 + */ + @JsonProperty("awrp_entry_code") + private String awrpEntryCode; + /** + * 上游分录号 + */ + @JsonProperty("awrp_src_con_eno") + private String awrpSrcConEno; + /** + * 费用项目.编码 + */ + @JsonProperty("expenseitem_number") + private String expenseitemNumber; + /** + * 计量单位.编码 + */ + @JsonProperty("measureunit_number") + private String measureunitNumber; + /** + * 物料编码.编码 + */ + @JsonProperty("material_number") + private String materialNumber; + /** + * 核算组织.编码 + */ + @JsonProperty("calorg_number") + private String calorgNumber; + /** + * 利润中心.编码 + */ + @JsonProperty("profitcenter_number") + private String profitcenterNumber; + /** + * 对方利润中心.编码 + */ + @JsonProperty("cprofitcenter_number") + private String cprofitcenterNumber; + /** + * 基本单位.编码 + */ + @JsonProperty("e_baseunit_number") + private String eBaseunitNumber; + /** + * WBS号.WBS编码 + */ + @JsonProperty("wbs_number") + private String wbsNumber; + /** + * 项目.项目编码 + */ + @JsonProperty("project_number") + private String projectNumber; + /** + * 税率.编码 + */ + @JsonProperty("taxrateid_number") + private String taxrateidNumber; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiApBillInvoiceVO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiApBillInvoiceVO.java new file mode 100644 index 00000000..d29d567c --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiApBillInvoiceVO.java @@ -0,0 +1,179 @@ +/** + * 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.pojo.vo; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.math.BigDecimal; + +/** + * 业财融合(BFI)A102-4 财务应付单查询 发票分录 + * + * @author Chill + */ +@Data +public class BfiApBillInvoiceVO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * id + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long id; + /** + * 分录行号 + */ + private Integer seq; + /** + * 发票ID + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long invid; + /** + * 收票单 + */ + @JsonProperty("i_billno") + private String iBillno; + /** + * 本次占用金额 + */ + @JsonProperty("i_usedamt") + private BigDecimal iUsedamt; + /** + * 发票类型[ELE:普通发票(电子), SE:专用发票(电子), GE:普通发票(纸质), SP:专用发票(纸质), PAPER:普通纸质卷票, MACH:通用机打发票, TAXI:的士票发票, TRAIN:火车票发票, PLANE:飞机票发票, OTHERCLOUD:其他发票, MOTOR:机动车销售发票, USERCAR:二手车发票, QUOTA:定额发票, TOLL:通行费电子发票, PASSENGER:客运发票, BRIDGE:过路过桥费发票, CARBOAT:车船税发票(专票), PAID:完税证明发票, STEAMER:轮船票发票, OTHER:其他发票, NORMPE:通用机打电子发票, FINE:财政电子发票, NOELETIC:全电普票, MAELETIC:全电专票, PAYLETTER:海关进口增值税专用缴款书, OVERSEAS:形式发票] + */ + @JsonProperty("i_invoicetype") + private String iInvoicetype; + /** + * 发票类型_标题 + */ + @JsonProperty("i_invoicetype_title") + private String iInvoicetypeTitle; + /** + * 发票代码 + */ + @JsonProperty("i_invoicecode") + private String iInvoicecode; + /** + * 发票号码 + */ + @JsonProperty("i_invoiceno") + private String iInvoiceno; + /** + * 开票日期 + */ + @JsonProperty("i_invoicedate") + private String iInvoicedate; + /** + * 价税合计 + */ + @JsonProperty("i_pricetaxtotal") + private BigDecimal iPricetaxtotal; + /** + * 税率(%) + */ + @JsonProperty("i_taxrate") + private BigDecimal iTaxrate; + /** + * 税额 + */ + @JsonProperty("i_tax") + private BigDecimal iTax; + /** + * 金额 + */ + @JsonProperty("i_amount") + private BigDecimal iAmount; + /** + * 销方名称 + */ + @JsonProperty("i_asstactname") + private String iAsstactname; + /** + * 购方名称 + */ + @JsonProperty("i_buyername") + private String iBuyername; + /** + * 税额不计入成本 + */ + @JsonProperty("i_istaxdeduction") + private Boolean iIstaxdeduction; + /** + * 备注 + */ + @JsonProperty("i_remark") + private String iRemark; + /** + * 行来源[1:发票采集, 2:发票下推, 3:应付指定发票, 4:发票指定应付, 5:发票匹配, 6:应收协同, 7:供应商协同] + */ + @JsonProperty("i_srctype") + private String iSrctype; + /** + * 行来源_标题 + */ + @JsonProperty("i_srctype_title") + private String iSrctypeTitle; + /** + * 发票流水号 + */ + @JsonProperty("i_serialno") + private String iSerialno; + /** + * 后补发票[0:否, 1:是] + */ + @JsonProperty("i_issupplement") + private String iIssupplement; + /** + * 后补发票_标题 + */ + @JsonProperty("i_issupplement_title") + private String iIssupplementTitle; + /** + * 可占用金额 + */ + @JsonProperty("i_canuseamt") + private BigDecimal iCanuseamt; + /** + * 预付款发票 + */ + private Boolean isprepayinv; + /** + * 科目类型[1:进口增值税, 2:进口关税, 3:进口消费税] + */ + @JsonProperty("i_subject_type") + private String iSubjectType; + /** + * 科目类型_标题 + */ + @JsonProperty("i_subject_type_title") + private String iSubjectTypeTitle; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiApBillPlanVO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiApBillPlanVO.java new file mode 100644 index 00000000..ae496c2e --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiApBillPlanVO.java @@ -0,0 +1,134 @@ +/** + * 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.pojo.vo; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.math.BigDecimal; + +/** + * 业财融合(BFI)A102-4 财务应付单查询 付款计划分录 + * + * @author Chill + */ +@Data +public class BfiApBillPlanVO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * id + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long id; + /** + * 分录行号 + */ + private Integer seq; + /** + * 到期日 + */ + private String planduedate; + /** + * 应付金额 + */ + private BigDecimal planpricetax; + /** + * 未结算金额 + */ + private BigDecimal unplansettleamt; + /** + * 已结算金额 + */ + private BigDecimal plansettledamt; + /** + * 备注 + */ + private String planremark; + /** + * 冻结状态[unfreeze:未冻结, allfreeze:已冻结] + */ + @JsonProperty("e_freezestate") + private String eFreezestate; + /** + * 合同 + */ + private String plancontract; + /** + * 拆分维度ID + */ + @JsonProperty("p_splitdimensionid") + private Integer pSplitdimensionid; + /** + * 付款比例(%) + */ + @JsonProperty("p_payrate") + private BigDecimal pPayrate; + /** + * 外部系统来源分录标识 + */ + @JsonProperty("awrp_entry_code_p") + private String awrpEntryCodeP; + /** + * 利润中心.编码 + */ + @JsonProperty("planprofitcenter_number") + private String planprofitcenterNumber; + /** + * 对方利润中心.编码 + */ + @JsonProperty("plancprofitcenter_number") + private String plancprofitcenterNumber; + /** + * 结算方式.编码 + */ + @JsonProperty("plansettletype_number") + private String plansettletypeNumber; + /** + * 物料编码.编码 + */ + @JsonProperty("planmaterial_number") + private String planmaterialNumber; + /** + * 费用项目.编码 + */ + @JsonProperty("planexpenseitem_number") + private String planexpenseitemNumber; + /** + * 项目.项目编码 + */ + @JsonProperty("planproject_number") + private String planprojectNumber; + /** + * 成本中心.编码 + */ + @JsonProperty("plancostcenter_number") + private String plancostcenterNumber; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiApBillVO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiApBillVO.java new file mode 100644 index 00000000..f38881fb --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiApBillVO.java @@ -0,0 +1,295 @@ +/** + * 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.pojo.vo; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.List; + +/** + * 业财融合(BFI)A102-4 财务应付单查询返回结果 + *

+ * 成功响应示例: + *

{@code
+ * {
+ *   "data": {
+ *     "filter": "[awrp_src_sys = 'YC']", "lastPage": true, "pageNo": 1, "pageSize": 10,
+ *     "rows": [
+ *       {
+ *         "id": "2393657068314697747", "billno": "AP-202601-001394", "billstatus": "C",
+ *         "pricetaxtotal": 10.0, "amount": 8.85, "tax": 1.15, "asstacttype": "bd_supplier",
+ *         "bizdate": "2026-01-10 00:00:00", "purmode": "CREDIT",
+ *         "awrp_src_sys": "YC", "awrp_src_code": "1", "awrp_src_bt": "awrp_contract_info",
+ *         "detailentry": [ ... ], "planentity": [ ... ], "inventry": [ ... ]
+ *       }
+ *     ]
+ *   },
+ *   "errorCode": "0", "message": null, "status": true
+ * }
+ * }
+ * + * @author Chill + */ +@Data +public class BfiApBillVO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * id + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long id; + /** + * 单据编号 + */ + private String billno; + /** + * 单据状态[A:暂存, B:已提交, C:已审核] + */ + private String billstatus; + /** + * 审核时间 + */ + private String auditdate; + /** + * 创建时间 + */ + private String createtime; + /** + * 应付金额 + */ + private BigDecimal pricetaxtotal; + /** + * 金额 + */ + private BigDecimal amount; + /** + * 税额 + */ + private BigDecimal tax; + /** + * 备注 + */ + private String remark; + /** + * 往来类型[bd_supplier:供应商, bos_user:人员, bd_customer:客户] + */ + private String asstacttype; + /** + * 单据日期 + */ + private String bizdate; + /** + * 汇率 + */ + private BigDecimal exchangerate; + /** + * 付款方式[CASH:现购, CREDIT:赊购] + */ + private String purmode; + /** + * 往来单位名称 + */ + private String asstactname; + /** + * 期初 + */ + private Boolean isperiod; + /** + * 汇率日期 + */ + private String exratedate; + /** + * 换算方式[0:直接汇率, 1:间接汇率] + */ + private String quotation; + /** + * 到期日 + */ + private String duedate; + /** + * 未核销金额 + */ + private BigDecimal unverifyamount; + /** + * 未结算金额 + */ + private BigDecimal unsettleamount; + /** + * 收款账号 + */ + private String payeebanknum; + /** + * 冲销单据 + */ + private Boolean iswrittenoff; + /** + * 已被冲销 + */ + private Boolean hadwrittenoff; + /** + * 录入含税价 + */ + private Boolean isincludetax; + /** + * 记账日期 + */ + private String bookdate; + /** + * 外部系统编号 + */ + @JsonProperty("awrp_src_sys") + private String awrpSrcSys; + /** + * 外部系统来源单据号 + */ + @JsonProperty("awrp_src_code") + private String awrpSrcCode; + /** + * 来源单据,如 awrp_contract_info + */ + @JsonProperty("awrp_src_bt") + private String awrpSrcBt; + /** + * 上游外部系统单据号 + */ + @JsonProperty("awrp_src_con_no") + private String awrpSrcConNo; + /** + * 结算组织.编码 + */ + @JsonProperty("org_number") + private String orgNumber; + /** + * 结算币.货币代码 + */ + @JsonProperty("currency_number") + private String currencyNumber; + /** + * 采购组织.编码 + */ + @JsonProperty("purorg_number") + private String purorgNumber; + /** + * 往来户.编码 + */ + @JsonProperty("asstact_number") + private String asstactNumber; + /** + * 采购组.编码 + */ + @JsonProperty("purdept_number") + private String purdeptNumber; + /** + * 采购员.业务员名称 + */ + @JsonProperty("purchaser_operatorname") + private String purchaserOperatorname; + /** + * 付款条件.编码 + */ + @JsonProperty("paycond_number") + private String paycondNumber; + /** + * 本位币.货币代码 + */ + @JsonProperty("basecurrency_number") + private String basecurrencyNumber; + /** + * 汇率表.编码 + */ + @JsonProperty("exratetable_number") + private String exratetableNumber; + /** + * 结算方式.编码 + */ + @JsonProperty("settlementtype_number") + private String settlementtypeNumber; + /** + * 付款组织.编码 + */ + @JsonProperty("payorg_number") + private String payorgNumber; + /** + * 款项性质.编码 + */ + @JsonProperty("payproperty_number") + private String paypropertyNumber; + /** + * 单据类型.编码 + */ + @JsonProperty("billtypeid_number") + private String billtypeidNumber; + /** + * 收款银行.编码(行号) + */ + @JsonProperty("bebank_number") + private String bebankNumber; + /** + * 组织间结算.编码 + */ + @JsonProperty("settlerelations_number") + private String settlerelationsNumber; + /** + * 付款币种.货币代码 + */ + @JsonProperty("paymentcurrency_number") + private String paymentcurrencyNumber; + /** + * 部门.编码 + */ + @JsonProperty("department_number") + private String departmentNumber; + /** + * 业务类型.编码 + */ + @JsonProperty("biztype_number") + private String biztypeNumber; + /** + * 收款供应商.编码 + */ + @JsonProperty("receivingsupplierid_number") + private String receivingsupplieridNumber; + /** + * 明细分录 + */ + @JsonProperty("detailentry") + private List detailentry; + /** + * 付款计划分录 + */ + private List planentity; + /** + * 发票分录 + */ + private List inventry; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiArBillEntryVO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiArBillEntryVO.java new file mode 100644 index 00000000..70250c0a --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiArBillEntryVO.java @@ -0,0 +1,215 @@ +/** + * 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.pojo.vo; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.math.BigDecimal; + +/** + * 业财融合(BFI)A302-4 财务应收单查询 明细分录 + * + * @author Chill + */ +@Data +public class BfiArBillEntryVO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * id + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long id; + /** + * 分录行号 + */ + private Integer seq; + /** + * 数量 + */ + @JsonProperty("e_quantity") + private BigDecimal eQuantity; + /** + * 赠品 + */ + @JsonProperty("e_ispresent") + private Boolean eIspresent; + /** + * 单价 + */ + @JsonProperty("e_unitprice") + private BigDecimal eUnitprice; + /** + * 金额 + */ + @JsonProperty("e_amount") + private BigDecimal eAmount; + /** + * 税率(%) + */ + @JsonProperty("e_taxrate") + private BigDecimal eTaxrate; + /** + * 含税单价 + */ + @JsonProperty("e_taxunitprice") + private BigDecimal eTaxunitprice; + /** + * 税额 + */ + @JsonProperty("e_tax") + private BigDecimal eTax; + /** + * 折扣方式[NULL:无, PERCENT:折扣率(%), PERUNIT:单位折扣额, TOTAL:折扣额] + */ + @JsonProperty("e_discountmode") + private String eDiscountmode; + /** + * 单位折扣(率) + */ + @JsonProperty("e_discountrate") + private BigDecimal eDiscountrate; + /** + * 折扣额 + */ + @JsonProperty("e_discountamount") + private BigDecimal eDiscountamount; + /** + * 实际单价 + */ + @JsonProperty("e_actunitprice") + private BigDecimal eActunitprice; + /** + * 实际含税单价 + */ + @JsonProperty("e_acttaxunitprice") + private BigDecimal eActtaxunitprice; + /** + * 应收金额 + */ + @JsonProperty("e_recamount") + private BigDecimal eRecamount; + /** + * 备注 + */ + @JsonProperty("e_remark") + private String eRemark; + /** + * 基本数量 + */ + @JsonProperty("e_baseunitqty") + private BigDecimal eBaseunitqty; + /** + * 规格型号 + */ + @JsonProperty("e_spectype") + private String eSpectype; + /** + * 合同 + */ + @JsonProperty("e_contract") + private String eContract; + /** + * 发票号码 + */ + @JsonProperty("e_invoiceno") + private String eInvoiceno; + /** + * 发票代码 + */ + @JsonProperty("e_invoicecode") + private String eInvoicecode; + /** + * 上游分录号 + */ + @JsonProperty("awrp_src_con_eno") + private String awrpSrcConEno; + /** + * 外部系统来源分录标识 + */ + @JsonProperty("awrp_entry_code") + private String awrpEntryCode; + /** + * 物料编码.编码 + */ + @JsonProperty("e_material_number") + private String eMaterialNumber; + /** + * 计量单位.编码 + */ + @JsonProperty("e_measureunit_number") + private String eMeasureunitNumber; + /** + * 基本单位.编码 + */ + @JsonProperty("e_baseunit_number") + private String eBaseunitNumber; + /** + * 费用项目.编码 + */ + @JsonProperty("e_expenseitem_number") + private String eExpenseitemNumber; + /** + * 利润中心.编码 + */ + @JsonProperty("profitcenter_number") + private String profitcenterNumber; + /** + * 对方利润中心.编码 + */ + @JsonProperty("cprofitcenter_number") + private String cprofitcenterNumber; + /** + * WBS号.WBS编码 + */ + @JsonProperty("wbs_number") + private String wbsNumber; + /** + * 税率.编码 + */ + @JsonProperty("taxrateid_number") + private String taxrateidNumber; + /** + * 项目.项目编码 + */ + @JsonProperty("project_number") + private String projectNumber; + /** + * 订货客户.编码 + */ + @JsonProperty("e_invoicecustomerid_number") + private String eInvoicecustomeridNumber; + /** + * 收货客户.编码 + */ + @JsonProperty("e_delivercustomerid_number") + private String eDelivercustomeridNumber; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiArBillPlanVO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiArBillPlanVO.java new file mode 100644 index 00000000..ecf72cdd --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiArBillPlanVO.java @@ -0,0 +1,119 @@ +/** + * 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.pojo.vo; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.math.BigDecimal; + +/** + * 业财融合(BFI)A302-4 财务应收单查询 收款计划分录 + * + * @author Chill + */ +@Data +public class BfiArBillPlanVO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * id + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long id; + /** + * 分录行号 + */ + private Integer seq; + /** + * 到期日 + */ + private String planduedate; + /** + * 应收金额 + */ + private BigDecimal planpricetax; + /** + * 未结算金额 + */ + private BigDecimal unplansettleamt; + /** + * 已结算金额 + */ + private BigDecimal plansettledamt; + /** + * 备注 + */ + private String planremark; + /** + * 合同 + */ + private String plancontract; + /** + * 收款比例(%) + */ + @JsonProperty("p_recrate") + private BigDecimal pRecrate; + /** + * 外部系统来源分录标识 + */ + @JsonProperty("awrp_entry_code_p") + private String awrpEntryCodeP; + /** + * 利润中心.编码 + */ + @JsonProperty("planprofitcenter_number") + private String planprofitcenterNumber; + /** + * 对方利润中心.编码 + */ + @JsonProperty("plancprofitcenter_number") + private String plancprofitcenterNumber; + /** + * 结算方式.编码 + */ + @JsonProperty("plansettletype_number") + private String plansettletypeNumber; + /** + * 物料编码.编码 + */ + @JsonProperty("planmaterial_number") + private String planmaterialNumber; + /** + * 费用项目.编码 + */ + @JsonProperty("planexpenseitem_number") + private String planexpenseitemNumber; + /** + * 项目.项目编码 + */ + @JsonProperty("planproject_number") + private String planprojectNumber; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiArBillVO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiArBillVO.java new file mode 100644 index 00000000..c592e600 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiArBillVO.java @@ -0,0 +1,252 @@ +/** + * 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.pojo.vo; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.List; + +/** + * 业财融合(BFI)A302-4 财务应收单查询返回结果 + *

+ * 成功响应示例: + *

{@code
+ * {
+ *   "data": {
+ *     "filter": "[awrp_src_sys = 'YC']", "lastPage": true, "pageNo": 1, "pageSize": 10,
+ *     "rows": [
+ *       {
+ *         "id": "2397125584581820416", "billno": "AR-260121-008445", "billstatus": "C",
+ *         "bizdate": "2026-01-21 00:00:00", "asstacttype": "bd_customer", "paymode": "CREDIT",
+ *         "recamount": 13.0, "amount": 11.5, "tax": 1.5,
+ *         "awrp_src_sys": "YC", "awrp_src_code": "111", "awrp_src_bt": "awrp_contract_info",
+ *         "entry": [ ... ], "planentity": [ ... ]
+ *       }
+ *     ]
+ *   },
+ *   "errorCode": "0", "message": null, "status": true
+ * }
+ * }
+ * + * @author Chill + */ +@Data +public class BfiArBillVO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * id + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long id; + /** + * 单据编号 + */ + private String billno; + /** + * 单据状态[A:暂存, B:已提交, C:已审核] + */ + private String billstatus; + /** + * 审核时间 + */ + private String auditdate; + /** + * 创建时间 + */ + private String createtime; + /** + * 单据日期 + */ + private String bizdate; + /** + * 到期日 + */ + private String duedate; + /** + * 往来类型[bd_supplier:供应商, bos_user:人员, bd_customer:客户] + */ + private String asstacttype; + /** + * 付款方式[CASH:现销, CREDIT:赊销] + */ + private String paymode; + /** + * 备注 + */ + private String remark; + /** + * 应收金额 + */ + private BigDecimal recamount; + /** + * 汇率 + */ + private BigDecimal exchangerate; + /** + * 金额 + */ + private BigDecimal amount; + /** + * 税额 + */ + private BigDecimal tax; + /** + * 汇率日期 + */ + private String exratedate; + /** + * 换算方式[0:直接汇率, 1:间接汇率] + */ + private String quotation; + /** + * 冲销单据 + */ + private Boolean iswrittenoff; + /** + * 已被冲销 + */ + private Boolean hadwrittenoff; + /** + * 已结算金额 + */ + private BigDecimal settleamount; + /** + * 记账日期 + */ + private String bookdate; + /** + * 发票号码 + */ + private String invoiceno; + /** + * 发票代码 + */ + private String invoicecode; + /** + * 已开票应收金额 + */ + private BigDecimal issueinvrecamt; + /** + * 外部系统编号 + */ + @JsonProperty("awrp_src_sys") + private String awrpSrcSys; + /** + * 外部系统来源单据号 + */ + @JsonProperty("awrp_src_code") + private String awrpSrcCode; + /** + * 来源单据,如 awrp_contract_info + */ + @JsonProperty("awrp_src_bt") + private String awrpSrcBt; + /** + * 上游外部系统单据号 + */ + @JsonProperty("awrp_src_con_no") + private String awrpSrcConNo; + /** + * 结算组织.编码 + */ + @JsonProperty("org_number") + private String orgNumber; + /** + * 往来户.编码 + */ + @JsonProperty("asstact_number") + private String asstactNumber; + /** + * 收款条件.编码 + */ + @JsonProperty("paycond_number") + private String paycondNumber; + /** + * 收款组织.编码 + */ + @JsonProperty("recorg_number") + private String recorgNumber; + /** + * 结算币.货币代码 + */ + @JsonProperty("currency_number") + private String currencyNumber; + /** + * 本位币.货币代码 + */ + @JsonProperty("basecurrency_number") + private String basecurrencyNumber; + /** + * 销售组.编码 + */ + @JsonProperty("salesgroup_number") + private String salesgroupNumber; + /** + * 销售员.业务员名称 + */ + @JsonProperty("salesman_operatorname") + private String salesmanOperatorname; + /** + * 结算方式.编码 + */ + @JsonProperty("settlementtype_number") + private String settlementtypeNumber; + /** + * 款项性质.编码 + */ + @JsonProperty("payproperty_number") + private String paypropertyNumber; + /** + * 单据类型.编码 + */ + @JsonProperty("billtype_number") + private String billtypeNumber; + /** + * 业务类型.编码 + */ + @JsonProperty("biztype_number") + private String biztypeNumber; + /** + * 付款客户.编码 + */ + @JsonProperty("paymentcustomerid_number") + private String paymentcustomeridNumber; + /** + * 明细分录 + */ + private List entry; + /** + * 收款计划分录 + */ + private List planentity; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiAttachmentUploadVO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiAttachmentUploadVO.java new file mode 100644 index 00000000..0e3437ea --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiAttachmentUploadVO.java @@ -0,0 +1,57 @@ +/** + * 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.pojo.vo; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 业财融合(BFI)A902 附件上传返回数据 + *

+ * 响应示例: + *

{@code
+ * {
+ *   "data": {"path": "211e6ce5c9805c00"},
+ *   "errorCode": "0",
+ *   "status": true
+ * }
+ * }
+ * + * @author Chill + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class BfiAttachmentUploadVO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 金蝶侧文件路径(附件面板回显/排查用) + */ + private String path; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiBillLockResultVO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiBillLockResultVO.java new file mode 100644 index 00000000..6a470ada --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiBillLockResultVO.java @@ -0,0 +1,78 @@ +/** + * 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 + * of 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.pojo.vo; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; + +/** + * 业财融合(BFI)单据锁定/解锁通知处理结果。 + *

lockBusiData(金蝶返回)与 unLockNotifyBusiData(WLTMS 回调应答)同构: + * updateCount 为处理单据数,updateResultList 为逐单处理结果(1-成功,0-失败/未匹配), + * 批量传参时单据编号逗号拼接、结果列表与传参顺序对应

+ * + * @author Chill + */ +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BfiBillLockResultVO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 处理单据数量 + */ + private Integer updateCount; + /** + * 逐单处理结果:1-成功,0-失败/未匹配 + */ + private List updateResultList; + + /** + * 组装成功应答 + * + * @param count 处理单据数 + * @return 应答结果 + */ + public static BfiBillLockResultVO of(int count) { + BfiBillLockResultVO result = new BfiBillLockResultVO(); + result.setUpdateCount(count); + result.setUpdateResultList(count > 0 ? List.of(1) : List.of(0)); + return result; + } + + /** + * 是否全部处理成功 + * + * @return true-更新数量大于0 + */ + public boolean isSuccess() { + return updateCount != null && updateCount > 0; + } +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiBillSaveResultErrorVO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiBillSaveResultErrorVO.java new file mode 100644 index 00000000..d471d80e --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiBillSaveResultErrorVO.java @@ -0,0 +1,75 @@ +/** + * 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.pojo.vo; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; +import java.util.Map; + +/** + * 业财融合(BFI)单据保存返回错误明细 + * + * @author Chill + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class BfiBillSaveResultErrorVO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 实体键 + */ + private String entityKey; + /** + * 分录行号 + */ + private Integer entryRowIndex; + /** + * 单据键值,含 awrp_src_code、awrp_src_sys + */ + private Map keys; + /** + * 行错误信息 + */ + private List rowMsg; + /** + * 子分录行号 + */ + private Integer subEntryRowIndex; + + /** + * 拼接行错误信息 + * + * @return 错误信息文本 + */ + public String getRowMsgText() { + return rowMsg == null || rowMsg.isEmpty() ? "" : String.join(";", rowMsg); + } +} 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 new file mode 100644 index 00000000..341cdca8 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiBillSaveResultItemVO.java @@ -0,0 +1,94 @@ +/** + * 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.pojo.vo; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 业财融合(BFI)A103-1 / A103-3 付款申请保存返回结果明细 + * + * @author Chill + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class BfiBillSaveResultItemVO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 单据序号 + */ + private Integer billIndex; + /** + * 单据是否保存成功 + */ + private Boolean billStatus; + /** + * 分录id集合 + */ + private Map entryIds; + /** + * 错误明细 + */ + private List errors; + /** + * 单据id + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long id; + /** + * 单据键值,含 awrp_src_code、awrp_src_sys + */ + private Map keys; + /** + * 单据编号 + */ + private String number; + /** + * 操作类型:Add 新增、Push 下推、Update 修改 + */ + private String type; + + /** + * 拼接错误明细信息 + * + * @return 错误信息文本 + */ + public String getErrorText() { + return errors == null || errors.isEmpty() ? "" : errors.stream() + .map(BfiBillSaveResultErrorVO::getRowMsgText) + .filter(text -> !text.isEmpty()) + .collect(Collectors.joining(";")); + } +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiBillSaveResultVO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiBillSaveResultVO.java new file mode 100644 index 00000000..d4fd36e4 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiBillSaveResultVO.java @@ -0,0 +1,67 @@ +/** + * 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.pojo.vo; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; + +/** + * 业财融合(BFI)A103-1 / A103-3 付款申请保存返回结果 + * + * @author Chill + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class BfiBillSaveResultVO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 操作失败数量 + */ + private String failCount; + /** + * 操作成功数量 + */ + private String successCount; + /** + * 返回结果详细信息 + */ + private List result; + + /** + * 是否全部单据保存成功 + * + * @return true-全部成功 + */ + public boolean isAllSuccess() { + return result != null && !result.isEmpty() + && result.stream().allMatch(item -> Boolean.TRUE.equals(item.getBillStatus())); + } +} 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 new file mode 100644 index 00000000..e8fe9d91 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiBlacklistVO.java @@ -0,0 +1,108 @@ +/** + * 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.pojo.vo; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 业财融合(BFI)A801-5 黑名单查询返回结果 + * + * @author Chill + */ +@Data +public class BfiBlacklistVO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * id + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long id; + /** + * 企业税号 + */ + private String number; + /** + * 企业名称 + */ + private String name; + /** + * 数据状态[A:暂存, B:已提交, C:已审核] + */ + private String status; + /** + * 数据状态标题 + */ + @JsonProperty("status_title") + private String statusTitle; + /** + * 使用状态[0:禁用, 1:可用] + */ + private String enable; + /** + * 使用状态标题 + */ + @JsonProperty("enable_title") + private String enableTitle; + /** + * 创建时间 + */ + private String createtime; + /** + * 修改时间 + */ + private String modifytime; + /** + * 主数据内码 + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long masterid; + /** + * 上报单位(部门) + */ + @JsonProperty("awrp_textfield1") + private String awrpTextfield1; + /** + * 失信类别[A:禁止交易类, B:风险警示类] + */ + private String awrpCombofield; + /** + * 失信类别标题 + */ + @JsonProperty("awrp_combofield_title") + private String awrpCombofieldTitle; + /** + * 列入事由 + */ + @JsonProperty("awrp_textareafield") + private String awrpTextareafield; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiClaimAnnounceVO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiClaimAnnounceVO.java new file mode 100644 index 00000000..f6b418bb --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiClaimAnnounceVO.java @@ -0,0 +1,158 @@ +/** + * 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.pojo.vo; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.math.BigDecimal; + +/** + * 业财融合(BFI)A301-2 收款认领公告查询返回结果 + *

+ * 成功响应示例: + *

{@code
+ * {
+ *   "data": {
+ *     "filter": "[claimusers LIKE '%苏浩晖%']", "lastPage": true, "pageNo": 1, "pageSize": 10,
+ *     "rows": [
+ *       {
+ *         "billno": "RLTZ-20251217-0002", "id": "2371903479531716608", "claimusers": "苏浩晖",
+ *         "claimme": false, "bizdate": "2025-12-15 09:55:23", "reamount": 2735.32,
+ *         "oppunit": "通联支付网络服务股份有限公司", "claimstatus": "0",
+ *         "claimedamount": 0.0, "unclaimamount": 2735.32, "description": "TL20251212SGRXLF7.68"
+ *       }
+ *     ],
+ *     "totalCount": 2
+ *   },
+ *   "errorCode": "0", "message": null, "status": true
+ * }
+ * }
+ * + * @author Chill + */ +@Data +public class BfiClaimAnnounceVO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 认领通知单 + */ + private String billno; + /** + * id + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long id; + /** + * 参与认领用户 + */ + private String claimusers; + /** + * 我已认领 + */ + private Boolean claimme; + /** + * 业务日期 + */ + private String bizdate; + /** + * 付款人 + */ + private String recpayer; + /** + * 手续费 + */ + private BigDecimal fee; + /** + * 确认认领时间 + */ + private String confirmdate; + /** + * 交易明细编号 + */ + private String tradedetailno; + /** + * 收款金额 + */ + private BigDecimal reamount; + /** + * 对方户名 + */ + private String oppunit; + /** + * 对方账号 + */ + private String oppbanknumber; + /** + * 对方开户行 + */ + private String oppbank; + /** + * 交易时间 + */ + private String tradetime; + /** + * 明细流水号 + */ + private String detailid; + /** + * 认领通知单状态 + */ + private String claimstatus; + /** + * 已认领金额 + */ + private BigDecimal claimedamount; + /** + * 未认领金额 + */ + private BigDecimal unclaimamount; + /** + * 摘要 + */ + private String description; + /** + * 付款金额 + */ + private BigDecimal payamount; + /** + * 收款人 + */ + private String recpayee; + /** + * 银行流水号 + */ + private String bankdetailno; + /** + * 收款人(展示名) + */ + private String recviewpayer; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiCustomerBankVO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiCustomerBankVO.java new file mode 100644 index 00000000..281934e7 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiCustomerBankVO.java @@ -0,0 +1,76 @@ +/** + * 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.pojo.vo; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 业财融合(BFI)A902-1 客户查询 银行信息分录 + * + * @author Chill + */ +@Data +public class BfiCustomerBankVO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * id + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long id; + /** + * 分录行号 + */ + private Integer seq; + /** + * 银行账号 + */ + private String bankaccount; + /** + * 账户名称 + */ + private String accountname; + /** + * 默认 + */ + @JsonProperty("isdefault_bank") + private Boolean isdefaultBank; + /** + * 国际银行账户号码 + */ + private String iban; + /** + * 账户性质[0:公司, 1:个人] + */ + @JsonProperty("account_property") + private String accountProperty; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiCustomerVO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiCustomerVO.java new file mode 100644 index 00000000..852d1c0a --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiCustomerVO.java @@ -0,0 +1,200 @@ +/** + * 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.pojo.vo; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; + +/** + * 业财融合(BFI)A902-1 客户查询返回结果 + *

+ * 成功响应示例: + *

{@code
+ * {
+ *   "data": { "filter": "", "lastPage": false, "pageNo": 1, "pageSize": 2, "rows": [], "totalCount": 103 },
+ *   "errorCode": "0",
+ *   "message": null,
+ *   "status": true
+ * }
+ * }
+ * + * @author Chill + */ +@Data +public class BfiCustomerVO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * id + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long id; + /** + * 编码 + */ + private String number; + /** + * 名称 + */ + private String name; + /** + * 数据状态[A:暂存, B:已提交, C:已审核] + */ + private String status; + /** + * 使用状态[0:禁用, 1:可用] + */ + private String enable; + /** + * 创建时间 + */ + private String createtime; + /** + * 修改时间 + */ + private String modifytime; + /** + * 统一社会信用代码(个人填写身份证号或手机号) + */ + private String societycreditcode; + /** + * 控制策略[1:逐级分配, 2:自由分配, 5:全局共享, 6:管控范围内共享, 7:私有] + */ + private String ctrlstrategy; + /** + * 伙伴类型[1:法人企业, 2:非法人企业, 3:非企业单位, 4:个人, 5:个体户, 18:中央企业, 19:中央部委, 6:民营企业, 7:非我区国有企业, 8:我区国有企业, 13:境外客商, 9:政府事业单位等, 10:农民专业合作社, 11:民政部门, 14:工会, 15:司法行政, 12:其他, 16:虚拟客商, 17:集体经济组织] + */ + private String type; + /** + * 业务职能[1:销售, 2:结算, 3:付款, 4:收货] + */ + private String bizfunction; + /** + * 电子邮箱 + */ + @JsonProperty("postal_code") + private String postalCode; + /** + * 简称 + */ + private String simplename; + /** + * 详细地址 + */ + @JsonProperty("bizpartner_address") + private String bizpartnerAddress; + /** + * 联系人 + */ + private String linkman; + /** + * 传真 + */ + @JsonProperty("bizpartner_fax") + private String bizpartnerFax; + /** + * 联系电话 + */ + @JsonProperty("bizpartner_phone") + private String bizpartnerPhone; + /** + * 纳税人识别号 + */ + @JsonProperty("tx_register_no") + private String txRegisterNo; + /** + * 法人代表 + */ + private String artificialperson; + /** + * 注册资本 + */ + private String regcapital; + /** + * 营业期限 + */ + private String businessterm; + /** + * 经营范围 + */ + private String businessscope; + /** + * 成立日期 + */ + private String establishdate; + /** + * 身份证号 + */ + private String idno; + /** + * 简拼 + */ + private String simplepinyin; + /** + * 备注 + */ + private String fcusbeizhu; + /** + * 数据来源系统[XH:星瀚, WCY:物产云, JCZX:检测中心, NBERP:南北ERP, WCCK:城矿ERP, WY:维云(服务), CMS:合同系统, DTP:数据中台] + */ + @JsonProperty("awrp_datasourcesys") + private String awrpDatasourcesys; + /** + * 是否为贸易客商[A:是, B:否] + */ + @JsonProperty("awrp_cusfortrade") + private String awrpCusfortrade; + /** + * 审核日期 + */ + private String approvedate; + /** + * 商务伙伴.编码 + */ + @JsonProperty("bizpartner_number") + private String bizpartnerNumber; + /** + * 结算方式.编码 + */ + @JsonProperty("settlementtypeid_number") + private String settlementtypeidNumber; + /** + * 收款条件.编码 + */ + @JsonProperty("receivingcondid_number") + private String receivingcondidNumber; + /** + * 银行信息分录 + */ + @JsonProperty("entry_bank") + private List entryBank; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiExchangeRateVO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiExchangeRateVO.java new file mode 100644 index 00000000..5bc5ae01 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiExchangeRateVO.java @@ -0,0 +1,164 @@ +/** + * 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.pojo.vo; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.math.BigDecimal; + +/** + * 业财融合(BFI)A801-4 汇率查询返回结果 + * + * @author Chill + */ +@Data +public class BfiExchangeRateVO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * id + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long id; + /** + * 编码 + */ + private String number; + /** + * 名称 + */ + private String name; + /** + * 数据状态[A:暂存, B:已提交, C:已审核] + */ + private String status; + /** + * 数据状态标题 + */ + @JsonProperty("status_title") + private String statusTitle; + /** + * 使用状态[0:禁用, 1:可用] + */ + private String enable; + /** + * 使用状态标题 + */ + @JsonProperty("enable_title") + private String enableTitle; + /** + * 创建时间 + */ + private String createtime; + /** + * 修改时间 + */ + private String modifytime; + /** + * 主数据内码 + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long masterid; + /** + * 禁用时间 + */ + private String disabledate; + /** + * 汇率类型[1:固定汇率, 2:浮动汇率] + */ + private String exctype; + /** + * 汇率类型标题 + */ + @JsonProperty("exctype_title") + private String exctypeTitle; + /** + * 直接汇率值 + */ + private BigDecimal excval; + /** + * 外币牌价[1:买入价, 2:中间价, 3:卖出价] + */ + private String foreignexcid; + /** + * 外币牌价标题 + */ + @JsonProperty("foreignexcid_title") + private String foreignexcidTitle; + /** + * 生效日期 + */ + private String effectdate; + /** + * 换算方式[1:直接汇率, 2:间接汇率] + */ + private String convertmode; + /** + * 换算方式标题 + */ + @JsonProperty("convertmode_title") + private String convertmodeTitle; + /** + * 数据源[FX_001:人民币中间价, FX_002:交叉汇率, FX_REF:人民币参考汇率] + */ + private String datasource; + /** + * 数据源标题 + */ + @JsonProperty("datasource_title") + private String datasourceTitle; + /** + * 失效日期 + */ + private String expirydate; + /** + * 间接汇率值 + */ + private BigDecimal indirectexrate; + /** + * 显示精度参数 + */ + private Integer precision; + /** + * 计算规则 + */ + private String computationrules; + /** + * 原币编码 + */ + @JsonProperty("orgcur_number") + private String orgcurNumber; + /** + * 原币名称 + */ + @JsonProperty("orgcur_name") + private String orgcurName; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiInvoiceApplyEntryVO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiInvoiceApplyEntryVO.java new file mode 100644 index 00000000..5716108d --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiInvoiceApplyEntryVO.java @@ -0,0 +1,159 @@ +/** + * 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.pojo.vo; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.math.BigDecimal; + +/** + * 业财融合(BFI)A303-4 开票申请单查询 单据体分录 + * + * @author Chill + */ +@Data +public class BfiInvoiceApplyEntryVO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * id + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long id; + /** + * 分录行号 + */ + private Integer seq; + /** + * 行性质[1:折扣行, 2:商品行] + */ + private String rowtype; + /** + * 商品名称 + */ + private String goodsname; + /** + * 简称 + */ + private String goodssimplename; + /** + * 规格型号 + */ + private String specification; + /** + * 计量单位 + */ + private String unit; + /** + * 数量 + */ + private BigDecimal num; + /** + * 单价(不含税) + */ + private BigDecimal unitprice; + /** + * 单价(含税) + */ + private BigDecimal taxunitprice; + /** + * 金额(不含税) + */ + private BigDecimal amount; + /** + * 金额(含税) + */ + private BigDecimal taxamount; + /** + * 税率[0:0%, 0.01:1%, 0.015:减按1.5%, 0.03:3%, 0.04:4%, 0.05:5%, 0.06:6%, 0.09:9%, 0.10:10%, 0.11:11%, 0.13:13%, 0.16:16%, 0.17:17%] + */ + private String taxrate; + /** + * 税额 + */ + private BigDecimal tax; + /** + * 剩余可申请数量 + */ + private BigDecimal remainvalidnum; + /** + * 剩余可申请金额 + */ + private BigDecimal remainvalidamount; + /** + * 剩余可申请税额 + */ + private BigDecimal remainvalidtax; + /** + * 已开金额 + */ + private BigDecimal issuedamount; + /** + * 已开税额 + */ + private BigDecimal issuedtax; + /** + * 已开价税合计 + */ + private BigDecimal issuedtotaltaxamount; + /** + * 是否享受优惠[0:不享受, 1:享受] + */ + private String policylogo; + /** + * 税收分类合并编码 + */ + private String goodscode; + /** + * 备注 + */ + private String remark; + /** + * 原始商品名称 + */ + private String origoodsname; + /** + * 折扣金额 + */ + private BigDecimal discountamount; + /** + * 折扣率 + */ + private BigDecimal discountrate; + /** + * 基准类型[0:数量基准, 1:金额基准] + */ + private String benchmark; + /** + * 是否赠品 + */ + private Boolean gift; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiInvoiceApplyOpenApiBillVO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiInvoiceApplyOpenApiBillVO.java new file mode 100644 index 00000000..c28c76bd --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiInvoiceApplyOpenApiBillVO.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.thirdparty.bfi.pojo.vo; + +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; + +/** + * 业财融合(BFI)A303-5 开票申请发票查询返回单据信息 + * + * @author Chill + */ +@Data +public class BfiInvoiceApplyOpenApiBillVO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 单据编号 + */ + private String billNo; + /** + * 发票列表 + */ + private List invoiceList; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiInvoiceApplyOpenApiInvoiceVO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiInvoiceApplyOpenApiInvoiceVO.java new file mode 100644 index 00000000..e58d5a08 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiInvoiceApplyOpenApiInvoiceVO.java @@ -0,0 +1,349 @@ +/** + * 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.pojo.vo; + +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.List; + +/** + * 业财融合(BFI)A303-5 开票申请发票查询返回发票明细 + * + * @author Chill + */ +@Data +public class BfiInvoiceApplyOpenApiInvoiceVO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 发票ID + */ + private Long id; + /** + * 发票代码 + */ + private String invoiceCode; + /** + * 发票号码 + */ + private String invoiceNumber; + /** + * 开票日期(时间戳) + */ + private Long invoiceDate; + /** + * 发票类型 + */ + private String invoiceType; + /** + * 发票状态 + */ + private Integer invoiceStatus; + /** + * 发票属性 + */ + private Integer invoiceProperty; + /** + * 发票来源 + */ + private String invoiceSource; + /** + * 单据编号 + */ + private String billNo; + /** + * 流水号 + */ + private String serialNo; + /** + * 购方名称 + */ + private String buyerName; + /** + * 购方纳税人识别号 + */ + private String buyerTaxpayerId; + /** + * 购方地址及电话 + */ + private String buyerAddressAndTel; + /** + * 购方银行及账号 + */ + private String buyerBankAndAccount; + /** + * 购方性质 + */ + private Integer buyerProperty; + /** + * 购方收件人邮箱 + */ + private String buyerRecipientMail; + /** + * 购方收件人电话 + */ + private String buyerRecipientPhone; + /** + * 销方名称 + */ + private String sellerName; + /** + * 销方纳税人识别号 + */ + private String sellerTaxpayerId; + /** + * 销方地址及电话 + */ + private String sellerAddressAndTel; + /** + * 销方银行及账号 + */ + private String sellerBankAndAccount; + /** + * 不含税金额 + */ + private BigDecimal totalAmount; + /** + * 税额 + */ + private BigDecimal totalTaxAmount; + /** + * 价税合计 + */ + private BigDecimal includeTaxAmount; + /** + * 可红冲金额 + */ + private BigDecimal canRedAmount; + /** + * 可红冲税额 + */ + private BigDecimal canRedTaxAmount; + /** + * 合并差额税额 + */ + private BigDecimal mergeDifferTaxAmount; + /** + * 开票人 + */ + private String drawer; + /** + * 收款人 + */ + private String payee; + /** + * 复核人 + */ + private String reviewer; + /** + * 作废人 + */ + private String canceler; + /** + * 备注 + */ + private String remark; + /** + * 校验码 + */ + private String checkCode; + /** + * 合同编号 + */ + private String contractNo; + /** + * 设备编号 + */ + private String deviceNo; + /** + * 终端编号 + */ + private String terminalNo; + /** + * 机构名称 + */ + private String orgName; + /** + * 原发票代码 + */ + private String originalInvoiceCode; + /** + * 原发票号码 + */ + private String originalInvoiceNumber; + /** + * 红冲单据号 + */ + private String redInfoBillNo; + /** + * 红冲原因 + */ + private String redReason; + /** + * 作废原因 + */ + private String abolishReason; + /** + * 发票文件URL + */ + private String invoiceFileUrl; + /** + * 发票图片URL + */ + private String invoiceImageUrl; + /** + * 数电PDF下载地址(发票云5.0.025版本返回) + */ + private String invoicePdfFileUrl; + /** + * 数电XML下载地址(发票云5.0.025版本返回) + */ + private String invoiceXmlFileUrl; + /** + * 开具状态 + */ + private String issueStatus; + /** + * 下载标记 + */ + private Integer downloadFlag; + /** + * 打印标记 + */ + private Integer printFlag; + /** + * 库存标记 + */ + private Integer inventoryFlag; + /** + * 特殊红冲标记 + */ + private Integer specialRedFlag; + /** + * 拆分合并标记 + */ + private String splitOrMergeFlag; + /** + * 商品明细列表 + */ + private List goods; + + /** + * 业财融合(BFI)A303-5 开票申请发票查询返回商品明细 + */ + @Data + public static class BfiInvoiceApplyOpenApiGoodsVO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 明细ID + */ + private String detailId; + /** + * 明细行号 + */ + private Integer detailRowNo; + /** + * 序号 + */ + private Integer seq; + /** + * 商品名称 + */ + private String goodsName; + /** + * 规格型号 + */ + private String specification; + /** + * 单位 + */ + private String units; + /** + * 数量 + */ + private String quantity; + /** + * 单价 + */ + private String price; + /** + * 金额 + */ + private BigDecimal amount; + /** + * 税率 + */ + private String taxRate; + /** + * 税额 + */ + private BigDecimal taxAmount; + /** + * 含税金额 + */ + private BigDecimal includeTaxAmount; + /** + * 含税单价 + */ + private String includeTaxPrice; + /** + * 行性质 + */ + private Integer lineProperty; + /** + * 税收分类编码 + */ + private String revenueCode; + /** + * 税收分类名称 + */ + private String revenueName; + /** + * 单据来源ID + */ + private String billSourceId; + /** + * 优惠标记 + */ + private Integer privilegeFlag; + /** + * 优惠内容 + */ + private String privilegeContent; + /** + * 征税标记 + */ + private String taxFlag; + /** + * 零税率标记 + */ + private String zeroTaxRateFlag; + } +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiInvoiceApplyVO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiInvoiceApplyVO.java new file mode 100644 index 00000000..909c1518 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiInvoiceApplyVO.java @@ -0,0 +1,377 @@ +/** + * 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.pojo.vo; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.List; + +/** + * 业财融合(BFI)A303-4 开票申请单查询返回结果 + *

+ * 成功响应示例: + *

{@code
+ * {
+ *   "data": {
+ *     "filter": "[systemsource = 'WCCK']", "lastPage": true, "pageNo": 1, "pageSize": 10,
+ *     "rows": [
+ *       {
+ *         "id": "2400746134621404160", "hsbz": "1", "producttype": "1", "billcomplete": "0",
+ *         "buyertaxno": "91450881MA7BFT2629", "salername": "广西壮族自治区建筑工程质量检测中心有限公司",
+ *         "salertaxno": "91450000MA5Q1G1M59", "invoicetype": "08xdp", "totalamount": 245.0,
+ *         "buyername": "广西贵港建设集团有限公司桂平分公司", "billno": "test", "billstatus": "C",
+ *         "systemsource": "WCCK", "comprehensivestatus": "01",
+ *         "sim_original_bill_item": [ ... ]
+ *       }
+ *     ]
+ *   },
+ *   "errorCode": "0", "message": null, "status": true
+ * }
+ * }
+ * + * @author Chill + */ +@Data +public class BfiInvoiceApplyVO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * id + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long id; + /** + * 单据日期 + */ + private String billdate; + /** + * 币别 + */ + private String currency; + /** + * 申请方 + */ + private String applicant; + /** + * 原购方名称 + */ + private String oribuyername; + /** + * 原购方地址电话 + */ + private String oribuyeraddr; + /** + * 原购方开户行 + */ + private String oribuyerbank; + /** + * 是否含税[0:不含税, 1:含税] + */ + private String hsbz; + /** + * 商品类型[1:开票项, 2:物料, 3:费用项目] + */ + private String producttype; + /** + * 单据完整[0:完整, 1:待补充] + */ + private String billcomplete; + /** + * 关闭状态[0:未关闭, 1:关闭] + */ + private String closestatus; + /** + * 购方纳税人识别号 + */ + private String buyertaxno; + /** + * 购方地址及电话 + */ + private String buyeraddr; + /** + * 购方开户行及账号 + */ + private String buyerbank; + /** + * 交付手机 + */ + private String buyerphone; + /** + * 购方企业类型[0:企业, 1:个人, 2:非企业单位] + */ + private String buyerproperty; + /** + * 销方开户行及账号 + */ + private String salerbank; + /** + * 收款人 + */ + private String payee; + /** + * 复核人 + */ + private String reviewer; + /** + * 销方名称 + */ + private String salername; + /** + * 销方纳税人识别号 + */ + private String salertaxno; + /** + * 销方地址及电话 + */ + private String saleraddr; + /** + * 开票人 + */ + private String drawer; + /** + * 发票推送邮箱 + */ + private String buyeremail; + /** + * 征税方式[0:普通征税, 1:减按计征, 2:差额征税, 01:差额征税-全额开票, 02:差额征税-差额开票, 3:减按征税] + */ + private String taxationstyle; + /** + * 发票种类[10xdp:数电发票(普通发票), 08xdp:数电发票(增值税专用发票)] + */ + private String invoicetype; + /** + * 特殊票种[00:非特殊票种, 02:收购, 06:抵扣通行费, 07:不抵扣通行费, 08:成品油, 11:卷烟, 18:机动车, E06:不动产租赁, E03:建筑服务, E05:不动产销售, E04:货物运输, E01:成品油(数电发票), E18:卷烟(数电发票), E12:自产农产品销售, E09:旅客运输, E14:机动车(数电发票), E07:代收车船税] + */ + private String specialtype; + /** + * 汇率日期 + */ + private String exchangedate; + /** + * 已申请金额 + */ + private BigDecimal confirmamount; + /** + * 申请金额 + */ + private BigDecimal invoiceamount; + /** + * 申请价税合计 + */ + private BigDecimal totalamount; + /** + * 申请税额 + */ + private BigDecimal totaltax; + /** + * 已开金额 + */ + private BigDecimal mainissuedamount; + /** + * 已开税额 + */ + private BigDecimal mainissuedtax; + /** + * 剩余可开金额 + */ + private BigDecimal surplusamount; + /** + * 剩余可开税额 + */ + private BigDecimal surplustax; + /** + * 换算方式[0:直接汇率, 1:间接汇率] + */ + private String quotation; + /** + * 原始价税合计 + */ + private BigDecimal oldtotalamount; + /** + * 购方名称 + */ + private String buyername; + /** + * 汇率 + */ + private BigDecimal exrate; + /** + * 价税合计 + */ + private BigDecimal invoicedtotalamount; + /** + * 开票日期 + */ + private String issuetime; + /** + * 合同编号 + */ + private String contractno; + /** + * 合同日期 + */ + private String contractdate; + /** + * 合同金额 + */ + private BigDecimal contractamount; + /** + * 开票说明 + */ + private String billremark; + /** + * 创建日期 + */ + private String createdate; + /** + * 单据类型[A:普通单, B:红冲单, C:作废单, D:重开单] + */ + private String billsourcetype; + /** + * 申请状态[0:未申请, 1:部分申请, 2:已申请] + */ + private String confirmstate; + /** + * 单据性质[-1:负数, 1:正数] + */ + private String billproperties; + /** + * 业务单据编号 + */ + private String billno; + /** + * 单据审批状态[A:暂存, B:已提交, C:已审核, D:无需审批] + */ + private String billstatus; + /** + * 审核意见 + */ + private String auditsuggestion; + /** + * 数据来源系统 + */ + private String systemsource; + /** + * 单据状态[0:正常, 1:搁置, 2:开票结束] + */ + private String validstate; + /** + * 购销身份[0:我是销方, 1:我是购方] + */ + private String salerorbuyer; + /** + * 数据来源系统[XH:星瀚, WCY:物产云, JCZX:检测中心, NBERP:南北ERP, WCCK:城矿ERP, WY:维云(服务), CMS:合同系统, DTP:数据中台] + */ + @JsonProperty("awrp_datasourcesys") + private String awrpDatasourcesys; + /** + * 单据综合状态[00:单据作废, 01:待开票, 02:合并拆分确认完成, 08:部分发票已开具完成, 09:全部发票已开具完成, 11:待提交, 12:待审批, 21:开票后部分红冲/作废, 22:开票后全部红冲/作废, 23:开票后部分红冲/作废并重开, 24:开票后全部红冲/作废并重开, 31:负数单据蓝票匹配中, 32:负数单据蓝票部分匹配完成, 33:负数单据蓝票全部匹配完成, 34:负数单据蓝票零匹配, 35:负数单据红字确认单申请中, 36:负数单据红字确认单部分申请完成, 37:负数单据红字确认单全部申请完成, 38:负数单据红字确认单全部申请失败, 39:负数单据匹配蓝票部分开票, 40:负数单据匹配红字申请部分完成, 41:负数单据匹配红字申请全部完成, 42:负数单据匹配红字申请部分开票] + */ + private String comprehensivestatus; + /** + * 展示卖方地址电话 + */ + private Boolean showsaleraddresstel; + /** + * 展示买方地址电话 + */ + private Boolean showbuyeraddresstel; + /** + * 展示卖方银行 + */ + private Boolean showsalerbank; + /** + * 展示买方银行 + */ + private Boolean showbuyerbank; + /** + * 币别.货币代码 + */ + @JsonProperty("fromcurr_number") + private String fromcurrNumber; + /** + * 本位币.货币代码 + */ + @JsonProperty("tocurr_number") + private String tocurrNumber; + /** + * 汇率表.编码 + */ + @JsonProperty("exratetable_number") + private String exratetableNumber; + /** + * 销售组织.编码 + */ + @JsonProperty("salesorgbase_number") + private String salesorgbaseNumber; + /** + * 结算组织.编码 + */ + @JsonProperty("settlementorgbase_number") + private String settlementorgbaseNumber; + /** + * 资金组织.编码 + */ + @JsonProperty("capitalorgbase_number") + private String capitalorgbaseNumber; + /** + * 客户.编码 + */ + @JsonProperty("customnameid_number") + private String customnameidNumber; + /** + * 组织.编码 + */ + @JsonProperty("orgid_number") + private String orgidNumber; + /** + * 经办人.经办人编码 + */ + @JsonProperty("operator_number") + private String operatorNumber; + /** + * 申请部门.编码 + */ + @JsonProperty("awrp_orgfield_number") + private String awrpOrgfieldNumber; + /** + * 项目.项目编码 + */ + @JsonProperty("awrp_project_number") + private String awrpProjectNumber; + /** + * 单据体分录 + */ + @JsonProperty("sim_original_bill_item") + private List simOriginalBillItem; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiInvoicePoolItemVO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiInvoicePoolItemVO.java new file mode 100644 index 00000000..756ec5ff --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiInvoicePoolItemVO.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.thirdparty.bfi.pojo.vo; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 业财融合(BFI)A801-1 查询全票池发票数据 发票明细 + * + * @author Chill + */ +@Data +public class BfiInvoicePoolItemVO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 分录行号 + */ + @JsonProperty("seq") + private String seq; + /** + * 商品名称 + */ + @JsonProperty("goodsName") + private String goodsName; + /** + * 规格型号 + */ + @JsonProperty("specModel") + private String specModel; + /** + * 单位 + */ + @JsonProperty("unit") + private String unit; + /** + * 数量 + */ + @JsonProperty("num") + private String num; + /** + * 单价 + */ + @JsonProperty("unitPrice") + private String unitPrice; + /** + * 金额 + */ + @JsonProperty("detailAmount") + private String detailAmount; + /** + * 税率 + */ + @JsonProperty("taxRate") + private String taxRate; + /** + * 税额 + */ + @JsonProperty("taxAmount") + private String taxAmount; + /** + * 税收分类编码 + */ + @JsonProperty("goodsCode") + private String goodsCode; + /** + * 折扣类型 + */ + @JsonProperty("discountType") + private String discountType; + /** + * 优惠政策 + */ + @JsonProperty("preferentialPolicy") + private String preferentialPolicy; + /** + * 增值税特殊事项 + */ + @JsonProperty("vatException") + private String vatException; + /** + * 零税率标识 + */ + @JsonProperty("zeroTaxRateFlag") + private String zeroTaxRateFlag; + /** + * 版本号 + */ + @JsonProperty("versionNo") + private String versionNo; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiInvoicePoolVO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiInvoicePoolVO.java new file mode 100644 index 00000000..6b9867f9 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiInvoicePoolVO.java @@ -0,0 +1,540 @@ +/** + * 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.pojo.vo; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; + +/** + * 业财融合(BFI)A801-1 查询全票池发票数据返回结果 + *

+ * 成功响应示例: + *

{@code
+ * {
+ *   "data": [
+ *     {
+ *       "downloadUrl": "234e244b2c803400", "invoiceAmount": "372.28", "invoiceType": "27",
+ *       "invoiceNo": "25532000000044538098", "salerTaxNo": "91532503MADPDYU671",
+ *       "buyerTaxNo": "91450100073757311R", "salerName": "红河熠龙酒店有限责任公司",
+ *       "totalAmount": "376", "totalTaxAmount": "3.72", "serialNo": "d798b8d286bd4b23b2b0e971a8adc79d0",
+ *       "imageUrl": "234e244b99803400", "checkStatus": "1",
+ *       "items": [ { "goodsName": "*住宿服务*住宿费", "detailAmount": "372.28", "taxRate": "0.01", ... } ]
+ *     }
+ *   ],
+ *   "errorCode": "0", "message": null, "status": true
+ * }
+ * }
+ * 字段明细参考票易通文档 https://open.piaozone.com/api-145421056 + * + * @author Chill + */ +@Data +public class BfiInvoicePoolVO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 发票号码 + */ + @JsonProperty("invoiceNo") + private String invoiceNo; + /** + * 发票类型[26:数电票(普票), 27:数电票(专票), 1:增值税电子普通发票, 2:增值税电子专用发票, 15:道路通行费电子普通发票, 25:财政电子票据, ...] + */ + @JsonProperty("invoiceType") + private String invoiceType; + /** + * 发票流水号 + */ + @JsonProperty("serialNo") + private String serialNo; + /** + * 原发票代码 + */ + @JsonProperty("originalInvoiceCode") + private String originalInvoiceCode; + /** + * 原发票号码 + */ + @JsonProperty("originalInvoiceNo") + private String originalInvoiceNo; + /** + * 不含税金额 + */ + @JsonProperty("invoiceAmount") + private String invoiceAmount; + /** + * 价税合计 + */ + @JsonProperty("totalAmount") + private String totalAmount; + /** + * 税额 + */ + @JsonProperty("taxAmount") + private String taxAmount; + /** + * 价税合计(含清单) + */ + @JsonProperty("totalTaxAmount") + private String totalTaxAmount; + /** + * 税率(票开放平台顶层税率字段) + */ + @JsonProperty("taxRate") + private String taxRate; + /** + * 可用税额 + */ + @JsonProperty("availableAmount") + private String availableAmount; + /** + * 金额使用状态[0:未使用, 2:全部使用](金蝶字典,选票口径仅保留未使用的票) + */ + @JsonProperty("amountUseStatus") + private String amountUseStatus; + /** + * 购方税号 + */ + @JsonProperty("buyerTaxNo") + private String buyerTaxNo; + /** + * 购方名称 + */ + @JsonProperty("buyerName") + private String buyerName; + /** + * 销方税号 + */ + @JsonProperty("salerTaxNo") + private String salerTaxNo; + /** + * 销方名称 + */ + @JsonProperty("salerName") + private String salerName; + /** + * 销方地址电话 + */ + @JsonProperty("salerAddressPhone") + private String salerAddressPhone; + /** + * 开票人 + */ + @JsonProperty("drawer") + private String drawer; + /** + * 收款人 + */ + private String payee; + /** + * 复核人 + */ + @JsonProperty("reviewer") + private String reviewer; + /** + * 备注 + */ + private String remark; + /** + * 开票日期 + */ + @JsonProperty("invoiceDate") + private String invoiceDate; + /** + * 是否作废删除[0:否, 1:是] + */ + @JsonProperty("delete") + private String delete; + /** + * 发票标签 + */ + @JsonProperty("invoiceTag") + private String invoiceTag; + /** + * 特殊类型标记 + */ + @JsonProperty("specialTypeMark") + private String specialTypeMark; + /** + * 是否电子信息[1:是] + */ + @JsonProperty("isElectricInvoice") + private String isElectricInvoice; + /** + * 查验状态[0:未查验, 1:通过, 2:不通过] + */ + @JsonProperty("checkStatus") + private String checkStatus; + /** + * 是否需要查验[1:是] + */ + @JsonProperty("needCheck") + private String needCheck; + /** + * 抵扣标志[1:可抵扣] + */ + @JsonProperty("deductionFlag") + private String deductionFlag; + /** + * 认证标志[0:未认证, 1:已认证] + */ + @JsonProperty("authenticateFlag") + private String authenticateFlag; + /** + * 认证时间 + */ + @JsonProperty("authenticateTime") + private String authenticateTime; + /** + * 勾选时间 + */ + @JsonProperty("selectTime") + private String selectTime; + /** + * 入账时间 + */ + @JsonProperty("recordedTime") + private String recordedTime; + /** + * 入账用途 + */ + @JsonProperty("recordedPurpose") + private String recordedPurpose; + /** + * 税局入账状态[01:未入账, 11:入账处理中, 02:已入账(税前扣除), 03:已入账(不扣除), 06:入账撤销] + */ + @JsonProperty("recordedStatus") + private String recordedStatus; + /** + * 税属期,如 2026-01 + */ + @JsonProperty("taxPeriod") + private String taxPeriod; + /** + * 所属组织id + */ + @JsonProperty("orgId") + private String orgId; + /** + * 所属组织编码 + */ + @JsonProperty("orgNumber") + private String orgNumber; + /** + * 所属组织名称 + */ + @JsonProperty("orgName") + private String orgName; + /** + * 文件下载地址(用于 A801-2 文件下载接口的 path 参数) + */ + @JsonProperty("downloadUrl") + private String downloadUrl; + /** + * 图片文件地址 + */ + @JsonProperty("imageUrl") + private String imageUrl; + /** + * pdf 文件地址 + */ + @JsonProperty("taxPdfUrl") + private String taxPdfUrl; + /** + * ofd 文件地址 + */ + @JsonProperty("taxOfdUrl") + private String taxOfdUrl; + /** + * xml 文件地址 + */ + @JsonProperty("xmlUrl") + private String xmlUrl; + /** + * 数据来源,如 电子税局下载 + */ + @JsonProperty("resource") + private String resource; + /** + * 单据创建时间 + */ + @JsonProperty("billCreateTime") + private String billCreateTime; + /** + * 额外参数:业务单据号 + */ + @JsonProperty("expense_num") + private String expenseNum; + /** + * 发票明细 + */ + @JsonProperty("items") + private List items; + /** + * 开具端xbrl文件名,数电票/财政电子票据返回 + */ + @JsonProperty("xbrlName") + private String xbrlName; + /** + * 电子凭证类型[einvoice:数电票, trainele:铁路客票, airele:航空行程单, financial:财政票据] + */ + @JsonProperty("xbrlType") + private String xbrlType; + /** + * 开具端xbrl文件下载地址 + */ + @JsonProperty("xbrlUrl") + private String xbrlUrl; + /** + * 个人标识 + */ + @JsonProperty("personFlag") + private Boolean personFlag; + /** + * 发票红蓝类型[0:蓝字发票, 1:红字发票] + */ + @JsonProperty("type") + private String type; + /** + * 发票代码,数电票一般无此字段 + */ + @JsonProperty("invoiceCode") + private String invoiceCode; + /** + * 校验码/随机码 + */ + @JsonProperty("checkCode") + private String checkCode; + /** + * 通行费入口 + */ + @JsonProperty("entrance") + private String entrance; + /** + * 通行费出口 + */ + @JsonProperty("exit") + private String exit; + /** + * 发票所在地,定额、过路过桥费发票返回 + */ + @JsonProperty("place") + private String place; + /** + * 省份 + */ + @JsonProperty("province") + private String province; + /** + * 城市 + */ + @JsonProperty("city") + private String city; + /** + * 区域 + */ + @JsonProperty("area") + private String area; + /** + * 发票区域 + */ + @JsonProperty("region") + private String region; + /** + * 目的地区域 + */ + @JsonProperty("destArea") + private String destArea; + /** + * 来源地区 + */ + @JsonProperty("sourceArea") + private String sourceArea; + /** + * 销方类型 + */ + @JsonProperty("salerType") + private String salerType; + /** + * 销方银行账号 + */ + @JsonProperty("salerAccount") + private String salerAccount; + /** + * 购方地址电话 + */ + @JsonProperty("buyerAddressPhone") + private String buyerAddressPhone; + /** + * 购方银行账号 + */ + @JsonProperty("buyerAccount") + private String buyerAccount; + /** + * 金额(备用字段) + */ + @JsonProperty("amount") + private String amount; + /** + * 可抵扣税额,用于旅客运输、海关缴款书等场景 + */ + @JsonProperty("effectiveTaxAmount") + private String effectiveTaxAmount; + /** + * 代开标记 + */ + @JsonProperty("proxyMark") + private String proxyMark; + /** + * 发票风险等级 + */ + @JsonProperty("invoiceRiskLevel") + private String invoiceRiskLevel; + /** + * 文件类型[1:pdf, 2:图片, 4:ofd, 9:xml] + */ + @JsonProperty("fileType") + private String fileType; + /** + * 保存文件名 + */ + @JsonProperty("fileName") + private String fileName; + /** + * 原始文件名 + */ + @JsonProperty("originalFileName") + private String originalFileName; + /** + * 发票快照预览地址 + */ + @JsonProperty("snapshotUrl") + private String snapshotUrl; + /** + * PDF下载地址 + */ + @JsonProperty("pdfurl") + private String pdfurl; + /** + * 图片像素 + */ + @JsonProperty("pixel") + private String pixel; + /** + * 图片旋转角度 + */ + @JsonProperty("rotationAngle") + private String rotationAngle; + /** + * 发票状态[0:正常, 1:失控, 2:作废, 3:红冲, 4:异常, 7:部分红冲, 8:全部红冲] + */ + @JsonProperty("invoiceStatus") + private String invoiceStatus; + /** + * 单据报销状态[1:未用, 30:在用, 60:已用, 65:已入账, 70:已归档] + */ + @JsonProperty("expendStatus") + private String expendStatus; + /** + * 报销状态(业务扩展) + */ + @JsonProperty("expenseStatus") + private String expenseStatus; + /** + * 是否修改 + */ + @JsonProperty("isModify") + private String isModify; + /** + * 是否修订 + */ + @JsonProperty("isRevise") + private String isRevise; + /** + * 是否有公司印章[0:无, 1:有],仅火车票退票凭证 + */ + @JsonProperty("companySeal") + private String companySeal; + /** + * 签收状态[0:未签收, 1:已签收] + */ + @JsonProperty("originalState") + private String originalState; + /** + * 销项清单是否完整 + */ + @JsonProperty("salelistComplete") + private String salelistComplete; + /** + * 销项清单行数 + */ + @JsonProperty("salelistSum") + private Integer salelistSum; + /** + * 税局入账属期,格式YYYY-MM-DD HH:MM:SS + */ + @JsonProperty("recordedPeriod") + private String recordedPeriod; + /** + * 更新时间,格式YYYY-MM-DD HH:MM:SS + */ + @JsonProperty("updateTime") + private String updateTime; + /** + * 发票流水号别名,同serialNo + */ + @JsonProperty("serial_no") + private String serialNoAlias; + /** + * 税局源XML下载地址,数电票 + */ + @JsonProperty("taxXmlUrl") + private String taxXmlUrl; + /** + * 出行人列表,客运票;票易通多数场景返回字符串数组,部分数据源(如发票助手)返回对象数组,宽松承载防解析失败 + */ + @JsonProperty("travelerList") + private List travelerList; + /** + * 发票抬头 + */ + @JsonProperty("title") + private String title; + /** + * 运输类抵扣标记 + */ + @JsonProperty("transportDeduction") + private String transportDeduction; + /** + * 发票时间,例16:51:32,过路过桥费发票 + */ + @JsonProperty("ftime") + private String ftime; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiOpenApiResultVO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiOpenApiResultVO.java new file mode 100644 index 00000000..f284162c --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiOpenApiResultVO.java @@ -0,0 +1,75 @@ +/** + * 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.pojo.vo; + +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; + +/** + * 业财融合(BFI)A303-5 开票申请发票查询统一返回结果 + *

+ * 成功响应示例: + *

{@code
+ * {
+ *   "data": [ { "billNo": "KPSQ-JCZX-202509-002276", "invoiceList": [ ... ] } ],
+ *   "errorCode": "S0000",
+ *   "success": true
+ * }
+ * }
+ * data 内容为发票明细,参考票易通文档 https://open.piaozone.com/api-146003729 + * + * @param data 数据类型 + * @author Chill + */ +@Data +public class BfiOpenApiResultVO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 返回数据 data + */ + private T data; + /** + * 响应码 + */ + private String errorCode; + /** + * 响应状态 + */ + private Boolean success; + + /** + * 是否调用成功 + * + * @return true-成功 + */ + public boolean isSuccess() { + return Boolean.TRUE.equals(success); + } +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiPageVO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiPageVO.java new file mode 100644 index 00000000..03779b44 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiPageVO.java @@ -0,0 +1,68 @@ +/** + * 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.pojo.vo; + +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; + +/** + * 业财融合(BFI)分页查询返回结果 + * + * @param 数据行类型 + * @author Chill + */ +@Data +public class BfiPageVO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 查询条件 + */ + private String filter; + /** + * 是否最后一页 + */ + private Boolean lastPage; + /** + * 页码 + */ + private Integer pageNo; + /** + * 页数 + */ + private Integer pageSize; + /** + * 总数 + */ + private Integer totalCount; + /** + * 数据行 + */ + private List rows; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiPayApplyEntryVO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiPayApplyEntryVO.java new file mode 100644 index 00000000..18b8f774 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiPayApplyEntryVO.java @@ -0,0 +1,250 @@ +/** + * 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.pojo.vo; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.math.BigDecimal; + +/** + * 业财融合(BFI)A103-2 付款申请单查询明细返回结果 + * + * @author Chill + */ +@Data +public class BfiPayApplyEntryVO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * id + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long id; + /** + * 核心单据类型 + */ + @JsonProperty("e_corebilltype") + private String eCorebilltype; + /** + * 源单id + */ + @JsonProperty("e_srcbillid") + @JsonSerialize(using = ToStringSerializer.class) + private Long eSrcbillid; + /** + * 源单分录id + */ + @JsonProperty("e_srcbillentryid") + @JsonSerialize(using = ToStringSerializer.class) + private Long eSrcbillentryid; + /** + * 业务协同号 + */ + private String bizcoopno; + /** + * 业务协同行ID + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long bizcoopentryid; + /** + * 成组号 + */ + private String groupnumber; + /** + * 成组行号 + */ + private String groupseq; + /** + * 核心单据号 + */ + @JsonProperty("e_corebillno") + private String eCorebillno; + /** + * 备注 + */ + @JsonProperty("e_remark") + private String eRemark; + /** + * 核心单据ID + */ + @JsonProperty("e_corebillid") + @JsonSerialize(using = ToStringSerializer.class) + private Long eCorebillid; + /** + * 往来账户 + */ + @JsonProperty("e_assacct") + private String eAssacct; + /** + * 应付金额 + */ + @JsonProperty("e_payamount") + private BigDecimal ePayamount; + /** + * 申请金额 + */ + @JsonProperty("e_applyamount") + private BigDecimal eApplyamount; + /** + * 申请金额折结算币 + */ + @JsonProperty("e_appseleamount") + private BigDecimal eAppseleamount; + /** + * 核准金额 + */ + @JsonProperty("e_approvedamt") + private BigDecimal eApprovedamt; + /** + * 核准金额折结算币 + */ + @JsonProperty("e_approvedseleamt") + private BigDecimal eApprovedseleamt; + /** + * 到期日 + */ + @JsonProperty("e_duedate") + private String eDuedate; + /** + * 已付金额 + */ + @JsonProperty("e_paidamt") + private BigDecimal ePaidamt; + /** + * 核心单据行号 + */ + @JsonProperty("e_corebillentryseq") + private Integer eCorebillentryseq; + /** + * 冻结状态[unfreeze:未冻结, allfreeze:已冻结] + */ + @JsonProperty("e_freezestate") + private String eFreezestate; + /** + * 规格型号 + */ + @JsonProperty("e_spectype") + private String eSpectype; + /** + * 合同 + */ + @JsonProperty("e_contract") + private String eContract; + /** + * 创建时间 + */ + @JsonProperty("e_createtime") + private String eCreatetime; + /** + * 修改时间 + */ + @JsonProperty("e_modifytime") + private String eModifytime; + /** + * 已退票 + */ + private Boolean isrefund; + /** + * 关闭状态[A:正常, B:已关闭] + */ + @JsonProperty("e_closestatus") + private String eClosestatus; + /** + * 利润中心.编码 + */ + @JsonProperty("profitcenter_number") + private String profitcenterNumber; + /** + * 对方利润中心.编码 + */ + @JsonProperty("cprofitcenter_number") + private String cprofitcenterNumber; + /** + * 付款类型.编码 + */ + @JsonProperty("e_paymenttype_number") + private String ePaymenttypeNumber; + /** + * 往来户.编码 + */ + @JsonProperty("e_asstact_number") + private String eAsstactNumber; + /** + * 结算方式.编码 + */ + @JsonProperty("e_settlementtype_number") + private String eSettlementtypeNumber; + /** + * 源单币种.货币代码 + */ + @JsonProperty("e_sourcecurrency_number") + private String eSourcecurrencyNumber; + /** + * 往来银行.编码(行号) + */ + @JsonProperty("e_bebank_number") + private String eBebankNumber; + /** + * 项目.项目编码 + */ + @JsonProperty("project_number") + private String projectNumber; + /** + * 物料编码.编码 + */ + @JsonProperty("e_material_number") + private String eMaterialNumber; + /** + * 费用项目.编码 + */ + @JsonProperty("e_expenseitem_number") + private String eExpenseitemNumber; + /** + * 合同实体.编码 + */ + @JsonProperty("e_conbillentity_number") + private String eConbillentityNumber; + /** + * 成本中心.编码 + */ + @JsonProperty("e_costcenter_number") + private String eCostcenterNumber; + /** + * 产品线.编码 + */ + @JsonProperty("e_productline_number") + private String eProductlineNumber; + /** + * 商品类别.编码 + */ + @JsonProperty("awrp_splb_number") + private String awrpSplbNumber; +} diff --git a/org/springblade/core/tool/node/TreeNode.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiPayApplyInvoiceVO.java similarity index 68% rename from org/springblade/core/tool/node/TreeNode.java rename to blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiPayApplyInvoiceVO.java index 114aa0c0..842ce43b 100644 --- a/org/springblade/core/tool/node/TreeNode.java +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiPayApplyInvoiceVO.java @@ -23,50 +23,47 @@ *

* Author: Chill Zhuang (bladejava@qq.com) */ -package org.springblade.core.tool.node; +package org.springblade.thirdparty.bfi.pojo.vo; +import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.annotation.JsonSerialize; import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; import lombok.Data; -import org.springblade.core.tool.utils.Func; import java.io.Serial; -import java.util.Objects; +import java.io.Serializable; /** - * 树型节点类 + * 业财融合(BFI)A103-2 付款申请单查询发票明细返回结果 * - * @author smallchill + * @author Chill */ @Data -public class TreeNode extends BaseNode { - +public class BfiPayApplyInvoiceVO implements Serializable { @Serial private static final long serialVersionUID = 1L; - - private String title; - + /** + * id + */ @JsonSerialize(using = ToStringSerializer.class) - private Long key; - + private Long id; + /** + * 分录行号 + */ + private Integer seq; + /** + * 流水号 + */ + @JsonProperty("i_serialno") + private String iSerialno; + /** + * 发票ID + */ @JsonSerialize(using = ToStringSerializer.class) - private Long value; - - @Override - public boolean equals(Object obj) { - if (this == obj) { - return true; - } - if (obj == null) { - return false; - } - TreeNode other = (TreeNode) obj; - return Func.equals(this.getId(), other.getId()); - } - - @Override - public int hashCode() { - return Objects.hash(id, parentId); - } - + private Long invid; + /** + * 行来源[1:发票采集, 2:指定发票] + */ + @JsonProperty("i_srctype") + private String iSrctype; } diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiPayApplyVO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiPayApplyVO.java new file mode 100644 index 00000000..da145c43 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiPayApplyVO.java @@ -0,0 +1,224 @@ +/** + * 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.pojo.vo; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.List; + +/** + * 业财融合(BFI)A103-2 付款申请单查询返回结果 + * + * @author Chill + */ +@Data +public class BfiPayApplyVO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * id + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long id; + /** + * 单据编号 + */ + private String billno; + /** + * 状态[A:暂存, B:已提交, C:已审核, D:已关闭, E:部分关闭] + */ + private String billstatus; + /** + * 状态标题 + */ + @JsonProperty("billstatus_title") + private String billstatusTitle; + /** + * 创建时间 + */ + private String createtime; + /** + * 修改时间 + */ + private String modifytime; + /** + * 审核时间 + */ + private String auditdate; + /** + * 请款事由 + */ + private String applycause; + /** + * 申请日期 + */ + private String applydate; + /** + * 汇率 + */ + private BigDecimal exchangerate; + /** + * 申请金额 + */ + private BigDecimal applyamount; + /** + * 申请金额折结算币 + */ + private BigDecimal appseleamount; + /** + * 核准金额 + */ + private BigDecimal approvalamount; + /** + * 核准金额折结算币 + */ + private BigDecimal aprseleamount; + /** + * 付款状态[Norequired:无需付款, Alreadypay:已付款, Unpaid:未付款, Inpayment:部分付款] + */ + private String paystatus; + /** + * 源单类型[ap_invoice:发票, ap_finapbill:财务应付单, ar_finarbill:财务应收单等] + */ + private String sourcebilltype; + /** + * 源单id + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long sourcebillid; + /** + * 汇率日期 + */ + private String exratedate; + /** + * 影像编号 + */ + private String imageno; + /** + * 冻结状态[unfreeze:未冻结, allfreeze:已冻结] + */ + private String freezestate; + /** + * 换算方式[0:直接汇率, 1:间接汇率] + */ + private String quotation; + /** + * 单据来源类型[0:手工新增, 1:导入生成, 2:后台生成, fromeas:EAS集成, 21:跨租户同步] + */ + private String billsrctype; + /** + * 到票预付 + */ + private Boolean isprepayinvoice; + /** + * 生成凭证 + */ + private Boolean isvoucher; + /** + * 申请人.工号 + */ + @JsonProperty("creator_number") + private String creatorNumber; + /** + * 修改人.工号 + */ + @JsonProperty("modifier_number") + private String modifierNumber; + /** + * 审核人.工号 + */ + @JsonProperty("auditor_number") + private String auditorNumber; + /** + * 单据类型.编码 + */ + @JsonProperty("billtype_number") + private String billtypeNumber; + /** + * 申请组织.编码 + */ + @JsonProperty("applyorg_number") + private String applyorgNumber; + /** + * 结算组织.编码 + */ + @JsonProperty("settleorg_number") + private String settleorgNumber; + /** + * 采购组织.编码 + */ + @JsonProperty("purorg_number") + private String purorgNumber; + /** + * 付款组织.编码 + */ + @JsonProperty("payorg_number") + private String payorgNumber; + /** + * 采购组.编码 + */ + @JsonProperty("purdept_number") + private String purdeptNumber; + /** + * 付款币种.货币代码 + */ + @JsonProperty("paycurrency_number") + private String paycurrencyNumber; + /** + * 结算币.货币代码 + */ + @JsonProperty("settlecurrency_number") + private String settlecurrencyNumber; + /** + * 汇率表.编码 + */ + @JsonProperty("exratetable_number") + private String exratetableNumber; + /** + * 外部系统来源单据号 + */ + @JsonProperty("awrp_src_code") + private String awrpSrcCode; + /** + * 外部系统编号 + */ + @JsonProperty("awrp_src_sys") + private String awrpSrcSys; + /** + * 明细 + */ + private List entry; + /** + * 发票明细 + */ + private List inventry; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiPayBillBankCheckVO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiPayBillBankCheckVO.java new file mode 100644 index 00000000..5480a349 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiPayBillBankCheckVO.java @@ -0,0 +1,86 @@ +/** + * 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.pojo.vo; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.List; + +/** + * 业财融合(BFI)A103-4 付款处理查询交易明细关联返回结果 + * + * @author Chill + */ +@Data +public class BfiPayBillBankCheckVO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * id + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long id; + /** + * 分录行号 + */ + private Integer seq; + /** + * 对账标识码 + */ + private String ebankcheckflag; + /** + * 交易明细编号 + */ + private String edetailbillno; + /** + * 交易日期 + */ + private String ebizdate; + /** + * 付款金额 + */ + private BigDecimal edebitamount; + /** + * 收款金额 + */ + private BigDecimal ecreditamount; + /** + * 交易明细ID + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long edetailid; + /** + * 电子回单 + */ + @JsonProperty("awrp_receiptno") + private List awrpReceiptno; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiPayBillEntryVO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiPayBillEntryVO.java new file mode 100644 index 00000000..cca491fc --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiPayBillEntryVO.java @@ -0,0 +1,223 @@ +/** + * 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.pojo.vo; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.math.BigDecimal; + +/** + * 业财融合(BFI)A103-4 付款处理查询分录返回结果 + * + * @author Chill + */ +@Data +public class BfiPayBillEntryVO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * id + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long id; + /** + * 分录行号 + */ + private Integer seq; + /** + * 备注 + */ + @JsonProperty("e_remark") + private String eRemark; + /** + * 实付金额 + */ + @JsonProperty("e_actamt") + private BigDecimal eActamt; + /** + * 实付折本币 + */ + @JsonProperty("e_localamt") + private BigDecimal eLocalamt; + /** + * 源单ID + */ + @JsonProperty("e_sourcebillid") + @JsonSerialize(using = ToStringSerializer.class) + private Long eSourcebillid; + /** + * 源单分录ID + */ + @JsonProperty("e_sourcebillentryid") + @JsonSerialize(using = ToStringSerializer.class) + private Long eSourcebillentryid; + /** + * 应付金额 + */ + @JsonProperty("e_payableamt") + private BigDecimal ePayableamt; + /** + * 应付折本币 + */ + @JsonProperty("e_payablelocamt") + private BigDecimal ePayablelocamt; + /** + * 折扣金额 + */ + @JsonProperty("e_discountamt") + private BigDecimal eDiscountamt; + /** + * 折扣折本币 + */ + @JsonProperty("e_discountlocamt") + private BigDecimal eDiscountlocamt; + /** + * 核心单据类型 + */ + @JsonProperty("e_corebilltype") + private String eCorebilltype; + /** + * 核心单据号 + */ + @JsonProperty("e_corebillno") + private String eCorebillno; + /** + * 核心单据行号 + */ + @JsonProperty("e_corebillentryseq") + private Integer eCorebillentryseq; + /** + * 已锁定金额 + */ + @JsonProperty("e_lockamt") + private BigDecimal eLockamt; + /** + * 未锁定金额 + */ + @JsonProperty("e_unlockamt") + private BigDecimal eUnlockamt; + /** + * 已结算金额 + */ + @JsonProperty("e_settledamt") + private BigDecimal eSettledamt; + /** + * 未结算金额 + */ + @JsonProperty("e_unsettledamt") + private BigDecimal eUnsettledamt; + /** + * 税率 + */ + private BigDecimal taxrate; + /** + * 税额 + */ + private BigDecimal taxamt; + /** + * 合同号 + */ + private String contractnumber; + /** + * 退款金额 + */ + @JsonProperty("e_refundamt") + private BigDecimal eRefundamt; + /** + * 退款说明 + */ + @JsonProperty("e_refunddes") + private String eRefunddes; + /** + * 排程id + */ + @JsonProperty("e_scheid") + private String eScheid; + /** + * 是否冻结 + */ + @JsonProperty("e_isfreeze") + private Boolean eIsfreeze; + /** + * 冻结金额 + */ + @JsonProperty("e_freezeamt") + private BigDecimal eFreezeamt; + /** + * 业务日期 + */ + private String entrybizdate; + /** + * 已结算金额折本币 + */ + @JsonProperty("e_settledlocalamt") + private BigDecimal eSettledlocalamt; + /** + * 未结算金额折本位币 + */ + @JsonProperty("e_unsettledlocalamt") + private BigDecimal eUnsettledlocalamt; + /** + * 合同行号 + */ + private String conbillrownum; + /** + * 合同编号 + */ + private String conbillnumber; + /** + * 合同ID + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long conbillid; + /** + * 合同行ID + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long conbillentryid; + /** + * 核心单据行id + */ + @JsonProperty("e_corebillentryid") + @JsonSerialize(using = ToStringSerializer.class) + private Long eCorebillentryid; + /** + * 核心单据id + */ + @JsonProperty("e_corebillid") + @JsonSerialize(using = ToStringSerializer.class) + private Long eCorebillid; + /** + * 合同实体.id + */ + @JsonProperty("conbillentity_id") + private String conbillentityId; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiPayBillReceiptVO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiPayBillReceiptVO.java new file mode 100644 index 00000000..f0dce6d4 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiPayBillReceiptVO.java @@ -0,0 +1,187 @@ +/** + * 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.pojo.vo; + +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.math.BigDecimal; + +/** + * 业财融合(BFI)A103-4 付款处理查询电子回单返回结果 + * + * @author Chill + */ +@Data +public class BfiPayBillReceiptVO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * id + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long id; + /** + * 对账标识码 + */ + private String bankcheckflag; + /** + * 单据编号 + */ + private String billno; + /** + * 状态[A:暂存, B:已提交, C:已审核] + */ + private String billstatus; + /** + * 业务日期 + */ + private String bizdate; + /** + * 明细日期 + */ + private String detaildatetime; + /** + * 交易明细ID + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long detailid; + /** + * 金额 + */ + private BigDecimal amount; + /** + * 付款金额 + */ + private BigDecimal debitamount; + /** + * 收款金额 + */ + private BigDecimal creditamount; + /** + * 收付标志[0:收款, 1:付款] + */ + private String creditdebitflag; + /** + * 摘要 + */ + private String description; + /** + * 用途 + */ + private String use; + /** + * 收款人名称 + */ + private String recname; + /** + * 收款人账号 + */ + private String recno; + /** + * 收款银行名称 + */ + private String recbankname; + /** + * 对方单位 + */ + private String oppunit; + /** + * 对方银行名称 + */ + private String oppbankname; + /** + * 对方银行账号 + */ + private String oppbanknumber; + /** + * 账户名称 + */ + private String accname; + /** + * 账号 + */ + private String accno; + /** + * 本方银行名称 + */ + private String bankname; + /** + * 电子回单号 + */ + private String receiptno; + /** + * 回单文件路径 + */ + private String filepath; + /** + * 文件服务地址 + */ + private String fileserverurl; + /** + * 是否是文件 + */ + private Boolean fileflag; + /** + * 文件url + */ + private String uploadfilename; + /** + * 回单文件打开密码 + */ + private String password; + /** + * 是否匹配 + */ + private Boolean match; + /** + * 匹配时间 + */ + private String matchtime; + /** + * 匹配类型 + */ + private String matchtype; + /** + * 数据来源 + */ + private String datasource; + /** + * 是否完整 + */ + private Boolean completeflag; + /** + * 创建时间 + */ + private String createtime; + /** + * 修改时间 + */ + private String modifytime; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiPayBillVO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiPayBillVO.java new file mode 100644 index 00000000..b43e0d8b --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiPayBillVO.java @@ -0,0 +1,924 @@ +/** + * 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.pojo.vo; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.List; + +/** + * 业财融合(BFI)A103-4 付款处理查询返回结果 + * + * @author Chill + */ +@Data +public class BfiPayBillVO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * id + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long id; + /** + * 单据编号 + */ + private String billno; + /** + * 单据状态[A:暂存, B:已提交, C:已审核, D:已收款(已付款), E:变更中, G:已退单] + */ + private String billstatus; + /** + * 审核日期 + */ + private String auditdate; + /** + * 修改时间 + */ + private String modifytime; + /** + * 创建时间 + */ + private String createtime; + /** + * 业务日期 + */ + private String bizdate; + /** + * 收款人类型[bd_customer:客户, bd_supplier:供应商, bos_org:公司, bos_user:职员, other:其他] + */ + private String payeetype; + /** + * 摘要 + */ + private String description; + /** + * 多分录 + */ + private Boolean issingle; + /** + * 付款金额折本位币 + */ + private BigDecimal localamt; + /** + * 付款汇率 + */ + private BigDecimal exchangerate; + /** + * 付款金额 + */ + private BigDecimal actpayamt; + /** + * 结算号 + */ + private String settletnumber; + /** + * 转账附言 + */ + private String usage; + /** + * 收款人实名 + */ + private String recaccbankname; + /** + * 收款行行号 + */ + private String recbanknumber; + /** + * 收款人名称 + */ + private String payeename; + /** + * 收款人基础资料标识 + */ + private String payeeformid; + /** + * 付款日期 + */ + private String paydate; + /** + * 源单ID + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long sourcebillid; + /** + * 收款人ID + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long payee; + /** + * 收款账号 + */ + private String payeebanknum; + /** + * 收款账户ID + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long payeeacctbank; + /** + * 收款账户基础资料标识 + */ + private String payeeaccformid; + /** + * 是否提交银企 + */ + private Boolean iscommitbe; + /** + * 提交银企批次流水 + */ + private String batchseqid; + /** + * 交易明细流水 + */ + private String detailseqid; + /** + * 银行付款单ID + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long bankpayingid; + /** + * 新增入口 + */ + private String entrance; + /** + * 收款银行名称 + */ + private String payeebankname; + /** + * 银行返回信息 + */ + private String bankreturnmsg; + /** + * 提交银企时间 + */ + private String commitbetime; + /** + * 对账标识码(旧) + */ + @JsonProperty("bankcheckflag_old") + private String bankcheckflagOld; + /** + * 付款方式 + */ + private String paymentmode; + /** + * 银行付款单状态 + */ + private String bankpaystatus; + /** + * 来源系统 + */ + private String sourcetype; + /** + * 生成凭证 + */ + private Boolean isvoucher; + /** + * 付款人 + */ + private String orgv; + /** + * 收款人 + */ + private String payeenamev; + /** + * 是否退票 + */ + private Boolean isrefund; + /** + * 是否归档 + */ + private Boolean isarchive; + /** + * 源单编码 + */ + private String sourcebillnumber; + /** + * 源单类型 + */ + private String sourcebilltype; + /** + * 退单原因 + */ + private String reason; + /** + * 退单时间 + */ + private String backdate; + /** + * 期望付款日期 + */ + private String expectdate; + /** + * 汇率日期 + */ + private String exratedate; + /** + * 支付渠道 + */ + private String paymentchannel; + /** + * 补充合同号 + */ + private Boolean issupplecontract; + /** + * 凭证号 + */ + private String vouchernum; + /** + * 收款人类型 + */ + private String itempayeetype; + /** + * 是否加签 + */ + private Boolean issingleca; + /** + * 对私支付 + */ + private Boolean ispersonpay; + /** + * 跨境支付 + */ + private Boolean iscrosspay; + /** + * 异币别付款 + */ + private Boolean isdiffcur; + /** + * 异币别付款汇率日期 + */ + private String dpexratedate; + /** + * 异币别付款汇率 + */ + private BigDecimal dpexchangerate; + /** + * 异币别付款金额 + */ + private BigDecimal dpamt; + /** + * 异币别付款金额折本位币 + */ + private BigDecimal dplocalamt; + /** + * 兑换汇率 + */ + private BigDecimal agreedrate; + /** + * 汇兑损益 + */ + private BigDecimal lossamt; + /** + * 手续费 + */ + private BigDecimal fee; + /** + * 兑换合约号 + */ + private String contractno; + /** + * 手续费承担方 + */ + private String feepayer; + /** + * 收款账号币别 + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long payeecurrency; + /** + * 待排程 + */ + private Boolean iswaitsche; + /** + * 影像编号 + */ + private String imageno; + /** + * 是否整单下推 + */ + private Boolean isheadpush; + /** + * 源单分录ID + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long sourceentryid; + /** + * 源单分录标识 + */ + private String sourceentry; + /** + * 业务类型 + */ + private String businesstype; + /** + * 收款现金账户ID + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long payeeacctcash; + /** + * 付款人编码 + */ + private String payernumber; + /** + * 付款人组织机构代码(统一社会信用编码) + */ + private String uniformsocialcreditcode; + /** + * 收款人编码 + */ + private String payeenumber; + /** + * 实际交易日期 + */ + private String acttradedate; + /** + * 紧急程度 + */ + private String priority; + /** + * 是否置顶 + */ + private Boolean istop; + /** + * 红冲标识 + */ + private String hotaccount; + /** + * 币别 + */ + private String inneraccountcurrency; + /** + * 收款金额 + */ + private BigDecimal inneraccountamount; + /** + * 退收款单ID + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long refundbillid; + /** + * 对账标识码 + */ + private String bankcheckflag; + /** + * 对账标识码详情 + */ + @JsonProperty("bankcheckflag_tag") + private String bankcheckflagTag; + /** + * 付款金额(含手续费) + */ + private BigDecimal totalpayamt; + /** + * 付款汇率换算方式 + */ + private String payquotation; + /** + * 异币别付款汇率换算方式 + */ + private String dppayquotation; + /** + * 兑换汇率换算方式 + */ + private String agreedquotation; + /** + * 手续费付款 + */ + private Boolean feepay; + /** + * 手续费独立流水 + */ + private Boolean singlestream; + /** + * 手续费付款日期 + */ + private String feepaydate; + /** + * 收款方省 + */ + private String recprovince; + /** + * 收款方市县 + */ + private String reccity; + /** + * 收款方邮箱 + */ + private String recemail; + /** + * 收款行地址 + */ + private String recbankaddress; + /** + * 清算要求参数 + */ + private String auditparam; + /** + * 付款方式 + */ + private String paymethod; + /** + * 服务级别 + */ + private String serlevel; + /** + * 支票类型 + */ + private String checktype; + /** + * 寄送方式 + */ + private String sendway; + /** + * 支票用途 + */ + private String checkuse; + /** + * 收款行Swift Code + */ + private String recswiftcode; + /** + * 收款行Routing Number + */ + private String recroutingnum; + /** + * 收款行其他行号 + */ + private String recothercode; + /** + * 电文指示 + */ + private String instructmsg; + /** + * 收款方地址 + */ + private String recaddress; + /** + * 清算方式 + */ + private String settlementmethod; + /** + * 收款人电话 + */ + private String mobile; + /** + * 收款方式 + */ + private String paymentterm; + /** + * 收款人FPS账号 + */ + private String paymentfps; + /** + * 收款人地区码 + */ + private String paymentareacode; + /** + * 通知收款人 + */ + private Boolean inforpayment; + /** + * 通知收款人邮箱 + */ + private String informrecemail; + /** + * 匹配标识 + */ + private String matchflag; + /** + * 已匹配收款金额 + */ + private BigDecimal matchamountrec; + /** + * 未匹配收款金额 + */ + private BigDecimal unmatchamountrec; + /** + * 需关联对方流水 + */ + private Boolean relateotherflow; + /** + * 已匹配付款金额 + */ + private BigDecimal matchamountpay; + /** + * 未匹配付款金额 + */ + private BigDecimal unmatchamountpay; + /** + * 匹配异常原因 + */ + private String matchflagmsg; + /** + * 票据找零 + */ + private Boolean isbilldibs; + /** + * 交易时间 + */ + private String transtime; + /** + * 交易状态 + */ + private String transstatus; + /** + * 单据同步id + */ + private String transbillid; + /** + * 分录 + */ + private List entry; + /** + * 交易明细关联 + */ + private List bankcheckentity; + /** + * 创建人.id + */ + @JsonProperty("creator_id") + @JsonSerialize(using = ToStringSerializer.class) + private Long creatorId; + /** + * 创建人.姓名 + */ + @JsonProperty("creator_name") + private String creatorName; + /** + * 最后修改人.id + */ + @JsonProperty("modifier_id") + @JsonSerialize(using = ToStringSerializer.class) + private Long modifierId; + /** + * 最后修改人.姓名 + */ + @JsonProperty("modifier_name") + private String modifierName; + /** + * 审核人.id + */ + @JsonProperty("auditor_id") + @JsonSerialize(using = ToStringSerializer.class) + private Long auditorId; + /** + * 审核人.姓名 + */ + @JsonProperty("auditor_name") + private String auditorName; + /** + * 付款人(公司).id + */ + @JsonProperty("org_id") + @JsonSerialize(using = ToStringSerializer.class) + private Long orgId; + /** + * 付款人(公司).名称 + */ + @JsonProperty("org_name") + private String orgName; + /** + * 银行账号.id + */ + @JsonProperty("payeracctbank_id") + @JsonSerialize(using = ToStringSerializer.class) + private Long payeracctbankId; + /** + * 银行账号.银行账号 + */ + @JsonProperty("payeracctbank_bankaccountnumber") + private String payeracctbankBankaccountnumber; + /** + * 付款银行.id + */ + @JsonProperty("payerbank_id") + @JsonSerialize(using = ToStringSerializer.class) + private Long payerbankId; + /** + * 付款银行.名称 + */ + @JsonProperty("payerbank_name") + private String payerbankName; + /** + * 收款银行.id + */ + @JsonProperty("payeebank_id") + @JsonSerialize(using = ToStringSerializer.class) + private Long payeebankId; + /** + * 收款银行.名称 + */ + @JsonProperty("payeebank_name") + private String payeebankName; + /** + * 付款币别.id + */ + @JsonProperty("currency_id") + @JsonSerialize(using = ToStringSerializer.class) + private Long currencyId; + /** + * 付款币别.名称 + */ + @JsonProperty("currency_name") + private String currencyName; + /** + * 结算方式.id + */ + @JsonProperty("settletype_id") + @JsonSerialize(using = ToStringSerializer.class) + private Long settletypeId; + /** + * 结算方式.名称 + */ + @JsonProperty("settletype_name") + private String settletypeName; + /** + * 资金流量项目.id + */ + @JsonProperty("fundflowitem_id") + @JsonSerialize(using = ToStringSerializer.class) + private Long fundflowitemId; + /** + * 资金流量项目.名称 + */ + @JsonProperty("fundflowitem_name") + private String fundflowitemName; + /** + * 付款类型.id + */ + @JsonProperty("paymenttype_id") + @JsonSerialize(using = ToStringSerializer.class) + private Long paymenttypeId; + /** + * 付款类型.名称 + */ + @JsonProperty("paymenttype_name") + private String paymenttypeName; + /** + * 出纳.id + */ + @JsonProperty("cashier_id") + @JsonSerialize(using = ToStringSerializer.class) + private Long cashierId; + /** + * 出纳.姓名 + */ + @JsonProperty("cashier_name") + private String cashierName; + /** + * 公司本位币.id + */ + @JsonProperty("basecurrency_id") + @JsonSerialize(using = ToStringSerializer.class) + private Long basecurrencyId; + /** + * 公司本位币.名称 + */ + @JsonProperty("basecurrency_name") + private String basecurrencyName; + /** + * 单据类型.id + */ + @JsonProperty("billtype_id") + @JsonSerialize(using = ToStringSerializer.class) + private Long billtypeId; + /** + * 现金账号.id + */ + @JsonProperty("payeracctcash_id") + @JsonSerialize(using = ToStringSerializer.class) + private Long payeracctcashId; + /** + * 现金账号.账户名称 + */ + @JsonProperty("payeracctcash_name") + private String payeracctcashName; + /** + * 核算组织.id + */ + @JsonProperty("openorg_id") + @JsonSerialize(using = ToStringSerializer.class) + private Long openorgId; + /** + * 核算组织.名称 + */ + @JsonProperty("openorg_name") + private String openorgName; + /** + * 账号.id + */ + @JsonProperty("inneraccount_id") + @JsonSerialize(using = ToStringSerializer.class) + private Long inneraccountId; + /** + * 账号.银行账号 + */ + @JsonProperty("inneraccount_bankaccountnumber") + private String inneraccountBankaccountnumber; + /** + * 退单人.id + */ + @JsonProperty("backuser_id") + @JsonSerialize(using = ToStringSerializer.class) + private Long backuserId; + /** + * 退单人.姓名 + */ + @JsonProperty("backuser_name") + private String backuserName; + /** + * 委托付款组织.id + */ + @JsonProperty("entrustorg_id") + @JsonSerialize(using = ToStringSerializer.class) + private Long entrustorgId; + /** + * 委托付款组织.名称 + */ + @JsonProperty("entrustorg_name") + private String entrustorgName; + /** + * 申请付款组织.id + */ + @JsonProperty("applyorg_id") + @JsonSerialize(using = ToStringSerializer.class) + private Long applyorgId; + /** + * 申请付款组织.名称 + */ + @JsonProperty("applyorg_name") + private String applyorgName; + /** + * 汇率表.id + */ + @JsonProperty("exratetable_id") + @JsonSerialize(using = ToStringSerializer.class) + private Long exratetableId; + /** + * 汇率表.名称 + */ + @JsonProperty("exratetable_name") + private String exratetableName; + /** + * 网银子账户.id + */ + @JsonProperty("netbankacct_id") + @JsonSerialize(using = ToStringSerializer.class) + private Long netbankacctId; + /** + * 网银子账户.子账户名称 + */ + @JsonProperty("netbankacct_name") + private String netbankacctName; + /** + * 收款人.id + */ + @JsonProperty("itempayee_id") + @JsonSerialize(using = ToStringSerializer.class) + private Long itempayeeId; + /** + * 收款人.名称 + */ + @JsonProperty("itempayee_name") + private String itempayeeName; + /** + * 异币别付款币别.id + */ + @JsonProperty("dpcurrency_id") + @JsonSerialize(using = ToStringSerializer.class) + private Long dpcurrencyId; + /** + * 异币别付款币别.名称 + */ + @JsonProperty("dpcurrency_name") + private String dpcurrencyName; + /** + * 异币别付款汇率表.id + */ + @JsonProperty("dpexratetable_id") + @JsonSerialize(using = ToStringSerializer.class) + private Long dpexratetableId; + /** + * 异币别付款汇率表.名称 + */ + @JsonProperty("dpexratetable_name") + private String dpexratetableName; + /** + * 手续费账户.id + */ + @JsonProperty("feeactbank_id") + @JsonSerialize(using = ToStringSerializer.class) + private Long feeactbankId; + /** + * 手续费账户.银行账号 + */ + @JsonProperty("feeactbank_bankaccountnumber") + private String feeactbankBankaccountnumber; + /** + * 手续费币别.id + */ + @JsonProperty("feecurrency_id") + @JsonSerialize(using = ToStringSerializer.class) + private Long feecurrencyId; + /** + * 手续费币别.名称 + */ + @JsonProperty("feecurrency_name") + private String feecurrencyName; + /** + * 付款方国家或地区.id + */ + @JsonProperty("paycountry_id") + @JsonSerialize(using = ToStringSerializer.class) + private Long paycountryId; + /** + * 付款方国家或地区.名称 + */ + @JsonProperty("paycountry_name") + private String paycountryName; + /** + * 付款标识.id + */ + @JsonProperty("paymentidentify_id") + @JsonSerialize(using = ToStringSerializer.class) + private Long paymentidentifyId; + /** + * 付款标识.名称 + */ + @JsonProperty("paymentidentify_name") + private String paymentidentifyName; + /** + * 收款方国家或地区.id + */ + @JsonProperty("reccountry_id") + @JsonSerialize(using = ToStringSerializer.class) + private Long reccountryId; + /** + * 收款方国家或地区.名称 + */ + @JsonProperty("reccountry_name") + private String reccountryName; + /** + * 交易类型.id + */ + @JsonProperty("crosstrantype_id") + @JsonSerialize(using = ToStringSerializer.class) + private Long crosstrantypeId; + /** + * 交易类型.名称 + */ + @JsonProperty("crosstrantype_name") + private String crosstrantypeName; + /** + * 交易种类.id + */ + @JsonProperty("transtype_id") + @JsonSerialize(using = ToStringSerializer.class) + private Long transtypeId; + /** + * 交易种类.名称 + */ + @JsonProperty("transtype_name") + private String transtypeName; + /** + * 付款代理行.id + */ + @JsonProperty("payproxybank_id") + @JsonSerialize(using = ToStringSerializer.class) + private Long payproxybankId; + /** + * 付款代理行.名称 + */ + @JsonProperty("payproxybank_name") + private String payproxybankName; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiRecBillBankCheckVO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiRecBillBankCheckVO.java new file mode 100644 index 00000000..5771b841 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiRecBillBankCheckVO.java @@ -0,0 +1,89 @@ +/** + * 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.pojo.vo; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.math.BigDecimal; + +/** + * 业财融合(BFI)A301-1 收款单查询 关联交易明细信息分录 + * + * @author Chill + */ +@Data +public class BfiRecBillBankCheckVO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * id + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long id; + /** + * 分录行号 + */ + private Integer seq; + /** + * 对账标识码 + */ + private String ebankcheckflag; + /** + * 交易日期 + */ + private String ebizdate; + /** + * 付款金额 + */ + private BigDecimal edebitamount; + /** + * 收款金额 + */ + private BigDecimal ecreditamount; + /** + * 交易明细编号(等于银行流水号) + */ + private String edetailbillno; + /** + * 交易明细ID + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long edetailid; + /** + * 交易明细币种.货币代码 + */ + @JsonProperty("ecurrency_number") + private String ecurrencyNumber; + /** + * 银行流水号 + */ + private String bankdetailno; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiRecBillEntryVO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiRecBillEntryVO.java new file mode 100644 index 00000000..a624e1c6 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiRecBillEntryVO.java @@ -0,0 +1,335 @@ +/** + * 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.pojo.vo; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.math.BigDecimal; + +/** + * 业财融合(BFI)A301-1 收款单查询 收款明细分录 + * + * @author Chill + */ +@Data +public class BfiRecBillEntryVO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * id + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long id; + /** + * 分录行号 + */ + private Integer seq; + /** + * 实收金额 + */ + @JsonProperty("e_actamt") + private BigDecimal eActamt; + /** + * 集成原始数据分录id + */ + @JsonProperty("e_eassrcid") + private String eEassrcid; + /** + * 单据同步id + */ + @JsonProperty("e_transbillid") + private String eTransbillid; + /** + * 单据同步分录id + */ + @JsonProperty("e_transentryid") + private String eTransentryid; + /** + * 业务协同号 + */ + private String bizcoopno; + /** + * 业务协同行ID + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long bizcoopentryid; + /** + * 成组号 + */ + private String groupnumber; + /** + * 成组行号 + */ + private String groupseq; + /** + * 实收折本币 + */ + @JsonProperty("e_localamt") + private BigDecimal eLocalamt; + /** + * 备注 + */ + @JsonProperty("e_remark") + private String eRemark; + /** + * 现金折扣 + */ + @JsonProperty("e_discountamt") + private BigDecimal eDiscountamt; + /** + * 折扣折本币 + */ + @JsonProperty("e_discountlocamt") + private BigDecimal eDiscountlocamt; + /** + * 应收金额 + */ + @JsonProperty("e_receivableamt") + private BigDecimal eReceivableamt; + /** + * 应收折本币 + */ + @JsonProperty("e_receivablelocamt") + private BigDecimal eReceivablelocamt; + /** + * 已锁定金额 + */ + @JsonProperty("e_lockamt") + private BigDecimal eLockamt; + /** + * 未锁定金额 + */ + @JsonProperty("e_unlockamt") + private BigDecimal eUnlockamt; + /** + * 已结算金额 + */ + @JsonProperty("e_settledamt") + private BigDecimal eSettledamt; + /** + * 未结算金额 + */ + @JsonProperty("e_unsettledamt") + private BigDecimal eUnsettledamt; + /** + * 核心单据类型 + */ + @JsonProperty("e_corebilltype") + private String eCorebilltype; + /** + * 核心单据号 + */ + @JsonProperty("e_corebillno") + private String eCorebillno; + /** + * 核心单据行号 + */ + @JsonProperty("e_corebillentryseq") + private Integer eCorebillentryseq; + /** + * 核心单据行id + */ + @JsonProperty("e_corebillentryid") + @JsonSerialize(using = ToStringSerializer.class) + private Long eCorebillentryid; + /** + * 核心单据id + */ + @JsonProperty("e_corebillid") + private String eCorebillid; + /** + * 源单ID + */ + @JsonProperty("e_sourcebillid") + @JsonSerialize(using = ToStringSerializer.class) + private Long eSourcebillid; + /** + * 源单分录ID + */ + @JsonProperty("e_sourcebillentryid") + @JsonSerialize(using = ToStringSerializer.class) + private Long eSourcebillentryid; + /** + * 变更拆分分录ID + */ + @JsonProperty("e_sourcebillresentryid") + @JsonSerialize(using = ToStringSerializer.class) + private Long eSourcebillresentryid; + /** + * 合同号 + */ + private String contractnumber; + /** + * 合同编号 + */ + private String conbillnumber; + /** + * 合同行号 + */ + private String conbillrownum; + /** + * 合同ID + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long conbillid; + /** + * 合同行ID + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long conbillentryid; + /** + * 合同实体.编码 + */ + @JsonProperty("conbillentity_number") + private String conbillentityNumber; + /** + * conbillentity_id + */ + @JsonProperty("conbillentity_id") + private String conbillentityId; + /** + * 手续费 + */ + @JsonProperty("e_fee") + private BigDecimal eFee; + /** + * 认领单编码 + */ + @JsonProperty("e_claimbill") + private String eClaimbill; + /** + * 挂合同批次号 + */ + @JsonProperty("e_contractbatch") + private String eContractbatch; + /** + * 组织间清分批次号 + */ + @JsonProperty("e_orgsdividebatch") + private String eOrgsdividebatch; + /** + * 清分状态[normal:正常, waitdivide:待清分, yetdivide:已清分] + */ + @JsonProperty("e_dividestatus") + private String eDividestatus; + /** + * 业务日期 + */ + private String entrybizdate; + /** + * 已结算金额折本币 + */ + @JsonProperty("e_settledlocalamt") + private BigDecimal eSettledlocalamt; + /** + * 未结算金额折本位币 + */ + @JsonProperty("e_unsettledlocalamt") + private BigDecimal eUnsettledlocalamt; + /** + * 已匹配核心单据标识 + */ + @JsonProperty("e_matchselltag") + private Boolean eMatchselltag; + /** + * 认领维度[ar_finarbill_detaillayout:物料明细, ar_finarbill_planlayout:收款计划, ar_finarbill_layout:整单] + */ + private String claimdimension; + /** + * 启用认领预占控制 + */ + private Boolean claimcomtrol; + /** + * 收款类型.编码 + */ + @JsonProperty("e_receivingtype_number") + private String eReceivingtypeNumber; + /** + * 资金用途.编码 + */ + @JsonProperty("e_fundflowitem_number") + private String eFundflowitemNumber; + /** + * 实际收款公司.编码 + */ + @JsonProperty("realreccompany_number") + private String realreccompanyNumber; + /** + * 物料.编码 + */ + @JsonProperty("e_material_number") + private String eMaterialNumber; + /** + * 费用项目.编码 + */ + @JsonProperty("e_expenseitem_number") + private String eExpenseitemNumber; + /** + * 项目.项目编码 + */ + @JsonProperty("project_number") + private String projectNumber; + /** + * 结算组织.编码 + */ + @JsonProperty("e_settleorg_number") + private String eSettleorgNumber; + /** + * 收款组织(虚拟收款用).编码 + */ + @JsonProperty("recorg_number") + private String recorgNumber; + /** + * 内部业务单元.编码 + */ + @JsonProperty("bizunit_number") + private String bizunitNumber; + /** + * 成本中心.编码 + */ + @JsonProperty("costcenter_number") + private String costcenterNumber; + /** + * 产品线.编码 + */ + @JsonProperty("productline_number") + private String productlineNumber; + /** + * 利润中心.编码 + */ + @JsonProperty("e_profitcenter_number") + private String eProfitcenterNumber; + /** + * 对方利润中心.编码 + */ + @JsonProperty("e_cprofitcenter_number") + private String eCprofitcenterNumber; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiRecBillFlowVO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiRecBillFlowVO.java new file mode 100644 index 00000000..0459c3bd --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiRecBillFlowVO.java @@ -0,0 +1,85 @@ +/** + * 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.pojo.vo; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 业财融合(BFI)A301-1 收款单查询 流水信息分录 + * + * @author Chill + */ +@Data +public class BfiRecBillFlowVO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * id + */ + private String id; + /** + * 分录行号 + */ + private String seq; + /** + * 交易明细编号 + */ + @JsonProperty("bei_billno") + private String beiBillno; + /** + * 收款金额 + */ + @JsonProperty("bei_creditamount") + private String beiCreditamount; + /** + * 明细流水号 + */ + @JsonProperty("bei_detailid") + private String beiDetailid; + /** + * 银行流水号 + */ + @JsonProperty("bei_bankdetailno") + private String beiBankdetailno; + /** + * 流水ID/票据ID + */ + private String infoid; + /** + * 票面金额 + */ + @JsonProperty("cdm_amount") + private String cdmAmount; + /** + * 票据号码 + */ + @JsonProperty("cdm_draftbillno") + private String cdmDraftbillno; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiRecBillVO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiRecBillVO.java new file mode 100644 index 00000000..fd0bbab6 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiRecBillVO.java @@ -0,0 +1,498 @@ +/** + * 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.pojo.vo; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.List; + +/** + * 业财融合(BFI)A301-1 收款单查询(收款处理查询)返回结果 + *

+ * 成功响应示例: + *

{@code
+ * {
+ *   "data": {
+ *     "filter": "[bizdate < '2026-09-09 00:00:00' AND billstatus = 'C' AND org.number IN ('01.01.20.02.04.01')]",
+ *     "lastPage": true, "pageNo": 1, "pageSize": 10,
+ *     "rows": [
+ *       {
+ *         "id": "2116801651917487104", "billno": "RV-202412-002371", "billstatus": "C",
+ *         "bizdate": "2024-12-30 00:00:00", "payertype": "bd_customer",
+ *         "actrecamt": 936.0, "payername": "广西新华弘文教育投资有限公司柳州柳城分公司",
+ *         "sourcebillnumber": "AR-241120-002728", "sourcebilltype": "ar_finarbill",
+ *         "bankcheckentity": [ ... ], "entry": [ ... ]
+ *       }
+ *     ]
+ *   },
+ *   "errorCode": "0", "message": null, "status": true
+ * }
+ * }
+ * + * @author Chill + */ +@Data +public class BfiRecBillVO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * id + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long id; + /** + * 单据编号 + */ + private String billno; + /** + * 单据状态[A:暂存, B:已提交, C:已审核, D:已收款, E:变更中, G:已退单] + */ + private String billstatus; + /** + * 审核时间 + */ + private String auditdate; + /** + * 修改时间 + */ + private String modifytime; + /** + * 创建时间 + */ + private String createtime; + /** + * 数据集成来源 + */ + private String integrationsrc; + /** + * 历史数据 + */ + private Boolean ishistory; + /** + * 红字冲正虚拟结算 + */ + private Boolean redvirtual; + /** + * 核销生成 + */ + private Boolean isfromwriteoff; + /** + * 选择结算号标识 + */ + private String selectdraftflag; + /** + * 记账日期 + */ + private String bookdate; + /** + * 已有电子回单 + */ + private Boolean isgetreceipt; + /** + * 退票流水ID + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long refunddetailid; + /** + * 业务日期 + */ + private String bizdate; + /** + * 付款人类型[bd_customer:客户, bd_supplier:供应商, bos_org:公司, bos_user:职员, other:其他] + */ + private String payertype; + /** + * 摘要 + */ + @JsonProperty("txt_description") + private String txtDescription; + /** + * 收款金额 + */ + private BigDecimal actrecamt; + /** + * 汇率 + */ + private BigDecimal exchangerate; + /** + * 折本位币 + */ + private BigDecimal localamt; + /** + * 付款人名称 + */ + private String payername; + /** + * 付款账户 + */ + private String payeracctbanknum; + /** + * 结算号 + */ + private String settletnumber; + /** + * 付款人ID + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long payer; + /** + * 付款人类型标识ID + */ + private String payerformid; + /** + * 付款账户类型标识ID + */ + private String payeraccformid; + /** + * 付款账户ID + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long payeracctbank; + /** + * 收款日期 + */ + private String payeedate; + /** + * 源单ID + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long sourcebillid; + /** + * 付款银行 + */ + private String payerbankname; + /** + * 代理收款 + */ + private Boolean isagent; + /** + * 生成凭证 + */ + private Boolean isvoucher; + /** + * 付款方式 + */ + private String paymentmode; + /** + * 是否归档 + */ + private Boolean isarchive; + /** + * 源单编码 + */ + private String sourcebillnumber; + /** + * 源单类型[cas_recbill:收款单, er_repaymentbill:还款单, cas_betransdetail:交易明细, ar_finarbill:财务应收单, cas_paybill:付款单, fca_transdownbill:下拨单, sm_salorder:销售订单, cfm_loanbill:提款单, cdm_drafttradebill:业务处理, ec_incomeapply:建筑请款单, cas_claimcenterbill:认领通知单, bei_intelrec:收款入账中心, bei_transdetail:交易明细, cim_invest_repaybill:本金收回, cim_invest_interestbill:收息处理, fca_transupbill:上划单, cfm_feebill:费用明细单, lc_present:交单处理, lc_forfaiting:福费廷, tm_businessbill:生命周期操作, ifm_loanbill:贷款放款处理, cdm_receivablebill:收票登记, cfm_loanbill_bond:债券发行, tm_structdeposit:结构性存款, tm_rateswap:互换, tm_bond_fix:固定利率债券/零息债券, tm_bond_float:浮动利率债券, tm_forex_options:外汇期权, cim_release:定期存款解活处理, cim_noticerelease:通知存款解活处理, scf_finrecbill:供应链融资收款处理, cim_redeem:理财赎回, cim_revenue:理财收益, ifm_currentintbill:存款利息结息记账处理, cim_dptrevenue:存款收益处理, fbd_suretyreleasebill:保证金存出处理, fl_receiptbill:售后租回回款, pmct_incomeapply:项目合同请款单, cas_agentpaybill:代发处理, gm_receiveletter:收函登记, ifm_currentintbill_bank:活期存款结息记账处理, fbd_surety_settleint:保证金收益, occba_moneyincome:资金收入单] + */ + private String sourcebilltype; + /** + * 退款批次号 + */ + private String refundbatchseqid; + /** + * 退款退票业务 + */ + private Boolean isrefund; + /** + * 全额退款 + */ + private Boolean isfullrefund; + /** + * 汇率日期 + */ + private String exratedate; + /** + * 凭证号 + */ + private String vouchernum; + /** + * 付款人类型[bos_org:公司, bd_supplier:供应商, bd_customer:客户, bos_user:职员] + */ + private String itempayertype; + /** + * 手续费 + */ + private BigDecimal fee; + /** + * 影像编号 + */ + private String imageno; + /** + * 退单原因 + */ + private String reason; + /** + * 退单日期 + */ + private String backdate; + /** + * 是否认款 + */ + private Boolean confirmlogo; + /** + * 实际交易日期 + */ + private String acttradedate; + /** + * 付款人编码 + */ + private String payernumber; + /** + * 是否未认领入账 + */ + private Boolean isunclaim; + /** + * 红冲标识 + */ + private String hotaccount; + /** + * 对账标识码 + */ + private String bankcheckflag; + /** + * 换算方式[0:直接汇率, 1:间接汇率] + */ + private String quotation; + /** + * 匹配标识 + */ + private String matchflag; + /** + * 已匹配收款金额 + */ + private BigDecimal matchamountrec; + /** + * 未匹配收款金额 + */ + private BigDecimal unmatchamountrec; + /** + * 需关联对方流水 + */ + private Boolean relateotherflow; + /** + * 已匹配付款金额 + */ + private BigDecimal matchamountpay; + /** + * 未匹配付款金额 + */ + private BigDecimal unmatchamountpay; + /** + * 匹配异常原因 + */ + private String matchflagmsg; + /** + * 结算状态 + */ + private String settlestatus; + /** + * 未结算金额 + */ + private BigDecimal unsettleamount; + /** + * 未结算金额(本位币) + */ + private BigDecimal unsettleamountbase; + /** + * 已结算金额 + */ + private BigDecimal settleamount; + /** + * 已结算金额(本位币) + */ + private BigDecimal settleamountbase; + /** + * 多收款类型 + */ + private Boolean multireceivingtype; + /** + * 业务类型[wallettransfer:数币转账] + */ + private String businesstype; + /** + * 退款类型[refund:退款, renote:退票] + */ + private String refundtype; + /** + * 手工标记为无需匹配 + */ + private Boolean handmarknomatch; + /** + * 来源系统 + */ + private String sourcetype; + /** + * 业务唯一码 + */ + private String bizfingerprint; + /** + * 是否虚拟收款单 + */ + private Boolean isvirtual; + /** + * 单据类型[SalesRec:销售收款, OtherRec:其他收款] + */ + private String biztype; + /** + * 收款类型.编码 + */ + @JsonProperty("receivingtype_number") + private String receivingtypeNumber; + /** + * 收款类型.名称 + */ + @JsonProperty("receivingtype_name") + private String receivingtypeName; + /** + * 收款银行.编码(合作金融机构) + */ + @JsonProperty("payeebank_number") + private String payeebankNumber; + /** + * 收款银行.名称 + */ + @JsonProperty("payeebank_name") + private String payeebankName; + /** + * 结算方式.编码 + */ + @JsonProperty("settletype_number") + private String settletypeNumber; + /** + * 出纳.工号 + */ + @JsonProperty("cashier_number") + private String cashierNumber; + /** + * 付款银行.编码(行号) + */ + @JsonProperty("f7_payerbank_number") + private String f7PayerbankNumber; + /** + * 现金账号.账户编码 + */ + @JsonProperty("accountcash_number") + private String accountcashNumber; + /** + * 资金流量项目.编码 + */ + @JsonProperty("fundflowitem_number") + private String fundflowitemNumber; + /** + * 会计.工号 + */ + @JsonProperty("clerk_number") + private String clerkNumber; + /** + * 币种.货币代码 + */ + @JsonProperty("currency_number") + private String currencyNumber; + /** + * 本位币.货币代码 + */ + @JsonProperty("basecurrency_number") + private String basecurrencyNumber; + /** + * 核算组织.编码 + */ + @JsonProperty("openorg_number") + private String openorgNumber; + /** + * 实际付款账户.编码 + */ + @JsonProperty("actpayaccount_number") + private String actpayaccountNumber; + /** + * 汇率表.编码 + */ + @JsonProperty("exratetable_number") + private String exratetableNumber; + /** + * 付款人.编码 + */ + @JsonProperty("itempayer_number") + private String itempayerNumber; + /** + * 退单人.工号 + */ + @JsonProperty("backuser_number") + private String backuserNumber; + /** + * 代收款核算组织.编码 + */ + @JsonProperty("agentrecorg_number") + private String agentrecorgNumber; + /** + * 账号.编码 + */ + @JsonProperty("inneraccount_number") + private String inneraccountNumber; + /** + * 对方利润中心.编码 + */ + @JsonProperty("cprofitcenter_number") + private String cprofitcenterNumber; + /** + * 收款人(公司).编码 + */ + @JsonProperty("org_number") + private String orgNumber; + /** + * 收款人(公司).名称 + */ + @JsonProperty("org_name") + private String orgName; + /** + * 收款人银行账号.编码 + */ + @JsonProperty("accountbank_number") + private String accountbankNumber; + /** + * 收款人银行账号.账户简称 + */ + @JsonProperty("accountbank_name") + private String accountbankName; + /** + * 关联交易明细信息分录 + */ + @JsonProperty("bankcheckentity") + private List bankcheckentity; + /** + * 收款明细分录 + */ + private List entry; + /** + * 流水信息分录 + */ + private List infoentry; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiReceivedInvoiceEntryVO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiReceivedInvoiceEntryVO.java new file mode 100644 index 00000000..902e8e1c --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiReceivedInvoiceEntryVO.java @@ -0,0 +1,281 @@ +/** + * 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.pojo.vo; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.math.BigDecimal; + +/** + * 业财融合(BFI)A801-3 收票单查询 明细分录 + * + * @author Chill + */ +@Data +public class BfiReceivedInvoiceEntryVO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * id + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long id; + /** + * 分录行号 + */ + private Integer seq; + /** + * 价税合计 + */ + @JsonProperty("e_pricetaxtotal") + private BigDecimal ePricetaxtotal; + /** + * 金额 + */ + @JsonProperty("e_amount") + private BigDecimal eAmount; + /** + * 税额 + */ + @JsonProperty("e_tax") + private BigDecimal eTax; + /** + * 税率(%) + */ + private BigDecimal taxrate; + /** + * 核心单据号 + */ + private String corebillno; + /** + * 核心单据行号 + */ + private Integer corebillentryseq; + /** + * 核心单据类型[pm_purorderbill:采购订单, conm_purcontract:采购合同, pm_om_purorderbill:简单委外订单, sctm_scpo:委外采购订单] + */ + private String corebilltype; + /** + * 核心单据类型_标题 + */ + @JsonProperty("corebilltype_title") + private String corebilltypeTitle; + /** + * 数量 + */ + private BigDecimal quantity; + /** + * 单价 + */ + private BigDecimal price; + /** + * 规格型号 + */ + private String spectype; + /** + * 赠品 + */ + private Boolean ispresent; + /** + * 折扣方式[PERCENT:折扣率(%), PERUNIT:单位折扣额, TOTAL:折扣额, NULL:无] + */ + private String discountmode; + /** + * 折扣方式_标题 + */ + @JsonProperty("discountmode_title") + private String discountmodeTitle; + /** + * 单位折扣(率) + */ + private BigDecimal discountrate; + /** + * 实际单价 + */ + private BigDecimal actprice; + /** + * 实际含税单价 + */ + private BigDecimal actpricetax; + /** + * 折扣额 + */ + private BigDecimal discountamt; + /** + * 折扣额(本位币) + */ + private BigDecimal discountlocalamt; + /** + * 源单ID + */ + @JsonProperty("e_sourcebillid") + private String eSourcebillid; + /** + * 源单分录ID + */ + @JsonProperty("e_sourcebillentryid") + private String eSourcebillentryid; + /** + * 价税合计(本位币) + */ + @JsonProperty("e_pricetaxtotalbase") + private BigDecimal ePricetaxtotalbase; + /** + * 金额(本位币) + */ + @JsonProperty("e_amountbase") + private BigDecimal eAmountbase; + /** + * 开票名称 + */ + @JsonProperty("e_invname") + private String eInvname; + /** + * 票面规格型号 + */ + @JsonProperty("e_invspectype") + private String eInvspectype; + /** + * 票面单位 + */ + @JsonProperty("e_invunit") + private String eInvunit; + /** + * 未关联金额 + */ + @JsonProperty("e_unrelatedamt") + private BigDecimal eUnrelatedamt; + /** + * 关联金额 + */ + @JsonProperty("e_relatedamt") + private BigDecimal eRelatedamt; + /** + * 核心单据ID + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long corebillid; + /** + * 核心单据行ID + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long corebillentryid; + /** + * 未匹配价税合计 + */ + @JsonProperty("e_unmatchamt") + private BigDecimal eUnmatchamt; + /** + * 未匹配数量 + */ + @JsonProperty("e_unmatchqty") + private BigDecimal eUnmatchqty; + /** + * 匹配规则(废弃)[ORDER:匹配订单, REC:匹配接收, IN:匹配验收] + */ + @JsonProperty("e_matchrule") + private String eMatchrule; + /** + * 匹配规则(废弃)_标题 + */ + @JsonProperty("e_matchrule_title") + private String eMatchruleTitle; + /** + * 创建时间 + */ + @JsonProperty("e_createtime") + private String eCreatetime; + /** + * 修改时间 + */ + @JsonProperty("e_modifytime") + private String eModifytime; + /** + * 合同行号 + */ + @JsonProperty("e_conbillrownum") + private String eConbillrownum; + /** + * 合同编号 + */ + @JsonProperty("e_conbillnumber") + private String eConbillnumber; + /** + * 合同ID + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long eConbillid; + /** + * 合同行ID + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long eConbillentryid; + /** + * 发票分录行号 + */ + @JsonProperty("e_inventryseq") + private Integer eInventryseq; + /** + * 基本数量 + */ + @JsonProperty("e_baseunitqty") + private BigDecimal eBaseunitqty; + /** + * 未匹配基本数量 + */ + @JsonProperty("e_unmatchbaseqty") + private BigDecimal eUnmatchbaseqty; + /** + * 税额(本位币) + */ + @JsonProperty("e_taxlocalamt") + private BigDecimal eTaxlocalamt; + /** + * 来源系统单据ID + */ + @JsonProperty("e_srcsysbillid") + private String eSrcsysbillid; + /** + * 来源系统单据分录ID + */ + @JsonProperty("e_srcsysbillentryid") + private String eSrcsysbillentryid; + /** + * 外部系统来源分录标识 + */ + @JsonProperty("awrp_entry_code") + private String awrpEntryCode; + /** + * 明细.e_conbillentity_id + */ + @JsonProperty("e_conbillentity_id") + private String eConbillentityId; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiReceivedInvoiceTypeVO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiReceivedInvoiceTypeVO.java new file mode 100644 index 00000000..1311f6d6 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiReceivedInvoiceTypeVO.java @@ -0,0 +1,50 @@ +/** + * 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.pojo.vo; + +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 业财融合(BFI)A801-3 收票单查询 发票类型 + * + * @author Chill + */ +@Data +public class BfiReceivedInvoiceTypeVO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 发票类型.编码 + */ + private String number; + /** + * 发票类型.名称 + */ + private String name; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiReceivedInvoiceVO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiReceivedInvoiceVO.java new file mode 100644 index 00000000..af54e157 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiReceivedInvoiceVO.java @@ -0,0 +1,413 @@ +/** + * 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.pojo.vo; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.List; + +/** + * 业财融合(BFI)A801-3 收票单查询返回结果 + * + * @author Chill + */ +@Data +public class BfiReceivedInvoiceVO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * id + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long id; + /** + * 单据编号 + */ + private String billno; + /** + * 单据状态[A:暂存, B:已提交, C:已审核] + */ + private String billstatus; + /** + * 单据状态_标题 + */ + @JsonProperty("billstatus_title") + private String billstatusTitle; + /** + * 审核时间 + */ + private String auditdate; + /** + * 修改时间 + */ + private String modifytime; + /** + * 创建时间 + */ + private String createtime; + /** + * 价税合计 + */ + private BigDecimal pricetaxtotal; + /** + * 金额 + */ + private BigDecimal amount; + /** + * 税额 + */ + private BigDecimal tax; + /** + * 备注 + */ + private String remark; + /** + * 销方名称 + */ + private String asstactname; + /** + * 发票号码 + */ + private String invoiceno; + /** + * 开票日期 + */ + private String issuedate; + /** + * 最迟付款日期 + */ + private String lastpaydate; + /** + * 收款人 + */ + private String payee; + /** + * 复核人 + */ + private String checker; + /** + * 开票人 + */ + private String drawer; + /** + * 纳税人识别号(买) + */ + private String buyertin; + /** + * 地址(买) + */ + private String buyeraddr; + /** + * 电话(买) + */ + private String buyertel; + /** + * 账号(买) + */ + private String buyeracct; + /** + * 纳税人识别号(卖) + */ + private String sellertin; + /** + * 地址(卖) + */ + private String selleraddr; + /** + * 电话(卖) + */ + private String sellertel; + /** + * 账号(卖) + */ + private String selleracct; + /** + * 密码 + */ + private String password; + /** + * 往来类型[bd_supplier:供应商, bos_user:人员, bd_customer:客户] + */ + private String asstacttype; + /** + * 付款方式[CASH:现购, CREDIT:赊购] + */ + private String paymenttype; + /** + * 付款方式_标题 + */ + @JsonProperty("paymenttype_title") + private String paymenttypeTitle; + /** + * 开户行(买) + */ + private String buyerbank; + /** + * 开户行(卖) + */ + private String sellerbank; + /** + * 汇率 + */ + private BigDecimal exchangerate; + /** + * 录入价税合计 + */ + private Boolean isincludetax; + /** + * 源单ID + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long sourcebillid; + /** + * 收款账号 + */ + private String receivablesacct; + /** + * 金额(本位币) + */ + private BigDecimal amountbase; + /** + * 价税合计(本位币) + */ + private BigDecimal pricetaxtotalbase; + /** + * 业务类型[PUR:采购, FEE:费用] + */ + private String biztype; + /** + * 业务类型_标题 + */ + @JsonProperty("biztype_title") + private String biztypeTitle; + /** + * 发票流水号 + */ + private String serialno; + /** + * 购方名称 + */ + private String buyername; + /** + * 发票代码 + */ + private String invoicecode; + /** + * 归档 + */ + private Boolean isarchive; + /** + * 源单类型[ap_invoice:发票, ap_busbill:暂估应付单, conm_purcontract:采购合同] + */ + private String sourcebilltype; + /** + * 源单类型_标题 + */ + @JsonProperty("sourcebilltype_title") + private String sourcebilltypeTitle; + /** + * 源单编码 + */ + private String sourcebillno; + /** + * 汇率日期 + */ + private String exratedate; + /** + * 发票类型 + */ + @JsonProperty("invoicetype_title") + private String invoicetypeTitle; + /** + * 发票状态[0:正常, 1:失控, 2:作废, 3:红冲, 4:异常, 5:非正常, 6:红字发票待确认, 7:部分红冲, 8:全部红冲] + */ + private String invoicestatus; + /** + * 发票状态_标题 + */ + @JsonProperty("invoicestatus_title") + private String invoicestatusTitle; + /** + * 影像编号 + */ + private String imageno; + /** + * 有源采集 + */ + private Boolean isreffin; + /** + * 电子发票预览地址 + */ + private String originalgraphurl; + /** + * 未关联金额 + */ + private BigDecimal unrelatedamt; + /** + * 收票日期 + */ + private String receivedate; + /** + * 查验状态[1:通过, 2:不通过, 3:未查验] + */ + private String checkstatus; + /** + * 查验状态_标题 + */ + @JsonProperty("checkstatus_title") + private String checkstatusTitle; + /** + * 未匹配价税合计 + */ + private BigDecimal unmatchamt; + /** + * 匹配规则(废弃) + */ + private String matchrule; + /** + * 匹配规则(废弃)_标题 + */ + @JsonProperty("matchrule_title") + private String matchruleTitle; + /** + * 税额不计入成本 + */ + private Boolean istaxdeduction; + /** + * 修改时间 + */ + private String lastupdatetime; + /** + * 同步至发票云[notsynchro:无需同步, waitsynchro:待同步, hassynchro:已同步] + */ + private String synstatus; + /** + * 同步至发票云_标题 + */ + @JsonProperty("synstatus_title") + private String synstatusTitle; + /** + * 同步发票云更改时间 + */ + private String changesynstatustime; + /** + * 换算方式[0:直接汇率, 1:间接汇率] + */ + private String quotation; + /** + * 换算方式_标题 + */ + @JsonProperty("quotation_title") + private String quotationTitle; + /** + * 已匹配 + */ + private Boolean ismatched; + /** + * 业务来源[ap_finapbill:财务应付单, ap_payapply:付款申请单] + */ + private String businesssource; + /** + * 业务来源_标题 + */ + @JsonProperty("businesssource_title") + private String businesssourceTitle; + /** + * 生成凭证 + */ + private Boolean isvoucher; + /** + * 匹配状态[0:未匹配, 1:部分匹配, 2:全部匹配] + */ + private String matchstatus; + /** + * 匹配状态_标题 + */ + @JsonProperty("matchstatus_title") + private String matchstatusTitle; + /** + * 税额(本位币) + */ + private BigDecimal taxbase; + /** + * 协同状态[A:无, B:进行中, C:成功, D:失败] + */ + private String coopstatus; + /** + * 协同状态_标题 + */ + @JsonProperty("coopstatus_title") + private String coopstatusTitle; + /** + * 单据来源类型[0:手工新增, 1:导入生成, 2:后台生成, 3:从总账导入, 4:协同调差生成, fromeas:EAS集成, 21:跨租户同步] + */ + private String billsrctype; + /** + * 单据来源类型_标题 + */ + @JsonProperty("billsrctype_title") + private String billsrctypeTitle; + /** + * 科目类型[1:进口增值税, 2:进口关税, 3:进口消费税] + */ + @JsonProperty("subject_type") + private String subjectType; + /** + * 科目类型_标题 + */ + @JsonProperty("subject_type_title") + private String subjectTypeTitle; + /** + * 外部系统来源单据号 + */ + @JsonProperty("awrp_src_code") + private String awrpSrcCode; + /** + * 外部系统编号 + */ + @JsonProperty("awrp_src_sys") + private String awrpSrcSys; + /** + * 外部单据类型 + */ + @JsonProperty("awrp_src_type") + private String awrpSrcType; + /** + * 明细分录 + */ + private List entry; + /** + * 发票类型 + */ + @JsonProperty("invoicetypef7") + private BfiReceivedInvoiceTypeVO invoicetypef7; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiResultVO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiResultVO.java new file mode 100644 index 00000000..cf5e58af --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiResultVO.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. + *

+ * 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.pojo.vo; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; +import org.springblade.thirdparty.bfi.constant.BfiConstant; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 业财融合(BFI)统一返回结果 + * + * @author Chill + */ +@Data +public class BfiResultVO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 数据 + */ + private T data; + /** + * 错误码,成功时为0 + */ + @JsonProperty("errorCode") + private String errorCode; + /** + * 错误消息 + */ + private String message; + /** + * 是否成功,true或false + */ + private Boolean status; + + /** + * 是否调用成功 + * + * @return true-成功 + */ + public boolean isSuccess() { + return Boolean.TRUE.equals(status) && BfiConstant.SUCCESS_CODE.equals(errorCode); + } +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiTokenVO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiTokenVO.java new file mode 100644 index 00000000..270fe6ae --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiTokenVO.java @@ -0,0 +1,81 @@ +/** + * 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.pojo.vo; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 业财融合(BFI)A100-1 获取令牌返回结果 + * + * @author Chill + */ +@Data +public class BfiTokenVO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 请求令牌,第三方应用调用 OpenAPI 的凭证,有效期2小时 + */ + @JsonProperty("access_token") + private String accessToken; + /** + * 令牌类型,默认为 Bearer + */ + @JsonProperty("token_type") + private String tokenType; + /** + * 刷新令牌,暂不支持 + */ + @JsonProperty("refresh_token") + private String refreshToken; + /** + * 作用域,用户授权给第三方的权限,默认为API + */ + private String scope; + /** + * access_token 的有效期,即距离过期的剩余时长(单位:毫秒) + */ + @JsonProperty("expires_in") + private Long expiresIn; + /** + * JWT(Json Web Token),此处可忽略 + */ + @JsonProperty("id_token") + private String idToken; + /** + * JWT 的有效期,即距离过期的剩余时长(单位:毫秒) + */ + @JsonProperty("id_token_expires_in") + private Long idTokenExpiresIn; + /** + * 语言 + */ + private String language; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiVerifyTokenVO.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiVerifyTokenVO.java new file mode 100644 index 00000000..5d133cdb --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/pojo/vo/BfiVerifyTokenVO.java @@ -0,0 +1,56 @@ +/** + * 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.pojo.vo; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 业财融合(BFI)A100-2 校验令牌返回结果 + * + * @author Chill + */ +@Data +public class BfiVerifyTokenVO implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + /** + * 令牌的有效期,即距离过期的剩余时长(单位:毫秒) + */ + @JsonProperty("expires_in") + private Long expiresIn; + /** + * 令牌是否有效,true/false + */ + private Boolean active; + /** + * 作用域,用户授权给第三方的权限,默认为API + */ + private String scope; +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/service/IBfiService.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/service/IBfiService.java new file mode 100644 index 00000000..50a26ba2 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/service/IBfiService.java @@ -0,0 +1,327 @@ +/** + * 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.service; + +import feign.Response; +import org.springblade.thirdparty.bfi.pojo.dto.BfiApBillQueryDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiApBillSaveDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiArBillQueryDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiArBillSaveDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiBillLockDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiClaimAnnounceQueryDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiClaimPushDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiCustomerQueryDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiCustomerSaveDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceApplyOpenApiQueryDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceApplyQueryDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceApplySaveDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceAssignItemDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceDownloadDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoicePoolQueryDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiPayApplyPushDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiPayApplyQueryDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiPayApplySaveDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiPayBillQueryDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiReceivedInvoiceQueryDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiReceivedInvoiceSaveDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiRecBillQueryDTO; +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.BfiBillSaveResultVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiBlacklistVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiClaimAnnounceVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiCustomerVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiExchangeRateVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiInvoiceApplyOpenApiBillVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiInvoiceApplyVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiInvoicePoolVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiOpenApiResultVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiPageVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiPayApplyVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiPayBillVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiReceivedInvoiceVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiRecBillVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiTokenVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiVerifyTokenVO; + +import java.time.LocalDate; +import java.util.List; + +/** + * 业财融合(BFI)服务 + * + * @author Chill + */ +public interface IBfiService { + + /** + * A100-1 获取令牌,每次调用实时请求远端并刷新缓存 + * + * @return 令牌信息,含 access_token、refresh_token、expires_in 等 + */ + BfiTokenVO getToken(); + + /** + * 获取访问令牌,优先取 Redis 缓存,过期后自动重新获取 + * + * @return access_token + */ + String getAccessToken(); + + /** + * A100-2 校验令牌有效性 + * + * @param token 待校验的令牌值 + * @return 校验结果,含 expires_in、active、scope + */ + BfiVerifyTokenVO verifyToken(String token); + + /** + * A100-3 撤回令牌 + * + * @param token 待撤回的令牌值 + * @return true-撤回成功 + */ + boolean withdrawToken(String token); + + /** + * A801-4 汇率查询 + * + * @param effectdate 生效日期-开始,格式:yyyy-MM-dd + * @param effectdateE 生效日期-结束,格式:yyyy-MM-dd + * @return 汇率列表 + */ + List queryExchangeRate(String effectdate, String effectdateE); + + /** + * A801-4 汇率查询 + * + * @param effectdate 生效日期-开始 + * @param effectdateE 生效日期-结束 + * @return 汇率列表 + */ + List queryExchangeRate(LocalDate effectdate, LocalDate effectdateE); + + /** + * A801-5 黑名单查询 + * + * @param enable 使用状态[1:可用, 0:禁用] + * @param number 企业税号数组,按税号过滤时传 + * @return 黑名单列表 + */ + List queryBlacklist(String enable, List number); + + /** + * A103-1 付款申请保存(根据星瀚应付下推付款申请) + * + * @param param 付款申请参数,分录须携带应付单分录id用于下推关联 + * @return 保存结果,含成功/失败数量与单据编号 + */ + BfiBillSaveResultVO pushPayApply(BfiPayApplyPushDTO param); + + /** + * A103-2 付款申请单查询 + * + * @param param 查询参数,页码从1开始,修改时间范围不可超过1个月 + * @return 付款申请单分页数据 + */ + BfiPageVO queryPayApply(BfiPayApplyQueryDTO param); + + /** + * A103-3 预付款申请(付款申请保存) + * + * @param param 预付款申请参数,分录付款类型应传预付款编码 + * @return 保存结果,含成功/失败数量与单据编号 + */ + BfiBillSaveResultVO savePrePayApply(BfiPayApplySaveDTO param); + + /** + * A103-4 付款处理查询 + * + * @param param 查询参数,业务日期范围不可超过1个月,付款人(公司)编码必填 + * @return 付款处理分页数据 + */ + BfiPageVO queryPayBill(BfiPayBillQueryDTO param); + + /** + * A902-1 客户查询 + * + * @param param 查询参数,页码从1开始,修改时间范围不可超过6个月 + * @return 客户分页数据 + */ + BfiPageVO queryCustomer(BfiCustomerQueryDTO param); + + /** + * A902-2 客户信息保存 + * + * @param param 客户参数,分类标准固定传 JBFLBZ/1102(外部客户) + * @return 保存结果,含成功/失败数量与客户编码 + */ + BfiBillSaveResultVO saveCustomer(BfiCustomerSaveDTO param); + + /** + * A102-2 财务应付单保存 + * + * @param param 应付单参数,分录须携带外部系统来源分录标识用于防重 + * @return 保存结果,含成功/失败数量与单据编号 + */ + BfiBillSaveResultVO saveApBill(BfiApBillSaveDTO param); + + /** + * A102-4 财务应付单查询 + * + * @param param 查询参数,页码从1开始,修改时间范围不可超过1个月 + * @return 财务应付单分页数据 + */ + BfiPageVO queryApBill(BfiApBillQueryDTO param); + + /** + * A302-2 财务应收单新增 + * + * @param param 应收单参数,分录须携带外部系统来源分录标识用于防重 + * @return 保存结果,含成功/失败数量与单据编号 + */ + BfiBillSaveResultVO saveArBill(BfiArBillSaveDTO param); + + /** + * A302-4 财务应收单查询 + * + * @param param 查询参数,页码从1开始,修改时间范围不可超过1个月 + * @return 财务应收单分页数据 + */ + BfiPageVO queryArBill(BfiArBillQueryDTO param); + + /** + * A302-6 收票指定应付 + * + * @param op 操作类型,assign 指定、unassign 取消指定 + * @param data 指定关系数据(收票单与财务应付单的来源系统编码+单号) + * @return 指定结果,含成功/失败数量 + */ + BfiBillSaveResultVO invoiceAssign(String op, List data); + + /** + * A303-1 开票申请单新增 + * + * @param param 开票申请单参数,业务单据编号与组织编码为候选键 + * @return 保存结果,含成功/失败数量与单据编号 + */ + BfiBillSaveResultVO saveInvoiceApply(BfiInvoiceApplySaveDTO param); + + /** + * A303-4 开票申请单查询 + * + * @param param 查询参数,页码从1开始,单据日期范围不可超过1个月 + * @return 开票申请单分页数据 + */ + BfiPageVO queryInvoiceApply(BfiInvoiceApplyQueryDTO param); + + /** + * A303-5 开票申请发票查询 + * + * @param param 查询原文参数,单据编号多个用英文逗号隔开,上限50个 + * @return 发票查询结果,data 为单据及发票明细列表 + */ + BfiOpenApiResultVO> queryInvoiceApplyInvoice(BfiInvoiceApplyOpenApiQueryDTO param); + + /** + * A303-2 收票单新增(批量保存) + * + * @param param 收票单参数,来源系统编码与来源单据号为候选键 + * @return 保存结果,含成功/失败数量与收票单编号 + */ + BfiBillSaveResultVO saveReceivedInvoice(BfiReceivedInvoiceSaveDTO param); + + /** + * A801-3 收票单查询 + * + * @param param 查询参数,页码从1开始 + * @return 收票单分页数据 + */ + BfiPageVO queryReceivedInvoice(BfiReceivedInvoiceQueryDTO param); + + /** + * A301-1 收款单查询(收款处理查询) + * + * @param param 查询参数,业务日期范围不可超过1个月 + * @return 收款处理分页数据 + */ + BfiPageVO queryRecBill(BfiRecBillQueryDTO param); + + /** + * A301-2 收款认领公告查询 + * + * @param param 查询参数,claimusers/modifytimeS/modifytimeE 必填,页码从1开始 + * @return 收款认领公告分页数据 + */ + BfiPageVO queryClaimAnnounce(BfiClaimAnnounceQueryDTO param); + + /** + * A301-3 收款认领结果推送 + * + * @param param 认领结果参数,须携带认领公告id + * @return 推送结果,含成功/失败数量 + */ + BfiBillSaveResultVO pushClaimResult(BfiClaimPushDTO param); + + /** + * A801-1 查询全票池发票数据(单页查询) + * + * @param param 查询参数,messageType/messageId 由服务端填充;queryParam.pageNo/pageSize 必填 + * @return 当前页票池发票数据,rim 契约不返回总数与末页标识 + */ + BfiPageVO queryInvoicePool(BfiInvoicePoolQueryDTO param); + + /** + * A801-2 进项发票下载(文件下载) + * + * @param path 文件url,如票池发票的 downloadUrl/imageUrl + * @return 文件流响应,调用方负责关闭 + */ + Response downloadInvoice(String path); + + /** + * A902 附件上传到指定单据 + * + * @param entityNumber 星瀚单据标识(如 ap_payapply) + * @param billPkId 星瀚单据内码(保存/查询响应返回的 id) + * @param controlKey 附件面板标识,统一 attachmentpanel + * @param fileName 附件名称(含扩展名) + * @param fileContent 附件内容 + * @return 上传结果,path 即金蝶侧文件路径 + */ + BfiAttachmentUploadVO uploadAttachment(String entityNumber, String billPkId, String controlKey, String fileName, byte[] fileContent); + + /** + * 单据锁定(lockBusiData):推送金蝶成功后锁定目标单据,接口 1对1(单次一个源单号对一个金蝶单号), + * 解锁结果由金蝶回调 unLockNotifyBusiData 通知 + * + * @param param 锁单参数,操作类型固定 LOCK,源系统 WLTMS、目标系统 XH + * @return true-锁定成功(updateCount 大于 0) + */ + boolean lockBusiData(BfiBillLockDTO param); +} 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 new file mode 100644 index 00000000..0cc4b18c --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/service/impl/BfiServiceImpl.java @@ -0,0 +1,752 @@ +/** + * 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.service.impl; + +import cn.hutool.json.JSONObject; +import cn.hutool.json.JSONUtil; +import feign.Response; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springblade.core.redis.cache.BladeRedis; +import org.springblade.core.tool.utils.Func; +import org.springblade.core.tool.utils.StringUtil; +import org.springblade.thirdparty.bfi.config.BfiProperties; +import org.springblade.thirdparty.bfi.constant.BfiConstant; +import org.springblade.thirdparty.bfi.exception.BfiException; +import org.springblade.thirdparty.bfi.feign.IBfiClient; +import org.springblade.thirdparty.bfi.pojo.dto.BfiApBillQueryDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiApBillSaveBodyDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiApBillSaveDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiArBillQueryDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiArBillSaveBodyDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiArBillSaveDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiBillLockDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiBlacklistQueryDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiClaimAnnounceQueryDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiClaimPushBodyDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiClaimPushDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiCustomerQueryDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiCustomerSaveBodyDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiCustomerSaveDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiExchangeRateQueryDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceApplyOpenApiBodyDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceApplyOpenApiQueryDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceApplyQueryDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceApplySaveBodyDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceApplySaveDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceAssignBodyDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceAssignItemDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceDownloadDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoicePoolQueryDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoicePoolQueryDataDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiPayApplyPushBodyDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiPayApplyPushDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiPayApplyQueryDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiPayApplySaveBodyDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiPayApplySaveDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiPayBillQueryDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiRecBillQueryDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiReceivedInvoiceQueryDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiReceivedInvoiceSaveBodyDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiReceivedInvoiceSaveDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiTokenDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiVerifyTokenDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiWithdrawTokenDTO; +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.BfiBillSaveResultVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiBlacklistVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiClaimAnnounceVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiCustomerVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiExchangeRateVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiInvoiceApplyOpenApiBillVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiInvoiceApplyVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiInvoicePoolVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiOpenApiResultVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiPageVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiPayApplyVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiPayBillVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiRecBillVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiReceivedInvoiceVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiResultVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiTokenVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiVerifyTokenVO; +import org.springblade.thirdparty.bfi.service.IBfiService; +import org.springblade.thirdparty.bfi.util.BfiUtil; +import org.springblade.thirdparty.bfi.util.InMemoryMultipartFile; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.Base64; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.function.Supplier; + +/** + * 业财融合(BFI)服务实现 + * + * @author Chill + */ +@Slf4j +@RequiredArgsConstructor +@Service +public class BfiServiceImpl implements IBfiService { + + /** + * 汇率查询日期格式 + */ + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + private final BfiProperties properties; + private final IBfiClient client; + private final BladeRedis bladeRedis; + + @Override + public BfiTokenVO getToken() { + BfiTokenDTO param = new BfiTokenDTO(); + param.setClientId(properties.getClientId()); + param.setClientSecret(properties.getClientSecret()); + param.setUsername(properties.getUsername()); + param.setAccountId(properties.getAccountId()); + param.setLanguage(properties.getLanguage()); + BfiUtil.fillRandomParam(param); + long start = System.currentTimeMillis(); + log.info("调用BFI接口 获取令牌开始"); + BfiResultVO result = commonCall("获取令牌", () -> client.getToken(param)); + log.info("调用BFI接口 获取令牌结束 耗时:{}", System.currentTimeMillis() - start); + BfiTokenVO tokenVO = Optional.ofNullable(result).map(BfiResultVO::getData).orElse(null); + if (tokenVO == null || StringUtil.isBlank(tokenVO.getAccessToken())) { + log.error("调用BFI接口 获取令牌返回数据为空:{}", JSONUtil.toJsonStr(result)); + throw new BfiException("调用BFI接口获取令牌异常,返回数据为空"); + } + // 缓存令牌,过期前提前失效 + cacheToken(tokenVO); + return tokenVO; + } + + @Override + public String getAccessToken() { + String token = bladeRedis.get(BfiConstant.BFI_TOKEN_CACHE_KEY); + if (token != null) { + return token; + } + return getToken().getAccessToken(); + } + + @Override + public BfiVerifyTokenVO verifyToken(String token) { + if (StringUtil.isBlank(token)) { + throw new BfiException("校验BFI令牌失败,令牌值不能为空"); + } + BfiVerifyTokenDTO param = new BfiVerifyTokenDTO(); + param.setClientId(properties.getClientId()); + param.setAccountId(properties.getAccountId()); + param.setTokenTypeHint(BfiConstant.TOKEN_TYPE_HINT_ACCESS); + param.setToken(token); + param.setLanguage(properties.getLanguage()); + BfiUtil.fillRandomParam(param); + BfiResultVO result = commonCall("校验令牌", () -> client.verifyToken(param)); + return result.getData(); + } + + @Override + public boolean withdrawToken(String token) { + if (StringUtil.isBlank(token)) { + throw new BfiException("撤回BFI令牌失败,令牌值不能为空"); + } + BfiWithdrawTokenDTO param = new BfiWithdrawTokenDTO(); + param.setClientId(properties.getClientId()); + param.setClientSecret(properties.getClientSecret()); + param.setAccountId(properties.getAccountId()); + param.setTokenTypeHint(BfiConstant.TOKEN_TYPE_HINT_ACCESS); + param.setToken(token); + param.setLanguage(properties.getLanguage()); + BfiUtil.fillRandomParam(param); + BfiResultVO result = commonCall("撤回令牌", () -> client.withdrawToken(param)); + // 撤回的令牌与缓存一致时移除本地缓存 + if (Boolean.TRUE.equals(result.getData()) && token.equals(bladeRedis.get(BfiConstant.BFI_TOKEN_CACHE_KEY))) { + bladeRedis.del(BfiConstant.BFI_TOKEN_CACHE_KEY); + } + return Boolean.TRUE.equals(result.getData()); + } + + @Override + public List queryExchangeRate(String effectdate, String effectdateE) { + if (StringUtil.isBlank(effectdate) || StringUtil.isBlank(effectdateE)) { + throw new BfiException("查询BFI汇率失败,生效日期不能为空"); + } + BfiExchangeRateQueryDTO param = new BfiExchangeRateQueryDTO(); + param.setEffectdateS(effectdate); + param.setEffectdateE(effectdateE); + param.setPageSize("10"); + String accessToken = getAccessToken(); + BfiResultVO> result = commonCall("汇率查询", () -> client.forexQuery(accessToken, param)); + BfiPageVO page = result.getData(); + return page != null && page.getRows() != null ? page.getRows() : List.of(); + } + + @Override + public List queryExchangeRate(LocalDate effectdate, LocalDate effectdateE) { + if (effectdate == null || effectdateE == null) { + throw new BfiException("查询BFI汇率失败,生效日期不能为空"); + } + return queryExchangeRate(effectdate.format(DATE_FORMATTER), effectdateE.format(DATE_FORMATTER)); + } + + @Override + public List queryBlacklist(String enable, List number) { + if (StringUtil.isBlank(enable)) { + throw new BfiException("查询BFI黑名单失败,使用状态不能为空"); + } + BfiBlacklistQueryDTO param = new BfiBlacklistQueryDTO(); + param.setEnable(enable); + if (Func.isNotEmpty(number)) { + param.setNumber(number); + } + String accessToken = getAccessToken(); + return BfiUtil.queryAllPages(pageNo -> { + param.setPageNo(pageNo); + param.setPageSize(BfiConstant.BLACKLIST_QUERY_PAGE_SIZE); + return commonCall("黑名单查询", () -> client.blacklistQuery(accessToken, param)).getData(); + }); + } + + @Override + public BfiBillSaveResultVO pushPayApply(BfiPayApplyPushDTO param) { + if (param == null || StringUtil.isBlank(param.getAwrpSrcCode()) || StringUtil.isBlank(param.getAwrpSrcSys())) { + throw new BfiException("推送BFI付款申请失败,外部系统编号与来源单据号不能为空"); + } + if (param.getEntry() == null || param.getEntry().isEmpty()) { + throw new BfiException("推送BFI付款申请失败,付款申请分录不能为空"); + } + String accessToken = getAccessToken(); + BfiPayApplyPushBodyDTO body = new BfiPayApplyPushBodyDTO(); + body.setData(Collections.singletonList(param)); + BfiResultVO result = commonCall("付款申请保存(应付下推)", () -> client.payApplyPush(accessToken, body)); + return result.getData(); + } + + @Override + public BfiPageVO queryPayApply(BfiPayApplyQueryDTO param) { + if (param == null || param.getPageNo() == null || param.getPageSize() == null) { + throw new BfiException("查询BFI付款申请单失败,分页参数不能为空"); + } + String accessToken = getAccessToken(); + BfiResultVO> result = commonCall("付款申请单查询", () -> client.payApplyQuery(accessToken, + param.getPageNo(), param.getPageSize(), param.getAwrpSrcCode(), param.getAwrpSrcSys(), param.getModifytimeS(), param.getModifytimeE())); + return result.getData(); + } + + @Override + public BfiBillSaveResultVO savePrePayApply(BfiPayApplySaveDTO param) { + if (param == null || StringUtil.isBlank(param.getAwrpSrcCode()) || StringUtil.isBlank(param.getAwrpSrcSys())) { + throw new BfiException("保存BFI预付款申请失败,外部系统编号与来源单据号不能为空"); + } + if (param.getEntry() == null || param.getEntry().isEmpty()) { + throw new BfiException("保存BFI预付款申请失败,付款申请分录不能为空"); + } + String accessToken = getAccessToken(); + BfiPayApplySaveBodyDTO body = new BfiPayApplySaveBodyDTO(); + body.setData(Collections.singletonList(param)); + BfiResultVO result = commonCall("预付款申请保存", () -> client.prePayApplySave(accessToken, body)); + return result.getData(); + } + + @Override + public BfiPageVO queryPayBill(BfiPayBillQueryDTO param) { + if (param == null || param.getPageNo() == null || param.getPageSize() == null) { + throw new BfiException("查询BFI付款处理失败,分页参数不能为空"); + } + if (StringUtil.isBlank(param.getBeginBizdate()) || StringUtil.isBlank(param.getEndBizdate())) { + throw new BfiException("查询BFI付款处理失败,业务日期范围不能为空"); + } + if (param.getOrgNumber() == null || param.getOrgNumber().isEmpty()) { + throw new BfiException("查询BFI付款处理失败,付款人(公司)编码不能为空"); + } + String accessToken = getAccessToken(); + BfiResultVO> result = commonCall("付款处理查询", () -> client.payBillQuery(accessToken, + param.getPageNo(), param.getPageSize(), param.getBeginBizdate(), param.getEndBizdate(), param.getBillno(), param.getOrgNumber())); + return result.getData(); + } + + @Override + public BfiPageVO queryCustomer(BfiCustomerQueryDTO param) { + if (param == null || param.getPageNo() == null || param.getPageSize() == null) { + throw new BfiException("查询BFI客户失败,分页参数不能为空"); + } + String accessToken = getAccessToken(); + BfiResultVO> result = commonCall("客户查询", () -> client.customerQuery(accessToken, + param.getPageNo(), param.getPageSize(), param.getEnable(), param.getStatus(), param.getNumber(), param.getCreateorgNumber(), + param.getSocietycreditcode(), param.getIdno(), param.getModifytimeS(), param.getModifytimeE())); + return result.getData(); + } + + @Override + public BfiBillSaveResultVO saveCustomer(BfiCustomerSaveDTO param) { + if (param == null || StringUtil.isBlank(param.getName())) { + throw new BfiException("保存BFI客户失败,客户名称不能为空"); + } + if (StringUtil.isBlank(param.getSocietycreditcode()) && StringUtil.isBlank(param.getIdno())) { + throw new BfiException("保存BFI客户失败,统一社会信用代码与身份证号不能同时为空"); + } + if (param.getEntryGroupstandard() == null || param.getEntryGroupstandard().isEmpty()) { + throw new BfiException("保存BFI客户失败,分类标准分录不能为空"); + } + String accessToken = getAccessToken(); + BfiCustomerSaveBodyDTO body = new BfiCustomerSaveBodyDTO(); + body.setData(Collections.singletonList(param)); + BfiResultVO result = commonCall("客户保存", () -> client.customerSave(accessToken, body)); + return result.getData(); + } + + @Override + public BfiBillSaveResultVO saveApBill(BfiApBillSaveDTO param) { + if (param == null || StringUtil.isBlank(param.getAwrpSrcSys()) || StringUtil.isBlank(param.getAwrpSrcCode())) { + throw new BfiException("保存BFI财务应付单失败,外部系统编号与来源单据号不能为空"); + } + if (param.getDetailentry() == null || param.getDetailentry().isEmpty()) { + throw new BfiException("保存BFI财务应付单失败,明细分录不能为空"); + } + String accessToken = getAccessToken(); + BfiApBillSaveBodyDTO body = new BfiApBillSaveBodyDTO(); + body.setData(Collections.singletonList(param)); + BfiResultVO result = commonCall("财务应付单保存", () -> client.apBillSave(accessToken, body)); + return result.getData(); + } + + @Override + public BfiPageVO queryApBill(BfiApBillQueryDTO param) { + if (param == null || param.getPageNo() == null || param.getPageSize() == null) { + throw new BfiException("查询BFI财务应付单失败,分页参数不能为空"); + } + String accessToken = getAccessToken(); + BfiResultVO> result = commonCall("财务应付单查询", () -> client.apBillQuery(accessToken, + param.getPageNo(), param.getPageSize(), param.getAwrpSrcCode(), param.getAwrpSrcSys(), + param.getModifytimeS(), param.getModifytimeE(), param.getBillstatus(), param.getSettlestatus())); + return result.getData(); + } + + @Override + public BfiBillSaveResultVO saveArBill(BfiArBillSaveDTO param) { + if (param == null || StringUtil.isBlank(param.getAwrpSrcSys()) || StringUtil.isBlank(param.getAwrpSrcCode())) { + throw new BfiException("保存BFI财务应收单失败,外部系统编号与来源单据号不能为空"); + } + if (param.getEntry() == null || param.getEntry().isEmpty()) { + throw new BfiException("保存BFI财务应收单失败,明细分录不能为空"); + } + String accessToken = getAccessToken(); + BfiArBillSaveBodyDTO body = new BfiArBillSaveBodyDTO(); + body.setData(Collections.singletonList(param)); + BfiResultVO result = commonCall("财务应收单保存", () -> client.arBillSave(accessToken, body)); + return result.getData(); + } + + @Override + public boolean lockBusiData(BfiBillLockDTO param) { + if (param == null || StringUtil.isBlank(param.getLockAction()) || StringUtil.isBlank(param.getSourceSystem()) + || StringUtil.isBlank(param.getTargetSystem()) || StringUtil.isBlank(param.getOperationType()) + || StringUtil.isBlank(param.getSourceBillType()) || StringUtil.isBlank(param.getSourceBillNo()) + || StringUtil.isBlank(param.getTargetBillType()) || StringUtil.isBlank(param.getTargetBillNo())) { + throw new BfiException("BFI单据锁定失败,操作类型/源目标系统/操作类型/单据类型与编号不能为空"); + } + // 锁单应答为 {updateCount, updateResultList} 裸结构(非 BfiResultVO 包装),独立校验 + try { + String accessToken = getAccessToken(); + BfiBillLockResultVO result = client.lockBusiData(accessToken, param); + if (result != null && result.isSuccess()) { + return true; + } + log.error("调用BFI接口单据锁定异常响应参数:{}", JSONUtil.toJsonStr(result)); + throw new BfiException("调用BFI接口单据锁定异常,应答缺少updateCount字段(金蝶侧可能返回了请求回显、未实际处理锁定) updateCount=" + + (result == null ? "无响应" : result.getUpdateCount())); + } catch (BfiException e) { + log.error("调用BFI接口单据锁定,BfiException异常", e); + throw e; + } catch (Exception e) { + log.error("调用BFI接口单据锁定异常", e); + throw new BfiException("调用BFI接口单据锁定异常", e); + } + } + + @Override + public BfiPageVO queryArBill(BfiArBillQueryDTO param) { + if (param == null || param.getPageNo() == null || param.getPageSize() == null) { + throw new BfiException("查询BFI财务应收单失败,分页参数不能为空"); + } + String accessToken = getAccessToken(); + BfiResultVO> result = commonCall("财务应收单查询", () -> client.arBillQuery(accessToken, + param.getPageNo(), param.getPageSize(), param.getAwrpSrcCode(), param.getAwrpSrcSys(), + param.getModifytimeS(), param.getModifytimeE(), param.getBillstatus(), param.getSettlestatus())); + return result.getData(); + } + + @Override + public BfiBillSaveResultVO invoiceAssign(String op, List data) { + if (StringUtil.isBlank(op)) { + throw new BfiException("BFI收票指定应付失败,操作类型不能为空"); + } + if (data == null || data.isEmpty()) { + throw new BfiException("BFI收票指定应付失败,指定关系数据不能为空"); + } + String accessToken = getAccessToken(); + BfiInvoiceAssignBodyDTO body = new BfiInvoiceAssignBodyDTO(); + body.setOp(op); + body.setData(data); + BfiResultVO result = commonCall("收票指定应付", () -> client.invoiceAssign(accessToken, body)); + return result.getData(); + } + + @Override + public BfiBillSaveResultVO saveInvoiceApply(BfiInvoiceApplySaveDTO param) { + if (param == null || StringUtil.isBlank(param.getBillno()) || StringUtil.isBlank(param.getOrgidNumber())) { + throw new BfiException("保存BFI开票申请单失败,组织编码与业务单据编号不能为空"); + } + if (param.getSimOriginalBillItem() == null || param.getSimOriginalBillItem().isEmpty()) { + throw new BfiException("保存BFI开票申请单失败,单据体分录不能为空"); + } + String accessToken = getAccessToken(); + BfiInvoiceApplySaveBodyDTO body = new BfiInvoiceApplySaveBodyDTO(); + body.setData(Collections.singletonList(param)); + BfiResultVO result = commonCall("开票申请单保存", () -> client.invoiceApplySave(accessToken, body)); + return result.getData(); + } + + @Override + public BfiPageVO queryInvoiceApply(BfiInvoiceApplyQueryDTO param) { + if (param == null || param.getPageNo() == null || param.getPageSize() == null) { + throw new BfiException("查询BFI开票申请单失败,分页参数不能为空"); + } + String accessToken = getAccessToken(); + BfiResultVO> result = commonCall("开票申请单查询", () -> client.invoiceApplyQuery(accessToken, + param.getPageNo(), param.getPageSize(), param.getBillno(), param.getSystemsource(), param.getBilldateS(), param.getBilldateE())); + return result.getData(); + } + + @Override + public BfiOpenApiResultVO> queryInvoiceApplyInvoice(BfiInvoiceApplyOpenApiQueryDTO param) { + if (param == null || StringUtil.isBlank(param.getSerialNo()) || StringUtil.isBlank(param.getSellerTaxpayerId())) { + throw new BfiException("查询BFI开票申请发票失败,单据编号与销方税号不能为空"); + } + if (properties.isMockEnabled()) { + log.info("【MOCK】查询BFI开票申请发票,serialNo={}", param.getSerialNo()); + return mockQueryInvoiceApplyInvoice(); + } + // data 原文需序列化为 JSON 后 Base64 编码 + String data = Base64.getEncoder().encodeToString(JSONUtil.toJsonStr(param).getBytes(StandardCharsets.UTF_8)); + BfiInvoiceApplyOpenApiBodyDTO body = new BfiInvoiceApplyOpenApiBodyDTO(); + body.setBusinessSystemCode(properties.getClientId()); + body.setInterfaceCode(BfiConstant.INVOICE_APPLY_OPEN_API_CODE); + body.setData(data); + String accessToken = getAccessToken(); + BfiOpenApiResultVO rawResult = commonOpenApiCall("开票申请发票查询", () -> client.invoiceApplyOpenApi(accessToken, body)); + // 响应 data 为 Base64 编码字符串,需解码后解析为 JSON + BfiOpenApiResultVO> result = new BfiOpenApiResultVO<>(); + result.setErrorCode(rawResult.getErrorCode()); + result.setSuccess(rawResult.getSuccess()); + if (rawResult.isSuccess() && StringUtil.isNotBlank(rawResult.getData())) { + String decodedJson = new String(Base64.getDecoder().decode(rawResult.getData()), StandardCharsets.UTF_8); + JSONObject decodedObj = JSONUtil.parseObj(decodedJson); + result.setErrorCode(decodedObj.getStr("errorCode")); + result.setSuccess(decodedObj.getBool("success")); + result.setData(decodedObj.getBeanList("data", BfiInvoiceApplyOpenApiBillVO.class)); + } + return result; + } + + @Override + public BfiBillSaveResultVO saveReceivedInvoice(BfiReceivedInvoiceSaveDTO param) { + if (param == null || StringUtil.isBlank(param.getAwrpSrcSys()) || StringUtil.isBlank(param.getAwrpSrcCode())) { + throw new BfiException("保存BFI收票单失败,外部系统编号与来源单据号不能为空"); + } + if (param.getEntry() == null || param.getEntry().isEmpty()) { + throw new BfiException("保存BFI收票单失败,明细分录不能为空"); + } + String accessToken = getAccessToken(); + BfiReceivedInvoiceSaveBodyDTO body = new BfiReceivedInvoiceSaveBodyDTO(); + body.setData(Collections.singletonList(param)); + BfiResultVO result = commonCall("收票单保存", () -> client.receivedInvoiceSave(accessToken, body)); + return result.getData(); + } + + @Override + public BfiPageVO queryReceivedInvoice(BfiReceivedInvoiceQueryDTO param) { + if (param == null || StringUtil.isBlank(param.getModifytimeS()) || StringUtil.isBlank(param.getModifytimeE())) { + throw new BfiException("查询BFI收票单失败,修改时间范围不能为空"); + } + String accessToken = getAccessToken(); + BfiResultVO> result = commonCall("收票单查询", () -> client.receivedInvoiceQuery(accessToken, param)); + return result.getData(); + } + + @Override + public BfiPageVO queryRecBill(BfiRecBillQueryDTO param) { + if (param == null || param.getPageNo() == null || param.getPageSize() == null) { + throw new BfiException("查询BFI收款单失败,分页参数不能为空"); + } + if (StringUtil.isBlank(param.getBeginBizdate()) || StringUtil.isBlank(param.getEndBizdate())) { + throw new BfiException("查询BFI收款单失败,业务日期范围不能为空"); + } + String accessToken = getAccessToken(); + BfiResultVO> result = commonCall("收款单查询", () -> client.recBillQuery(accessToken, + param.getPageNo(), param.getPageSize(), param.getBeginBizdate(), param.getEndBizdate(), param.getBillno(), param.getOrgNumber())); + return result.getData(); + } + + @Override + public BfiPageVO queryClaimAnnounce(BfiClaimAnnounceQueryDTO param) { + if (param == null || StringUtil.isBlank(param.getClaimusers())) { + throw new BfiException("查询BFI收款认领公告失败,参与认领用户名不能为空"); + } + if (StringUtil.isBlank(param.getModifytimeS()) || StringUtil.isBlank(param.getModifytimeE())) { + throw new BfiException("查询BFI收款认领公告失败,修改时间范围不能为空"); + } + if (param.getPageNo() == null || param.getPageSize() == null) { + throw new BfiException("查询BFI收款认领公告失败,分页参数不能为空"); + } + String accessToken = getAccessToken(); + BfiResultVO> result = commonCall("收款认领公告查询", () -> client.claimAnnounceQuery(accessToken, + param.getClaimusers(), param.getBillstatus(), param.getModifytimeS(), param.getModifytimeE(), param.getPageNo(), param.getPageSize())); + return result.getData(); + } + + @Override + public BfiBillSaveResultVO pushClaimResult(BfiClaimPushDTO param) { + if (param == null || param.getBillid() == null) { + throw new BfiException("推送BFI收款认领结果失败,认领公告id不能为空"); + } + if (StringUtil.isBlank(param.getAwrpSrcSys()) || StringUtil.isBlank(param.getAwrpSrcCode())) { + throw new BfiException("推送BFI收款认领结果失败,来源系统编码与来源单据编码不能为空"); + } + if (param.getEntryentity() == null || param.getEntryentity().isEmpty()) { + throw new BfiException("推送BFI收款认领结果失败,单据体分录不能为空"); + } + String accessToken = getAccessToken(); + BfiClaimPushBodyDTO body = new BfiClaimPushBodyDTO(); + body.setData(Collections.singletonList(param)); + BfiResultVO result = commonCall("收款认领结果推送", () -> client.claimBillPush(accessToken, body)); + return result.getData(); + } + + @Override + public BfiPageVO queryInvoicePool(BfiInvoicePoolQueryDTO param) { + if (param == null || param.getQueryParam() == null) { + throw new BfiException("查询BFI全票池发票失败,查询参数不能为空"); + } + BfiInvoicePoolQueryDataDTO data = param.getQueryParam(); + if (data.getPageNo() == null || data.getPageSize() == null) { + throw new BfiException("查询BFI全票池发票失败,pageNo与pageSize不能为空"); + } + param.setMessageType(BfiConstant.MESSAGE_TYPE_INVOICE_QUERY); + // rim 契约要求每次请求携带唯一 messageId + param.setMessageId(UUID.randomUUID().toString()); + long start = System.currentTimeMillis(); + log.info("调用BFI接口 全票池发票查询开始 pageNo:{},pageSize:{}", data.getPageNo(), data.getPageSize()); + BfiResultVO> result = commonRimCall("全票池发票查询", + () -> client.invoicePoolQuery(getAccessToken(), param)); + List rows = result.getData() != null ? result.getData() : List.of(); + BfiPageVO page = new BfiPageVO<>(); + page.setPageNo(data.getPageNo()); + page.setPageSize(data.getPageSize()); + page.setRows(rows); + log.info("调用BFI接口 全票池发票查询结束 耗时:{}ms,本页 {} 条", System.currentTimeMillis() - start, rows.size()); + return page; + } + + @Override + public Response downloadInvoice(String path) { + if (StringUtil.isBlank(path)) { + throw new BfiException("下载BFI进项发票失败,文件url不能为空"); + } + BfiInvoiceDownloadDTO param = new BfiInvoiceDownloadDTO(); + param.setPath(path); + String accessToken = getAccessToken(); + long start = System.currentTimeMillis(); + log.info("调用BFI接口 进项发票下载开始 path:{}", path); + try { + Response response = client.invoiceDownload(accessToken, param); + if (response == null || response.status() != 200) { + log.error("调用BFI接口 进项发票下载异常响应状态:{}", response == null ? "无响应" : response.status()); + throw new BfiException("调用BFI接口进项发票下载异常,响应状态:" + (response == null ? "无响应" : response.status())); + } + log.info("调用BFI接口 进项发票下载结束 耗时:{}ms", System.currentTimeMillis() - start); + return response; + } catch (BfiException e) { + throw e; + } catch (Exception e) { + log.error("调用BFI接口 进项发票下载异常", e); + throw new BfiException("调用BFI接口进项发票下载异常", e); + } + } + + @Override + public BfiAttachmentUploadVO uploadAttachment(String entityNumber, String billPkId, String controlKey, String fileName, byte[] fileContent) { + if (StringUtil.isBlank(entityNumber) || StringUtil.isBlank(billPkId)) { + throw new BfiException("上传BFI附件失败,entityNumber与billPkId不能为空"); + } + if (StringUtil.isBlank(fileName) || fileContent == null || fileContent.length == 0) { + throw new BfiException("上传BFI附件失败,附件名称与内容不能为空"); + } + String accessToken = getAccessToken(); + // A902 参数原文 JSON:{"entityNumber":"ap_payapply","billPkId":2386455983883835392,"controlKey":"attachmentpanel"} + JSONObject args = new JSONObject(); + args.set("entityNumber", entityNumber); + args.set("billPkId", billPkId); + args.set("controlKey", StringUtil.isBlank(controlKey) ? "attachmentpanel" : controlKey); + InMemoryMultipartFile file = new InMemoryMultipartFile("file", fileName, MediaType.APPLICATION_OCTET_STREAM_VALUE, fileContent); + long start = System.currentTimeMillis(); + log.info("调用BFI接口 附件上传开始 entityNumber:{},billPkId:{},文件:{},大小:{}字节", + entityNumber, billPkId, fileName, fileContent.length); + BfiResultVO result = commonCall("附件上传", () -> client.uploadAttachment(accessToken, file, args.toString())); + BfiAttachmentUploadVO uploadVO = result.getData(); + if (uploadVO == null || StringUtil.isBlank(uploadVO.getPath())) { + log.error("调用BFI接口 附件上传返回数据为空:{}", JSONUtil.toJsonStr(result)); + throw new BfiException("调用BFI接口附件上传异常,返回数据为空"); + } + log.info("调用BFI接口 附件上传结束 耗时:{}ms,kingdeePath:{}", System.currentTimeMillis() - start, uploadVO.getPath()); + return uploadVO; + } + + /** + * 公共调用方法,统一校验响应结果 + * + * @param type 调用类型描述 + * @param supplier 实际调用 + * @param 响应数据类型 + * @return 响应结果 + */ + private BfiResultVO commonCall(String type, Supplier> supplier) { + try { + BfiResultVO result = supplier.get(); + if (result != null && result.isSuccess()) { + return result; + } + log.error("调用BFI接口 {}异常响应参数:{}", type, JSONUtil.toJsonStr(result)); + 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); + } catch (BfiException e) { + log.error("调用BFI接口 {},BfiException异常", type, e); + throw e; + } catch (Exception e) { + log.error("调用BFI接口 {}异常", type, e); + throw new BfiException("调用BFI接口" + type + "异常", e); + } + } + + /** + * 收票 rim/message 契约公共调用方法,成功错误码兼容 0 与 0000 + * + * @param type 调用类型描述 + * @param supplier 实际调用 + * @param 响应数据类型 + * @return 响应结果 + */ + private BfiResultVO commonRimCall(String type, Supplier> supplier) { + try { + BfiResultVO result = supplier.get(); + if (result != null && Boolean.TRUE.equals(result.getStatus()) + && (BfiConstant.SUCCESS_CODE.equals(result.getErrorCode()) || BfiConstant.RIM_SUCCESS_CODE.equals(result.getErrorCode()))) { + return result; + } + log.error("调用BFI接口 {}异常响应参数:{}", type, JSONUtil.toJsonStr(result)); + 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); + } catch (BfiException e) { + throw e; + } catch (Exception e) { + log.error("调用BFI接口 {}异常", type, e); + throw new BfiException("调用BFI接口" + type + "异常", e); + } + } + + /** + * 开放平台公共调用方法,校验 success 标识 + * + * @param type 调用类型描述 + * @param supplier 实际调用 + * @param 响应数据类型 + * @return 响应结果 + */ + private BfiOpenApiResultVO commonOpenApiCall(String type, Supplier> supplier) { + try { + BfiOpenApiResultVO result = supplier.get(); + if (result != null && result.isSuccess()) { + return result; + } + log.error("调用BFI接口 {}异常响应参数:{}", type, JSONUtil.toJsonStr(result)); + String errorCode = Optional.ofNullable(result).map(BfiOpenApiResultVO::getErrorCode).orElse(""); + throw new BfiException("调用BFI接口" + type + "异常 " + errorCode); + } catch (BfiException e) { + throw e; + } catch (Exception e) { + log.error("调用BFI接口 {}异常", type, e); + throw new BfiException("调用BFI接口" + type + "异常", e); + } + } + + /** + * 缓存令牌,按 expires_in 毫秒数设置提前失效时间 + * + * @param tokenVO 令牌信息 + */ + private void cacheToken(BfiTokenVO tokenVO) { + if (tokenVO.getExpiresIn() == null || tokenVO.getExpiresIn() <= BfiConstant.TOKEN_EXPIRE_BUFFER_MILLIS) { + return; + } + // 过期时间 - 缓冲时长,防止刚好过期 + long expireMillis = tokenVO.getExpiresIn() - BfiConstant.TOKEN_EXPIRE_BUFFER_MILLIS; + bladeRedis.getValueOps().setIfAbsent(BfiConstant.BFI_TOKEN_CACHE_KEY, tokenVO.getAccessToken(), Duration.ofMillis(expireMillis)); + } + + /** + * Mock 开票申请发票查询(开发调试用) + * + * @return mock 发票查询结果 + */ + private BfiOpenApiResultVO> mockQueryInvoiceApplyInvoice() { + String mockData = "W3siaW52b2ljZUxpc3QiOlt7ImFib2xpc2hSZWFzb24iOiIiLCJiaWxsTm8iOiJLUFNRLUpDWlgtMjAyNjAzLTAwMDU4MCIsImJ1eWVyQWRkcmVzc0FuZFRlbCI6IumSpuW3nuW4gumSpuW3nua4r+WvjOa4r+aWsOWfjjMw5qCLMDjoh7MxM+WPt+WSjDMx5qCLMDHoh7MwOeWPt+WVhumTujA3NzctMzY2NTExMSIsImJ1eWVyQmFua0FuZEFjY291bnQiOiLkuK3lm73lhpzkuJrpk7booYzogqHku73mnInpmZDlhazlj7jpkqblt57muK/mlK/ooYwgMjA3NDA0MDEwNDAwMTA3NjYiLCJidXllck5hbWUiOiLlub/opb/moYLlu7rlt6XnqIvotKjph4/mo4DmtYvmnInpmZDlhazlj7giLCJidXllclByb3BlcnR5IjowLCJidXllclJlY2lwaWVudE1haWwiOiIiLCJidXllclJlY2lwaWVudFBob25lIjoiIiwiYnV5ZXJUYXhwYXllcklkIjoiOTE0NTA3MDQwNTEwMDE2ODdRIiwiY2FuUmVkQW1vdW50IjozNjIyLjY0LCJjYW5SZWRUYXhBbW91bnQiOjIxNy4zNiwiY2FuY2VsZXIiOiIiLCJjaGVja0NvZGUiOiIiLCJkZXZpY2VObyI6IiIsImRvd25sb2FkRmxhZyI6MiwiZHJhd2VyIjoi6ZmI5bCP5bCPIiwiZ29vZHMiOlt7ImFtb3VudCI6MzYyMi42NCwiYmlsbFNvdXJjZUlkIjoiIiwiZGV0YWlsSWQiOiIyNDM3OTM3MTA2NTMwNjE5MzkzIiwiZGV0YWlsUm93Tm8iOjEsImdvb2RzTmFtZSI6IirpibTor4Hlkqjor6LmnI3liqEq5qOA5rWL6LS5IiwiaW5jbHVkZVRheEFtb3VudCI6Mzg0MC4wMCwiaW5jbHVkZVRheFByaWNlIjoiMzg0MCIsImxpbmVQcm9wZXJ0eSI6MCwicHJpY2UiOiIzNjIyLjY0MTUwOTQzMzk2IiwicHJpdklsZWdlQ29udGVudCI6IiIsInByaXZpbGVnZUZsYWciOjAsInF1YW50aXR5IjoiMSIsInJlbWFpblJlZEFtb3VudCI6MzYyMi42NCwicmVtYWluUmVkUXVhbnRpdHkiOiIxIiwicmVtYWluUmVkVGF4IjoyMTcuMzYsInJldmVudWVDb2RlIjoiMzA0MDYwMjk5MDAwMDAwMDAwMCIsInJldmVudWVOYW1lIjoiKumJtOivgeWSqOivouacjeWKoSoiLCJzZXEiOjAsInNwZWNpZmljYXRpb24iOiIiLCJ0YXhBbW91bnQiOjIxNy4zNiwidGF4RmxhZyI6IjEiLCJ0YXhSYXRlIjoiMC4wNiIsInVuaXRzIjoi6aG5IiwiemVyb1RheFJhdGVGbGFnIjoiIn1dLCJpZCI6MjQzNzkzNzEwNjUzMDYxOTM5MiwiaW5jbHVkZVRheEFtb3VudCI6Mzg0MC4wMCwiaW52ZW50b3J5RmxhZyI6MCwiaW52b2ljZUNvZGUiOiIiLCJpbnZvaWNlRGF0ZSI6MTc3MzgyNDc2NDAwMCwiaW52b2ljZUZpbGVVcmwiOiJodHRwczovL2FwaS5waWFvem9uZS5jb20vcnBhL2ZyZWUvcHJldmlldy8zNjYxOTQwMDM3ODk2OTEzOTIvOTE0NTAwMDBNQTVRMUcxTTU5LzVORlkyWEZHNlNWV0lXNzJPUDA1P3R5cGU9MSIsImludm9pY2VJbWFnZVVybCI6Imh0dHBzOi8vYXBpLnBpYW96b25lLmNvbS9ycGEvZnJlZS9wcmV2aWV3LzM2NjE5NDAwMzc4OTY5MTM5Mi85MTQ1MDAwME1BNVExRzFNNTkvNU5GWTJYRkc2U1ZXSVc3Mk9QMDU/dHlwZT0yIiwiaW52b2ljZU51bWJlciI6IjI2NDUyMDAwMDAwNDIzMjQwOTE2IiwiaW52b2ljZVBkZkZpbGVVcmwiOiJodHRwczovL2FwaS5waWFvem9uZS5jb20vcnBhL2ZyZWUvcHJldmlldy8zNjYxOTQwMDM3ODk2OTEzOTIvOTE0NTAwMDBNQTVRMUcxTTU5LzVORlkyWEZHNlNWV0lXNzJPUDA1P3R5cGU9MCIsImludm9pY2VQcm9wZXJ0eSI6MCwiaW52b2ljZVNvdXJjZSI6IjQiLCJpbnZvaWNlU3RhdHVzIjowLCJpbnZvaWNlVHlwZSI6IjA4eGRwIiwiaW52b2ljZVhtbEZpbGVVcmwiOiJodHRwczovL2FwaS5waWFvem9uZS5jb20vcnBhL2ZyZWUvcHJldmlldy8zNjYxOTQwMDM3ODk2OTEzOTIvOTE0NTAwMDBNQTVRMUcxTTU5LzVORlkyWEZHNlNWV0lXNzJPUDA1P3R5cGU9MyIsImlzc3VlU3RhdHVzIjoiMCIsIm1lcmdlRGlmZmVyVGF4QW1vdW50IjowLjAwLCJvcmdOYW1lIjoi5bm/6KW/5aOu5peP6Ieq5rK75Yy65bu6562R5bel56iL6LSo6YeP5qOA5rWL5Lit5b+D5pyJ6ZmQ5YWs5Y+4Iiwib3JpZ2luYWxJbnZvaWNlQ29kZSI6IiIsIm9yaWdpbmFsSW52b2ljZU51bWJlciI6IiIsInBheWVlIjoiIiwicHJpbnRGbGFnIjowLCJyZWRJbmZvQmlsbE5vIjoiIiwicmVkUmVhc29uIjoiIiwicmVtYXJrIjoiIiwicmVzdWx0IjoiIiwicmV2aWV3ZXIiOiIiLCJzZWxsZXJBZGRyZXNzQW5kVGVsIjoi5Y2X5a6B5biC6KW/5Lmh5aGY5Yy65YyX6ZmF6LevMeWPtzA3NzEtMzE3OTYwMSIsInNlbGxlckJhbmtBbmRBY2NvdW50Ijoi5bel5ZWG6ZO26KGM5Y2X5a6B5biC5rC45paw5pSv6KGMMjEwMjEwNDAwOTMwMDI5NjA0MSIsInNlbGxlck5hbWUiOiLlub/opb/lo67ml4/oh6rmsrvljLrlu7rnrZHlt6XnqIvotKjph4/mo4DmtYvkuK3lv4PmnInpmZDlhazlj7giLCJzZWxsZXJUYXhwYXllcklkIjoiOTE0NTAwMDBNQTVRMUcxTTU5Iiwic2VyaWFsTm8iOiIyMDBfMjAyNjAzMTgxNzA1NTAwMDFfMDAwMSIsInNrbSI6IiIsInNwZWNpYWxSZWRGbGFnIjowLCJzcGxpdE9yTWVyZ2VGbGFnIjoiMCIsInRheEZsYWciOiIxIiwidGVybWluYWxObyI6IiIsInRvdGFsQW1vdW50IjozNjIyLjY0LCJ0b3RhbFRheEFtb3VudCI6MjE3LjM2fV0sImJpbGxObyI6IktQU1EtSkNaWC0yMDI2MDMtMDAwNTgwIn1d"; + BfiOpenApiResultVO> result = new BfiOpenApiResultVO<>(); + result.setErrorCode("0"); + result.setSuccess(true); + String decodedJson = new String(Base64.getDecoder().decode(mockData), StandardCharsets.UTF_8); + JSONObject decodedObj = JSONUtil.parseObj(decodedJson); + result.setErrorCode(decodedObj.getStr("errorCode")); + result.setSuccess(decodedObj.getBool("success")); + result.setData(decodedObj.getBeanList("data", BfiInvoiceApplyOpenApiBillVO.class)); + return result; + } +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/util/BfiUtil.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/util/BfiUtil.java new file mode 100644 index 00000000..ed603bb2 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/util/BfiUtil.java @@ -0,0 +1,142 @@ +/** + * 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.util; + +import org.springblade.core.tool.utils.DateUtil; +import org.springblade.core.tool.utils.Func; +import org.springblade.thirdparty.bfi.pojo.dto.BfiBaseDTO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiPageVO; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.UUID; +import java.util.function.IntFunction; + +/** + * 业财融合(BFI)接口通用参数工具类 + *

+ * BFI 开放接口要求每次请求携带不可重复的随机数 nonce 以及服务端时间戳 timestamp, + * 后续新增接口统一继承 {@link BfiBaseDTO} 并调用 {@link #fillRandomParam(BfiBaseDTO)} 填充 + * + * @author Chill + */ +public class BfiUtil { + + /** + * 时间戳格式:yyyy-MM-dd HH:mm:ss + */ + public static final String TIMESTAMP_PATTERN = "yyyy-MM-dd HH:mm:ss"; + + /** + * 分页查询默认最大页数保护,防止金蝶侧 lastPage 异常时死循环 + */ + public static final int DEFAULT_MAX_PAGES = 50; + + private BfiUtil() { + } + + /** + * 生成随机数,每次请求不可重复 + * + * @return 随机数字符串 + */ + public static String nonce() { + return UUID.randomUUID().toString(); + } + + /** + * 生成当前时间戳,服务端校验当前时间前后5分钟 + * + * @return yyyy-MM-dd HH:mm:ss 格式时间字符串 + */ + public static String timestamp() { + return DateUtil.formatDateTime(new Date()); + } + + /** + * 填充随机数与时间戳,每次调用均生成新值 + * + * @param param 业务请求参数 + * @param 参数类型 + * @return 填充后的参数 + */ + public static T fillRandomParam(T param) { + param.setNonce(nonce()); + param.setTimestamp(timestamp()); + return param; + } + + /** + * BFI 分页查询通用翻页,自动翻页汇总全量数据行(默认最大页数保护) + *

+ * 适用所有返回 {@link BfiPageVO} 的查询接口 + * (A103-2 付款申请单查询、A103-4 付款处理查询、A102-4 财务应付单查询、A902-1 客户查询等): + *

{@code
+	 * List rows = BfiUtil.queryAllPages(pageNo -> {
+	 * 	param.setPageNo(pageNo);
+	 * 	param.setPageSize(pageSize);
+	 * 	return bfiService.queryPayApply(param);
+	 * });
+	 * }
+ * + * @param pageFetcher 页码 -> 分页结果,lambda 内负责设置页码(必要时含页大小)并调用接口 + * @param 数据行类型 + * @return 全量数据行 + */ + public static List queryAllPages(IntFunction> pageFetcher) { + return queryAllPages(pageFetcher, DEFAULT_MAX_PAGES); + } + + /** + * BFI 分页查询通用翻页,自动翻页汇总全量数据行 + *

+ * 终止条件:返回页为空、数据行为空、lastPage=true、数据行不满一页(按返回 pageSize 判断)、超出最大页数保护 + * + * @param pageFetcher 页码 -> 分页结果,lambda 内负责设置页码(必要时含页大小)并调用接口 + * @param maxPages 最大页数保护 + * @param 数据行类型 + * @return 全量数据行 + */ + public static List queryAllPages(IntFunction> pageFetcher, int maxPages) { + List rows = new ArrayList<>(); + for (int pageNo = 1; pageNo <= maxPages; pageNo++) { + BfiPageVO page = pageFetcher.apply(pageNo); + if (page == null || Func.isEmpty(page.getRows())) { + break; + } + rows.addAll(page.getRows()); + if (Boolean.TRUE.equals(page.getLastPage())) { + break; + } + // 末页数据不满一页时无需继续翻页(pageSize 取返回值,返回为空时依赖 lastPage 与最大页数保护) + if (page.getPageSize() != null && page.getRows().size() < page.getPageSize()) { + break; + } + } + return rows; + } +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/util/InMemoryMultipartFile.java b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/util/InMemoryMultipartFile.java new file mode 100644 index 00000000..37b09524 --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/util/InMemoryMultipartFile.java @@ -0,0 +1,101 @@ +/** + * 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.util; + +import org.springframework.lang.NonNull; +import org.springframework.web.multipart.MultipartFile; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; + +/** + * 内存字节数组版 MultipartFile:A902 附件上传经 Feign multipart 发送时使用, + * 文件内容已整体读入内存(单文件软限制 20MB,见 KingdeeAttachmentConstant) + * + * @author Chill + */ +public class InMemoryMultipartFile implements MultipartFile { + + private final String name; + private final String originalFilename; + private final String contentType; + private final byte[] content; + + public InMemoryMultipartFile(String name, String originalFilename, String contentType, byte[] content) { + this.name = name; + this.originalFilename = originalFilename; + this.contentType = contentType; + this.content = content == null ? new byte[0] : content; + } + + @Override + @NonNull + public String getName() { + return name; + } + + @Override + public String getOriginalFilename() { + return originalFilename; + } + + @Override + public String getContentType() { + return contentType; + } + + @Override + public boolean isEmpty() { + return content.length == 0; + } + + @Override + public long getSize() { + return content.length; + } + + @Override + @NonNull + public byte[] getBytes() { + return content; + } + + @Override + @NonNull + public InputStream getInputStream() { + return new ByteArrayInputStream(content); + } + + @Override + public void transferTo(@NonNull File destination) throws IOException { + try (FileOutputStream outputStream = new FileOutputStream(destination)) { + outputStream.write(content); + } + } +} diff --git a/blade-third-party-api/blade-bfi-api/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/blade-third-party-api/blade-bfi-api/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000..d810ba6b --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +org.springblade.thirdparty.bfi.config.ThirdPartyBfiAutoConfiguration diff --git a/blade-third-party-api/blade-bfi-api/src/test/java/org/springblade/thirdparty/bfi/service/BfiServiceImplTest.java b/blade-third-party-api/blade-bfi-api/src/test/java/org/springblade/thirdparty/bfi/service/BfiServiceImplTest.java new file mode 100644 index 00000000..5794196b --- /dev/null +++ b/blade-third-party-api/blade-bfi-api/src/test/java/org/springblade/thirdparty/bfi/service/BfiServiceImplTest.java @@ -0,0 +1,607 @@ +/* + * 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.service; + +import cn.hutool.json.JSONObject; +import cn.hutool.json.JSONUtil; +import feign.Request; +import feign.Response; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springblade.core.redis.cache.BladeRedis; +import org.springblade.thirdparty.bfi.config.BfiProperties; +import org.springblade.thirdparty.bfi.exception.BfiException; +import org.springblade.thirdparty.bfi.feign.IBfiClient; +import org.springblade.thirdparty.bfi.pojo.dto.BfiApBillQueryDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiApBillSaveDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiApBillSaveEntryDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiArBillQueryDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiArBillSaveDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiArBillSaveEntryDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiClaimAnnounceQueryDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiClaimPushDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiClaimPushEntryDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiCustomerGroupEntryDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiCustomerQueryDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiCustomerSaveDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceApplyOpenApiQueryDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceApplyQueryDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceApplySaveDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceApplySaveEntryDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceAssignBodyDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceAssignItemDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceAssignRefDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoicePoolQueryDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoicePoolQueryDataDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiRecBillQueryDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiReceivedInvoiceQueryDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiReceivedInvoiceSaveDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiReceivedInvoiceSaveEntryDTO; +import org.springblade.thirdparty.bfi.pojo.dto.BfiTokenDTO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiApBillVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiArBillVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiClaimAnnounceVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiCustomerVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiInvoiceApplyVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiInvoicePoolVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiOpenApiResultVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiPageVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiRecBillVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiReceivedInvoiceVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiResultVO; +import org.springblade.thirdparty.bfi.pojo.vo.BfiTokenVO; +import org.springblade.thirdparty.bfi.service.impl.BfiServiceImpl; +import org.springframework.data.redis.core.ValueOperations; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * 业财融合(BFI)服务实现单元测试 + * + * @author Chill + */ +@ExtendWith(MockitoExtension.class) +class BfiServiceImplTest { + + @Mock + private BfiProperties properties; + + @Mock + private IBfiClient client; + + @Mock + private BladeRedis bladeRedis; + + @Mock + private ValueOperations valueOperations; + + @InjectMocks + private BfiServiceImpl service; + + @BeforeEach + void setUp() { + lenient().when(properties.getClientId()).thenReturn("test-client"); + lenient().when(properties.getClientSecret()).thenReturn("test-secret"); + lenient().when(properties.getUsername()).thenReturn("test-user"); + lenient().when(properties.getAccountId()).thenReturn("test-account"); + lenient().when(properties.getLanguage()).thenReturn("zh_CN"); + lenient().when(bladeRedis.getValueOps()).thenReturn(valueOperations); + } + + /** + * 构建成功响应 + */ + private BfiResultVO success(T data) { + BfiResultVO result = new BfiResultVO<>(); + result.setStatus(true); + result.setErrorCode("0"); + result.setData(data); + return result; + } + + /** + * 构建保存结果 + */ + private BfiBillSaveResultVO saveResult(int successCount) { + BfiBillSaveResultVO result = new BfiBillSaveResultVO(); + result.setSuccessCount(String.valueOf(successCount)); + result.setFailCount("0"); + return result; + } + + /** + * 构建令牌数据 + */ + private BfiTokenVO tokenVO() { + BfiTokenVO tokenVO = new BfiTokenVO(); + tokenVO.setAccessToken("token-1"); + tokenVO.setExpiresIn(600_000L); + return tokenVO; + } + + /** + * 模拟缓存未命中并通过 getToken 获取令牌 + */ + private void mockAccessToken() { + when(bladeRedis.get("blade:third:bfi:token")).thenReturn(null); + when(client.getToken(any(BfiTokenDTO.class))).thenReturn(success(tokenVO())); + } + + @Test + void getTokenShouldCacheAndReturnToken() { + when(client.getToken(any(BfiTokenDTO.class))).thenReturn(success(tokenVO())); + BfiTokenVO tokenVO = service.getToken(); + assertEquals("token-1", tokenVO.getAccessToken()); + ArgumentCaptor captor = ArgumentCaptor.forClass(BfiTokenDTO.class); + verify(client).getToken(captor.capture()); + assertEquals("test-client", captor.getValue().getClientId()); + assertNotNull(captor.getValue().getNonce()); + assertNotNull(captor.getValue().getTimestamp()); + verify(valueOperations).setIfAbsent(eq("blade:third:bfi:token"), eq("token-1"), any()); + } + + @Test + void getTokenShouldThrowWhenDataEmpty() { + when(client.getToken(any(BfiTokenDTO.class))).thenReturn(success(new BfiTokenVO())); + assertThrows(BfiException.class, () -> service.getToken()); + } + + @Test + void getAccessTokenShouldReturnCachedValue() { + when(bladeRedis.get("blade:third:bfi:token")).thenReturn("cached-token"); + assertEquals("cached-token", service.getAccessToken()); + verifyNoInteractions(client); + } + + @Test + void getAccessTokenShouldFetchWhenCacheMiss() { + mockAccessToken(); + assertEquals("token-1", service.getAccessToken()); + } + + @Test + void queryCustomerShouldReturnPage() { + mockAccessToken(); + BfiPageVO page = new BfiPageVO<>(); + when(client.customerQuery(eq("token-1"), eq(1), eq(10), any(), any(), any(), any(), any(), any(), any(), any())) + .thenReturn(success(page)); + BfiCustomerQueryDTO param = new BfiCustomerQueryDTO(); + param.setPageNo(1); + param.setPageSize(10); + assertEquals(page, service.queryCustomer(param)); + } + + @Test + void queryCustomerShouldThrowWhenPageEmpty() { + assertThrows(BfiException.class, () -> service.queryCustomer(new BfiCustomerQueryDTO())); + } + + @Test + void saveCustomerShouldValidateRequiredFields() { + BfiCustomerSaveDTO noName = new BfiCustomerSaveDTO(); + assertThrows(BfiException.class, () -> service.saveCustomer(noName)); + + BfiCustomerSaveDTO noCreditCode = new BfiCustomerSaveDTO(); + noCreditCode.setName("客户A"); + noCreditCode.setEntryGroupstandard(List.of(new BfiCustomerGroupEntryDTO())); + assertThrows(BfiException.class, () -> service.saveCustomer(noCreditCode)); + + BfiCustomerSaveDTO noGroup = new BfiCustomerSaveDTO(); + noGroup.setName("客户A"); + noGroup.setSocietycreditcode("91450000MA5Q1G1M59"); + assertThrows(BfiException.class, () -> service.saveCustomer(noGroup)); + verifyNoInteractions(client); + } + + @Test + void saveCustomerShouldWrapData() { + mockAccessToken(); + when(client.customerSave(anyString(), any())).thenReturn(success(saveResult(1))); + BfiCustomerSaveDTO param = new BfiCustomerSaveDTO(); + param.setName("客户A"); + param.setSocietycreditcode("91450000MA5Q1G1M59"); + param.setEntryGroupstandard(List.of(new BfiCustomerGroupEntryDTO())); + BfiBillSaveResultVO result = service.saveCustomer(param); + assertEquals("1", result.getSuccessCount()); + ArgumentCaptor captor = + ArgumentCaptor.forClass(org.springblade.thirdparty.bfi.pojo.dto.BfiCustomerSaveBodyDTO.class); + verify(client).customerSave(eq("token-1"), captor.capture()); + assertEquals(param, captor.getValue().getData().get(0)); + } + + @Test + void saveApBillShouldValidateRequiredFields() { + BfiApBillSaveDTO noSrc = new BfiApBillSaveDTO(); + assertThrows(BfiException.class, () -> service.saveApBill(noSrc)); + + BfiApBillSaveDTO noEntry = new BfiApBillSaveDTO(); + noEntry.setAwrpSrcSys("WCCK"); + noEntry.setAwrpSrcCode("AP-1"); + assertThrows(BfiException.class, () -> service.saveApBill(noEntry)); + verifyNoInteractions(client); + } + + @Test + void saveApBillShouldWrapData() { + mockAccessToken(); + when(client.apBillSave(anyString(), any())).thenReturn(success(saveResult(1))); + BfiApBillSaveDTO param = new BfiApBillSaveDTO(); + param.setAwrpSrcSys("WCCK"); + param.setAwrpSrcCode("AP-1"); + param.setDetailentry(List.of(new BfiApBillSaveEntryDTO())); + assertEquals("1", service.saveApBill(param).getSuccessCount()); + } + + @Test + void queryApBillShouldReturnPage() { + mockAccessToken(); + BfiPageVO page = new BfiPageVO<>(); + when(client.apBillQuery(eq("token-1"), eq(1), eq(10), any(), any(), any(), any(), any(), any())) + .thenReturn(success(page)); + BfiApBillQueryDTO param = new BfiApBillQueryDTO(); + param.setPageNo(1); + param.setPageSize(10); + assertEquals(page, service.queryApBill(param)); + } + + @Test + void saveArBillShouldValidateRequiredFields() { + BfiArBillSaveDTO noSrc = new BfiArBillSaveDTO(); + assertThrows(BfiException.class, () -> service.saveArBill(noSrc)); + + BfiArBillSaveDTO noEntry = new BfiArBillSaveDTO(); + noEntry.setAwrpSrcSys("WCCK"); + noEntry.setAwrpSrcCode("AR-1"); + assertThrows(BfiException.class, () -> service.saveArBill(noEntry)); + verifyNoInteractions(client); + } + + @Test + void saveArBillShouldWrapData() { + mockAccessToken(); + when(client.arBillSave(anyString(), any())).thenReturn(success(saveResult(1))); + BfiArBillSaveDTO param = new BfiArBillSaveDTO(); + param.setAwrpSrcSys("WCCK"); + param.setAwrpSrcCode("AR-1"); + param.setEntry(List.of(new BfiArBillSaveEntryDTO())); + assertEquals("1", service.saveArBill(param).getSuccessCount()); + } + + @Test + void queryArBillShouldReturnPage() { + mockAccessToken(); + BfiPageVO page = new BfiPageVO<>(); + when(client.arBillQuery(eq("token-1"), eq(1), eq(10), any(), any(), any(), any(), any(), any())) + .thenReturn(success(page)); + BfiArBillQueryDTO param = new BfiArBillQueryDTO(); + param.setPageNo(1); + param.setPageSize(10); + assertEquals(page, service.queryArBill(param)); + } + + @Test + void invoiceAssignShouldValidateAndWrapBody() { + assertThrows(BfiException.class, () -> service.invoiceAssign(null, List.of())); + assertThrows(BfiException.class, () -> service.invoiceAssign("assign", List.of())); + + mockAccessToken(); + when(client.invoiceAssign(anyString(), any())).thenReturn(success(saveResult(1))); + BfiInvoiceAssignRefDTO invoice = new BfiInvoiceAssignRefDTO(); + invoice.setAwrpSrcCode("INV-1"); + invoice.setAwrpSrcSys("WCCK"); + BfiInvoiceAssignRefDTO apBill = new BfiInvoiceAssignRefDTO(); + apBill.setAwrpSrcCode("AP-1"); + apBill.setAwrpSrcSys("WCCK"); + BfiInvoiceAssignItemDTO item = new BfiInvoiceAssignItemDTO(); + item.setApInvoice(List.of(invoice)); + item.setApFinapbill(List.of(apBill)); + assertEquals("1", service.invoiceAssign("assign", List.of(item)).getSuccessCount()); + ArgumentCaptor captor = ArgumentCaptor.forClass(BfiInvoiceAssignBodyDTO.class); + verify(client).invoiceAssign(eq("token-1"), captor.capture()); + assertEquals("assign", captor.getValue().getOp()); + } + + @Test + void saveInvoiceApplyShouldValidateRequiredFields() { + BfiInvoiceApplySaveDTO noBillNo = new BfiInvoiceApplySaveDTO(); + assertThrows(BfiException.class, () -> service.saveInvoiceApply(noBillNo)); + + BfiInvoiceApplySaveDTO noItem = new BfiInvoiceApplySaveDTO(); + noItem.setBillno("KPSQ-1"); + noItem.setOrgidNumber("01.00"); + assertThrows(BfiException.class, () -> service.saveInvoiceApply(noItem)); + verifyNoInteractions(client); + } + + @Test + void saveInvoiceApplyShouldWrapData() { + mockAccessToken(); + when(client.invoiceApplySave(anyString(), any())).thenReturn(success(saveResult(1))); + BfiInvoiceApplySaveDTO param = new BfiInvoiceApplySaveDTO(); + param.setBillno("KPSQ-1"); + param.setOrgidNumber("01.00"); + param.setSimOriginalBillItem(List.of(new BfiInvoiceApplySaveEntryDTO())); + assertEquals("1", service.saveInvoiceApply(param).getSuccessCount()); + } + + @Test + void queryInvoiceApplyShouldReturnPage() { + mockAccessToken(); + BfiPageVO page = new BfiPageVO<>(); + when(client.invoiceApplyQuery(eq("token-1"), eq(1), eq(10), any(), any(), any(), any())) + .thenReturn(success(page)); + BfiInvoiceApplyQueryDTO param = new BfiInvoiceApplyQueryDTO(); + param.setPageNo(1); + param.setPageSize(10); + assertEquals(page, service.queryInvoiceApply(param)); + } + + @Test + void queryInvoiceApplyInvoiceShouldEncodeBase64Data() { + mockAccessToken(); + // 模拟接口返回 base64 编码的 data + String responseData = "{\"data\":[{\"billNo\":\"KPSQ-1\",\"invoiceList\":[]}],\"errorCode\":\"0\",\"success\":true}"; + String encodedData = Base64.getEncoder().encodeToString(responseData.getBytes(StandardCharsets.UTF_8)); + BfiOpenApiResultVO openApiResult = new BfiOpenApiResultVO<>(); + openApiResult.setSuccess(true); + openApiResult.setErrorCode("S0000"); + openApiResult.setData(encodedData); + when(client.invoiceApplyOpenApi(anyString(), any())).thenReturn(openApiResult); + BfiInvoiceApplyOpenApiQueryDTO param = new BfiInvoiceApplyOpenApiQueryDTO(); + param.setSerialNo("KPSQ-1,KPSQ-2"); + param.setSellerTaxpayerId("91450000MA5Q1G1M59"); + service.queryInvoiceApplyInvoice(param); + ArgumentCaptor captor = + ArgumentCaptor.forClass(org.springblade.thirdparty.bfi.pojo.dto.BfiInvoiceApplyOpenApiBodyDTO.class); + verify(client).invoiceApplyOpenApi(eq("token-1"), captor.capture()); + assertEquals("BILL.INVOICE.QUERY", captor.getValue().getInterfaceCode()); + assertEquals("test-client", captor.getValue().getBusinessSystemCode()); + String decoded = new String(Base64.getDecoder().decode(captor.getValue().getData()), StandardCharsets.UTF_8); + JSONObject json = JSONUtil.parseObj(decoded); + assertEquals("KPSQ-1,KPSQ-2", json.getStr("serialNo")); + assertEquals("91450000MA5Q1G1M59", json.getStr("sellerTaxpayerId")); + } + + @Test + void queryInvoiceApplyInvoiceShouldThrowWhenParamsBlank() { + assertThrows(BfiException.class, () -> service.queryInvoiceApplyInvoice(new BfiInvoiceApplyOpenApiQueryDTO())); + verifyNoInteractions(client); + } + + @Test + void saveReceivedInvoiceShouldValidateRequiredFields() { + BfiReceivedInvoiceSaveDTO noSrc = new BfiReceivedInvoiceSaveDTO(); + assertThrows(BfiException.class, () -> service.saveReceivedInvoice(noSrc)); + + BfiReceivedInvoiceSaveDTO noEntry = new BfiReceivedInvoiceSaveDTO(); + noEntry.setAwrpSrcSys("WCCK"); + noEntry.setAwrpSrcCode("INV-1"); + assertThrows(BfiException.class, () -> service.saveReceivedInvoice(noEntry)); + verifyNoInteractions(client); + } + + @Test + void saveReceivedInvoiceShouldWrapData() { + mockAccessToken(); + when(client.receivedInvoiceSave(anyString(), any())).thenReturn(success(saveResult(1))); + BfiReceivedInvoiceSaveDTO param = new BfiReceivedInvoiceSaveDTO(); + param.setAwrpSrcSys("WCCK"); + param.setAwrpSrcCode("INV-1"); + param.setEntry(List.of(new BfiReceivedInvoiceSaveEntryDTO())); + assertEquals("1", service.saveReceivedInvoice(param).getSuccessCount()); + } + + @Test + void queryReceivedInvoiceShouldValidateModifyTime() { + assertThrows(BfiException.class, () -> service.queryReceivedInvoice(new BfiReceivedInvoiceQueryDTO())); + verifyNoInteractions(client); + } + + @Test + void queryReceivedInvoiceShouldReturnPage() { + mockAccessToken(); + BfiPageVO page = new BfiPageVO<>(); + when(client.receivedInvoiceQuery(anyString(), any(BfiReceivedInvoiceQueryDTO.class))).thenReturn(success(page)); + BfiReceivedInvoiceQueryDTO param = new BfiReceivedInvoiceQueryDTO(); + param.setModifytimeS("2026-09-08 10:36:29"); + param.setModifytimeE("2026-09-08 10:39:36"); + assertEquals(page, service.queryReceivedInvoice(param)); + } + + @Test + void queryRecBillShouldValidateRequiredFields() { + assertThrows(BfiException.class, () -> service.queryRecBill(new BfiRecBillQueryDTO())); + + BfiRecBillQueryDTO noDate = new BfiRecBillQueryDTO(); + noDate.setPageNo(1); + noDate.setPageSize(10); + assertThrows(BfiException.class, () -> service.queryRecBill(noDate)); + verifyNoInteractions(client); + } + + @Test + void queryRecBillShouldReturnPage() { + mockAccessToken(); + BfiPageVO page = new BfiPageVO<>(); + when(client.recBillQuery(eq("token-1"), eq(1), eq(10), any(), any(), any(), any())) + .thenReturn(success(page)); + BfiRecBillQueryDTO param = new BfiRecBillQueryDTO(); + param.setPageNo(1); + param.setPageSize(10); + param.setBeginBizdate("2026-08-08"); + param.setEndBizdate("2026-09-08"); + assertEquals(page, service.queryRecBill(param)); + } + + @Test + void queryClaimAnnounceShouldValidateParams() { + BfiClaimAnnounceQueryDTO nullParam = null; + assertThrows(BfiException.class, () -> service.queryClaimAnnounce(nullParam)); + + BfiClaimAnnounceQueryDTO noTimeRange = new BfiClaimAnnounceQueryDTO(); + noTimeRange.setClaimusers("张三"); + noTimeRange.setPageNo(1); + noTimeRange.setPageSize(10); + assertThrows(BfiException.class, () -> service.queryClaimAnnounce(noTimeRange)); + } + + @Test + void queryClaimAnnounceShouldReturnPage() { + mockAccessToken(); + BfiPageVO page = new BfiPageVO<>(); + when(client.claimAnnounceQuery("token-1", "张三", null, "2026-01-01", "2026-06-30", 1, 10)).thenReturn(success(page)); + BfiClaimAnnounceQueryDTO param = new BfiClaimAnnounceQueryDTO(); + param.setClaimusers("张三"); + param.setModifytimeS("2026-01-01"); + param.setModifytimeE("2026-06-30"); + param.setPageNo(1); + param.setPageSize(10); + assertEquals(page, service.queryClaimAnnounce(param)); + } + + @Test + void pushClaimResultShouldValidateRequiredFields() { + assertThrows(BfiException.class, () -> service.pushClaimResult(null)); + + BfiClaimPushDTO noBillId = new BfiClaimPushDTO(); + assertThrows(BfiException.class, () -> service.pushClaimResult(noBillId)); + + BfiClaimPushDTO noEntry = new BfiClaimPushDTO(); + noEntry.setBillid(1L); + noEntry.setAwrpSrcSys("WCCK"); + noEntry.setAwrpSrcCode("RL-1"); + assertThrows(BfiException.class, () -> service.pushClaimResult(noEntry)); + verifyNoInteractions(client); + } + + @Test + void pushClaimResultShouldWrapData() { + mockAccessToken(); + when(client.claimBillPush(anyString(), any())).thenReturn(success(saveResult(1))); + BfiClaimPushDTO param = new BfiClaimPushDTO(); + param.setBillid(2398805822369971200L); + param.setAwrpSrcSys("WCCK"); + param.setAwrpSrcCode("RL-1"); + param.setEntryentity(List.of(new BfiClaimPushEntryDTO())); + assertEquals("1", service.pushClaimResult(param).getSuccessCount()); + } + + @Test + void queryInvoicePoolShouldValidateQueryParam() { + assertThrows(BfiException.class, () -> service.queryInvoicePool(new BfiInvoicePoolQueryDTO())); + BfiInvoicePoolQueryDTO param = new BfiInvoicePoolQueryDTO(); + param.setQueryParam(new BfiInvoicePoolQueryDataDTO()); + assertThrows(BfiException.class, () -> service.queryInvoicePool(param)); + verifyNoInteractions(client); + } + + @Test + void queryInvoicePoolShouldReturnRows() { + mockAccessToken(); + BfiInvoicePoolVO invoice = new BfiInvoicePoolVO(); + when(client.invoicePoolQuery(anyString(), any(BfiInvoicePoolQueryDTO.class))) + .thenReturn(success(List.of(invoice))); + BfiInvoicePoolQueryDataDTO data = new BfiInvoicePoolQueryDataDTO(); + data.setInvoiceNo("25532000000044538098"); + data.setPageNo(1); + data.setPageSize(500); + BfiInvoicePoolQueryDTO param = new BfiInvoicePoolQueryDTO(); + param.setQueryParam(data); + BfiPageVO page = service.queryInvoicePool(param); + assertEquals(1, page.getRows().size()); + assertEquals(1, page.getPageNo()); + assertEquals(500, page.getPageSize()); + } + + @Test + void downloadInvoiceShouldValidatePath() { + assertThrows(BfiException.class, () -> service.downloadInvoice(null)); + verifyNoInteractions(client); + } + + @Test + void downloadInvoiceShouldThrowWhenStatusNotOk() { + mockAccessToken(); + Response response = downloadResponse(500, "Internal Error"); + when(client.invoiceDownload(anyString(), any())).thenReturn(response); + assertThrows(BfiException.class, () -> service.downloadInvoice("234e244b2c803400")); + response.close(); + } + + @Test + void downloadInvoiceShouldReturnResponse() { + mockAccessToken(); + Response response = downloadResponse(200, "OK"); + when(client.invoiceDownload(anyString(), any())).thenReturn(response); + assertEquals(response, service.downloadInvoice("234e244b2c803400")); + response.close(); + } + + /** + * 构建下载响应 + */ + private Response downloadResponse(int status, String reason) { + Request request = Request.create(Request.HttpMethod.POST, "/ierp/kapi/v2/awrp/bosext/attachment/download", + Map.of(), null, StandardCharsets.UTF_8); + return Response.builder().status(status).reason(reason).request(request).build(); + } + + @Test + void commonCallShouldThrowWhenRemoteFailed() { + mockAccessToken(); + BfiResultVO> failed = new BfiResultVO<>(); + failed.setStatus(false); + failed.setErrorCode("400"); + failed.setMessage("操作失败"); + when(client.customerQuery(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())) + .thenReturn(failed); + BfiCustomerQueryDTO param = new BfiCustomerQueryDTO(); + param.setPageNo(1); + param.setPageSize(10); + BfiException exception = assertThrows(BfiException.class, () -> service.queryCustomer(param)); + assertTrue(exception.getMessage().contains("操作失败")); + } + + @Test + void getAccessTokenShouldNotCallRemoteWhenCached() { + when(bladeRedis.get("blade:third:bfi:token")).thenReturn("cached-token"); + service.getAccessToken(); + verify(client, never()).getToken(any()); + } +} 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 4f6027c0..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 @@ -31,7 +33,43 @@ public class OAConvertConstant { */ public static final Long ROOT_PARENT_ID = 0L; /** - * 同步人员时挂载的顶级组织名称 + * OA 同步挂载的本地顶级组织主键(集团) */ - public static final String ROOT_COMPANY_NAME = "桂物物流集团"; + public static final Long OA_SYNC_ROOT_DEPT_ID = 1L; + /** + * 历史集团顶级主键(迁移前为 id=135) + */ + public static final Long LEGACY_OA_SYNC_ROOT_DEPT_ID = 135L; + /** + * OA 同步挂载的本地顶级组织默认名称(集团) + */ + public static final String OA_SYNC_ROOT_DEPT_NAME = "广西现代物流集团有限公司"; + /** + * OA 同步挂载的本地顶级组织编码(集团) + */ + public static final String OA_SYNC_ROOT_DEPT_CODE = "GWWL"; + /** + * 承运商等业务使用的外部组织顶级名称(与集团顶级并存) + */ + public static final String EXTERNAL_ORG_NAME = "外部组织"; + /** + * 组织编码分段数字位数(不足补零) + */ + public static final int DEPT_CODE_SEGMENT_WIDTH = 3; + /** + * 历史人员同步创建的伪顶级编码,同步时需清理 + */ + public static final String LEGACY_ROOT_DEPT_CODE = "OACROOT"; + /** + * 历史集团顶级名称(迁移/清理旧名时使用) + */ + 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/blade-third-party-api/pom.xml b/blade-third-party-api/pom.xml index 5e8c4552..d0a0b444 100644 --- a/blade-third-party-api/pom.xml +++ b/blade-third-party-api/pom.xml @@ -19,6 +19,7 @@ blade-ocr-api blade-track-api blade-wechat-api + blade-bfi-api pom BladeX 第三方API集合 diff --git a/doc/docs/业务流程/2026-09-16-付款管理正式付款同步金蝶(财务应付单+付款申请下推)技术文档.md b/doc/docs/业务流程/2026-09-16-付款管理正式付款同步金蝶(财务应付单+付款申请下推)技术文档.md new file mode 100644 index 00000000..c0df77c1 --- /dev/null +++ b/doc/docs/业务流程/2026-09-16-付款管理正式付款同步金蝶(财务应付单+付款申请下推)技术文档.md @@ -0,0 +1,250 @@ +# 正式付款同步金蝶(星瀚)技术文档:财务应付单 + 付款申请下推 + +> 适用系统:TMS(BladeX 微服务平台,服务:blade-transport) +> 对接系统:金蝶云·星瀚(业财融合 BFI,经 iPaaS 网关) +> 关联文档:`doc/docs/TMS业财融合-集成流程图文字版与业务流程梳理.md`、`doc/docs/业财融合系统接口文档.xlsx`、`doc/docs/业务流程/付款申请同步金蝶(星瀚)技术文档.md`(A103-3 预付款路径) +> 最后更新:2026-09-18(字段映射与联调默认值按当前代码重新梳理) + +--- + +## 1. 背景与范围 + +正式结算单发起付款申请后,付款管理(`blade_payment_application`,`paymentType = settlement_payment`)生成一条记录;审批通过后勾选点"批量同步",需**先推送财务应付单(A102-2),再由应付下推付款申请(A103-1)**,两步完成后才视为同步成功。 + +- 触发入口:付款管理页面(前端 `tms-erp-web/src/views/payment/payment-application.vue`)"批量同步"按钮,权限码 `payment_application_sync` +- 对接接口: + - **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 直推付款申请单,见《付款申请同步金蝶(星瀚)技术文档》 +- 调用方式:前端 → TMS 后端转发 → 星瀚(OAuth2 凭证仅存后端,前端不持有) +- 落库结果:A103-1 返回的付款申请单 id 写入 `kingdee_bill_no`(既有字段);**A102-2 返回的应付单 id 不落库**,仅校验保存成功,后续如需可经 A102-4 按防重键(`awrp_src_sys` + `awrp_src_code`)查询(`BfiServiceImpl.queryApBill` 已封装),本次无表结构变更 + +--- + +## 2. 业务流程 + +### 2.1 整体链路(对应需求文档 4.3 步骤 4) + +``` +正式结算单发起付款申请 → 付款管理生成记录 → 审批通过(approved) + → 批量同步(本文档) + ① A102-2 财务应付单保存(防重键幂等) + ② A103-1 应付下推付款申请(分录关联①的 awrp_entry_code) + → 金蝶财务共享审批(黑盒) → 付款处理 → 付款完成 + → 后续(待建设):A103-2/A103-4 定时回写、A902 附件、B901 锁单 +``` + +### 2.2 批量同步时序 + +``` +前端 payment-application.vue blade-transport 后端 星瀚(BFI) + │ 勾选 approved 记录 │ │ + │ POST /payment-application/ │ │ + │ sync-kingdee-batch [id...] │ │ + │──────────────────────────────────────>│ 逐单循环(单条失败不中断批次) │ + │ │ FOR UPDATE 锁单 + approved 校验 │ + │ │ ① 组装 BfiApBillSaveDTO │ + │ │ POST ap_finapbill/save ──────────────>│ + │ │ <── result[]{id,...} ─────────────────│ + │ │ 仅校验保存成功(应付单id不落库) │ + │ │ ② 组装 BfiPayApplyPushDTO │ + │ │ entry.awrp_finapbill_entryid │ + │ │ = paymentNo + "-1" │ + │ │ POST ap_finapbill/push ──────────────>│ + │ │ <── result[]{id,...} ─────────────────│ + │ │ kingdeeBillNo = 付款申请单id │ + │ │ kingdeeStatus = synced │ + │ │ 下游台账联动(结算付款记录/预付台账) │ + │ <── R>(逐单结果)────────│ │ +``` + +### 2.3 前置条件与幂等约定 + +| 约定 | 说明 | +| --- | --- | +| 状态校验 | 仅 `approvalStatus = approved` 允许同步 | +| 幂等跳过 | `kingdeeStatus = synced/paying/paid/closed` 的单据直接返回已存单据号,不重复推送 | +| ①防重键 | 星瀚应付单侧 `awrp_src_code`(=付款申请号_结算明细单据号1_单据号2...,去重拼接)+ `awrp_src_sys`,②失败后重推时①走更新语义,不重复生成应付单 | +| ②防重键 | 付款申请单侧 `awrp_src_code`(=paymentNo)+ `awrp_src_sys`;分录行 `awrp_entry_code`(=paymentNo + "-序号")不得重复 | +| ②关联键 | `awrp_finapbill_entryid` = ①明细的 `awrp_entry_code`(=paymentNo + "-序号"),**不依赖①返回的内部 id** | +| 失败重推 | 任一步失败置 `kingdeeStatus = failed`,可重新勾选同步(①已成功的应付单按防重键走更新语义) | +| 前置校验 | 分录解析前校验:未关联正式结算明细、货物类型未维护档案编码、计量单位为空均报错;未关联合同、组织编码为空、结算币种为空时同步失败 | + +--- + +## 3. 接口定义 + +### 3.1 A102-2 财务应付单保存 + +- URL:`{thirdParty.bfi.baseUrl}/ierp/kapi/v2/awrp/ap/ap/ap_finapbill/save` +- Method:`POST`,请求体 `{"data": [ {主表字段, "detailentry":[明细]} ]}` +- 返回结构与 A103-3 一致:`data.result[].id` 为星瀚应付单内部主键,`billStatus=false` 时以 `errors` 抛出业务异常,`errorCode/message/status` 由 `BfiServiceImpl.commonCall` 统一校验。 + +### 3.2 A103-1 付款申请保存(应付下推) + +- URL:`{thirdParty.bfi.baseUrl}/ierp/kapi/v2/awrp/ap/ap/ap_finapbill/push` +- Method:`POST`,请求体 `{"data": [ {主表字段, "entry":[分录]} ]}` +- 返回 `result[].id` 为星瀚付款申请单内部主键(写入 `kingdee_bill_no`),`result[].number` 为星瀚单据编号(FKSQ-xxxx,暂不落库),`result[].type = Push`。 + +--- + +## 4. 字段映射关系 + +### 4.1 A102-2 主表(`BfiApBillSaveDTO` ← 付款申请实体 + 合同 + 常量) + +| 接口字段 | 含义 | 必填 | 来源 | 说明 | +| --- | --- | --- | --- | --- | +| billtypeid_number | 单据类型.编码 | 是 | 按映射字典转换:`KingdeeBfiConstant.BFI_AP_BILL_BILLTYPE_NAME`(默认"服务采购应付")经 `AP_BILL_BILLTYPE_MAP` 名称→编码 | 路由规则见 5.3;未维护名称报错 | +| org_number | 结算组织.编码 | 是 | **合同所属组织**:`contract.organizationId` → `dept.deptCode`(复用 `bfiOrgNumber`) | 缺失报错 | +| bizdate | 单据日期 | 是 | `applyDate`(空取当天) | 沿用 yyyyMMdd,**待确认是否需 yyyy-MM-dd** | +| asstacttype | 往来类型 | 是 | **按合同类别路由**(`bfiAsstactType`):`contractCategory = 客户合同` → `bd_customer`,否则 → `bd_supplier` | 与结算类型路由口径一致 | +| asstact_number | 往来户.编码 | 是 | **合同乙方**(`bfiAsstactNumber`):`contract.partyB` → 客商档案按全称/简称匹配取 `customerCode` | 乙方为空或客商档案未维护编码均报错,不回退原值 | +| purmode | 付款方式 | 是 | 常量 `BFI_AP_BILL_PUR_MODE_CASH` | `CASH` 现购(系统自动 100% 推付款) | +| currency_number | 结算币别.货币代码 | 是 | **合同结算币种** `contract.settlementCurrency`(复用 `bfiCurrency`);`RMB` 统一映射为 `CNY` | 缺失报错 | +| payorg_number | 付款组织.编码 | 是 | 同 org_number | 采购组织 purorg_number 暂不传 | +| payproperty_number | 款项性质.编码 | 是 | 常量 `BFI_AP_BILL_PAY_PROPERTY` | 暂按 `2001`,**待金蝶确认** | +| isincludetax / ispricetotal | 录入含税价 / 录入总价 | 否 | 固定 `true` / `true`;`isintax` 固定 `false` | 录入总价免单价换算 | +| awrp_src_sys | 外部系统编号 | 是 | 配置 `thirdParty.bfi.payApply.srcSys` | 应付单候选键 | +| awrp_src_code | 外部系统来源单据号 | 是 | **`paymentNo + "_" + 结算明细单据号1_单据号2...`**(单据号去重、保持行序,`String.join("_")`) | 应付单候选键 | +| awrp_src_type | 外部单据类型 | 是 | 常量 `BFI_AP_BILL_SRC_TYPE` | 暂按 `TMS财务应付单`,**待金蝶确认** | + +未传的可选字段(代码注释保留):exratetable_number/exratedate(汇率表/汇率日期)、asstactname(往来单位名称)、remark(备注)、awrp_src_bt(来源单据)、awrp_src_con_no(上游合同号)、settlementtype_number(结算方式)、taxrateid_number(税率)、purorg_number(采购组织)、duedate、bookdate、department_number、preentry(预付信息)等。 + +### 4.2 A102-2 明细(`BfiApBillSaveEntryDTO`,按货物编码+计量单位聚合,多行) + +分录数据由 `resolveBfiEntryGroups` 解析:**货物费用行**(`blade_formal_settlement_detail_fee`,每货物一条)优先,无费用行时兜底**结算明细行**;按货物编码+计量单位聚合(一行含多种货物类型时金额数量均分),已移除按申请付款金额分摊逻辑,**金额直接取结算金额**。 + +| 接口字段 | 含义 | 必填 | 来源 | 说明 | +| --- | --- | --- | --- | --- | +| material_number | 物料编码.编码 | 是 | **联调临时:常量 `BFI_AP_BILL_MATERIAL_NUMBER_TEST`(`SP0324`)**(调试块 `if(true)` 强制覆盖) | 真实来源:货物类型 → 货物类型档案 `cargoCode`,档案对齐后恢复 | +| measureunit_number | 计量单位.编码 | 是 | **联调临时:常量 `BFI_AP_BILL_MEASUREUNIT_NUMBER_TEST`(`TON`)**(同上) | 真实来源:快照运费计算单位 `quantityUnit`(档案暂无编码字段) | +| quantity | 明细.数量 | 是 | 组内 `transportQuantity` 合计 | ≤0 兜底 1 | +| e_pricetaxtotal | 明细.应付金额 | 是 | 组内 `settlementAmountTax`(结算金额含税)合计 | **必传**,缺失金蝶报"应付金额不能为空" | +| e_amount | 明细.金额(不含税) | 否 | 组内 `settlementAmountNoTax`(结算金额不含税)合计 | | +| project_number | 项目.项目编码 | 是 | **联调临时:常量 `BFI_AP_BILL_PROJECT_NUMBER_TEST`(`999`)** | 真实来源:`projectId` → `ProjectApply.projectCode`,无项目传 `999` | +| awrp_entry_code | 外部系统来源分录标识 | 是 | `paymentNo + "-" + 组序号` | 候选键;②下推时以 `awrp_finapbill_entryid` 关联此行 | + +未传:planentity 付款计划(不传由金蝶按明细自动汇总)、e_tax、taxrateid_number、e_remark、awrp_src_con_eno。 + +### 4.3 A103-1 主表(`BfiPayApplyPushDTO`) + +| 接口字段 | 含义 | 必填 | 来源 | 说明 | +| --- | --- | --- | --- | --- | +| awrp_src_code / awrp_src_sys | 来源单据号/外部系统编号 | 是 | `paymentNo` / 配置 `payApply.srcSys` | 付款申请单候选键 | +| applydate | 申请日期 | 是 | `applyDate`(空取当天) | 同 bizdate 格式 | +| applycause | 请款事由 | 否 | 复用 `bfiApplyCause` | 结算付款 → `结算付款申请` | +| payorg_number / purorg_number | 付款/采购组织.编码 | 否 | 同 A102-2 合同组织编码 | 传值更稳,不传金蝶按应付单自动带出 | +| exratetable_number / exratedate | 汇率表/汇率日期 | 否 | 配置复用 / 同 applydate | 不传金蝶自动带出 | +| awrp_textfield1 | 付款渠道 | 否 | 复用 `bfiPaymentMethodLabel`:`paymentMethod` 经字典 `pay_method` 翻译为标签 | | +| creator_name | 申请人 | 否 | `applicantName` | | + +### 4.4 A103-1 分录(`BfiPayApplyPushEntryDTO`,与应付单明细一一对应多行) + +| 接口字段 | 含义 | 必填 | 来源 | 说明 | +| --- | --- | --- | --- | --- | +| e_paymenttype_number | 付款类型.编码 | 是 | 常量 `201` | 正式付款 | +| awrp_finapbill_entryid | 结算单分录id | 是 | `paymentNo + "-" + 组序号` | **对应 A102-2 明细 awrp_entry_code** | +| awrp_entry_code | 对方唯一行 id | 是 | `paymentNo + "-" + 组序号` | 不得重复 | +| e_assacct | 往来账户 | 是 | `bankAccount` | 收款账号(页面"收款账户") | +| e_bebank_number | 往来银行.编码 | 是 | `bankName` | **必填编码,当前暂传银行名称,待银行档案编码映射**(与 A103-3 遗留项一致) | +| e_applyamount | 申请金额 | 是 | 组内 `settlementAmountTax`(结算金额含税) | 与对应应付单分录 `e_pricetaxtotal` 一致 | +| e_expaydate | 期望付款日 | 否 | 同 applydate | | +| project_number | 项目.项目编码 | 是 | **联调临时:常量 `BFI_AP_BILL_PROJECT_NUMBER_TEST`(`999`)**(真实取值 `bfiProjectNumber` 代码注释保留) | 无项目传 `999` | +| e_remark | 备注 | 否 | `remark` | | +| awrp_src_type | 外部单据类型 | 是 | 复用配置 `payApply.srcType` | | + +未传:e_duedate、e_settlementtype_number、e_costcenter_number、awrp_splb_number。 + +--- + +## 5. 常量与待确认清单 + +### 5.1 设计约定 + +A102-2 所需的金蝶侧固定档案编码**不进 Nacos 配置**,统一定义在常量类 `org.springblade.transport.constant.KingdeeBfiConstant`;金蝶侧确认取值后**直接改常量重新发包**即可。 + +### 5.2 常量清单(`org.springblade.transport.constant.KingdeeBfiConstant`) + +| 常量 | 当前值 | 用途 | 状态 | +| --- | --- | --- | --- | +| BFI_AP_BILL_BILLTYPE_NAME | `服务采购应付` | A102-2 单据类型默认名称,经 `AP_BILL_BILLTYPE_MAP` 转换为编码(`ApFin_service_BT_S`) | 待金蝶确认 | +| AP_BILL_BILLTYPE_MAP | 9 项映射 | 财务应付单类型字典(借贷项调整/费用采购应付/其他应付/产品委外应付/标准采购应付/采购费用应付/销售费用应付/服务采购应付/资产采购应付) | 全量字典 | +| AR_BILL_BILLTYPE_MAP | 5 项映射 | 财务应收单类型字典,当前付款链路未启用 | 全量字典 | +| PAY_APPLY_BILLTYPE_MAP | 2 项映射 | 付款申请单类型字典(采购付款申请/其他付款申请),使用规则待业务确认 | 全量字典 | +| BFI_SUPPLIER_ASSTACT_TYPE | `bd_supplier` | 往来类型-供应商 | 启用 | +| BFI_CUSTOMER_ASSTACT_TYPE | `bd_customer` | 往来类型-客户(合同类别为"客户合同"时使用) | 启用 | +| BFI_AP_BILL_PAY_PROPERTY | `2001` | A102-2 款项性质 | 待金蝶确认 | +| BFI_AP_BILL_PUR_MODE_CASH | `CASH` | A102-2 付款方式(现购,自动 100% 推付款) | 启用 | +| BFI_AP_BILL_PUR_MODE_CREDIT | `CREDIT` | A102-2 付款方式(赊购,金蝶侧可调金额) | 备用 | +| BFI_AP_BILL_MATERIAL_NUMBER_TEST | `SP0324` | **联调临时**:明细物料编码,调试块强制覆盖真实取值 | 联调后恢复档案取值 | +| BFI_AP_BILL_MEASUREUNIT_NUMBER_TEST | `TON` | **联调临时**:明细计量单位编码,同上 | 联调后恢复档案取值 | +| BFI_AP_BILL_PROJECT_NUMBER_TEST | `999` | **联调临时**:A102-2/A103-1 项目编码 | 档案对齐后恢复 `bfiProjectNumber` | +| BFI_AP_BILL_SRC_TYPE | `TMS财务应付单` | A102-2 外部单据类型 | 待金蝶确认 | + +### 5.3 单据类型路由与映射转换 + +| 正式结算单结算类型 | 对应金蝶单据 | 单据类型字典 | 当前状态 | +| --- | --- | --- | --- | +| 应付(payable) | 财务应付单(A102-2 `/ap_finapbill/save`) | `AP_BILL_BILLTYPE_MAP`(9 项) | **已启用**,默认"服务采购应付" | +| 应收(receivable) | 财务应收单(A302-2 `/ar_finarbill/save`) | `AR_BILL_BILLTYPE_MAP`(5 项) | 未启用,同步时拦截报错,推送规则待业务确认 | + +转换流程:同步时按 `settlementId` 查正式结算单 `settlementType` 路由;应付走 A102-2,billtype 由默认名称"服务采购应付"经映射字典转编码;查不到结算单(聚合多结算单场景)暂按应付处理。 + +### 5.4 其他待确认项(代码内以 TODO 注释标注) + +| 项 | 现状 | 待确认内容 | +| --- | --- | --- | +| 日期格式 | bizdate/applydate 沿用 A103-3 的 `yyyyMMdd` | 金蝶若校验 `yyyy-MM-dd` 需统一调整 | +| 结算方式/税率 | settlementtype_number、taxrateid_number 不传 | 金蝶是否强制、编码取值 | +| 预付抵扣 | preentry(y_billtype/y_billid/y_settleamt)不传 | 存在预付款抵扣时是否需传,本期不传、后续迭代 | +| 银行编码 | e_bebank_number 暂传银行名称 | 必填编码,待银行档案编码映射(与 A103-3 遗留项相同) | +| 物料/计量单位/项目编码 | 联调期临时传金蝶测试档案值(SP0324/TON/999,见 4.2/4.4 与 5.2) | 档案对齐后移除 A102-2 明细中的调试块并恢复真实取值 | +| 币种映射 | 合同结算币种 `RMB` 统一映射为 `CNY`(`bfiCurrency`) | 长期保留 | +| 付款申请单/收入确认单类型映射 | 字典已存 `PAY_APPLY_BILLTYPE_MAP`(2 项);收入确认单(ar_revcfmbill_*)字典未入库 | 付款申请单、收入确认单的映射使用规则待业务确认 | + +--- + +## 6. 数据库变更 + +**本次无表结构变更。** A103-1 返回的付款申请单 id 仍落既有字段 `blade_payment_application.kingdee_bill_no`;A102-2 返回的应付单 id 不落库(下推关联、幂等重推均不依赖该 id),后续 A102-4 勾稽、应付单附件上传、反审核删除等场景需要时,按防重键 `awrp_src_sys + awrp_src_code(=paymentNo)` 经 `queryApBill` 查询即可。前端列表也无新增列。 + +--- + +## 7. 异常处理与批量语义 + +| 场景 | 行为 | +| --- | --- | +| 单笔同步 `syncKingdee` | 校验失败/推送失败置 `failed` 并抛 `ServiceException`,前端提示原因(含金蝶 errors 明细) | +| ①成功②失败 | `kingdeeStatus = failed`;重推时①按防重键走更新语义,继续② | +| 批量同步 `syncKingdeeBatch` | 逐单循环,**单条失败不中断批次**;返回结果列表附 `付款申请[id]同步失败:原因` | +| 货物类型未维护档案编码 / 计量单位为空 | 分录解析(`collectEntryGroups`)时拦截,报错提示补充档案 | +| 合同乙方为空 / 客商档案未维护客商编码 | 往来户取值(`bfiAsstactNumber`)时拦截,报错提示合同号与乙方名称 | +| 金蝶接口级失败 | `BfiServiceImpl.commonCall` 抛 `BfiException`,同上处理 | +| 重复点击同步 | 已 `synced` 单据跳过;`failed`/`unsynced` 允许重推 | + +--- + +## 8. 遗留事项 + +1. **物料/计量单位/项目编码(联调临时)**:A102-2 明细当前经调试块固定传金蝶测试档案值(`SP0324`/`TON`,见 `pushBfiApBill` 内 `if(true)` 调试代码),项目编码 A102-2/A103-1 固定传 `999`;金蝶侧档案对齐后需**删除调试块**并恢复真实取值(货物类型档案编码 / 快照计量单位 / `bfiProjectNumber`)。 +2. **应付单 id 不落库**:A102-2 仅校验保存成功;后续 A102-4 勾稽、应付单附件、反审核删除等需要 id 的场景,按防重键经 `queryApBill` 二次查询。 +3. **银行编码映射**:e_bebank_number 暂传银行名称,待建立银行档案编码映射(与预付款路径遗留项相同)。 +4. **预付抵扣 preentry**:正式付款涉及预付款抵扣时是否传预付信息,本期不传、后续迭代。 +5. **付款完成模拟待拆除**:同步成功后仍保留 `paidAmount = 申请金额`、`mockPaymentRecords()` 模拟付款记录(单号前缀 `MOCK-PAY-`),真实流程应由 A103-2/A103-4 定时回写驱动,与预付款路径共用该遗留项。 +6. **正式结算单同步金蝶仍为 mock**:`FormalSettlementServiceImpl.syncKingdee` 当前仅生成 `K3AP+时间戳` 假单号,与本文档的应付单推送(A102-2)无关联,后续如需结算单直推应付单需另行设计。 +7. **单据编号 number**(AP/FKSQ-xxxx)暂未落库,如需展示可经 A102-4/A103-2 查询。 +8. **单据类型映射规则待确认**:付款申请单(采购/其他)、收入确认单(ar_revcfmbill_*)的类型使用规则待业务确认;应收结算单对应财务应收单(A302-2)的推送链路未启用,当前同步时拦截报错。 + +--- + +## 9. 涉及代码位置 + +| 内容 | 位置 | +| --- | --- | +| 同步入口/分支 | `blade-service/blade-transport/.../service/impl/PaymentApplicationServiceImpl.java`(syncKingdee / syncKingdeeBatch / pushBfiApBill / pushBfiPayApplyPush / pushBfiPayApply / requireBfiSaveSuccess / requireBfiBillId) | +| A102-2 常量 | `blade-service/blade-transport/.../constant/KingdeeBfiConstant.java` | +| Controller | `blade-service/blade-transport/.../controller/PaymentApplicationController.java`(/sync-kingdee、/sync-kingdee-batch,本次无改动) | +| BFI 客户端 | `blade-third-party-api/blade-bfi-api/.../feign/IBfiClient.java`(AP_BILL_SAVE_URL / PAY_APPLY_PUSH_URL) | +| BFI 服务封装 | `blade-third-party-api/blade-bfi-api/.../service/impl/BfiServiceImpl.java`(saveApBill / pushPayApply,本次无改动) | +| 请求/返回模型 | `blade-bfi-api/.../pojo/dto/BfiApBillSaveDTO.java`、`BfiApBillSaveEntryDTO.java`、`BfiPayApplyPushDTO.java`、`BfiPayApplyPushEntryDTO.java` | +| 前端页面 | `tms-erp-web/src/views/payment/payment-application.vue`(无逻辑改动);`src/option/payment/paymentApplication.js`(无改动) | diff --git a/doc/docs/业务流程/2026-09-17-BFI分页查询通用翻页工具(BfiUtil-queryAllPages)技术文档.md b/doc/docs/业务流程/2026-09-17-BFI分页查询通用翻页工具(BfiUtil-queryAllPages)技术文档.md new file mode 100644 index 00000000..485b2e77 --- /dev/null +++ b/doc/docs/业务流程/2026-09-17-BFI分页查询通用翻页工具(BfiUtil-queryAllPages)技术文档.md @@ -0,0 +1,101 @@ +# BFI 分页查询通用翻页工具(BfiUtil.queryAllPages)技术文档 + +> 适用系统:TMS(BladeX 微服务平台,模块:`blade-third-party-api/blade-bfi-api`) +> 组件类型:通用工具类(非业务流程),服务业财融合全部 `BfiPageVO` 分页查询接口 +> 关联文档:`2026-09-17-付款管理金蝶付款结果定时回写(A103-2付款申请单查询+A103-4付款处理查询)技术文档.md` +> 最后更新:2026-09-17 + +--- + +## 1. 背景与范围 + +`IBfiService` 中共有 9 个查询接口返回 `BfiPageVO` 分页结构,各接口的分页翻页逻辑完全一致(页码递增、空页/末页终止、防死循环保护)。原实现在 `PaymentApplicationServiceImpl` 中以私有方法 `queryPage` 承载,无法被其他查询场景复用,现抽取为 `BfiUtil.queryAllPages` 通用工具。 + +设计目标: + +- **一处实现,全部查询复用**:付款结果回写(A103-2/A103-4)之外,后续 A102-4 应付勾稽、A302-4 应收核对、A303-4 开票结果回写、A801-3/A301-1/A301-2 收票收款认领等场景接入时零成本复用 +- **调用方只关心"取第 N 页"**:翻页推进、终止判断、全量汇总由工具类负责 +- **防死循环**:金蝶侧 `lastPage` 异常(恒为 false)时按最大页数保护终止 + +## 2. 适用接口清单 + +| 接口编号 | 名称 | `IBfiService` 方法 | 返回结构 | +| --- | --- | --- | --- | +| A103-2 | 付款申请单查询 | `queryPayApply` | `BfiPageVO` | +| A103-4 | 付款处理查询 | `queryPayBill` | `BfiPageVO` | +| A102-4 | 财务应付单查询 | `queryApBill` | `BfiPageVO` | +| A302-4 | 财务应收单查询 | `queryArBill` | `BfiPageVO` | +| A303-4 | 开票申请单查询 | `queryInvoiceApply` | `BfiPageVO` | +| A902-1 | 客户查询 | `queryCustomer` | `BfiPageVO` | +| A801-3 | 收票单查询 | `queryReceivedInvoice` | `BfiPageVO` | +| A301-1 | 收款单查询 | `queryRecBill` | `BfiPageVO` | +| A301-2 | 收款认领公告查询 | `queryClaimAnnounce` | `BfiPageVO` | + +不适用:`queryInvoicePool`(A801-1,rim 契约限制不能全量查询,已改为 pageNo/pageSize 单页透传,由 `BfiServiceImpl` 直接单次调用)、`queryExchangeRate`(A801-4,单页调用由 `BfiServiceImpl` 内部处理)。 + +## 3. API 定义 + +```java +// 默认最大页数保护(50 页) +public static List queryAllPages(IntFunction> pageFetcher) + +// 自定义最大页数保护 +public static List queryAllPages(IntFunction> pageFetcher, int maxPages) +``` + +| 参数 | 说明 | +| --- | --- | +| `pageFetcher` | 页码 → 分页结果函数。lambda 内负责按传入页码设置请求参数(页码必设,页大小按各接口限制设置)并调用 `IBfiService` 对应查询方法 | +| `maxPages` | 最大页数保护,防止金蝶侧 `lastPage` 异常导致死循环;默认 `BfiUtil.DEFAULT_MAX_PAGES = 50` | +| 返回值 | 全量数据行汇总列表(按页序追加);无数据时返回空列表 | + +## 4. 翻页终止条件(按判断顺序) + +| 序 | 条件 | 说明 | +| --- | --- | --- | +| 1 | 返回页为 null 或数据行为空 | 本页无数据即止 | +| 2 | `lastPage = true` | 金蝶标识已最后一页 | +| 3 | `rows.size() < 响应体 pageSize` | 末页数据不满一页,无需继续;**pageSize 取响应体返回值**(`BfiPageVO.pageSize`),调用方无需额外传入,响应体未返回 pageSize 时自动退化为依赖条件 2 与最大页数保护 | +| 4 | 页码超过 `maxPages` | 防御性终止,异常场景兜底 | + +## 5. 用法示例 + +```java +// A103-2 付款申请单查询:来源单据号批量查询 +private List queryPayApplyPages(List awrpSrcCodes) { + BfiPayApplyQueryDTO param = new BfiPayApplyQueryDTO(); + param.setAwrpSrcCode(awrpSrcCodes); + param.setAwrpSrcSys(bfiPayApplySrcSys); + return BfiUtil.queryAllPages(pageNo -> { + param.setPageNo(pageNo); + param.setPageSize(PAGE_SIZE); + return bfiService.queryPayApply(param); + }); +} +``` + +lambda 内通过闭包复用同一参数对象,仅需覆盖 `pageNo`;请求参数的各接口约束(如 A103-4 付款组织编码必填、时间范围不超过 1 个月)由调用方在构造参数时保证。 + +## 6. 设计说明 + +| 决策 | 说明 | +| --- | --- | +| 抽取为 `IntFunction>` 而非传入 DTO | 各查询接口的入参 DTO、Feign 方法签名各不相同,仅"按页码取结果"语义一致;函数式入参对调用方约束最小,DTO 字段设置留在调用方(编译期类型安全) | +| 不再需要 `rowsGetter` 参数 | 全部查询统一返回 `BfiPageVO`,`getRows` 内聚到工具类;早期版本传入 `BfiPageVO::getRows` 属冗余 | +| pageSize 判满取响应体 | 早期版本要求调用方传入请求 pageSize,两处易失配;响应体 `pageSize` 为金蝶实际执行值,更可靠 | +| 空列表而非 null | 无数据返回空列表,调用方无需判空 | + +## 7. 迁移记录 + +| 变更 | 内容 | +| --- | --- | +| 新增 | `BfiUtil.queryAllPages` 两个重载 + `DEFAULT_MAX_PAGES` 常量 | +| 删除 | `PaymentApplicationServiceImpl` 私有方法 `queryPage` 及 `PAY_RESULT_MAX_PAGES` 常量 | +| 替换 | 付款结果回写 `queryPayApplyPages` / `queryPayBillPages` 改调 `BfiUtil.queryAllPages` | + +## 8. 涉及代码位置 + +| 内容 | 位置 | +| --- | --- | +| 工具类 | `blade-third-party-api/blade-bfi-api/src/main/java/org/springblade/thirdparty/bfi/util/BfiUtil.java` | +| 使用方 | `blade-service/blade-transport/.../service/impl/PaymentApplicationServiceImpl.java`(付款结果回写)、`KingdeeApBillSyncSupport.java`(应付单同步)、`CustomerArchiveServiceImpl.java`(客户档案同步) | diff --git a/doc/docs/业务流程/2026-09-17-付款管理金蝶付款结果定时回写(A103-2付款申请单查询+A103-4付款处理查询)技术文档.md b/doc/docs/业务流程/2026-09-17-付款管理金蝶付款结果定时回写(A103-2付款申请单查询+A103-4付款处理查询)技术文档.md new file mode 100644 index 00000000..46d7f4d1 --- /dev/null +++ b/doc/docs/业务流程/2026-09-17-付款管理金蝶付款结果定时回写(A103-2付款申请单查询+A103-4付款处理查询)技术文档.md @@ -0,0 +1,167 @@ +# 付款管理金蝶付款结果定时回写(A103-2 付款申请单查询 + A103-4 付款处理查询)技术文档 + +> 适用系统: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` +> 最后更新:2026-09-17 + +--- + +## 1. 背景与范围 + +付款申请推送金蝶(A103-1/A103-3)成功后,金蝶侧依次完成:财务共享审批(黑盒,机器人处理)→ 付款处理 → 付款完成。原实现将"付款完成"整段模拟(`paidAmount = 申请金额`、`MOCK-PAY-` 前缀付款记录、履约付款数据直接置 `paid`),本文档建设真实回写链路并停用模拟: + +- 触发入口: + - **定时任务**:`PaymentKingdeeResultTask`,`@Scheduled` 默认每 10 分钟(`thirdParty.bfi.payResult.cron`),开关 `thirdParty.bfi.payResult.syncEnabled` + - **手动触发**:付款管理页面"同步付款结果"按钮(权限码复用 `payment_application_sync`)→ `POST /payment-application/sync-kingdee-result` +- 对接接口: + - **A103-2 付款申请单查询** `POST /ierp/kapi/v2/awrp/ap/ap_payapply/query`:按 `awrp_src_code`(=paymentNo)批量查询,回填金蝶单据编号(billno)、单据状态、付款状态、已付金额 + - **A103-4 付款处理查询** `POST /ierp/kapi/v2/awrp/cas/cas_paybill/query`:按付款人(公司)编码 + 业务日期窗口查询,回填付款记录(付款单号、付款日期、付款金额、整体报文留档) +- 联动更新:付款完成(paystatus=Alreadypay)时复用既有逻辑联动履约付款数据(预结算预付记录 `blade_pre_settlement_advance`、正式结算付款记录 `blade_formal_settlement_payment`) + +## 2. 业务流程 + +### 2.1 集成链路(对应方案文档 4.3 节步骤 11) + +``` +付款申请已推送金蝶(kingdeeStatus = synced) + → 定时任务(每10分钟) + → A103-2 付款申请单查询(awrp_src_code 批量,50单/批) + 回填 kingdee_bill_id / kingdee_bill_no(billno)/ kingdeeStatus / paid_amount + → A103-4 付款处理查询(付款中/已付款单据,按付款组织分组 + 最近1个月窗口) + 回填付款记录 blade_payment_application_record(并清理历史 MOCK-PAY- 模拟记录) + → 付款完成(Alreadypay):联动履约付款数据(预结算预付、正式结算付款记录) + → (后续环节)付款附件推送 A902、合同系统履约同步 C106-2,待建设 +``` + +### 2.2 定时回写时序 + +``` +PaymentKingdeeResultTask PaymentApplicationServiceImpl 星瀚(BFI) + │ @Scheduled 每10分钟 │ │ + │ syncKingdeePaymentResult() ──────>│ │ + │ │ ①查本地 approved + synced/paying │ + │ │ ②awrp_src_code 分批(50) ─────────────────>│ A103-2 + │ │ <── billno/billstatus/paystatus/entry ────│ + │ │ 逐单独立事务:回填单号/状态/已付金额 │ + │ │ Alreadypay → 联动履约付款数据 │ + │ │ ③付款组织分组 + 最近1个月窗口 ──────────────>│ A103-4 + │ │ <── 付款处理单(sourcebillid/sourcebillnumber)│ + │ │ 勾稽匹配 → 回填付款记录(幂等) │ + │ <── 执行摘要日志 ──────────────────│ │ +``` + +### 2.3 查询范围与幂等约定 + +| 约定 | 说明 | +| --- | --- | +| A103-2 查询范围 | `approvalStatus = approved` 且 `kingdeeStatus IN (synced, paying)`;`paid/closed` 为终态不再轮询 | +| A103-2 防重键 | `awrp_src_code`(=paymentNo)+ `awrp_src_sys`(=WLTMS)批量查询,50 单/批,自动翻页(页大小 200,最大 50 页保护) | +| 同号多单 | 同一 `awrp_src_code` 返回多条时取 `modifytime` 最新的一条 | +| A103-4 查询范围 | 付款中/已付款单据(本轮 A103-2 查到 `paystatus ≠ Unpaid` 的)+ 已付款但尚无真实付款记录的单据(补偿轮询) | +| A103-4 入参约束 | 付款人(公司)编码必填(优先取 A103-2 返回 `payorg_number`,兜底按合同所属组织解析);业务日期窗口取最近 1 个月(接口限制不超过 1 个月) | +| A103-4 勾稽键 | `sourcebillid`(=付款申请单星瀚id `kingdee_bill_id`)优先,`sourcebillnumber`(=付款申请单编号 `kingdee_bill_no`)兜底 | +| 付款记录幂等 | 按 `payment_application_id + payment_no`(=付款处理单号)判重,存在则更新;回填时清理该申请单下 `MOCK-PAY-` 前缀的历史模拟记录 | +| 逐单隔离 | 每笔申请通过 `SpringUtil` 自代理走独立事务(`applyPayApplyResult` / `applyPayBillRecord`),单条失败不中断批次,异常记录日志后继续 | + +### 2.4 状态映射(kingdeeStatus 扩展) + +| A103-2 返回 | 本地 kingdeeStatus | 展示名 | 是否终态 | +| --- | --- | --- | --- | +| `paystatus = Alreadypay` | `paid` | 已付款 | 是(触发履约联动) | +| `billstatus = D`(已关闭)且未付款 | `closed` | 已关闭 | 是 | +| `paystatus = Inpayment` | `paying` | 付款中 | 否(继续轮询) | +| `billstatus = A暂存/B已提交/C已审核` + `paystatus = Unpaid` | `synced` | 已生成 | 否(继续轮询) | + +既有状态保持不变:`unsynced` 未生成、`failed` 生成失败(可重推)。 + +## 3. 字段映射关系 + +### 3.1 A103-2 回写(`BfiPayApplyVO` → `PaymentApplication`) + +| 星瀚字段 | 含义 | 落库字段 | 说明 | +| --- | --- | --- | --- | +| id | 星瀚单据内部主键 | `kingdee_bill_id` | A902 附件上传等后续环节使用 | +| billno | 单据编号(FKSQ-xxxx) | `kingdee_bill_no` | 展示用金蝶单据号;A103-4 勾稽键之一 | +| billstatus | 单据状态 A/B/C/D | `kingdee_status` | 见 2.4 状态映射 | +| paystatus | 付款状态 | `kingdee_status` | 见 2.4 状态映射 | +| entry[].e_paidamt | 分录已付金额(汇总) | `paid_amount` | 分录缺失且已付款时兜底取申请金额 | +| payorg_number | 付款组织编码 | —(内存) | A103-4 付款人(公司)编码入参 | + +### 3.2 A103-4 回填(`BfiPayBillVO` → `PaymentApplicationRecord`) + +| 星瀚字段 | 含义 | 落库字段 | 说明 | +| --- | --- | --- | --- | +| billno | 付款处理单号 | `payment_no` / `kingdee_bill_no` | 幂等判重键 | +| paydate | 付款日期 | `paid_date` | 取前 10 位解析 yyyy-MM-dd,解析失败置空并告警 | +| actpayamt | 付款金额 | `paid_amount` | 2 位小数 | +| 整体报文 | 付款处理单全量 JSON | `voucher_json` | 凭证号、银行流水、电子回单等信息留档追溯 | + +## 4. 付款完成联动(履约付款数据) + +付款状态变为 `paid` 时复用既有逻辑(与原模拟阶段一致,数据源改为真实回写值): + +| 联动项 | 逻辑 | 位置 | +| --- | --- | --- | +| 预结算预付记录 | `syncPreSettlementAdvance`:`paid_amount` = 申请金额、`bill_status = paid`、`kingdee_advance_no` = 金蝶单据号,刷新预结算单预付汇总与正式结算来源预付统计 | PaymentApplicationServiceImpl | +| 正式结算付款记录 | `syncFormalSettlementPayments`:按申请单-结算单关联分摊已付金额,创建/更新 `blade_formal_settlement_payment`(`bill_status = paid`、金蝶单据号),刷新结算单付款汇总 | 同上 | + +部分付款(`paying`)仅更新付款申请 `paid_amount` 与付款记录,**不**触发履约联动(待付款完成一次性联动)。 + +## 5. 模拟逻辑停用说明 + +- `mockPaymentRecords()`(`MOCK-PAY-` 付款记录模拟)与 `syncKingdee` 内的付款完成模拟代码段**保留方法体、注释调用处**并备注原因:联调环境无金蝶付款数据时可恢复注释模拟使用。 +- `syncKingdee` 现仅落 `kingdee_bill_id` + `kingdee_status = synced`;`kingdee_bill_no`、`paid_amount`、付款记录、履约付款数据全部由定时回写驱动。 +- 存量数据兼容: + - 已 `synced` 单据(`kingdee_bill_no` 存的是旧语义星瀚id)由任务首轮 A103-2 按 paymentNo 查询自动修正为 billno; + - 手动重推幂等返回兼容旧数据(`kingdee_bill_id` 为空时回退返回 `kingdee_bill_no`)。 + +## 6. 数据库变更 + +| 变更 | 内容 | 脚本 | +| --- | --- | --- | +| 新增列 | `blade_payment_application.kingdee_bill_id` varchar(32):金蝶单据id(星瀚内部主键) | `doc/sql/transport/blade_payment_application_kingdee_bill_id_20260917.sql`(幂等可重复执行) | +| 语义调整 | `kingdee_bill_no`:星瀚id → **金蝶单据编号 billno**(由回写任务回填) | 无 DDL | +| 建表模板 | `blade_payment_application_20260821.sql` 已同步包含新列 | 新装环境直接执行 | + +## 7. 配置项(Nacos:`thirdParty.bfi.payResult.*`) + +模板见 `doc/nacos/blade-dev.yaml`、`blade-prod.yaml`、`third-party-api.yaml`: + +| 配置键 | 环境变量 | 默认值 | 说明 | +| --- | --- | --- | --- | +| thirdParty.bfi.payResult.syncEnabled | BFI_PAY_RESULT_SYNC_ENABLED | true | 回写任务开关 | +| thirdParty.bfi.payResult.cron | BFI_PAY_RESULT_CRON | 0 */10 * * * ? | 调度表达式(默认每10分钟) | + +调度启用:`TransportApplication` 增加 `@EnableScheduling`。 + +## 8. 异常处理 + +| 场景 | 行为 | +| --- | --- | +| A103-2/A103-4 接口级失败(status=false / errorCode≠0) | `BfiServiceImpl.commonCall` 抛 `BfiException`,当前批次终止,下轮任务自动重试 | +| 单笔回写异常 | 记录 error 日志(含 paymentNo 与堆栈),计数后继续下一单 | +| A103-4 付款组织编码获取失败(无 A103-2 数据且合同缺失/组织编码为空) | warn 日志跳过该单,下轮重试 | +| 付款日期解析失败 | warn 日志,`paid_date` 置空,其余字段正常回填 | +| 手动触发与定时任务并发 | 同单以 `updateById` 主键更新兜底,最终一致;付款记录按单号判重不重复插入 | + +## 9. 涉及代码位置 + +| 内容 | 位置 | +| --- | --- | +| 定时任务入口 | `blade-service/blade-transport/.../task/PaymentKingdeeResultTask.java` | +| 回写编排 | `blade-service/blade-transport/.../service/impl/PaymentApplicationServiceImpl.java`(syncKingdeePaymentResult :409 / applyPayApplyResult :512 / applyPayBillRecord :538 / queryPayApplyPages / queryPayBillPages / kingdeeResultStatus / matchPayBill) | +| 手动触发接口 | `blade-service/blade-transport/.../controller/PaymentApplicationController.java`(POST /sync-kingdee-result) | +| 状态常量 | `blade-service/blade-transport/.../constant/KingdeeBfiConstant.java`(KINGDEE_STATUS_* / BFI_PAY_STATUS_* / BFI_BILL_STATUS_CLOSED) | +| 实体/翻译 | `blade-transport-api/.../pojo/entity/PaymentApplication.java`(kingdeeBillId);`transport/wrapper/PaymentApplicationWrapper.java`(状态翻译) | +| BFI 客户端 | `blade-third-party-api/blade-bfi-api/.../service/IBfiService.java`(queryPayApply / queryPayBill,本次无改动);分页通用翻页 `util/BfiUtil.java`(queryAllPages,A102-4/A302-4/A303-4 等分页查询可复用) | +| 前端 | `tms-erp-web/src/views/payment/payment-application.vue`(同步付款结果按钮);`src/api/payment/paymentApplication.js`(syncKingdeeResult / kingdeeStatusOptions) | +| SQL | `doc/sql/transport/blade_payment_application_kingdee_bill_id_20260917.sql`;建表模板 `blade_payment_application_20260821.sql` | +| Nacos 配置模板 | `doc/nacos/blade-dev.yaml`、`blade-prod.yaml`、`third-party-api.yaml`(thirdParty.bfi.payResult.*) | + +## 10. 遗留事项与联调确认 + +1. **A103-4 勾稽键语义待联调确认**:以 `sourcebillid = 付款申请单星瀚id`、`sourcebillnumber = 付款申请单编号(billno)` 假设实现,联调时需用真实付款处理单验证;若金蝶侧源单指向不同,仅需调整 `matchPayBill` 匹配逻辑。 +2. **付款日期格式**:`paydate` 按前 10 位 yyyy-MM-dd 兼容解析,金蝶若返回纯时间戳需调整 `parseKingdeeDate`。 +3. **A103-2 分页翻页**:`awrp_src_code` 批量查询理论单页返回(批次 50 < 页大小 200),翻页保护仅防御异常场景。 +4. **后续环节待建设**:付款附件推送 A902(凭 `kingdee_bill_id`)、合同系统履约同步 C106-2、付款申请反审核异常回收(B901 解锁)。 diff --git a/doc/docs/业务流程/2026-09-17-客商档案同步金蝶(A902-1查询回填+A902-2审批推送)技术文档.md b/doc/docs/业务流程/2026-09-17-客商档案同步金蝶(A902-1查询回填+A902-2审批推送)技术文档.md new file mode 100644 index 00000000..1916ccdb --- /dev/null +++ b/doc/docs/业务流程/2026-09-17-客商档案同步金蝶(A902-1查询回填+A902-2审批推送)技术文档.md @@ -0,0 +1,307 @@ +# 客商档案同步金蝶(星瀚)技术文档:A902-1 查询回填 + A902-2 审批推送 + +> 适用系统:TMS(BladeX 微服务平台,服务:blade-transport)、前端 Saber3(页面:客商档案) +> 对接系统:金蝶云·星瀚(业财融合 BFI,经 iPaaS 网关) +> 关联文档:`doc/docs/TMS业财融合-集成流程图文字版与业务流程梳理.md`(图 01 客户新增与查询)、`doc/docs/TMS业财融合方案.docx` +> 最后更新:2026-09-17 + +--- + +## 1. 背景与范围 + +按业财融合「SC-01 客商主数据协同」流程,TMS 新增客商时须先查询金蝶是否已有该客商(A902-1),已有则回填金蝶维护的工商信息;TMS 审批通过后,将金蝶不存在的客商推送至金蝶完成创建(A902-2),金蝶校验工商信息后由机器人自动审核,业务系统方可使用该客商。 + +**现状结论**:现有客商新增交互(前端 `tms-erp-web/src/views/vehicle/customer-archive.vue`、后端 `CustomerArchiveServiceImpl` 的 `submit → submitApproval → approve/reject` 链路)**均无金蝶调用**;blade-transport 仅暴露汇率查询 `BfiExchangeRateController`。底层能力已就绪:`blade-bfi-api` 的 `IBfiService.queryCustomer`(A902-1)、`saveCustomer`(A902-2)及配套 DTO/VO/Feign。 + +**本期范围**: + +- 新增客商时按统一社会信用代码查询金蝶并自动回填(A902-1)。 +- 客商准入审批通过后自动推送金蝶(A902-2),失败可重推。 +- 金蝶已存在(已审核)的客商:回填建档,审批通过后**跳过推送**。 + +**不在本期范围**: + +- 客商更新(工商/个人信息变更走人工流程,非接口)。 +- 客商数据定时同步子流程(A902-1 / A901-1 定时任务)。 +- 合同合规系统相对方同步。 + +**关键约定**(已与业务确认): + +| 约定 | 决策 | +| --- | --- | +| A902-1 触发时机 | 统一社会信用代码输入满 18 位且通过本地格式校验后自动查询(A902-1 按信用代码查询,名称/法人为表单必填项但不参与查询) | +| A902-2 触发时机 | 审批通过(`approve`)后自动推送,推送失败不影响审批结果,支持列表重推 | +| 金蝶已存在(已审核) | 回填建档,审批通过后跳过 A902-2 推送 | + +--- + +## 2. 业务流程 + +### 2.1 业财融合整体链路(SC-01 客商主数据协同) + +``` +TMS 新增客商,录入统一社会信用代码 + → A902-1 客户查询(本文档 3.1) + ├─ 金蝶已审核(C):提示并回填金蝶工商信息(工商信息只允许金蝶维护,TMS 锁定不可改) + ├─ 金蝶暂存/已提交(A/B):提示"金蝶审核中,请稍后重新添加",流程终止 + └─ 金蝶不存在:正常填写(现有营业执照 OCR 逻辑不变) + → 填写客商业务信息 → 提交客商准入审批(TMS 内部审批,不调金蝶) + → 审批通过 → A902-2 客商保存推送金蝶(本文档 3.2,金蝶机器人自动审核) + → 审批通过前 A902-1 复查到金蝶已审核的 → 跳过推送,仅本地建档 + → 金蝶客户新增成功后自动下推供应商(金蝶侧行为,TMS 无感知) +``` + +### 2.2 客商档案金蝶协同状态(`kingdeeStatus`) + +| 值 | 含义 | 设置时机 | 后续动作 | +| --- | --- | --- | --- | +| `none` | 未推送 | 新增客商默认值 | 审批通过后参与推送 | +| `exist` | 金蝶已有档案 | A902-1 查到状态=已审核(C) | 审批通过后跳过推送(终态) | +| `synced` | 已推送成功 | A902-2 返回成功,回写金蝶客户编码 | 金蝶受理(status=B),A902-1 复查 status=C 已审核才是最终成功;定时任务每轮复查审核状态,审核中不重推(避免金蝶侧重复),重推时幂等跳过 | +| `failed` | 推送失败 / 金蝶审核中 | A902-2 失败,或复查到 A/B 状态 | 列表「同步金蝶」按钮重推 | + +### 2.3 前端查询回填时序(新增态) + +``` +customer-archive.vue blade-transport 星瀚(BFI) + │ 统一社会信用代码输入满18位 │ │ + │ 且通过格式校验(防抖500ms) │ │ + │ GET /bfi/customer/query-by-credit-code│ │ + │──────────────────────────────────────>│ A902-1:pageNo=1,pageSize=1, │ + │ │ societycreditcode=[code] ───────>│ + │ │ <── rows[](含status/工商信息) ──│ + │ <── R ────────────│ │ + │ status=C:提示"金蝶已创建该客户, │ │ + │ 将获取金蝶的工商信息",回填并锁定 │ │ + │ status=A/B:提示"金蝶审核中,请稍后重新添加",阻断保存 │ + │ 不存在:正常填写 │ │ +``` + +### 2.4 审批通过后推送时序 + +``` +CustomerArchiveController CustomerArchiveServiceImpl 星瀚(BFI) + │ POST /customer-archive/approve │ │ + │──────────────────────────────────────>│ approve() 落库审批通过 │ + │ <── R 成功 ────────────────────────────│ │ + │ POST /customer-archive/sync-kingdee │(审核通过后前端自动触发;失败亦可为 │ + │──────────────────────────────────────>│ 人工在列表重推) │ + │ │ 1. 校验 approvalStatus=approved │ + │ │ 2. kingdeeStatus∈{synced,exist}→幂等返回 │ + │ │ 3. A902-1 复查: │ + │ │ 已审核(C)→置exist,跳过推送 │ + │ │ 暂存/已提交(A/B)→置failed(金蝶审核中) │ + │ │ 4. 不存在→组装DTO推 A902-2 ─────────────>│ + │ │ <── result[]{billStatus,id/number} ─────│ + │ │ 5. 成功→kingdeeNumber+synced │ + │ │ 失败→failed+失败原因 │ + │ <── R<推送结果> ───────────────────────│ │ +``` + +> 实施说明:推送由后端 `/customer-archive/approve` 端点成功后**内部触发**(try-catch,推送失败不影响审批结果);`/sync-kingdee` 端点仅作为 `failed` 状态的人工重推入口,前端无需在审批成功后单独调用。 + +### 2.5 前置条件与幂等约定 + +| 约定 | 说明 | +| --- | --- | +| 推送前置状态 | 仅 `approvalStatus = approved` 允许推送金蝶 | +| 幂等跳过 | `kingdeeStatus ∈ {synced, exist}` 直接返回,不重复推送 | +| 防重键 | 星瀚侧以统一社会信用代码(`societycreditcode`)为候选键;TMS 侧 `validateUnique` 已保证信用代码本地唯一 | +| 失败重推 | `kingdeeStatus = failed` 时列表「同步金蝶」按钮可重推,权限码 `customer_archive_sync` | +| 推送失败不回滚审批 | 审核通过落库成功后推送;推送环节异常只置 `failed`,不影响审批结果 | +| 工商信息锁定 | 来源金蝶回填的工商字段 TMS 不允许变更,与「工商信息只在金蝶维护」规则一致 | + +--- + +## 3. 接口定义 + +### 3.1 A902-1 客户查询 + +- URL:`GET {thirdParty.bfi.baseUrl}/ierp/kapi/v2/awrp/basedata/bd_customer/query`(可用 `thirdParty.bfi.customerQueryUrl` 覆盖) +- 鉴权:`access_token` 由 `BfiServiceImpl` 自动获取与缓存(OAuth2,有效期 2 小时),业务代码无需关心 +- 本次调用参数:`pageNo=1`、`pageSize=1`、`societycreditcode=[统一社会信用代码]`;**不传 `status` 过滤**(需按返回状态区分三分支) +- 返回:`BfiPageVO`,取 `rows` 第一条;关键返回字段见 4.1 回填映射表 +- TMS 内部封装端点:`GET /blade-transport/bfi/customer/query-by-credit-code?creditCode={code}`,返回 `R`(无数据返回 `R.data(null)`) + +### 3.2 A902-2 客户保存 + +- URL:`POST {thirdParty.bfi.baseUrl}/ierp/kapi/v2/awrp/basedata/bd_customer/save`(可用 `thirdParty.bfi.customerSaveUrl` 覆盖) +- 请求体:`{"data": [ {…BfiCustomerSaveDTO 字段} ]}`(单条) +- 校验(`BfiServiceImpl.saveCustomer` 已内置):`name` 必填;`societycreditcode` 与 `idno` 不能同时为空;`entry_groupstandard` 必填 +- 返回结构: + +```json +{ + "data": { + "failCount": "0", + "successCount": "1", + "result": [ + { + "billIndex": 0, + "billStatus": true, + "errors": [], + "id": "2395327167211785216", + "number": "Cus-20260917-0001" + } + ] + }, + "errorCode": "0", + "status": true +} +``` + +- 成功判定:`errorCode=0` 且 `failCount=0` 且 `result[0].billStatus=true`;取 `number`(金蝶客户编码)回写 `kingdee_number`,取不到 `number` 时兜底取 `id` +- `status` 固定传 `B`(已提交),金蝶校验工商信息通过后由机器人自动审核为已审核 + +--- + +## 4. 字段映射与转换 + +### 4.1 A902-1 回填映射(金蝶 → TMS 表单,工商信息锁定) + +| TMS 表单字段 | 金蝶字段(BfiCustomerVO) | 转换/回填规则 | +| --- | --- | --- | +| unifiedCreditCode | societycreditcode | 即查询入参,不覆盖 | +| fullName(客商名称) | name | 直接回填,锁定 | +| legalPerson(企业法人) | artificialperson | 直接回填,锁定 | +| registeredRegionName / registeredDetailAddress | bizpartner_address | 文本回填至详细地址;省市区无法结构化解析时不回填,锁定已回填项 | +| businessScope(经营范围) | businessscope | 直接回填,锁定 | +| registeredCapital(注册资金,万元) | regcapital | 正则提取首个数值按「万元」处理(如 `1000万元人民币`→`1000`);无法解析不回填 | +| businessTermType / businessEndDate | businessterm | 含「长期/无固定」→ `长期`;提取文本中最后一个 `yyyy-MM-dd` 日期为截止日 → `固定期限`;均无法解析不回填 | +| shortName(客商简称) | simplename | 仅当表单为空时回填,**不锁定**(业务信息,TMS 可改) | +| —(回填来源标记) | status | `status=C` 时置 `kingdeeStatus=exist`,随表单提交落库;金蝶客户编码与 TMS 客商编号一致,无需单独存储 | + +**锁定字段集合**(`kingdeeStatus=exist` 时表单置灰):客商名称、企业法人、注册资金;统一社会信用代码仅编辑态锁定,新增态允许重新输入——修改代码后重置回填数据(`kingdeeStatus` 还原为 `none`,清空名称/法人/注册资金/详细地址/经营范围/营业期限),并对新代码重新触发 A902-1 查询。经营范围、营业期限、所在地区、详细地址仅回填、不锁定(TMS 可改)。客商分类、客商性质、所属组织、开票税点、信用额度、负责人、联系电话、客商材料等业务信息正常编辑。 + +TMS 无对应字段的金蝶返回项(注册资本原文、成立日期 establishdate、纳税人识别号等)不回填、不入库。 + +### 4.2 A902-2 推送转换表(TMS → 金蝶) + +| 金蝶字段 | 来源 | 转换规则 | +| --- | --- | --- | +| status | 固定 | `B` 已提交(常量 `BfiConstant.BILL_STATUS_SUBMITTED`) | +| name | fullName | 直传 | +| societycreditcode | unifiedCreditCode | 直传(必填,候选键) | +| tx_register_no | unifiedCreditCode | 与统一社会信用代码一致时推送 | +| artificialperson | legalPerson | 直传 | +| type(伙伴类型) | customerNature(客商性质) | 客商性质字典(`nature_of_client`)dictKey 即金蝶伙伴类型编码,直接推送;dictKey 为空或非有效编码(1~17)时抛 `ServiceException`(推送置 failed,不兜底) | +| bizpartner_address | registeredAddress | `prepare()` 已拼接的完整地址直传 | +| businessscope | businessScope | 多选编码 → 选项 label 文本、以「、」拼接 | +| businessterm | businessTermType + businessEndDate | `长期` → `长期`;`固定期限` → `至 yyyy-MM-dd`(TMS 未采集起始日) | +| bizpartner_phone | contactPhone | 直传 | +| fcusbeizhu | remark | 直传,空串传 `""` | +| bizfunction | 固定 | `1,2,3,4` | +| awrp_datasourcesys | 配置 | `BfiProperties`(公共键 `thirdParty.bfi.srcSys`),默认 `WLTMS`(运输TMS) | +| awrp_cusfortrade | 固定 | `A` 是(`BfiConstant.CUS_FOR_TRADE_YES`) | +| entry_groupstandard | customerKind | `standardid_number` 固定 JBFLBZ;`groupid_number` 按客商类型:内部组织(internal)→ 1101,外部客商(external,含空)→ 1102 | +| createorg_number | 不传 | 由星瀚按默认创建组织处理 | +| number | customerCode | 传 TMS 客商编号,**金蝶客户编码与 TMS 客商编号保持一致**,无需本地冗余存储 | +| entry_bank | receiptAccounts 收款账户明细 | 每条账户一行分录:`bankaccount`=银行账号、`accountname`=开户行、`currency_number`=CNY(`BfiConstant.CURRENCY_CNY`)、`isdefault_bank`=是否默认;未填收款账户时不传该分录 | +| entry_linkman | principal + contactPhone | `contactperson`=客商负责人、`phone`=联系电话、`isdefault_linkman`=true;未填负责人时不传该分录 | +| 注册资金 regcapital | — | **A902-2 无此字段**,新客商推送不含;仅 A902-1 回填使用 | + +### 4.3 伙伴类型取值(客商性质字典直取) + +`nature_of_client` 字典已重构:**dictKey 即金蝶伙伴类型编码**,录入时按《客商填报审核编码规范》(`doc/docs/客商填报审核编码规范.xlsx`)选择性质(看我区/非我区、机构类别)。前端字典下拉按项目约定存 dictValue 文本,推送时 `CustomerBfiConverter.resolvePartnerType(customerNature)` 将其解析为 dictKey 作为 `type`: + +| dictKey | dictValue(客商性质) | 金蝶伙伴类型 | +| --- | --- | --- | +| 8 | 我区国企/国企控股 | 8 我区国有企业 | +| 7 | 非我区国企/国企控股 | 7 非我区国有企业 | +| 6 | 民企/民企控股 | 6 民营企业 | +| 12 | 三资企业 | 12 其他 | +| 17 | 集体企业 | 17 集体经济组织 | +| 5 | 个体 | 5 个体户 | + +取值规则(`CustomerBfiConverter.resolvePartnerType(customerNature)`):前端字典下拉存 **dictValue 文本**(项目约定),后端解析时——值为数字编码(dictKey,1~17)直接使用;为 dictValue 文本(如「我区国企/国企控股」,含存量数据)时按 `nature_of_client` 字典反查对应 dictKey;均无法解析时抛 `ServiceException`(提示「客商性质未配置有效的金蝶伙伴类型编码」),`syncKingdee` 推送环节捕获后置 `failed` 并记录失败原因,等待修正字典数据后重推。 + +固定值集中放在 `blade-transport` 常量类 `org.springblade.transport.constant.CustomerBfiConstants`(**不走 Nacos 配置**),伙伴类型编码全集见 `BfiCustomerSaveDTO.type` 注释(1 法人企业 / 2 非法人企业 / 3 非企业单位 / 4 个人 / 5 个体户 / 6 民营企业 / 7 非我区国有企业 / 8 我区国有企业 / 9 政府事业单位等 / 10 农民专业合作社 / 11 民政部门 / 12 其他 / 13 境外客商 / 14 工会 / 15 司法行政 / 16 虚拟客商 / 17 集体经济组织)。 + +--- + +## 5. 改动清单 + +### 5.1 后端(tms-api,blade-transport) + +| 文件 | 动作 | 说明 | +| --- | --- | --- | +| `controller/BfiCustomerController.java` | 新增 | 仿 `BfiExchangeRateController`:`GET /bfi/customer/query-by-credit-code`,`pageNo=1/pageSize=1/societycreditcode=[code]` 调 `bfiService.queryCustomer`,返回 `R` | +| `constant/CustomerBfiConstants.java` | 新增 | 见 4.3 | +| `service/impl/CustomerBfiConverter.java` | 新增 | ① `buildSaveDTO(CustomerArchiveVO)`:按 4.2 组装 `BfiCustomerSaveDTO`;② `resolvePartnerType(customerNature)`;③ `buildBusinessterm(...)`、`parseRegcapital(...)` 等解析工具 | +| `pojo/entity/CustomerArchive.java`(blade-transport-api) | 修改 | 新增 `kingdeeNumber`、`kingdeeStatus`、`kingdeeFailReason`、`kingdeeSyncTime` 字段 | +| `pojo/vo/CustomerArchiveVO.java`、`excel/CustomerArchiveExcel.java` | 修改 | 同步新增字段(Excel 增加「金蝶客户编码」「金蝶同步状态」列) | +| `service/impl/CustomerArchiveServiceImpl.java` | 修改 | ① 新增 `syncKingdee(Long id)`:2.4 时序 1-5 步;② `approve()` 落库后**由 Controller 调用** `syncKingdee`(推送异常内部消化不影响审批);③ 复用 `addChangeRecord` 记录「推送金蝶成功/失败」 | +| `controller/CustomerArchiveController.java` | 修改 | ① `approve` 端点成功后调用 `syncKingdee`;② 新增 `POST /customer-archive/sync-kingdee`(`@PreAuth` 权限码 `customer_archive_sync`) | +| `wrapper/CustomerArchiveWrapper.java` | 无改动 | BeanUtil.copyProperties 自动携带新字段 | + +依赖说明:`blade-transport` 已依赖 `blade-bfi-api`(`BfiExchangeRateController`、`PaymentApplicationServiceImpl` 在用),无需新增 pom 依赖。 + +### 5.2 数据库(7 种数据库脚本同步,`doc/sql/`) + +```sql +ALTER TABLE blade_customer_archive ADD COLUMN kingdee_status varchar(20) NOT NULL DEFAULT 'none' COMMENT '金蝶协同状态:none未推送/exist金蝶已有/synced已推送/failed推送失败'; +ALTER TABLE blade_customer_archive ADD COLUMN kingdee_fail_reason varchar(500) DEFAULT NULL COMMENT '金蝶推送失败原因'; +ALTER TABLE blade_customer_archive ADD COLUMN kingdee_sync_time datetime DEFAULT NULL COMMENT '金蝶推送时间'; +CREATE INDEX idx_kingdee_status ON blade_customer_archive (kingdee_status); +``` + +> 库存量数据无需刷数:存量客商默认 `none`,仅在再次触发推送场景(重推按钮)才会与金蝶交互。 + +### 5.3 菜单按钮 SQL(权限码) + +```sql +-- parent_id 取「客商档案」菜单实际 id +INSERT INTO blade_menu (id, parent_id, code, name, alias, path, source, sort, category, action, is_open, component, remark, is_deleted) +VALUES (2075431800000000001, {客商档案菜单id}, 'customer_archive_sync', '同步金蝶', 'customer_archive_sync', '', '', 1, 2, 0, 1, '', '', 0); +``` + +### 5.4 前端(tms-erp-web) + +| 文件 | 动作 | 说明 | +| --- | --- | --- | +| `src/api/vehicle/customer-archive.js` | 修改 | 新增 `queryKingdeeCustomer(creditCode)`(GET `/blade-transport/bfi/customer/query-by-credit-code`)、`syncKingdee(id)`(POST `/blade-transport/customer-archive/sync-kingdee`) | +| `src/views/vehicle/customer-archive.vue` | 修改 | ① 新增态 watch `unifiedCreditCode`:满 18 位且通过现有格式校验(18 位字符 + 校验码)后防抖 500ms 自动查询;② 三分支处理(2.3);③ `kingdeeStatus==='exist'` 时锁定 4.1 锁定字段集合(disabled);④ `normalizeArchive()` 携带 `kingdeeStatus/kingdeeNumber`;⑤ 列表新增「同步金蝶」按钮(仅 `approved + failed` 可点)与「金蝶客户编码」「金蝶同步状态」列;⑥ 编辑态不触发查询;⑦ 审批通过推送由后端 approve 端点内部触发,前端无需额外调用 | + +--- + +## 6. 异常处理 + +| 场景 | 处理 | +| --- | --- | +| 前端 A902-1 查询超时/异常 | `$message.warning('金蝶查询失败,可继续填写,审批通过后系统将自动复查')`,不阻断填写;后端推送前复查兜底 | +| A902-1 查到金蝶暂存/已提交 | 弹窗提示「金蝶审核中,请稍后重新添加」,前端阻断保存(流程终止,对齐流程图) | +| 推送时复查到金蝶已审核 | 置 `exist` + `kingdeeNumber`,跳过推送,返回「金蝶已有该客商档案,无需推送」 | +| 推送时复查到金蝶暂存/已提交 | 置 `failed`,`kingdee_fail_reason='金蝶侧该客商审核中,请稍后重推'` | +| A902-2 返回 `billStatus=false` / `failCount>0` | 置 `failed`,`errors` 拼接写入 `kingdee_fail_reason`(截断 500 字),日志 `@Slf4j` 占位符输出含客商 id 与信用代码 | +| A902-2 网关异常(超时/5xx/token 失效) | `BfiException` 统一上抛,同上置 `failed`;token 由 `BfiServiceImpl` 自动续期 | +| 重推仍失败 | 保持 `failed`;定时任务(`CustomerKingdeeSyncTask`,开关 `thirdParty.bfi.customer.syncEnabled`,默认关闭)每轮自动重推,也可列表「同步金蝶」人工重推;连续失败转人工核对金蝶侧该信用代码客商状态 | + +--- + +## 7. 实施核对清单 + +1. [ ] 与财务核对 4.3 客商性质字典 dictKey 与金蝶伙伴类型编码对应关系(录入侧按《客商填报审核编码规范》选择性质),以及 `createorg_number` 取值。 +2. [ ] DDL 按 `doc/sql/` 各数据库方言同步(MySQL / PostgreSQL / Oracle / SQL Server / 达梦 / 人大金仓 / 崖山),排序规则 `utf8mb4_general_ci`。 +3. [ ] 前端锁定字段集合与 4.1 一致;`exist` 状态下后端不额外强校验锁定字段是否被改(前端已置灰,以提交值为准,工商信息实际以金蝶为准)。 +4. [ ] 「同步金蝶」按钮权限码 `customer_archive_sync` 与菜单 SQL 一致。 +5. [ ] A902-1/A902-2 联调环境验证:三分支提示文案、回填解析(regcapital/businessterm)、A902-2 成功回写编码。 +6. [ ] 回归:现有新增/编辑/审批/导入导出不受影响(`kingdeeStatus` 默认 `none` 不参与既有校验)。 + +--- + +## 8. 实施记录 + +- 2026-09-17:按本文档完成后端(`CustomerArchive` 实体/Excel/Mapper 扩展、`CustomerBfiConstants`、`CustomerBfiConverter`、`CustomerArchiveServiceImpl.syncKingdee`、`BfiCustomerController`、`CustomerArchiveController` approve 挂推送 + `/sync-kingdee`)与前端(`customer-archive.js` 两个 API、`customer-archive.vue` 查询回填/锁定/阻断、列表「同步金蝶」按钮与「金蝶同步状态」列);`mvn clean compile -DskipTests` 与 `pnpm run build` 均通过。 +- 2026-09-17(第二轮调整): + - **取消 `kingdee_number` 字段**:A902-2 `number` 直接传 TMS 客商编号,金蝶客户编码与客商编号保持一致;DDL 缩减为 3 字段,前端移除「金蝶客户编码」列及相关引用。 + - **客商数据定时同步子流程本期不做**:经确认 ERP/TMS 无此既有逻辑,且金蝶 SDK(blade-bfi-api)暂未提供 A901-1 供应商查询;该子流程不阻塞核心链路,待 A901-1 能力补齐后按 §2.3 步骤9(ERP 定时调 A902-1/A901-1 更新客商数据与使用状态)另行立项。推送失败(`failed`)由列表「同步金蝶」按钮人工重推。 +- 2026-09-17(第四轮调整):经确认放宽锁定范围,经营范围、营业期限、所在地区(省市区)、详细地址在 `exist` 状态下仅回填不置灰,可正常编辑;锁定字段集合缩减为客商名称、企业法人、注册资金。统一社会信用代码在新增态支持重新输入:修改代码即重置回填数据并重新触发 A902-1 查询,编辑态仍锁定。 +- 2026-09-17(第五轮调整):A902-2 伙伴类型转换改为依据 `doc/docs/客商填报审核编码规范.xlsx`:以统一社会信用代码登记管理部门前缀为主(身份证/手机号→个人,1→政府事业单位等、3→司法行政、5→民政部门、8→工会、N→集体经济组织、Y→其他、92→个体户、93→农民专业合作社、91→企业按客商性质细分),原客商性质映射降级为前缀未识别/代码缺失时的兜底。`CustomerBfiConstants` 新增伙伴类型编码常量与前缀映射表,`CustomerBfiConverter.resolvePartnerType` 增加 `unifiedCreditCode` 入参。`mvn clean compile` 通过。 +- 2026-09-17(第六轮调整):按数据库字典 `nature_of_client` 实际值(国企/国企控股、民企/民企控股、三资企业、集体企业、个体)重建 `NATURE_TYPE_MAPPING`;91 开头企业取消「含国有」文本特判,统一走性质映射(国企/国企控股→8)。三资企业→其他(12)、集体企业→集体经济组织(17) 为近似映射,待财务核对。`mvn clean compile` 通过。 +- 2026-09-17(第七轮调整):BFI 推送配置抽取统一为 `org.springblade.transport.config.BfiProperties`(`@ConfigurationProperties(prefix = "thirdparty.bfi")`,prefix 须全小写,宽松绑定匹配 Nacos 中 camelCase 键):数据来源系统编码收口为公共键 `thirdParty.bfi.srcSys`(原 `thirdParty.bfi.customer.srcSys` / `thirdParty.bfi.payApply.srcSys` 废弃,环境变量改用 `BFI_SRC_SYS`),客商 `customer.createOrg`、付款申请 `payApply.billtype/exratetable/srcType` 集中管理;`CustomerBfiConverter`、`PaymentApplicationServiceImpl`、`KingdeeApBillSyncSupport` 改为注入 `BfiProperties`。Nacos 配置(blade-dev/blade-prod/third-party-api.yaml)同步:bfi 节点新增 `srcSys`、`customer.createOrg`,payApply 节点移除 `srcSys`。`mvn clean compile` 通过。 +- 2026-09-17(第八轮调整):`nature_of_client` 字典重构为 dictKey 即金蝶伙伴类型编码(8 我区国企/国企控股、7 非我区国企/国企控股、6 民企/民企控股、12 三资企业、17 集体企业、5 个体),录入时按编码规范选择性质,推送侧 `resolvePartnerType` 直接取 dictKey 推送(1~17 校验,非法/为空/存量文本值兜底 `6`);删除信用代码前缀转换与文本映射表及 `CustomerBfiConstants` 中相关常量。`mvn clean compile` 通过。 +- 2026-09-17(第九轮调整):伙伴类型取消兜底,`resolvePartnerType` 在客商性质 dictKey 为空或非有效编码(1~17)时抛 `ServiceException`,由 `syncKingdee` 推送环节捕获置 `failed` 并记录原因,修正数据后可重推;删除 `DEFAULT_PARTNER_TYPE` 常量。`mvn clean compile` 通过。 +- 2026-09-20(第十轮调整):移除 `customer.createOrg` 配置项,`createorg_number` 固定传 `CustomerBfiConstants.CUSTOMER_CREATE_ORG`(WLTMS),`BfiProperties` 删除 customer 节点,仓库与 Nacos(blade-dev)配置同步移除。前端修复:统一社会信用代码查询去除同码去重守卫(原 `kingdeeQueriedCode` 拦截导致改回历史代码不再触发 A902-1 查询),代码一经变化即防抖重新查询。`mvn clean compile` 与 `pnpm run build` 通过。 +- 2026-09-20(第十一轮调整):定时同步 `syncKingdeeCustomerData` 增加 ③ 已推送(synced)客商的金蝶审核确认——A902-2 成功仅代表金蝶受理(status=B),按信用代码 A902-1 批量复查,status=C 已审核才记为最终成功,A/B 审核中保持 synced 等待下轮复查(不重推,避免金蝶侧重复建档),汇总文案与日志体现「金蝶已审核/审核中/未查询到」统计;`CustomerArchiveServiceImpl` 常量补充 Javadoc,新增 `@SpringBootTest` 手动触发测试类 `CustomerArchiveSyncKingdeeDataTest`(pom 补 `spring-boot-starter-test`),Controller 不加测试端点。`mvn clean compile` 与 `test-compile` 通过。 +- 待人工执行:`doc/sql/transport/blade_customer_archive_kingdee_20260917.sql`(含「同步金蝶」按钮菜单 INSERT);与财务核对客商性质映射键与 `createorg_number` 取值。 +- 2026-09-17(第三轮新增定时任务):新增 `task/CustomerKingdeeSyncTask`(仿 `PaymentKingdeeResultTask`,开关 `thirdParty.bfi.customer.syncEnabled` 默认关闭、cron `thirdParty.bfi.customer.cron` 默认每天 02:00)与 `ICustomerArchiveService.syncKingdeeCustomerData()`:① 待推送客商(approved 且 none/failed)复用 `syncKingdee` 自动推送(自代理逐条独立事务,单条失败不中断);② 已同步客商(synced/exist)按信用代码分批(50/批)A902-1 查询,金蝶禁用(enable=0)联动停用客商档案并记变更,金蝶恢复可用不自动启用。A901-1 供应商查询仍待 SDK 提供能力后扩展。`mvn clean compile` 通过。 diff --git a/doc/docs/业务流程/2026-09-18-付款管理金蝶同步全链路逻辑梳理(推送链路与回写链路连贯性).md b/doc/docs/业务流程/2026-09-18-付款管理金蝶同步全链路逻辑梳理(推送链路与回写链路连贯性).md new file mode 100644 index 00000000..85932a8d --- /dev/null +++ b/doc/docs/业务流程/2026-09-18-付款管理金蝶同步全链路逻辑梳理(推送链路与回写链路连贯性).md @@ -0,0 +1,145 @@ +# 付款管理金蝶同步全链路逻辑梳理(推送链路与回写链路连贯性) + +> 关联文档: +> - `2026-09-16-付款管理正式付款同步金蝶(财务应付单+付款申请下推)技术文档.md` +> - `2026-09-17-付款管理金蝶付款结果定时回写(A103-2付款申请单查询+A103-4付款处理查询)技术文档.md` +> - `../TMS业财融合-集成流程图文字版与业务流程梳理.md`(§4.3 流程步骤、§4.4 关键规则) +> +> 本文为 2026-09-18 幂等恢复改造后的全链路现状梳理,代码落点 `PaymentApplicationServiceImpl`。 + +## 1. 背景与范围 + +付款申请审批通过后与金蝶(星瀚)的双向同步: + +- **推送链路(TMS → 金蝶)**:正式结算付款两步链路 A102-2 财务应付单保存 → A103-1 应付下推付款申请;预付款一步链路 A103-3 预付款保存。 +- **回写链路(金蝶 → TMS)**:定时任务经 A103-2 付款申请单查询回写单据与付款状态,经 A103-4 付款处理查询回填付款记录。 + +原实现两步推送同处一个本地事务,A102-2 成功后 A103-1 失败会导致"金蝶已生成、本地回滚为生成失败"的断链,重试被"请勿重复推送"拦截而死锁。本次改造确立全链路恢复原则: + +> **以金蝶侧实际生成为准:已生成则幂等跳过/回填勾稽,未生成才续推;推送接口真正报错才算同步失败。** + +## 2. 接口与角色清单 + +| 接口 | 名称 | 方向 | 方法(TMS 侧) | 用途 | +| --- | --- | --- | --- | --- | +| A102-2 | 财务应付单保存 | 推送 | `pushBfiApBill` | 正式结算付款第一步 | +| A102-4 | 财务应付单查询 | 查询 | `queryApBillExistNos` | 推送前幂等预检(防重键) | +| A103-1 | 付款申请保存(应付下推) | 推送 | `pushBfiPayApplyPush` | 正式结算付款第二步 | +| A103-2 | 付款申请单查询 | 查询 | `queryPayApplyPages` / `queryLatestPayApply` | 恢复勾稽 + 定时回写 | +| A103-3 | 付款申请保存(预付款) | 推送 | `pushBfiPayApply` | 预付款一步链路 | +| A103-4 | 付款处理查询 | 查询 | `queryPayBillPages` | 回填付款记录 | + +**防重键体系**(`awrp_src_sys = WLTMS`,常量统一在 `KingdeeBfiConstant`): + +| 键 | 取值 | 使用接口 | +| --- | --- | --- | +| 付款申请单来源单号 | `awrp_src_code = paymentNo` | A103-1 / A103-2 / A103-3 | +| 财务应付单来源单号 | `awrp_src_code = paymentNo_结算明细单据号拼接`(`ExternalSrcCodeSupport.buildSrcCode`) | A102-2 / A102-4 | +| 分录标识 | `bfiEntryCode = paymentNo-分录序号(1 起)` | A102-2 `awrp_entry_code`、A103-1 `awrp_finapbill_entryid` / `awrp_entry_code`、A103-3(序号固定 1) | + +公共约定:各接口日期文本统一 `yyyy-MM-dd HH:mm:ss`(`bfiDateTimeText`,申请日期为空取当天);分页查询统一 `BfiUtil.queryAllPages` 自动翻页(页大小 200、最大 50 页保护);批量入参按 50 单/批(`batchForeach`);A103-2 结果按来源单号取最近修改一条(`latestPayApplyBySrcCode`)。 + +## 3. 状态机 + +本地 `kingdeeStatus` 流转与 A103-2 状态映射(`kingdeeResultStatus`): + +``` +unsynced ──推送成功──> synced ──paystatus=Inpayment──> paying ──paystatus=Alreadypay──> paid(终态) + │ │ │ + │ └──billstatus=D──> closed(终态) └──billstatus=D──> closed(终态) + └──推送异常(事务回滚 + 独立事务落状态)──> failed ──重推成功或回写勾稽纠偏──> synced/paying/paid +``` + +| A103-2 返回 | 本地状态 | 是否终态 | +| --- | --- | --- | +| paystatus=Alreadypay | paid | 是(不再轮询) | +| billstatus=D(已关闭) | closed | 是(不再轮询) | +| paystatus=Inpayment | paying | 否(继续轮询) | +| 其余(A暂存/B已提交/C已审核 + Unpaid) | synced | 否(继续轮询) | +| 无返回(金蝶未生成) | 状态不变 | — | + +门禁:仅 `approvalStatus = approved` 允许同步;已 synced/paying/paid/closed 在 `doSyncKingdee` 入口幂等跳过并返回既有单据 id。 + +## 4. 推送链路完整逻辑(TMS → 金蝶) + +### 4.1 入口与事务边界 + +- 单条:`POST /sync-kingdee` → `syncKingdee(id)`;批量:`POST /sync-kingdee-batch` → `syncKingdeeBatch(ids)`。 +- 经自代理逐单调用 `doSyncKingdee`:**单据级事务**,单条失败只回滚自身,批量不中断。 +- 失败时在独立事务 `markKingdeeSyncFailed` 落 `failed`(仅 approved 单据生效)。 +- 批量同步共享 `KingdeeSyncContext`:档案缓存(合同/客商/项目/货物类型/结算明细)+ 应付单防重键缓存,批量 A102-4 预热一次(`preloadApBillExistNos`,仅正式结算单据),消除逐单远程查询 N+1。 + +### 4.2 正式结算付款链路(settlement_payment) + +| 步骤 | 动作 | 说明 | +| --- | --- | --- | +| 1 | `requirePayableSettlement` | 结算类型路由:应付放行,应收(A302-2)待业务确认暂拦截 | +| 2 | `resolveBfiEntryGroups` | 结算明细按货物编码+计量单位聚合分录组(金额取结算金额含税/不含税) | +| 3 | A102-4 幂等预检 | 按防重键查应付单是否已生成(上下文缓存优先,懒加载兜底) | +| 3a | **已生成 → 幂等跳过保存**,返回已有单据编号并回填 `kingdeeBillNo` | 说明上次同步已在后续环节中断,应续推而非拦截报错 | +| 3b | 未生成 → A102-2 保存 | 仅校验保存成功,应付单 id 不落库;单据编号回填 `kingdeeBillNo` | +| 4 | **已存在时先 A103-2 恢复勾稽**(`reconcileExistPayApply`) | 付款申请单已生成 → 回填 `kingdeeBillId/kingdeeBillNo`,直接视为同步成功,跳过下推 | +| 5 | 勾稽未命中 → A103-1 应付下推 | 分录经 `bfiEntryCode` 与 A102-2 明细一一关联;返回星瀚单据 id | +| 6 | 落库 | `kingdeeBillId = 付款申请单 id`,`kingdeeStatus = synced` | + +### 4.3 预付款链路(project_advance / progress_advance) + +| 步骤 | 动作 | 说明 | +| --- | --- | --- | +| 1 | 同步前状态为 `failed` 时先 A103-2 恢复勾稽 | 金蝶已生成 → 回填并视为成功,避免重推产生重复单据;首推(状态非 failed)不多一次远程查询 | +| 2 | 勾稽未命中 → A103-3 保存 | 不绑应付单;返回星瀚单据 id | +| 3 | 落库 | 同 4.2 第 6 步 | + +## 5. 回写链路完整逻辑(金蝶 → TMS) + +触发:`PaymentKingdeeResultTask` 定时任务(默认每 10 分钟,`thirdParty.bfi.payResult.syncEnabled/cron`),也可手动 `POST /sync-kingdee-result`;逻辑收敛在 `syncKingdeePaymentResult()`。 + +### 5.1 ① A103-2 单据与付款状态回写 + +- 轮询范围:`approved` 且 `kingdeeStatus IN (synced, paying, failed)`;paid/closed 终态不再轮询。 +- **failed 纳入勾稽**:金蝶已生成说明上次推送实际成功(如下推请求超时但远端已落单、本地事务回滚),查到即经 `applyPayApplyResult` 回填 `kingdeeBillNo/billno`、`kingdeeBillId/id`、状态、已付金额(分录 `e_paidamt` 汇总),自动纠偏,防止人工重推重复;未查到保持 failed 等待异常处理后重新推送。**任务只勾稽回写,不主动推送**。 +- 付款完成(paid)联动:清理 MOCK 付款记录、履约付款数据(预结算预付记录、正式结算付款记录)。 + +### 5.2 ② A103-4 付款记录回填 + +- 候选:付款中/已付款单据(本轮 A103-2 查到 `paystatus ≠ Unpaid`)+ 已付款但无真实付款记录的单据(补偿轮询)。 +- 按付款人(公司)编码分组查询(编码优先取 A103-2 `payorg_number`,兜底合同组织;业务日期窗口最近 1 个月,接口限制)。 +- 勾稽:付款处理单源单 id(= `kingdeeBillId`)优先,源单编号(= `kingdeeBillNo`)兜底(`matchPayBill`)。 +- 回填(`applyPayBillRecord`):按付款处理单号 upsert 付款记录(金额、日期、凭证留档 JSON)。 + +## 6. 两边逻辑连贯性对照 + +### 6.1 正常时序(正式结算付款) + +``` +TMS 审批通过 → A102-2 保存应付单(回填 kingdeeBillNo)→ A103-1 下推付款申请(落 kingdeeBillId,置 synced) +→ 任务 A103-2 回写(billno 覆盖 kingdeeBillNo,状态 synced→paying→paid)→ 任务 A103-4 回填付款记录 → 终态 +``` + +### 6.2 异常场景矩阵(两边行为与闭环路径) + +| # | 断点场景 | 金蝶侧实际 | 本地状态 | 恢复路径 | 闭环 | +| --- | --- | --- | --- | --- | --- | +| 1 | 推送前校验/档案缺失失败(A102-2 之前) | 无任何单据 | failed | 重新推送走全链路首推 | ✅ | +| 2 | A102-2 成功、A103-1 报错(如分录校验失败) | 应付单已生成 | failed(事务回滚,编号回填一并回滚) | 重推:A102-4 预检命中 → 幂等跳过保存 → A103-2 勾稽未命中 → A103-1 续推;下推再报错才真实失败 | ✅ 本次改造 | +| 3 | A103-1 远端已落单但响应超时/本地回滚 | 应付单 + 付款申请单均已生成 | failed | 重推:A102-4 命中跳过 → A103-2 勾稽命中 → 回填置 synced;或定时任务自动纠偏 | ✅ 本次改造 | +| 4 | 预付款 A103-3 远端成功但本地失败 | 付款申请单已生成 | failed | 重推前 A103-2 预检命中 → 回填跳过;或定时任务纠偏 | ✅ 本次改造 | +| 5 | 推送成功但 kingdeeBillNo 尚为应付单编号 | 付款申请单已生成 | synced | 定时任务 A103-2 回写覆盖为付款申请单编号(字段语义随阶段演进,设计如此) | ✅ | +| 6 | 手工重推与定时任务并发纠偏 | 单据唯一(防重键唯一) | — | 两侧均为"先查后动":勾稽只读远端按 paymentNo 唯一归并,重复执行幂等无冲突 | ✅ | +| 7 | 金蝶反审核删除后重推 | 无单据 | failed | 预检查无 → 重新推送生成新单据(符合"金蝶反审核+删除后 ERP 重推"规则) | ✅ | +| 8 | failed 且金蝶确无单据(如档案长期缺失) | 无单据 | failed | 每轮任务空查一次 A103-2(量级 = failed 单据数),状态保持,等待人工处理 | ⚠️ 已知边界 | + +### 6.3 连贯性结论 + +1. **推送侧自洽**:三条件分支(应付单已存在 / 付款申请单已存在 / 均不存在)覆盖全部断点组合,任一断点重推均从断点续传而非从头重推,且以远端实际为准。 +2. **回写侧衔接**:恢复成功后本地状态为 synced(或直接 paid/paying),落入任务轮询范围,付款状态与付款记录由任务持续驱动至终态,无需再次人工干预。 +3. **双通道兜底**:手工重推(勾稽预检)与定时任务(勾稽纠偏)对同一断点均可闭环,先后到达不冲突。 +4. **语义一致性**:防重键、分录标识、日期格式、A103-2 归并规则均已收敛为统一生成点(`apBillSrcCode`/`bfiEntryCode`/`bfiDateTimeText`/`latestPayApplyBySrcCode`/`batchForeach`),两侧关联字段不存在漂移可能。 + +## 7. 已知边界与注意事项 + +1. **failed 空查**:确无金蝶单据的 failed 单据会被任务持续空查(每轮 A103-2 一次),如量大可考虑按申请日期窗口截断或引入重试次数上限(当前不加,保持简单)。 +2. **`kingdeeBillNo` 阶段语义**:推送瞬间为应付单编号,勾稽/回写后为付款申请单编号;依赖该字段展示时以回写后的值为准。 +3. **应收结算(A302-2)未接入**:结算类型为应收的单据在 `requirePayableSettlement` 拦截,推送规则待业务确认。 +4. **A102-2/A103-1 联调临时取值**:物料/计量单位/项目编码仍传金蝶测试档案值(调试块),档案对齐后需拆除,见 2026-09-16 技术文档遗留项。 +5. **A103-2 勾稽不校验金额**:恢复勾稽以"来源单号存在"为准,不比对金额与分录;若远端单据被人工修改,以远端为准回写(与回写任务原则一致)。 diff --git a/doc/docs/业务流程/2026-09-20-应付单与付款申请同步金蝶全链路业务流程(A102-2保存-A102-4查询-A103-1下推-A103-2回写).md b/doc/docs/业务流程/2026-09-20-应付单与付款申请同步金蝶全链路业务流程(A102-2保存-A102-4查询-A103-1下推-A103-2回写).md new file mode 100644 index 00000000..65a4b628 --- /dev/null +++ b/doc/docs/业务流程/2026-09-20-应付单与付款申请同步金蝶全链路业务流程(A102-2保存-A102-4查询-A103-1下推-A103-2回写).md @@ -0,0 +1,379 @@ +# 应付单与付款申请同步金蝶全链路业务流程(正式结算单同步 → 付款管理批量同步 → 付款结果定时回写) + +> 适用系统:TMS(BladeX 微服务平台,服务:blade-transport) +> 对接系统:金蝶云·星瀚(业财融合 BFI,经 iPaaS 网关) +> 覆盖接口:A102-2 财务应付单保存、A102-4 财务应付单查询、A103-1 付款申请保存(应付下推)、A103-2 付款申请单查询 +> 关联文档:`2026-09-18-付款管理金蝶同步全链路逻辑梳理(推送链路与回写链路连贯性).md`、`2026-09-16-付款管理正式付款同步金蝶(财务应付单+付款申请下推)技术文档.md`、`2026-09-17-付款管理金蝶付款结果定时回写(A103-2付款申请单查询+A103-4付款处理查询)技术文档.md` +> 最后更新:2026-09-20(按当前代码重新梳理:A102-2 已由正式结算单页面触发推送;接口入参来源及对应前端字段直接内嵌在各环节业务流程步骤中) + +--- + +## 1. 整体业务链路 + +一笔正式结算付款要完成金蝶同步,整体分**三个环节**,由三个页面/功能接力完成: + +``` +环节一:正式结算单「同步金蝶」(人工,正式付款单页面) + 审批通过的应付类正式结算单 → 推送金蝶财务应付单(A102-2,前置 A102-4 幂等预检) + → 回写结算单:金蝶单据编号 + 同步状态 synced + +环节二:付款管理「批量同步」(人工,付款管理页面) + 审批通过的付款申请(结算付款)→ 前置校验应付单已生成(A102-4)→ A103-2 幂等预检 + → 推送金蝶付款申请单(A103-1 应付下推,分录勾稽应付单付款计划分录) + → 回写付款申请:金蝶单据id/编号 + 同步状态 synced + (预付款 project_advance / progress_advance 不走本链路,直接 A103-3 保存,见关联文档) + +环节三:PaymentKingdeeResultTask 定时回写(自动,默认每10分钟) + ① A103-2 付款申请单查询:回填金蝶单据编号、单据状态、已付金额(synced → paying → paid/closed) + ② A103-4 付款处理查询:回填真实付款记录(金额、付款日期、凭证留档) +``` + +核心设计原则:**以金蝶侧实际生成为准**。每一步推送前先查询(防重键)确认金蝶是否已有单据:已生成则幂等跳过/回填勾稽,未生成才推送;推送接口真正报错才算同步失败。任一环节中断后重新触发,均从断点续传而非从头重推。 + +--- + +## 2. 四个接口在链路中的角色 + +| 接口 | 名称 | 方向 | 触发环节 | 业务用途 | +| --- | --- | --- | --- | --- | +| A102-2 | 财务应付单保存 | TMS → 金蝶 | 环节一 | 一张正式结算单生成一张金蝶财务应付单 | +| A102-4 | 财务应付单查询 | TMS → 金蝶(查询) | 环节一前置 / 环节二前置 | ①推送前幂等预检应付单是否已生成;②反查应付单付款计划分录标识(awrp_entry_code_p),供 A103-1 勾稽 | +| A103-1 | 付款申请保存(应付下推) | TMS → 金蝶 | 环节二 | 基于已生成的应付单下推生成金蝶付款申请单 | +| A103-2 | 付款申请单查询 | TMS → 金蝶(查询) | 环节二前置(幂等预检/恢复勾稽)+ 环节三①(定时回写) | 查询付款申请单是否已生成、单据状态与付款进度 | +| A103-4 | 付款处理查询 | TMS → 金蝶(查询) | 环节三② | 回填实际付款记录(本文档附带说明,逻辑在定时任务内) | + +(A103-3 预付款直接保存仅预付款链路使用,不在本文范围。) + +--- + +## 3. 环节一:正式结算单「同步金蝶」(推送 A102-2) + +### 3.1 业务流程 + +1. 操作入口:正式付款单(正式结算单)页面「同步金蝶」按钮,单笔操作。 +2. 前置状态校验(不满足直接报错,不调用金蝶): + - 审批状态必须是 **审批通过(approved)**;草稿/审批中/已驳回/已作废均不允许。 + - 结算类型必须是 **应付(payable)**;应收(receivable)对应财务应收单(A302-2),推送规则未确认,当前拦截报错。 + - 同步状态已是 **已同步(synced)** 时幂等返回,不重复推送。 + - 正式结算单必须存在结算明细(分录取数来源)。 +3. 组装财务应付单防重键(外部系统来源单据号):**正式结算单号_结算明细单据号1_单据号2…**(单据号去重、保持行序、下划线拼接)。一张结算单对应一张金蝶应付单,同一结算单下多笔付款申请共享该应付单。 +4. 解析应付单明细分录:逐条**应付货物费用行**生成一条分录(不聚合),取数校验(任一不满足报错拦截): + - 结算明细对应应付明细必须存在货物费用行(取数必须来自应付费用明细,无兜底); + - 货物类型必须在货物类型档案中维护货物编码(多种货物类型以「、/,」分隔时不拆行,取第一种类型); + - 运费计算单位不能为空(优先快照运费计算单位,兜底数量单位;计量单位档案暂无编码字段,暂以单位名称作编码)。 +5. **A102-4 财务应付单查询**:按防重键查询金蝶是否已生成财务应付单。 + + - **已生成** → 幂等跳过保存,直接取已有单据编号进入第 7 步(说明上次同步已保存成功、在后续环节中断); +- **未生成** → 进入第 6 步调用 A102-2 保存。 + +A102-4 入参来源: + + | 接口字段 | 含义 | 来源 | 前端字段(来源页面) | + | --- | --- | --- | --- | + | awrp_src_code | 外部系统来源单据号(支持多值) | 第 3 步组装的财务应付单防重键:正式结算单号_结算明细单据号拼接 | 系统组装:正式结算单页面「结算单号」formalSettlementNo + 结算明细「单据号」documentNo(`formal-settlement.vue` / `formalSettlementTable.js`) | + | awrp_src_sys | 外部系统编号 | 配置项 thirdParty.bfi.srcSys,默认 WLTMS | 无(后端配置,前端不出现) | +| pageNo / pageSize | 分页 | 自动翻页(页大小 200,最大 50 页保护) | 无(系统生成) | + +A102-4 返回取用:`billno` 单据编号(预检命中时即结算单回写的金蝶单据编号);`planentity[].awrp_entry_code_p` 付款计划分录标识(环节二 A103-1 勾稽关联用)。 + +6. **A102-2 财务应付单保存**。 + + A102-2 主表入参来源: + + | 接口字段 | 含义 | 必填 | 来源 | 前端字段(来源页面) | + | --- | --- | --- | --- | --- | + | billtypeid_number | 单据类型.编码 | 是 | 常量默认名称「服务采购应付」经单据类型映射字典转为编码 `ApFin_service_BT_S` | 无(后端常量) | + | org_number | 结算组织.编码 | 是 | 合同所属组织:合同.organizationId → 部门编码(未维护报错) | 合同管理页面「所属组织」organizationId/organizationName(`contract-manage.vue`);部门编码 deptCode 在系统部门管理维护 | + | bizdate | 单据日期 | 是 | 同步当日(格式 yyyy-MM-dd HH:mm:ss) | 无(系统生成) | + | asstacttype | 往来类型 | 是 | 按合同类别路由:合同类别=「客户合同」→ bd_customer(客户),否则 bd_supplier(供应商) | 合同管理页面「合同类别」contractCategory(下拉字典) | + | asstact_number | 往来户.编码 | 是 | 合同乙方 → 客商档案按全称/简称匹配取客商编码(乙方为空或未维护客商编码均报错) | 合同管理页面「乙方」partyB(下拉选择)→ 客商档案页面「客商编号」customerCode(按客商全称/简称匹配,`customer-archive.vue`) | + | purmode | 付款方式 | 是 | 常量 CASH(现购,金蝶自动 100% 推付款) | 无(后端常量) | + | currency_number | 结算币别.货币代码 | 是 | 合同结算币种,RMB 统一映射 CNY(为空报错) | 合同管理页面「结算币种」settlementCurrency(下拉选择) | + | payorg_number | 付款组织.编码 | 是 | 同结算组织编码 | 同 org_number | + | payproperty_number | 款项性质.编码 | 是 | 常量 2001(待金蝶确认) | 无(后端常量) | + | isincludetax / ispricetotal / isintax | 录入含税价/录入总价/是否含税 | 否 | 固定 true / true / false(录入总价免单价换算) | 无(后端常量) | + | awrp_src_sys | 外部系统编号 | 是 | 配置项,默认 WLTMS(应付单防重键之一) | 无(后端配置) | + | awrp_src_code | 外部系统来源单据号 | 是 | 第 3 步组装的防重键:正式结算单号_结算明细单据号拼接 | 系统组装:正式结算单页面「结算单号」formalSettlementNo + 结算明细「单据号」documentNo | + | awrp_src_type | 外部单据类型 | 是 | 常量「TMS财务应付单」(待金蝶确认) | 无(后端常量) | + + A102-2 明细分录(detailentry,每条应付货物费用行一条)入参来源: + + | 接口字段 | 含义 | 必填 | 来源 | 前端字段(来源页面) | + | --- | --- | --- | --- | --- | + | material_number | 物料编码.编码 | 是 | 货物类型 → 货物类型档案货物编码;**联调期临时固定传金蝶测试档案值 SP0324** | 应收应付明细页面「货物类型」cargoType(支持「、/,」多类型取第一种)→ 基础数据-货物类型页面「货物类型编码」cargoCode(`cargo-type.vue`) | + | measureunit_number | 计量单位.编码 | 是 | 快照运费计算单位(兜底数量单位);**联调期临时固定传 TON** | 应收应付明细页面「运费计算单位」priceUnit(历史快照兜底数量单位 quantityUnit;计量单位档案暂无编码字段暂传单位名称) | + | quantity | 数量 | 是 | 该行运输数量,无吨位兜底 1 | 应收应付明细页面「运输总量」transportQuantity | + | price | 单价 | 否 | 费用明细运输单价(单据头录入总价,单价仅参考) | 应收应付明细页面「运输单价」unitPrice | + | e_pricetaxtotal | 应付金额(含税) | 是 | 该费用行调整后总金额(含税) | 应收应付明细页面「调整后金额」afterAmount(含调整金额 adjustAmount 调整) | + | e_amount | 金额(不含税) | 否 | 结算货物费用行不含税金额(应付费用行无不含税字段,经结算费用行回链;无映射兜底 0) | 正式结算明细「结算金额(不含税)」settlementAmountNoTax(`formalSettlementTable.js`) | + | project_number | 项目.项目编码 | 是 | **联调期临时固定传金蝶测试档案值 999**(真实取值:项目档案项目编码,无项目传 999) | 联调期无;真实来源=项目申请页面「项目编号」projectCode(`project-apply.js`) | + | awrp_entry_code | 外部系统来源分录标识 | 是 | 应付货物费用行 id(雪花 id 天然唯一);A103-1 的 awrp_entry_code 与之对应 | 无(系统生成,前端不出现) | + + 付款计划(planentity)暂不传:由金蝶按明细自动汇总生成,其付款计划分录标识(awrp_entry_code_p)后续经 A102-4 反查。 + +7. 返回与落库: + - 成功:返回 `result[0].number`(金蝶应付单编号)→ 写入正式结算单金蝶单据编号,同步状态 = synced,记录同步时间;`result[0].id` 为星瀚内部主键,不落库。 + - 失败:金蝶单据级失败以 errors 明细抛出业务异常,本次同步失败(结算单同步状态保持原值,可重新触发)。 + +### 3.2 最终金蝶数据 + +![image-20260920114138689](F:\workspace\work\xdwl\dev\tms\tms-api\doc\docs\业务流程\2026-09-20-应付单与付款申请同步金蝶全链路业务流程(A102-2保存-A102-4查询-A103-1下推-A103-2回写).assets\image-20260920114138689.png) + + + + + +--- + +## 4. 环节二:付款管理「批量同步」(推送 A103-1) + +### 4.1 业务流程 + +1. 操作入口:付款管理页面勾选记录点「批量同步」(也支持单笔同步接口);权限码 `payment_application_sync`。 + +2. 批量语义: + - 逐单独立事务:**单条失败不中断整批**,失败单据落「生成失败(failed)」状态并返回失败原因; + - 整批共享档案缓存(合同/客商/货物类型/结算明细等),首单加载后全批复用; + - 批量开始前整批一次 A102-4 批量查询(每批最多 50 个防重键,入参来源同环节一第 5 步:防重键 + WLTMS + 自动翻页),预热应付单是否已生成的缓存,消除逐单远程查询。 + +3. 前置状态校验(逐单,不满足不调金蝶): + - 审批状态必须是 **审批通过(approved)**; + - 金蝶同步状态为 **synced / paying / paid / closed** 的幂等跳过,直接返回已有金蝶单据 id,不重复推送; + - 付款类型为**结算付款(settlement_payment)** 走本链路;预付款(project_advance / progress_advance)走 A103-3 直推,不在本文范围。 + +4. **结算类型路由 + 应付单已生成校验(A102-4)**:关联正式结算单结算类型为应收(receivable)时拦截报错(A302-2 未启用);随后按每张关联正式结算单的防重键调 A102-4 校验应付单已生成,任一未生成都报错提示「请先在正式付款单页面同步金蝶」——即环节一未完成时环节二不允许执行。 + +5. **A103-2 幂等预检(恢复勾稽)**:按付款申请号查询金蝶付款申请单是否已生成(防上次下推成功但本地回滚的断链场景)。已生成 → 直接回填单据 id 与编号,视为同步成功,跳过下推进第 8 步;未生成 → 继续。 + + A103-2 入参来源(幂等预检): + + | 接口字段 | 含义 | 来源 | 前端字段(来源页面) | + | --- | --- | --- | --- | + | awrp_src_code | 外部系统来源单据号(支持多值) | 付款申请号 paymentNo(付款申请单防重键) | 付款管理列表「单据号」paymentNo(系统生成,`payment-application.vue`) | + | awrp_src_sys | 外部系统编号 | 配置项 thirdParty.bfi.srcSys,默认 WLTMS | 无(后端配置) | + | pageNo / pageSize | 分页 | 自动翻页(页大小 200,最大 50 页保护) | 无(系统生成) | + + 取数规则:查询结果按来源单号归并,同一单号取最近修改(modifytime 最大)的一条;查到 `id`/`billno` 即回填并视为已生成。 + +6. **解析付款申请分录**:与环节一应付单明细分录同源(逐条应付货物费用行一条,校验项相同)。 + +7. **A103-1 付款申请保存(应付下推)**:每条分录先经 A102-4 按所属结算单防重键反查金蝶生成的付款计划分录标识(awrp_entry_code_p,同一结算单只反查一次,入参来源同环节一第 5 步),用于分录勾稽关联。 + + A103-1 主表入参来源: + + | 接口字段 | 含义 | 必填 | 来源 | 前端字段(来源页面) | + | --- | --- | --- | --- | --- | + | awrp_src_code | 外部系统来源单据号 | 是 | 付款申请号 paymentNo(付款申请单防重键) | 付款管理列表「单据号」paymentNo(系统生成) | + | awrp_src_sys | 外部系统编号 | 是 | 配置项,默认 WLTMS | 无(后端配置) | + | applydate | 申请日期 | 是 | 付款申请申请日期(空取当天,格式 yyyy-MM-dd HH:mm:ss) | 付款申请表单「申请日期」applyDate(`payment-application-form.vue`,页面禁用、自动当天) | + + 其余主表字段(付款组织、采购组织、请款事由、汇率表、付款渠道、申请人等)当前不传,由金蝶根据应付单自动带出。 + + A103-1 分录(entry,与应付单明细分录一一对应)入参来源: + + | 接口字段 | 含义 | 必填 | 来源 | 前端字段(来源页面) | + | --- | --- | --- | --- | --- | + | e_paymenttype_number | 付款类型.编码 | 是 | 常量 201(正式付款) | 无(后端常量) | + | awrp_finapbill_entryid | 结算单分录id(勾稽键) | 是 | 经 A102-4 反查该分录所属正式结算单应付单的付款计划分录标识 awrp_entry_code_p | 无(系统反查,前端不出现) | + | awrp_entry_code | 对方系统唯一行id(不能重复) | 是 | 应付货物费用行 id(与环节一应付单明细 awrp_entry_code 同源同值) | 无(系统生成) | + | e_assacct | 往来账户 | 是 | 付款申请收款账户(收款方银行账号) | 付款申请表单「收款账号」:选择收款方收款账号 receiptAccountId,落库字段 bankAccount(只读展示为 form.bankAccount) | + | e_bebank_number | 往来银行.编码 | 是 | **联调期临时固定传金蝶测试档案值 001110012009**(待建立银行档案编码映射) | 联调期无;真实对应付款申请表单「开户银行」bankName(随收款账号带出、只读) | + | e_applyamount | 申请金额 | 是 | 该分录应付金额(含税),与对应应付单分录一致 | 应收应付明细页面「调整后金额」afterAmount(非付款申请表单「申请付款金额」appliedAmount,分录金额不按申请金额分摊) | + | project_number | 项目.项目编码 | 是 | **联调期临时固定传 999** | 联调期无;真实来源=付款申请表单「所属项目」projectId → 项目申请「项目编号」projectCode | + | awrp_src_type | 外部单据类型 | 是 | 配置项(默认 0KzXs,待金蝶确认) | 无(后端配置) | + +8. **落库**:金蝶单据 id(星瀚付款申请单内部主键)、金蝶单据编号(下推返回的单据编号,立即可见)、同步状态 = synced。 + +9. 失败处理:任一步异常 → 单据事务回滚,独立事务落「生成失败(failed)」(仅审批通过单据生效),返回原因(含金蝶 errors 明细)。failed 单据可重新勾选同步,从断点续传。 + +### 4.2 最终金蝶数据 + +![image-20260920114206305](F:\workspace\work\xdwl\dev\tms\tms-api\doc\docs\业务流程\2026-09-20-应付单与付款申请同步金蝶全链路业务流程(A102-2保存-A102-4查询-A103-1下推-A103-2回写).assets\image-20260920114206305.png) + + + +--- + +## 5. 环节三:PaymentKingdeeResultTask 定时回写(A103-2 / A103-4) + +### 5.1 任务与触发 + +- 调度:Spring 定时任务,默认**每 10 分钟**一次;由 Nacos 配置 `thirdParty.bfi.payResult.syncEnabled`(开关,默认关)与 `thirdParty.bfi.payResult.cron` 控制。 +- 手动触发:付款管理「手动回写金蝶付款结果」接口,逻辑同一入口。 +- 任务只**查询回写,不主动推送**。 + +### 5.2 ① A103-2 付款申请单查询回写 + +1. 轮询范围(同时满足):审批状态 = approved,且金蝶同步状态 ∈ {synced, paying, failed}。paid / closed 为终态不再轮询;failed 纳入勾稽——金蝶查到说明上次推送实际成功(如请求超时但远端已落单),自动纠偏回填,防止人工重推产生重复单据。 +2. 查询方式:按付款申请号分批(每批 50)调 A103-2,自动翻页;同一来源单号取最近修改一条。 + + A103-2 入参来源(定时回写): + + | 接口字段 | 含义 | 来源 | 前端字段(来源页面) | + | --- | --- | --- | --- | + | awrp_src_code | 外部系统来源单据号(批量分批,每批 50,支持多值) | 待回写付款申请的付款申请号 paymentNo | 付款管理列表「单据号」paymentNo(系统生成) | + | awrp_src_sys | 外部系统编号 | 配置项 thirdParty.bfi.srcSys,默认 WLTMS | 无(后端配置) | + | pageNo / pageSize | 分页 | 自动翻页(页大小 200,最大 50 页保护) | 无(系统生成) | + +3. 回写内容(查到才更新,未查到状态不变): + - 金蝶单据编号 ← 返回 billno;金蝶单据 id ← 返回 id; + - 金蝶同步状态 ← 按「A103-2 状态映射」(见 6.3); + - 已付金额 ← 分录已付金额(e_paidamt)汇总;分录缺失且已付款时兜底取申请金额。 +4. 付款完成(paid)联动:更新履约付款数据(预结算预付记录、正式结算付款记录)。 + +### 5.3 ② A103-4 付款处理查询回填付款记录 + +1. 回填候选: + - 本轮 A103-2 查到付款状态 ≠ Unpaid(已开始付款)的单据; + - 已付款(paid)但尚无真实付款记录的单据(补偿轮询)。 +2. 查询方式:按**付款人(公司)编码**分组查询;业务日期窗口固定最近 1 个月(接口限制);自动翻页。 + + A103-4 入参来源: + + | 接口字段 | 含义 | 来源 | 前端字段(来源页面) | + | --- | --- | --- | --- | + | org_number | 付款人(公司)编码(支持多值,按编码分组查询) | 优先 A103-2 返回的付款组织编码 payorg_number,兜底按合同所属组织解析部门编码 | 兜底来源=合同管理页面「所属组织」organizationId → 系统部门管理「部门编码」deptCode | + | begin_bizdate / end_bizdate | 业务日期窗口 | 最近 1 个月(接口限制不超过 1 个月),截止当天 | 无(系统生成) | + | pageNo / pageSize | 分页 | 自动翻页(页大小 200,最大 50 页保护) | 无(系统生成) | + +3. 勾稽匹配:付款处理单的源单 id = 本地金蝶单据 id 优先;源单编号 = 本地金蝶单据编号 兜底。 +4. 回填:按付款处理单号 upsert 付款记录——实际付款金额、付款日期、付款处理单整体 JSON 留档(凭证号、银行流水、电子回单等);同时清理历史模拟(MOCK-PAY- 前缀)付款记录。 + +--- + +## 6. 状态限制与状态机 + +### 6.1 各环节调用前的状态门禁汇总 + +| 环节 | 单据 | 必须满足 | 幂等跳过 | 拦截报错 | +| --- | --- | --- | --- | --- | +| 环节一 正式结算单同步 | 正式结算单 | 审批状态=approved;结算类型=payable;有结算明细 | 同步状态=synced 直接返回已有编号 | 草稿/审批中/已驳回/已作废;应收结算类型;无结算明细;档案缺失(组织编码/币种/客商编码/货物编码/计量单位/货物费用行) | +| 环节二 付款管理同步 | 付款申请 | 审批状态=approved | 金蝶状态∈{synced,paying,paid,closed} 直接返回已有单据id | 未审批;结算类型为应收;关联正式结算单的应付单未生成(提示先完成环节一);档案缺失 | +| 环节三 定时回写 | 付款申请 | 审批状态=approved 且金蝶状态∈{synced,paying,failed} | paid/closed 终态不再轮询 | 单笔回写异常仅计数不中断整轮 | + +### 6.2 金蝶同步状态机(本地字段 kingdeeStatus / kingdeeSyncStatus) + +``` +unsynced 未生成 ──推送成功──> synced 已生成 ──paystatus=Inpayment──> paying 付款中 ──paystatus=Alreadypay──> paid 已付款(终态) + │ │ │ + │ └────billstatus=D──> closed 已关闭(终态)└──billstatus=D──> closed 已关闭(终态) + └──推送异常(事务回滚+独立事务落状态)──> failed 生成失败 ──重推成功或回写勾稽纠偏──> synced / paying / paid +``` + +### 6.3 A103-2 返回值 → 本地状态映射 + +| A103-2 返回 | 本地状态 | 是否终态(停止轮询) | +| --- | --- | --- | +| 付款状态 paystatus = Alreadypay | paid 已付款 | 是 | +| 单据状态 billstatus = D | closed 已关闭 | 是 | +| 付款状态 paystatus = Inpayment | paying 付款中 | 否(继续轮询) | +| 其余(billstatus=A/B/C 且 paystatus=Unpaid) | synced 已生成 | 否(继续轮询) | +| 查无此单(金蝶未生成) | 状态不变 | —(failed 单据保持等待人工处理) | + +--- + +## 7. 字典对照表 + +### 7.1 审批状态(approvalStatus,正式结算单/付款申请共用) + +| 字典值 | 含义 | +| --- | --- | +| draft | 草稿 | +| reviewing | 审批中(财务审核) | +| approved | 审批通过(**唯一允许同步金蝶的状态**) | +| returned | 已驳回 | +| voided | 已作废 | + +### 7.2 金蝶同步状态(kingdeeStatus / kingdeeSyncStatus,本地) + +| 字典值 | 含义 | 说明 | +| --- | --- | --- | +| unsynced | 未生成 | 新建单据默认 | +| synced | 已生成 | 推送成功,等待金蝶付款 | +| paying | 付款中 | A103-2 paystatus=Inpayment 部分付款 | +| paid | 已付款 | A103-2 paystatus=Alreadypay,终态 | +| closed | 已关闭 | A103-2 billstatus=D,终态 | +| failed | 生成失败 | 推送异常/金蝶返回失败,可重推 | + +### 7.3 金蝶付款申请单状态(A103-2 返回) + +| 字段 | 字典值 | 含义 | +| --- | --- | --- | +| paystatus 付款状态 | Alreadypay | 已付款 | +| paystatus 付款状态 | Inpayment | 部分付款(付款中) | +| paystatus 付款状态 | Unpaid | 未付款 | +| billstatus 单据状态 | A | 暂存 | +| billstatus 单据状态 | B | 已提交 | +| billstatus 单据状态 | C | 已审核 | +| billstatus 单据状态 | D | 已关闭 | + +### 7.4 金蝶单据枚举(常量维护,待金蝶侧确认后调整) + +| 类别 | 字典值 | 含义 | +| --- | --- | --- | +| 付款类型 e_paymenttype_number | 201 | 正式付款(结算付款) | +| 付款类型 e_paymenttype_number | 202 | 预付款(A103-3 路径使用) | +| 往来类型 asstacttype | bd_supplier | 供应商(合同类别非「客户合同」) | +| 往来类型 asstacttype | bd_customer | 客户(合同类别=「客户合同」) | +| 付款方式 purmode | CASH | 现购(金蝶自动 100% 推付款,当前使用) | +| 付款方式 purmode | CREDIT | 赊购(金蝶侧可调付款金额,备用) | +| 财务应付单单据类型 | 服务采购应付 → ApFin_service_BT_S | A102-2 当前默认(另有借贷项调整/费用采购应付/其他应付/产品委外应付/标准采购应付/采购费用应付/销售费用应付/资产采购应付共 9 项映射字典) | +| 财务应收单单据类型 | 服务销售应收 → arfin_sersal_BT_S 等 5 项 | 应收链路未启用 | +| 付款申请单单据类型 | 采购付款申请 → ap_payapply_BT_S、其他付款申请 → ap_payapply_oth_BT_S | 使用规则待业务确认 | +| 款项性质 payproperty_number | 2001 | 材料采购(暂定,待金蝶确认) | +| 外部单据类型 awrp_src_type | TMS财务应付单(A102-2)/ 配置值(A103-1) | 应付单/付款申请单来源标识,待金蝶确认 | + +### 7.5 TMS 业务类型 + +| 字段 | 字典值 | 含义 | +| --- | --- | --- | +| paymentType 付款类型 | settlement_payment | 结算付款(走 A102-4 预检 + A103-1 下推链路) | +| paymentType 付款类型 | project_advance | 项目预付款(走 A103-3 直推链路) | +| paymentType 付款类型 | progress_advance | 进度预付款(走 A103-3 直推链路) | +| settlementType 结算类型 | payable | 应付(对应财务应付单 A102-2,已启用) | +| settlementType 结算类型 | receivable | 应收(对应财务应收单 A302-2,未启用,同步拦截) | +| 请款事由 applycause | 结算付款申请 / 项目预付款申请 / 进度预付款申请 | 按 paymentType 路由(A103-3 路径使用) | + +### 7.6 防重键体系(awrp_src_sys = WLTMS) + +| 单据 | 防重键 awrp_src_code 取值 | 使用接口 | +| --- | --- | --- | +| 财务应付单 | 正式结算单号_结算明细单据号1_单据号2…(去重保序拼接) | A102-2 / A102-4 | +| 付款申请单 | 付款申请号(paymentNo) | A103-1 / A103-2 / A103-3 | +| 应付单付款计划分录 | awrp_entry_code_p:金蝶自动生成,A102-4 反查获得 | A103-1 勾稽关联 | +| 分录唯一标识 | awrp_entry_code:应付货物费用行 id(雪花 id) | A102-2 明细 / A103-1 分录 | + +--- + +## 8. 异常与恢复场景 + +| # | 断点场景 | 金蝶侧实际 | 本地状态 | 恢复路径 | +| --- | --- | --- | --- | --- | +| 1 | 环节一推送前校验失败(档案缺失等) | 无应付单 | 结算单状态不变 | 补档案后重新同步 | +| 2 | A102-2 成功后中断(返回未落库) | 应付单已生成 | 结算单未 synced | 重新同步:A102-4 预检命中 → 幂等跳过保存,直接回填编号 | +| 3 | 环节二前置校验:应付单未生成 | 无应付单 | 付款申请状态不变 | 报错提示先在正式付款单页面同步金蝶(先完成环节一) | +| 4 | A103-1 远端已落单但本地回滚 | 应付单+付款申请单均已生成 | failed | 重推:A102-4 命中跳过前置 → A103-2 勾稽命中 → 回填置 synced;或定时任务自动纠偏 | +| 5 | 同步成功但状态待推进 | 付款申请单已生成 | synced | 定时任务 A103-2 回写驱动 synced→paying→paid/closed,A103-4 回填付款记录 | +| 6 | 金蝶反审核删除后重推 | 无单据 | failed/synced | 预检查无 → 重新推送生成新单据 | +| 7 | failed 且金蝶确无单据 | 无单据 | failed | 每轮任务空查一次 A103-2,状态保持,等待人工处理(已知边界) | + +--- + +## 9. 涉及功能与代码位置(索引用) + +| 内容 | 位置 | +| --- | --- | +| 环节一入口 | `FormalSettlementServiceImpl.syncKingdee`(正式付款单页面「同步金蝶」) | +| 环节二入口 | `PaymentApplicationController`(/sync-kingdee、/sync-kingdee-batch、/sync-kingdee-result)→ `PaymentApplicationServiceImpl`(syncKingdee / doSyncKingdee / syncKingdeeBatch) | +| 环节三定时任务 | `PaymentKingdeeResultTask` → `PaymentApplicationServiceImpl.syncKingdeePaymentResult`(applyPayApplyResult / applyPayBillRecord) | +| A102-2/A102-4 公共支撑 | `KingdeeApBillSyncSupport`(防重键组装、A102-4 查询/反查、A102-2 组装保存、分录解析、档案取数) | +| 金蝶常量/字典 | `KingdeeBfiConstant`;配置 `thirdParty.bfi.*`(srcSys=WLTMS 等) | +| BFI 客户端 | `blade-third-party-api/blade-bfi-api`:IBfiClient / BfiServiceImpl(saveApBill=A102-2、queryApBill=A102-4、pushPayApply=A103-1、queryPayApply=A103-2、queryPayBill=A103-4) | + +## 10. 遗留注意事项 + +1. **联调临时取值**:A102-2 明细物料/计量单位、A103-1 往来银行编码、双方项目编码当前传金蝶测试档案值(SP0324 / TON / 001110012009 / 999),档案对齐后需恢复真实取值。 +2. **应收链路未启用**:结算类型为应收的单据在两侧均拦截报错(A302-2 待业务确认)。 +3. **kingdeeBillNo 阶段语义**:正式结算单上为应付单编号;付款申请上先为下推返回的付款申请单编号,后续被 A103-2 回写覆盖校准。 +4. **failed 空查**:确无金蝶单据的 failed 付款申请会被定时任务每轮空查一次,量大可考虑窗口截断(当前保持简单)。 +5. **付款申请单/收入确认单类型映射、预付抵扣 preentry、结算方式/税率**:均待业务或金蝶侧确认,本期不传/未启用。 diff --git a/doc/docs/业务流程/2026-09-20-应收结算与开票申请同步金蝶设计(A302-2 + A302-4 + A303-1+A303-5).md b/doc/docs/业务流程/2026-09-20-应收结算与开票申请同步金蝶设计(A302-2 + A302-4 + A303-1+A303-5).md new file mode 100644 index 00000000..dca28067 --- /dev/null +++ b/doc/docs/业务流程/2026-09-20-应收结算与开票申请同步金蝶设计(A302-2 + A302-4 + A303-1+A303-5).md @@ -0,0 +1,262 @@ +# 应收结算与开票申请同步金蝶设计(A302-2/A302-4 + A303-1/A303-5) + +> 日期:2026-09-20 +> 模块:tms-api(blade-transport、blade-service-api/blade-transport-api、blade-third-party-api/blade-bfi-api 零改动) +> 关联文档:`tms-api/doc/docs/业务流程/2026-09-20-应付单与付款申请同步金蝶全链路业务流程(A102-2保存-A102-4查询-A103-1下推-A103-2回写).md` +> 状态:待评审 + +--- + +## 1. 背景与目标 + +正式结算单的应收(receivable)链路与开票申请的金蝶同步目前未接入:应收结算单「同步金蝶」直接拦截报错;开票申请「同步金蝶」为模拟实现(生成 `K3INV+时间戳` 假单号,未真正调金蝶)。BFI 客户端侧 `saveArBill`(A302-2)、`queryArBill`(A302-4)、`saveInvoiceApply`(A303-1)、`queryInvoiceApply`(A303-4)、`queryInvoiceApplyInvoice`(A303-5) 均已实现但无调用方。 + +本次目标: + +1. **应收结算链路**:应收正式结算单「同步金蝶」→ A302-2 财务应收单保存(前置 A302-4 幂等预检),完全对齐应付链路(A102-2/A102-4)模式。 +2. **开票申请链路真实化**:开票申请「同步金蝶」替换为真实 A303-1 推送(前置校验关联应收单已生成 + A303-4 幂等预检)。 +3. **定时回写扩展**:复用 `PaymentKingdeeResultTask`,新增 ② A302-4 应收勾稽纠偏、③ A303-5 开票结果回写(发票结果落新表,状态推进 `invoiced` 终态)。 + +## 2. 范围 + +**做**:上述三条链路;新表 `blade_invoice_application_invoice`;`KingdeeBfiConstant`/`BfiProperties` 常量与配置扩展;定时任务编排扩展;手动触发接口。 + +**不做(后续迭代)**:收票 A303-2/A302-6、收款认领 A301-1/2/3、客商风险等级与高风险管控、履约计划、附件推送、收票认领先进先出规则、应付链路任何改动、BFI 客户端任何改动。 + +## 3. 总体原则(与既有链路一致) + +1. **先内后外**:单据先在 TMS 审批通过(approved)再推金蝶。 +2. **以金蝶侧实际生成为准**:每次推送前按防重键(awrp_src_sys=WLTMS + awrp_src_code)查金蝶幂等预检,已生成则回填跳过;推送失败可断点续传。 +3. **failed 纳入定时勾稽纠偏**:远端实际成功但本地回滚的单据由定时任务查到后自动纠偏,防止人工重推产生重复单据。 + +## 4. 业务流程 + +``` +【环节一】正式结算单「同步金蝶」(人工,单笔) + 审批通过的应收正式结算单 + → 校验:approved;settlementType=receivable(本次放行);非 synced;有结算明细 + → 组装防重键(正式结算单号_结算明细单据号…,与应付同格式) + → A302-4 幂等预检 ──已生成──> 幂等跳过保存,直接回填已有单号 + → A302-2 财务应收单保存(明细分录逐应收货物费用行) + → 回写结算单:kingdeeBillNo + synced + syncedTime + +【环节二】开票申请「同步金蝶」(人工,替换 K3INV 模拟) + 审批通过的开票申请 + → 前置校验:A302-4 校验关联应收结算单的应收单已生成(未生成拦截,提示先完成环节一) + → A303-4 幂等预检(billno=applicationNo)──已生成──> 直接回填,视为已同步 + → A303-1 开票申请单新增(购销方/商品行分录) + → 回写开票申请:kingdeeBillNo(金蝶真实单号)+ synced + +【环节三】PaymentKingdeeResultTask 定时(每10分钟,编排三个子流程,各自 try/catch 互不影响) + ① A103-2/A103-4 付款回写(现状不动) + ② A302-4 应收勾稽纠偏:轮询 approved+receivable+状态∈{synced,failed}, + 按防重键批量查询:查到→补金蝶单号、failed纠偏为synced;查不到→不变(不推进状态机) + ③ A303-5 开票结果回写:轮询 approved+kingdeeStatus=synced+金蝶单号非空的开票申请, + serialNo 批量查询(≤50/批)+ 销方税号:查到发票→逐张落新表(按发票号幂等) + → 申请单 kingdeeStatus=invoiced(终态);查不到→保持 synced 继续轮询 +``` + +收款由收款认领链路(A301-x)负责,不在本次范围;应收单的审核/核销状态不回写。 + +## 5. 状态机与防重键 + +### 5.1 状态机 + +``` +应收结算单 kingdeeSyncStatus(无新状态值): + unsynced ──A302-2成功──> synced(终态) + └──推送异常──> failed ──重推 或 定时A302-4勾稽纠偏──> synced + +开票申请 kingdeeStatus(新增枚举值 invoiced,varchar 无需 DDL): + unsynced ──A303-1成功──> synced ──A303-5查到发票──> invoiced(终态) + └──推送异常──> failed ──人工重推(A303-4 预检命中→幂等回填)──> synced + 注:定时任务只轮询 synced(failed 无真实金蝶单号,A303-5 查询无入口,纠偏走重推预检) +``` + +### 5.2 防重键体系(awrp_src_sys = WLTMS) + +| 单据 | awrp_src_code | 使用接口 | +| --- | --- | --- | +| 财务应收单 | 正式结算单号_结算明细单据号1_单据号2…(去重保序下划线拼接) | A302-2 / A302-4(预检+定时勾稽) | +| 开票申请单 | 开票申请单号 applicationNo(A303-1 billno 候选键) | A303-1 / A303-4(幂等)/ A303-5(serialNo) | +| 分录唯一标识 | 应收货物费用行 id(雪花 id,awrp_entry_code) | A302-2 明细 | + +## 6. 接口字段映射 + +### 6.1 A302-2 财务应收单保存(主表) + +| 接口字段 | 取数 | 备注 | +| --- | --- | --- | +| billtype_number | 常量「服务销售应收」→ arfin_sersal_BT_S | AR_BILL_BILLTYPE_MAP 已有;映射名新增常量,待金蝶确认 | +| org_number / recorg_number | 合同所属组织 deptCode | 复用 bfiOrgNumber | +| bizdate | 同步当日 yyyy-MM-dd HH:mm:ss | | +| asstacttype | bd_customer(固定客户) | | +| asstact_number | 结算单付款方 payerName → 客商档案(全称/简称)客商编码 | 未维护报错;与应付(合同乙方)对称 | +| paymode | CASH(现销) | 待金蝶确认 | +| payproperty_number | 1001(一般销售) | 与应付 2001 区分,待金蝶确认 | +| currency_number | 合同结算币种(RMB→CNY) | 复用 bfiCurrency | +| isincludetax / ispricetotal / isintax | true / true / false | 与应付一致 | +| awrp_src_sys / awrp_src_code / awrp_src_type | WLTMS / 防重键 /「TMS财务应收单」 | src_type 新常量,待金蝶确认 | +| awrp_src_bt / awrp_src_con_no | 本期不传 | 与应付一致,保留注释 | +| duedate / 结算方式 / 税率编码 | 本期不传 | 待业务确认(遗留清单) | + +### 6.2 A302-2 明细分录(entry,逐应收货物费用行,不聚合) + +| 接口字段 | 取数 | 备注 | +| --- | --- | --- | +| e_material_number | 货物类型→货物类型档案货物编码 | 联调期固定 SP0324(同应付) | +| e_measureunit_number | 运费计算单位(兜底数量单位) | 联调期固定 TON(同应付) | +| e_quantity | 运输数量,兜底 1 | | +| e_unitprice | 运输单价 | ispricetotal=true 仅参考 | +| e_recamount | 应收金额(含税)= 费用行调整后总金额 afterAmount | 对应应付 e_pricetaxtotal 语义 | +| e_amount | 金额(不含税)= 结算费用行不含税回链 | 兜底 0,与应付同源逻辑 | +| project_number | 联调期 999 | | +| awrp_entry_code | 应收货物费用行 id | 候选键 | + +收款计划 planentity 不传,由金蝶按明细自动汇总。 + +### 6.3 A303-1 开票申请单新增(主表) + +| 接口字段 | 取数 | 备注 | +| --- | --- | --- | +| orgid_number | 申请部门 deptCode | deptId→SysCache | +| billno | applicationNo | 候选键,天然唯一 | +| billdate | applicationDate yyyy-MM-dd HH:mm:ss | | +| billproperties | 1(正数) | | +| invoicetype | electronic_normal→10xdp、electronic_special→08xdp | 数电普票/专票 | +| taxationstyle | 0(普通征税) | | +| buyeremail | departmentEmails | 必填 | +| buyerproperty | 0(企业) | | +| buyertaxno / buyername | 申请单快照 taxpayerNo / receiverName | | +| salername / salertaxno | 申请单快照 issuerName(结算单收款方/开票单位);按开票单位查客商档案(全称/简称)取统一社会信用代码 | A303-5 的 sellerTaxpayerId 同源,不走 Nacos 配置 | +| buyeraddr + showbuyeraddresstel / buyerbank + showbuyerbank | 申请单受票方快照 注册地址+联系电话、开户行+银行账号(新增/编辑选择开票信息时落库) | 展示开关固定 true;DTO 非必填,金蝶是否强校验待联调确认 | +| saleraddr + showsaleraddresstel / salerbank + showsalerbank | 客商档案注册地址+联系电话、客商开票信息(启用默认优先)开户行+银行账号;两个展示开关固定 true | DTO 标非必填,数电专票金蝶强校验(缺失即报错),随地址/银行值一同送 | +| totalamount | invoiceAmount(价税合计) | | +| invoiceamount / totaltax | 明细不含税合计 / 税额合计 | | +| customnameid_number | receiverCustomerId → 客商档案客商编码 | 未维护报错 | +| awrp_project_number | 联调期 999 | | +| awrp_orgfield_number | 申请部门 deptCode | | +| awrp_src_bt | ar_finarbill | 上游财务应收单 | +| awrp_src_con_no | 关联结算单金蝶应收单号 | 多张取第一张,拼接规则联调确认 | +| billsource / systemsource | 3(固定)/ 配置 srcSys | systemsource 待联调确认 | +| 合同号/合同金额/购方地址银行等选填 | 本期不传 | 保留注释(遗留清单) | + +### 6.4 A303-1 分录(sim_original_bill_item,逐页面「开票内容」商品行一条) + +取数口径:**商品行(`blade_invoice_application_line`)落库什么就传什么**,不回查开票项目主数据、不按开票明细→费用行展开分摊。 + +| 接口字段 | 取数 | 备注 | +| --- | --- | --- | +| goodsname / goodssimplename | line.goodsName / line.goodsCategory | 货物或服务简称 / 商品和服务分类 | +| taxratecodeid_number / goodscode | line.taxClassificationCode(商品行快照,同源) | 新增/编辑开票申请选择开票内容时随前端落库,缺失即报错提示重选;goodscode 联调确认必传 | +| num | line.quantity | 数量为 0 兜底 1 | +| unit | line.unit | 空兜底「个」 | +| taxunitprice | line.totalAmount ÷ quantity | 6 位小数;数量为 0 时按兜底 1 计算 | +| unitprice | line.unitPriceNoTax | 不含税单价 | +| taxamount / amount / tax | line.totalAmount / line.amountNoTax / line.taxAmount | 价税合计 / 不含税金额 / 税额 | +| taxrate | line.taxRate ÷ 100(0-100 转小数) | 税率为空即报错;行税率是百分数,与开票项目 default_tax_rate(小数)单位不同 | +| remark | line.remark | | +| awrp_entry_code | line.id | 外部来源分录标识(候选键),雪花 id 天然唯一 | +| benchmark / rowtype | 0(数量基准)/ 2(商品行) | rowtype 联调校准 | +| awrp_src_con_eno | 待联调确认 | 先不传,见遗留清单 | +| awrp_src_type | 配置 thirdParty.bfi.invoiceApply.srcType(默认 0KzXs) | 与付款申请 payApply.srcType 对齐 | + +### 6.5 A302-4 / A303-5 回写取数 + +- **A302-4 勾稽**:按 awrp_src_code 归并,命中即以 `billno` 回填/纠偏,其余字段不取用。 +- **A303-5**:`serialNo`(金蝶开票申请单号,≤50/批逗号拼接)+ `sellerTaxpayerId`(与 A303-1 同源:按各申请单开票单位查客商档案统一社会信用代码,按税号分组分批查询,无法解析的申请单跳过并告警)。返回为票易通原文 `List`:整体 JSON 留档 `raw_json`,宽松提取发票号码/开票日期/价税合计三个字段(Jackson 泛型解析,具体字段名联调校准后收敛为常量映射)。 + +## 7. 代码组织与改动清单 + +### 7.1 blade-transport(主改动) + +| # | 文件 | 改动 | +| --- | --- | --- | +| 1 | `support/KingdeeArBillSyncSupport`(新建) | 应收单防重键组装(复用 ExternalSrcCodeSupport.buildSrcCode)、A302-4 批量查询 queryArBillExistNos、应收分录解析 resolveArEntryGroups(取数源同应付:blade_receivable_payable_cargo_fee)、pushArBill(A302-4 预检 + A302-2 组装保存)。公共档案取数(bfiOrgNumber/bfiCurrency/requireContract/客商编码查询)经注入 KingdeeApBillSyncSupport 复用,不复制不修改现有类 | +| 2 | `FormalSettlementServiceImpl.syncKingdee` | 移除应收拦截;按 settlementType 路由 payable→pushApBill / receivable→pushArBill;落库逻辑共用 | +| 3 | `FormalSettlementServiceImpl.syncKingdeeArResult`(新增) | A302-4 应收勾稽纠偏(轮询+批量查+回填纠偏),接口方法+Controller 手动触发端点 `/sync-kingdee-ar-result` | +| 4 | `InvoiceApplicationServiceImpl.syncKingdee` | 模拟实现替换为真实推送:A302-4 前置校验(关联应收单已生成)→ A303-4 幂等预检 → A303-1 保存 → 回写 | +| 5 | `InvoiceApplicationServiceImpl.syncKingdeeInvoiceResult`(新增) | A303-5 回写:轮询+批量查+发票落新表+invoiced 推进;Controller 手动触发端点 `/sync-kingdee-invoice-result` | +| 6 | `task/PaymentKingdeeResultTask` | 注入 IFormalSettlementService、IInvoiceApplicationService,编排 ①付款回写 ②应收勾稽 ③开票结果 三个子流程,各自独立 try/catch,汇总日志 | +| 7 | `constant/KingdeeBfiConstant` | 新增:BFI_AR_BILL_BILLTYPE_NAME「服务销售应收」、BFI_AR_BILL_PAY_PROPERTY=1001、BFI_AR_BILL_PAY_MODE_CASH=CASH、BFI_AR_BILL_SRC_TYPE「TMS财务应收单」、KINGDEE_STATUS_INVOICED=invoiced、发票类型映射(electronic_normal→10xdp / electronic_special→08xdp) | +| 8 | `config/BfiProperties` | 新增 invoiceApply 节点:srcType(默认 0KzXs)。销方名称/税号、税收分类编码不走配置:销方名称取申请单开票单位快照、税号按开票单位查客商档案统一社会信用代码、税收分类编码取商品行开票内容快照 | +| 9 | `mapper/InvoiceApplicationInvoiceMapper`(新建) | 接口 + XML 同包 | + +### 7.2 blade-transport-api + +| # | 文件 | 改动 | +| --- | --- | --- | +| 10 | `pojo/entity/InvoiceApplicationInvoice`(新建) | TenantEntity:invoiceApplicationId、serialNo、invoiceNo、invoiceDate、invoiceAmount、rawJson | +| 11 | `IFormalSettlementService` / `IInvoiceApplicationService` | 新增 syncKingdeeArResult / syncKingdeeInvoiceResult 接口方法 | +| 12 | 开票申请详情 VO | 附带发票结果列表(@TableField(exist=false) 或关联查询),前端展示开票结果 | + +## 8. 数据库变更 + +新表 `blade_invoice_application_invoice`(MySQL 版,正式提交时同步 doc/sql 全部 7 库脚本;无菜单 SQL): + +```sql +CREATE TABLE `blade_invoice_application_invoice` ( + `id` bigint NOT NULL COMMENT '主键', + `tenant_id` varchar(12) DEFAULT '000000' COMMENT '租户ID', + `invoice_application_id` bigint NOT NULL COMMENT '开票申请id', + `serial_no` varchar(64) DEFAULT NULL COMMENT '金蝶开票申请单编号', + `invoice_no` varchar(64) NOT NULL COMMENT '发票号码', + `invoice_date` datetime DEFAULT NULL COMMENT '开票日期', + `invoice_amount` decimal(18,2) DEFAULT NULL COMMENT '价税合计', + `raw_json` text COMMENT 'A303-5返回原文JSON留档', + `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 NULL COMMENT '状态', + `is_deleted` int DEFAULT 0 COMMENT '是否已删除', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_application_invoice` (`invoice_application_id`,`invoice_no`), + KEY `idx_iai_application_id` (`invoice_application_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='开票申请金蝶发票结果'; +``` + +落库幂等:按(invoice_application_id, invoice_no)唯一键,已存在跳过。开票结果不反写结算单——结算单开票状态仍由收票认领链路(InvoiceReceipt)驱动,避免双链路冲突。 + +## 9. 配置项(Nacos thirdParty.bfi.*) + +```yaml +thirdParty: + bfi: + srcSys: WLTMS # 已有 + payResult: + syncEnabled: true # 已有,任务总开关(三个子流程共用) + cron: 0 */10 * * * ? # 已有 + invoiceApply: + srcType: 0KzXs # 新增:分录外部单据类型,与 payApply.srcType 对齐(有默认值,一般无需配置) +``` + +销方名称/销方税号、税收分类编码**不再需要配置**:开票申请「新增」页选择结算单即带出开票单位(销方),销方税号按开票单位查客商档案统一社会信用代码;税收分类编码随「开票内容」选择快照到商品行(blade_invoice_application_line.tax_classification_code,增量脚本 `blade_invoice_application_line_tax_code_20260921.sql`)。 + +## 10. 异常与恢复场景 + +| # | 断点场景 | 金蝶侧实际 | 本地状态 | 恢复路径 | +| --- | --- | --- | --- | --- | +| 1 | 应收单推送前校验失败(档案缺失等) | 无应收单 | 结算单状态不变 | 补档案重新同步 | +| 2 | A302-2 成功但本地回滚 | 应收单已生成 | 未 synced | 重新同步 A302-4 命中→幂等回填;或定时任务纠偏 | +| 3 | 开票申请同步时应收单未生成 | — | 不变 | 拦截报错,先完成环节一 | +| 4 | A303-1 成功但本地回滚 | 开票申请单已生成 | 未 synced | 重推时 A303-4 命中→幂等回填 | +| 5 | 金蝶开票处理中 | 无发票 | synced | A303-5 每轮空查,直到开出票 | +| 6 | A303-5 查到发票 | 发票已开出 | synced | 落发票结果表→invoiced 终态 | + +任一子流程异常只计数不中断整轮(与付款回写一致)。 + +## 11. 验证 + +1. `mvn clean compile -DskipTests` 编译通过(AGENTS.md 要求;测试由用户执行)。 +2. 每个被改符号编辑前跑 GitNexus impact(upstream),提交前 detect_changes(仓库强制规范)。 +3. 联调验证顺序:应收结算单同步(A302-2/4)→ 开票申请同步(A303-1/4)→ 手动触发定时回写(A302-4 勾稽 + A303-5 回写)。 + +## 12. 遗留与待确认清单 + +1. **A302-2**:paymode CASH、款项性质 1001、单据类型「服务销售应收」、awrp_src_type「TMS财务应收单」、到期日/结算方式/税率编码——均待金蝶确认。 +2. **A303-1**:rowtype、awrp_src_con_eno、购方地址/银行展示、systemsource 取值、多结算单单号拼接规则、部门邮箱分隔符——联调校准。 +3. **A303-5**:返回原文字段名(发票号/开票日期/金额)联调后从宽松解析收敛为固定映射。 +4. ~~**销方主体**:当前走配置~~ 已改为单据取数:销方名称=开票申请开票单位快照(结算单收款方带出),销方税号=开票单位对应客商档案统一社会信用代码(要求我方开票主体在客商档案中维护)。 +5. **应收结算单开票完成状态**:本次不反写结算单;如业务需要"已开票"标识,后续在结算单侧增量。 diff --git a/doc/docs/业务流程/2026-09-20-收票管理同步金蝶全票池发票(A801-1查询+批量同步+定时绑定关联)业务流程.md b/doc/docs/业务流程/2026-09-20-收票管理同步金蝶全票池发票(A801-1查询+批量同步+定时绑定关联)业务流程.md new file mode 100644 index 00000000..2466978a --- /dev/null +++ b/doc/docs/业务流程/2026-09-20-收票管理同步金蝶全票池发票(A801-1查询+批量同步+定时绑定关联)业务流程.md @@ -0,0 +1,213 @@ +# 收票管理同步金蝶全票池发票(A801-1 查询 + 批量同步 + 定时绑定关联)业务流程 + +> 适用系统:TMS(BladeX 微服务平台,服务:blade-transport) +> 对接系统:金蝶发票云旗舰版·票易通(piaozone)全票池,经业财融合 BFI 网关(rim 契约)透传 +> 关联文档:`doc/docs/TMS业财融合-集成流程图文字版与业务流程梳理.md`(进项收票 A801-1 章节)、`2026-09-17-BFI分页查询通用翻页工具(BfiUtil-queryAllPages)技术文档.md`、`2026-09-17-付款管理金蝶付款结果定时回写(A103-2付款申请单查询+A103-4付款处理查询)技术文档.md`、`2026-09-17-客商档案同步金蝶(A902-1查询回填+A902-2审批推送)技术文档.md` +> 最后更新:2026-09-20 +> +> **⚠️ 退役声明(2026-09-22)**:本文档描述的**链路二(定时任务全量拉取、增量窗口、批量 upsert、定时绑定、漏绑补偿)及票种/客商税号双白名单、手动全量同步端点、按票号补查已全部退役删除**,选票改为实时查询 A801-1、镜像降级为登记存根(保存时单票重查落库),终态口径见 `2026-09-22-金蝶票池选票统一查镜像(收票登记+结算单认领+按票号补查+统一组件)业务流程.md`(已改写为实时查询版)。**仍然有效的部分**:链路一的手动逐单同步 `POST /invoice-receipt/sync-kingdee`(职责收敛为刷新已登记票的镜像行)、A801-1 契约与 `queryPoolByInvoiceNo` 单查口径、镜像表结构(含 `serial_no/buyer_tax_no/saler_tax_no/check_status` 等列,图05 推送链消费 `invoice_type_code/items_json`)。下文与退役链路相关的叙述保留作历史口径,不再代表现状。 + +--- + +## 1. 背景与范围 + +收票管理(`/payment/invoice-receipt`)当前依赖本地镜像表 `blade_kingdee_invoice_pool`(金蝶进项发票票据池镜像)选票登记,镜像数据无自动采集来源;「批量同步」按钮(权限码 `invoice_receipt_sync`)调用的 `syncKingdee` 仅回读本地镜像复制 `kingdeeBillNo/kingdeeStatus`,**并未真正请求金蝶**。本文档建设两条真实同步链路: + +- **链路一(手动批量同步)**:收票管理列表勾选「审批通过」的收票登记 → 批量同步 → 后端逐单按发票号码调用 **A801-1 查询全票池发票数据** → 刷新票据池镜像并绑定/回填同步状态。 +- **链路二(定时任务)**:新增定时任务分页拉取 A801-1 全票池数据(增量窗口)→ 按 `invoiceNo` upsert 镜像 → **与收票登记做绑定关联**(按发票号码回填 `kingdeeInvoicePoolId`、金蝶流水号与同步状态),覆盖金蝶侧采票滞后于收票登记的场景。 + +不在本期范围:A801-2 发票附件文件下载(`attachmentsJson` 仅记录 `downloadUrl` 备用)、收票单推送金蝶(A303-2)、收票认领结果与正式结算单自动认领。 + +## 2. 对接接口(A801-1) + +| 项 | 说明 | +| --- | --- | +| 接口 | A801-1 查询全票池发票数据(票易通 rim 契约,经 BFI 网关 `/kapi/app/rim/message` 透传) | +| 既有能力 | `IBfiService.queryInvoicePool(BfiInvoicePoolQueryDTO)` 单页查询,本次**无改动复用** | +| 请求体 | `{ messageType: "invoiceQuery", messageId: <服务端填充>, queryParam: { pageNo, pageSize, ...筛选 } }`,`pageSize` 上限 500,`pageNo` 从 1 开始 | +| 响应 | `{ data: [发票...], errorCode, message, status }`,**不返回总数、无末页标识**;成功码 0000 由 `BfiServiceImpl.commonCall` 统一处理 | +| 关键查询条件 | `invoiceNo`(发票号码,手动同步按此精确查询)、`updateTime/updateTimeEnd`(更新时间窗口,定时任务增量拉取)、`buyerTaxNo/salerTaxNo`(税号)、`expenseStatus`(单据使用状态)、`invoiceDateStart/End`(开票日期区间) | +| 关键返回字段 | `invoiceNo` 发票号码、`serialNo` 发票流水号(**票池唯一标识**)、`invoiceDate`、`invoiceType`、`totalAmount` 价税合计、`taxAmount`、`taxRate`、`buyerName/buyerTaxNo`、`salerName/salerTaxNo`、`checkStatus` 查验状态、`delete` 是否作废删除、`downloadUrl` | + +## 3. 业务流程 + +### 3.1 集成链路总览 + +``` +金蝶发票云全票池(A801-1 查询全票池发票数据) + │ + ├── 链路一:手动批量同步(收票管理列表「批量同步」按钮) + │ 勾选 approvalStatus = approved 的收票登记(前端已限定) + │ → 逐单 POST /invoice-receipt/sync-kingdee + │ → 后端按登记发票号码精确查询 A801-1 + │ → 命中:upsert 镜像 + 绑定 kingdeeInvoicePoolId + 回填金蝶流水号/同步状态 + │ → 未命中:置 unsynced,流水记录原因「金蝶票池未查询到」 + │ + └── 链路二:定时任务(InvoiceReceiptKingdeeSyncTask,默认关闭) + → A801-1 按 updateTime 增量窗口分页拉取(pageSize 500,最大 50 页保护) + → 按 invoiceNo upsert 镜像 blade_kingdee_invoice_pool + → 绑定关联:未绑定镜像的收票登记按 invoiceNo 匹配回填 + → 已绑定登记:跟随镜像刷新金蝶流水号/同步状态(delete=1 → failed) +``` + +### 3.2 链路一:手动批量同步时序 + +``` +invoice-receipt.vue InvoiceReceiptController InvoiceReceiptServiceImpl 金蝶(BFI网关) + │ 批量同步按钮 │ │ │ + │ 勾选 approved 行(前端校验) │ │ │ + │ 逐单 syncKingdee(id) ─────────>│ POST /invoice-receipt/ │ │ + │ │ sync-kingdee?id=xxx │ │ + │ │ ──────────────────────────────>│ ①校验 approvalStatus=approved │ + │ │ │ ②按 invoiceNo ───────────────>│ A801-1 + │ │ │ <── 票池发票(0/1条)──────────│ + │ │ │ ③命中:upsert 镜像(按 invoiceNo)│ + │ │ │ 绑定 kingdeeInvoicePoolId │ + │ │ │ 回填 kingdeeBillNo=serialNo │ + │ │ │ 刷新 kingdeeStatus;写 sync 流水│ + │ │ │ ④未命中:kingdeeStatus=unsynced │ + │ │ │ 写 sync 流水(原因) │ + │ <── 同步结果(金蝶流水号)──────────────────────────────────────│ │ +``` + +前端 `handleBatchSync` 现有逻辑(`invoice-receipt.vue:366`)保持不变:仅允许勾选「审批通过」行、`Promise.all` 逐单调单条接口,失败由 axios 拦截器统一提示。 + +### 3.3 链路二:定时绑定时序 + +``` +InvoiceReceiptKingdeeSyncTask InvoiceReceiptServiceImpl 金蝶(BFI网关) + │ @Scheduled cron(可配) │ │ + │ syncEnabled 开关校验 │ │ + │ syncKingdeeInvoicePool() ───────>│ ①增量窗口(滚动近N天)+ 发票种类/客商税号白名单 │ + │ │ ②A801-1 分页拉取(pageSize 500,最大50页)────>│ + │ │ <── 票池发票列表 ────────────────────────────│ + │ │ ③白名单过滤(购销双方税号须命中客商档案)后按号批量 upsert 镜像│ + │ │ ④绑定回填批量落库(页级事务,批量失败整页跳过) │ + │ │ ⑤补偿绑定:未绑定登记按发票号码精确补查(不限窗口)──>│ + │ │ 单轮限量、批量落库,直到绑定成功为止 │ + │ <── 执行摘要日志(拉取/过滤/镜像/绑定/刷新/失败/补偿统计)│ │ +``` + +- 任务类仿 `PaymentKingdeeResultTask` / `CustomerKingdeeSyncTask`:业务逻辑收敛在 `IInvoiceReceiptService.syncKingdeeInvoicePool()`,任务类只做开关与调度,便于手动触发联调。 +- 增量窗口约定:**每轮固定滚动近 N 天**(可配,建议 30 天),不依赖"上次成功时间点"——单轮失败不会漏,同一批数据会被后续每轮反复幂等覆盖。 +- **客商税号白名单(购、销双方均须命中)**:每轮加载客商档案有效税号(`unifiedCreditCode`,status=1),窗口拉回的发票须**购方税号与销方税号同时命中**该集合才入镜像、参与绑定;我方主体税号同样维护在客商档案中,故进项票要求「购方=我方、销方=建档客商」同时成立。白名单外发票不入镜像不绑定;**集合为空不限制**(档案未维护时兼容全量)。由此推论:**供应商未在客商档案维护统一社会信用代码,其进项票不会进票据池镜像,前端选票也就选不到**——档案维护是同步的前置条件,可用每页过滤明细日志的「客商税号白名单外 N 张」定位漏档。`buyerTaxNo`/`salerTaxNo` 均为 rim 契约单值参数,无法按税号集合远程查询,故采用窗口拉取 + 内存过滤的等价实现。 +- **防漏绑补偿**:滚动窗口唯一的漏绑缝隙是「持续失败超过 N 天(窗口滑出 updateTime)」,第 ⑤ 步兜底——仍为未绑定(`kingdeeInvoicePoolId` 为空)的登记按发票号码精确补查 A801-1(优先复用本地镜像,缺失才远程),单轮限量(`compensateLimit`,默认 200,按创建时间先进先出),绑定成功即移出补查队列;金蝶票池暂无该发票时跳过不写库、待后续轮次。**手动同步与补偿绑定不受税号白名单约束**(票由登记侧发起,属我方票)。 +- 提供手动触发端点 `POST /invoice-receipt/sync-invoice-pool`(权限码复用 `invoice_receipt_sync`),返回执行摘要,供联调与补偿。 + +### 3.4 匹配与幂等规则 + +| 约定 | 说明 | +| --- | --- | +| 匹配键 | `invoiceNo`(发票号码)。收票登记侧 `assertInvoiceUnused` 已保证一张发票仅可登记一次,镜像侧按 `invoiceNo` upsert 天然幂等 | +| 发票种类白名单 | `thirdParty.bfi.invoiceReceipt.invoiceTypes`(逗号分隔票易通编码,如 `26,27`)控制可同步的发票种类:白名单外发票**不落镜像不绑定**(手动同步记「发票类型不在同步范围」流水,定时拉取在类型过滤统计中体现);配置为空不限制。定时拉取翻页终止仍按原始页条数判断,不受过滤影响 | +| 客商税号白名单 | 客商档案有效税号(`unifiedCreditCode`,status=1)为白名单,窗口拉回的发票须**购方税号与销方税号同时命中**才入镜像、参与绑定(我方主体税号亦维护在客商档案中);**集合为空不限制**。供应商未建档/未维护统一社会信用代码 → 其进项票不进镜像、前端选票选不到。与发票种类白名单叠加生效;手动同步与补偿绑定不受此约束 | +| 镜像批量写库 | 同页镜像**一次批量查询**既有记录(`invoiceNo IN 同页号码`)+ `Db.saveBatch` / `Db.updateBatchById` **批量写入**(避免逐条查改 N+1);写入前按**票面字段指纹**比对(`attachments_json` 等大字段在内),**无变化的镜像不重写**,避免每轮整窗重写大字段;指纹中空值按「维持库中原值」处理,与 MyBatis-Plus 非空字段更新策略一致,防止源侧空字段每轮误判为变更。批量写失败中断本轮,由任务层记录异常、下轮重试(upsert 幂等) | +| 绑定批量落库 | 绑定回填先在**内存**完成(登记实体按页批量查出,票面字段不覆盖),再逐项比对 `kingdeeInvoicePoolId`/`kingdeeBillNo`/`kingdeeStatus`,**三项均无变化的登记不更新、不计入统计**;有变化的以**页级事务**批量落库:`updateBatchById` 登记绑定 + `Db.saveBatch` 同步流水(`applyInvoicePoolSyncBatch`,JDBC batch)。**流水只在绑定关系变化(原因「定时绑定金蝶票池发票」)或金蝶状态迁移(原因「定时刷新金蝶票池状态」)时写入**,纯流水号回填不刷流水。批量失败**整页跳过**计入失败统计,下轮任务幂等重试,**无逐单降级路径**(`applyInvoicePoolSyncResult` 仅手动单条同步使用) | +| 防漏绑补偿 | 增量窗口为滚动近 N 天,单轮失败不漏;唯一缝隙是「持续失败超 N 天窗口滑出」。补偿绑定步骤对未绑定登记(`kingdeeInvoicePoolId` 为空、非作废)批量补绑:本地镜像**一次批量查询**复用(主流程本轮已落库镜像命中免远程),缺失才按发票号码**精确补查** A801-1(rim 契约无批量号码参数,仅此处逐号远程),新增镜像批量写入 + 绑定页级事务批量落库;单轮限量 `compensateLimit`(默认 200)先进先出,绑定成功即出队;票池暂无则跳过待下轮,不写库不刷流水 | +| 手动同步查询 | A801-1 `invoiceNo` 精确单查(rim 契约单页,返回 0/1 条);多条理论上不存在,取第一条并 warn 日志 | +| 定时绑定范围 | `approvalStatus IN (draft, reviewing, approved, returned)` 且(`kingdeeInvoicePoolId` 为空 或 指向镜像不存在的记录);`voided` 不参与绑定 | +| 绑定回填范围 | 仅回填**绑定关系与金蝶侧信息**:`kingdeeInvoicePoolId`、`kingdeeBillNo`(=serialNo)、`kingdeeStatus`;**不覆盖**登记已保存的票面字段(发票号、金额、税额、收开票单位等),避免冲掉人工登记信息 | +| 镜像刷新口径 | 镜像表字段一律以 A801-1 返回为准(票池是唯一事实源);镜像存在即可被新登记选票 | +| 金额校验 | 绑定时若票池价税合计与登记 `invoiceAmount` 不一致仅记 warn 日志(跨系统开票金额可能变更),不阻断绑定 | +| 已绑定状态刷新 | 跟随镜像刷新 `kingdeeStatus`;票池 `delete=1`(作废删除)置 `failed`,流水记录原因,**不自动作废**收票登记,人工确认处理 | +| 逐单隔离 | 定时链路无逐单路径:镜像批量查改 + 绑定页级批量落库;仅批量落库异常时整页跳过、下轮幂等重试 | +| 关键日志 | 任务开始(增量窗口 + 发票种类 + 客商税号白名单个数 + 单页/最大页数)、每页拉取条数(含 BFI 侧远程耗时)、镜像批量写入条数(新增/更新/无变化)、每页落库小结(变更/绑定/状态刷新/流水条数)、绑定成功逐条(发票号码 + 登记id + 金蝶流水号)、落库异常(含页码与堆栈)、翻页触顶 warn(窗口尾部本轮未处理)、补偿小结(补偿绑定数 + 本地镜像命中 + 远程按号补查次数 + 票池无票清单 + 耗时)、锁跳过 warn、未启用分布式锁 warn、结束汇总摘要(翻页/拉取/类型过滤/客商过滤/镜像新增更新/绑定/状态刷新/登记无变化跳过/失败/补偿绑定/耗时) | + +### 3.5 状态映射(kingdeeStatus 语义) + +| 场景 | 本地 kingdeeStatus | 展示名 | 说明 | +| --- | --- | --- | --- | +| A801-1 命中且 `delete=0` | `synced` | 已同步 | 票池匹配成功,`kingdeeBillNo` 回填 `serialNo` | +| A801-1 未命中 | `unsynced` | 未同步 | 金蝶票池未查询到(可能未采集/发票号不符),流水记录原因 | +| A801-1 命中且 `delete=1` | `failed` | 同步失败 | 发票在金蝶侧已作废/删除,流水记录原因,人工确认 | +| 同步过程异常(接口失败) | 保持原状态 | — | 本轮终止或该单跳过,下轮重试 | + +## 4. 字段映射关系 + +### 4.1 A801-1 → 镜像表 `blade_kingdee_invoice_pool` + +| A801-1 字段 | 含义 | 落库字段 | 说明 | +| --- | --- | --- | --- | +| invoiceNo | 发票号码 | `invoice_no` | upsert 匹配键 | +| serialNo | 发票流水号 | `serial_no`(**新增列**) | 票池唯一标识 | +| invoiceDate | 开票日期 | `invoice_date` | yyyy-MM-dd | +| invoiceType | 发票类型编码 | `invoice_type` | 经 `BfiConstant.INVOICE_TYPE_NAMES` 编码转名称落库(与正式结算单认领口径一致) | +| taxRate | 税率(小数) | `tax_rate` | 小数转百分数(0.13 → 13),顶层为空回退首条明细 | +| totalAmount | 价税合计 | `invoice_amount` | 与正式结算单认领同口径(价税合计即发票金额) | +| taxAmount | 税额 | `tax_amount` | 2 位小数 | +| buyerName | 购方名称 | `receiver_name` | 受票单位 | +| buyerTaxNo | 购方税号 | `buyer_tax_no`(**新增列**) | 便于按组织税号过滤 | +| salerName | 销方名称 | `issuer_name` | 开票单位 | +| salerTaxNo | 销方税号 | `saler_tax_no`(**新增列**) | 便于按销方过滤 | +| checkStatus | 查验状态 | `check_status`(**新增列**) | 0 未查验 / 1 通过 / 2 不通过 | +| delete | 是否作废删除 | —(映射为登记侧 `failed`) | 不在镜像冗余存储,随查随判 | +| downloadUrl | 文件下载地址 | `attachments_json` | `{"source":"piaozone","path":"","name":"<发票号码>.pdf"}`,A801-2 接入备用 | +| updateTime/billCreateTime | 票池更新时间 | `source_updated_time` | 镜像排序字段 | + +### 4.2 镜像/票池 → 收票登记绑定回填 + +| 来源 | 回填字段 | 说明 | +| --- | --- | --- | +| 镜像主键 | `kingdee_invoice_pool_id` | 绑定关系,绑定后登记详情/编辑可关联票池 | +| 镜像 `serial_no` | `kingdee_bill_no` | 金蝶侧唯一流水号(A801-1 无单据号概念,以流水号作金蝶标识) | +| 匹配结果 | `kingdee_status` | 见 3.5 状态映射 | +| — | `blade_invoice_receipt_record` | 每次同步写 `actionType=sync` 流水(成功/未匹配/作废原因),定时任务操作人以「定时任务」名义记录 | + +## 5. 数据库变更 + +| 变更 | 内容 | 说明 | +| --- | --- | --- | +| 新增列 | `blade_kingdee_invoice_pool`:`serial_no` varchar(64)、`saler_tax_no` varchar(32)、`buyer_tax_no` varchar(32)、`check_status` varchar(8) | 独立幂等脚本 `doc/sql/transport/blade_kingdee_invoice_pool_a8011_20260920.sql`(参照 `blade_payment_application_kingdee_bill_id_20260917.sql` 风格,**不改动**建表模板);排序规则 `utf8mb4_general_ci` | +| 索引 | 无需新增 | 建表模板已有唯一索引 `uk_kingdee_invoice_pool_no (tenant_id, invoice_no)`,覆盖 upsert 与绑定匹配 | +| 收票表 | `blade_invoice_receipt` 无结构变更 | 复用既有 `kingdee_invoice_pool_id/kingdee_bill_no/kingdee_status` | + +## 6. 配置项(Nacos:`thirdParty.bfi.invoiceReceipt.*`) + +模板同步 `doc/nacos/blade-dev.yaml`、`blade-prod.yaml`、`third-party-api.yaml`: + +| 配置键 | 环境变量 | 默认值 | 说明 | +| --- | --- | --- | --- | +| thirdParty.bfi.invoiceReceipt.syncEnabled | BFI_INVOICE_RECEIPT_SYNC_ENABLED | false | 定时绑定任务开关(上线初期关闭,联调稳定后开启) | +| thirdParty.bfi.invoiceReceipt.cron | BFI_INVOICE_RECEIPT_CRON | 0 */30 * * * ? | 调度表达式(默认每 30 分钟) | +| thirdParty.bfi.invoiceReceipt.incrementDays | BFI_INVOICE_RECEIPT_INCREMENT_DAYS | 30 | 首轮/兜底增量窗口天数 | +| thirdParty.bfi.invoiceReceipt.invoiceTypes | BFI_INVOICE_RECEIPT_INVOICE_TYPES | 空 | 同步发票种类白名单(逗号分隔票易通编码,如 26,27 数电普票/专票),为空不限制 | +| thirdParty.bfi.invoiceReceipt.compensateLimit | BFI_INVOICE_RECEIPT_COMPENSATE_LIMIT | 200 | 补偿绑定单轮限量(未绑定登记按发票号码精确补查条数上限) | +| thirdParty.bfi.invoiceReceipt.lockLeaseSeconds | BFI_INVOICE_RECEIPT_LOCK_LEASE_SECONDS | 1800 | 全票池同步 single-flight 锁租约秒数,须大于最坏一轮耗时;进程异常退出时锁在租约到期后自动释放 | + +**数据源前置条件**:`doc/nacos/blade-dev.yaml`、`blade-test.yaml`、`blade-prod.yaml` 的 MySQL 连接串须带 `rewriteBatchedStatements=true`,否则 `Db.saveBatch` / `updateBatchById` 仍是逐条往返、不构成真正的 JDBC 批量(该参数为 MySQL 专有,切换其他数据库需移除)。配置变更后需重启服务生效。 + +**分布式锁前置条件**:`RedisLockClient` 仅在 `blade.lock.enabled=true` 时装配(该开关无缺省值,不配即关闭)。dev/test 模板已开启,且 `blade.lock.address` 与 `spring.data.redis` 指向同一实例;prod 模板保持关闭,开启前须先补 `blade.lock.password`(与 `spring.data.redis` 一致),否则 Redisson 建连被拒会拖垮依赖该模板的所有服务。锁未启用时全票池同步照常执行,只是失去多副本 single-flight 保护并打 warn 日志。 + +## 7. 异常处理 + +| 场景 | 行为 | +| --- | --- | +| A801-1 接口级失败(status=false / errorCode≠0000) | `BfiServiceImpl.commonCall` 抛 `BfiException`:链路一该单同步失败并提示;链路二当前页终止,下轮任务自动重试 | +| 手动同步按发票号未查到 | 登记 `kingdeeStatus=unsynced`,流水记录「金蝶票池未查询到」,不抛异常中断批量 | +| 绑定时金额不一致 | warn 日志(发票号 + 双方金额),仍按 3.4 口径绑定 | +| 单条绑定/状态刷新异常 | 记录 error 日志(含发票号与堆栈),计数后继续下一单 | +| 定时任务与手动同步并发 | 链路二入口按租户加 Redis 分布式锁做 **single-flight**(`RedisLockClient.tryLock(key, REENTRANT, 0, lockLeaseSeconds)`,零等待):多副本调度、手动端点撞上进行中的轮次时,抢不到锁的一侧直接跳过并返回「同步进行中,本轮跳过」,不重复拉取与写库;未开启 `blade.lock.enabled` 时降级为无锁执行并 warn。锁之外仍靠镜像按 invoiceNo upsert、登记按主键更新、绑定判重幂等保证最终一致 | +| A801-1 翻页异常(rim 无总数) | 以「本页条数 < pageSize 或空页」终止,最大 50 页保护,防御死循环 | + +## 8. 涉及代码位置 + +| 内容 | 位置 | 改动 | +| --- | --- | --- | +| 前端页面 | `tms-erp-web/src/views/payment/invoice-receipt.vue`(handleBatchSync)、`src/api/payment/invoiceReceipt.js`(syncKingdee) | 无需改动(复用现有单条同步接口与按钮) | +| 同步入口改造 | `blade-service/blade-transport/.../service/impl/InvoiceReceiptServiceImpl.java`(syncKingdee 接入 A801-1 查询 + 镜像 upsert + 绑定回填) | 修改 | +| 定时绑定编排 | 同上 `syncKingdeeInvoicePool()`(RedisLockClient single-flight + 租户锁键)、`executeInvoicePoolSync()`(增量窗口分页拉取 + 票面指纹比对 upsert + 变更比对绑定)、`applyInvoicePoolSyncBatch()`(页级事务:绑定批量更新 + 同步流水批量写入);`applyInvoicePoolSyncResult()` 仅手动单条同步使用 | 新增 | +| Service 接口 | `.../service/IInvoiceReceiptService.java`(syncKingdeeInvoicePool) | 新增 | +| 定时任务 | `blade-service/blade-transport/.../task/InvoiceReceiptKingdeeSyncTask.java` | 新增(仿 PaymentKingdeeResultTask) | +| 手动触发端点 | `.../controller/InvoiceReceiptController.java`(POST /invoice-receipt/sync-invoice-pool) | 新增(可选) | +| 镜像实体 | `blade-transport-api/.../pojo/entity/KingdeeInvoicePool.java`(serialNo/salerTaxNo/buyerTaxNo/checkStatus) | 修改 | +| BFI 客户端 | `blade-third-party-api/blade-bfi-api/.../service/IBfiService.java`(queryInvoicePool)、`pojo/dto/BfiInvoicePoolQueryDataDTO.java`、`pojo/vo/BfiInvoicePoolVO.java` | 无改动复用 | +| SQL | `doc/sql/transport/`(镜像表新增列 + 索引幂等脚本) | 新增 | +| Nacos 配置模板 | `doc/nacos/blade-dev.yaml`、`blade-test.yaml`、`blade-prod.yaml`(thirdParty.bfi.invoiceReceipt.*、MySQL `rewriteBatchedStatements`、`blade.lock.enabled`)、`third-party-api.yaml` | 新增 | + +## 9. 遗留事项与联调确认 + +1. **A801-1 增量窗口口径**:`updateTime/updateTimeEnd`(yyyy-MM-dd HH:mm:ss)为票池更新时间,联调时需用真实票池数据验证窗口过滤有效性;无效则退化为按 `invoiceDateStart/End`(开票日期)滚动窗口全量比对(upsert 幂等,代价可接受)。 +2. **`serialNo` 作金蝶标识**:A801-1 无金蝶单据号概念,以发票流水号回填 `kingdee_bill_no`;后续如接 A303-2 收票单推送,金蝶侧单据号语义另行扩展,避免混用。 +3. **镜像存量数据**:镜像表存量记录 `serial_no` 为空,由首轮定时任务/手动同步按 invoiceNo 命中后补齐;未命中的存量数据保持原状不清理。 +4. **A801-2 附件下载**:`attachmentsJson.path` 仅记录备用,查看/下载附件待 A801-2 接入后建设。 +5. **票池作废联动**:`delete=1` 仅置 `failed` 提示人工确认,是否自动作废收票登记待业务确认后调整。 diff --git a/doc/docs/业务流程/2026-09-21-应收结算与开票申请同步金蝶全链路业务流程(A302-2保存-A302-4勾稽-A303-1新增-A303-4预检-A303-5回写).md b/doc/docs/业务流程/2026-09-21-应收结算与开票申请同步金蝶全链路业务流程(A302-2保存-A302-4勾稽-A303-1新增-A303-4预检-A303-5回写).md new file mode 100644 index 00000000..9a5b7792 --- /dev/null +++ b/doc/docs/业务流程/2026-09-21-应收结算与开票申请同步金蝶全链路业务流程(A302-2保存-A302-4勾稽-A303-1新增-A303-4预检-A303-5回写).md @@ -0,0 +1,240 @@ +# 应收结算与开票申请同步金蝶全链路业务流程(正式结算单同步 → 开票申请同步 → 应收勾稽与开票结果定时回写) + +> 适用系统:TMS(BladeX 微服务平台,服务:blade-transport) +> 对接系统:金蝶云·星瀚(业财融合 BFI,经 iPaaS 网关) +> 覆盖接口:A302-2 财务应收单保存、A302-4 财务应收单查询、A303-1 开票申请单新增、A303-4 开票申请单查询、A303-5 开票申请发票查询 +> 关联文档:`2026-09-20-应收结算与开票申请同步金蝶设计.md`(设计口径与遗留清单)、`2026-09-20-应付单与付款申请同步金蝶全链路业务流程(A102-2保存-A102-4查询-A103-1下推-A103-2回写).md`(应付侧对称链路) +> 最后更新:2026-10-08(联调纠偏:A303-1 awrp_src_con_no 改传首张结算单金蝶应收单防重键 awrp_src_code,传单据编号会报「源单编号不存在」;2026-10-01 上游来源单据放开传值 awrp_src_bt=ar_finarbill;2026-09-21 按当前代码梳理,销方名称/销方税号/税收分类编码随单据取数) + +--- + +## 1. 整体业务链路 + +应收侧一笔结算到最终开出数电票,分**三个环节**,由两个页面 + 一个定时任务接力完成: + +``` +环节一:正式结算单「同步金蝶」(人工,正式收款单/结算单页面,单笔) + 审批通过的应收类正式结算单 → 推送金蝶财务应收单(A302-2,前置 A302-4 幂等预检) + → 回写结算单:金蝶单据编号 + 同步状态 synced + +环节二:开票申请「同步金蝶」(人工,开票申请页面,单笔) + 审批通过的开票申请 → 前置校验关联结算单的应收单已在金蝶生成(A302-4,缺一拦截) + → A303-4 幂等预检(billno=开票申请单号,命中直接勾稽回填) + → 推送金蝶开票申请单(A303-1,购销方信息 + 发票商品行分录) + → 回写开票申请:金蝶单据编号 + 同步状态 synced + +环节三:PaymentKingdeeResultTask 定时回写(自动,默认每10分钟,三子流程各自 try/catch 互不影响) + ① A103-2/A103-4 付款结果回写(应付链路,本文不展开) + ② A302-4 应收勾稽纠偏:轮询 approved+应收+{synced,failed},按防重键批量查询, + 查到 → 补金蝶单号、failed 纠偏为 synced;查不到 → 状态不变 + ③ A303-5 开票结果回写:轮询 approved+synced+金蝶单号非空的开票申请,按销方税号分组 + 分批(≤50/批)查询,查到发票 → 逐张落结果表(发票号幂等)→ 申请单推进 invoiced(终态) +``` + +核心设计原则(与应付链路一致): + +1. **先内后外**:单据先在 TMS 审批通过(approved)才允许推金蝶。 +2. **以金蝶侧实际生成为准**:每次推送前按防重键查询确认,已生成则幂等跳过/回填勾稽,未生成才推送;推送接口真正报错才算失败。 +3. **断点续传**:任一环节中断(远端成功、本地回滚)后重新触发或等定时勾稽,均从断点恢复,不产生重复单据。 +4. **配置最小化**:金蝶推送所需的业务取数一律随单据快照或走档案(客商/开票项目/货物类型/部门),Nacos 仅剩外部系统编号 srcSys 与分录外部单据类型 srcType(有默认值)。 + +--- + +## 2. 五个接口在链路中的角色 + +| 接口 | 名称 | 方向 | 触发环节 | 业务用途 | +| --- | --- | --- | --- | --- | +| A302-2 | 财务应收单保存 | TMS → 金蝶 | 环节一 | 一张应收正式结算单生成一张金蝶财务应收单(ar_finarbill) | +| A302-4 | 财务应收单查询 | TMS → 金蝶(查询) | 环节一前置 / 环节二前置 / 环节三② | ①推送前幂等预检;②开票申请推送前校验关联应收单已生成;③定时勾稽纠偏(防重键批量查询,50/批+自动翻页) | +| A303-1 | 开票申请单新增 | TMS → 金蝶 | 环节二 | 生成金蝶开票申请单(上游勾稽财务应收单),由金蝶/票易通完成实际开票 | +| A303-4 | 开票申请单查询 | TMS → 金蝶(查询) | 环节二前置 | 按 billno=开票申请单号 幂等预检,断链重推时命中即勾稽回填 | +| A303-5 | 开票申请发票查询 | TMS → 金蝶(查询) | 环节三③ | 按开票申请单号+销方税号查已开发票,落 `blade_invoice_application_invoice` 并推进开票完成 | + +收款认领(A301-x)、收票(A303-2/A302-6)、应收单核销状态回写、结算单开票完成反写均不在本链路(后续迭代)。 + +--- + +## 3. 环节一:正式结算单「同步金蝶」推送财务应收单(A302-2) + +代码入口:`FormalSettlementServiceImpl.syncKingdee` → 按结算类型路由,应收走 `KingdeeArBillSyncSupport.pushArBill`(应付走 A102-2,见应付侧文档)。 + +### 3.1 业务流程 + +1. 操作入口:正式结算单页面「同步金蝶」按钮,单笔。 +2. 前置校验(不满足直接报错,不调金蝶): + - 审批状态 = **approved**; + - 同步状态已是 **synced** → 幂等返回已有金蝶单号,不重复推送(failed 允许重推); + - 必须有结算明细(分录取数来源)。 +3. 组装财务应收单防重键(awrp_src_code):**正式结算单号_结算明细单据号1_单据号2…**(去重保序、下划线拼接),与应付侧同规则;一张结算单只生成一张金蝶应收单。 +4. 解析应收单明细分录(`resolveArEntryGroups`,逐**应收货物费用行**一条、不聚合): + - 结算明细 `sourceDetailId` 回链应收应付货物费用行(`blade_receivable_payable_cargo_fee`),无货物费用行直接报错拦截; + - 含税金额取费用行调整后总金额 `afterAmount`,不含税经结算货物费用行 `sourceFeeId` 回链(无映射兜底 0); + - 货物类型(「、/,」多类型不拆行,取第一种)必须在货物类型档案维护货物编码;计量单位取运费计算单位(兜底数量单位)。 +5. **A302-4 幂等预检**:按防重键批量查询,已生成 → 幂等跳过保存、直接取已有单号回写;未生成 → 调 A302-2 保存。 +6. **A302-2 保存**,主表入参来源: + + | 接口字段 | 含义 | 必填 | 来源 | 前端字段(来源页面) | + | --- | --- | --- | --- | --- | + | billtype_number | 单据类型.编码 | 是 | 常量名称「服务销售应收」经 `AR_BILL_BILLTYPE_MAP` 转 `arfin_sersal_BT_S`(待金蝶确认,改常量名即可换映射) | 无(后端常量) | + | org_number / recorg_number | 结算组织/收款组织.编码 | 是 | 合同所属组织 deptCode(未维护报错) | 合同管理页面「所属组织」→ 部门编码 | + | bizdate | 单据日期 | 是 | 同步当日 yyyy-MM-dd HH:mm:ss | 无(系统生成) | + | asstacttype | 往来类型 | 是 | 固定 bd_customer(应收对方必为客户) | 无(后端常量) | + | asstact_number | 往来户.编码 | 是 | 结算单**付款方** payerName → 客商档案按全称/简称匹配取客商编码(未维护报错) | 正式结算单页面「付款方」payerName → 客商档案「客商编号」customerCode | + | paymode | 付款方式 | 是 | 常量 CASH(现销,金蝶自动 100% 推收款) | 无(后端常量,待金蝶确认) | + | payproperty_number | 款项性质.编码 | 是 | 常量 1001(一般销售,与应付 2001 区分,待金蝶确认) | 无(后端常量) | + | currency_number | 结算币别.货币代码 | 是 | 合同结算币种(RMB→CNY) | 合同管理页面「结算币种」 | + | isincludetax / ispricetotal | 录入含税价/录入总价 | 否 | 固定 true / true(免单价换算) | 无(后端常量) | + | awrp_src_sys | 外部系统编号(候选键) | 是 | 配置 thirdParty.bfi.srcSys,默认 WLTMS | 无(后端配置) | + | awrp_src_code | 外部来源单据号(候选键) | 是 | 第 3 步防重键 | 系统组装:「结算单号」+结算明细「单据号」 | + | awrp_src_type | 外部单据类型 | 是 | 常量「TMS财务应收单」(待金蝶确认) | 无(后端常量) | + | 到期日/结算方式/税率编码 | — | 否 | 本期不传(TODO 待金蝶确认后补充) | — | + + 明细分录(entry,逐应收货物费用行): + + | 接口字段 | 含义 | 必填 | 来源 | + | --- | --- | --- | --- | + | e_material_number | 物料编码.编码 | 是 | 货物类型→货物类型档案货物编码;**联调期临时固定 SP0324**(pushArBill 内 `if (true)` 调试块覆盖,校准后移除) | + | e_measureunit_number | 计量单位.编码 | 是 | 运费计算单位(兜底数量单位,档案暂无编码传名称);**联调期临时固定 TON** | + | e_quantity / e_unitprice | 数量/单价 | 是/否 | 费用行运输数量(兜底 1)/运输单价(录入总价仅参考) | + | e_recamount | 应收金额(含税) | 是 | 费用行调整后总金额 afterAmount | + | e_amount | 金额(不含税) | 否 | 结算货物费用行不含税回链,兜底 0 | + | project_number | 项目.项目编码 | 是 | 联调期固定 999(真实来源=项目档案项目编码) | + | awrp_entry_code | 来源分录标识(候选键) | 是 | 应收货物费用行 id(雪花 id 天然唯一) | + + 收款计划 planentity 不传,由金蝶按明细自动汇总生成。 + +7. 回写落库:`result[0].number` → 结算单 `kingdeeBillNo` + `kingdeeSyncStatus=synced` + `syncedTime`,并记结算单变更历史「同步金蝶财务应收单{单号}」。失败抛业务异常,状态不变可重推。 + +### 3.2 状态机与勾稽纠偏 + +``` +kingdeeSyncStatus:unsynced ──A302-2成功──> synced(终态) + └──推送异常──> failed ──人工重推 或 定时②A302-4勾稽纠偏──> synced +``` + +定时子流程②(`FormalSettlementServiceImpl.syncKingdeeArResult`,手动端点 `POST /formal-settlement/sync-kingdee-ar-result`):轮询 approved+receivable+{synced,failed},按防重键批量查 A302-4;查到 → 补回缺失单号、failed 纠偏为 synced;查不到 → 不动。返回汇总:待查 X 笔、回写 Y 笔(纠偏 Z 笔)。 + +--- + +## 4. 环节二:开票申请「同步金蝶」推送开票申请单(A303-1) + +代码入口:`InvoiceApplicationServiceImpl.syncKingdee`;页面 `invoice-application-form.vue`(列表 `invoice-application.vue`)。 + +### 4.1 单据创建与审批(推送前) + +1. 新增开票申请:选择**应收正式结算单**(可多张,须同合同/项目/组织/收付款方)→ 自动带出所属项目、**开票单位**(=结算单收款方,即销方)、承办部门、可开票金额;受票方从付款方客商的开票信息(`blade_customer_invoice_info`)选择,纳税人识别号/开户行/账号/注册地址随选择带出并快照到申请单。 +2. 开票内容按「发票张」维护商品行:商品和服务分类 + 货物或服务简称均从**开票项目主数据**(`blade_invoice_item`)选择,选择时默认税率随带,并**快照税收分类编码**到商品行 `tax_classification_code`(A303-1 分录用,不走配置)。 +3. 商品行只能整体开「运费」或整体开非运费(运费不得与其他费用并票);本次开票金额=商品行含税合计,不得超过可开票金额。 +4. 状态流转:draft(草稿可编辑/删除)→ submit → reviewing → approve → approved(或 return 驳回、void 作废);仅 **approved** 允许同步金蝶。 +5. 结算单开票状态(unreceived/partial/completed)由申请单分摊金额实时刷新(作废/删除联动回退),金蝶回写不反写结算单。 + +### 4.2 同步金蝶流程 + +1. 校验:approved;已 synced/invoiced 幂等返回(invoiced 终态);申请单号非空。 +2. **A302-4 前置校验**:逐关联结算单组装应收单防重键批量查询,任一结算单的财务应收单未在金蝶生成 → 拦截报错「请先在正式付款单页面同步金蝶」(即先完成环节一)。 +3. **A303-4 幂等预检**:billno=开票申请单号查询,金蝶已生成 → 直接回填单号 + synced(勾稽回填,防断链重推重复)。 +4. 取数校验(任一不满足报错):开票单位(销方)非空;**销方税号=开票单位按全称/简称匹配客商档案的统一社会信用代码**;销方地址电话(客商档案注册地址+联系电话)与销方开户行账号(客商开票信息开户行+账号)均已维护(数电专票金蝶强校验);部门邮箱(发票推送邮箱)、购方识别号、申请部门编码、受票方客商编码均已维护;每条商品行已有税收分类编码快照与税率(历史单据缺失时提示编辑重选开票内容)。 +5. **A303-1 新增**,主表入参来源: + + | 接口字段 | 含义 | 必填 | 来源 | 前端字段(来源页面) | + | --- | --- | --- | --- | --- | + | orgid_number / awrp_orgfield_number | 组织/申请部门.编码 | 是 | 承办部门 deptId → SysCache 部门编码(未维护报错) | 开票申请页「承办部门」(结算单带出) | + | billno | 业务单据编号(候选键) | 是 | 开票申请单号 applicationNo | 系统生成(TMSKV 规则单号) | + | billdate | 单据日期 | 是 | 申请日期(提交时置当日) | 页面「申请日期」 | + | billproperties | 单据性质 | 是 | 固定 1(正数) | 无 | + | invoicetype | 发票种类 | 是 | electronic_normal→10xdp(数电普票)、electronic_special→08xdp(数电专票) | 页面「发票类型」下拉 | + | taxationstyle | 征税方式 | 是 | 固定 0(普通征税) | 无 | + | buyeremail | 发票推送邮箱 | 是 | 部门邮箱(客商开票信息邮箱多选,`;` 拼接) | 页面「部门邮箱」 | + | buyerproperty / buyertaxno / buyername | 购方企业类型/税号/名称 | 是 | 固定 0(企业)/ 申请单快照 taxpayerNo / receiverName | 页面「受票方单位」选择带出 | + | buyeraddr / showbuyeraddresstel | 购方地址及电话 / 展示买方地址电话 | 否(金蝶是否强校验待联调确认) | 申请单受票方快照 注册地址+联系电话拼接,展示开关固定 true | 页面「注册地址」「联系电话」(受票方开票信息带出) | + | buyerbank / showbuyerbank | 购方开户行及账号 / 展示买方银行 | 否(待联调确认) | 申请单受票方快照 开户行+银行账号拼接,展示开关固定 true | 页面「开户银行」「银行账号」(受票方开票信息带出) | + | salername | 销方名称 | 是 | 申请单快照开票单位 issuerName(=结算单收款方) | 页面「开票单位」(结算单带出) | + | salertaxno | 销方纳税人识别号 | 是 | 开票单位 → 客商档案统一社会信用代码(**A303-5 sellerTaxpayerId 同源**) | 客商档案「统一社会信用代码」 | + | saleraddr / showsaleraddresstel | 销方地址及电话 / 展示卖方地址电话 | 否(数电专票强校验必传) | 客商档案注册地址+联系电话拼接,展示开关固定 true | 客商档案「注册地址」「联系电话」 | + | salerbank / showsalerbank | 销方开户行及账号 / 展示卖方银行 | 否(数电专票强校验必传) | 客商开票信息(启用、默认优先)开户行+银行账号拼接,展示开关固定 true | 客商开票信息「开户银行」「银行账号」 | + | totalamount / invoiceamount / totaltax | 价税合计/不含税合计/税额合计 | 是 | 申请单开票金额 / 商品行不含税合计 / 税额合计 | 页面「本次开票金额」、商品行自动计算 | + | customnameid_number | 客户.编码 | 是 | 受票方客商档案客商编码(未维护报错) | 客商档案「客商编号」 | + | awrp_project_number | 项目.项目编码 | 是 | 联调期 999 | — | + | billsource | 数据来源 | 是 | 固定 3 | 无 | + | awrp_src_bt / awrp_src_con_no | 上游来源单据/单号 | 是 | ar_finarbill / 首张关联结算单的金蝶应收单**防重键 awrp_src_code**(联调确认金蝶按防重键匹配源单,2026-10-08 纠偏:传单据编号报「源单编号不存在」;多张取第一张) | `结算单号_结算明细单据号拼接`(与 A302-2 防重键同源) | + | systemsource | 系统来源 | 否 | thirdParty.bfi.srcSys(默认 WLTMS) | 无(后端配置) | + + 分录(sim_original_bill_item,逐「开票内容」商品行一条,按发票张次+张内行号平铺):取数口径即商品行落库值,**不回查开票项目主数据、不按开票明细→费用行展开分摊**(实现见 `InvoiceApplicationServiceImpl.resolveInvoiceApplyEntries`,仅查张次与商品行两张表): + + | 接口字段 | 含义 | 必填 | 来源 | + | --- | --- | --- | --- | + | rowtype / benchmark | 行性质/基准类型 | 是 | 固定 2(商品行)/ 0(数量基准)(rowtype 联调校准) | + | goodsname / goodssimplename | 货物名称/商品和服务分类 | 是 | 行 goodsName(货物或服务简称)/ goodsCategory(分类名称) | + | taxratecodeid_number / goodscode | 税收分类编码.编码/税收分类合并编码 | 是 | 同行快照 tax_classification_code(选开票内容时随开票项目主数据落库;goodscode 联调确认必传) | + | num / unit | 数量/计量单位 | 是 | 行数量(≤0 兜底 1)/ 单位(空兜底「个」) | + | taxunitprice / taxamount | 含税单价/含税金额 | 是 | 行价税合计÷数量、行价税合计 total_amount | + | amount / unitprice / tax | 不含税金额/单价/税额 | 是 | 行不含税金额 amount_no_tax、不含税单价 unit_price_no_tax、税额 tax_amount | + | taxrate | 税率 | 是 | 行税率 0-100 转小数字符串(13→"0.13"),行税率为空即报错 | + | remark | 备注 | 否 | 行备注 | + | awrp_entry_code | 来源分录标识(候选键) | 是 | 商品行 id(雪花 id 唯一) | + | awrp_src_type | 分录外部单据类型 | 是 | 配置 thirdParty.bfi.invoiceApply.srcType,默认 0KzXs(有默认值,一般无需配置) | + +6. 回写落库:`result[0].number` → `kingdeeBillNo` + `kingdeeStatus=synced` + `syncedTime`,记录操作历史「同步金蝶」。 + +### 4.3 状态机 + +``` +kingdeeStatus:unsynced ──A303-1成功──> synced ──A303-5查到发票──> invoiced(开票完成,终态) + └──推送异常──> failed ──人工重推(A303-4 预检命中→幂等回填)──> synced +定时③只轮询 synced(failed 无真实金蝶单号,A303-5 无查询入口) +``` + +--- + +## 5. 环节三③:开票结果定时回写(A303-5) + +代码入口:`InvoiceApplicationServiceImpl.syncKingdeeInvoiceResult`,手动端点 `POST /invoice-application/sync-kingdee-invoice-result`。 + +1. 待回写集合:approved + synced + 金蝶单号非空,按 `kingdeeBillNo`(金蝶开票申请单号=serialNo)去重归并。 +2. **销方税号解析(与 A303-1 同源)**:逐单按开票单位查客商档案统一社会信用代码,**按税号分组**后组内 ≤50/批逗号拼接调 A303-5;解析不到(开票单位为空/客商档案未维护信用代码)的单跳过并 warn 计数,不阻断整轮。 +3. 返回为票易通原文 `data[]{billNo, invoiceList[]}`:按 billNo 归并回申请单,逐张宽松提取(候选键:发票号 invoiceNo/invoice_no/invoiceNumber;开票日期、价税合计同理),**原文整体 JSON 留档 `raw_json`**;字段名联调校准后收敛为固定映射。 +4. 落 `blade_invoice_application_invoice`:按(invoice_application_id, invoice_no)唯一键幂等,已存在跳过;该单有新增发票 → `kingdeeStatus=invoiced`(开票完成,前端详情展示发票列表)。 +5. 开票结果**不反写结算单**;结算单开票状态仍由申请单分摊驱动。 +6. 汇总文案:待查 X 笔(销方税号无法解析跳过 N 笔),回写发票 Y 张(开票完成 M 笔,失败批次 K 个)。 + +任务调度:`PaymentKingdeeResultTask`(`thirdParty.bfi.payResult.cron` 默认每 10 分钟,总开关 `thirdParty.bfi.payResult.syncEnabled`,三子流程共用;任一子流程异常仅记日志不中断整轮)。 + +--- + +## 6. 防重键与幂等体系汇总 + +| 单据/对象 | 防重键/幂等依据 | 使用接口 | +| --- | --- | --- | +| 财务应收单 | awrp_src_sys=WLTMS + awrp_src_code=正式结算单号_结算明细单据号拼接 | A302-2 / A302-4(预检+定时勾稽+开票前置校验) | +| 应收单分录 | awrp_entry_code=应收货物费用行 id | A302-2 | +| 开票申请单 | billno=开票申请单号 applicationNo | A303-1 / A303-4(预检)/ A303-5(serialNo) | +| 开票申请分录 | awrp_entry_code=商品行 id | A303-1 | +| 金蝶发票结果 | 唯一键(invoice_application_id, invoice_no) | A303-5 落库 | + +断点恢复场景:应收/开票推送成功但本地回滚 → 重推时 A302-4/A303-4 命中幂等回填;failed 应收单 → 定时勾稽自动纠偏;金蝶开票处理中 → ③每轮空查保持 synced,直到开出票。 + +--- + +## 7. 数据前提与上线操作 + +| # | 事项 | 说明 | +| --- | --- | --- | +| 1 | 建表/变更 SQL | `doc/sql/transport/blade_invoice_application_invoice_20260920.sql`(发票结果表);`blade_invoice_application_line_tax_code_20260921.sql`(商品行税收分类编码快照列,可重复执行) | +| 2 | Nacos 配置 | 仅需已有 `thirdParty.bfi.srcSys`(默认 WLTMS)与任务开关/`cron`;`invoiceApply.salerName/salerTaxNo/taxratecode` **已取消**,`invoiceApply.srcType` 有默认值无需配置 | +| 3 | 客商档案 | 我方开票单位(结算单收款方)与受票方客户均须入客商档案:我方须维护**统一社会信用代码**(销方税号),受票方须维护**客商编码**;受票方另需维护客商开票信息(税号、邮箱等) | +| 4 | 主数据 | 开票项目须维护税收分类编码(新增开票内容选择即快照);货物类型档案货物编码;部门编码 | +| 5 | 联调校准项 | A302-2:单据类型/款项性质 1001/CASH/src_type 文案、物料 SP0324+计量 TON+项目 999 临时值(`pushArBill` 调试块);A303-1:rowtype(systemsource 已传 srcSys、上游单据号已放开传首张结算单金蝶应收单编号);A303-5:原文字段名收敛 | +| 6 | 联调顺序 | 应收结算单同步(①)→ 开票申请同步(②)→ 手动触发 `sync-kingdee-ar-result` / `sync-kingdee-invoice-result` 验证回写 | + +--- + +## 8. 关键代码索引 + +| 环节 | 代码位置(blade-service/blade-transport) | +| --- | --- | +| 环节一推送 | `service/impl/FormalSettlementServiceImpl.syncKingdee` + `support/KingdeeArBillSyncSupport`(pushArBill / arBillSrcCode / queryArBillExistNos / resolveArEntryGroups),档案取数复用 `support/KingdeeApBillSyncSupport` | +| 环节二推送 | `service/impl/InvoiceApplicationServiceImpl.syncKingdee / buildInvoiceApplyEntry` | +| 环节三②/③ | `FormalSettlementServiceImpl.syncKingdeeArResult` / `InvoiceApplicationServiceImpl.syncKingdeeInvoiceResult`,编排 `task/PaymentKingdeeResultTask` | +| 常量/配置 | `constant/KingdeeBfiConstant`(AR 单据类型映射、1001、CASH、TMS财务应收单、ar_finarbill、10xdp/08xdp、invoiced)、`config/BfiProperties`(thirdParty.bfi.*) | +| 手动端点 | `POST /formal-settlement/sync-kingdee-ar-result`、`POST /invoice-application/sync-kingdee-invoice-result`(网关按服务名路由至 blade-transport) | +| 前端 | 正式结算单页面(同步金蝶按钮)、`views/payment/invoice-application-form.vue`(新增开票申请:选结算单带出销方、开票内容快照税收分类编码) | diff --git a/doc/docs/业务流程/2026-09-22-收票单推送金蝶全链路业务流程(A303-2保存-A302-6指定应付-A801-3预检回写).md b/doc/docs/业务流程/2026-09-22-收票单推送金蝶全链路业务流程(A303-2保存-A302-6指定应付-A801-3预检回写).md new file mode 100644 index 00000000..0cdd1337 --- /dev/null +++ b/doc/docs/业务流程/2026-09-22-收票单推送金蝶全链路业务流程(A303-2保存-A302-6指定应付-A801-3预检回写).md @@ -0,0 +1,197 @@ +# 收票单推送金蝶全链路业务流程(A303-2 收票单保存 → A302-6 收票指定应付 → A801-3 幂等预检与定时回写) + +> 适用系统:TMS(BladeX 微服务平台,服务:blade-transport) +> 对接系统:金蝶云·星瀚(业财融合 BFI,经 iPaaS 网关 `/gwzh/BFI` 直连,不经过合同合规系统) +> 覆盖接口:A303-2 收票单批量保存、A302-6 收票指定应付、A801-3 收票单查询、A102-4 财务应付单查询(预检)、A801-1 查询全票池发票数据(镜像明细回填) +> 关联文档:`TMS业财融合-集成流程图文字版与业务流程梳理.md` 第 6 节(图05 定稿口径)、`2026-09-20-收票管理同步金蝶全票池发票(A801-1查询+批量同步+定时绑定关联)业务流程.md`(环节①镜像链路,本文档叠加其字段)、`2026-09-22-金蝶票池选票统一查镜像(收票登记+结算单认领+按票号补查+统一组件)业务流程.md`(选票统一查镜像)、`2026-09-21-应收结算与开票申请同步金蝶全链路业务流程….md`(销项侧对称链路) +> 最后更新:2026-09-22(按当前代码梳理;已编码完成,未联调) + +--- + +## 1. 整体业务链路 + +图05「付款-收票查询及认领(进项收票)」分三个环节,环节①已建成,本文档覆盖环节②③: + +``` +环节①(已建成,不在本文):票池镜像拉取 + 收票登记选票 + 审批 + 批量同步/定时绑定 + A801-1 拉全票池入镜像 blade_kingdee_invoice_pool(票种白名单 + 购销双税号 AND 白名单) + → 收票登记查镜像选票(一票一登记)→ 审批通过 → 定时绑定回填 kingdeeInvoicePoolId/流水号 + +环节②:收票登记「推送金蝶收票单」(人工批量,POST /invoice-receipt/push-kingdee) + 前置校验(审批通过 + 已绑定镜像 + 查验通过 + 恰好分摊一张应付结算单) + → A102-4 预检关联财务应付单已在金蝶生成(缺则拦截"请先同步金蝶应付单") + → A801-3 幂等预检(按发票号防重键,命中跳过保存续推指定) + → A303-2 收票单批量保存 → 回写金蝶收票单号 + pushed + → 立即 A302-6 收票指定应付(一票一应付)→ assigned(终态,锁单) + +环节③:PaymentKingdeeResultTask 第④子流程 定时回写(A801-3,独立开关,默认每10分钟) + 金蝶侧红冲/作废/部分红冲/全部红冲 → 回收为 failed 提示人工(不自动作废登记) + pushed(保存成功后中断、指定未完成)→ 指定补偿重发 A302-6 + assigned 仅补登缺失的金蝶收票单号(保存成功但响应丢失的历史数据) +``` + +核心设计原则(与付款/应收链路一致): + +1. **先内后外**:收票登记先审批通过(approved)并绑定镜像才允许推送。 +2. **以金蝶侧实际生成为准**:每次推送前按防重键查询确认,已生成则幂等跳过保存、续推指定;推送接口真正报错才算失败。 +3. **断点续传**:任一环节中断后重新触发(人工重推或定时补偿),从断点恢复,不产生重复单据。 +4. **一票一应付**:金蝶当前不支持一张发票指定多张应付单分摊金额,收票登记分摊结算单数 ≠ 1 直接拦截(A302-6 按一对一指定)。 +5. **异常回收人工处置**:金蝶侧删除/解除指定/红冲 → 本地置 failed 提示人工,须先在金蝶处置完毕再由人工重推。 + +--- + +## 2. 五个接口在链路中的角色 + +| 接口 | 名称 | 方向 | 触发环节 | 业务用途 | +| --- | --- | --- | --- | --- | +| A303-2 | 收票单批量保存 | TMS → 金蝶 | 环节② | 一张收票登记生成一张金蝶收票单(ap_invoice),票面明细逐行落分录 | +| A302-6 | 收票指定应付 | TMS → 金蝶 | 环节② / 环节③补偿 | 按「收票单防重键 ↔ 财务应付单防重键」绑定(op=assign),一票一应付 | +| A801-3 | 收票单查询 | TMS → 金蝶(查询) | 环节②前置 / 环节③ | 幂等预检 + 定时回写;契约无来源单号参数,按购方名称+修改时间窗口翻页、本地按防重键匹配 | +| A102-4 | 财务应付单查询 | TMS → 金蝶(查询) | 环节②前置 | 校验关联结算单的财务应付单已在金蝶生成(付款侧 A102-2 同步的产物),缺则拦截 | +| A801-1 | 查询全票池发票数据 | TMS → 金蝶(查询) | 环节②兜底 | 推送时镜像缺发票明细行/票种编码(早于字段上线或滑出增量窗口)按票号窄查回填 | + +合同侧履约收票同步(C107-1)开发中、本期不接;进项发票附件下载(A801-2)备用。 + +--- + +## 3. 环节②:推送金蝶收票单编排 + +代码入口:`InvoiceReceiptController.pushKingdee` → `InvoiceReceiptServiceImpl.pushKingdeeReceivedInvoice(List ids)` → 逐单 `pushOneReceivedInvoice` → `KingdeeReceivedInvoiceSyncSupport`。 + +### 3.1 前置校验(预组装计划,逐单报因不拦截整批) + +| # | 校验 | 拦截提示 | +| --- | --- | --- | +| 1 | 审批状态 = approved | 仅审批通过的收票登记允许推送金蝶 | +| 2 | 推送状态 ≠ assigned | 收票单已指定应付,无需重复推送 | +| 3 | 已绑定票池镜像(kingdeeInvoicePoolId 且镜像存在) | 收票登记未绑定票池镜像,请先完成同步绑定 | +| 4 | 镜像查验状态 checkStatus ≠ 2(金蝶每5分钟查验) | 金蝶发票查验不通过,请先人工核实票池发票 | +| 5 | 分摊结算单恰好 1 张(金蝶不支持多应付分摊) | 金蝶不支持一张发票指定多张应付单,收票登记须恰好分摊一张应付正式结算单 | +| 6 | 分摊结算单为审批通过、未作废的应付正式结算单,且有结算明细 | 结算单不是审批通过的应付正式结算单 / 无结算明细,无法组装应付单防重键 | +| 7 | A102-4 批量预检:财务应付单已生成(本批防重键一次查询) | 请先同步金蝶应付单:结算单 xxx 未生成财务应付单 | + +预检拦截(1~7)**不改动推送状态**;仅保存/指定阶段真正失败才置 failed。 + +### 3.2 幂等预检与保存 + +1. **A801-3 幂等预检**:未终态计划按购方单位(接口必填)分组,回看窗口(`thirdParty.bfi.receivedInvoice.queryWindowDays`,默认 30 天,金蝶约束 ≤1 个月)`BfiUtil.queryAllPages` 翻页,返回行按 `awrpSrcSys + awrpSrcCode(=发票号码)` 本地匹配,同键多行取修改时间最新。 + - 命中且发票状态正常 → 跳过 A303-2 保存,取金蝶单号**续推指定**(上次保存成功、指定中断的断点恢复); + - 命中但发票状态异常(作废2/红冲3/部分红冲7/全部红冲8)→ 置 failed 报人工,金蝶处置完毕重推; + - 未命中 → 执行保存。 +2. **镜像明细兜底回填**:镜像 `items_json` 或 `invoice_type_code` 为空(入镜像早于字段上线/滑出 30 天增量窗口)→ 按票号窄查 A801-1(`fetchMirrorItemsFromPool`,镜像入池时已过白名单,回填不再复检)补写镜像后再组装。 +3. **A303-2 保存**:`saveReceivedInvoice`(网关 body 为批量 data 数组,服务端当前单笔包装),成功取 `result[0].number` 即金蝶收票单号。 + +### 3.3 A303-2 报文取数 + +主表(联调口径:先只送 DTO 标注【必填】+ 票面核心字段,非必填 setter 注释保留): + +| 字段 | 取数 | +| --- | --- | +| awrp_src_sys / awrp_src_code | `thirdParty.bfi.srcSys`(默认 WLTMS)/ **发票号码**(一票一登记天然唯一,重推同键幂等) | +| invoiceno | 发票号码(非必填但为收票单核心字段,必送) | +| receivedate / issuedate | 推送当日 / 镜像开票日期(yyyy-MM-dd HH:mm:ss) | +| biztype | `PUR`(采购,**待金蝶确认**,常量 `KingdeeBfiConstant.BFI_RECEIVED_BIZ_TYPE`) | +| invoicetypef7_number | 镜像 `invoice_type_code`(票易通原始编码 26/27,名称反查不可靠故新增此列) | +| paymenttype / asstacttype | `CREDIT` 赊购(不让金蝶自动推付款)/ `bd_supplier` 供应商 | +| org_number / currency_number | 分摊结算单关联合同的组织编码(部门编码)/ 结算币种(RMB→CNY),复用 `KingdeeApBillSyncSupport.bfiOrgNumber/bfiCurrency` | +| receivablessupp_number | 合同乙方(开票供应商)客商档案编码,复用 `bfiAsstactNumber`(含编码缓存) | +| exratetable_number / awrp_src_type | `01` / `TMS收票登记`(**待金蝶确认**) | +| buyername / asstactname | 镜像受票单位 / 开票单位 | +| isincludetax | true(录入价税合计) | +| 销/购方税号、地址、电话、开户行账号 | 非必传,setter 注释保留(被金蝶强校验时从镜像 `saler_tax_no/buyer_tax_no/bank_name/bank_account/phone` 补齐,参照 A303-1 联调经验) | + +分录 entry(逐 A801-1 明细行展开,来源镜像 `items_json`): + +| 字段 | 取数 | +| --- | --- | +| e_pricetaxtotal / e_amount / e_tax | 明细行 金额+税额 / 金额 / 税额(2 位小数) | +| quantity / price | 票面数量(≤0 兜底 1)/ 票面单价 | +| material_number | **联调临时值**,Nacos `thirdParty.bfi.receivedInvoice.materialNumber`(默认 SP0324,与应收/应付调试值同源,校准后移除) | +| e_taxclass_number | 票面明细税收分类编码 goodsCode(缺失报错拦截) | +| taxrateid_number | 票面税率(小数)换算金蝶税率编码:0.13→V13、0.01→V1、0→V0 | +| awrp_entry_code | **发票号码-行号**(候选键) | + +### 3.4 A302-6 收票指定应付 + +保存成功先落 `pushed`(断点可恢复),随后立即执行指定:`invoiceAssign(op=assign, data=[{ap_invoice:[{发票号码,srcSys}], ap_finapbill:[{应付单防重键,srcSys}]}])`。财务应付单防重键与付款侧同源:**正式结算单号_结算明细单据号下划线拼接**(`KingdeeApBillSyncSupport.apBillSrcCode`)。成功后置 `assigned` 终态并记同步流水;失败置 failed + 原因,人工重推(重推凭 3.2 幂等预检跳过保存、仅补指定)。 + +--- + +## 4. 状态机与锁单 + +`blade_invoice_receipt.kingdee_push_status`(与票池绑定层 `kingdee_status` 语义分离,互不覆盖): + +``` +unsynced(默认)──保存成功──▶ pushed ──指定成功──▶ assigned(终态,锁作废) + │ │ + └────保存/预检异常──────────┴──▶ failed ──人工处理后重推──▶(幂等续推) + ▲ +定时回写:金蝶红冲/作废/删除 ── pushed/assigned ──┘(异常回收) +``` + +| 状态 | 含义 | 允许操作 | +| --- | --- | --- | +| unsynced | 未推送 | 编辑(草稿态本不允许推送)、推送 | +| pushed | 金蝶收票单已保存、指定未完成/未确认(瞬态,正常链路即被指定接管) | 推送(幂等续推)、定时指定补偿 | +| assigned | 已指定应付,终态;金蝶侧生成收票单并锁定 | 仅定时回写轮询;**禁止作废**(锁单),金蝶异常回收获占 | +| failed | 保存/指定失败或金蝶侧异常回收(红冲/作废/状态异常) | 人工在金蝶删除/解除指定后重推(同防重键幂等) | + +锁单防护:`voidBill` 在 pushed/assigned 时拦截「金蝶已生成收票单[xxx],不允许作废;请先在金蝶删除收票单/解除指定」;编辑/删除仅草稿、驳回态(既有约束,推送不受影响)。金蝶侧删除/解除指定**不自动执行**(unassign 不启用),一律人工处置后重推。 + +--- + +## 5. 环节③:A801-3 定时回写 + +代码入口:`PaymentKingdeeResultTask.syncKingdeeReceivedInvoiceResult`(独立开关 `thirdParty.bfi.receivedInvoiceResult.syncEnabled` 默认 false、`cron` 默认每10分钟)→ `InvoiceReceiptServiceImpl.syncKingdeeReceivedInvoiceResult`;联调可手动 `POST /invoice-receipt/sync-kingdee-result`。 + +轮询对象:pushed/assigned 且非作废、已绑定镜像的收票登记;按镜像购方名称分组、回看窗口(`thirdParty.bfi.receivedInvoice.resultWindowDays`,默认 30 天)批量翻页匹配防重键。 + +| 场景 | 处置 | +| --- | --- | +| 金蝶发票状态异常(作废/红冲/部分红冲/全部红冲) | → failed + 原因「已回收推送状态,请在金蝶删除/解除指定后重推」,不自动作废登记 | +| pushed 且金蝶侧收票单正常 | 指定补偿:批量预组装应付单防重键后重发 A302-6 → assigned;分摊关系不满足一票一应付的置 failed 报人工 | +| assigned 且本地缺金蝶收票单号 | 单号补登(保存成功但响应丢失的历史数据),不写流水 | +| 窗口内查不到 | **不改状态**(金蝶修改时间延迟/滑窗不误判删除),由人工重推兜底 | + +--- + +## 6. 防重键与幂等体系汇总 + +| 单据/对象 | 防重键/幂等依据 | 使用接口 | +| --- | --- | --- | +| 金蝶收票单 | awrp_src_sys=WLTMS + awrp_src_code=**发票号码** | A303-2(保存候选键)/ A801-3(预检+回写本地匹配)/ A302-6(ap_invoice 引用) | +| 收票单分录 | awrp_entry_code=发票号码-行号 | A303-2 | +| 财务应付单(被指定方) | awrp_src_code=正式结算单号_结算明细单据号拼接(与 A102-2 同源) | A102-4 预检 / A302-6(ap_finapbill 引用) | +| 票池镜像 | 唯一索引(tenant_id, invoice_no),票面指纹含新增 items_json/invoice_type_code | A801-1 upsert | +| 收票登记 | 发票号码同租户唯一(一票一登记) | 登记层保证 | + +断点恢复:保存成功响应丢失 → 重推 A801-3 命中跳过保存;指定失败 → failed 重推仅补指定(幂等预检在位);pushed 崩溃残留 → 定时指定补偿。 + +--- + +## 7. 数据前提与上线操作 + +| # | 事项 | 说明 | +| --- | --- | --- | +| 1 | 变更 SQL | `doc/sql/transport/blade_invoice_receipt_kingdee_push_20260922.sql`(幂等可重复执行):镜像表 `items_json`/`invoice_type_code`,收票登记表 `kingdee_received_invoice_no`/`kingdee_push_status`/`kingdee_push_msg`/`kingdee_push_time`。**执行后方可启动服务** | +| 2 | Nacos 配置 | 任务开关 `thirdParty.bfi.receivedInvoiceResult.syncEnabled/cron`;窗口 `thirdParty.bfi.receivedInvoice.queryWindowDays/resultWindowDays`(默认 30);联调临时值 `thirdParty.bfi.receivedInvoice.materialNumber`(默认 SP0324)。均有代码默认值,不配置也能跑 | +| 3 | 存量镜像补齐 | items/票种编码随 30 天增量窗口逐轮自动补写(指纹变更触发重写);窗口外老票由推送时 A801-1 窄查回填兜底 | +| 4 | 数据前提 | 收票登记须新链路登记(票在镜像、绑定收敛);**本地 21 条造数收票登记镜像悬空推不动,联调须新登记票**;供应商须建档并维护统一社会信用代码 + 客商编码;合同须维护组织编码/结算币种/乙方;结算单先完成付款侧 A102-2 应付单同步 | +| 5 | 联调校准项 | `biztype=PUR`、`awrp_src_type=TMS收票登记`、`materialNumber=SP0324`、金蝶税率编码 V13 命名、分录必填范围(金蝶强校验常多于 DTO 标注,参照 A303-1 经验逐步放开注释字段)、A302-6 结果结构与 `requireBfiSaveSuccess` 兼容性 | +| 6 | 联调顺序 | 执行 SQL → 全票池同步(补 items)→ 新收票登记选票→审批→批量同步绑定 → 付款侧结算单同步生成应付单 → `/push-kingdee` 推送 → 金蝶查收票单与指定关系 → 手动 `/sync-kingdee-result` 验证回写 | + +--- + +## 8. 关键代码索引 + +| 环节 | 代码位置 | +| --- | --- | +| 推送端点 | `blade-service/blade-transport controller/InvoiceReceiptController.pushKingdee / syncKingdeeResult`(`POST /invoice-receipt/push-kingdee`、`POST /invoice-receipt/sync-kingdee-result`,菜单权限 invoice_receipt) | +| 推送编排/回写 | `service/impl/InvoiceReceiptServiceImpl.pushKingdeeReceivedInvoice / pushOneReceivedInvoice / buildReceivedInvoicePushPlan / syncKingdeeReceivedInvoiceResult / applyReceivedInvoicePushResult` | +| 报文组装 | `support/KingdeeReceivedInvoiceSyncSupport`(pushReceivedInvoice / assignApBill / queryReceivedInvoiceBySrcCodes / parseItems),应付侧复用 `support/KingdeeApBillSyncSupport`(apBillSrcCode / queryApBillExistNos / requireContract / bfiOrgNumber / bfiCurrency / bfiAsstactNumber) | +| 镜像明细落库 | `InvoiceReceiptServiceImpl.fillInvoicePool / buildItemsJson`(items_json、invoice_type_code 随收票登记保存时 `persistMirrorFromRemote` 单票 upsert 落库,无指纹比对表——票池改回实时查询后指纹机制已退役) | +| 定时任务 | `task/PaymentKingdeeResultTask.syncKingdeeReceivedInvoiceResult`(第④子流程独立调度) | +| 接口契约 | `blade-third-party-api/blade-bfi-api`:`BfiReceivedInvoiceSaveDTO/EntryDTO`(A303-2)、`BfiInvoiceAssignItemDTO/RefDTO`(A302-6)、`BfiReceivedInvoiceQueryDTO/VO/EntryVO`(A801-3)、`IBfiService.saveReceivedInvoice/invoiceAssign/queryReceivedInvoice` | +| 常量/配置 | `constant/KingdeeBfiConstant`(RECEIVED_PUSH_* 状态机、BFI_RECEIVED_BIZ_TYPE、BFI_RECEIVED_INVOICE_SRC_TYPE)、`config/BfiProperties.ReceivedInvoice` | +| 展示 | `InvoiceReceiptVO.kingdeePushStatusName` + `InvoiceReceiptWrapper` 翻译;列表查询支持 kingdeePushStatus 过滤 | +| 前端 | 本期未接按钮,收票管理页面「批量推送金蝶」与推送状态列后续迭代接入 | diff --git a/doc/docs/业务流程/2026-09-22-金蝶票池选票统一查镜像(收票登记+结算单认领+按票号补查+统一组件)业务流程.md b/doc/docs/业务流程/2026-09-22-金蝶票池选票统一查镜像(收票登记+结算单认领+按票号补查+统一组件)业务流程.md new file mode 100644 index 00000000..e7869c41 --- /dev/null +++ b/doc/docs/业务流程/2026-09-22-金蝶票池选票统一查镜像(收票登记+结算单认领+按票号补查+统一组件)业务流程.md @@ -0,0 +1,85 @@ +# 金蝶票池选票实时查询(镜像降级为登记存根)业务流程 + +> 适用系统:TMS(BladeX 微服务平台,服务:blade-transport;前端:tms-erp-web) +> 对接系统:金蝶发票云旗舰版·票易通(piaozone)全票池 A801-1,经业财融合 BFI 网关(rim 契约)透传 +> 关联文档:`2026-09-20-收票管理同步金蝶全票池发票(A801-1查询+批量同步+定时绑定关联)业务流程.md`(同步链已整体退役,仅存推送/回写侧仍有效的键与端点见该文档退役声明)、`2026-09-22-收票单推送金蝶全链路业务流程(A303-2保存-A302-6指定应付-A801-3预检回写).md`(图05 推送链路,镜像字段 invoice_type_code/items_json 由其取数消费) +> 设计依据:`docs/superpowers/specs/2026-09-22-invoice-pool-remote-reversal-design.md` +> 最后更新:2026-09-23(选票可用过滤 + 有界循环补页游标分页 + 单据使用状态收敛为后端固定查未用;2026-09-22 本文原为「金蝶票池选票统一查镜像」,同日二次决策改回实时查询后整篇重写;文件名保留以免断链) +> +> **⚠️ 退役声明(2026-10-05)**:本文描述的**实时查询链路(游标分页、可用过滤拉取后执行、claimed/registered 纯展示装饰)已被第三次定稿回切为镜像读**,终态口径见 `2026-10-05-金蝶票池选票回切镜像读(本地真分页+票号回源+瘦身同步+认领闸门)业务流程.md`。**仍然有效的部分**:保存重查闸门 `persistMirrorFromRemote`(含 isUsablePoolRow 复核,原样保留)、镜像 `(tenant_id, invoice_no)` 唯一与存根写入语义、A801-2 附件下载镜像优先/回源链路。下文其余叙述作历史口径。 + +--- + +## 1. 背景与决策 + +2026-09-22 上午曾定稿"选票统一查本地镜像 + 全量同步维护镜像"方案。同日二次决策**推翻该方案**:同步体系(定时任务、增量窗口、批量 upsert、定时绑定、漏绑补偿、票种/客商税号双白名单)维护过重,且选票以金蝶侧实时状态为准更可靠。 + +**定稿口径**: + +- 收票登记新增、正式结算单认领两处选票改为**实时查 A801-1 全票池**;A801-1 无总数,翻页采用"多取 1 条探测 hasMore + 有界循环补页 + 扫描游标"的**游标式下一页翻页**(无跳页能力,机制详见 §2)。 +- (2026-09-23)选票**只出可用票**:金蝶返回行须同时满足 剩余可用金额 `availableAmount > 0` 且 金额使用状态 `amountUseStatus = "0"`(未使用,2-全部使用)。A801-1 无该维度的服务端过滤条件,只能拉取后客户端过滤,由此引入补页与游标(§2)。 +- (2026-09-23)**单据使用状态**不再作为前端筛选条件,后端固定下推"未用"(`expenseStatus = "1"`)。 +- `blade_kingdee_invoice_pool` 语义降级为**登记存根**:只存被业务选中的发票,写入时机=**收票登记保存时服务端重查金蝶落库**(否决"选中即写")。 +- 全量同步体系**退役删除**:定时任务、增量窗口、镜像批量 upsert、定时绑定(receiptMap)、漏绑补偿、白名单过滤(票种 + 客商税号 AND,客商建档不再是选票前置)、手动全量同步端点与"立即同步"按钮、按票号补查(语义并入主查询)。 +- 金蝶不可用时查询与保存重查均**直接报错**(镜像不再提供读服务,无降级路径——接受"每次开弹窗/每次保存打一次金蝶"的一次性成本,换取同步体系归零)。 + +## 2. 接口契约(后端) + +两个门面端点分挂各自菜单(类级 `@PreAuth` 按菜单隔离),同一服务方法落点;**路径/方法不变、语义换芯**: + +| 端点 | 说明 | +| --- | --- | +| `GET /invoice-receipt/invoice-pool`、`GET /formal-settlement/invoice-pool` | 实时统一查询,收参 `KingdeeInvoicePoolQueryDTO`,返回 `R`(游标分页) | + +- 已删除端点:`POST /*/invoice-pool/fetch`(按票号补查,语义并入主查询的发票号码条件)、`POST /invoice-receipt/sync-invoice-pool`(手动全量同步)。 +- **保留端点**:`POST /invoice-receipt/sync-kingdee`(列表"批量同步",职责收敛为刷新**已登记票**的镜像行与状态,镜像行此时必已存在)。 + +**查询条件**(`KingdeeInvoicePoolQueryDTO`):`current/size`(size 钳位 1~100)、`invoiceNo`、`invoiceDateStart/End`(服务端强校验 yyyy-MM-dd)、`salerTaxNo/buyerTaxNo`、`invoiceType`(票易通编码,如 26/27,选项取 `BfiConstant.INVOICE_TYPE_NAMES`)、`cursor`(2026-09-23 新增,下一页续扫游标)。`expenseStatus` 已从前端条件删除,后端固定下推"未用"(`KingdeeBfiConstant.POOL_EXPENSE_STATUS_UNUSED`)。**删除**了镜像时代的 `salerName/buyerName/claimed/registered` 条件(远程不支持名称模糊;本地状态不可下推)。 + +**A801-1 统一查询口径**:所有出站查询一律携带 `invoiceStatus = "0"`(正常票)与 `expenseStatus = "1"`(单据使用状态未用,后端固定;按票号单查 `queryPoolByInvoiceNo` 不下推 expenseStatus);返回行先做**选票可用过滤**(`isUsablePoolRow`:票号非空 + 剩余可用金额 `availableAmount > 0` + 金额使用状态 `amountUseStatus = "0"`,可用金额空白/解析失败按不可用兜底;口径常量集中在 `KingdeeBfiConstant`),再按 `invoiceNo` trim 全等防御过滤(防接口模糊匹配语义假命中),最后截断 size 条。 + +**有界循环补页分页(2026-09-23)**:可用过滤发生在拉取之后,单页远程返回可能大部分被过滤,因此 `invoicePoolPage` 在单请求内循环补页凑满一页——首拉 `size+1` 轻量探测,不足再按契约上限 500 行/次补拉,单请求最多 3 次远程调用(`POOL_MAX_REMOTE_CALLS`,过滤率过低时以此封顶防请求无限拉长),仍不足则返回稀疏页。远程短页(返回行数 < 请求数)即耗尽信号;`hasMore = 未耗尽 或 窗口内还有剩余过滤行`。**翻下一页必须回传上页 `nextCursor`**(格式"远程页码:已消费过滤行数",游标页码按 500 行窗口定位),否则按页码锚定会漏行/重行;游标解析失败自动降级为页码锚定,不阻断查询。 + +**返回体**(`KingdeeInvoicePoolPageVO`):`{ records: List, current, size, hasMore, nextCursor }`——**不再合成 total**;`nextCursor` 为下一页续扫游标,null 表示没有更多。行映射:票种编码→中文名、税率小数转百分数(顶层优先、回退明细首行)、`invoiceAmount = availableInvoiceAmount = matchedAmount = totalAmount`、`availableTaxAmount = availableAmount`(2026-09-23 新增"剩余可用金额"展示列,金蝶可用税额口径,**不改** `availableInvoiceAmount` 的价税合计口径——认领金额校验依赖该口径)、`attachmentJson = {source:'piaozone', path:downloadUrl, name:票号.pdf}`;`id` 置空(未入镜像)。`BfiInvoicePoolVO` 同步新增 `amountUseStatus` 字段承接金蝶返回。 + +**claimed/registered 纯展示装饰**:对本页票号批量查 `blade_formal_settlement_invoice`(关联主单未删且非 voided)与 `blade_invoice_receipt`(未删且非 voided),内存打标;只读展示,**不落库、不进查询条件**。 + +## 3. 保存时落库(收票登记,`persistMirrorFromRemote`) + +`InvoiceReceiptSaveRequest` 已删除 `kingdeeInvoicePoolId` 字段(前端不再传)。save/submit 链路新增"镜像绑定"步骤: + +``` +① 按 invoiceNo 调 queryPoolByInvoiceNo 远程 A801-1 精确单查(含票号全等防御) +② 未命中或 delete="1" → ServiceException("金蝶票池未查询到发票 X,不能保存"),保存中止 + (选中→保存竞态:票被删/红冲在此拦截;不主动作废既有登记,与列表同步口径一致) +③ 命中后按选票同口径 isUsablePoolRow 复核可用性(2026-09-23 新增): + 剩余可用金额为 0 或金额使用状态已变更 → ServiceException("发票 X 剩余可用金额为0或金额使用状态已变更,不能保存") + (防选票到保存之间票被他人认领/使用造成超用;明细补查 fetchMirrorItemsFromPool 不做该过滤) +④ fillInvoicePool 构建/刷新镜像行(保留图05 的 invoiceTypeCode/itemsJson 映射) +⑤ 保存/更新镜像行 → receipt.kingdeeInvoicePoolId = mirror.id, + 回填 kingdeeBillNo = serialNo、kingdeeStatus +``` + +镜像按 `(tenant_id, invoice_no)` 唯一(既有 uk),多单登记同票共享同一镜像行;单票 upsert,**无指纹表、无批量写**。编辑态表单"同步"按钮(`syncReferenceData`)为**纯预览**——调查询接口按票号取单行(records[0])回填表单,不写镜像;镜像刷新统一收敛到保存动作。 + +## 4. 前端统一组件(`src/components/kingdee-invoice-pool-dialog/main.vue`) + +- Props:`modelValue / title / confirmText / multiple / initialInvoiceNo / queryApi`(`fetchApi/syncApi` 已删除);事件 `confirm(rows)`(单选 `[row]`、多选选中数组)。打开时重置查询、按 `initialInvoiceNo` 预填、清勾选、回第 1 页加载。 +- 查询表单五条件(2026-09-23 删除"单据使用状态",后端固定查未用):发票号码、开票日期区间、销方税号、购方税号、发票类型(编码下拉);查询/重置按钮在加载期间禁用。 +- 表格:票面列 + **剩余可用金额**列(2026-09-23 新增,`availableTaxAmount`)+ **认领状态/登记状态**两个 tag(仅展示);"金蝶同步"列已删除(远程票恒在池中无意义);行主键用发票号码兜底(`rowKey = invoiceNo || id`,远程票 id 恒空);单选 radio/双击直选,多选 `reserve-selection` 跨页保留。 +- 分页区:**游标式**——"上一页/下一页" + "第 N 页" + 每页条数(10/20/50/100);**下一页请求回传上页 `nextCursor` 续扫**(新查询/换页大小/重新打开自动重置游标与页缓存);**上一页直接回显已浏览页缓存(`pageCache`),不再发请求**;`hasMore=false` 禁用下一页,第 1 页禁用上一页,加载中全部禁用;查询条件变更或换页大小即回第 1 页;翻页请求失败时页码回滚到发起前值(不停错页)。 +- 接线:收票登记表单(`multiple=false`,回填不再取 `id`,金蝶单据号取 `serialNo`、状态固定显示"未同步",保存落库后由后端刷新);结算单编辑器(`multiple=true`、`confirm-text="确认认领"`,`handleInvoiceClaimConfirm` 把复数 `attachmentsJson` 归一为认领契约单数 `attachmentJson`)。 +- 翻页期间票池数据变动:游标页可能重复/漏行(组件按发票号码做行主键与选择去重,结算认领确认前还有票号集合查重);最终一致性以保存/提交时服务端重查为准,无资金风险。 + +## 5. 数据清理 + +`doc/sql/transport/blade_kingdee_invoice_pool_prune_20260922.sql`(幂等可重跑):删除 `blade_kingdee_invoice_pool` 中未被任何未删 `blade_invoice_receipt.kingdee_invoice_pool_id` 引用的历史全量同步镜像行。**执行前必须先跑脚本内盘点 SELECT 并向用户报数确认**(dev 库清理口径:事务 + 回滚 SQL + mysqldump 文本兜底)。 + +## 6. 已知限制与验证 + +- 每次打开弹窗(最多 3 次补页调用)、每次保存都是金蝶远程调用(预期成本);金蝶不可用期间选票/收票保存均不可用。 +- 单据使用状态固定后端下推"未用"(前端无此条件);选票可用过滤(`availableAmount > 0` 且 `amountUseStatus = "0"`)在拉取后进行,过滤率过低时单请求最多补页 3 次即返回稀疏页,靠 `hasMore + nextCursor` 继续翻页。 +- 下一页依赖前端回传 `nextCursor`;上一页走前端缓存回显(0 请求),缓存页为浏览时快照,票池实时变动最终以保存/提交时服务端重查为准。 +- 无表结构、无菜单 SQL 变更;Nacos 三份模板中 `thirdParty.bfi.invoiceReceipt.*` 同步相关键已随任务删除(图05 `sync-kingdee`/推送链使用的键保留)。 +- 后端验证:`mvn clean compile -DskipTests -pl blade-service/blade-transport -am` + `mvn test-compile` 通过;前端 `pnpm run build` 通过;退役符号两仓库 grep 归零。 +- 手工回归清单与过程记录:`.superpowers/sdd/2026-09-22-invoice-pool-remote-reversal/`(各任务简报/报告/评审包)。 diff --git a/doc/docs/业务流程/2026-09-24-kingdee-attachment-sync-design.md b/doc/docs/业务流程/2026-09-24-kingdee-attachment-sync-design.md new file mode 100644 index 00000000..977df069 --- /dev/null +++ b/doc/docs/业务流程/2026-09-24-kingdee-attachment-sync-design.md @@ -0,0 +1,219 @@ +# 金蝶附件同步(A902)设计文档 + +- 日期:2026-09-24 +- 范围:后端同步链路联带 A902 附件上传(付款申请、正式结算应付/应收、收票登记、开票申请)+ 前端状态列/提示/超时调整 +- 背景:单据推送金蝶成功后,TMS 侧附件(attachmentsJson)不随单上行,金蝶单据缺影像。星瀚 A902「附件上传到指定单据」接口已具备(见《业财融合系统接口文档.xlsx》同名页签),此前未在 BFI 客户端实现 +- 前置:原计划复用 2026-09-24-kingdee-push-timeout-anticlick 的 loading+守卫+try/finally 模式;经评审确认该前置未落地,本期**不实施防连点(后补)**,本设计仍不新增前端状态机 + +## 一、目标 + +1. 五类单据推送金蝶成功后,后端联带逐个上传附件至金蝶对应单据附件面板(A902) +2. 收票登记含发票文件:票池镜像附件 path → A801-2 下载 → A902 回传收票单 +3. 附件同步独立状态、失败可补传(重推自动补);前端列表列 + message 可见 +4. 客商档案预留 entityNumber 映射配置,二期接入(开票申请 entityNumber 已确认为 `sim_original_bill`,提入本期) + +## 二、A902 / A801-2 契约 + +### A902 附件上传到指定单据 + +- `POST /ierp/kapi/v2/frame/attachment/uploadFile`,multipart/form-data +- Header:`access_token`(复用现有 getToken/token 缓存) +- Body:`file`(文件)+ `attachmentUploadFileArgs`(JSON):`{"entityNumber":"ap_payapply","billPkId":2386455983883835392,"controlKey":"attachmentpanel"}` +- 返回:`{"data":{"path":"211e6ce5c9805c00"},"errorCode":"0","status":true}`,`data.path` 即金蝶侧文件路径 + +### A801-2 文件下载(收票发票文件用) + +- `POST /ierp/kapi/v2/awrp/bosext/attachment/download`,body `path`(镜像 attachmentsJson 的 path)→ 返回文件流 + +### entityNumber / controlKey 映射 + +| 业务单据 | entityNumber | controlKey | 期次 | +| --- | --- | --- | --- | +| 付款申请单 | `ap_payapply` | `attachmentpanel` | 本期 | +| 财务应付单(正式结算应付面板) | `ap_finapbill` | `attachmentpanel` | 本期 | +| 财务应收单(正式结算应收面板) | `ar_finarbill` | `attachmentpanel` | 本期 | +| 收票单 | `ap_invoice` | `attachmentpanel` | 本期 | +| 开票申请单 | `sim_original_bill` | `attachmentpanel` | 本期 | +| 客商(资质附件) | 待金蝶确认 | 待确认 | 二期 | + +映射以常量类维护:`KingdeeAttachmentConstant.ENTITY_NUMBERS`(biz_type → entityNumber),controlKey 统一 `attachmentpanel`;二期客商补条目不改架构。 + +## 三、领域模型 + +### 实体 + +**业务附件(不动)**:各单据 `attachmentsJson` JSON 列,是「单据有哪些附件」的业务事实,随表单编辑、随审批快照。本设计不读写其结构(只读 url/name)。 + +**附件同步表 `blade_kingdee_attachment_sync`(新增)**: + +| 字段 | 说明 | +| --- | --- | +| id / tenant_id / 审计字段 | 标准结构 | +| biz_type | `pay_application` / `formal_settlement_ap` / `formal_settlement_ar` / `invoice_receipt` / `invoice_application` | +| biz_id | 单据 id | +| attachment_key | 幂等键:用户附件=附件 url;发票文件=A801-2 的 path(path 变化视为新文件可重传) | +| attachment_name | 附件名(展示/排查用) | +| kingdee_path | A902 返回的 data.path | +| sync_status | 1 成功 / 0 失败 | +| fail_reason | 失败原因(超限/下载失败/金蝶报错) | +| sync_time | 上传时间 | + +- 唯一键 `uk_(biz_type, biz_id, attachment_key)`;同步前按 `(biz_type, biz_id)` 载入成功集合,命中即跳过(幂等) +- 同步流水落点见「四、同步流水落点(评审定案)」 + +**单据表加列**:`kingdee_bill_id`(星瀚单据内码,取自保存响应 `BfiBillSaveResultItemVO.id`)。付款申请已有该列;本期新增三列(SQL 见 `doc/sql/transport/blade_kingdee_attachment_sync_20260924.sql`): + +| 表 | 列 | 说明 | +| --- | --- | --- | +| blade_formal_settlement | kingdee_bill_id | A102-2/A302-2 保存响应返回;幂等跳过/存量缺失经 A102-4/A302-4 按防重键反查回填 | +| blade_invoice_receipt | kingdee_received_invoice_id | A303-2 保存响应返回;A801-3 幂等预检命中/定时回写时一并回填 | +| blade_invoice_application | kingdee_bill_id | A303-1 保存响应返回;A303-4 勾稽回填一并带回 | + +### 附件同步状态机(单附件行级) + +``` +(无记录) ──上传成功──> 成功(终态,幂等跳过) + └────上传失败──> 失败(记 fail_reason;重推时重新尝试,成功则覆盖为成功) +``` + +列表聚合展示态(运行时聚合,非存储字段,评审定案):`无附件` / `未上传` / `部分未传` / `已上传`。 +判定:单据 attachmentsJson 无可上传附件→无附件;同步表成功数=0→未上传;0<成功数<附件数→部分未传;成功数≥附件数→已上传。 + +### 不变式 + +1. 附件上传必须晚于主单推送成功、拿到并落库 billPkId 之后 +2. 附件失败永不回滚、不污染主单同步状态(逐附件独立 try/catch) +3. 同 `(biz_type, biz_id, attachment_key)` 最多一条成功记录 +4. 不自动重试;重推主单时自动补传(主单被幂等预检跳过不影响附件补传分支) +5. 单文件软限制 20MB(常量,待金蝶确认后调整);超限/下载失败记原因跳过,不阻塞同单其余附件,更不阻塞同批其他单据 + +## 四、后端设计 + +### BFI 客户端(blade-bfi-api) + +`IBfiClient` 新增一个方法,复用现有 token 配置(`BfiFeignClientConfig`): + +- `uploadAttachment`(A902):`produces = MULTIPART_FORM_DATA_VALUE`,`@RequestPart("file")`(内容为内存字节数组,经 `InMemoryMultipartFile` 适配,单文件软限制 20MB)+ `@RequestPart("attachmentUploadFileArgs")`(JSON 字符串),返回 `BfiResultVO`,`data.path` 即金蝶侧文件路径 +- A801-2 下载**复用已有** `IBfiClient.invoiceDownload` / `IBfiService.downloadInvoice`(`feign.Response` 流式读取),本期仅在附件链路启用,无新增接口 + +### 附件同步支持类(blade-transport) + +新增 `KingdeeAttachmentSyncSupport`(`org.springblade.transport.support`,与 `KingdeeApBillSyncSupport` 等同包同风格): + +``` +syncAttachments(bizType, bizId, billPkId, List) // 主流程 +syncUserAttachments(bizType, bizId, billPkId, attachmentsJson) // 用户附件快捷入口 +parseUserAttachments(json) / parseInvoicePoolFile(json) // 解析:数组项容忍 url/link/fileUrl/downloadUrl/domain/src;镜像对象取 path +countAttachments(json) / successCountByBizId(bizType, bizIds) // 列表聚合(一次 GROUP BY,防 N+1) +aggregateStatus(attachmentCount, successCount) // 四态判定 +``` + +流程:载入已成功集合 → 过滤已传 → 过滤超限(记 fail_reason)→ 逐个(http(s) 下载 / A801-2 下载,流式封顶读取)→ A902 上传 → 落记录。单附件 try/catch,异常记失败继续下一个;结果汇总(`AttachmentSyncSummary`:total/success/failed/skipCount/failReasons)供接入点写操作流水与前端提示。同步表既有失败记录重试成功时覆盖为成功(复用主键 update)。 + +### 各模块接入点(主单成功、billPkId 落库之后,均在主单事务提交后执行) + +| 模块 | 接入点 | entityNumber | 附件来源 | +| --- | --- | --- | --- | +| 付款申请 | `PaymentApplicationServiceImpl.syncKingdee(id, context)` 包装层(`doSyncKingdee` 事务提交后) | ap_payapply | 单据 attachmentsJson | +| 正式结算应付/应收 | `FormalSettlementServiceImpl.syncKingdee`(无事务包装)→ `doSyncKingdee`(@Transactional)提交后 | ap_finapbill / ar_finarbill | 结算单 attachmentsJson | +| 收票登记 | `pushOneReceivedInvoice`:A303-2 落 pushed 后、A302-6 指定应付前 | ap_invoice | 登记附件 + 镜像发票文件(A801-2 下载,key=path) | +| 开票申请 | `InvoiceApplicationServiceImpl.syncKingdee`(无事务包装)→ `doSyncKingdee` 提交后 | sim_original_bill | 单据 attachmentsJson | + +事务边界(ADR-7):附件上传是慢远程调用,统一放在主单事务**之外**——正式结算/开票申请原 `syncKingdee` 事务体下移为 `doSyncKingdee`(经自代理调用),付款申请沿用既有包装层模式;附件失败仅在附件链路内消化,绝不回滚主单、不拉长主单持锁时间。 + +星瀚内码(billPkId)回填路径: + +- 推送路径:保存/下推响应 `BfiBillSaveResultItemVO.id` 随单落库(`BfiApBillPushOutcome`/`ReceivedInvoicePushOutcome` 扩展携带 billId) +- 幂等/存量路径:本地 kingdee_bill_id 缺失时反查——正式结算 A102-4/A302-4(`queryApBillExistId`/`queryArBillExistId`)、收票 A801-3 预检行/定时回写行自带 id、开票申请 A303-4 勾稽行自带 id;反查命中即回填并记 info 日志,仍未命中则跳过附件待重推补传 + +重推补传:现有同步入口无改动——主单被幂等预检判已存在时,仍进入附件补传分支(billPkId 取本地 kingdee_bill_id,缺失则按上述路径反查回填)。 + +### 同步流水落点(评审定案) + +附件明细以 `blade_kingdee_attachment_sync` 自身为准(sync_time/fail_reason 已是完整流水);仅在**有 actionType 流水表的单据**追加 `attachment_upload` 操作记录:收票登记(`InvoiceReceiptRecord`)、开票申请(`InvoiceApplicationRecord`);付款申请/正式结算无流水表,不追加。 + +### 列表状态聚合 + +各列表 VO(PaymentApplicationVO/InvoiceApplicationVO/FormalSettlementVO/InvoiceReceiptVO)新增三个展示字段:`attachmentCount`、`attachmentSyncStatus`(none/pending/partial/done)、`attachmentSyncStatusName`(无附件/未上传/部分未传/已上传)。各 `selectPage` 转换后按本页 biz_ids 一次 `GROUP BY biz_id`(`selectMaps`)查同步表成功计数,与解析 attachmentsJson 的附件数对比得出聚合态,避免 N+1;正式结算按应付/应收拆两组业务类型各查一次。 + +## 五、前端设计 + +| 项 | 内容 | +| --- | --- | +| 超时 | 实施勘误:paymentApplication/invoiceApplication/formalSettlement 的 syncKingdee 实际已是 120s(批量 300s),无需调整;唯一风险点是 `invoiceReceipt.js pushKingdee` 未设 timeout(走全局 10s 默认),已显式放宽为 120000 | +| 列表列 | payment-application.vue、invoice-application.vue、formal-settlement.vue(option `formalSettlementTable.js`)、invoice-receipt.vue 增「附件同步」列(`attachmentSyncStatusName`):无附件/未上传/部分未传/已上传 | +| message | 同步成功后 `await loadTable()` 刷新列表,按本次同步 ids 过滤刷新行中 `attachmentSyncStatus ∈ {pending, partial}` 的数量,追加 warning 提示「其中 N 单附件未全部上传,可重推补传」;收票页追加进推送结果弹窗文案 | +| 交互 | 无新增按钮(重推即补传);loading/守卫/try-finally 防连点模式本期未实施(见「不做的事」) | + +## 六、不做的事 + +- 前端同步按钮防连点模式(loading+守卫+try/finally):本期评审确认不实施,后补 +- 客商档案附件推送(二期,等金蝶确认 entityNumber/controlKey;本期仅预留映射配置 `KingdeeAttachmentConstant.ENTITY_NUMBERS`) +- 附件自动重试、定时补传任务 +- attachmentsJson 结构改造(去 JSON 化属独立重构,见 ADR-3) +- OFD/PDF 发票文件在金蝶侧的可预览性验证 + +## 七、待确认(金蝶侧) + +1. A902 单文件大小上限、允许格式(当前后端软限制 20MB 常量) +2. 客商的 entityNumber/controlKey +3. A902 调用频率限制(批量串行是否需间隔) +4. OFD 数电票文件上传收票单附件面板是否可用(主文档 641 行注明「金蝶不支持直接推送数电票 OFD 附件」,需验证 A902 通道例外与否) + +## 八、验证 + +1. 后端:`mvn clean compile -DskipTests -pl`(改动模块)——✅ 已通过(bfi-api / transport-api / blade-transport) +2. 前端:`pnpm run typecheck`——⚠️ 仓库既有问题无法运行(vue-tsc 3.3.11 与 typescript@7.0.2 不兼容,TS7 移除 `./lib/tsc` 导出,与本特性无关);已用 @vue/compiler-sfc 解析 + `node --check` 校验全部改动 .vue/.js 语法通过 +3. 手动(用户执行): + - 含附件单据(付款申请/开票申请/正式结算/收票登记)单条/批量同步 → 金蝶对应单据附件面板可见文件 + - 同步成功但附件失败(断网/超限模拟)→ 主单状态正常、列表「附件同步」列示未传/部分未传、重推自动补传 + - 重推已全部上传的单据 → A902 零调用(幂等跳过,日志「历史已成功 N 个」) + - 收票登记:镜像发票文件经 A801-2 下载后出现在收票单附件面板 + - 关键日志链路:`调用BFI接口 附件上传开始/结束` → `金蝶附件上传成功` → `金蝶附件同步完成` → 各接入点「同步完成」日志 + +## 九、ADR 摘要(本次评审定案) + +| # | 决策 | 备选与理由 | +| --- | --- | --- | +| ADR-1 | 后端联带自动传附件 | 前端两段式/独立按钮被否:状态机复杂、易漏传 | +| ADR-2 | 附件独立状态+失败可补传,不自动重试 | 主单状态机不被污染;重推即补传零新增交互 | +| ADR-3 | 幂等落独立同步表;**保留 attachmentsJson** | 附件业务事实(编辑/审批快照)与集成状态(幂等/path/原因)职责分离,url 幂等键是必要冗余而非数据冗余;推送期间单据锁定,无并发写 JSON 风险;去 JSON 化若要做属独立重构 | +| ADR-4 | 本期 5 单据(开票申请 entityNumber 已确认 `sim_original_bill` 提入本期),客商预留配置 | entityNumber 是硬约束,猜错即传不上 | +| ADR-5 | 同步接口超时 60s + 后端软限制跳过 | 大文件不拖死整批;不引入异步任务复杂度 | +| ADR-6 | 收票含发票文件:A801-2 下载→A902 回传,key=path | path 变化(红冲重开)自动触发重传 | +| ADR-7 | 附件上传在主单事务提交后执行(正式结算/开票申请拆 `doSyncKingdee` 事务体) | 附件是慢远程调用:放事务内会拉长持锁时间;放事务外失败只消化在附件链路,主单状态不被污染 | + +## 十、实施记录(2026-09-24 as-built) + +评审定案与设计的差异,均已按本节落地: + +| # | 事项 | 设计原假设 | 实施定案 | +| --- | --- | --- | --- | +| 1 | 前端防连点模式 | 前置已落地、直接复用 | 未落地,本期不实施(后补) | +| 2 | 前端超时 | 30s→60s 全量放宽 | 其余已 120s/300s 不动;仅 `invoiceReceipt.pushKingdee` 显式 120s(原全局 10s 默认) | +| 3 | 同步流水落点 | 「现有同步流水」统一新增 action | 无统一流水表:新表即明细事实源;收票/开票申请有 actionType 流水表追加 `attachment_upload`,付款申请/正式结算不追加 | +| 4 | 聚合态术语 | 正文与术语表两版冲突 | 定案:无附件/未上传/部分未传/已上传(CONTEXT.md 已落档) | +| 5 | A801-2 客户端 | 需新增 downloadFile | 已有 `invoiceDownload`,直接复用 | +| 6 | 应付/应收/收票推送返回 | 未明确内码获取方式 | `BfiApBillPushOutcome`/`ReceivedInvoicePushOutcome` 扩展 billId;幂等/存量经查询接口反查回填(`queryApBillExistId`/`queryArBillExistId`、A801-3/A303-4 查询行自带 id) | + +关键产物: + +- 后端:`IBfiClient.uploadAttachment`、`BfiAttachmentUploadVO`、`InMemoryMultipartFile`、`KingdeeAttachmentConstant`(biz_type/entityNumber/controlKey/20MB/聚合态)、`KingdeeAttachmentSync` 实体 + Mapper/Service、`KingdeeAttachmentSyncSupport` +- 接线:PaymentApplicationServiceImpl、FormalSettlementServiceImpl、InvoiceReceiptServiceImpl、InvoiceApplicationServiceImpl、KingdeeApBillSyncSupport、KingdeeArBillSyncSupport、KingdeeReceivedInvoiceSyncSupport +- 前端:4 option 列 + 4 页面 warning 提示 + invoiceReceipt.js 超时 +- SQL:`doc/sql/transport/blade_kingdee_attachment_sync_20260924.sql`(建表 + 3 加列,可重复执行) +- 提交:tms-api `dee649d`(docs) → `79aaa9b`(feat) → `9f95c75`(log);tms-erp-web `aabe67e` + +## 术语表 + +| 术语 | 含义 | +| --- | --- | +| A902 | 星瀚「附件上传到指定单据」接口(注意与客商接口 A902-1/A902-2 是两套编号) | +| A801-2 | 星瀚「文件下载」接口(票池发票影像) | +| billPkId | 星瀚单据内码,保存响应 `BfiBillSaveResultItemVO.id`,A902 定位目标单据 | +| entityNumber | 星瀚单据标识(如 ap_payapply) | +| controlKey | 星瀚附件面板/附件字段标识,统一 attachmentpanel | +| 附件幂等键 | 判定「同一附件已传过」的键:用户附件=附件 url;发票文件=A801-2 的 path(path 变化视为新文件) | +| 镜像发票文件 | 票池镜像 attachmentsJson 保存的发票文件 {source,path,name},上传收票单时经 A801-2 取回回传 | +| 附件同步聚合态 | 无附件/未上传/部分未传/已上传(列表展示用,运行时聚合) | +| 重推补传 | 主单重复推送(含幂等跳过)时自动补传未成功附件,无新增交互 | diff --git a/doc/docs/业务流程/2026-09-24-收款认领金蝶全链路业务流程(A301-2公告同步-A301-3认领推送-A301-1收款单回写).md b/doc/docs/业务流程/2026-09-24-收款认领金蝶全链路业务流程(A301-2公告同步-A301-3认领推送-A301-1收款单回写).md new file mode 100644 index 00000000..d7db7410 --- /dev/null +++ b/doc/docs/业务流程/2026-09-24-收款认领金蝶全链路业务流程(A301-2公告同步-A301-3认领推送-A301-1收款单回写).md @@ -0,0 +1,222 @@ +# 收款认领金蝶全链路业务流程(A301-2 认领公告同步 → A301-3 认领结果推送 → A301-1 收款单查询回写) + +> 适用系统:TMS(BladeX 微服务平台,服务:blade-transport) +> 对接系统:金蝶云·星瀚(业财融合 BFI,经 iPaaS 网关 `/gwzh/BFI` 直连,不经过合同合规系统) +> 覆盖接口:A301-2 收款认领公告查询、A301-3 收款认领结果推送、A301-1 收款单查询(收款处理查询) +> 关联文档:`TMS业财融合-集成流程图文字版与业务流程梳理.md` 第 7 节(图06 回款认领)、`2026-09-17-BFI分页查询通用翻页工具(BfiUtil-queryAllPages)技术文档.md`、`2026-09-23-金蝶(星瀚)BFI接口调用全链路梳理(按业务域汇总).md` +> 最后更新:2026-09-24(按当前代码梳理;接口契约在 BFI 客户端已编码,本期完成业务侧接线与联调准备) + +--- + +## 1. 整体业务链路 + +图06「收款查询及认领(回款认领)」采用**业务先行认领(场景二)**:金蝶司库收到银行流水并完成**第一轮自动认领(到客户维度)**,TMS 认领到合同/结算单维度后回推金蝶生成收款单。分三段: + +``` +阶段一:认领公告同步(TMS 手动触发,POST /receipt-flow/sync) + A301-2 收款认领公告查询(claimusers=当前登录用户真实姓名) + → BfiUtil.queryAllPages 全量翻页 → 按 detail_serial_no 幂等 upsert 收款流水表 blade_kingdee_receipt_flow + → 前端"手动同步流水"按钮直接调用(不再传本地 mock 行) + +阶段二:二次认领(页面认领,POST /receipt-flow/claim) + 用户在收款流水详情/批量认领页选择审批通过的应收正式结算单并按比例分摊 + → 本地生成认领主单 blade_receipt_claim + 分摊表 blade_receipt_claim_settlement + 流水金额状态更新 + → A301-3 收款认领结果推送(billid=公告id、分录=结算单分摊行) + → 推送成功回写金蝶收款单号 + kingdee_bill_status=pushed;失败则抛异常整体回滚 + +阶段三:收款单查询回写(手动触发,POST /receipt-claim-record/query-kingdee-result) + A301-1 收款单查询(近 1 个月窗口全量翻页) + → 按 sourcebillnumber=awrp_src_code 本地匹配已推送认领 + → 回写金蝶收款单号与单据状态(A暂存/B已提交/C已审核/D已收款) +``` + +核心设计原则(与付款/应收/收票链路一致): + +1. **公告为准**:收款流水统一由 A301-2 认领公告落库,前端不再自造流水数据;按 `detail_serial_no` 幂等 upsert。 +2. **推送先于结果**:认领保存与 A301-3 推送在同一事务内,推送失败整体回滚,避免金蝶与本地认领不一致。 +3. **认领公告 id 必填**:A301-3 按 `billid` 定位公告,同步阶段必须落库公告 id(`bill_id`),缺失拦截。 +4. **以金蝶侧实际生成为准**:A301-3 推送成功回写金蝶收款单号;A301-1 回写仅推进 `kingdee_bill_status`,不改本地认领金额。 +5. **异常回收人工处置**:本期认领作废不推金蝶(本地生成 KDCX 冲单号),金蝶侧退票/红冲由人工处置后重推,后续迭代接入。 + +--- + +## 2. 三个接口在链路中的角色 + +| 接口 | 名称 | 方向 | 触发环节 | 业务用途 | +| --- | --- | --- | --- | --- | +| A301-2 | 收款认领公告查询 | TMS → 金蝶(查询) | 阶段一(手动同步) | 按参与认领用户拉取金蝶认领公告,落库存收款流水表供页面认领 | +| A301-3 | 收款认领结果推送 | TMS → 金蝶 | 阶段二(页面认领) | 将二次认领结果(认领至结算单,即应收单)推给金蝶生成收款单 | +| A301-1 | 收款单查询 | TMS → 金蝶(查询) | 阶段三(手动回写) | 查询金蝶收款单(收款处理)状态,按来源单据编码回写认领结果 | + +查询类接口时间窗约束:A301-1 **业务日期范围 ≤ 1 个月**;A301-2 无时间窗参数,仅按用户与分页。 + +--- + +## 3. 阶段一:A301-2 认领公告同步 + +代码入口:`ReceiptFlowController.sync`(`POST /receipt-flow/sync`,菜单权限 `receipt_flow`,按钮权限 `receipt_flow_sync`)→ `ReceiptFlowServiceImpl.sync()`。 + +### 3.1 编排 + +1. **确定参与认领用户**:`UserCache.getUserRealName(AuthUtil.getUserId())`,为空拦截「当前用户无姓名,无法同步认领公告」。 +2. **全量翻页**:`BfiUtil.queryAllPages(pageNo -> bfiService.queryClaimAnnounce(queryDTO))`,受 `BfiUtil.DEFAULT_MAX_PAGES`(50 页)保护。时间窗口从 Nacos 配置 `thirdParty.bfi.claimAnnounce.months` 读取(默认 6 个月)。 +3. **幂等落库**:按 `detail_serial_no`(取 `detailid`,缺省回退 `tradedetailno`/`bankdetailno`)`FOR UPDATE` 查重后 insert/update 收款流水表;重复明细流水号抛「明细流水号重复」拦截。 +4. **操作留痕**:`blade_receipt_flow_record` 记 `sync` 操作(新增/更新逐行记录,operator=当前用户)。 + +### 3.2 A301-2 返回 → 收款流水表字段映射 + +`blade_kingdee_receipt_flow`(新增列见 §7 SQL): + +| A301-2 返回(BfiClaimAnnounceVO) | 收款流水表字段 | 说明 | +| --- | --- | --- | +| id | `bill_id` | **认领公告 id,A301-3 推送必填**,本期新增 | +| billno | `receipt_notice_no` | 认领通知单 | +| recviewpayer | `payer_name` | 付款人展示名 | +| reamount | `receipt_amount` | 收款金额 | +| oppunit | `counterparty_name` | 对方户名 | +| oppbanknumber | `counterparty_account` | 对方账号 | +| oppbank | `counterparty_bank` | 对方开户行 | +| description | `summary` | 摘要 | +| tradetime | `transaction_time` | 交易时间 | +| detailid(缺省回退 tradedetailno/bankdetailno) | `detail_serial_no` | 明细流水号,**幂等键(tenant_id, detail_serial_no 唯一索引)** | +| unclaimamount | `unclaimed_amount` | 未认领金额,本期新增 | +| fee | `fee` | 手续费,本期新增 | +| recpayee | `payee_name` | 收款人,本期新增 | +| claimstatus | `kingdee_claim_status` | 公告认领状态(0未认领/1已认领),本期新增 | +| claimme | `claim_me` | 我已认领,本期新增 | +| claimedamount | —(不落库) | 本地 `claimed_amount` 只记录 TMS 认领金额,与公告已认领金额语义区分 | + +> **口径说明**:`claimed_amount`/`claim_status` 为**本地认领金额/认领状态**(由 claim/作废驱动),与金蝶公告的 `claimstatus`/`claimedamount` 互不覆盖;公告侧状态单独存 `kingdee_claim_status` / `unclaimed_amount`。 + +### 3.3 前端 + +`receipt-flow.vue`「手动同步流水」按钮不再生成 mock 行,直接 `POST /receipt-flow/sync`(无请求体),后端返回同步条数后刷新列表。 + +--- + +## 4. 阶段二:A301-3 认领结果推送 + +代码入口:`ReceiptFlowController.claim`(`POST /receipt-flow/claim`)→ `ReceiptFlowServiceImpl.claim(ReceiptClaimRequest)`。 + +### 4.1 编排 + +1. **本地校验**(沿用现有逻辑):流水锁行、结算单锁行、分摊金额上限校验(累计认领不超结算金额、不超流水剩余可认领金额)、关联结算单同一项目/组织/收付款方。 +2. **本地落库**:插入 `blade_receipt_claim`(claim_status=claimed、kingdee_bill_status=none、awrp_src_code=生成的来源单据编码)+ 分摊表 `blade_receipt_claim_settlement` + 刷新结算单收款汇总 + 更新流水的 `claimed_amount/claim_status` + 认领留痕。 +3. **生成来源单据编码**:`awrp_src_code = SKCL + yyyyMMddHHmmss + 认领id尾号6位`(参照冲单号生成,同文件 `buildKingdeeBillNo` 风格),落库 `blade_receipt_claim.awrp_src_code`(本期新增),作为 A301-1 回写匹配键。 +4. **A301-3 推送**:`bfiService.pushClaimResult(dto)`(网关 body 单笔包装)。推送成功(`BfiBillSaveResultVO` 全项成功)→ 回写 `kingdee_bill_no`(`result[0].number` 金蝶收款单号)+ `kingdee_bill_status=pushed`;失败 → 抛 `ServiceException`(汇总行错误文本)**整体回滚**。 + +> **幂等说明**:`awrp_src_sys + awrp_src_code` 为金蝶候选键;推送成功但本地回滚(网络/DB 抖动)后重推会**重新生成 awrp_src_code** 产生新单,联调期以推送失败回滚为主路径规避;后续如遇需要可改为推送前置生码 + 成功补偿落库。 + +### 4.2 A301-3 报文取数 + +| DTO 字段 | 取数 | 说明 | +| --- | --- | --- | +| billid | 流水 `bill_id` | **必填**,缺失拦截「认领公告id缺失,请先同步金蝶收款流水」 | +| awrp_src_sys | `thirdParty.bfi.srcSys`(默认 WLTMS) | 与付款/收票链路同源 | +| awrp_src_code | `SKCL+时间戳+认领id尾号` | 见 4.1 | +| recpaytype_number | `KingdeeBfiConstant.BFI_CLAIM_REC_PAY_TYPE`(**待确认**,示例 101) | 收款类型编码 | +| paymenttype | `KingdeeBfiConstant.BFI_CLAIM_PAYMENT_TYPE`(**待确认**,默认 bd_customer) | 付款人类型 | +| recviewpayer | 流水 `payer_name` | 付款人名称 | +| recpayer | 流水 `payer_id`(同步时暂不落库) | **待确认**:金蝶付款人 master id,DTO 标记必填;联调若金蝶强校验则需按付款人名称反查 A902-1 客户/供应商档案 id 补齐 | +| entryentity[] | 各结算单分摊行 | 见 4.3 | + +### 4.3 A301-3 分录取数 + +| entryentity 字段 | 取数 | +| --- | --- | +| e_receivableamt | 分摊金额 `allocated_receipt_amount` | +| e_corebilltype | `ar_finarbill`(财务应收单,复用 `KingdeeBfiConstant.BFI_AR_BILL_SRC_BT`),有金蝶应收单号时携带 | +| e_corebillno | 分摊结算单 `formal_settlement.kingdee_bill_no`(A302-2 同步回写,缺失时不传,`@JsonInclude(NON_NULL)` 省略) | +| seq | 分摊行序号(1 起) | +| e_receivingtype_number | 同 `recpaytype_number`(**待确认**,示例仅含 e_receivableamt,其余非必填按需放开) | + +### 4.4 金额流转 + +认领分摊后收款流水本地状态: + +``` +claim_amount(认领主单) = Σ 分摊金额 +flow.claimed_amount = Σ 该流水所有有效认领分摊(local) +flow.claim_status = 0 → unclaimed; 定时回写开关(复用 `PaymentKingdeeResultTask` 编排风格)本期预留,未接入定时调度;后续按认证领记录量接入独立子流程(开关 `thirdParty.bfi.receiptClaimResult.*`)。 + +--- + +## 6. 状态机与防重体系 + +### 6.1 收款流水本地认领状态 + +``` +unclaimed ──认领分摊──▶ partial/claimed ──(全部作废)──▶ unclaimed + ▲ │ + └────同步公告幂等 upsert 不覆盖(公告已认领金额不落 claimed_amount) +``` + +### 6.2 认领记录金蝶状态 + +``` +none(认领落库默认)──A301-3推送成功──▶ pushed ──A301-1回写D──▶ received(终态) + │ │ └──C──▶ approved──┐ + └────推送失败(抛异常回滚,无残留) └──A/B/garker──▶ failed ──┘ +作废(本期不推金蝶):→ voided + KDCX 冲单号(本地) +``` + +### 6.3 防重键与幂等汇总 + +| 对象 | 防重键/幂等依据 | 使用接口 | +| --- | --- | --- | +| 收款流水 | 唯一索引(tenant_id, detail_serial_no) | A301-2 sync upsert | +| 金蝶收款单 | awrp_src_sys + awrp_src_code | A301-3(候选键)/ A301-1(回写按 sourcebillnumber 匹配) | +| 认领记录 | 本地主键 + awrp_src_code(回写匹配键) | claim / A301-1 | + +--- + +## 7. 数据前提与上线操作 + +| # | 事项 | 说明 | +| --- | --- | --- | +| 1 | 变更 SQL | `doc/sql/transport/blade_receipt_flow_claim_20260924.sql`(幂等可重复执行):收款流水表新增 `bill_id/payer_id/kingdee_claim_status/claim_me/unclaimed_amount/fee/payee_name`,认领主单新增 `awrp_src_code`。**执行后方可启动服务** | +| 2 | Nacos 配置 | 无新增必配项;A301-1 回写窗口(31 天)本周期内为代码常量,后续抽配置 | +| 3 | claimusers 前提 | 金蝶认领公告的 `claimusers` 字段须与 TMS 用户真实姓名匹配,否则手动同步查不到公告;联调时需金蝶侧在公告维护认领人 | +| 4 | A301-3 待确认字段 | `recpaytype_number`(示例 101)、`paymenttype`(默认 bd_customer)、`recpayer`(金蝶付款人 master id 来源)、分录 `e_corebilltype/e_corebillno/e_receivingtype_number` 是否必传——以金蝶接口文档为准,常量收敛于 `KingdeeBfiConstant.BFI_CLAIM_*` | +| 5 | 结算单前提 | 认领分摊的应收正式结算单须审批通过、未作废,且已完成付款侧/应收侧金蝶同步(`formal_settlement.kingdee_bill_no` 有值)以便分录携带财务应收单号 | +| 6 | 联调顺序 | 执行 SQL → 金蝶维护认领公告及认领人 → 页面「手动同步流水」→ 收款流水列表/详情认领(选择结算单分摊)→ 推送若失败按金蝶报错调整常量 → 认领记录页「查询金蝶认领结果」验证回写 | + +--- + +## 8. 关键代码索引 + +| 环节 | 代码位置 | +| --- | --- | +| 认领公告同步 | `controller/ReceiptFlowController.sync`(`POST /receipt-flow/sync`)→ `service/impl/ReceiptFlowServiceImpl.sync()` | +| 认领推送 | `controller/ReceiptFlowController.claim`(`POST /receipt-flow/claim`)→ `service/impl/ReceiptFlowServiceImpl.claim()/pushClaimResultByBfi()` | +| 收款单回写 | `controller/ReceiptClaimRecordController.queryKingdeeResult`(`POST /receipt-claim-record/query-kingdee-result`)→ `service/impl/ReceiptClaimRecordServiceImpl.refreshKingdeeResult()` | +| BFI 客户端契约 | `blade-third-party-api/blade-bfi-api`:`IBfiService.queryClaimAnnounce/pushClaimResult/queryRecBill`、`BfiClaimAnnounceVO`、`BfiClaimPushDTO/EntryDTO`、`BfiRecBillVO`、`BfiClaimAnnounceQueryDTO/BfiRecBillQueryDTO` | +| 常量/配置 | `constant/KingdeeBfiConstant`(BFI_CLAIM_REC_PAY_TYPE、BFI_CLAIM_PAYMENT_TYPE、KINGDEE_STATUS_PUSHED/RECEIVED)、`config/BfiProperties`(srcSys) | +| 展示 | `ReceiptFlowVO.kingdeeClaimStatusName` + `ReceiptFlowWrapper` 翻译;`ReceiptClaimRecordVO.kingdeeBillStatusName`(含 pushed/received) | +| 前端 | `tms-erp-web:views/payment/receipt-flow.vue`(手动同步去 mock)、`views/payment/receipt-claim-record.vue`(查询金蝶认领结果按钮)、`api/payment/receiptFlow.js、receiptClaimRecord.js` | \ No newline at end of file diff --git a/doc/docs/业务流程/2026-09-27-kingdee-attachment-download-design.md b/doc/docs/业务流程/2026-09-27-kingdee-attachment-download-design.md new file mode 100644 index 00000000..611fa719 --- /dev/null +++ b/doc/docs/业务流程/2026-09-27-kingdee-attachment-download-design.md @@ -0,0 +1,171 @@ +# 金蝶附件下载(A801-2 前端下载通道)设计文档 + +- 日期:2026-09-27 +- 范围:票池镜像发票文件面向前端的查看/下载通道(后端流式代理端点 + 前端三处入口改造);附件同步(A902)链路零改动 +- 背景:2026-09-24 金蝶附件同步设计核验时确认缺口——票池镜像发票文件的 attachmentsJson 为 `{source:"piaozone", path, name}`,其中 `path` 是金蝶侧文件地址(A801-1 的 downloadUrl,非 http 直链),前端 `invoiceAttachmentUrl`/`attachmentFileUrl` 只识别 `url/link/fileUrl`,因此正式结算认领发票行、付款申请发票附件行的「查看/下载」按钮对这类文件弹「暂无可查看/下载附件」;A801-2 后端能力已具备(`IBfiClient.invoiceDownload`),但仅用于附件同步链路(A801-2 下载 → A902 回传),无面向前端的下载端点。 + +## 一、目标 + +1. 前端可在票池弹窗、正式结算认领发票行、付款申请发票附件行三处查看/下载票池发票文件,文件经后端代理 A801-2 流式返回 +2. http 型附件(OSS 直链)的现有查看/下载行为完全不变,两类附件按字段特征自动分流 +3. 不提供「任意 path」下载能力,防止开放代理与越权读取金蝶文件 + +## 二、现状与缺口(核验结论) + +| 入口 | 现状 | 缺口 | +| --- | --- | --- | +| 票池弹窗 `kingdee-invoice-pool-dialog` | 仅选择发票,无附件操作列 | 无查看/下载入口 | +| 正式结算 `formal-settlement-editor.vue` 认领发票行 | 有 查看/下载 按钮,`invoiceAttachmentUrl` 只取 `url/link/fileUrl`(:2288-2305) | path 型附件取不到 url,按钮弹「暂无可查看/下载附件」 | +| 付款申请 `payment-application-form.vue` 发票附件行 | 同上(:1385-1411) | 同上 | +| 收票登记 `invoice-receipt-form.vue` | 登记附件(OSS 直链)正常 | 镜像发票文件入口由票池弹窗覆盖,本表单不改 | + +行数据具备条件:票池弹窗选中行与认领行均 `...row` 展开(formal-settlement-editor.vue:2195-2216),自带 `invoiceNo` 与 `attachmentJson`,满足「按发票号定位」的入参要求(勘误说明见「实施记录」1)。 + +## 三、契约 + +### A801-2 文件下载(已有,零改动) + +- `POST /ierp/kapi/v2/awrp/bosext/attachment/download`,body `{"path": "<票池 downloadUrl>"}`,Header `access_token`(复用现有 getToken/token 缓存) +- 返回文件流;客户端 `IBfiClient.invoiceDownload` / `BfiServiceImpl.downloadInvoice` 已实现(status 校验、日志齐全),本期直接复用 + +### 契约核验(2026-09-27,源:《业财融合系统接口文档.xlsx》页签「文件下载」「查询全票池发票数据」「附件上传到指定单据接口」) + +1. **A801-2 失败响应是 JSON 而非流**:`{"data":null,"errorCode":"FILE_IS_NOT_EXIST","message":"附件不存在。","status":false}`;成功响应文档未给出字段定义(即原始文件流)。后端代理必须先判 Content-Type 再写流——失败时解析 `message`/`errorCode` 透传给前端,不能把 JSON 错误体当文件下发。 +2. **path 形态三方一致**:A801-2 请求示例 `234e244b2c803400`、A801-1 票池响应 `downloadUrl:"234e244b2c803400"`(另有 `imageUrl`/`pdfurl`/`snapshotUrl` 同形态;`originalFileName` 为 `serialNo.pdf`)、A902 返回 `data.path:"211e6ce5c9805c00"`——均为 16 位 hex 文件 id。镜像 path 可直接作为 A801-2 入参(与现状一致),且 A902 返回的 kingdee_path 与 A801-2 的 path 大概率同命名空间(见「待确认」1)。 +3. **entityNumber 映射获接口文档印证**:「附件上传到指定单据接口」页签内嵌映射表——付款申请单 `ap_payapply`、财务应付单 `ap_finapbill`、财务应收单 `ar_finarbill`、收票单 `ap_invoice`、开票申请单 `sim_original_bill`(controlKey 统一 `attachmentpanel`),与上期 `KingdeeAttachmentConstant` 完全一致,无需调整。 +4. A801-1 上游为票易通(queryParam 见 open.piaozone.com),`downloadUrl` 的有效期文档未说明;A902 页签未载明单文件大小上限与频率限制——「待确认」项维持。 + +### 新增 TMS 下载端点 + +`GET /blade-transport/invoice-receipt/invoice-pool/attachment/download?invoiceNo=<发票号>` + +- 鉴权:BladeX 登录态(网关 + Secure 框架),与同控制器 `/invoice-pool` 查询一致,不额外加 `@PreAuth` +- 租户隔离:`KingdeeInvoicePool extends TenantEntity`,`getById` 经 MP 租户插件自动过滤,跨租户 id 等同不存在 +- 成功响应:文件流。Header: + - `Content-Type`:透传 BFI 响应头,缺失时兜底 `application/pdf`(票池发票文件均为 PDF) + - `Content-Length`:BFI 响应头携带时透传 + - `Content-Disposition`:`attachment; filename=""; filename*=UTF-8''`,文件名取镜像 attachmentsJson 的 `name`(发票号.pdf) +- 失败响应(均在写响应头**之前**失败,走全局异常处理返回 `R` JSON): + +| 场景 | 行为 | +| --- | --- | +| 镜像未命中且 A801-1 实时回源未命中(含跨租户票号,租户隔离下等同不存在) | `ServiceException("该发票没有金蝶侧文件")` | +| 镜像/实时行均无 downloadUrl(如 OFD-only 票) | 同上,`ServiceException("该发票没有金蝶侧文件")` | +| BFI 返回非 200,或响应体为 JSON 错误(如 `FILE_IS_NOT_EXIST`) | 解析 BFI `message`/`errorCode` 抛 `ServiceException`(如「调用BFI文件下载失败:附件不存在。」),错误信息透传前端 | +| 传输中断 | 已写头后无法回退,流中断即连接断开,前端 reject | + +## 四、后端设计 + +接线(均在既有文件内新增,无新文件): + +- `InvoiceReceiptController`:新增 `downloadPoolAttachment(@RequestParam String invoiceNo, HttpServletResponse response)`,与 `/invoice-pool` 同域同风格(`@GetMapping` + `@ApiOperationSupport` + `@Operation`) +- `IInvoiceReceiptService` / `InvoiceReceiptServiceImpl`:新增 `downloadPoolAttachment(String invoiceNo, HttpServletResponse response)` + +流程: + +``` +取镜像(自动租户过滤)→ 解析 attachmentsJson 取 path/name +→ bfiService.downloadInvoice(path) ← 在写任何响应字节之前 +→ 校验 status==200 且 Content-Type 非 JSON(防御 BFI 以 200 返回错误体) +→ 写响应头(Content-Type / Content-Length / Content-Disposition) +→ 8KB 缓冲流式拷贝 feign Response → ServletOutputStream,flush,finally 关闭 Response +``` + +设计要点: + +1. **path 一律服务端解析,接口不收 path 参数(ADR-1)**:按发票号定位,解析顺序「镜像快照优先 → A801-1 实时回源兜底」——镜像快照与上传链路(A902 镜像文件取回)同源同口径(同取 `blade_kingdee_invoice_pool.attachmentsJson`);但票池弹窗行是 A801-1 实时行、未必已入镜像,故下载必须有实时回源兜底(上传只发生在已绑定镜像的收票单上,无此场景)。镜像快照过期(红冲重开)时报「附件不存在。」不自动刷新,与上传取快照口径一致,刷新机制见「待确认」2 +2. **先校验后写流**:所有可预知错误发生在写头之前,保证前端拿到的是可识别的 JSON 错误而非损坏文件(ADR-3)。A801-2 失败时返回 JSON(`{"status":false,"errorCode":"FILE_IS_NOT_EXIST","message":"附件不存在。"}`,HTTP 状态文档未标注,按 200 + `status:false` 防御),解析 `message`/`errorCode` 透传给前端;成功时按 Content-Type 判定为文件流再写响应 +3. **全程流式**:不落盘、不整文件进堆,与同步链路的 20MB `readCapped` 无关——下载通道**不设 20MB 上限**(ADR-4) +4. **复用 BFI 配置**:token 缓存与 Feign 超时(连 10s / 读 60s,`BfiFeignClientConfig`)不动;无事务、不落附件同步表、不记操作流水 +5. **日志**:BFI 侧沿用「调用BFI接口 进项发票下载开始/结束」;业务侧补一条 info(操作人、发票号、字节数),便于审计 +6. 文件名编码:RFC 5987 `filename*` + ASCII 兜底,中文发票号不乱码 + +## 五、前端设计 + +### API 与公共工具 + +- `src/api/payment/invoiceReceipt.js` 新增: + +```js +// 金蝶票池发票文件下载(后端代理 A801-2),耗时较普通接口长,放宽到 120s +export const downloadInvoicePoolAttachment = invoiceNo => + request({ url: `${baseUrl}/invoice-pool/attachment/download`, method: 'get', params: { invoiceNo }, responseType: 'blob', timeout: 120000 }); +``` + +- 新增公共工具 `src/utils/kingdee-attachment.js`(三入口共用,避免复制三份): + - `isKingdeePathAttachment(attachment)`:`source === 'piaozone'`,或「有 `path` 且非 http(s) 开头」——判定为金蝶侧文件 + - `downloadKingdeeAttachment(row)`:金蝶型 → 调下载 API;若响应 blob 的 `type` 含 `application/json` 则读取文本解析出 msg 后 `message.error`(兼容全局异常处理器以 200 返回 R 的情况);否则 `downloadFile(blob, name)`;URL 型 → 回退既有 `downloadFileByUrl` + - `viewKingdeeAttachment(row)`:金蝶型 → 拉取 blob 后 `URL.createObjectURL` 新窗口打开(PDF 可预览;OFD 等浏览器不支持的格式提示改用下载);URL 型 → 维持 `window.open(url)` + - 两个动作均带行级 loading 守卫(单行按钮级即可,沿用「防连点全局模式本期不实施」的口径) + +### 入口改造(三处) + +| 文件 | 改造点 | +| --- | --- | +| `src/components/kingdee-invoice-pool-dialog/main.vue` | 表格新增「附件」操作列:查看 / 下载(行自带 invoiceNo + attachmentJson) | +| `src/views/settlement/components/formal-settlement-editor.vue` | `viewInvoiceAttachment` / `downloadInvoiceAttachment`(:2296-2305)增加金蝶型分支,走公共工具;URL 型行为不变 | +| `src/views/payment/payment-application-form.vue` | `viewInvoiceAttachment` / `downloadInvoiceAttachment`(:1393-1411)同上 | + +收票登记表单不改(镜像文件入口由票池弹窗覆盖)。 + +## 六、不做的事 + +- **已上传金蝶附件(`blade_kingdee_attachment_sync.kingdee_path`)的回源下载**:A801-2 对 A902 返回 path(如 `211e6ce5c9805c00`)的兼容性未验证,待金蝶确认后二期评估 +- 批量发票文件打包 zip 下载 +- 后端转存 OSS 再发直链的方案(ADR-2 否决,理由见下) +- 附件同步(A902)链路与 `KingdeeAttachmentSyncSupport` 的任何改动 + +## 七、待确认(金蝶侧) + +1. A801-2 是否可下载 A902 返回的 `data.path`(决定二期「从金蝶恢复已上传附件」可行性)。契约核验补充依据:A902 返回 path(`211e6ce5c9805c00`)与 A801-2 path 参数示例(`234e244b2c803400`)同为 16 位 hex 文件 id,疑似同一文件命名空间,大概率兼容,需实测确认 +2. 票池 `downloadUrl` 的有效期与失效表现:镜像落库后 path 是否会过期、失效时 A801-2 返回什么(已知失效类错误如 `FILE_IS_NOT_EXIST`「附件不存在。」);接口文档未载明有效期,上游为票易通(queryParam 见 open.piaozone.com),若会过期,需明确经 A801-1 刷新镜像的机制(影响下载失败时的用户提示与补救路径) +3. A801-2 频率限制(本场景为用户点击型、低频,预期无压力,确认即可);A902 页签亦未载明单文件大小上限与频率限制,上期 20MB 软限制待一并确认 + +## 八、验证 + +1. 后端:`mvn clean compile -DskipTests -pl blade-service/blade-transport -am` +2. 前端:`pnpm run typecheck` 因仓库既有问题不可用(vue-tsc 3.3.11 与 typescript@7.0.2 不兼容,与上期同因),沿用 @vue/compiler-sfc 解析 + `node --check` 校验全部改动 .vue/.js +3. 手动(用户执行): + - 票池弹窗行 查看/下载 → 得到 `发票号.pdf`,内容可打开 + - 正式结算认领发票行、付款申请发票附件行:金蝶型附件 查看/下载 成功;OSS 型附件行为与改造前完全一致 + - 镜像缺失 path / 发票未同步票池 → 友好提示,不产生损坏文件 + - 不存在的发票号 → 「该发票没有金蝶侧文件」(镜像与 A801-1 回源均未命中;跨租户票号在租户隔离下等同不存在) + - BFI 停机/超时模拟 → 前端 JSON 错误识别并提示,浏览器不落损坏文件 + - path 失效模拟(BFI 返回 `FILE_IS_NOT_EXIST`)→ 前端提示透传的「附件不存在。」,不落损坏文件 + - 日志链路:`调用BFI接口 进项发票下载` → 业务侧下载 info + +## 九、ADR 摘要 + +| # | 决策 | 备选与理由 | +| --- | --- | --- | +| ADR-1 | 按发票号定位,path 服务端解析(镜像优先 → A801-1 实时兜底) | 前端传 path 被否:等同开放代理,可遍历下载金蝶任意文件;只信镜像不够——票池弹窗是 A801-1 实时行、未必已入镜像;发票号在镜像表同租户唯一(A801-1 upsert 匹配键),天然带租户与数据边界 | +| ADR-2 | 后端流式代理,不转存 OSS | 转存需处理双存储一致性、清理策略与额外延时;票池文件为点击型低频小文件(PDF),代理足够;若日后高频/大文件再评估 | +| ADR-3 | 先校验后写流,错误返回 JSON | 先写头后失败会导致前端拿到损坏文件且无法提示;前端按响应 Content-Type 是否为 JSON 统一识别成败,不依赖 HTTP 状态码细节 | +| ADR-4 | 下载通道不设 20MB 上限 | 20MB 是 A902 上传软限制(金蝶契约),A801-2 下载无此约束,流式透传不占堆内存 | +| ADR-5 | 查看走 blob objectURL 新窗口,作为增强项 | 后端不区分 inline/attachment,减少契约面;OFD 等不可预览格式由前端降级为下载提示 | + +## 十、实施记录(2026-09-27 as-built) + +实施中发现的现状与设计的两处偏差,已按实际落地(勘误同上期 as-built 风格): + +| # | 事项 | 设计原假设 | 实施定案 | +| --- | --- | --- | --- | +| 1 | 下载定位键 | 按票池镜像 id(`GET /invoice-pool/{poolId}/attachment/download`) | **按发票号**(`GET /invoice-pool/attachment/download?invoiceNo=`)。原因:票池弹窗行是 A801-1 实时行、`id` 恒空(`toPoolViewVO` 注释即注明);正式结算/付款详情行是关系行(`FormalSettlementInvoice`/`PaymentApplicationInvoice` 的 id 为关系行 id 而非镜像 id)。三处入口统一仅可靠携带 `invoiceNo`;票号在镜像表同租户唯一(A801-1 upsert 匹配键),按票号取**实时镜像**还自动跟随 path 变化(红冲重开)。定位顺序:镜像优先(0 远程调用)→ 镜像未落库/无文件时实时回源 A801-1 按票号取 `downloadUrl`(远程模糊匹配命中行做全等防御) | +| 2 | 失败返回方式 | 走全局异常处理返回 R JSON | 写流前的业务失败(票号空/无金蝶侧文件/BFI JSON 错误体)在服务内捕获后经 `WebUtil.renderJson(response, R.fail(msg))` 直接写 HTTP 200 + JSON。原因:blade-transport 无本地异常处理器,全局处理器行为在 jar 内不可控(可能非 200,axios 拦截器对 blob 错误体无法解析出 msg);`WebUtil.renderJson` 为本仓库既有惯例(运单批量导入校验同款)。前端 `kingdee-attachment.js` 统一按响应 Content-Type 含 `application/json` 识别失败并弹出 msg | + +实施产物: + +- 后端:`InvoiceReceiptController.downloadPoolAttachment`(`/invoice-receipt/invoice-pool/attachment/download`,GET,继承类级 `@PreAuth(menu="invoice_receipt")`)、`IInvoiceReceiptService.downloadPoolAttachment`、`InvoiceReceiptServiceImpl` 实现(`resolvePoolAttachment` 定位 + `doDownloadPoolAttachment` A801-2 流式透传 + `readBfiJsonMessage` 错误体解析,`bfiResponse.close()` finally 兜底;IOException(客户端断开)仅记日志) +- 前端:`api/payment/invoiceReceipt.js` 新增 `downloadInvoicePoolAttachment`(blob + 120s);新增 `utils/kingdee-attachment.js`(`isKingdeePathAttachment` 判定 + 下载/预览 + JSON 错误识别 + 静默标记防双 toast);三处入口——票池弹窗新增「附件」列(行级 `attachmentBusy` 守卫)、正式结算编辑器与付款申请表单的 查看/下载 按钮按附件类型分流(`kingdeeAttachmentBusy` 守卫,OSS 直链行为不变) +- 验证:后端 `mvn clean compile -DskipTests -pl blade-service/blade-transport -am` ✅;前端 @vue/compiler-sfc 解析 + `node --check` 全部改动文件 ✅(`pnpm run typecheck` 因仓库既有 vue-tsc/TS7 不兼容问题不可用,与上期同因) + +## 术语表 + +| 术语 | 含义 | +| --- | --- | +| A801-2 | 星瀚「文件下载」接口,按 path 取回文件流(本期对前端暴露的下载通道) | +| 票池镜像 | `blade_kingdee_invoice_pool`,A801-1 全票池发票的本地镜像,attachmentsJson 存 `{source:"piaozone", path, name}` | +| path 型附件 | 金蝶侧文件:attachmentsJson 项有 `path` 无 http url,只能经 A801-2 取回 | +| URL 型附件 | OSS 直链附件:attachmentsJson 项有 `url/link/fileUrl`,前端可直连下载(现有行为) | +| billPkId | 星瀚单据内码(上期 A902 使用,本特性不涉及,仅口径区分) | +| 开放代理 | 接口接受任意目标地址参数并代为取回内容的安全缺陷,本设计以 id 定位规避 | diff --git a/doc/docs/业务流程/2026-09-27-金蝶附件上传下载链路梳理.md b/doc/docs/业务流程/2026-09-27-金蝶附件上传下载链路梳理.md new file mode 100644 index 00000000..bd716f97 --- /dev/null +++ b/doc/docs/业务流程/2026-09-27-金蝶附件上传下载链路梳理.md @@ -0,0 +1,213 @@ +# TMS ↔ 金蝶 上传/下载链路梳理 + +- 日期:2026-09-27 +- 依据:代码真实实现(blade-third-party-api/blade-bfi-api、blade-service/blade-transport)+ 《2026-09-24-kingdee-attachment-sync-design.md》《2026-09-27-kingdee-attachment-download-design.md》 +- 回答三个问题:**系统需要从金蝶下载哪些东西**、**上传和下载的逻辑是什么**、**上传与下载能否对应上(闭环)** + +--- + +## 一、结论摘要 + +1. **文件级下载目前只有一种:票池发票文件(发票影像 PDF)**。判断正确——全代码库中唯一返回文件流的金蝶接口是 A801-2「文件下载」(`IBfiClient.invoiceDownload`),它的消费方只有两处,都围绕票池发票文件:① 附件同步链路内「下载回传」收票单;② 面向前端的票池发票文件下载端点(2026-09-27 新增,当前未提交)。销项开票发票(A303-5 回写)不走文件流接口:A303-5 响应自带发票云文件直链 URL,落库后前端直连查看/下载(2026-09-30 补充,见 3.4)。 +2. 除文件外,系统还从金蝶「下载」**数据**(JSON 查询结果):票池发票元数据(A801-1)、收票单结果(A801-3)、付款申请/付款处理结果(A103-2/A103-4)、应付/应收单结果(A102-4/A302-4)、开票申请单与开票结果(A303-4/A303-5)、客商(A902-1)、汇率(A801-4)。这些用于幂等预检、星瀚内码反查、状态/结果定时回写。 +3. **上传与下载基本能对应上,但有三处缺口**:① 已上传金蝶的附件(`blade_kingdee_attachment_sync.kingdee_path`)没有回源下载通道(A801-2 是否兼容 A902 返回的 path 未验证,二期待金蝶确认);② 收款认领链路(A301-1/2/3)客户端已实现、业务零接线;③ 客商资质附件上传(A902)二期预留,entityNumber 未确认——客商附件目前既无上传也无下载。 +4. 每一个「推送/保存」类接口都有对应的「查询」类接口做幂等预检或结果回写,数据面上传下载成对;文件面只有「下载发票→上传回传」一条闭环。 + +--- + +## 二、接口总清单与业务接线状态 + +`IBfiClient`(blade-bfi-api)共 25 个接口,按业务接线情况分三类: + +### 2.1 业务已接线(上传/推送类) + +| 编号 | 接口 | IBfiService 方法 | 业务调用点 | +| --- | --- | --- | --- | +| A103-1 | 应付下推付款申请 | `pushPayApply` | PaymentApplicationServiceImpl:1045 | +| A103-3 | 预付款申请保存 | `savePrePayApply` | PaymentApplicationServiceImpl:949 | +| A102-2 | 财务应付单保存 | `saveApBill` | KingdeeApBillSyncSupport:318 | +| A302-2 | 财务应收单保存 | `saveArBill` | KingdeeArBillSyncSupport:258 | +| A303-2 | 收票单新增(批量) | `saveReceivedInvoice` | KingdeeReceivedInvoiceSyncSupport:218 | +| A302-6 | 收票指定应付 | `invoiceAssign` | KingdeeReceivedInvoiceSyncSupport:288 | +| A303-1 | 开票申请单新增 | `saveInvoiceApply` | InvoiceApplicationServiceImpl:702 | +| A902-2 | 客户保存 | `saveCustomer` | CustomerArchiveServiceImpl:527 | +| **A902** | **附件上传到指定单据** | `uploadAttachment` | KingdeeAttachmentSyncSupport:164(唯一入口) | + +### 2.2 业务已接线(下载/查询类) + +| 编号 | 接口 | IBfiService 方法 | 业务调用点 | 用途 | +| --- | --- | --- | --- | --- | +| A801-1 | 查询全票池发票数据 | `queryInvoicePool` | InvoiceReceiptServiceImpl:285、1110 | 选票弹窗实时查询;保存收票登记落镜像;下载回源兜底 | +| A801-3 | 收票单查询 | `queryReceivedInvoice` | KingdeeReceivedInvoiceSyncSupport:115 | 推送幂等预检;定时回写 | +| **A801-2** | **文件下载(发票文件)** | `downloadInvoice` | InvoiceReceiptServiceImpl:1248、KingdeeAttachmentSyncSupport:367 | **见第三节** | +| A102-4 | 财务应付单查询 | `queryApBill` | KingdeeApBillSyncSupport:155、182 | 幂等预检;正式结算内码反查 | +| A302-4 | 财务应收单查询 | `queryArBill` | KingdeeArBillSyncSupport:106、133 | 幂等预检;应收结果回写、开票前置校验、内码反查 | +| A103-2 | 付款申请单查询 | `queryPayApply` | PaymentApplicationServiceImpl:743 | 付款结果定时回写;单条状态回查 | +| A103-4 | 付款处理查询 | `queryPayBill` | PaymentApplicationServiceImpl:833 | 付款处理结果勾稽回写 | +| A303-4 | 开票申请单查询 | `queryInvoiceApply` | InvoiceApplicationServiceImpl:581 | 推送幂等预检;附件内码反查 | +| A303-5 | 开票申请发票查询(OpenApi) | `queryInvoiceApplyInvoice` | InvoiceApplicationServiceImpl.executeInvoiceResultSync | 开票结果回写(发票号 + 板式文件/数电PDF/XML文件地址落库,2026-09-30 扩展) | +| A902-1 | 客户查询 | `queryCustomer` | CustomerArchiveServiceImpl:554、821;BfiCustomerController:70 | 推送前置幂等复查;定时同步复查;客商列表穿透查询 | +| A801-4 | 汇率查询 | `queryExchangeRate` | BfiExchangeRateController:63(GET /bfi/exchange-rate) | 前端汇率展示 | + +### 2.3 客户端已实现、业务零调用(仅测试覆盖) + +| 编号 | 接口 | 说明 | +| --- | --- | --- | +| A301-1 | 收款单查询(`queryRecBill`) | 收款认领链路未启用 | +| A301-2 | 收款认领公告查询(`queryClaimAnnounce`) | 同上 | +| A301-3 | 收款认领结果推送(`pushClaimResult`) | 同上(上传方向) | +| A100-2/3 | 令牌校验/撤回 | 基础设施预留,token 管理由 `BfiRequestInterceptor`/缓存承担 | + +> 结论:**收款认领(收款单从金蝶下载 → TMS 认领 → 结果回推)是设计上预留、尚未接线的下载场景**,是目前除发票文件外唯一「未来可能需要从金蝶下载」的业务,与《业财融合方案》中收款认领规划对应。 + +--- + +## 三、文件下载链路(金蝶 → TMS)——「下载什么」的完整答案 + +### 3.1 唯一被下载的文件:票池发票文件 + +文件从哪来:A801-1 票池查询响应的 `downloadUrl`(16 位 hex 文件 id,另有 `imageUrl`/`pdfurl`/`snapshotUrl` 同形态),保存收票登记落镜像时序列化为 `blade_kingdee_invoice_pool.attachmentsJson`:`{"source":"piaozone","path":"","name":"<发票号>.pdf"}`(InvoiceReceiptServiceImpl `buildAttachmentJson`:1206-1215)。 + +文件怎么取:A801-2 `POST /ierp/kapi/v2/awrp/bosext/attachment/download`,body `{"path":"..."}`,返回文件流。BFI 层实现 `BfiServiceImpl.downloadInvoice`:532-555(校验 path 非空、HTTP 200、日志齐全),失败时金蝶返回 JSON 错误体(如 `FILE_IS_NOT_EXIST`「附件不存在。」)。 + +### 3.2 用途一:附件同步链路内下载回传(后端自动,无前端交互) + +`KingdeeAttachmentSyncSupport.fetchContent`:365-385:当附件项 `kingdeePathSource=true`(镜像发票文件)时,调 `bfiService.downloadInvoice(path)` → `readCapped` 流式读取(64KB 缓冲,**20MB 封顶**,超限中断并记失败原因)→ 内存字节数组 → A902 上传至收票单附件面板。 + +特点:同步链路内的下载**必须**经 20MB 软限制(A902 上传契约约束),下载的文件即传即走、不落盘不存 OSS。 + +### 3.3 用途二:前端下载通道(用户点击,2026-09-27 新增,当前未提交) + +端点:`GET /blade-transport/invoice-receipt/invoice-pool/attachment/download?invoiceNo=<发票号>`(InvoiceReceiptController:90-96,类级 `@PreAuth(menu="invoice_receipt")`)。 + +实现(InvoiceReceiptServiceImpl `downloadPoolAttachment`:1219-1312): + +``` +按发票号定位 path(resolvePoolAttachment) + ① 本地镜像优先:blade_kingdee_invoice_pool 按票号查(MP 租户插件自动隔离),解析 attachmentsJson 取 path/name + ② 镜像未落库或无文件 → A801-1 实时回源(queryPoolByInvoiceNo,模糊匹配结果做票号全等防御) +→ bfiService.downloadInvoice(path) ← 在写任何响应字节之前 +→ 校验:HTTP 200(BFI 层)+ Content-Type 非 application/json(防 JSON 错误体当文件下发) +→ 写响应头(Content-Type 缺省兜底 application/pdf / Content-Length 透传 / Content-Disposition RFC5987 文件名) +→ 8KB 缓冲流式透传 feign Response → ServletOutputStream,不落盘不进堆,finally 关闭 Response +``` + +错误处理:写流前的业务失败(票号空/无金蝶侧文件/BFI JSON 错误体)经 `WebUtil.renderJson(response, R.fail(msg))` 写 HTTP 200 JSON,前端按响应 Content-Type 是否含 `application/json` 统一识别;传输中断(IOException,客户端断开)仅记日志。**此通道不设 20MB 上限**(20MB 是 A902 上传契约,A801-2 下载无此约束)。 + +前端三处入口(票池弹窗附件列、正式结算认领发票行、付款申请发票附件行)经 `kingdee-attachment.js` 分流:`source === 'piaozone'` 或「有 path 且非 http(s) 开头」判为金蝶型走该端点;OSS 直链附件维持现有 `downloadFileByUrl` 行为不变。 + +### 3.4 用途三:销项开票发票文件直链查看/下载(2026-09-30 新增) + +开票申请链路的销项发票文件不走 A801-2:A303-5 开票结果查询响应自带发票云文件直链——`invoiceFileUrl`(板式文件:数电票 OFD、航信百望税盘普通电票 PDF、电子专票/UKey 电票 OFD)、`invoiceImageUrl`(PDF 图片预览地址)、`invoicePdfFileUrl`/`invoiceXmlFileUrl`(数电 PDF/XML,发票云 5.0.025+ 返回)。 + +- **落库**:`executeInvoiceResultSync` 回写时将四个地址独立落 `blade_invoice_application_invoice`(补丁 `blade_invoice_application_invoice_urls_20260930.sql`),原文 JSON 照旧留档;已落库但缺地址的历史记录由同任务字段级补空(只补空不覆盖已有值)。 +- **展示**:正式结算单详情 `FormalSettlementVO.issuedInvoices`(`FormalSettlementServiceImpl.listIssuedInvoices`:结算单 → 开票申请关联 → 发票结果,仅保留非作废未删除申请),前端 `formal-settlement-editor.vue` 发票信息卡片内只读表格「开票发票」提供查看/下载。该列表只读、不落 `blade_formal_settlement_invoice`、不参与匹配金额计算,避免被 `rebuildInvoices` 全删重建逻辑误持久化。 +- **下载通道**:URL 为 http(s) 直链,按 3.3 前端分流约定归入「OSS 直链」一类——前端 `window.open`(查看,数电 PDF 优先)/ `downloadFileByUrl`(下载,按直链扩展名命名)直连,不经 A801-2 代理,不新增文件流接口。 +- **时效性**:直链有效期未知,与 5.2 缺口 #4(票池文件时效性)同源待金蝶确认;回写补齐策略为仅补空不刷新,若金蝶确认直链会过期需另行设计刷新机制。 + +### 3.5 数据下载(查询回写)的触发机制 + +| 定时任务(PaymentKingdeeResultTask / CustomerKingdeeSyncTask) | cron 默认 | 拉取内容 | 回写目标 | +| --- | --- | --- | --- | +| payResult | 每 10 分钟 | A103-2 付款申请单状态 + A103-4 付款处理结果 | 付款申请 kingdee_status | +| receivedInvoiceResult | 每 10 分钟 | A801-3 收票单结果 | 收票登记 kingdee_push_status、内码 | +| formalSettlement 应收回写(`syncKingdeeArResult`:631,随 payResult 任务调度) | 同上 | A302-4 应收单结果 | 正式结算应收面板状态 | +| invoiceApplication 开票结果(`syncKingdeeInvoiceResult`:803) | 同上 | A303-4 申请单 + A303-5 发票明细 | 开票申请状态 + 发票号落库 | +| customer(CustomerKingdeeSyncTask) | 每天凌晨 2 点 | A902-1 客商(按信用代码批量复查) | 客商档案协同状态 | + +另有手动触发点:各单据「同步金蝶结果」按钮(如 InvoiceReceiptController `/sync-kingdee-result`)、推送前的幂等预检(A102-4/A302-4/A801-3/A303-4/A103-2)、附件内码反查、票池选票弹窗实时查询(A801-1,游标分页 + 可用性过滤)。 + +--- + +## 四、文件上传链路(TMS → 金蝶)——A902 附件同步 + +### 4.1 唯一上传通道 + +`KingdeeAttachmentSyncSupport.syncAttachments`:125-189,经 `BfiServiceImpl.uploadAttachment`:558-583 调 A902 `POST /ierp/kapi/v2/frame/attachment/uploadFile`(multipart,`file` + `attachmentUploadFileArgs` JSON:entityNumber/billPkId/controlKey),返回 `data.path` 落 `blade_kingdee_attachment_sync.kingdee_path`。 + +核心机制: + +- **幂等**:按 `(biz_type, biz_id, attachment_key)` 唯一键;同步前载入已成功集合,命中即跳过(重推 A902 零调用);失败记录重试成功时覆盖更新 +- **幂等键**:用户附件 = 附件 url;发票文件 = A801-2 的 path(path 变化即红冲重开,自动视为新文件重传) +- **限制**:单文件 20MB 软限制(下载前按声明大小拦截 + 下载后按实际字节校验) +- **隔离**:逐附件独立 try/catch,失败记 `fail_reason` 继续下一个;附件失败不回滚主单、不阻塞同批其他单据 +- **补传**:不自动重试;重推主单时自动补传(主单被幂等预检跳过不影响附件分支) + +### 4.2 五类单据接入点(均已落地,主单事务提交后执行) + +| 单据 | biz_type → entityNumber | 接入点(代码位置) | 附件来源 | billPkId 缺失时的反查 | +| --- | --- | --- | --- | --- | +| 付款申请 | `pay_application` → `ap_payapply` | PaymentApplicationServiceImpl.syncKingdee:387 → doSyncKingdee 事务提交后 :403 → syncAttachmentsAfterPush:414 | 单据 attachmentsJson(用户附件) | 无(本地 kingdeeBillId 缺失即跳过待重推) | +| 正式结算-应付 | `formal_settlement_ap` → `ap_finapbill` | FormalSettlementServiceImpl.syncKingdee:510 → syncAttachmentsAfterPush:525 | 结算单 attachmentsJson | A102-4 按防重键反查回填(`resolveKingdeeBillId`) | +| 正式结算-应收 | `formal_settlement_ar` → `ar_finarbill` | 同上(按 settlementType 分流 :547-548) | 同上 | A302-4 反查回填 | +| 收票登记 | `invoice_receipt` → `ap_invoice` | InvoiceReceiptServiceImpl.pushOneReceivedInvoice:720,A303-2 落 pushed 后、A302-6 指定应付前 :786 → syncAttachmentsAfterPush:809 | 登记附件(用户附件)+ 镜像发票文件(**A801-2 下载**) | A801-3 预检行自带 id 回填 :752-754 | +| 开票申请 | `invoice_application` → `sim_original_bill` | InvoiceApplicationServiceImpl.syncKingdee:497 → syncAttachmentsAfterPush:511 | 单据 attachmentsJson | A303-4 勾稽回填带回 | + +流水落点:收票登记(`InvoiceReceiptRecord`)与开票申请(`InvoiceApplicationRecord`)有 actionType 流水表,追加 `attachment_upload` 记录;付款申请/正式结算无流水表不追加。列表聚合态(无附件/未上传/部分未传/已上传)由 `successCountByBizId`(一次 GROUP BY)+ `aggregateStatus` 运行时聚合。 + +### 4.3 数据上传(单据推送)不在本档展开 + +付款申请(A103-1/A103-3)、财务应付/应收单(A102-2/A302-2)、收票单+指定应付(A303-2/A302-6)、开票申请(A303-1)、客商(A902-2)的报文组装与状态机,见各 Support 类与《业财融合方案》主文档。 + +--- + +## 五、上传 ↔ 下载对应关系(能否对上) + +### 5.1 已闭环的对应 + +| 对应项 | 上传侧 | 下载侧 | 状态 | +| --- | --- | --- | --- | +| **票池发票文件** | A801-2 下载(20MB 封顶)→ A902 回传收票单附件面板,key=path | A801-2 前端下载端点(不设上限),镜像优先 + A801-1 回源 | ✅ 完整闭环:同一份文件既能自动回传金蝶,又能给前端人工查看/下载 | +| **用户附件(OSS 直链)** | http(s) 下载(60s 超时、20MB 封顶)→ A902 上传 5 类单据 | 不经金蝶:前端直连 OSS(`downloadFileByUrl` 现有行为) | ✅ 各自闭环,两类附件按字段特征分流互不干扰 | +| **单据数据面** | A103-1/3、A102-2、A302-2、A303-2/6、A303-1、A902-2 | A103-2/4、A102-4、A302-4、A801-3、A303-4/5、A902-1(幂等预检 + 内码反查 + 结果回写) | ✅ 每个推送接口都有查询接口配套,成对出现 | +| **票池元数据** | (消费方)收票登记落镜像、A303-2 分录取数 | A801-1 实时查询(选票弹窗 + 回源) | ✅ 闭环 | +| **销项开票发票文件** | (数据面)A303-1 开票申请推送 | A303-5 回写发票结果含文件直链,落库后前端直连查看/下载(不经 A801-2) | ✅ 闭环(2026-09-30 补充,见 3.4) | + +### 5.2 对不上的缺口(单向或未启用) + +| # | 缺口 | 现状 | 影响 | +| --- | --- | --- | --- | +| 1 | **已上传附件的金蝶侧回源下载** | A902 上传成功后 `kingdee_path` 已落 `blade_kingdee_attachment_sync`,但没有任何代码用它发起 A801-2 下载 | TMS 侧附件若 OSS 丢失/清理,无法从金蝶恢复。A902 返回 path(`211e6ce5c9805c00`)与 A801-2 path(`234e244b2c803400`)同为 16 位 hex,疑似同命名空间,但兼容性**未验证**——2026-09-27 设计「不做的事」第 1 条明确列为二期待确认项 | +| 2 | **收款认领链路** | A301-1 收款单查询、A301-2 认领公告查询、A301-3 结果推送在 `IBfiClient`/`BfiServiceImpl` 已完整实现,业务层零调用 | 金蝶收款数据下载 → TMS 认领 → 结果回推整条链路待启用;启用前 A301 系列不产生任何下载 | +| 3 | **客商资质附件** | `KingdeeAttachmentConstant.ENTITY_NUMBERS` 仅预留注释(CustomerArchiveServiceImpl 侧无任何附件上传接线);客商的 entityNumber/controlKey 待金蝶确认 | 客商附件目前既不上传也不下载,二期接入时补一个 biz_type 条目即可复用现有架构 | +| 4 | 票池文件时效性 | 镜像 `attachmentsJson` 落库后 path 是否会过期未知;下载时镜像快照过期(红冲重开)报「附件不存在」**不自动刷新**,与上传取快照口径一致。2026-09-30 落库的 A303-5 销项发票云直链(见 3.4)同样受时效性约束,回写仅补空不刷新 | 需金蝶确认 `downloadUrl`/发票直链有效期与失效表现,决定是否要补定时刷新镜像机制 | +| 5 | 附件面板反向查询 | 金蝶无「查询单据附件列表」接口接入 | 附件同步状态只能以本地同步表为准,无法与金蝶侧附件面板对账 | + +--- + +## 六、验证核对表(文档 vs 代码一致性) + +| 设计文档断言 | 代码核实结果 | +| --- | --- | +| A902 五类单据接入、entityNumber 映射 | ✅ `KingdeeAttachmentConstant.ENTITY_NUMBERS` 五条映射与文档完全一致,未配置类型抛 ServiceException | +| A801-2 复用 `invoiceDownload`,附件链路无新增接口 | ✅ `KingdeeAttachmentSyncSupport.fetchContent` 直接调 `bfiService.downloadInvoice` | +| 附件上传在主单事务提交后执行(ADR-7) | ✅ 付款申请经 `syncKingdee` 包装层(doSyncKingdee 事务外 :403);正式结算/开票申请经 `SpringUtil.getBean(...).doSyncKingdee` 自代理(:512/:499);收票在推送编排内但独立 try/catch(:786) | +| 幂等键 url/path、失败覆盖更新、20MB 双重校验 | ✅ syncAttachments:144-177(跳过/超限拦截/覆盖更新),readCapped:388-403 | +| 下载按发票号定位、镜像优先 + 实时回源、先校验后写流 | ✅ resolvePoolAttachment:1292-1312、doDownloadPoolAttachment:1242-1285,JSON 错误体解析 readBfiJsonMessage:1315-1332 | +| 下载不设 20MB 上限、8KB 流式透传 | ✅ DOWNLOAD_BUFFER_SIZE 缓冲拷贝,无 readCapped 调用 | +| 失败经 `WebUtil.renderJson` 返回 200 JSON(勘误 #2) | ✅ downloadPoolAttachment:1222-1235 | +| 收票/开票申请追加 `attachment_upload` 流水,付款/结算不追加 | ✅ InvoiceReceiptServiceImpl:823、InvoiceApplicationServiceImpl:522;付款/结算接入点无 record 调用 | + +--- + +## 七、顺带发现的风险点(与本次梳理主题相关,建议确认) + +1. **客商定时同步被硬编码过滤到单一客商**:`CustomerArchiveServiceImpl.syncKingdeeCustomerData`:625-628 的查询条件含 `.eq(CustomerArchive::getCustomerCode, "KS000067")`,即每天凌晨 2 点的客商同步任务只会处理这一个客商编码,疑似联调/灰度遗留。上线前需移除,否则其余客商的定时推送与状态复查全部不生效。 +2. **2026-09-27 下载功能代码未提交**:`InvoiceReceiptController`、`IInvoiceReceiptService`、`InvoiceReceiptServiceImpl` 的下载端点改动仍在工作区(git status modified),设计文档已写 as-built,注意随下次提交入库。 +3. 镜像定位 `resolvePoolAttachment`:1293-1294 按票号 selectList 后取 `mirrors.get(0)`:依赖「同租户票号唯一」(A801-1 upsert 匹配键保证),若上游契约变化出现重复票号将静默取第一条,可考虑加 `last("limit 1")` 或唯一索引兜底(当前表无票号唯一索引约束见 `blade_kingdee_invoice_pool_a8011_20260920.sql`)。 + +--- + +## 八、术语与代码索引 + +| 术语 | 代码落点 | +| --- | --- | +| A902 附件上传 | `IBfiClient.uploadAttachment`(blade-bfi-api feign)/ `BfiServiceImpl.uploadAttachment` | +| A801-2 文件下载 | `IBfiClient.invoiceDownload` / `BfiServiceImpl.downloadInvoice` | +| 附件同步主流程 | `org.springblade.transport.support.KingdeeAttachmentSyncSupport` | +| 附件同步表 | `blade_kingdee_attachment_sync`(实体 `KingdeeAttachmentSync`,建表 `doc/sql/transport/blade_kingdee_attachment_sync_20260924.sql`) | +| 票池镜像 | `blade_kingdee_invoice_pool`(`KingdeeInvoicePool`,`InvoiceReceiptServiceImpl` 内 upsert) | +| 开票回写发票结果 | `blade_invoice_application_invoice`(实体 `InvoiceApplicationInvoice`,建表 `blade_invoice_application_invoice_20260920.sql` + 文件地址补丁 `blade_invoice_application_invoice_urls_20260930.sql`) | +| 前端下载端点 | `InvoiceReceiptController.downloadPoolAttachment`(GET /invoice-receipt/invoice-pool/attachment/download) | +| entityNumber 映射 | `org.springblade.transport.constant.KingdeeAttachmentConstant` | +| 定时回写任务 | `org.springblade.transport.task.PaymentKingdeeResultTask`、`CustomerKingdeeSyncTask` | diff --git a/doc/docs/业务流程/2026-09-27-金蝶(星瀚)BFI接口调用全链路梳理(按业务域汇总·更新版).md b/doc/docs/业务流程/2026-09-27-金蝶(星瀚)BFI接口调用全链路梳理(按业务域汇总·更新版).md new file mode 100644 index 00000000..5a26cea4 --- /dev/null +++ b/doc/docs/业务流程/2026-09-27-金蝶(星瀚)BFI接口调用全链路梳理(按业务域汇总·更新版).md @@ -0,0 +1,648 @@ +# 金蝶(星瀚)BFI 接口调用全链路梳理(按业务域汇总·更新版) + +> 整理日期:2026-09-27 +> 依据:当前代码(`tms-api/blade-third-party-api/blade-bfi-api` 客户端 + `tms-api/blade-service/blade-transport` 业务实现,git 基线 `50573e7` 2026-09-24)与三份材料逐条核对: +> ① `doc/docs/TMS业财融合-集成流程图文字版与业务流程梳理.md`(2026-09-14,6 张集成流程图 OCR 文字版); +> ② `doc/docs/TMS业财融合方案.docx`(V1.2,2026-08-10,8 大集成场景方案); +> ③ `doc/docs/业务流程/2026-09-23-金蝶(星瀚)BFI接口调用全链路梳理(按业务域汇总).md`(上一版总纲)。 +> 本文是 09-23 总纲的**全面更新版**:自 09-23 以来代码有 12 个提交(09-23 19:00 收票防重键修复 + 09-24 两批重构/新增),接口清单、分录口径、勾稽方式、联调残留与缺陷清单均有实质变化,全文按当前代码重新核实。 +> 每个接口仍按 **触发场景 → 调用顺序 → 参数来源 → 响应存储 → 依赖与幂等** 五个维度说明;文末附全景依赖、状态机、定时任务与配置开关、差异/风险清单。 + +--- + +## 目录 + +- [0. 阅读须知与本次更新要点](#0-阅读须知与本次更新要点) +- [1. 集成总览与公共机制](#1-集成总览与公共机制) +- [2. 业务域一:客商档案(A902-1 客户查询 / A902-2 客户保存)](#2-业务域一客商档案a902-1-客户查询--a902-2-客户保存) +- [3. 业务域二:财务应付与付款(A102-4 财务应付单查询 / A102-2 财务应付单保存 / A103-1 应付下推生成付款申请 / A103-3 预付款申请保存 / A103-2 付款申请单查询 / A103-4 付款处理查询)](#3-业务域二财务应付与付款) +- [4. 业务域三:财务应收与开票(A302-4 财务应收单查询 / A302-2 财务应收单保存 / A303-4 开票申请单查询 / A303-1 开票申请单新增 / A303-5 开票发票查询)](#4-业务域三财务应收与开票) +- [5. 业务域四:收票与金蝶票池(A801-1 全票池发票查询 / A303-2 收票单批量保存 / A302-6 收票指定应付 / A801-3 收票单查询)](#5-业务域四收票与金蝶票池) +- [6. 业务域五:收款认领(A301-2 收款认领公告查询 / A301-3 收款认领结果推送 / A301-1 收款单查询)★新增](#6-业务域五收款认领a301-2-收款认领公告查询--a301-3-收款认领结果推送--a301-1-收款单查询新增) +- [7. 业务域六:公共取数(A801-4 汇率)★新启用](#7-业务域六公共取数a801-4-汇率新启用) +- [8. 全景:接口先后顺序与依赖关系](#8-全景接口先后顺序与依赖关系) +- [9. 状态机汇总](#9-状态机汇总) +- [10. 定时任务与配置开关汇总](#10-定时任务与配置开关汇总) +- [11. 代码与三份材料差异 / 风险清单(更新版)](#11-代码与三份材料差异--风险清单更新版) + +--- + +## 0. 阅读须知与本次更新要点 + +1. **接口编号口径**:金蝶星瀚侧开放接口以 `Axxx-x` 操作码标识,代码常量、文档标题、日志均沿用该口径。 +2. **权威顺序**:以**当前代码**为准;与 09-23 总纲及单链路文档冲突处一律按本文第 11 节结论。 +3. **本次(09-23 → 09-27)核心变化一览**: + +| # | 变化 | 说明 | +|---|---|---| +| 1 | **收款认领链路接线(新增业务域五)** | A301-2 公告同步 / A301-3 认领推送 / A301-1 收款单回写由「客户端已实现无调用」变为「业务侧已接线(全部手动触发,无定时)」 | +| 2 | **A801-4 汇率查询启用(新增业务域六)** | 新增 `GET /bfi/exchange-rate`,结算页面按币种+日期取金蝶汇率;A102-2/A302-2 推送时传 `exchangerate/exratedate`(结算单页面维护值) | +| 3 | **应付/应收/收票分录口径重构** | 分录 = **结算明细行(每行一条)+ 手工结算合计费用项(每行一条)**(新表 `blade_formal_settlement_summary_fee`,仅 manualFlag=1);分录新增「运输货物 / 车号」字段(仅明细行传) | +| 4 | **A103-1 勾稽方式重大变化** | **A102-4 反查付款计划分录 `awrp_entry_code_p` 的步骤已移除**;`awrp_finapbill_entryid` 改为**本地组装的主结算单应付单防重键**(`settlementApBillSrcCode`),分录由多条费用行改为**单条**(付款申请号-1) | +| 5 | **A103-1 结算方式与往来账户完善** | `e_settlementtype_number` 按付款方式显式映射(汇票→012-02 应付票据电子银行承兑汇票,其余→02 银行转账);`e_bebank_number` 改取客商收款账户(`blade_customer_receipt_account`)**联行号**,未维护即拦截 | +| 6 | **开票申请按发票张次拆分** | 新表 `blade_invoice_application_sheet`:**每张发票张次推一张金蝶开票申请单**;billno 单张=申请单号、多张=申请单号-张次序号;A303-4 预检改为**按全部候选 billno 批量幂等预检**(已生成张次回填跳过、未生成张次重推) | +| 7 | **收票推送防重键修复** | A302-6 指定应付与 A801-3 **推送预检**统一改用收票登记 id(原误用发票号,导致指定应付报「收票单不存在」、预检永不命中);⚠️ **A801-3 定时回写仍按发票号匹配,缺陷残留**(见 11.2) | +| 8 | **物料/计量单位真实映射链路建成(调试覆盖仍在)** | 物料按「装卸大类 / 国际业务 / 国内+运输方式」映射名称 → `BFI_MATERIAL_NUMBER_MAP` 映射金蝶编码;计量单位按合同计费方案计费单位 → 计量单位档案金蝶编码(`blade_measurement_unit` 已初始化、新表 `blade_kingdee_material`);但三处 `if(true)` 调试组合块仍做**最终覆盖**(兜底 物流配送 Item-00001080+TON;收票侧未命中还**随机取组合**) | +| 9 | **客商分组 1102 问题修复** | `customerKind` 已落库(`CustomerArchive.customerKind`),internal→1101 / 其余→1102 可正确分支 | +| 10 | **主数据初始化 SQL** | `doc/sql/transport/kingdee_master_data_init_20260924.sql`:计量单位金蝶编码(TON/CUM/PCS/KG/KM/25/04/sou/014/013)+ 金蝶服务类物料档案 `blade_kingdee_material` | + +4. **两条推送主线 + 两条数据主线 + 一条认领主线**: + - 应付主线:财务应付单(A102-2)→ 付款申请下推(A103-1,预付款走 A103-3)→ 状态回写(A103-2/A103-4); + - 应收主线:财务应收单(A302-2)→ 开票申请(A303-1,按张次拆分)→ 开票结果(A303-5); + - 数据主线:客商档案(A902-1/A902-2)、金蝶发票池(A801-1)、汇率(A801-4); + - 认领主线:收款认领公告(A301-2)→ 认领结果推送(A301-3)→ 收款单回写(A301-1)。 + +--- + +## 1. 集成总览与公共机制 + +### 1.1 调用架构 + +``` +blade-transport 业务服务 + │ 调用 IBfiService(参数校验 + 自动取令牌) + ▼ +blade-bfi-api(Feign 客户端 IBfiClient,@FeignClient url=${thirdParty.bfi.baseUrl}) + │ 请求头:access_token(业务令牌) + Authorization(iPaaS 网关认证,BfiRequestInterceptor 统一注入) + ▼ +iPaaS 网关(/gwzh/BFI,不直连金蝶) + ▼ +金蝶星瀚 /ierp/kapi/... +``` + +- 客户端模块:`blade-third-party-api/blade-bfi-api`(`org.springblade.thirdparty.bfi`)。 +- 业务编排模块:`blade-service/blade-transport`(`support/` 三个推送组件、`service/impl/` 各业务实现、`task/` 定时任务)。 +- **无重试**:Feign 未配 Retryer(NEVER_RETRY)、无 fallback;失败抛 `BfiException`,由调用方决定(批量循环逐条 catch,单条失败不中断整批)。 +- 配置前缀两套并存(读同一批 nacos 键的不同子集): + - bfi-api `BfiProperties`(prefix `third-party.bfi`):baseUrl / authorization / clientId / clientSecret / username / accountId / language / mockEnabled; + - transport `BfiProperties`(prefix `thirdparty.bfi`):srcSys(默认 WLTMS)/ payApply.*(billtype=`ap_payapply_oth_BT_S`、exratetable=`01`、srcType=`0KzXs`)/ invoiceApply.*(srcType=`0KzXs`)/ receivedInvoice.*; + - `payResult.*`、`receivedInvoiceResult.*`、`customer.*` 由定时任务以 `@Value` 消费。 + - 配置样例见 `doc/nacos/third-party-api.yaml`(默认 baseUrl `http://172.16.203.134:8000/gwzh/BFI`)。 + +### 1.2 接口清单总表(含在用状态,2026-09-27) + +来源:`blade-bfi-api/.../feign/IBfiClient.java`(URL 可被 `thirdParty.bfi.*Url` 配置覆盖)。 + +| 操作码 | 星瀚端点 | Feign 方法 | 用途 | 业务侧是否在用 | +|---|---|---|---|---| +| A100-1 | POST /ierp/kapi/oauth2/getToken | getToken | 获取令牌 | ✅ 自动(每次调用前) | +| A100-2 | POST /ierp/kapi/oauth2/verifyToken | verifyToken | 校验令牌 | ❌ 已实现无调用 | +| A100-3 | POST /ierp/kapi/oauth2/withdrawToken | withdrawToken | 撤回令牌 | ❌ 已实现无调用 | +| A102-2 | POST /ierp/kapi/v2/awrp/ap/ap/ap_finapbill/save | apBillSave | 财务应付单保存 | ✅ 正式结算单(应付)推送 | +| A102-4 | GET /ierp/kapi/v2/awrp/ap/ap_finapbill/query | apBillQuery | 财务应付单查询 | ✅ 预检/硬校验/批量预热(**反查付款计划分录已移除**) | +| A103-1 | POST /ierp/kapi/v2/awrp/ap/ap/ap_finapbill/push | payApplyPush | 应付下推生成付款申请 | ✅ 付款申请推送(单分录) | +| A103-2 | GET /ierp/kapi/v2/awrp/ap/ap_payapply/query | payApplyQuery | 付款申请单查询 | ✅ 推送预检 + 定时回写 | +| A103-3 | POST /ierp/kapi/v2/awrp/ap/ap/ap_payapply/save | savePrePayApply | 预付款申请保存 | ✅ 预付款(项目垫资/进度垫资)直推 | +| A103-4 | GET /ierp/kapi/v2/awrp/cas/cas_paybill/query | payBillQuery | 付款处理查询 | ✅ 定时回写付款记录 | +| A301-1 | GET /ierp/kapi/v2/awrp/cas/cas_recbill/queryRecBill | recBillQuery | 收款单查询(收款处理查询) | ✅ **认领结果回写(手动)** ★新启用 | +| A301-2 | GET /ierp/kapi/v2/awrp/cas/cas_claimannounce/query | claimAnnounceQuery | 收款认领公告查询 | ✅ **收款流水同步(手动)** ★新启用 | +| A301-3 | POST /ierp/kapi/v2/awrp/cas/cas/cas_claimbill/claimbillPush | claimBillPush | 收款认领结果推送 | ✅ **认领推送(认领事务内)** ★新启用 | +| A302-2 | POST /ierp/kapi/v2/awrp/ar/ar/ar_finarbill/save | arBillSave | 财务应收单保存 | ✅ 正式结算单(应收)推送 | +| A302-4 | GET /ierp/kapi/v2/awrp/ar/ar_finarbill/query | arBillQuery | 财务应收单查询 | ✅ 预检/硬校验/定时勾稽 | +| A302-6 | POST /ierp/kapi/v2/awrp/ap/ap/ap_invoice/assign | invoiceAssign | 收票指定应付 | ✅ 收票推送第二步 | +| A303-1 | POST /ierp/kapi/v2/awrp/sim/sim_original_bill/save | invoiceApplySave | 开票申请单新增 | ✅ **按发票张次逐张推送** | +| A303-2 | POST /ierp/kapi/v2/awrp/ap/ap_invoice/cusBatchSave | receivedInvoiceSave | 收票单批量保存 | ✅ 收票登记推送 | +| A303-4 | GET /ierp/kapi/v2/awrp/sim/sim_original_bill/query | invoiceApplyQuery | 开票申请单查询 | ✅ **批量 billno 幂等预检** | +| A303-5 | POST /ierp/kapi/app/sim/openApi | invoiceApplyOpenApi | 开票发票查询(票易通 openApi,Base64 包裹) | ✅ 定时回写开票结果 | +| A801-1 | POST /ierp/kapi/v2/awrp/rim/rim_invoice/query | invoicePoolQuery | 全票池发票查询(rim 契约) | ✅ 选票/保存重查/补票种(实时,无定时) | +| A801-2 | POST /ierp/kapi/v2/awrp/bosext/attachment/download | invoiceDownload | 进项发票文件下载 | ❌ 已实现无调用(备用) | +| A801-3 | GET /ierp/kapi/v2/awrp/ap/ap_invoice/apinvoicequery | receivedInvoiceQuery | 收票单查询 | ✅ 推送预检 + 定时回写 | +| A801-4 | GET /ierp/kapi/v2/awrp/base/bd_exrate_tree/Forexquery | forexQuery | 汇率查询 | ✅ **结算取汇率(手动,页面触发)** ★新启用 | +| A902-1 | GET /ierp/kapi/v2/awrp/basedata/bd_customer/query | customerQuery | 客户查询 | ✅ 档案回填/推送前复查/定时复查 | +| A902-2 | POST /ierp/kapi/v2/awrp/basedata/bd_customer/save | customerSave | 客户保存 | ✅ 档案推送 | + +> 与基础业务文档《TMS业财融合-集成流程图文字版与业务流程梳理》第 10 节「接口速查」的差异:文档清单中的 **A901-1 供应商查询、A801-5 黑名单、A913 项目保存、A101-1/2 合同保存、A102-1/A302-1 暂估单、A902 附件上传** 均未在客户端实现(合同/项目同步走合同合规系统,非金蝶 A 类接口;A902 附件上传各推送链路均未接,方案文档中的「审批单据/磅单等作为附件推送金蝶」暂未实现)。 + +**查询类接口时间窗约束**(IBfiService Javadoc):A103-2 付款申请单查询 / A103-4 付款处理查询 / A102-4 财务应付单查询 / A302-4 财务应收单查询 / A303-4 开票申请单查询 / **A301-1 收款单查询** 查询区间 **≤ 1 个月**;A902-1 客户查询 **≤ 6 个月**;A301-2 收款认领公告查询 无时间窗参数(按认领用户+分页)。 + +### 1.3 认证与令牌(A100-1 获取令牌) + +- **取令牌**:`BfiServiceImpl.getToken()`。请求体 `BfiTokenDTO`:`client_id / client_secret / username / accountId(数据中心) / language`,另由 `BfiUtil.fillRandomParam` 填充 `nonce`(UUID) 与 `timestamp`(服务端校验 ±5 分钟)。 +- **缓存**:Redis,key=`blade:third:bfi:token`;TTL = `expires_in` − 120 秒缓冲;`getAccessToken()` 先读缓存,未命中才调 A100-1 获取令牌。无 refresh 机制;写入用 `setIfAbsent`,已有未过期缓存时新令牌不会覆盖(排障需知)。 +- **注入**:双头——业务方法 `@RequestHeader("access_token")` 显式携带;`BfiRequestInterceptor` 统一加 `Authorization` 网关头。`BfiFeignClientConfig` 用空拦截器覆盖全局 BladeFeignRequestInterceptor;超时 10s/60s。 + +### 1.4 响应封装、分页与异常处理 + +- **统一响应** `BfiResultVO`:`data / errorCode("0"=成功) / message / status(Boolean)`;`isSuccess() = status && errorCode=="0"`。三类封装:`commonCall`(标准)、`commonRimCall`(rim 契约,兼容 "0"/"0000",A801-1 全票池发票查询用)、`commonOpenApiCall`(A303-5 开票发票查询,只看 `success`;请求/响应 data 均 Base64(JSON))。 +- **保存类响应** `BfiBillSaveResultVO`:`successCount/failCount`(String)、`result[]`(`billIndex/billStatus/entryIds/errors/id/number/type: Add/Push/Update`)。业务侧统一 `requireBfiSaveSuccess` 校验 `result[0].billStatus`,失败抛异常。 +- **分页**:`BfiPageVO{filter, lastPage, pageNo, pageSize, totalCount, rows}`;通用翻页 `BfiUtil.queryAllPages`(页码递增,终止:页空 / `lastPage=true` / rows 不满页 / 超 50 页)适用于 9 个 BfiPageVO 类查询接口。**不适用**:A801-1 全票池发票查询(rim 契约无 lastPage/totalCount,游标分页 `PoolScanCursor`,单请求上限 3 次远程调用)与 A801-4 汇率查询(单页固定 pageSize=10)。 +- **异常转换**:`BfiExceptionTranslator`(@RestControllerAdvice)→ `R.fail(500)`。 + +### 1.5 批量同步上下文 KingdeeSyncContext(09-24 重构) + +`transport/service/KingdeeSyncContext.java`:Java record,8 个可写缓存: + +| 缓存 | 内容 | 变化 | +|---|---|---| +| `measureUnitCodeByName` | 计量单位名称→金蝶编码 | ★新增(配合主数据初始化 SQL) | +| `contractCache` | 合同 id→ContractManage | 保留 | +| `customerCodeCache` | 名称→客商编码 | 保留 | +| `projectCache` | 项目 id→ProjectApply | 保留 | +| `apBillNoCache` | 应付单防重键→金蝶单号 | 保留(含「未生成」空串标记) | +| `settlementDetailCache` | 结算单 id→结算明细列表 | 保留 | +| `settlementCache` | 结算单 id→FormalSettlement | ★新增(结算单维度传参) | +| `receiptAccountCache` | 客商 id→收款账户 | ★新增(付款联行号取数) | +| ~~`cargoCodeByName`~~ / ~~`apBillPlanCodeCache`~~ | — | **已删除**(货物编码映射被物料映射替代;付款计划分录反查已移除) | + +- 生命周期 = 一次批量同步方法调用(`empty()` 创建,方法结束即弃),非 Spring Bean;缓存未命中自动回退逐单查询。 +- 使用方:`PaymentApplicationServiceImpl`(批量前 `preloadApBillExistNos` 整批预热)、`FormalSettlementServiceImpl`、`InvoiceReceiptServiceImpl`。 + +### 1.6 防重键(候选键)总表(2026-09-27 口径) + +金蝶侧按「来源系统 + 来源单据号」幂等(`awrp_src_sys` + `awrp_src_code`,分录级 `awrp_entry_code`)。组装工具 `ExternalSrcCodeSupport.buildSrcCode`(下划线拼接、去空白、去重保序)。 + +| 金蝶单据 | awrp_src_code | awrp_src_sys / srcType | 分录 awrp_entry_code | 预检查重接口 | +|---|---|---|---|---| +| 财务应付单 A102-2 | `正式结算单号_结算明细单据号1_…` | WLTMS;`TMS财务应付单` | 明细行=**结算明细单据号**;手工合计费用项行=**结算单号-费用类型-费用项**拼接 | A102-4 财务应付单查询 | +| 财务应收单 A302-2 | 同应付口径 | WLTMS;`TMS财务应收单` | 同应付口径 | A302-4 财务应收单查询 | +| 付款申请 A103-1 | `paymentNo` | WLTMS;srcType=配置 `0KzXs` | **固定 `paymentNo-1`(单分录)** | A103-2 付款申请单查询 | +| 预付款申请 A103-3 | 付款申请号口径 | WLTMS;`0KzXs` | `paymentNo-1`(单分录) | A103-2 付款申请单查询 预检共用 | +| 开票申请 A303-1 | `billno = application_no`(单张)或 `application_no-张次序号`(多张)★变化 | systemsource=WLTMS;srcType=`0KzXs` | 开票商品行 id(blade_invoice_application_line.id) | A303-4 开票申请单查询(**批量 billno**) | +| 收票单 A303-2 | `收票登记 id` | WLTMS;`TMS收票登记` | 与 A102-2 分录同源同值(明细行/手工合计行) | A801-3 收票单查询 | +| 收款认领 A301-3 | `SKCL+yyyyMMddHHmmss+认领id尾号6位`(blade_receipt_claim.awrp_src_code)★新增 | WLTMS | 结算单分摊行(seq 序号) | A301-1 收款单查询(sourcebillnumber 匹配) | +| 客户 A902-2 | `number = TMS客商编号`(KS+6位自增),`societycreditcode` 为金蝶侧候选键 | awrp_datasourcesys=WLTMS | — | A902-1 客户查询 | + +### 1.7 公共档案取数(应付/应收/收票三侧复用 `KingdeeApBillSyncSupport`) + +| 取数项 | 规则 | 变化 | +|---|---|---| +| 组织 org/payorg(应收 recorg) | 结算单 `contract_id` → `blade_contract_manage.organization_id` → `SysCache.getDept().getDeptCode()` | 取数主体由合同改为结算单(结算单维度传参) | +| 币种 | 合同 `settlement_currency`,`RMB→CNY` | 不变 | +| **汇率 exchangerate / exratedate** | **正式结算单页面维护值**(来源:结算页面经 `GET /bfi/exchange-rate` 调金蝶 A801-4 汇率查询回填);未维护不送 | ★新增 | +| 往来户 asstact_number | 名称(合同乙方 / 结算单付款方 / 开票单位)→ 本地 `blade_customer_archive` 按 `full_name/short_name` 匹配取 `customer_code`;查不到即报错拦截 | 不变 | +| 往来类型 asstacttype | 合同类别=`客户合同` → `bd_customer`,否则 `bd_supplier` | 不变 | +| **物料编码** | 真实映射链路:装卸大类费用行→`装卸`;国际业务模式/项目业务类型→`国际货运代理服务费`;国内→`国内道路货物运输服务-`+运单运输方式;再经 `BFI_MATERIAL_NUMBER_MAP` 映射金蝶编码(`Item-00001081` / `XDWL11010001` 等);⚠️ 三处 `if(true)` 调试组合块仍做最终覆盖(兜底 物流配送 Item-00001080+TON;**收票侧未命中随机取**) | ★重构 | +| **计量单位** | 费用类型/费用项匹配合同计费方案计费单位 → `blade_measurement_unit` 金蝶编码(`context.measureUnitCodeByName`);⚠️ 同被调试组合块覆盖 | ★重构 | +| 金额(含税) | 结算明细行=结算金额(含税);手工合计费用项行=合计行结算金额;原三级取数兜底保留 | 调整 | +| 金额(不含税) | 结算明细行取结算金额(不含税);**手工合计费用项行无不含税金额不传** | 调整 | +| **运输货物 / 车号** | 结算明细行传货物名称 / 车号(金蝶接口新字段);合计费用项行不传 | ★新增 | +| 日期文本 | 统一 `yyyy-MM-dd HH:mm:ss` | 不变 | + +--- + +## 2. 业务域一:客商档案(A902-1 客户查询 / A902-2 客户保存) + +**定位**:全局数据前提。其他链路的往来户编码(`customer_code`)来自 `blade_customer_archive`,与金蝶客户的对应关系由本链路建立(**金蝶客户编码 = TMS 客商编号**,无独立存储)。 + +![客商档案页面(2026-09-29):新增弹窗按统一社会信用代码满 18 位防抖触发 A902-1 查询回填;审批通过档案的 A902-2 推送为行内「同步金蝶」链接(仅 approved 且 kingdeeStatus=failed 时显示,人工重推入口),另有定时复查/重推任务](./2026-09-27-金蝶(星瀚)BFI接口调用全链路梳理(按业务域汇总·更新版).assets/bfi-01-customer-archive.png) + +### 2.1 A902-1 客户查询(回填 / 复查) + +**触发场景(3 个入口)** + +| 入口 | 触发 | 代码 | +|---|---|---| +| ① 前端新增回填 | 录入统一社会信用代码满 18 位防抖查询 → `GET /bfi/customer/query-by-credit-code`(pageNo=1/pageSize=1) | BfiCustomerController | +| ② 单条推送前复查 | `/customer-archive/sync-kingdee` 或审批通过内部推送前,`syncKingdee(id)` 先查一次 | CustomerArchiveServiceImpl | +| ③ 定时批量复查 | `CustomerKingdeeSyncTask`(cron `thirdParty.bfi.customer.cron` 默认 `0 0 2 * * ?`,开关默认 false) | CustomerKingdeeSyncTask | + +**调用顺序**:无固定前置;定时路径按 50 个信用代码/批、`BfiUtil.queryAllPages` 自动翻页取全,键=大写信用代码,单批异常不中断。 + +**参数来源**:`societycreditcode` = `blade_customer_archive.unified_credit_code`;分页参数固定。 + +**响应存储**: +- 前端回填:`status=C` 回填名称/法人/地址/经营范围/注册资金/经营期限等表单字段(锁定客商名称/法人/注册资金);`A/B` 前端阻断保存。原始报文不存储。 +- 后端复查:`C`→`kingdee_status=exist`(终态);`A/B`→`failed`(「金蝶侧该客商审核中,请稍后重推」)。 +- 定时附加职责:synced/exist 且启用客商复查 `C`→exist;`enable=0` → 联动停用 TMS 档案 `status=2`(单向,金蝶恢复不自动启用)。 +- 落库字段:`kingdee_status / kingdee_fail_reason / kingdee_sync_time`。 + +### 2.2 A902-2 客户保存(推送) + +**触发场景(3 入口,收敛于 `pushCustomerToKingdee`)**:① 审批通过自动推送(`POST /customer-archive/approve`,失败不影响审批);② 列表人工重推(`POST /customer-archive/sync-kingdee`,仅 approved,synced/exist 幂等返回);③ 定时重推(pending = approved 且 none/failed)。 + +**调用顺序**:先 A902-1 复查(金蝶已有 `C` 直接置 exist 跳过)→ 组装报文(`CustomerBfiConverter.buildSaveDTO`)→ A902-2 保存。**只推新增建档**:审批后的档案变更不重推(无更新链路);`synced`(金蝶受理 B)不重推,由定时复查升级 exist。 + +**参数要点**: +- `number`=`customer_code`(submit 时生成 `KS`+6 位自增);`name/societycreditcode/artificialperson` = full_name/unified_credit_code/legal_person; +- `type`=customer_nature→字典 dictKey(解析失败抛异常,不兜底);`bizfunction` 固定 `1,2,3,4`; +- `entry_groupstandard`:**★已修复** —— `customerKind` 已落库(`CustomerArchive.customerKind`),internal→1101(内部组织)、其余→1102(外部客商); +- `entry_bank`:`blade_customer_receipt_account`(账号/开户行/CNY/isdefault);⚠️ `bankNumber` 仍硬编码 "001110012009"(CustomerBfiConverter:141,todo); +- `entry_linkman`:principal + contact_phone;`status` 不传。 + +**响应存储**:`result[0].billStatus=true` → `synced`;`false` → `failed` + errors.rowMsg(截 500 字);异常 → `failed`。返回的 number/id 不落库。 + +**依赖与幂等**:无前置;被应付/应收/收票/开票链路取 `customer_code`;⚠️ 其他链路**不校验客商 `kingdee_status`**(软依赖,见 11.2-#3)。幂等:本地 synced/exist 直接返回;金蝶按 `societycreditcode` 防重。 + +--- + +## 3. 业务域二:财务应付与付款 + +**链路总览(三环节接力,★ 标注本次变化)** + +``` +环节一 正式结算单页面「同步金蝶」(POST /formal-settlement/sync-kingdee) + A102-4 预检 ──▶ A102-2 保存应付单(明细行+手工合计费用项分录,汇率随单)──▶ 结算单回写 kingdee_bill_no + synced +环节二 付款管理「同步金蝶 / 批量同步」(POST /payment-application/sync-kingdee[-batch]) + A102-4 硬校验(应付单已生成,逐关联结算单) ★不再反查付款计划分录 + ──▶ A103-2 幂等预检 ──▶ A103-1 下推(单分录,勾稽键=主结算单应付防重键;预付款走 A103-3)──▶ 回写 kingdee_bill_id/no + synced +环节三 PaymentKingdeeResultTask 定时(每10分钟,默认关) + A103-2 查状态(synced→paying→paid/closed) ──▶ A103-4 查付款处理单 ──▶ 付款记录落库(含 voucher_json) +``` + +### 3.1 A102-2 财务应付单保存 + +![正式结算单页面(2026-09-29):应付 / 应收 Tab 同页,勾选一条单据后点工具栏「同步金蝶」——payable 路由 A102-2、receivable 路由 A302-2(4.1),成功后回写结算单 kingdee_bill_no 并置 synced](./2026-09-27-金蝶(星瀚)BFI接口调用全链路梳理(按业务域汇总·更新版).assets/bfi-02-formal-settlement.png) + +- **触发**:`POST /formal-settlement/sync-kingdee`(FormalSettlementController:209),结算类型=`payable` 路由(`receivable` 路由 A302-2 财务应收单保存,同一入口)。无定时。 +- **顺序**:校验 approved+应付类型 → synced 幂等返回 → 组防重键 → **A102-4 预检** → 未生成才 A102-2 保存(`KingdeeApBillSyncSupport.pushApBill`)→ 回写。单笔单事务。 +- **参数来源**: + - 防重键 `正式结算单号_结算明细单据号…`;srcType=`TMS财务应付单`;billtype=`服务采购应付`→`ApFin_service_BT_S`; + - org/payorg/币种/往来户/往来类型:见 1.7;**exchangerate/exratedate=结算单页面维护值(未维护不送)** ★新增; + - purmode=CASH、款项性质=2001、isincludetax/ispricetotal=true;**settlementtype_number(结算方式)不在应付单送**(仅 A103-1 分录需要); + - **分录(★新口径)**:`resolveEntryGroups` = 结算明细行(每行一条)+ **手工结算合计费用项**(`blade_formal_settlement_summary_fee.manualFlag=1`,每行一条;系统自动汇总行不送,防与明细重复); + - `awrp_entry_code`:明细行=结算明细单据号;合计费用项行=`结算单号-费用类型-费用项`拼接; + - 明细行带运输货物(货物名称)/车号;数量=运输量(兜底 1,合计行默认 1);单价=结算金额/数量反算; + - ⚠️ 调试组合块强制物料/计量单位(见 1.7);⚠️ 项目编码仍传测试值 `999`; + - **planentity 仍不传** → 金蝶按分录自动汇总生成付款计划。 +- **响应存储**:`result[0].number` → `blade_formal_settlement.kingdee_bill_no`、`kingdee_sync_status='synced'`;id 不落库。失败抛异常整体回滚保持 unsynced(**不落 failed**)。 +- **依赖**:本地数据齐备即可;**是 A103-1 应付下推生成付款申请与 A303-2 收票单批量保存的硬前置**。 +- **幂等**:A102-4 按防重键预检,已存在直接返回已有 billno(alreadyExist=true)。 + +### 3.2 A102-4 财务应付单查询(★用途缩减) + +- **触发**:纯内部步骤,无独立入口。**三个用途**(原四个,反查已移除):① A102-2 推送前幂等预检;② A103-1 推送前「应付单已生成」硬校验(逐关联结算单,未生成报「请先在正式付款单页面同步金蝶」);③ 批量同步整批预热(`preloadApBillExistNos`,未命中编码补空串标记避免重复远程查询)。 +- ~~原用途④「反查付款计划分录 awrp_entry_code_p」已随 09-24 重构移除~~:A103-1 的勾稽键改为本地组装(见 3.3)。 +- **参数/存储**:`awrp_src_code`(多值,50 键/批、页大小 200)+ `awrp_src_sys`;结果不落库,仅入 `apBillNoCache`。 +- **幂等**:本身即防重手段。 + +### 3.3 A103-1 付款申请保存(应付下推)/ A103-3 预付款直推(★大改) + +![付款管理页面(2026-09-29):「批量同步」= /payment-application/sync-kingdee[-batch](A102-4 硬校验 → A103-2 幂等预检 → A103-1 下推,预付款走 A103-3);「同步付款结果」手动触发 A103-2 / A103-4 状态与付款记录回写](./2026-09-27-金蝶(星瀚)BFI接口调用全链路梳理(按业务域汇总·更新版).assets/bfi-03-payment-application.png) + +- **触发**:`POST /payment-application/sync-kingdee`(单笔)与 `/sync-kingdee-batch`(批量)。**预付款**(project_advance/progress_advance)走 A103-3 直推。 +- **调用顺序**(`doSyncKingdee`,单据级事务): + 1. 锁单 + approved 校验 → 状态门禁(synced/paying/paid/closed 幂等跳过); + 2. 结算类型路由 → **A102-4 硬校验**(关联的**每张**正式结算单应付单必须已生成,按本地组装的防重键逐单预检); + 3. **A103-2 幂等预检**(`reconcileExistPayApply`:正式路径统一先预检;预付款路径仅 failed 恢复时预检——查到即回填跳过,防「远端已落单本地回滚」重推); + 4. **A103-1 下推**(单分录)/ **A103-3 保存** → 回写。批处理:整批一次 A102-4 预热 → 逐单自代理独立事务 → 单条失败仅回滚自身、落 `failed`、不中断整批。 +- **A103-1 付款申请保存(应付下推)参数(★新口径)**: + - 防重键 `awrp_src_code`=`paymentNo`;`applydate`=申请日期(空取当天);`exratetable_number`=配置 `01`(注释保留,实际不送——按应付单自动带出);`awrp_textfield1`=付款方式字典标签(非必传); + - **分录固定一条**:`e_paymenttype_number`=201(正式付款);**`e_settlementtype_number` 按付款方式映射(★新增):汇票付款→`012-02`(应付票据电子银行承兑汇票),其余→`02`(银行转账)**; + - **`awrp_finapbill_entryid` = 主结算单应付单防重键(★本地组装 `settlementApBillSrcCode`,不再 A102-4 反查 `awrp_entry_code_p`)**——主结算单=付款申请 `settlementId`,聚合多结算单取关联结算明细首个结算单 id;⚠️ 支撑类注释仍写「A102-4 反查、禁止自行拼接」,**注释与代码不一致,金蝶侧对该值的校验口径需联调确认**(见 11.2-#4); + - `awrp_entry_code`=`paymentNo-1`;`e_assacct`=付款申请 `bankAccount`(收款账号);**`e_bebank_number`=客商收款账户联行号(★`blade_customer_receipt_account`,未维护报错拦截)**; + - `e_applyamount`=申请付款金额(2 位 HALF_UP);project 仍传 `999`。 +- **A103-3 预付款申请保存参数**:单分录 `paymentNo-1`;`e_paymenttype_number` 按付款类型(正式=201/预付);`e_assacct`=bankAccount;**`e_bebank_number`=`entity.getBankName()`**(⚠️ 语义存疑:往来银行.编码取的是名称字段,见 11.2-#5);`awrp_textfield1`=付款方式标签;汇率表/汇率日期=申请日期;**project=`entity.projectId`→项目档案 `projectCode`(真实编码,无则 999)** ★改善。 +- **响应存储**:`requireBfiBillIdAndFillNo`:`result[0].id` → `kingdee_bill_id`;`result[0].number` → `kingdee_bill_no`(推送即时值,后续被 A103-2 回写覆盖校准);`kingdee_status='synced'`。异常:事务回滚后独立事务 `markKingdeeSyncFailed` → `failed`。 +- **依赖**:硬前置 = A102-2 已生成(A102-4 校验)。失败重推断点续传:A102-4 命中跳过 → A103-2 命中直接回填。 +- **幂等**:三层——本地状态门禁、A102-4 应付预检、A103-2 按 paymentNo 查重。 + +### 3.4 A103-2 付款申请单查询(状态回写 + 推送预检) + +- **触发**:① 定时 `PaymentKingdeeResultTask.syncKingdeePaymentResult`(默认 `0 */10 * * * ?`,开关默认 false);② 手动 `POST /payment-application/sync-kingdee-result`;③ A103-1/A103-3 推送链内部幂等预检。 +- **顺序**:查待回写(approved 且 status ∈ {synced, paying, **failed**})→ paymentNo 去重按 50/批查询 → 同一 srcCode 取 `modifytime` 最新 → 逐单独立事务回写。 +- **响应存储**:`billno`→kingdee_bill_no、`id`→kingdee_bill_id;状态映射:`Alreadypay`→**paid**(终)> `billstatus=D`→**closed**(终)> `Inpayment`→**paying** > 其余→synced;`entry[].e_paidamt` 汇总→`paid_amount`(缺失且 paid 时兜底=申请金额);paid 联动履约(写 `blade_pre_settlement_advance` 与 `blade_formal_settlement_payment`);**failed 纳入勾稽纠偏**:查到即视为上次推送实际成功,纠正为 synced。 +- **依赖/幂等**:按防重键查询,无硬前置;主键更新天然幂等;终态不再轮询;查无此单状态不变。 + +### 3.5 A103-4 付款处理查询(付款记录回写) + +- **触发**:定时子流程 + 手动端点。**仅查询,不推送**。 +- **顺序**:候选 = 本轮 A103-2 查到 `paystatus≠Unpaid` 的单 + 已 paid 但无真实付款记录的单(补偿);按付款组织编码分组逐组查询;按源单勾稽匹配(`sourcebillid`==kingdee_bill_id 优先,`sourcebillnumber`==kingdee_bill_no 兜底)。 +- **参数**:`org_number` 优先取 A103-2 返回 `payorg_number`,兜底按合同所属组织解析;业务日期窗口=最近 1 个月;页大小 200 翻页。 +- **响应存储**:upsert `blade_payment_application_record`——`billno`→payment_no+kingdee_bill_no;`actpayamt`→paid_amount;`paydate`→paid_date;**整单 JSON→`voucher_json`**(凭证号/银行流水/电子回单留档);清理 MOCK-PAY- 历史模拟记录。 +- **依赖/幂等**:勾稽需已知 kingdee_bill_id → 必然晚于推送链;付款组织解析失败仅 warn 跳过下轮重试;按付款处理单号判重更新。 + +--- + +## 4. 业务域三:财务应收与开票 + +**链路总览** + +``` +环节一 正式结算单页面「同步金蝶」(应收类型) + A302-4 预检 ──▶ A302-2 保存应收单(明细行+手工合计费用项分录,汇率随单)──▶ 结算单回写 kingdee_bill_no + synced +环节二 开票申请「同步金蝶」 + A302-4 硬校验(应收单已生成) ──▶ A303-4 批量 billno 幂等预检 ──▶ A303-1 按发票张次逐张新增 ──▶ 张次/申请单回写单号 + synced +环节三 定时回写(PaymentKingdeeResultTask 串行第 2/3 步) + A302-4 勾稽纠偏(failed→synced / 补单号) + A303-5 开票发票查询(serialNo 按 application.kingdee_bill_no 逗号拆分)──▶ 发票落库 ──▶ invoiced(终态) +``` + +> 推送链路**无批量端点**;查询侧为批量(防重键 50/批 + 自动翻页 200/页)。 + +### 4.1 A302-2 财务应收单保存 + +- **触发**:`POST /formal-settlement/sync-kingdee`,`settlementType=receivable` 路由 → `KingdeeArBillSyncSupport.pushArBill`。用户动作,无定时。 +- **顺序**:防重键组装 → **A302-4 幂等预检**(命中即返回已有单号跳过保存)→ 结算单维度取数(合同/组织/币种/客商)→ 组装 `BfiArBillSaveDTO` → 保存 → 回写。 +- **参数**:防重键同应付口径;srcType=`TMS财务应收单`;billtype=`服务销售应收`→`arfin_sersal_BT_S`;`bizdate`=同步当日;**exchangerate/exratedate=结算单页面维护值** ★新增;`asstacttype` 固定 `bd_customer`;`asstact_number`=结算单付款方 payer_name→客商编码;paymode=CASH、payproperty=1001; + **分录(★新口径,与 A102-2 完全同源)**:结算明细行 + 手工合计费用项各一条,`awrp_entry_code` 同应付口径;调试组合块同在;项目 `999`。 +- **响应存储**:`result[0].number` → `blade_formal_settlement.kingdee_bill_no`、`synced`;id 不落库;失败回滚保持 unsynced(**不落 failed**)。 +- **依赖/幂等**:是 A303-1 开票申请单新增的硬前置;A302-4 预检防重推。 + +### 4.2 A302-4 财务应收单查询 + +- **触发**:① A302-2 推送前幂等预检;② **定时勾稽纠偏**(`syncKingdeeArResult`:轮询 approved+receivable+status ∈ {synced, **failed**},批量 50 键/批:查到补缺失 kingdee_bill_no、failed 纠偏为 synced;查不到不变);③ **A303-1 推送前置硬校验**(任一关联结算单应收单未生成即拦截)。 +- **参数/存储**:`awrp_src_code`(多值)+ `awrp_src_sys`;落库仅限勾稽纠偏补号/状态纠正。 +- **依赖/幂等**:A302-2 财务应收单保存 →(A302-4 财务应收单查询)→ A303-1 开票申请单新增 硬顺序。 + +### 4.3 A303-4 开票申请单查询(★批量幂等预检) + +- **触发**:仅一处——A303-1 推送前预检。 +- **★变化**:按**全部候选 billno 列表**(单张=application_no;多张=application_no-1/2/3…)一次性查询(pageSize=max(张数,10)),命中的张次幂等回填跳过,未命中的张次重新推送——支持「部分张次已生成」的断链重推。 +- **参数/存储**:查询键 `billno`(多值);命中结果仅用于跳过与回填 sheet.kingdee_bill_no。 + +### 4.4 A303-1 开票申请单新增(★按张次拆分重写) + +![开票管理页面(2026-09-29):工具栏「批量同步」= /invoice-application/sync-kingdee,A302-4 硬校验 → A303-4 按全部候选 billno 批量幂等预检 → A303-1 按发票张次逐张新增;列表支持按金蝶单据状态筛选](./2026-09-27-金蝶(星瀚)BFI接口调用全链路梳理(按业务域汇总·更新版).assets/bfi-04-invoice-application.png) + +- **触发**:用户动作 `POST /invoice-application/sync-kingdee` → `syncKingdee`。单申请一次。 +- **调用顺序**:校验 approved → synced/invoiced 幂等返回 → 关联结算单非空 → **A302-4 硬校验** → **A303-4 批量预检** → 销/购方取数校验 → **逐张发票张次**组装保存(`requireBfiSaveSuccess` 校验每张)→ 批量回写张次与申请单。 +- **参数**: + - **张次(★新表 `blade_invoice_application_sheet`)**:每张金蝶开票申请单对应一个张次;`billno`=单张取 `application_no`(兼容存量)、多张取 `application_no-张次序号`;价税合计=本张 `invoice_amount`;分录=本张商品行(`blade_invoice_application_line` 按 `invoice_sheet_id` 分组、lineNo 升序),`awrp_entry_code`=商品行 id; + - 头字段(沿用):`orgid_number`=承办部门 dept_code;`billdate`=application_date;`invoicetype`:数电专票 `08xdp` / 数电普票 `10xdp`;`billproperties=1`、`taxationstyle=0`、`buyeremail`=department_emails;购方=申请单受票方快照列;销方=salername/开票单位税号(客商档案)/地址电话/银行账号;`customnameid_number`=receiver_customer_id→客商编码;`hsbz`:普票=1 否则 0;`awrp_project_number`=999;systemsource=WLTMS。 +- **响应存储(★)**:每张 `sheet.kingdee_bill_no`=金蝶返回 number(幂等命中的张次回填候选 billno);**`entity.kingdee_bill_no`=全部张次单号逗号拼接**;`kingdee_status='synced'`;记操作历史。 +- **依赖/幂等**:硬前置 = A302-2 应收单已生成;幂等 = billno 候选键 + A303-4 批量预检回填(**未生成张次可断点续推**)。 + +### 4.5 A303-5 开票发票查询(结果回写) + +- **触发**:定时(PaymentKingdeeResultTask 第 3 步)+ 手动 `POST /invoice-application/sync-kingdee-invoice-result`。 +- **顺序**:待回写 = approved + synced + 有 kingdee_bill_no → **按 `application.kingdee_bill_no` 逗号拆分逐个 serialNo**(★适配张次多单号)→ 按销方税号分组(开票单位→客商档案税号,解析不到跳过 warn)→ 组内 ≤50/批调 A303-5 开票发票查询(票易通 openApi,Base64 包裹)。 +- **响应存储**:`billNo + invoiceList[]` → `blade_invoice_application_invoice`(serial_no、invoice_no、invoice_date、invoice_amount、raw_json);幂等 = 批量预查已有发票号 + 唯一键 `uk_application_invoice(tenant_id, application_id, invoice_no)`;有新增发票 → `invoiced` 终态。**发票结果不反写结算单**。 +- **依赖/幂等**:必须先有 A303-1 开票申请单新增产生的单号;唯一键防重;批次失败仅计数不中断。 + +--- + +## 5. 业务域四:收票与金蝶票池 + +**架构现状**:票池浏览**实时回源 A801-1 全票池发票查询**(全量定时同步任务已退役),镜像表 `blade_kingdee_invoice_pool` 降级为**登记存根**;推送链 A303-2 收票单批量保存 → A302-6 收票指定应付 人工批量按钮触发;A801-3 收票单查询 承担推送预检 + 定时回写。 + +### 5.1 A801-1 全票池发票查询(选票 / 保存重查 / 补票种) + +- **触发**:① 选票弹窗:收票登记 `GET /invoice-receipt/invoice-pool`、结算单认领 `GET /formal-settlement/invoice-pool`;② 收票登记**保存重查**(`persistMirrorFromRemote`,按票号精确单查,作为「保存重查闸门」);③ 推送时镜像缺票种编码的按票号窄查补填。**无定时任务**。 +- **顺序**:游标分页(`PoolScanCursor`)→ 首拉 size+1 探测 → 不足按契约上限 500 行/次补拉,单请求最多 3 次远程调用 → 短页即耗尽。 +- **参数**:`invoiceStatus`="0"(正常票)、`expenseStatus`="1"(未用,后端固定下推);票号/销购方税号/票种/日期来自前端 DTO(日期强校验)。 +- **行过滤**:票号非空 + `amountUseStatus="0"` + `availableAmount>0`;按票号查询时 trim 全等防御(防远程模糊匹配假命中)。本地打标 registered/claimed 纯展示。 +- **响应存储**:浏览**不落库**;落库仅保存收票登记(按票号精确单查→可用性复核→`(tenant_id, invoice_no)` FOR UPDATE + upsert 存根)与推送补查。镜像列含 `invoice_type_code`(票易通原始编码,A303-2 取数)、税率(小数→百分数)、票面四要素、`check_status`、`attachments_json`、`items_json`(仅存档,**不作分录取数**)。 +- **依赖/幂等**:仅 BFI 网关;**金蝶不可用则选票与收票保存整体不可用**(无镜像读降级);保存重查闸门拦截红冲/作废/被占用竞态;一票一登记(`assertInvoiceUnused`)。 + +### 5.2 A303-2 收票单批量保存 + A302-6 指定应付(推送链) + +![收票管理页面(2026-09-29):工具栏「批量同步」= /invoice-receipt/push-kingdee,链路 A102-4 应付预检 → A801-3 幂等预检(防重键=收票登记 id)→ A303-2 收票单保存 → A302-6 指定应付;选票 / 保存重查经 A801-1 实时回源票池](./2026-09-27-金蝶(星瀚)BFI接口调用全链路梳理(按业务域汇总·更新版).assets/bfi-05-invoice-receipt.png) + +- **触发**:`POST /invoice-receipt/push-kingdee`。单笔失败不中断整批,逐单报因。 +- **调用顺序**(`pushOneReceivedInvoice`): + 1. 批量取数防 N+1(分摊/镜像/结算单/明细一次取回); + 2. 组装推送计划:approved、状态≠assigned、已绑定镜像、`checkStatus=2`(查验通过)、**恰好分摊 1 张审批通过应付结算单(一票一应付)**、有结算明细;不满足写 `blockedReason` 不改状态; + 3. **A102-4 预检**:关联结算单应付单必须已生成,缺则拦截「请先同步金蝶应付单」; + 4. **A801-3 幂等预检**:按镜像购方名称分组、窗口 30 天翻页,本地按 `awrpSrcSys+awrpSrcCode`(=**收票登记 id**,★243d5be 修复)匹配;命中且状态正常 → 跳过 A303-2 直接续推指定(断点恢复);命中但发票状态异常(作废 2/红冲 3/部分红冲 7/全部红冲 8)→ 置 failed 报人工; + 5. 镜像缺票种 → A801-1 按票号窄查回填; + 6. **A303-2 保存** → 成功先落单号 + `pushed`; + 7. **A302-6 指定应付**(★入参改 receipt.getId())→ 成功 `assigned`(终态)+ 记流水;失败 `failed` + 原因。unassign 不自动执行。 +- **参数**: + - A303-2 收票单批量保存:`awrp_src_code`=收票登记 id;srcType=`TMS收票登记`;`invoiceno`=receipt.invoice_no;`receivedate`=推送当日;`issuedate`=镜像 invoice_date;`invoicetypef7_number`=镜像 invoice_type_code(缺则报错);销购方四要素=镜像票面;org/币种/`receivablessupp_number`(合同乙方→客商编码)复用公共取数;**分录与 A102-2 同源同口径(★结算明细行+手工合计费用项)**;⚠️ 调试组合块**含随机兜底**(未命中组合随机选一个物料,见 11.2-#6); + - A302-6 收票指定应付:`ap_invoice` 引用 `awrp_src_code`=收票登记 id(★修复);`ap_finapbill` 引用 `apBillSrcCode`=正式结算单号_明细单据号(与 A102-2 同源)。 +- **响应存储**:`result[0].number` → `blade_invoice_receipt.kingdee_received_invoice_no`;状态机 `unsynced→pushed→assigned/failed` → `kingdee_push_status`;失败原因→`kingdee_push_msg`(截 500 字);终态/失败写 `blade_invoice_receipt_record` 流水。`blade_invoice_receipt.kingdee_bill_no / kingdee_status` 两列已删除(20260923),金蝶状态唯一来源=`kingdee_push_status`。 +- **依赖/幂等**:A302-6 依赖 A303-2 同键收票单已生成、且依赖 A102-2 应付单(硬预检);A801-3 预检命中跳过保存;pushed 断点由定时补偿重发 A302-6;assigned 拒绝重推;pushed/assigned 状态**禁止本地作废**(voidBill)。 + +### 5.3 A801-3 收票单查询(预检 + 定时回写) + +- **触发**:① 推送链内部预检(上);② 定时 `PaymentKingdeeResultTask.syncKingdeeReceivedInvoiceResult`(独立开关默认 false,cron 默认每 10 分钟);③ 手动 `POST /invoice-receipt/sync-kingdee-result`。 +- **回写顺序**:轮询 `kingdee_push_status ∈ (pushed, assigned)` 且非作废、已绑定镜像 → 按 `receiver_name`(接口必填购方名称)分组 → 窗口 30 天翻页 → 逐单处置:发票状态异常→`failed`+「已回收推送状态」;`pushed`→**补偿重发 A302-6**(★防重键已修复为登记 id)→ assigned;`assigned` 缺单号→仅补登单号;**窗口内查不到不改状态**。 +- **⚠️ 疑似缺陷残留(★未修复)**:定时回写**仍按发票号码**组装查询集与匹配(`entry.getValue().stream().map(InvoiceReceipt::getInvoiceNo)` + `matched.get(receipt.getInvoiceNo())`),而 A303-2 落库的 `awrp_src_code` 是**收票登记 id** → 回写将**永远匹配不到 TMS 自推单据**(静默跳过,异常回收/指定补偿/单号补登全部失效)。09-23 的 243d5be 提交只修复了**推送预检与 A302-6** 路径,回写路径漏改。见 11.2-#1。 + +### 5.4 选票 / 认领流(是否调金蝶对照) + +| 流 | 入口 | 金蝶调用 | 本地存储 | +|---|---|---|---| +| 收票登记 | `POST /invoice-receipt/save` | 保存重查 A801-1(闸门) | 镜像存根 upsert + 绑定 + 分摊 + 流水 | +| 结算单认领(按票) | `POST /formal-settlement/claim-invoices`;查票 `GET /formal-settlement/invoice-pool` | 查票实时 A801-1;**确认认领时不调金蝶** | `blade_formal_settlement_invoice` + 联动付款申请 matched;⚠️ **仍无保存重查闸门**(前端传票面直接落库,09-23 提案至今未实现) | +| 结算单收款认领(收票流水) | ReceiptFlowController / ReceiptClaimRecordController | **不调金蝶**(镜像流水由外部 `sync` 推入) | `blade_kingdee_receipt_flow`、`blade_receipt_claim(_settlement)`、`blade_receipt_flow_record` | + +--- + +## 6. 业务域五:收款认领(A301-2 收款认领公告查询 / A301-3 收款认领结果推送 / A301-1 收款单查询)★新增 + +**定位**:图06「回款认领」采用**业务先行认领(场景二)**:金蝶司库收到银行流水完成第一轮自动认领(到客户维度),TMS 二次认领到结算单(应收单)维度后回推金蝶生成收款单。**三个接口本期全部接线,均为手动触发,无定时任务**(定时开关 `thirdParty.bfi.receiptClaimResult.*` 预留未接)。专项文档:`2026-09-24-收款认领金蝶全链路业务流程….md`。 + +``` +阶段一 POST /receipt-flow/sync(手动同步流水按钮) + A301-2 认领公告查询(claimusers=当前登录用户真实姓名) + → BfiUtil.queryAllPages 全量翻页 → 按 detail_serial_no 幂等 upsert blade_kingdee_receipt_flow +阶段二 POST /receipt-flow/claim(页面认领) + 本地认领落库(blade_receipt_claim + 分摊表)+ A301-3 认领结果推送 同一事务 + → 成功回写金蝶收款单号 + kingdee_bill_status=pushed;失败抛异常整体回滚 +阶段三 POST /receipt-claim-record/query-kingdee-result(手动回写) + A301-1 收款单查询(近 30 天窗口全量翻页)→ sourcebillnumber==awrp_src_code 匹配 → 回写单号与状态 +``` + +### 6.1 A301-2 收款认领公告查询(公告同步) + +![收款流水页面(2026-09-29):「手动同步流水」= POST /receipt-flow/sync(A301-2 公告查询,claimusers=当前用户真实姓名,全量翻页幂等落库);勾选流水后「批量认领」在认领事务内同步推送 A301-3](./2026-09-27-金蝶(星瀚)BFI接口调用全链路梳理(按业务域汇总·更新版).assets/bfi-06-receipt-flow.png) + +- **触发**:仅手动 `POST /receipt-flow/sync`(按钮权限 `receipt_flow_sync`)。前端已去 mock,直接调后端。 +- **顺序**:确定参与认领用户(`UserCache.getUserRealName`,为空拦截)→ 全量翻页(pageSize 200,50 页保护,时间窗口从 Nacos 配置 `thirdParty.bfi.claimAnnounce.months` 读取,默认 6 个月)→ 幂等落库 → 操作留痕(`blade_receipt_flow_record` 记 sync)。 +- **参数**:`claimusers`=当前用户真实姓名(**前提:金蝶公告的 claimusers 须与 TMS 用户姓名一致**,否则查不到公告)。 +- **响应存储**:upsert `blade_kingdee_receipt_flow`,幂等键 `detail_serial_no`(取 detailid,缺省回退 tradedetailno/bankdetailno;重复明细流水号拦截);落库 `bill_id`(**认领公告 id,A301-3 收款认领结果推送必填**)、billno→receipt_notice_no、reamount、对方户名/账号/开户行、摘要、交易时间、unclaimed_amount、fee、payee_name、claimstatus→kingdee_claim_status、claimme。**公告侧已认领金额/状态与本地 claimed_amount/claim_status 互不覆盖**。 +- **依赖/幂等**:独立;唯一索引 `(tenant_id, detail_serial_no)`。 + +### 6.2 A301-3 收款认领结果推送 + +- **触发**:页面认领 `POST /receipt-flow/claim`(流水详情/批量认领页选择审批通过的应收正式结算单按比例分摊)。 +- **顺序**:本地校验(锁行、分摊金额上限、同一项目/组织/收付款方)→ 本地落库(claim + 分摊 + 流水 claimed_amount/claim_status 刷新 + 结算单收款汇总)→ 生成 `awrp_src_code = SKCL+yyyyMMddHHmmss+认领id尾号6位` → **A301-3 推送**(同事务)→ 成功回写 `kingdee_bill_no`(result[0].number)+ `kingdee_bill_status=pushed`;失败抛 `ServiceException` 整体回滚(无残留)。 +- **参数**:`billid`=流水 `bill_id`(**必填**,缺失拦截「认领公告id缺失,请先同步金蝶收款流水」);`awrp_src_sys`=WLTMS;`awrp_src_code`=SKCL 码;`recpaytype_number`=常量(示例 101,**待金蝶确认**);`paymenttype`=bd_customer(**待确认**);`recpayer`=流水 payer_id(**待确认**:金蝶付款人 master id,联调若强校验需按名称反查 A902-1 补齐);分录 `entryentity[]`=各结算单分摊行:`e_receivableamt`=分摊金额、`e_corebilltype`=`ar_finarbill`、`e_corebillno`=分摊结算单 `kingdee_bill_no`(缺失不传)、`seq`=行号、`e_receivingtype_number` 同 recpaytype_number(待确认)。 +- **依赖**:分摊结算单须审批通过、未作废、且已完成 A302-2 同步(有 kingdee_bill_no)以便分录携带应收单号。 +- **幂等**:`awrp_src_sys+awrp_src_code` 为金蝶候选键;⚠️ 推送成功但本地回滚后重推会**重新生成 awrp_src_code** 产生新单(联调期以推送失败回滚为主路径规避)。 + +### 6.3 A301-1 收款单查询(认领结果回写) + +![认领记录页面(2026-09-29):「查询金蝶认领结果」= POST /receipt-claim-record/query-kingdee-result,手动调 A301-1 收款单查询(今天回溯 30 天),按 sourcebillnumber == awrp_src_code(SKCL 码)匹配回写单号与状态](./2026-09-27-金蝶(星瀚)BFI接口调用全链路梳理(按业务域汇总·更新版).assets/bfi-07-receipt-claim-record.png) + +- **触发**:仅手动 `POST /receipt-claim-record/query-kingdee-result`。**仅查询,不推送**。 +- **顺序**:轮询 `claim_status=claimed` 且 `awrp_src_code` 非空且 `kingdee_bill_status ∈ (pushed, approved)` → A301-1 按业务日期**今天回溯 30 天**(≤1 个月约束)全量翻页(pageSize 200)→ 本地匹配 `sourcebillnumber == awrp_src_code` → 逐条回写。 +- **响应存储**:`kingdee_bill_no`=收款单 billno(首条命中后仅推进状态);`kingdee_bill_status` 映射:A 暂存/B 已提交→pushed;C 已审核→approved;**D 已收款→received(终态)**;其他/G 退单→failed。窗口内查不到不改状态(金蝶回单延迟兜底)。**不改本地认领金额/分摊**。 +- **依赖/幂等**:依赖 A301-3 产生的 awrp_src_code;主键更新幂等。 + +--- + +## 7. 业务域六:公共取数(A801-4 汇率)★新启用 + +- **触发**:前端结算页面按「结算币种 + 汇率日期」调用 `GET /bfi/exchange-rate?currencyCode=&effectdate=`(BfiExchangeRateController),用于正式结算单页面维护汇率/汇率日期。手动、单次。 +- **顺序**:按日期查 A801-4 全量汇率(当日默认,页大小固定 10)→ 本地按 `orgcur_number` 匹配币种 → 返回汇率 VO。 +- **响应**:不落库(汇率/汇率日期随结算单提交落 `blade_formal_settlement.exchange_rate / exchange_rate_date`);查不到币种/日期返回 R.fail。 +- **下游消费**:A102-2 / A302-2 保存时传 `exchangerate`(结算汇率)与 `exratedate`(汇率日期),未维护不传;A103-1 下推汇率相关字段不传(按应付单自动带出)。 +- **与方案文档对照**:方案 V1.2「汇率在金蝶维护,TMS 结算时通过接口获取并记录在结算单据上,期末调汇全部在金蝶处理」——**代码已落地**。 + +--- + +## 8. 全景:接口先后顺序与依赖关系 + +### 8.1 链路依赖图 + +``` + ┌────────────────── 客商档案(全局前提,软依赖) ──────────────────┐ + │ A902-2 客户保存(审批通过自动/人工/定时重推) │ + │ A902-1 客户查询(前端回填/推送前复查/定时复查) │ + │ ⇒ 本地 customer_code(其他链路只查本地表,不校验金蝶侧状态) │ + └───────────────────────────┬───────────────────────────────────┘ + ▼ +【应付主线】 + 正式结算单(应付) ─A102-4 预检─▶ A102-2 保存应付单 ─▶ 结算单.kingdee_bill_no + synced + │ (汇率 exchangerate/exratedate ← A801-4 页面取数) + │ A102-4 硬校验(每张关联结算单) │ A102-4 预检(收票推送硬前置) + ▼ ▼ + 付款申请 ─A103-2 预检─▶ A103-1 下推(单分录;勾稽键=主结算单应付防重键,本地组装) + (预付款→A103-3 直推) ─▶ 付款申请.kingdee_bill_id/no + synced + │ + 定时:A103-2 状态回写(synced→paying→paid/closed, failed勾稽纠偏) + ▼ + A103-4 付款处理查询 ─▶ blade_payment_application_record(+voucher_json) + +【应收主线】 + 正式结算单(应收) ─A302-4 预检─▶ A302-2 保存应收单 ─▶ 结算单.kingdee_bill_no + synced + │ A302-4 硬校验 + ▼ + 开票申请 ─A303-4 批量billno预检─▶ A303-1 逐张次新增 ─▶ 张次.kingdee_bill_no + 申请单.kingdee_bill_no(逗号拼接) + synced + │ + 定时:A302-4 勾稽纠偏 + A303-5(serialNo 按逗号拆分)─▶ 发票落库 ─▶ invoiced(终态) + +【收票主线】(与应收主线无调用耦合,与应付主线强耦合) + 金蝶票池 A801-1(实时选票 / 保存重查闸门 / 补票种) ─▶ 镜像存根 blade_kingdee_invoice_pool + │ 保存收票登记(一票一登记) + ▼ + 收票登记 ─A102-4 预检(应付已生成) + A801-3 预检(防重键=登记id)─▶ A303-2 收票单保存 ─▶ pushed + ▼ + A302-6 指定应付(引用=登记id)─▶ assigned(终态) + │ + 定时:A801-3 回写(⚠️ 仍按发票号匹配,疑缺陷残留) + +【收款认领主线】★新增(全部手动触发,无定时) + 金蝶第一轮自动认领(到客户)─▶ A301-2 公告同步(claimusers=用户姓名)─▶ blade_kingdee_receipt_flow + │ 页面二次认领(分摊到应收结算单) + ▼ + 认领落库 + A301-3 推送(billid=公告id;src_code=SKCL码;分录=e_corebillno=结算单应收单号)─▶ pushed + ▼ + 手动:A301-1 收款单查询(sourcebillnumber==SKCL码)─▶ approved/received(终) + +【公共取数】 + 结算页面 ─▶ A801-4 汇率查询 ─▶ 结算单.exchange_rate/exchange_rate_date ─▶ 随 A102-2/A302-2 推送 +``` + +### 8.2 硬顺序规则(代码强制) + +| # | 顺序规则 | 强制方式 | +|---|---|---| +| 1 | 客商档案先于一切推送:本地 `customer_code` 必须存在 | 查不到直接报错拦截;**金蝶侧是否已审核不校验**(软依赖) | +| 2 | A102-2(应付单)先于 A103-1(付款下推) | A102-4 硬校验(逐关联结算单);~~A102-4 反查 awrp_entry_code_p~~ **已移除** | +| 3 | A102-2(应付单)先于 A303-2/A302-6(收票推送) | A102-4 硬预检 | +| 4 | A302-2(应收单)先于 A303-1(开票申请) | A302-4 硬校验 | +| 5 | A303-2 收票单批量保存 先于 A302-6 收票指定应付 | 同一次推送内串行;assigned 依赖 pushed | +| 6 | A302-2(应收单)先于 A301-3(认领推送分录携带应收单号) | 分录 e_corebillno 缺失不传(软依赖),联调前提建议先同步应收 | +| 7 | 推送类先于回写类:A103-2 付款申请单查询/A103-4 付款处理查询、A302-4 财务应收单查询 勾稽、A303-5 开票发票查询、A801-3 收票单查询、A301-1 收款单查询 都依赖推送产生的键/单号 | 查询键(paymentNo / srcCode / billNo / serialNo / SKCL码)由推送链写入本地 | +| 8 | A801-4 汇率查询先于 A102-2 财务应付单保存/A302-2 财务应收单保存(如需携带汇率) | 页面维护动作,非代码强制(未维护不传) | + +### 8.3 断链恢复语义(各链路通用模式) + +- **本地失败、远端未落单**:状态保持 unsynced(应付/应收/开票)或 failed(付款申请/收票),可重推。 +- **远端成功、本地回滚**:重推时预检命中幂等回填——应付/应收走 A102-4 财务应付单查询/A302-4 财务应收单查询 防重键、付款申请走 A103-2 付款申请单查询 按 paymentNo、开票走 A303-4 开票申请单查询 按候选 billno(张次级)、收票走 A801-3 收票单查询 按 srcCode(登记 id)、认领走 A301-1 收款单查询 按 sourcebillnumber(⚠️ A301-3 重推会换新码,见 6.2)。 +- **推送成功后状态滞后**:定时任务回写(A103-2 付款申请单查询→paid/closed、A303-5 开票发票查询→invoiced、A801-3 收票单查询→assigned 补偿;A301-1 收款单查询 暂为手动)。 +- **批量语义**:整批预热 → 逐单独立事务 → 单条失败不中断整批(付款批量、收票推送、定时回写均如此)。 + +--- + +## 9. 状态机汇总 + +| 表.字段 | 状态集 | 迁移 | 写入方 | +|---|---|---|---| +| `blade_formal_settlement.kingdee_sync_status` | unsynced / synced | unsynced→(A102-2 财务应付单保存 或 A302-2 财务应收单保存 成功)→synced;失败回滚保持 unsynced;定时勾稽可纠偏 failed→synced(防御性,代码无 failed 写入) | FormalSettlementServiceImpl | +| `blade_payment_application.kingdee_status` | unsynced / synced / paying / paid(终) / closed(终) / failed | unsynced→(A103-1 应付下推生成付款申请/A103-3 预付款申请保存)→synced→(Inpayment)→paying→(Alreadypay)→paid;billstatus=D→closed;推送异常→failed;failed 可被 A103-2 付款申请单查询 勾稽纠偏回 synced | PaymentApplicationServiceImpl + PaymentKingdeeResultTask | +| `blade_invoice_application.kingdee_status` | unsynced / synced / invoiced(终) | unsynced→(A303-1 开票申请单新增 全部张次成功)→synced→(A303-5 开票发票查询 有新发票)→invoiced;失败回滚保持 unsynced(**无 failed 写入**) | InvoiceApplicationServiceImpl + 定时 | +| `blade_invoice_application_sheet.kingdee_bill_no` | 空 / 金蝶单号 | 张次级单号,A303-1 推送或 A303-4 幂等命中时回填 | InvoiceApplicationServiceImpl | +| `blade_invoice_receipt.kingdee_push_status` | unsynced / pushed / assigned(终) / failed | unsynced→(A303-2 收票单批量保存)→pushed→(A302-6 收票指定应付)→assigned;A302-6 失败/发票异常回收→failed;pushed 由定时补偿重发 A302-6(⚠️ 回写匹配缺陷,见 11.2) | InvoiceReceiptServiceImpl + 定时 | +| `blade_customer_archive.kingdee_status` | none / synced / failed / exist(终) | 审批通过→(A902-2 客户保存)→synced(金蝶受理 B);金蝶审核 C 后定时复查→exist;推送失败/A/B→failed | CustomerArchiveServiceImpl + CustomerKingdeeSyncTask | +| `blade_receipt_claim.kingdee_bill_status` ★新增 | none / pushed / approved / received(终) / failed / voided | none→(A301-3 收款认领结果推送)→pushed→(A301-1 收款单查询: C)→approved→(D)→received;A/B→pushed 维持;G/其他→failed;作废→voided+KDCX 冲单号(本地) | ReceiptFlowServiceImpl + ReceiptClaimRecordServiceImpl | +| `blade_kingdee_receipt_flow.claim_status` ★新增 | unclaimed / partial / claimed | 公告同步不覆盖;认领分摊→partial/claimed;全部作废→unclaimed | ReceiptFlowServiceImpl | + +--- + +## 10. 定时任务与配置开关汇总 + +| 任务类 | 方法 | 覆盖接口 | cron(默认) | 开关(默认) | 手动端点 | +|---|---|---|---|---|---| +| PaymentKingdeeResultTask | syncKingdeePaymentResult | ① A103-2 付款申请单查询 + A103-4 付款处理查询 ② A302-4 财务应收单查询 勾稽 ③ A303-5 开票发票查询 | `thirdParty.bfi.payResult.cron` = `0 */10 * * * ?` | `thirdParty.bfi.payResult.syncEnabled` = **false** | `POST /payment-application/sync-kingdee-result`、`POST /formal-settlement/sync-kingdee-ar-result`、`POST /invoice-application/sync-kingdee-invoice-result` | +| PaymentKingdeeResultTask | syncKingdeeReceivedInvoiceResult | A801-3 收票单查询 回写 | `thirdParty.bfi.receivedInvoiceResult.cron` = `0 */10 * * * ?` | `thirdParty.bfi.receivedInvoiceResult.syncEnabled` = **false** | `POST /invoice-receipt/sync-kingdee-result` | +| CustomerKingdeeSyncTask | syncKingdeeCustomerData | A902-1 客户查询 复查 + A902-2 客户保存 重推 | `thirdParty.bfi.customer.cron` = `0 0 2 * * ?` | `thirdParty.bfi.customer.syncEnabled` = **false** | `POST /customer-archive/sync-kingdee` | +| (预留未接) | — | A301-1 收款单查询 认领回写 | — | `thirdParty.bfi.receiptClaimResult.*`(代码未消费) | `POST /receipt-claim-record/query-kingdee-result`(手动) | + +> 所有已接线的定时开关**代码默认关闭**,需 Nacos 显式开启。⚠️ 客商定时任务仍有联调残留过滤(只处理 KS000067,见 11.1-#3),开启前必须移除。收款认领三接口**全部手动触发**(A301-2 sync、A301-3 认领、A301-1 回写),与方案文档「每天定时获取金蝶收款流水」不一致(见 11.3)。 + +--- + +## 11. 代码与三份材料差异 / 风险清单(更新版) + +### 11.1 联调残留(上线前必须处理;对照 09-23 清单标注状态) + +| # | 位置 | 问题 | 状态 vs 09-23 | +|---|---|---|---| +| 1 | KingdeeApBillSyncSupport:299、KingdeeArBillSyncSupport:234、KingdeeReceivedInvoiceSyncSupport:244 | `if(true)` 调试组合块强制物料/计量单位(固定 4 组合,兜底 物流配送 Item-00001080+TON) | ⚠️ **部分解决**:真实映射链路已建成(装卸/国际/国内+运输方式 → 金蝶编码;计量单位档案编码),但调试块仍做**最终覆盖** | +| 2 | KingdeeReceivedInvoiceSyncSupport 调试块 | 收票侧映射表与应付侧不一致,且**未命中组合时随机取一个物料**(`new Random().nextInt`)——同一票每次推送物料可能不同 | ⚠️ **新增风险** | +| 3 | CustomerArchiveServiceImpl:629 | 定时客商同步硬编码 `.eq(customerCode, "KS000067")`,只处理一条 | ❌ **仍在** | +| 4 | CustomerBfiConverter:141 | 客商银行分录 `bankNumber` 硬编码 "001110012009"(todo) | ❌ **仍在**(付款申请侧已改为取客商收款账户真实联行号,仅客商推送残留) | +| 5 | KingdeeApBillSyncSupport / KingdeeArBillSyncSupport / A103-1 分录 / 开票申请 | 项目编码传测试值 `999` | ⚠️ **部分解决**:A103-3 预付款已按项目档案真实编码(无项目才 999);其余仍 999 | +| 6 | KingdeeBfiConstant 遗留常量 | `BFI_AP_BILL_MATERIAL_NUMBER_TEST(SP0324)`、`BFI_AP_BILL_BANKACCOUNT_NUMBER_TEST` 等联调常量仍在(BfiProperties.receivedInvoice.materialNumber 默认值亦为 SP0324) | ⚠️ 无调用方或被覆盖,建议清理 | + +### 11.2 疑似缺陷(建议人工确认) + +| # | 问题 | 证据 | 状态 vs 09-23 | +|---|---|---|---| +| 1 | **A801-3 定时回写按发票号码匹配防重键,而 A303-2 落库 awrp_src_code=收票登记 id** → 回写(异常回收/指定补偿/单号补登)永远匹配不到 TMS 自推单据,静默跳过。09-23 的 243d5be 只修复了推送预检与 A302-6 路径 | InvoiceReceiptServiceImpl(syncKingdeeReceivedInvoiceResult:`map(InvoiceReceipt::getInvoiceNo)` + `matched.get(receipt.getInvoiceNo())`,而预检路径已改 `String.valueOf(receipt.getId())`) | ⚠️ **部分修复(回写路径残留)** | +| 2 | A103-1(应付下推生成付款申请)`awrp_finapbill_entryid` 实际传**主结算单应付单防重键(本地组装)**,而 PaymentApplicationServiceImpl 与 KingdeeApBillSyncSupport 注释仍写「A102-4 反查 awrp_entry_code_p,禁止各接口自行拼接」——注释与代码矛盾;金蝶侧按单据头 srcCode 勾稽付款计划是否成立需联调确认 | PaymentApplicationServiceImpl:962-963 vs :1129 注释;KingdeeApBillSyncSupport:81/:359-361 注释 | ⚠️ **新增待确认** | +| 3 | 应收/开票链路 `failed` 状态无写入路径(回滚后停留 unsynced;轮询集合中的 failed 只能人工改库产生) | FormalSettlementServiceImpl、InvoiceApplicationServiceImpl | ❌ **仍在** | +| 4 | 其他链路推送前不校验客商 `kingdee_status`,客商未推送/未审核也会带编码推单 | KingdeeApBillSyncSupport.queryCustomerCode | ❌ **仍在** | +| 5 | A103-3 预付款分录 `e_bebank_number`(往来银行.编码)取 `entity.getBankName()`(名称字段)——与 A103-1 取联行号的口径不一致,疑似取错字段 | PaymentApplicationServiceImpl:885 vs :969 | ⚠️ **新增待确认** | +| 6 | 收款认领 A301-3:推送成功但本地回滚后重推会**重新生成 awrp_src_code** 产生金蝶重复收款单(联调期以推送失败回滚规避);且 `recpaytype_number/paymenttype/recpayer/e_receivingtype_number` 等字段取值待金蝶侧确认 | ReceiptFlowServiceImpl(09-24 专项文档 §4.1/§7) | ⚠️ **新增待确认** | + +### 11.3 代码与三份材料的口径差异 + +**vs《TMS业财融合方案.docx》(V1.2 方案)** + +| 方案描述 | 代码现状 | +|---|---| +| SC-04「付款申请可分多次下推」 | A103-1 当前**单分录单次下推**(一申请一单,`paymentNo-1`);多次下推未实现 | +| SC-04 预付/尾款两模式、实付=全额−预付抵扣−已付 | 本地金额口径已实现;金蝶侧预付冲应付由金蝶自动处理(代码不传冲抵明细) | +| 附件推送(审批单据/磅单/合同签章随单推金蝶 A902 附件上传) | **未实现**:A902 未在客户端实现,各推送链路均不传附件 | +| SC-05「结算审批时同步录入进项发票…同步生成收票单并自动绑定」 | 流程拆分:收票登记独立审批 → 人工批量 `push-kingdee` 推送(非结算审批时自动) | +| SC-07「每天定时获取金蝶收款流水」 | A301-2 当前**手动按钮触发**,无定时(`receiptClaimResult.*` 开关预留未接) | +| SC-08「按发票张数生成多个金蝶开票申请单,按票面行拆分明细」 | ✅ **已实现**(张次拆分) | +| 汇率「TMS 结算时通过接口获取金蝶汇率记录在结算单据上」 | ✅ **已实现**(A801-4 汇率查询 → 结算单页面维护 → 随 A102-2 财务应付单保存/A302-2 财务应收单保存 推送) | +| 客商「TMS 按机构类别转换伙伴类型再推送」 | `customer_nature` 字典映射;分组 1101/1102 已按落库 customerKind 区分 | + +**vs《TMS业财融合-集成流程图文字版与业务流程梳理》(09-14)** + +| 文字版描述 | 代码现状 | +|---|---| +| 图05/第6节 票池「定时/手动拉取全票池 → 本地镜像 → 查本地镜像选票」 | 已演进:**实时回源 A801-1 全票池发票查询**(无定时拉取),镜像降级为登记存根(09-23 已按此定稿) | +| 图05 状态一览「A303-2/A302-6/A801-3 已编码未联调」 | 已联调推进:A801-3 预检与 A302-6 防重键 bug 已修(243d5be),回写仍带缺陷(11.2-#1) | +| 图06/第7节 收款认领 A301-1/A301-2/A301-3 | ✅ **已接线**(手动触发;公告 claimusers 须与 TMS 用户姓名匹配;多个 A301-3 字段待金蝶确认) | +| 第9节 汇率 A801-4「TMS 结算时获取」 | ✅ 已实现 | +| 第9节 黑名单 A801-5、A901-1 供应商查询、A913 项目、A101-1/2 合同 | ❌ 均未实现(合同/项目/风险走合同合规系统 C 类接口,本期不在 BFI 范围) | +| 第9节 附件 A902 上传 | ❌ 未实现(方案中随单附件推送金蝶暂缺) | + +**vs《2026-09-23 全链路梳理》(上一版总纲)** + +| 09-23 结论 | 当前状态 | +|---|---| +| A301-1/2/3「业务侧无调用」 | ❌ 已过时:收款认领链路接线(业务域五) | +| A801-4「业务侧无调用」 | ❌ 已过时:结算取汇率(业务域七) | +| A102-4 用途④「反查 awrp_entry_code_p,是 A103-1 硬前置」 | ❌ 已过时:反查移除,勾稽键本地组装(11.2-#2) | +| A103-1 分录「逐费用行一条,e_bebank_number=测试常量」 | ❌ 已过时:单分录、联行号取客商收款账户、结算方式显式映射 | +| 开票申请「billno=application_no,一张申请一张金蝶单」 | ❌ 已过时:按张次拆分多单,billno 加张次后缀,A303-4 批量预检 | +| 9.1-#5 customerKind 恒推 1102 | ✅ 已修复(字段落库) | +| 9.2-#1 A801-3 回写匹配缺陷 | ⚠️ 部分修复(预检/A302-6 已修,回写残留) | +| 9.1-#1 物料 SP0324/TON 强制、#2 项目 999、#3 银行硬编码 | ⚠️ 演进(真实映射+调试覆盖并存;见 11.1) | +| 9.1-#3 付款 e_bebank_number 测试值 | ✅ 已修复(联行号);客商 converter 残留 | + +### 11.4 其他已知注意点 + +- 客户端常量两处并存(`bfi/constant/BfiConstant` 与 `transport/constant/KingdeeBfiConstant`),语义重复,改动需双侧核对。 +- `BfiBillSaveResultVO.successCount/failCount` 为 String 类型。 +- token 缓存 `setIfAbsent`:已有缓存时强制取的新令牌不会覆盖旧缓存。 +- 操作码与 URL 资源域不完全对应(A303-2 收票单批量保存/A801-3 收票单查询 挂 `/ap/ap_invoice/`,A303-1 开票申请单新增/A303-4 开票申请单查询 挂 `/sim/`,A301 系列收款认领接口挂 `/cas/`),检索时勿按 URL 前缀猜操作码。 +- 结算单认领(claim-invoices)**仍无保存重查闸门**(前端传票面直接落库,09-23 提案未实现)。 +- 主数据初始化 SQL `kingdee_master_data_init_20260924.sql`(计量单位金蝶编码 + `blade_kingdee_material`)须先执行,物料/单位映射才有正确档案值;认领链路须先执行 `blade_receipt_flow_claim_20260924.sql`。 + +--- + +## 附:本文档与单链路文档的关系 + +- 本文是**总纲更新版**:跨链路顺序、依赖、公共机制、状态机、差异风险汇总,基线为 2026-09-24 代码(`50573e7`)。 +- 单链路细节仍以 `doc/docs/业务流程/` 对应文档为深入参考;其中与代码冲突处一律以本文第 11 节结论 + 当前代码为准。 +- 09-23 版总纲中被本文覆盖的过时结论:A301 系列收款认领接口/A801-4 汇率查询 未调用、A102-4 财务应付单查询 反查、付款申请分录口径、开票单张口径、客商 1102 问题——请以本文为准。 diff --git a/doc/docs/业务流程/2026-09-28-收款认领推送金蝶重构设计(认领本地化-A301-3定时+手动推送-按流水归集).md b/doc/docs/业务流程/2026-09-28-收款认领推送金蝶重构设计(认领本地化-A301-3定时+手动推送-按流水归集).md new file mode 100644 index 00000000..ef4b0bdb --- /dev/null +++ b/doc/docs/业务流程/2026-09-28-收款认领推送金蝶重构设计(认领本地化-A301-3定时+手动推送-按流水归集).md @@ -0,0 +1,258 @@ +# 收款认领推送金蝶重构设计(认领本地化 → A301-3 定时 + 手动推送 → 按流水归集) + +> 适用系统:TMS(blade-transport) +> 对接系统:金蝶云·星瀚(BFI,经 iPaaS 网关直连) +> 覆盖接口:A301-2 收款认领公告查询(不变)、A301-3 收款认领结果推送(**触发时机重构**)、A301-1 收款单查询回写(**轮询对象改造**) +> 前置文档:`2026-09-24-收款认领金蝶全链路业务流程(A301-2公告同步-A301-3认领推送-A301-1收款单回写).md`(下称「旧文档」,描述的是重构前现状) +> 最后更新:2026-09-28(设计稿,未实施) + +--- + +## 1. 为什么要重构 + +旧文档描述的现状(对应代码 `ReceiptFlowServiceImpl.claim()` L353):**每次认领落库后立即在事务内推送 A301-3**。带来三个问题: + +1. **部分认领立即推**:一笔流水分两次认领(40 万 + 60 万),金蝶侧生成两张收款单,司库对账时一笔到账裂成多单。 +2. **来源编码撞码**:`awrp_src_code` 用结算单号拼接,两次认领若关联同一批结算单,编码重复,A301-1 回写按 `sourcebillnumber` 匹配存在歧义(旧文档 §4.1 幂等说明已指出)。 +3. **金额口径混乱**:本地认领上限按 `receipt_amount`(收款金额)控制,而金蝶公告侧有第一轮自动认领后的 `unclaimed_amount`(未认领金额),两个口径互不参照,可能出现本地认领金额与金蝶侧待认领金额对不上的脏数据。 + +## 2. 新设计原则(对旧文档原则的修订) + +| # | 旧原则(旧文档 §1) | 新原则 | +| --- | --- | --- | +| 1 | 公告为准(A301-2 落库流水) | **不变** | +| 2 | 推送先于结果(认领与推送同事务,失败整体回滚) | **废弃**。改为:**认领本地先行,推送异步解耦**——认领动作只落本地库,永不直接调金蝶;推送由定时任务 + 前端手动按钮承担 | +| 3 | 认领公告 id 必填 | **不变**,并扩展:`unclaimed_amount` 同样必填,缺失拦截 | +| 4 | 以金蝶侧实际生成为准 | **不变**,扩展为:**推送前实时对账**——推送前重查 A301-2 拿最新 `unclaimed_amount`,校验通过才推 | +| 5 | 异常回收人工处置(作废不推金蝶) | **不变**,并强化:**已推送流水禁止二次推送**,作废后的金蝶侧冲销一律人工处置 | + +新增两条核心规则(本次重构的业务闸门): + +- **认领完成 = 累计有效认领金额 == 公告未认领金额**(`unclaimed_amount`,即 A301-2 的 `unclaimamount`;作废分摊不计入累计)。只有认领完成的流水才满足推送条件——**不认领完就不推**。 +- **推送单元 = 收款流水**:一条流水只产生一张金蝶收款单,**单条分录、金额 = 全部未推送金额**(累计有效认领,闸门保证等于公告未认领金额);`awrp_src_code` 按分摊结算单号下划线拼接(A301-3 契约口径),一条流水只推送一次,编码不会撞码。 + +## 3. 领域模型与职责划分 + +### 3.1 三张表的分工 + +``` +blade_kingdee_receipt_flow(收款流水) ← 推送的权威载体 + 认领状态 claim_status / 累计认领 claimed_amount(展示缓存) + 公告侧 unclaimed_amount / bill_id / fee ... + 推送状态 push_status / 推送失败原因 push_fail_reason / 推送时间 pushed_time + 推送编码 awrp_src_code / 金蝶收款单号 kingdee_bill_no / 回写状态 kingdee_bill_status + +blade_receipt_claim(认领主单) ← 认领动作的留痕 + 认领人/部门/日期/附件/备注、claim_amount(本次认领金额) + kingdee_bill_no / kingdee_bill_status 仅作冗余展示(由流水级联刷新) + 【去掉】awrp_src_code(移至流水表) + +blade_receipt_claim_settlement(分摊行) ← 累计认领的权威来源 + 累计认领 = Σ(status=1 行的 allocated_receipt_amount) ← 作废行 status=0,天然不计入 +``` + +**权威口径**:累计认领永远以分摊表实时聚合为准(`flowClaimedAmount()`,现有实现),流水上的 `claimed_amount` 只是展示缓存,在认领/作废/同步/推送前校验四个时机重算维护。 + +### 3.2 流水新增字段 + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `push_status` | varchar(20) | 推送状态:`none` 未推送(默认)/ `pushed` 已推送 / `failed` 推送失败 | +| `push_fail_reason` | varchar(1000) | 最近一次推送失败原因(成功后清空) | +| `pushed_time` | datetime | 首次推送成功时间 | +| `awrp_src_code` | varchar(64) | 推送来源编码 = **分摊结算单号下划线拼接**(A301-3 契约口径);推送时生成并落库,分摊不变则重试编码不变(依赖金蝶候选键 `awrp_src_sys+awrp_src_code` 幂等,防重试双单) | + +认领状态 `claim_status` **口径变更**(字段沿用,含义改): + +``` +unclaimed 累计有效认领 = 0 +partial 0 < 累计有效认领 < unclaimed_amount +claimed 累计有效认领 == unclaimed_amount(且 > 0) ← 「认领完成」,唯一可推送状态 +``` + +不再与 `receipt_amount` 比较。前端「剩余可认领」= `unclaimed_amount − 累计有效认领`。 + +`kingdee_bill_status`(回写生命周期)从认领主单上移到流水:`none → pushed →(A301-1 回写)approved / received / failed`;认领主单保留同名冗余字段仅作展示。 + +### 3.3 状态机 + +``` +流水推送状态: + none ──推送成功──▶ pushed ──A301-1 回写──▶ approved ──▶ received(终态) + │ │ └──(A/B/退单)──▶ failed(回写失败) + └──推送失败──▶ failed ──重试(同码)──▶ pushed / 维持 failed + ※ pushed 为终态闸门:任何路径不允许二次推送(作废→重认领→再满足等式也不推) + +流水认领状态(对照 unclaimed_amount,随认领/作废/同步刷新重算): + unclaimed ⇄ partial ⇄ claimed +``` + +## 4. 流程编排 + +### 4.1 认领(POST /receipt-flow/claim,纯本地化) + +改造 `ReceiptFlowServiceImpl.claim()`: + +1. 锁流水后校验:`bill_id` 非空(沿用)+ **`unclaimed_amount` 非空**(缺失拦截「公告未认领金额缺失,请重新同步金蝶收款流水」)。 +2. 结算单侧校验全部不变(审批通过/应收类型/未作废/同项目组织收付款方/单张结算单累计 ≤ 结算金额)。 +3. **流水维度上限改为公告口径**:`累计有效认领 + 本次分摊 ≤ unclaimed_amount`(原为 ≤ receipt_amount),报错文案「累计认领金额不能超过公告未认领金额」。 +4. 本地落库:认领主单(`kingdee_bill_status=none`)+ 分摊行 + 刷新结算单收款汇总 + 更新流水 `claimed_amount/claim_status`(按 3.2 新口径)+ 认领留痕。 +5. **删除 `pushClaimResultToKingdee(...)` 调用**(L353)——认领事务到此结束,不产生任何 BFI 调用。 + +### 4.2 推送(统一入口,定时任务与手动按钮共用) + +新服务方法 `pushClaimToKingdee(Long flowId)`,**方法级事务,推送异常在事务内消化(不外抛),以返回值向调用方报告成败与原因**——保证失败路径的状态落库不被回滚: + +``` +① FOR UPDATE 锁流水 +② 静态闸门(硬校验,不满足直接返回失败): + - bill_id 非空 + - push_status != pushed ←「已推送禁止二次推送」终态闸门 + - 累计有效认领(分摊表实时聚合)> 0 且 == unclaimed_amount(compareTo==0,严格相等) +③ 实时对账:重查 A301-2 拿最新公告 + - 手动按钮:claimusers = 当前登录用户真实姓名 + - 定时任务:按候选流水的认领人姓名(认领主单 claimer_name 去重)逐人查询,§4.3 + - 按 bill_id 匹配本流水 → 刷新 unclaimed_amount / kingdee_claim_status / claim_me + - 公告查不到 → 失败:「金蝶公告不存在或已不在认领范围,请重新同步」 + - 重验:累计有效认领 == 最新 unclaimed_amount,不等 → 失败并提示两侧金额差额 + (不等说明金蝶侧有人认领过或金额有变,需人工核对后作废/补认领) +④ awrp_src_code:按分摊结算单号下划线拼接生成并落库(分摊不变则重试编码不变) +⑤ 组装 A301-3:billid=流水.bill_id,**单条分录**: + e_receivableamt = 全部未推送金额(累计有效认领); + seq / e_corebillno = 第一张结算单id(分摊按结算单id升序,多张取最小) +⑥ 调 pushClaimResult: + 成功 → push_status=pushed、push_fail_reason 清空、pushed_time=now、 + kingdee_bill_no=响应 number(**A301-3 成功响应 number 为空,取回执 keys.awrp_src_code 即来源编码占位**)、 + kingdee_bill_status=pushed; + 级联刷新该流水名下全部未作废认领主单的 kingdee_bill_no/kingdee_bill_status + 失败 → push_status=failed、push_fail_reason=金蝶错误文本汇总(认领数据不回滚) +⑦ 推送动作留痕(blade_receipt_flow_record,operation=push) +``` + +**事务边界说明**:BFI HTTP 调用在事务内,流水行锁覆盖整个推送过程——天然防「定时任务与手动按钮并发双推」(后到者会看到 `push_status=pushed` 或等锁后走静态闸门被拦)。锁粒度为单流水行锁,持锁时长为一次接口调用,可接受。 + +### 4.3 定时任务 ReceiptClaimPushTask + +沿用 `PaymentKingdeeResultTask` 编排模式(task 类只做开关与调度,逻辑收敛在服务方法,返回 summary): + +```java +@Value("${thirdParty.bfi.receiptClaimPush.syncEnabled:false}") // 默认关闭,联调稳定后开启 +@Scheduled(cron = "${thirdParty.bfi.receiptClaimPush.cron:0 */10 * * * ?}") +``` + +扫描对象:`bill_id` 非空 AND `push_status IN (none, failed)` AND `claim_status = claimed`(缓存口径预筛缩小集合)。 + +**A301-2 重查的认领人问题**:A301-2 的 `claimusers` 必填且为单个用户姓名(`BfiClaimAnnounceQueryDTO` L50-52),定时任务没有登录用户上下文。处理: + +1. 取候选流水的有效认领主单 `claimer_name` 去重集合; +2. **按认领人串行处理**:先调 `BfiUtil.queryAllPages(queryClaimAnnounce)` 查该认领人的公告(时间窗沿用 `thirdParty.bfi.claimAnnounce.months`,默认 6 个月),推完其名下全部流水后再处理下一个认领人; +3. 该认领人名下流水按公告 `id == 流水.bill_id` 匹配刷新,再走 §4.2 ③-⑦; +4. 单流水推送失败 try/catch 独立消化,不影响同批其他流水;同一流水被多个认领人认领时只在首个覆盖它的认领人处推送一次。 + +> 联调确认项:`claimusers` 若支持逗号分隔多人,可合并为一次查询(见 §8)。 + +### 4.4 手动推送按钮(收款流水页) + +- 行按钮「推送金蝶」:`POST /receipt-flow/push-kingdee`(body: flowId),按钮权限 `receipt_flow_push`。 +- 前端可用条件(展示层控制,后端硬校验为准):`claim_status=claimed` 且 `push_status != pushed` 且 `bill_id` 非空。 +- 后端走 §4.2 同一入口,用当前用户姓名重查公告;若当前用户不在公告认领范围内(查不到),提示「当前用户不在该公告认领范围内,请由认领人操作或重新同步后重试」。 +- 列表新增「推送状态」「推送失败原因」列;详情弹窗展示推送留痕。 +- 本期不做批量推送按钮(定时任务已覆盖批量场景)。 + +### 4.5 A301-1 回写改造(POST /receipt-claim-record/query-kingdee-result) + +`ReceiptClaimRecordServiceImpl.refreshKingdeeResult()` 轮询对象由认领主单改为**流水**: + +- 扫描:`push_status = pushed` 且 `kingdee_bill_status IN (pushed, approved)` 的流水; +- 匹配:金蝶收款单 `sourcebillnumber == 流水.awrp_src_code`(一流水一码,无歧义); +- 回写:`flow.kingdee_bill_status` 按状态映射推进(A/B→pushed、C→approved、D→received、其他→failed);`kingdee_bill_no` 以金蝶真实收款单号**覆盖**推送时的来源编码占位,并级联刷新名下未作废认领主单的冗余字段; +- 定时接入复用现有任务风格(`thirdParty.bfi.receiptClaimResult.*`,本期仍可手动触发)。 + +### 4.6 作废(不变 + 两点明确) + +`voidClaim()` 现有逻辑保留: + +1. **累计认领不考虑作废**:作废行 `status=0`,分摊聚合天然排除——与「累计认领不考虑作废的」要求一致,无需改代码;作废后重算流水 `claimed_amount/claim_status`(口径按 §3.2)。 +2. **已推送的认领不允许作废**:流水 `push_status=pushed` 时作废直接拦截(后端硬校验 + 前端按钮隐藏),金蝶侧冲销/调整由人工处置;作废仅对未推送流水生效,永不触发推送、不生成冲单(金蝶单号/状态保留原值)。终态闸门继续兜底:即使数据漂移使等式再次满足,`push_status=pushed` 也阻止二次推送。 + +### 4.7 重新同步(A301-2 sync 补一处) + +同步 upsert 刷新 `unclaimed_amount` 后,**对值发生变化的流水重算 `claim_status`**(对照分摊聚合),保证公告漂移后认领状态不失真。 + +## 5. 边界场景与防重汇总 + +| 场景 | 行为 | +| --- | --- | +| 定时任务与手动按钮并发推同一流水 | 流水行锁串行化 + `push_status != pushed` 静态闸门,不会双推 | +| 推送超时但金蝶实际已生成收款单 | 本地记 failed;重试时分摊不变则 `awrp_src_code` 不变,金蝶候选键幂等,不产生重复收款单 | +| 推送成功后作废一笔认领、再补认领到等式满足 | 终态闸门拦截,不二次推送;金蝶侧冲销人工处置 | +| 公告侧 unclaimed_amount 变小(他人在金蝶认领) | 推送前实时对账发现差额 → 拦截并提示差额,人工作废多余本地认领后重推 | +| 公告侧 unclaimed_amount 变大 | 用户补认领到等式满足后推送(认领上限随新值放开) | +| unclaimamount = 0 的公告 | 认领上限为 0,不可认领;累计认领 > 0 的推送前置条件天然排除空推 | +| 手动推送者不在公告认领范围 | A301-2 按其姓名查不到公告 → 拦截提示,改由认领人操作 | + +防重键沿用旧文档 §6.3,变更点:`awrp_src_code` 由「认领主单、每次认领各自拼接」改为「流水、按该流水全部分摊结算单号拼接、仅推送一次」,A301-1 匹配键随之移到流水上。 + +## 6. 前端改动汇总(tms-erp-web) + +| 页面 | 改动 | +| --- | --- | +| `receipt-flow.vue` | ① 认领按钮可用条件:`剩余可认领 = unclaimed_amount − 累计认领 > 0`(原按 receipt_amount);② 新增「推送金蝶」行按钮(条件 §4.4);③ 列表加「推送状态/推送失败原因」列;④ 详情展示推送留痕 | +| `receipt-flow-form.vue` | 分摊上限与提示文案改为公告未认领金额口径 | +| `receipt-claim-record.vue` | 不变(金蝶状态列继续展示冗余字段;「查询金蝶认领结果」按钮保留) | +| `api/payment/receiptFlow.js` | 新增 `pushKingdee` | + +## 7. SQL 变更 + +新增幂等脚本 `doc/sql/transport/blade_receipt_flow_push_20260928.sql`,沿用仓库惯例(`information_schema` 探测 + `PREPARE/EXECUTE` 动态 DDL),**支持重复执行**:0924 脚本执行过与否均可跑,任意顺序、任意次数,已存在的列/索引自动跳过、已不存在的列/索引自动跳过删除。 + +| # | 对象 | 变更 | 说明 | +| --- | --- | --- | --- | +| 1-6 | `blade_kingdee_receipt_flow` | ADD COLUMN `push_status`(默认 none)、`push_fail_reason`、`pushed_time`、`awrp_src_code`、`kingdee_bill_no`、`kingdee_bill_status`(默认 none) | 推送域字段上收至流水 | +| 7-8 | `blade_receipt_claim` | DROP KEY `idx_receipt_claim_src_code`、DROP COLUMN `awrp_src_code` | 推送编码上移至流水;0924 未执行过则本就无此列,探测后自动跳过 | +| 9-10 | `blade_kingdee_receipt_flow` | ADD KEY `idx_kingdee_receipt_flow_push_scan` (tenant_id, push_status, claim_status)、`idx_kingdee_receipt_flow_src_code` (tenant_id, awrp_src_code) | 定时任务扫描 + A301-1 回写匹配 | +| 11 | `blade_menu` | INSERT 按钮权限 `receipt_flow_push`(父级 2090000000000001260 收款流水,id 2090000000000001264,ON DUPLICATE 幂等) | 手动推送按钮 | + +0924 脚本(`blade_receipt_flow_claim_20260924.sql`)保持原样不动,两个脚本无执行顺序依赖。上线顺序:先执行 0924(若未执行)→ 执行 0928 → 启动服务。 + +## 8. 待联调确认项 + +| # | 事项 | +| --- | --- | +| 1 | `claimusers` 是否支持逗号分隔多人(支持则定时任务合并为一次 A301-2 查询) | +| 2 | A301-3 字段沿用旧文档 §7 待确认清单(recpaytype_number / paymenttype / recpayer / e_corebilltype / e_corebillno) | +| 3 | `unclaimamount` 是否已扣手续费(fee)口径——本方案等式直接对照 `unclaimamount` 本身,无论是否含费均自洽,但需确认推送后金蝶侧 `unclaimamount` 归零的时效,用于判断重查窗口 | +| 4 | 同一公告在金蝶侧被人工认领部分金额后,A301-2 返回的 `unclaimamount` 更新时效(影响实时对账的准确性预期) | + +## 9. 与旧文档的差异对照 + +| 环节 | 旧(2026-09-24) | 新(本设计) | +| --- | --- | --- | +| 认领事务 | 落库后同事务立即推 A301-3,失败整体回滚 | 纯本地落库,不调 BFI | +| 推送触发 | 每次认领动作 | 定时任务(`receiptClaimPush.*` 开关)+ 手动按钮,双入口同一服务方法 | +| 推送条件 | 无(认领即推) | 累计有效认领 == 公告未认领金额(推送前重查 A301-2 实时对账) | +| 推送粒度 | 每次认领一张金蝶收款单 | 一条流水一张金蝶收款单(单条分录,金额=全部未推送金额) | +| awrp_src_code | 认领主单上,每次认领各自按结算单号拼接(多流水分摊到同一批结算单时撞码) | 流水上,按该流水全部分摊结算单号拼接,一条流水仅推送一次,不再撞码 | +| 认领上限/认领完成口径 | receipt_amount | unclaimed_amount | +| 推送状态/失败原因 | 无(失败即回滚无痕) | 流水上 push_status / push_fail_reason / pushed_time,失败可重试可追溯 | +| A301-1 轮询对象 | 认领主单 | 流水(级联刷新认领主单冗余字段) | +| 二次推送 | 可能(作废重认领再推) | 禁止(终态闸门) | + +## 10. 代码改动索引(已实施,2026-09-28) + +| 环节 | 位置 | 改动 | +| --- | --- | --- | +| 认领 | `ReceiptFlowServiceImpl.claim()` | 上限口径改公告未认领金额(缺失/为0拦截);删除同步推送调用,事务内只落本地 | +| 推送 | `IReceiptFlowService.pushClaimToKingdee(flowId)` / `pushClaimByTask()` | 新增。共用 `pushClaimLocked`(`TransactionTemplate` 编程式事务,流水行锁覆盖闸门→对账→推送→落库,防并发双推);失败不抛异常跨事务边界,以 `ReceiptClaimPushResult` 返回 | +| 定时任务 | `task/ReceiptClaimPushTask.java`(新增) | Nacos 开关 `thirdParty.bfi.receiptClaimPush.syncEnabled/cron`(默认关闭、每10分钟);候选=认领完成且 push_status ∈ (none, failed),按认领人姓名去重逐人重查 A301-2 | +| 推送报文 | `buildClaimPushDTO` | billid=流水.bill_id;**单条分录**:e_receivableamt=全部未推送金额(累计有效认领),seq/e_corebillno=第一张结算单id;awrp_src_code=分摊结算单号下划线拼接 | +| 手动接口 | `ReceiptFlowController.pushKingdee`(`POST /receipt-flow/push-kingdee`) | 成功返回金蝶收款单号,失败 R.fail(原因);按钮权限 `receipt_flow_push` | +| 回写 | `ReceiptClaimRecordServiceImpl.refreshKingdeeResult()` | 轮询对象改流水(push_status=pushed 且回写状态 pushed/approved),按流水 awrp_src_code 匹配,级联刷新认领主单冗余字段 | +| 作废 | `ReceiptClaimRecordServiceImpl.voidClaim()` | claim_status 重算口径改对照公告未认领金额;**不再生成本地 KDCX 冲单号**(金蝶单号/状态保留原值);**已推送流水(push_status=pushed)作废拦截** | +| 同步 | `ReceiptFlowServiceImpl.sync()` + 共享 `queryClaimAnnounces` | unclaimed_amount 刷新后按新口径重算 claim_status;公告查询方法供 sync/手动推送/定时任务共用 | +| 常量 | `KingdeeBfiConstant` | 新增 RECEIPT_CLAIM_PUSH_NONE/PUSHED/FAILED、RECEIPT_CLAIM_BILL_NONE | +| 实体/VO | `KingdeeReceiptFlow`(推送域6字段)、`ReceiptClaim`(删 awrpSrcCode)、`ReceiptClaimMapper.xml`(删列)、`ReceiptFlowVO/Wrapper`(pushStatusName/kingdeeBillStatusName、剩余可认领改公告口径)、`ReceiptClaimPushResult`(新增 DTO) | — | +| 详情留痕 | `ReceiptFlowServiceImpl.detail()` | claimRecords 含 claim + push 两类动作记录 | +| 批量化 | `FormalSettlementServiceImpl.refreshPaymentSummaries(Collection)`(新增)+ `calculatePaymentSummary` 拆纯计算重载 | 固定数次批量查询完成 N 张结算单收款汇总重算(关系/付款申请/申请状态/预收来源/认领分摊各一次 IN 查询 + updateBatchById);单笔 `refreshPaymentSummary` 与 `refreshPaymentSummariesForPreSettlement` 复用同一口径 | +| 批量化 | `claim()` / `voidClaim()` / `settlementCandidates()` / `sync()` | ① 结算单锁行改一次 `IN ... ORDER BY id FOR UPDATE`(与逐单加锁顺序一致防死锁);② 结算单累计认领额一次聚合查询(候选单列表页原先每页最多 200 次查询);③ 作废分摊行改单条 UPDATE(实体携带 status 保留审计字段填充);④ 同步留痕改 `Db.saveBatch` 批量插入 | +| 前端 | `receipt-flow.vue`(推送金蝶按钮+canPush、推送状态/失败原因/金蝶单号/金蝶状态列、详情推送字段)、`receipt-flow-form.vue`(剩余可认领金额字段、上限口径)、`receipt-flow-batch-form.vue`(批量口径)、`option/payment/receiptFlow.js`(新列)、`api/payment/receiptFlow.js`(pushKingdee) | — | diff --git a/doc/docs/业务流程/2026-09-28-收款认领流程(业务版).md b/doc/docs/业务流程/2026-09-28-收款认领流程(业务版).md new file mode 100644 index 00000000..0c51b815 --- /dev/null +++ b/doc/docs/业务流程/2026-09-28-收款认领流程(业务版).md @@ -0,0 +1,110 @@ +# 收款认领流程(业务版) + +> 业务域:图06「收款查询及认领(回款认领)」的 TMS 二次认领环节 +> 核心规则:**认领只在本系统登记,不同步金蝶;一笔流水认领完成后,才推送金蝶生成收款单——不认领完就不推。** +> 最后更新:2026-09-28 + +--- + +## 一、认领的对象:收款流水从哪来 + +金蝶司库收到银行收款流水后,先自动认领到客户维度,剩余未落到合同/结算单的部分形成**未认领金额**。业务人员在 TMS「收款流水」页面点「手动同步流水」,把这些公告同步成本系统的收款流水——每条流水带一个**公告未认领金额**,这就是后续认领的额度基准(重复同步不会产生重复流水,金额有变化会刷新)。 + +--- + +## 二、认领操作流程 + +**入口**:收款流水列表/详情的「认领」按钮——流水还有剩余可认领额度(公告未认领金额 − 累计认领)时可操作。 + +**操作步骤**: + +| 步骤 | 操作 | 规则 | +| --- | --- | --- | +| 1. 选结算单 | 选择一张或多张应收正式结算单 | 须审批通过、未作废;多张须同一项目、同一组织、同一收付款方 | +| 2. 录分摊金额 | 逐张结算单录入本次认领的收款金额 | ① 单张结算单:累计被认领金额 ≤ 其结算金额;② 整条流水:累计认领金额 ≤ 公告未认领金额(只认领金蝶侧缺口) | +| 3. 确认 | 提交认领 | 生成一条认领记录(含分摊明细),刷新流水认领进度 | + +**关键规则**: + +1. 确认后认领只在本系统登记,**不与金蝶发生任何交互**。 +2. 一条流水**允许多次认领**(部分认领):今天认 40 万给 A 结算单,明天再认 60 万给 B 结算单,直到累计认领等于公告未认领金额。 + +--- + +## 三、认领状态 + +### 流水认领状态(认领进度) + +| 状态 | 含义 | +| --- | --- | +| 未认领 | 还没有任何有效认领 | +| 部分认领 | 累计认领 < 公告未认领金额 | +| **认领完成** | 累计认领 = 公告未认领金额(正好补齐金蝶侧缺口) | + +- 累计认领金额 = 该流水下所有**有效**认领分摊之和,**作废的认领不计入**; +- 剩余可认领金额 = 公告未认领金额 − 累计认领金额。 + +### 认领记录状态 + +| 状态 | 含义 | +| --- | --- | +| 已认领 | 有效认领,金额计入流水累计 | +| 已作废 | 不再计入累计,流水认领进度相应回退 | + +--- + +## 四、认领完成后的推送(认领流程的出口) + +**推送条件(闸门)**: + +1. 流水**认领完成**——累计认领 = 公告未认领金额,**不认领完就不推**; +2. 该流水**尚未推送过**——已推送的流水不允许二次推送。 + +**推送方式(两个入口,同一套规则)**: + +- 定时任务自动推送:系统定期扫描满足条件的流水自动推; +- 手动推送:流水页面「推送金蝶」按钮,条件不满足时置灰。 + +**推送前对账**:推送前先到金蝶重查该公告的**最新未认领金额**,与本地累计认领核对——一致才推;不一致(如他人在金蝶侧手工认领过)则拦截并提示差额,人工核对作废或补认领后再推。 + +**推送结果**: + +| 结果 | 表现 | +| --- | --- | +| 成功 | 金蝶生成**一张收款单**(一笔流水一张单,多张结算单是该单下的多条分摊明细);流水推送状态置"已推送",回填金蝶收款单号 | +| 失败 | 推送状态置"推送失败"并记录失败原因;**本地认领保留不回滚**;排除原因后可重新推送,重试不会在金蝶产生重复单据 | + +### 流水推送状态 + +| 状态 | 含义 | 后续 | +| --- | --- | --- | +| 未推送 | 未满足推送条件或尚未轮到推送 | 继续认领 / 等待定时任务或手动推送 | +| 已推送 | 金蝶已生成收款单 | 等待金蝶处理结果回写(审核/收款状态由回写推进);不再接受二次推送 | +| 推送失败 | 上次推送被金蝶拒绝或异常 | 按失败原因整改后重新推送 | + +> 认领记录页可随时手动「查询金蝶认领结果」,回写金蝶收款单号与处理状态(已推送 → 审核通过 → 已收款)。 + +--- + +## 五、作废认领 + +1. 仅认领人本人可作废自己的认领记录;作废后该笔分摊不再计入累计,流水认领状态相应回退(认领完成 → 部分认领/未认领)。 +2. **已推送金蝶的认领不允许作废**(按钮不显示,后端拦截)——金蝶侧收款单已生成,冲销/调整由人工在金蝶处置。 +3. **作废永远不会触发推送**:作废仅对未推送流水生效,纯本地生效,无金蝶影响;补认领到认领完成后仍可正常推送。 + +--- + +## 六、典型场景 + +1. **一次认领完成**:未认领金额 100 万,一次认领到 2 张结算单(60 万 + 40 万)→ 认领完成 → 推送,金蝶生成一张 100 万收款单。 +2. **分两次认领完成**:先认 40 万(部分认领,不推金蝶),再认 60 万补齐 → 认领完成 → 推送,仍是一张收款单含两笔分摊。 +3. **金额不一致被拦截**:本地累计 100 万,推送前金蝶重查最新未认领只剩 80 万 → 拦截提示差额 20 万 → 作废多余认领后再推。 +4. **推送失败重试**:因结算单尚未同步金蝶应收被拒 → 记录失败原因 → 补齐前置后重推,不产生重复收款单。 + +--- + +## 七、注意事项 + +1. **结算单前提**:认领的结算单须已完成金蝶应收侧同步(有金蝶应收单号),推送时金蝶才能按应收单核销。 +2. **认领人前提**:金蝶公告的认领人姓名须与 TMS 用户真实姓名一致,否则同步不到流水;推送前对账也要求操作者在公告认领范围内(定时任务按各认领人自动查询,不受单个人限制)。 +3. **两个口径不要混**:「公告未认领金额」是金蝶侧待认领缺口(额度与认领完成的判定基准);「累计认领金额」是本系统登记的有效认领合计。认领完成 = 两者相等。 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 new file mode 100644 index 00000000..130d952c --- /dev/null +++ b/doc/docs/业务流程/2026-09-29-预付款申请A103-3金蝶链路补齐方案(e_bebank_number统一-消失解锁-回写默认开启).md @@ -0,0 +1,169 @@ +# 预付款申请(A103-3)金蝶链路补齐方案:e_bebank_number 统一、A103-2 消失解锁、回写默认开启 + +> 日期:2026-09-29 +> 关联文档: +> - `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-17-付款管理金蝶付款结果定时回写(A103-2付款申请单查询+A103-4付款处理查询)技术文档.md` +> - `doc/docs/业务流程/2026-09-18-付款管理金蝶同步全链路逻辑梳理(推送链路与回写链路连贯性).md` + +## 1. 背景与范围 + +对照方案文档图03 对 A103-3 预付款路径做金蝶侧缺口盘点,本方案补齐三项(合同合规系统相关事项另期处理): + +| # | 缺口 | 原状 | 本方案 | +|---|---|---|---| +| 1 | A103-3 分录 `e_bebank_number`(往来银行.编码)取 `bankName`(银行名称字段) | 与 A103-1 取客商收款账户联行号的口径不一致,疑似取错字段 | 统一取联行号,未维护即拦截 | +| 2 | 金蝶侧反审核删除付款申请单后,TMS 无感知、无恢复路径 | A103-2 查不到仅 `continue`,本地停留 synced/paying;状态门禁禁止重推 → 单据永久卡死 | 连续两轮查询消失 → `unlockBill()` 解锁置失败,交人工重推 | +| 3 | 定时回写任务默认关闭 | 代码 `@Value` 默认 false(注释却写"默认开启"),三套 Nacos 配置默认 false,上线必须手工开启 | 代码与 Nacos 默认均改为开启,注释纠正 | + +不改动范围:A103-3 推送报文其余字段、三层幂等门禁、A902 附件链路、A103-4 付款记录回写、履约数据联动(均已就绪)。 + +## 2. 改造一:A103-3 `e_bebank_number` 与 A103-1 统一口径 + +### 2.1 变更内容 + +`PaymentApplicationServiceImpl#pushBfiPayApply`: + +| 项 | 变更前 | 变更后 | +|---|---|---| +| 取值 | `entity.getBankName()`(付款申请"收款银行名称"文本) | `bfiBebankNumber(entity, context)` | +| 实际含义 | 银行名称(非编码,金蝶档案无法匹配) | 付款申请所选客商收款账户(`blade_customer_receipt_account`)**联行号 `cnapsCode`** | +| 缺失行为 | 不校验,直接传名称 | 收款账户不存在 / 未维护联行号 → `ServiceException` 拦截推送(与 A103-1 同口径) | + +```java +// 往来银行.编码:与 A103-1 统一口径,取付款申请所选客商收款账户联行号(未维护即拦截) +entry.setEBebankNumber(this.bfiBebankNumber(entity, context)); +``` + +### 2.2 取值链路(与 A103-1 完全一致) + +1. 付款申请选择了收款账户(`receiptAccountId`)→ 按账户 id 直查,批量同步经 `KingdeeSyncContext.receiptAccountCache()` 复用; +2. 未选账户 → 按合同乙方客商 + 收款账号兜底匹配(默认账户优先); +3. `cnapsCode` 为空 → 拦截:"收款账户未维护联行号,请先在客商档案维护收款信息联行号"。 + +### 2.3 影响 + +- **新增推送前置条件**:预付款推送前必须维护收款账户联行号。存量已 synced 单据不受影响(状态门禁不会重推);failed 待重推单据需先补联行号。 +- 解锁重推(改造二)产生的"新单"同样走该口径,与金蝶侧往来银行档案编码匹配。 + +## 3. 改造二:A103-2 消失解锁兜底(ADR-0005) + +### 3.1 业务问题 + +图03 异常回收分支:财务共享驳回后,金蝶侧对该付款申请单**反审核 + 删除**,TMS 需解锁并允许重新推送。此前 TMS 只有正向回写:A103-2 查不到该单仅跳过,本地停留 synced/paying,且 `doSyncKingdee` 状态门禁禁止 synced/paying 重推——单据卡死,只能改库解救。 + +B901 UNLOCK 事件通知属合同系统对接域,本期不建设;按 ADR-0005 约定,A103-2 轮询兜底与 B901 共用同一 `unlockBill()` 方法,B901 后续接入时零改动复用。 + +### 3.2 判定规则:"连续两轮查询消失" + +| 要素 | 规则 | +|---|---| +| 判定对象 | 本轮 A103-2 批量查询中未查得的、本地状态为 **synced / paying** 的单据 | +| 健康前提 | 同批次其余付款申请号正常返回(A103-2 服务整体故障时整轮抛异常,不做消失判定) | +| 排除对象 | failed(金蝶查不到属正常态,等待人工重推)、终态 paid/closed(不参与轮询)、paymentNo 为空(防御) | +| 计数存储 | Redis `transport:payment-application:kingdee-pay-miss:{付款申请id}`,每轮未查得 `INCR`,TTL 2100s(覆盖 3 个 10 分钟回写周期;任务中断超时后计数自然过期,需重新累计,方向保守) | +| 查得即清零 | A103-2 查得该单 → `DEL` 计数键 | +| 解锁阈值 | 连续计数 ≥ 2 轮 → 调用 `unlockBill(id, "A103-2 连续N轮查询消失")` | +| Redis 异常 | 放弃本轮判定(解锁会清空金蝶单号,属破坏性动作,宁可延迟不可误判) | + +### 3.3 `unlockBill()` 语义(ADR-0005,接口方法,B901 可直接复用) + +前置:仅 synced / paying 生效;failed 与终态单据自动跳过。 + +动作(单据级事务,条件更新防并发): + +1. 清除消失计数键; +2. `kingdee_status`:synced/paying → **failed(生成失败)**; +3. `kingdee_bill_id`、`kingdee_bill_no` **清空**(原值 warn 日志留档;金蝶侧单据已删除,原内码对 A902 附件、A103-4 勾稽均已失效); +4. **不自动重推**——金蝶反审核删除大概率伴随驳回意见,原样重推等于无视驳回(ADR-0005),置 failed 回人工。 + +### 3.4 重推闭环与误判安全网 + +解锁后单据进入 failed,与"推送异常"恢复路径完全合并: + +```mermaid +sequenceDiagram + participant T as TMS 定时回写(A103-2) + participant R as Redis 计数 + participant K as 金蝶(星瀚) + participant U as 人工 + + T->>K: A103-2 按 paymentNo 批量查询 + K-->>T: 某单未查得(同批其余正常) + T->>R: INCR 消失计数(第1轮,仅告警) + T->>K: 下一轮再查仍未见 + T->>R: INCR 计数 ≥ 2 + T->>T: unlockBill():status=failed、清单号/内码(日志留档) + U->>U: 核实金蝶确已删除(驳回原因) + U->>T: 「同步金蝶」重新推送 + T->>K: A103-2 勾稽预检(failed 恢复场景) + alt 金蝶侧单据实际存在(误判) + K-->>T: 命中 → 回填单据id/编号,跳过推送(不产生重复单据) + else 确已删除 + K-->>T: 未命中 → A103-3 重新保存,生成新单 + T->>K: A902 附件上传到新单 + end +``` + +要点:**即使误判解锁,重推链的 A103-2 勾稽预检也能兜底**——预付款路径 failed 恢复场景必经 `reconcileExistPayApply`,正式路径推送前统一预检,命中即回填跳过,不会产生重复单据。 + +### 3.5 涉及代码 + +| 位置 | 内容 | +|---|---| +| `IPaymentApplicationService#unlockBill(Long id, String source)` | 新增接口方法(B901 接入时复用) | +| `PaymentApplicationServiceImpl#unlockBill` | 解锁实现(事务 + 条件更新) | +| `PaymentApplicationServiceImpl#handlePayApplyMissed` | 消失轮次判定(回写主流程接入) | +| `executePaymentResultSync` ① 循环 | `payApply == null` → `handlePayApplyMissed`;查得 → `clearPayApplyMissKey` | + +## 4. 改造三:定时回写默认开启 + +| 位置 | 变更 | +|---|---| +| `PaymentKingdeeResultTask` | 注释纠正为"代码默认关闭(安全兜底),由 Nacos 按环境显式开启"(`@Value` 兜底默认保持 `false`) | +| `doc/nacos/blade-dev.yaml` / `blade-prod.yaml` / `third-party-api.yaml` | `syncEnabled: ${BFI_PAY_RESULT_SYNC_ENABLED:true}`——**环境配置默认开启**,未部署 Nacos 配置时代码兜底关闭 | + +- 调度频率不变:`0 */10 * * * ?`(每 10 分钟)。 +- 环境变量 `BFI_PAY_RESULT_SYNC_ENABLED=false` 仍可显式关闭(灰度/演练用)。 +- 消失解锁依赖该任务运行,开关关闭期间兜底判定同步停摆(预期行为)。 + +## 5. 状态机全景(变更后) + +``` +unsynced ─A103-1/A103-3─▶ synced ─Inpayment─▶ paying ─Alreadypay─▶ paid(终) + │ ▲ │ + │ └─ A103-2 勾稽纠偏 ──┘ + ├─ 连续两轮查询消失 / B901 UNLOCK ─▶ failed ─人工重推─▶ synced +推送异常 ─────────────────────────────────────────────────────▶ failed +billstatus=D ──────────────────────────────────────────────────▶ closed(终) +``` + +- 解锁后 `kingdee_bill_id/no` 清空 → A103-2/A103-4 后续轮询按 paymentNo 重新勾稷新单; +- failed 纳入轮询的既有纠偏逻辑与解锁路径互不冲突(failed 查不到不计数、查到回填 synced)。 + +## 6. 配置项汇总 + +| 配置 | 默认 | 说明 | +|---|---|---| +| `thirdParty.bfi.payResult.syncEnabled` | Nacos 配置 `true`;代码兜底 `false` | 回写任务总开关,关闭则状态回写与消失解锁同时停摆 | +| `thirdParty.bfi.payResult.cron` | `0 */10 * * * ?` | 回写周期,同时决定消失解锁的轮次间隔 | +| `thirdParty.bfi.payResult.lockLeaseSeconds` | `1800` | 回写 single-flight 锁租约 | +| Redis 键 `transport:payment-application:kingdee-pay-miss:{id}` | TTL 2100s | 消失轮次计数,无配置项(常量) | + +## 7. 验证要点 + +1. **联行号拦截**:收款账户未维护联行号的预付款点击"同步金蝶" → 报"收款账户未维护联行号"且不产生金蝶单据;维护后重推成功,A103-2 查回的 `e_bebank_number` 为联行号。 +2. **消失解锁**:推送成功(synced)→ 金蝶侧反审核并删除该付款申请单 → 等待 ≥2 个回写周期 → 日志出现"第1轮/连续2轮",单据变 failed、`kingdee_bill_id/no` 清空。 +3. **误判回填**:解锁后金蝶侧单据实际存在(构造 A103-2 两次查询异常)→ 人工重推 → 勾稽命中回填,金蝶侧不出现重复单据。 +4. **开关回归**:`BFI_PAY_RESULT_SYNC_ENABLED=false` 环境变量能覆盖默认值关闭任务。 + +## 8. 遗留事项(不在本方案范围) + +| # | 事项 | 状态 | +|---|---|---| +| 1 | B901 UNLOCK 回调接入(解锁延迟从 ≤2 个回写周期缩短为事件即时) | 待建设,复用 `unlockBill()` 零改动 | +| 2 | closed 终态(金蝶驳回/关闭)票据额度释放与待处理提醒 | 未实现 | +| 3 | A103-3 预付款不传 `awrpSrcConNo`(合同号)是否符合金蝶勾稽要求 | 待联调确认 | +| 4 | A103-2 查询未传 modifytime 时间窗(接口约束 ≤1 个月)是否放行 | 待联调确认 | diff --git a/doc/docs/业务流程/2026-09-30-金蝶(星瀚)BFI定时任务逻辑全梳理(付款回写-应收勾稽-开票回写-收票回写-客商同步-收款认领推送).md b/doc/docs/业务流程/2026-09-30-金蝶(星瀚)BFI定时任务逻辑全梳理(付款回写-应收勾稽-开票回写-收票回写-客商同步-收款认领推送).md new file mode 100644 index 00000000..f3045260 --- /dev/null +++ b/doc/docs/业务流程/2026-09-30-金蝶(星瀚)BFI定时任务逻辑全梳理(付款回写-应收勾稽-开票回写-收票回写-客商同步-收款认领推送).md @@ -0,0 +1,209 @@ +# 金蝶(星瀚)BFI 定时任务逻辑全梳理(付款回写 + 应收勾稽 + 开票回写 + 收票回写 + 客商同步 + 收款认领推送) + +> 整理日期:2026-09-30 +> 依据:当前代码(git 基线 `2e91e11` 收款认领本地化提交),代码位置 `blade-service/blade-transport/src/main/java/org/springblade/transport/task/` 与各服务实现。 +> 关联文档:`2026-09-27-金蝶(星瀚)BFI接口调用全链路梳理(按业务域汇总·更新版).md`(总纲)、`2026-09-27-blade-transport定时任务全梳理(金蝶客商同步-金蝶结果回写-收票回写).md`(上一版,**未收录收款认领推送任务,行号已过时,以本文为准**)。 +> 接口名称口径与总纲一致:编号后标注金蝶侧接口名称。 + +--- + +## 1. 任务总览 + +task 目录共 **3 个任务类、4 个调度方法**,覆盖 6 条金蝶(星瀚)链路的回写/同步/推送。调度由 `TransportApplication` 的 `@EnableScheduling` 开启;每个方法 = Nacos 开关 + cron 表达式,业务逻辑全部收敛在服务层方法(任务类只做开关判断、异常捕获与中文摘要日志),定时与手动端点共用同一服务方法。 + +| 调度方法 | 开关(thirdParty.bfi.*,代码默认) | 默认 cron | 服务方法(代码位置) | 覆盖接口 | 手动端点 | +|---|---|---|---|---|---| +| `PaymentKingdeeResultTask.syncKingdeePaymentResult` | `payResult.syncEnabled`(false) | `0 */10 * * * ?` | `PaymentApplicationServiceImpl.syncKingdeePaymentResult`(:644,编排 3 个子流程) | ① A103-2 付款申请单查询 + A103-4 付款处理查询 ② A302-4 财务应收单查询 勾稽 ③ A303-5 开票发票查询 | `POST /payment-application/sync-kingdee-result`、`POST /formal-settlement/sync-kingdee-ar-result`、`POST /invoice-application/sync-kingdee-invoice-result` | +| `PaymentKingdeeResultTask.syncKingdeeReceivedInvoiceResult` | `receivedInvoiceResult.syncEnabled`(false) | `0 */10 * * * ?` | `InvoiceReceiptServiceImpl.syncKingdeeReceivedInvoiceResult`(:968) | A801-3 收票单查询(回写) | `POST /invoice-receipt/sync-kingdee-result` | +| `CustomerKingdeeSyncTask.syncKingdeeCustomerData` | `customer.syncEnabled`(false) | `0 0 2 * * ?` 每天 02:00 | `CustomerArchiveServiceImpl.syncKingdeeCustomerData`(:637) | A902-1 客户查询 复查 + A902-2 客户保存 推送 | `POST /customer-archive/sync-kingdee` | +| `ReceiptClaimPushTask.pushClaimToKingdee` ★新增 | `receiptClaimPush.syncEnabled`(false) | `0 */10 * * * ?` | `ReceiptFlowServiceImpl.pushClaimByTask`(:714) | A301-3 收款认领结果推送 | `POST /receipt-flow/push-kingdee`(单条手动) | + +> 认领链路另两个接口仍为**纯手动、无定时**:A301-2 收款认领公告查询(`POST /receipt-flow/sync`)、A301-1 收款单查询(`POST /receipt-claim-record/query-kingdee-result`)。旧总纲「收款认领三接口全部手动触发」的说法已被本任务打破:**A301-3 自 `2e91e11` 起为定时 + 手动双入口**。 +> ⚠️ 代码开关默认值均为 `false`;`doc/nacos/third-party-api.yaml` 样例中 `payResult.syncEnabled` 的环境变量兜底值为 `true`(`BFI_PAY_RESULT_SYNC_ENABLED:true`),以 Nacos 实际下发为准。 + +--- + +## 2. 公共机制 + +### 2.1 single-flight 分布式锁(KingdeeSyncLockSupport,★09-27 后新建) + +6 个服务方法入口统一经 `KingdeeSyncLockSupport.runSingleFlight(锁前缀, 租约秒数, 任务名, action)`(`support/KingdeeSyncLockSupport.java:61`): + +- **零等待抢锁**:`tryLock(0, leaseSeconds)`,已有轮次在跑立刻放弃本轮并返回「XX进行中,本轮跳过」摘要(定时下轮幂等重试,手动稍后再试);多副本部署的定时任务与手动触发共用一道闸,同一轮全局最多一份在跑。 +- **锁键 = 前缀 + 租户**:定时任务无登录上下文时按 `default` 租户加锁,锁粒度与多租户数据隔离边界一致。 +- **租约兜底**:租约(各任务默认 1800 秒)须大于最坏一轮耗时(含翻页远程查询),否则租约到期另一副本会并发起跑;进程异常退出时锁到期自动释放。 +- **降级**:`blade.lock.enabled=false` 时不加锁直接执行(多副本各自起跑),打 warn 日志——生产必须开启分布式锁。 + +各任务锁前缀与租约配置键: + +| 任务 | 锁键前缀(transport:…) | 租约配置键(默认 1800) | +|---|---|---| +| 付款结果回写 | `payment-application:kingdee-pay-result-sync:` | `thirdParty.bfi.payResult.lockLeaseSeconds` | +| 应收勾稽 | `formal-settlement:…`(AR_RESULT) | `thirdParty.bfi.arResult.lockLeaseSeconds` | +| 开票结果回写 | `invoice-application:…`(INVOICE_RESULT) | `thirdParty.bfi.invoiceResult.lockLeaseSeconds` | +| 收票结果回写 | `invoice-receipt:…`(RECEIVED_RESULT) | ⚠️ `thirdParty.bfi.receivedInvoice.lockLeaseSeconds`(前缀少「Result」,与其余开关键不同名) | +| 客商同步 | `customer-archive:…`(CUSTOMER_SYNC) | `thirdParty.bfi.customer.lockLeaseSeconds` | +| 收款认领推送 | `receipt-flow:…`(RECEIPT_CLAIM_PUSH) | `thirdParty.bfi.receiptClaimPush.lockLeaseSeconds` | + +### 2.2 批量与时间窗口径(IBfiService 契约) + +- 批量查询统一 **50 键/批**(付款 `PAY_RESULT_QUERY_BATCH=50`、开票 `INVOICE_RESULT_QUERY_BATCH=50`、客商信用代码 `KINGDEE_CUSTOMER_QUERY_BATCH=50`),页大小 200,`BfiUtil.queryAllPages` 自动翻页(页空/`lastPage`/不满页/超 50 页终止)。 +- 查询类接口时间窗:A103-2 付款申请单查询 / A103-4 付款处理查询 / A302-4 财务应收单查询 / A303-4 开票申请单查询 / A301-1 收款单查询 **≤ 1 个月**;A902-1 客户查询 **≤ 6 个月**;A301-2 收款认领公告查询 无接口级窗口(代码按 `thirdParty.bfi.claimAnnounce.months`,默认近 6 个月,逐认领人查询)。 +- 事务边界:任务方法与 single-flight 主体**无事务**;单据级写操作经自代理/`TransactionTemplate` 走**独立事务**(如 `applyPayApplyResult`、`applyPayBillRecord`、`pushClaimLocked`),单条失败只回滚自身并计数,不中断整批。 +- 可观测:每个子流程返回中文执行摘要(扫描数、成功/失败/纠偏/跳过计数),任务类 `log.info` 落盘。 + +--- + +## 3. 任务一:付款结果回写(PaymentKingdeeResultTask.syncKingdeePaymentResult) + +编排 **3 个互相独立的子流程**(各自 try/catch,一个异常不影响其余两个): + +### 3.1 子流程①:A103-2 付款申请单查询 + A103-4 付款处理查询 + +实现:`PaymentApplicationServiceImpl.executePaymentResultSync`(:655)。 + +``` +待回写付款申请(approved + kingdeeStatus IN (synced, paying, failed),paid/closed 终态不再轮询) + → A103-2 按 paymentNo(awrp_src_code)50/批查询,同一来源单号取 modifytime 最新一条 + 命中 ──> 清除消失计数 ──> applyPayApplyResult(独立事务): + 回填 kingdee_bill_id / kingdee_bill_no + 状态映射:paystatus=Alreadypay→paid(终)> billstatus=D→closed(终)> Inpayment→paying > 其余→synced + 已付金额 = 分录 e_paidamt 汇总(分录缺失且 paid 时兜底=申请金额) + paid 联动履约:syncPreSettlementAdvance(预结算预付)+ syncFormalSettlementPayments(实付分摊/结算付款记录) + 未命中 ──> 消失解锁判定(ADR-0005,见下) + → A103-4 付款记录回填: + 候选 = 本轮 A103-2 查得 paystatus≠Unpaid 的单 + 已 paid 但无真实付款记录的单(补偿) + 按付款组织编码分组(org_number 优先取 A103-2 返回 payorg_number,兜底按合同所属组织解析,取不到 warn 跳过) + 每组按最近 1 个月业务日期窗口翻页查询(接口 ≤1 个月约束) + 勾稽:sourcebillid==kingdee_bill_id 优先,sourcebillnumber==kingdee_bill_no 兜底 + 命中 ──> applyPayBillRecord(独立事务):清理 MOCK-PAY- 模拟记录 → upsert blade_payment_application_record + (billno→payment_no、actpayamt→paid_amount、paydate→paid_date、整单 JSON→voucher_json 留档) +``` + +**★ 消失解锁(ADR-0005,`handlePayApplyMissed` :575)**:A103-2 连续未查得「已生成/付款中」单据时,按 Redis 计数累计消失轮次(键 `transport:payment-application:kingdee-pay-miss:{id}`,TTL 2100 秒≈覆盖 3 个回写周期;生成失败单据查不到属正常态不计数)。**连续 2 轮**(`PAY_RESULT_MISS_UNLOCK_ROUNDS=2`)未查得即判定金蝶侧已反审核删除,调用 `unlockBill`(:544):`kingdeeStatus synced/paying→failed`,**清空 kingdee_bill_id / kingdee_bill_no**(原值日志留档),不自动重推,等待人工核实后重推;重推时 A103-2 幂等预检兜底,误判场景(金蝶单据实际存在)自动回填不产生重复单据。Redis 异常时放弃本轮判定(解锁是破坏性动作,宁可延迟不可误判)。本轮重新查得或单据解锁时清除计数。 + +### 3.2 子流程②:A302-4 财务应收单查询 勾稽纠偏 + +实现:`FormalSettlementServiceImpl.executeArResultSync`(:690)。 + +- 轮询:approved + settlementType=receivable + kingdeeSyncStatus IN (synced, **failed**)。 +- 逐单查结算明细组装防重键(与 A302-2 财务应收单保存传参一致:`正式结算单号_结算明细单据号…`),同一防重键取首单。 +- `queryArBillExistNos` 批量查询:查到 → 回填缺失的 kingdee_bill_no、状态统一置 synced、记 syncedTime(failed→synced 即生成失败纠偏;synced 且已有单号的跳过);查不到 → 状态不变。 +- 同步成功回写已含 **kingdee_bill_id(星瀚单据内码,A902 附件上传定位用)**。 + +### 3.3 子流程③:A303-5 开票发票查询 结果回写 + +实现:`InvoiceApplicationServiceImpl.executeInvoiceResultSync`(:878)。 + +- 轮询:approved + kingdeeStatus=synced + kingdee_bill_no 非空(invoiced 终态不再轮询;failed 无真实单号,纠偏走重推时的 A303-4 批量预检)。 +- kingdee_bill_no 逗号拼接拆分为逐个 serialNo → 按开票单位反查客商档案取销方税号(取不到跳过并计数)→ 按税号分组 → 每组 ≤50/批调 A303-5 开票发票查询(票易通 openApi)。 +- 发票落库:发票号批量去重(批次内预查已有记录),新增插入 `blade_invoice_application_invoice`(serial_no、invoice_no、invoice_date、invoice_amount、**发票文件地址四字段**:板式文件/预览图/数电 PDF/数电 XML、raw_json);**已有记录缺失文件地址的按本次返回字段级补空**(不覆盖已有值)。 +- 有新增发票 → 申请单推进 invoiced(终态);发票插入、地址补齐、申请单更新三段**分开批量落库**。 + +--- + +## 4. 任务二:收票结果回写(A801-3 收票单查询,PaymentKingdeeResultTask.syncKingdeeReceivedInvoiceResult) + +实现:`InvoiceReceiptServiceImpl.executeReceivedInvoiceResultSync`(:979)。处理推送链中断/异常的补偿,一轮一体处理三类场景: + +``` +待回写收票登记(kingdeePushStatus IN (pushed, assigned) + 非作废 + 已绑定票池镜像) + → 批量加载 blade_kingdee_invoice_pool 镜像,按镜像购方名称分组(接口必填购方名称) + → pushed 单据批量预组装财务应付单防重键(分摊→结算单→结算明细,一票一应付才可组装) + → 回看窗口 = thirdParty.bfi.receivedInvoice.resultWindowDays(默认 30 天),按购方分组翻页查询 + 匹配键 = 收票登记 id(★已修复:与 A303-2 收票单批量保存 落库的 awrp_src_code 同源, + 旧版按发票号匹配永远查不到自推单据的缺陷已关闭) + 窗口内查不到 ──> 不判删除(金蝶修改时间延迟/滑窗),保持现状由人工重推兜底 + 命中后三分支: + ① 发票状态异常(作废/红冲等) ──> 异常回收:置 failed +「金蝶删除/解除指定后重推」,记同步流水 + ② pushed 且状态正常 ──> 指定补偿:凭预组装防重键重发 A302-6 收票指定应付 → assigned + 防重键缺失(不满足一票一应付)→ 置 failed 报人工 + ③ assigned 但缺金蝶收票单号 ──> 单号补登:仅补 kingdee_received_invoice_no(历史响应丢失数据) + → 统一走 applyReceivedInvoicePushResult 落库(单号/状态/失败原因/推送时间),终态/失败记 blade_invoice_receipt_record 流水 +``` + +--- + +## 5. 任务三:客商数据定时同步(CustomerKingdeeSyncTask.syncKingdeeCustomerData) + +实现:`CustomerArchiveServiceImpl.executeCustomerDataSync`(:648)。一次查出全部审批通过客商(**★原 11.1-#3 硬编码 `.eq(customerCode,"KS000067")` 联调过滤已移除**),内存按金蝶协同状态分三组,单条失败不中断批次: + +``` +审批通过客商(approvalStatus=approved,一次全量查询) + ① 待推送(none/failed)── 批量 A902-1 客户查询 复查(信用代码去重大写、50/批、自动翻页,避免逐条 N+1) + 金蝶已有且已审核(status=C) ──> 直接置 exist(终态),跳过推送 + 金蝶侧暂存/已提交(A/B,审核中) ──> 置 failed「金蝶侧该客商审核中,请稍后重推」 + 金蝶不存在 ──> 逐条 A902-2 客户保存 推送(收款账户按客商 id 批量一次查,组装报文避免 N+1) + 成功 → synced / 失败 → failed + 原因(截断留痕) + ② 已推送复查(synced)── 与③共用同一次批量 A902-1(信用代码合并去重,仅调一次接口) + status=C ──> 升级 exist(终态,A902-2 受理 B 只是中间态) + 仍在审核 ──> 保持 synced 下轮再看(不重推,防金蝶重复建档);查无档案 warn 计数 + ③ 停用联动候选(synced/exist 且本地启用)── 复查结果 enable=0 + ──> TMS 档案联动停用(status=2);金蝶恢复可用不自动启用(单向,人工控制) + → 集中批量落库:updateBatchById 更新档案 + Db.saveBatch 插入 blade_customer_change_record 变更留痕(各自 try/catch) +``` + +--- + +## 6. 任务四:收款认领定时推送(ReceiptClaimPushTask.pushClaimToKingdee)★新增 + +> 背景:`2e91e11` 收款认领本地化改造——页面「认领」动作只在本地登记(`blade_receipt_claim` + 分摊表),**A301-3 收款认领结果推送改为「定时 + 手动」双入口**,按收款流水归集推送。旧方案的「认领事务内同步推送」「SKCL+时间戳防重码」口径均已废弃。 + +实现:`ReceiptFlowServiceImpl.pushClaimByTask`(:714,定时)与 `pushClaimToKingdee`(:685,手动单条)共用推送主体 `doPushClaimToKingdee`(:820)。 + +### 6.1 定时扫描与编排(executeReceiptClaimPush :728) + +``` +候选流水(blade_kingdee_receipt_flow):bill_id 非空(已同步到公告) + + push_status IN (none, failed)(未推送/推送失败,pushed 终态闸门不重推) + + claim_status = claimed(认领完成是唯一可推送状态)+ 有效 + → 按「认领人」分组(blade_receipt_claim.claimer_name,有效认领 claimed),认领人姓名排序串行处理: + 每个认领人一次 A301-2 收款认领公告查询(claimusers=认领人姓名,近 claimAnnounce.months 个月全量翻页) + ——定时任务无登录人上下文,A301-2 的 claimusers 又是单姓名必填,故必须逐人查询 + 公告按公告id(bill_id)归集 → 其名下每条候选流水按 bill_id 匹配: + 公告查询失败 ──> 该认领人名下流水全部跳过(计数),下轮重试 + 未命中公告 ──> 单条跳过(计数) + 命中 ──> pushClaimLocked 推送(成功后加入本轮已推集合,同一流水被多人认领只推一次) + → 摘要:扫描 N 条,成功 X,失败 Y,公告未命中跳过 Z +``` + +### 6.2 单条推送主体(doPushClaimToKingdee,事务 + 行锁) + +- **并发防护**:`pushClaimLocked` 经 `TransactionTemplate` 开事务,流水行 `FOR UPDATE` 行锁覆盖闸门、对账、推送、落库全过程——防定时任务与手动按钮对同一流水双推。 +- **闸门**:`push_status=pushed` 拒绝重复推送(作废重认领使条件再满足也不推,金蝶侧人工处置);公告 id 缺失拦截「请先手动同步金蝶收款流水」。 +- **推送前对账**:以最新公告刷新公告侧未认领金额/公告认领状态/我已认领,并按全部分摊行重算累计认领金额与 `claim_status` 快照落库;**非 claimed(金额与公告未认领金额不一致)拦截不推**。 +- **防重键 ★口径变化**:`awrp_src_code` = 该流水**全部有效分摊行的结算单号去重下划线拼接**(不再是 `SKCL+yyyyMMddHHmmss+尾号`)。分摊不变则重试编码不变,金蝶按 `awrp_src_sys + awrp_src_code` 幂等,防重试双单;失败落库后下轮自动重试复用同一编码。 +- **A301-3 报文(单分录)**:`billid`=公告 id(必填);`recpaytype_number`=常量 `999`(待金蝶确认)、`paymenttype`=`bd_customer`、`recviewpayer`/`recpayer`=流水付款方名称/账号;分录仅一条,`e_receivableamt`=累计认领金额(全部未推送金额),`seq`/`e_corebillno`=第一张结算单 id(`e_corebilltype` 已注释不传)。 +- **结果处理**:全项 `billStatus=true` 才算成功;成功响应 `number` 为空,金蝶收款单号取**回执键值 `keys.awrp_src_code`**。成功 → 流水 `push_status=pushed`、`kingdee_bill_no`、`kingdee_bill_status=pushed`、清失败原因,并级联刷新名下未作废认领主单冗余字段 + 记 `blade_receipt_flow_record` 流水;失败 → `push_status=failed` + 原因(截 1000 字),本地认领数据保留,等待下轮/手动重推。 +- **A301-1 收款单查询 回写**(仍纯手动 `POST /receipt-claim-record/query-kingdee-result`)按 `sourcebillnumber == 流水 awrp_src_code` 匹配回写收款单号与状态(A/B→pushed、C→approved、D→received 终态、G→failed),与上表状态机衔接。 + +--- + +## 7. 配置开关汇总(Nacos thirdParty.bfi.*) + +| 配置键 | 代码默认 | yaml 样例(doc/nacos/third-party-api.yaml) | 说明 | +|---|---|---|---| +| `payResult.syncEnabled` / `payResult.cron` | false / `0 */10 * * * ?` | **true**(`BFI_PAY_RESULT_SYNC_ENABLED`)/ 同左 | 付款结果回写总开关(含应收勾稽、开票回写) | +| `payResult.lockLeaseSeconds` | 1800 | — | single-flight 锁租约 | +| `receivedInvoiceResult.syncEnabled` / `.cron` | false / `0 */10 * * * ?` | — | 收票结果回写 | +| `receivedInvoice.lockLeaseSeconds` / `receivedInvoice.resultWindowDays` | 1800 / 30 | — | ⚠️ 键名无「Result」后缀;回看窗口天数 | +| `customer.syncEnabled` / `customer.cron` | false / `0 0 2 * * ?` | — | 客商同步 | +| `customer.lockLeaseSeconds` | 1800 | — | single-flight 锁租约 | +| `receiptClaimPush.syncEnabled` / `.cron` / `.lockLeaseSeconds` | false / `0 */10 * * * ?` / 1800 | false / 同左 / 1800 | 收款认领定时推送 ★新增 | +| `claimAnnounce.months` | 近 6 个月 | — | A301-2 公告查询窗口(同步/手动推送对账/定时推送对账共用) | + +> 分布式锁依赖 `blade.lock.enabled=true`;未开启时 single-flight 降级为直接执行(生产必须开启)。 + +--- + +## 8. 共性设计与既有关键机制的落地状态 + +1. **single-flight 全覆盖**:6 条定时/手动共用链路全部经分布式锁串行化,杜绝多副本重复回写/重复推送。 +2. **failed 纳入轮询的自愈纠偏**:付款申请(A103-2 查到即纠正 synced)、应收结算单(A302-4 勾稽纠偏)——金蝶侧已生成说明上次推送实际成功,查到即回填,避免人工重推产生重复单据。 +3. **消失解锁兜底**(ADR-0005):金蝶侧反审核删除的单据经「连续 2 轮查询消失」解锁置失败,解决 synced/paying 单据在金蝶被删后 TMS 永久锁死的问题。 +4. **终态不轮询**:paid/closed(付款)、exist(客商)、invoiced(开票)、assigned/failed+窗口外(收票)、pushed(认领流水)均退出扫描集。 +5. **批量防 N+1**:50/批远程查询、页大小 200 自动翻页、收款账户/镜像/分摊/结算明细批量预取、集中批量落库。 +6. **对照 09-27 总纲第 11 节的两个状态变化**: + - 11.2-#1「A801-3 定时回写按发票号匹配」缺陷 **已修复**(现按收票登记 id 匹配,`queryReceivedInvoiceBySrcCodes`); + - 11.1-#3「客商定时同步硬编码 KS000067」**已移除**。 +7. **旧文档过时点**(以本文为准):09-27 定时任务全梳理未收录 `ReceiptClaimPushTask`;「收款认领三接口全部手动」「A301-3 在认领事务内推送、awrp_src_code=SKCL 随机码」的描述已被认领本地化改造取代。 diff --git a/doc/docs/业务流程/2026-10-05-审批通过自动同步金蝶设计(事件触发+定时重推+手动兜底).md b/doc/docs/业务流程/2026-10-05-审批通过自动同步金蝶设计(事件触发+定时重推+手动兜底).md new file mode 100644 index 00000000..5f50817d --- /dev/null +++ b/doc/docs/业务流程/2026-10-05-审批通过自动同步金蝶设计(事件触发+定时重推+手动兜底).md @@ -0,0 +1,177 @@ +# 审批通过自动同步金蝶设计(事件触发 + 定时重推 + 手动兜底) + +> 整理日期:2026-10-05 +> 目标:推送到金蝶的各单据,**审批通过后自动推送,省掉页面手动点「同步金蝶」按钮这一步**;节点间的先后顺序由既有硬预检 + 失败重推消化,全程无需人工点击。 +> 依据:`2026-09-27-金蝶(星瀚)BFI接口调用全链路梳理(按业务域汇总·更新版).md`、`TMS业财融合-集成流程图文字版与业务流程梳理.md`、当前代码(探索核实)。 + +--- + +## 1. 范围 + +| 业务域 | 推送链路 | 现状触发 | 本设计 | +|---|---|---|---| +| 正式结算单(应付/应收) | A102-4 预检 → A102-2 / A302-2 | 手动按钮 | ✅ 审批通过自动推 | +| 付款申请(正式/预付) | A102-4 硬校验 → A103-2 预检 → A103-1 / A103-3 | 手动按钮(单/批) | ✅ 审批通过自动推 | +| 开票申请 | A302-4 硬校验 → A303-4 预检 → A303-1 逐张次 | 手动按钮 | ✅ 审批通过自动推 | +| 收票登记 | A102-4 预检 + A801-3 预检 → A303-2 → A302-6 | 手动批量按钮 | ✅ 审批通过自动推 | +| 客商档案 | A902-2 | 已是审批通过自动推 | ➖ 不动(先例) | +| 收款认领 | A301-3 | 认领页面动作事务内推送 + ReceiptClaimPushTask 定时 | ➖ 不动(无审批节点) | +| 查询回写类 | A103-2 / A303-5 / A801-3 / A301-1 | 定时/手动 | ➖ 不动(不是推送) | + +## 2. 现状与差距(代码核实结论) + +1. **无工作流引擎**:blade-transport 全模块零 flowable 引用,审批 = 直接置 `approvalStatus=approved`(`TransportBusiConstant.java:44`)。 +2. **审批有两条入口**,自动推送必须全覆盖: + - 手动:`POST /xxx/approve` → `service.approve(request)`; + - OA 回调:`MkProcessPublicController` → `MkProcessMessageService` → `MkProcessBizHandlers.approveFromProcess`。 + - ⚠️ 其中 **formal-settlement 与 payment-application 的 OA handler 直接置 approved、绕过 `service.approve`**(付款侧因此漏掉票据额度联动 `useBillBalance`,结算侧不写变更记录——既有不一致,本设计顺手统一)。 +3. **推送失败落库口径不一致**:付款/收票失败落 `failed`;结算单/开票失败整体回滚保持 `unsynced`("没推过"与"推失败"无法区分,总纲 11.2-#3 缺陷)。 +4. **事件设施现成**:voucher 模块已有 `ApplicationEventPublisher` + `@TransactionalEventListener(AFTER_COMMIT)` 模式。 +5. 审批端点全部单条,无批量审批入口。 +6. 客商档案先例:审批后推送异常被吞(Controller 层 try/catch + Service 层落 failed),**推送失败不影响审批结果**。 + +## 3. 总体方案 + +``` + ┌─ 手动 POST /xxx/approve ─→ service.approve() ─┐ +审批通过(两条入口)─┤ ├─ 事务内置 approved + 发布 KingdeeBillApprovedEvent + └─ OA 回调 approveFromProcess ─────────────────┘ (OA handler 统一改走 service.approve) + │ 事务提交(AFTER_COMMIT) + ▼ + KingdeeApprovalSyncListener(域开关判断 + 全量 try/catch 吞异常) + │ 复用手动按钮同一 Service 方法 + ┌────────────┬────────────┴──────────┬──────────────┐ + ▼ ▼ ▼ ▼ + 结算单 sync 付款申请 sync 开票申请 sync 收票 push + (A102-2/A302-2) (A103-1/A103-3) (A303-1) (A303-2+A302-6) + │ │ │ │ + 成功→synced 成功→synced 成功→synced 成功→pushed/assigned + 失败→failed 失败→failed 失败→failed 失败→failed + └────────────┴───────────┬───────────┴──────────────┘ + ▼ + KingdeeAutoSyncRetryTask(定时,默认关):仅重推 failed + 前置未就绪(如应付单未生成)→ 预检拦截 → 再次 failed → 下轮继续 + 前置就绪 → 自动续推成功(硬顺序由预检保证,不做级联) +``` + +三条不变式: +1. **审批结果不受推送影响**:事件在审批事务提交后才消费,监听器整体 try/catch,推送异常只落库 + 记日志(客商先例)。 +2. **只有审批状态真正变为 approved 的那次才发布事件**(重复回调被 approve 的状态校验拒绝,天然不重复推送)。 +3. **复用手动按钮同一 Service 方法**:自动推送与手动推送走完全相同的校验、预检、回写逻辑,不另写推送路径。 + +## 4. 设计要点 + +### 4.1 触发点(覆盖双入口) + +- 新增事件 `KingdeeBillApprovedEvent(String bizType, Long id)`,`bizType` 复用 `MkProcessBizHandlers` 既有取值(`formal-settlement` / `payment-application` / `invoice-application` / `invoice-receipt`)。 +- 4 个实体的 `service.approve()` 在状态置 approved 成功后 `publishEvent`(须确保 approve 方法在事务内执行——均有变更记录写入,已是 @Transactional;无事务时 `@TransactionalEventListener` 默认丢弃事件,实现时核实兜底)。 +- **统一 OA 回调**:`MkProcessBizHandlers` 中 formal-settlement(:309-318)与 payment-application(:372-380)的 `approveFromProcess` 改为调 `service.approve(request)`,与 invoice-application / invoice-receipt 的 handler 写法对齐。副作用即修复:付款 OA 审批补上票据额度联动 `useBillBalance`、结算 OA 审批补上变更记录。 +- 客商档案已有自己的审批后推送,不动。 + +### 4.2 执行模型:AFTER_COMMIT 同步推送 + +- 监听器 `KingdeeApprovalSyncListener` 用 `@TransactionalEventListener(phase = AFTER_COMMIT)`,事务提交后在当前线程调用推送(与客商档案现网行为一致)。正常推送秒级;Feign 超时上限 10s 连接 / 60s 读取。 +- `ponytail:` 同步推送占用审批请求线程,若 BFI 网关大面积变慢导致审批接口可感知变慢,再把监听器内部改为提交到守护线程池(`MkProcessMessageService` 已有同款裸 `ScheduledExecutorService` 模式,1 处改动)。 +- 路由: + +| bizType | 调用(与手动按钮相同) | +|---|---| +| formal-settlement | `formalSettlementService.syncKingdee(id)`(内部按 settlementType 路由 A102-2/A302-2) | +| payment-application | `paymentApplicationService.syncKingdee(id)`(内部路由 A103-1/A103-3) | +| invoice-application | `invoiceApplicationService.syncKingdee(id)` | +| invoice-receipt | `invoiceReceiptService.pushKingdeeReceivedInvoice(List.of(id))` | + +### 4.3 失败统一落 failed(修 11.2-#3) + +- 结算单、开票申请的推送方法改为**"事务回滚后独立事务落 failed + 失败原因"**(沿用付款申请已有 `markKingdeeSyncFailed` 模式),与付款/收票口径统一。无需新列、无需 SQL(状态字段均为 varchar,新增 `failed` 取值即可)。 +- 收票自动推送遇到推送计划不满足(未恰好分摊 1 张审批通过应付结算单、查验未通过等,现写 `blockedReason` 不动状态):**自动推送路径下置 failed + 原因**,纳入重推循环;前置补齐后重推自动续上。 +- 前端列表的金蝶状态筛选/展示补 `failed` 选项(结算单/开票)。 + +### 4.4 节点先后顺序:预检保证,不做级联 + +- 每个节点只在**自身审批通过**时推自己;跨节点硬顺序(A102-2→A103-1/A303-2、A302-2→A303-1)仍由既有 A102-4/A302-4 硬预检强制。 +- 前置未就绪(上游还没推成)→ 本次推送失败落 failed(原因"前置应付/应收单未生成")→ 重推任务下轮再试 → 上游就绪后自动续推成功。预检与断点续传机制天然支持。 +- **不做级联**(上游推完自动连推下游):失败传播复杂、且下游有自己的审批节点,级联会绕过"审批通过才推"的语义。 + +### 4.5 定时重推任务(兜底) + +- 新增 `KingdeeAutoSyncRetryTask`(`task/` 包,@Component + @Value 开关 + @Scheduled cron,与既有任务同款)。 +- **只重推 `failed`,不扫描 `unsynced`**——存量"审批通过但从未推送"的历史单不自动补推(避免上线即批量推历史数据),仍走手动按钮。 +- 每域候选集(均要求未作废): + +| 域 | 查询条件 | 动作 | +|---|---|---| +| 结算单 | `kingdee_sync_status='failed'` 且 approved | `syncKingdee(id)` | +| 付款申请 | `kingdee_status='failed'` 且 approved | `syncKingdee(id)` | +| 开票申请 | `kingdee_status='failed'` 且 approved | `syncKingdee(id)` | +| 收票登记 | `kingdee_push_status='failed'` 且 approved | `pushKingdeeReceivedInvoice(List.of(id))` | + +- 逐条 try/catch,单条失败不中断;与付款勾稽纠偏(A103-2 查到即纠正 synced)天然兼容:重推前预检命中远端已落单会幂等回填,不会重复生成。 +- 开关 `thirdParty.bfi.autoSync.retryEnabled` 默认 false,cron 默认 `0 */10 * * * ?`。 +- `ponytail:` 重推无次数上限,永久性坏数据(如联行号未维护)会每 10 分钟失败一次;噪音可接受后,再加 retry_count 上限列。 + +### 4.6 配置开关与灰度 + +监听器内按域判断,4 个开关全部默认 false(Nacos 显式打开),与既有定时任务开关风格一致: + +```yaml +third-party: + bfi: + autoSync: + settlement: false # 结算单审批后自动推 A102-2/A302-2 + payment: false # 付款申请审批后自动推 A103-1/A103-3 + invoiceApply: false # 开票申请审批后自动推 A303-1 + receipt: false # 收票登记审批后自动推 A303-2+A302-6 + retryEnabled: false # failed 定时重推 +``` + +> 注意配置前缀:transport 侧读 `third-party.bfi`(BfiProperties 现行前缀,与 nacos 键对齐),落地时以实际前缀为准。 +> 关闭某域开关 = 该域回到现状(审批不推送、手动按钮照用);重推开关独立,可只关重推保手动。 + +### 4.7 保持不变的部分 + +- 手动同步按钮**全部保留**(失败重推与人工兜底入口,三入口并存:自动 + 定时 + 手动)。 +- 结算单汇率未维护 → 照旧"不传不阻断",不在审批侧强校验。 +- 定时回写任务(PaymentKingdeeResultTask / CustomerKingdeeSyncTask / ReceiptClaimPushTask)不动。 +- 状态守卫式锁单(synced 禁止作废等)不动;B901 锁单回调本分支未实现,与本设计无耦合。 + +## 5. 改动清单(文件级) + +| # | 文件 | 改动 | +|---|---|---| +| 1 | `transport/event/KingdeeBillApprovedEvent.java` | 新增 record(bizType, id) | +| 2 | `transport/listener/KingdeeApprovalSyncListener.java` | 新增:AFTER_COMMIT 监听 + 域开关 + 路由 4 个 sync 方法 + 全量 try/catch | +| 3 | `FormalSettlementServiceImpl.approve`(:539) | 末尾发布事件 | +| 4 | `PaymentApplicationServiceImpl.approve`(:400) | 末尾发布事件 | +| 5 | `InvoiceApplicationServiceImpl.approve`(:483) | 末尾发布事件 | +| 6 | `InvoiceReceiptServiceImpl.approve`(:587) | 末尾发布事件 | +| 7 | `MkProcessBizHandlers`(formal-settlement :309 / payment-application :372) | `approveFromProcess` 改调 `service.approve(request)`(统一入口 + 修复 useBillBalance 绕过) | +| 8 | `FormalSettlementServiceImpl` / `InvoiceApplicationServiceImpl` 推送失败路径 | 事务回滚后独立事务落 `failed` + 原因(沿用 `markKingdeeSyncFailed` 模式) | +| 9 | `InvoiceReceiptServiceImpl` 推送计划 blocked 分支 | 自动推送路径 blocked → failed + 原因 | +| 10 | `transport/task/KingdeeAutoSyncRetryTask.java` | 新增:4 域 failed 重推,开关 + cron | +| 11 | 前端结算单/开票列表 | 金蝶状态筛选/展示补 `failed`(小改) | + +无新表、无 DDL、无新依赖。 + +## 6. 边界场景推演 + +| 场景 | 行为 | +|---|---| +| 推送成功但审批事务实际回滚(AFTER_COMMIT 后回滚不存在,理论不发生) | 不适用——事件只在事务成功提交后消费 | +| 推送成功、落 failed 前进程崩溃 | 下轮重推预检命中(A102-4/A103-2/A303-4/A801-3)幂等回填,不重复建单 | +| 付款审批通过但结算单应付单未生成 | A102-4 硬校验拦截 → failed("前置应付单未生成") → 重推任务续推 | +| 结算单推送失败挂 failed,下游付款/收票/开票全failed等它 | 重推任务每轮都重试各域 failed,上游成功后下游自动续上 | +| OA 审批通过的单 | OA handler 统一走 service.approve 后同样触发事件,覆盖 | +| 同一单重复 OA 回调 | approve 状态校验拒绝重复审批,不发布事件 | +| 永久性坏数据(如联行号未维护) | 每轮重推失败落原因,修数后下轮自动成功;重推无上限(见 4.5 天花板) | +| 历史存量 approved-unsynced 单 | 不自动补推,手动按钮处理 | + +## 7. 待确认默认值(按推荐默认落地,可改) + +| 默认 | 内容 | 备选 | +|---|---|---| +| D1 | AFTER_COMMIT 同步推送(同线程) | 改守护线程池异步(1 处改动,见 4.2) | +| D2 | OA handler 统一改走 service.approve(顺修 useBillBalance 绕过) | 保持 handler 直置、在各 handler 单独发事件 | +| D3 | 重推只扫 failed,存量 unsynced 不补推 | 加"存量补推"一次性开关 | +| D4 | 收票 blocked 条件在自动推送时置 failed | 保持 blockedReason 不动状态 + 重推扫 blocked | +| D5 | 重推无次数上限 | 加 retry_count 上限 | diff --git a/doc/docs/业务流程/2026-10-05-金蝶票池选票回切镜像读(本地真分页+票号回源+瘦身同步+认领闸门)业务流程.md b/doc/docs/业务流程/2026-10-05-金蝶票池选票回切镜像读(本地真分页+票号回源+瘦身同步+认领闸门)业务流程.md new file mode 100644 index 00000000..385d2a98 --- /dev/null +++ b/doc/docs/业务流程/2026-10-05-金蝶票池选票回切镜像读(本地真分页+票号回源+瘦身同步+认领闸门)业务流程.md @@ -0,0 +1,91 @@ +# 金蝶票池选票回切镜像读(本地真分页 + 票号回源 + 瘦身同步 + 认领闸门)业务流程 + +> 适用系统:TMS(BladeX 微服务平台,服务:blade-transport;前端:tms-erp-web) +> 对接系统:金蝶发票云旗舰版·票易通(piaozone)全票池 A801-1,经业财融合 BFI 网关(rim 契约)透传 +> 关联文档:`2026-09-22-金蝶票池选票统一查镜像…业务流程.md`(2026-09-22 定稿镜像读版、同日改写为实时版,本文第三次定稿)、`2026-09-23-金蝶票池选票方案对比评估与优化建议(镜像读+回源+保存重查闸门).md`(本方案评估依据)、`2026-09-20-收票管理同步金蝶全票池发票…业务流程.md`(首版同步体系,绑定/补偿/白名单部分维持退役) +> 设计依据:2026-10-05 grilling 会话定稿(Q1 回源 / Q2 瘦身同步 / Q3 双闸门 / Q4 全恢复条件能力 / Q5 邻接功能不动不留开关 / Q6 新分支 / Q7 手动 bootstrap) +> 最后更新:2026-10-05 + +--- + +## 1. 背景与决策 + +实时透传方案(2026-09-22 二次决策)运行暴露:每次开弹窗/翻页/保存都是一次 BFI 网关远程链路(秒级)、无 total 游标翻页不可跳页、名称模糊与认领/登记过滤被删、金蝶抖动期间选票整体瘫痪。2026-09-23 评估文档结论成立:**该回切的是"查询架构",不该回切的是"同步体系负担"**,且保存重查闸门是两版方案中唯一资金安全级防线。 + +**定稿口径**: + +- 收票登记新增、正式结算单认领两处选票回切**查本地镜像** `blade_kingdee_invoice_pool`:本地 MySQL 真分页(total/跳页)、名称模糊、票种、双税号、claimed/registered 三态过滤全部恢复;**仅出可用票**(原 `isUsablePoolRow` 口径本地化执行:`available_amount > 0` 且 `amount_use_status = '0'`)。 +- **票号未命中回源**:票号精确查本地无结果(且无认领/登记过滤条件)时,窄查一次 A801-1(票号全等防御 + 可用复核)→ upsert 入镜像 → 本地重查返回。兜底增量窗口缝隙内的当日新票;名称模糊本地为空**不回源**(远程不支持名称条件,救不了)。 +- **瘦身版定时同步**回归:只保留「增量窗口分页拉取 + 镜像全列批量 upsert」,**不恢复**绑定回填、漏绑补偿、双白名单、指纹比对(保存即写存根绑定,登记侧无定时绑定需求;全列覆盖消灭指纹清单漏字段静默不同步的坑)。 +- **双闸门保留/补齐**:收票保存重查(`persistMirrorFromRemote`)原样保留;结算认领确认(`claimInvoices`)补齐服务端按票号逐张重查校验——窗口拉取口径 `invoiceStatus="0"`,被删除/红冲票不再出现在拉取结果,镜像可能陈旧,资金安全由闸门保证。 + +## 2. 接口契约(后端) + +| 端点 | 说明 | +| --- | --- | +| `GET /invoice-receipt/invoice-pool`、`GET /formal-settlement/invoice-pool` | 镜像统一查询,收参 `KingdeeInvoicePoolQueryDTO`,返回 `R>`(records/total/size/current) | +| `POST /invoice-receipt/sync-invoice-pool` | 手动触发全票池同步(bootstrap/联调/补偿三用),返回执行摘要 | + +- **已删除**:游标契约 `KingdeeInvoicePoolPageVO`(hasMore/nextCursor)与 DTO `cursor` 字段、实时链路 `PoolScanCursor`/`toPoolViewVO`/`decorateUseFlags`。 +- `KingdeeInvoicePoolQueryDTO` 条件:`current/size`(size 钳位 1~100)、`invoiceNo`(模糊)、`invoiceDateStart/End`(服务端强校验 yyyy-MM-dd)、`salerName/buyerName`(名称模糊)、`invoiceType`(票易通编码,匹配镜像 `invoice_type_code`)、`salerTaxNo/buyerTaxNo`(精确)、`claimed/registered`(三态过滤)。 +- **KingdeeInvoicePoolMapper.xml**:`selectInvoicePoolPage` 单条 SQL 承载全部口径——`usableFilter`(`available_amount > 0 AND amount_use_status = '0'`)+ claimed/registered EXISTS 子查询(剔除作废结算单/作废登记,手工带租户与逻辑删除条件)+ 动态条件,`ORDER BY source_updated_time DESC`。 +- VO 展示列:`availableTaxAmount`(剩余可用金额,金蝶可用税额口径,仅展示)与 `availableInvoiceAmount/matchedAmount`(价税合计口径,认领金额校验依赖)并存。 + +## 3. 保存与认领闸门 + +``` +收票保存/提交(不变):persistMirrorFromRemote 按票号重查 A801-1 + → 未命中/delete=1 中止;isUsablePoolRow 复核(金额可用 0/使用状态变更中止)→ 写存根 + 绑定 +结算认领确认(新增):claimInvoices 校验后逐张 assertInvoicesClaimable + → 窄查 A801-1 + 票号全等防御 + isUsablePoolRow 同口径复核,任一不通过 ServiceException 中止 +``` + +闸门数据不回写镜像(认领侧只校验不落库);可用性陈旧窗口由闸门兜底,无资损路径。 + +## 4. 定时同步(瘦身版) + +``` +InvoiceReceiptKingdeeSyncTask(@Scheduled cron,thirdParty.bfi.invoiceReceipt.cron 默认每30分钟,syncEnabled 默认关闭) + → IInvoiceReceiptService.syncKingdeeInvoicePool()(KingdeeSyncLockSupport.runSingleFlight 单飞,锁键按租户) + → executePoolSync():滚动近 incrementDays 天(默认30)窗口 + ① 发票种类白名单下推(thirdParty.bfi.invoiceReceipt.invoiceTypes,单值参数按编码拆轮,每轮独立额度;空不限制) + ② updateTime 切片续扫(syncWindowSlice):单查询窗口受 50页×500条=2.5万硬上限,触顶自动二分 + [start,mid)+[mid,end) 递归续扫(右片不含 mid 不重不漏;细到 1 秒仍满页防御报 error 跳过) + 出站口径 invoiceStatus="0",不推 expenseStatus(在用/已用票随 updateTime 进窗口刷新使用状态,本地过滤自然排除) + → batchUpsertInvoicePool:同页号码一次批量查既有镜像 → fillInvoicePool 全列填充 → 有变化才写: + 轻量比对 serialNo/价税合计/税额/剩余可用金额/金额使用状态(compareTo 比金额,空对空不算变化), + 无变化跳过 UPDATE(消整窗重复拉取的无效写);有变化 Db.updateBatchById、新增 Db.saveBatch + → 已登记票跟随补绑:同页票号批量定位登记,仅未绑定存量登记补绑+写 sync 流水(页级事务批量落库) + → 失败中断本轮由任务层记日志,下轮幂等重试 +``` + +- 手动端点与定时任务共用单飞锁;`blade.lock.enabled=false` 时降级无锁并 warn(与付款回写/客商同步同模式)。 +- 删除/红冲盲区:拉取不到的票镜像保持旧值看似可用——展示层陈旧可接受(无 `source_synced_time` 新增列,复用 `source_updated_time` 排序),资金安全由第 3 节双闸门保证。 + +## 5. 数据库与配置 + +| 项 | 内容 | +| --- | --- | +| 镜像表新增列 | `available_amount` decimal(18,2)、`amount_use_status` varchar(8),幂等脚本 `doc/sql/transport/blade_kingdee_invoice_pool_available_20261005.sql`(可重跑) | +| 新增索引 | `blade_formal_settlement_invoice (tenant_id, invoice_no)`(认领过滤 EXISTS 走索引) | +| Nacos | `third-party-api.yaml` 新增 `thirdParty.bfi.invoiceReceipt.*`:`syncEnabled`(false)/`cron`(每30分钟)/`incrementDays`(30)/`invoiceTypes`(白名单编码,如 26,27,下推远端按编码拆轮)/`lockLeaseSeconds`(1800) | +| 存量数据 | 存量存根行两列为 NULL,不参与选票(可用过滤排除),随增量窗口同步自然回填;回切当天手动跑一次 `POST /invoice-receipt/sync-invoice-pool` bootstrap 铺底 | + +## 6. 前端统一组件(`src/components/kingdee-invoice-pool-dialog/main.vue`) + +- 分页区回切 `el-pagination`(total/sizes/prev/pager/next/jumper,可跳页);游标机件(nextCursor/pageCache/上一页缓存回显)删除。 +- 条件区恢复:开票单位/受票单位名称模糊 + 认领状态/登记状态三态下拉;保留:票号、日期区间(默认近一月)、双税号、发票类型(编码下拉 26/27)。 +- 保留实时版增强:剩余可用金额列、认领/登记 tag、附件查看/下载(A801-2 代理)、`rowKey = id || invoiceNo`(回源响应理论上有 id,兜底不变)。 +- 页脚提示"按名称检索范围为已同步票,未找到请改用发票号码或税号精确查找"。 +- 接线不变:收票登记表单(单选)、结算单编辑器(多选、`handleInvoiceClaimConfirm` 归一 `attachmentJson`)。 + +## 7. 邻接功能(本次不动) + +A801-2 附件下载(镜像优先/实时回源,镜像覆盖面恢复后受益)、A801-5 黑名单查询(实时全量)、收票推送链 A303-2/A302-6/A801-3(消费镜像 `invoice_type_code/items_json`,不受影响)、收票保存重查与存根写入语义。 + +## 8. 已知限制 + +- 名称模糊仅检索已同步票(组件页脚已声明);同步缝隙内新票靠票号回源兜底,纯浏览需等下一轮同步。 +- 30 分钟全列重写为已知写放大(千~万行级,成本可接受;量级上来后升级为整行载荷规范化哈希,**不恢复**人工字段清单式指纹)。 +- 首轮 bootstrap 前无条件浏览为空表(上线操作项,见第 5 节)。 +- 后端验证:`mvn compile -DskipTests -pl blade-service/blade-transport -am` 通过;前端 `pnpm run build` 通过。 diff --git a/doc/docs/业务流程/2026-10-09-金蝶单据锁定与解锁业务流程(lockBusiData锁定-unLockNotifyBusiData回调-失败补偿-删单还原).md b/doc/docs/业务流程/2026-10-09-金蝶单据锁定与解锁业务流程(lockBusiData锁定-unLockNotifyBusiData回调-失败补偿-删单还原).md new file mode 100644 index 00000000..6e03d642 --- /dev/null +++ b/doc/docs/业务流程/2026-10-09-金蝶单据锁定与解锁业务流程(lockBusiData锁定-unLockNotifyBusiData回调-失败补偿-删单还原).md @@ -0,0 +1,203 @@ +# 金蝶单据锁定与解锁业务流程(lockBusiData 锁定 - unLockNotifyBusiData 回调 - 失败补偿 - 删单还原) + +- 日期: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`(一行 = 一张金蝶目标单) + +## 一、涉及单据与触发链路 + +| 业务单据 | 推送接口 | 推送入口 | 金蝶目标单 | 锁定触发 | +| --- | --- | --- | --- | --- | +| 正式结算单(应付) | A102-2 财务应付单保存 | 结算单页面「同步金蝶」 | ApFin_other_BT_S | 推送成功后 | +| 正式结算单(应收) | A302-2 财务应收单保存 | 同上 | arfin_other_BT_S | 推送成功后 | +| 开票申请单 | A303-1 开票申请新增 | 开票申请页面「同步金蝶」 | cdm_paybill_bank_elect_BT_S(每张发票张次一张) | 推送成功后 | +| 付款申请单 | A103-1 应付下推(正式结算付款)/ A103-3 直接保存(预付款) | 付款申请页面「同步金蝶」(单笔/批量) | ap_payapply_oth_BT_S(每张付款申请一张) | 推送成功后 | + +- lockBusiData:WLTMS 调金蝶,源系统 WLTMS、目标系统 XH、lockAction=LOCK +- unLockNotifyBusiData:金蝶调 WLTMS,源系统 XH、目标系统 WLTMS、lockAction=UNLOCK +- 传参约定:接口 1对1,单次仅一个源单号(sourceBillNo)对一个金蝶单号(targetBillNo);多张金蝶单(开票申请一张主单对 N 张金蝶单)逐张调用,单张成败互不影响,成败结果批量落库 +- 付款申请天然 1对1:一张付款申请对应一张金蝶付款申请单(A103-1/A103-3 均同类型),源单号=付款申请号 paymentNo +- WLTMS 单据类型常量已预留:`KingdeeLockConstant.SOURCE_PAY_APPLY`(PAY_APPLY)/`TARGET_PAY_APPLY`(ap_payapply_oth_BT_S),且已在 `TARGET_TO_SOURCE_MAP` 反查映射中 +- 出入参同构,应答为 `{updateCount, updateResultList}` 裸结构(逐单 1-成功/0-失败) + +## 二、主线流程 + +### 1. 推送并锁定(WLTMS → 金蝶) + +``` +用户点「同步金蝶」 + ├─ doSyncKingdee(单据级事务):幂等预检 → A102-2/A302-2/A303-1/A103-1/A103-3 保存 → 回写 kingdeeBillNo/同步状态=synced + │ └─ 事务提交 + ├─ 锁定(lockAfterPush): + │ ├─ 每张金蝶目标单一行锁记录,先落 failed 占位(保证补偿扫表无遗漏) + │ ├─ 接口1对1:逐张调用 LOCK + SAVE,单张成败互不影响 + │ ├─ 单张应答成功 → 该张翻 locked ──→ 单据冻结 + │ └─ 单张失败/超时 → 保持 failed,tryCount+1,失败原因落 fail_reason(主单同步状态不受影响) + └─ 附件联带上传(A902,原有动作,后置不受影响) +``` + +**锁定后的效果**: + +- 开票申请:`作废`入口被 `checkEditable` 拦截(提示"该单据已被金蝶锁定,不可修改") +- 结算单:synced 状态本身已拦截编辑/调整/作废,锁定期被同一组守卫覆盖 +- 付款申请:`作废`入口被 `checkEditable` 拦截(金蝶锁定中不可改动;草稿保存/删除仅限推送前,无需守卫) +- 重复点「同步金蝶」:推送幂等命中直接返回已有单号,已 locked 的锁记录跳过,不会重复锁 + +### 2. 金蝶侧解锁回调(金蝶 → WLTMS) + +金蝶在星瀚侧对单据解锁或删单后,回调 `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、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. 锁定失败补偿(定时兜底,不依赖人工) + +``` +KingdeeBillLockRetryTask(thirdParty.bfi.billLock.retryEnabled,默认每5分钟) + └─ 扫 lock_status=failed 且 try_count<5 的锁记录 + ├─ 接口1对1:逐张重调 lockBusiData,单张成败互不影响;成败结果批量落库 + ├─ 成功 → 翻 locked,进入正常锁定态 + └─ 失败 → try_count+1,超过 5 次停试,留 fail_reason 待人工排查 +``` + +## 三、状态视角 + +**锁记录状态机**: + +``` +failed(锁定失败,待补偿)→ locked(金蝶锁定中)→ unlocked(终态) +``` + +同单重新推送生成新锁记录,走新一轮循环。 + +付款申请双路解锁共用同一还原口径(unlockBill,ADR-0005):① 金蝶删单/解锁回调(本流程);② A103-2 定时回写"已同步单据连续两轮查询消失"兜底。两路幂等由 kingdeeStatus 状态机保证,非已生成/付款中状态自动跳过。 + +**业务单据生命周期**(以结算单为例): + +``` +草稿 → 提交 → 审批通过 ──同步金蝶──→ synced(金蝶单已生成) + │ │ + │ ├─ 锁定成功:locked(冻结,不可动) + │ │ │ + │ │ ├─ 金蝶锁单(SAVE):unlocked,流水留痕 + │ │ └─ 金蝶删单(DELETE):作废 + 同步信息还原 unsynced + │ │ (结算明细/付款/开票关联的后续动作 + │ │ 由各自前置校验自然拦截重推) + │ └─ 锁定失败:failed,定时补偿重锁 + └─ 推送失败:failed(可重推) +``` + +## 四、关键设计点 + +1. **先占位再锁**:锁记录落库先于远程调用,补偿任务扫 `failed` 就是完整的待重试集合,不漏单 +2. **锁失败不回滚推送**:金蝶单已生成,本地回滚只会制造断链;代价是短暂"已推未锁"窗口,由补偿任务闭合 +3. **1对1逐张锁**:lockBusiData 单次仅支持一个源单号对一个金蝶单号,逐张调用互不连坐;成败结果 updateBatchById 批量落库,避免逐张 update +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 显示原因; + `failed` 且 `tryCount≥5` 红 tag「锁定失败·已达重试上限」提示重推同步金蝶;null 不显示 +7. **付款申请纳入**(2026-10-10 新增):一张付款申请=一张金蝶付款申请单,1对1约定天然满足,无需逐张拆分; + 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 实现契约即可复用 + +## 五、代码落点速查 + +| 内容 | 位置 | +| --- | --- | +| 锁单 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分钟) | +| 推送后锁定触发 | `FormalSettlementServiceImpl.syncKingdee`、`InvoiceApplicationServiceImpl.syncKingdee`、`PaymentApplicationServiceImpl.syncKingdee`(均为事务提交后) | +| 删单还原 | `FormalSettlementServiceImpl.voidByKingdee`、`InvoiceApplicationServiceImpl.voidByKingdee`;付款申请复用 `PaymentApplicationServiceImpl.unlockBill`(ADR-0005 口径,回调分发于 `KingdeeBillLockServiceImpl.restoreOnKingdeeDelete`) | +| 建表脚本 | `doc/sql/transport/blade_kingdee_bill_lock_20261009.sql` | + +## 六、联调前配置 + +1. Nacos 配置 `thirdParty.bfi.billLockUrl`:lockBusiData 真实路径(代码默认占位 `/ierp/kapi/app/billLock/lockBusiData`,以金蝶最终提供为准) +2. Nacos 开启补偿:`thirdParty.bfi.billLock.retryEnabled: true`(按环境) +3. 向金蝶提供回调地址:网关地址 + transport 服务路由 + `/kingdee-bfi/unLockNotifyBusiData`; + 免登免授权已三层配置(照 BladeX 两层鉴权模型): + ① 第一层 Token 放行:`blade.secure.skip-url` 放行 `/blade-transport/kingdee-bfi/**` 与 `/kingdee-bfi/**`; + ② 第二层注解放行:端点类级 `@PreAuth(AuthConstant.PERMIT_ALL)`(即 permitAll())+ `@TenantIgnore`; + ③ 框架层规则兜底:`blade.secure.auth` 追加两 pattern 的 `permitAll()` 规则(模板与 Nacos 实际均已配)。 + Nacos 实际配置已核对一致;环境级 blade-dev/test/prod.yaml 无 secure 段覆盖。 + 联调验证:服务启动后无令牌 POST 应返回业务参数校验错误(JSON),返回 401「请求未授权」即白名单未生效 +4. 与金蝶确认 WLTMS 单据类型编码(SETTLEMENT/INVOICE_APPLY/PAY_APPLY)与 DELETE 还原口径 + (付款申请 DELETE 不作废源单、走生成失败+人工重推,与 A103-2 反审核删除兜底同口径; + 2026-10-15 付款申请链路已联调实测通过:回调方向反转定位、DELETE 还原、解锁标识均验证 OK) diff --git a/doc/docs/业务流程/2026-10-13-金蝶应付应收收票单明细分录拆分与物料映射逻辑梳理.md b/doc/docs/业务流程/2026-10-13-金蝶应付应收收票单明细分录拆分与物料映射逻辑梳理.md new file mode 100644 index 00000000..d6ae7d70 --- /dev/null +++ b/doc/docs/业务流程/2026-10-13-金蝶应付应收收票单明细分录拆分与物料映射逻辑梳理.md @@ -0,0 +1,177 @@ +# 金蝶应付/应收/收票单明细分录拆分与物料映射逻辑梳理(含取值口径) + +> 更新日期:2026-10-13 +> 适用链路:财务应付单(A102-2)、财务应收单(A302-2)、收票单(A303-2)明细分录 +> 核心代码:`blade-transport` 模块 `org.springblade.transport.support.KingdeeApBillSyncSupport`(共享解析) + +--- + +## 1. 总体设计 + +应付单、应收单、收票单三个金蝶单据的明细分录**共用同一份解析结果**(`KingdeeApBillSyncSupport.BfiEntryGroup`),拆分规则、物料映射、计量单位、税率分组只在应付支撑组件解析一次,三个消费端(`pushApBill` / `pushArBill` / `pushReceivedInvoice`)直接逐条转 DTO,保证三单明细同源同口径、分录标识同键、金蝶侧逐行勾稽。 + +``` +正式结算单(blade_formal_settlement) + ├── 结算明细行(blade_formal_settlement_detail,一个运单/一个车牌) + │ └── 货物费用行(blade_formal_settlement_detail_fee,一车多货按货物拆) + └── 结算合计费用项(blade_formal_settlement_summary_fee,仅取手动添加 manualFlag=1) + ↓ 解析(KingdeeApBillSyncSupport.resolveEntryGroups) + BfiEntryGroup 列表(货物 → 费用大类 → 税率 三级拆分后) + ↓ 同一份结果 + 应付单 A102-2 / 应收单 A302-2 / 收票单 A303-2 明细分录 +``` + +--- + +## 2. 取值来源总表 + +| 数据 | 来源表/配置 | 字段 | 用途 | +|---|---|---|---| +| 结算明细行 | `blade_formal_settlement_detail` | `document_no` 单据号、`vehicle_no` 车牌号、`transport_type` 运输方式、`cargo_name` 货物名称、`transport_quantity` 运输量、`fee_items_json` 费用项JSON、`settlement_amount_tax/no_tax` 结算金额(含税/不含税) | 无货物费用行时的唯一货物行;车号/运输方式/数量取数 | +| 货物费用行 | `blade_formal_settlement_detail_fee` | `cargo_name`、`transport_quantity`、`fee_items_json`、`settlement_amount_tax/no_tax`、`line_no` | 一车多货按货物拆行的金额与数量来源 | +| 结算合计费用项 | `blade_formal_settlement_summary_fee` | `fee_type` 费用类型、`fee_item` 费用项、`settlement_amount`、`manual_flag` | 手动添加费用(manualFlag=1)每行单独一条分录 | +| 合同 | `blade_contract_manage` | `billing_plan_json` 计费方案JSON(规则含 `feeType`/`feeItem`/`billingUnit`/`taxRate`) | 税率分组、计量单位匹配 | +| 计量单位档案 | system 服务 `getMeasurementUnits()` | 单位名称 → `unit_code` | 计费单位 → 金蝶计量单位编码 | +| 费用项档案 | `blade_fee_item`(system 服务 `getFeeItems()`) | `name` 费用项名、`fee_category` 费用类型编码、`tax_rate` 税率(备用) | 费用大类判定 | +| 业务字典 | 字典 `fee_category` | 编码 → 名称(如"装卸"、"物流配送") | 费用类型编码翻译成名称后做大类判定 | +| 项目 | `ProjectApply`(`project_id` 关联) | `business_mode` 业务模式、`business_type` 业务类型 | 国内/国际运输判定(含"国际"即国际) | +| 物料/计量单位映射 | Nacos `thirdparty.bfi.material.*`(`BfiProperties.Material`) | 见第 6 节 | 物料编码、计量单位编码映射 | + +`fee_items_json` 结构:`{"费用项名": 金额, ...}`(如 `{"运输费": 3120, "装卸费": 800}`),保持行序解析。 + +--- + +## 3. 明细拆分规则(核心逻辑) + +### 3.1 三级拆分:货物 → 费用大类 → 税率 + +对每条结算明细行(一个运单,同一车牌号就是一个车): + +1. **按货物拆**(一车多货):存在货物费用行(`blade_formal_settlement_detail_fee`)时,每个货物费用行单独拆;无货物费用行时结算明细行本身即唯一货物。 +2. **按费用大类拆**:货物行的 `fee_items_json` 里逐个费用项判定大类(见第 4 节)——装卸大类、运输大类(非装卸全部归运输类)。 +3. **按税率拆**:同大类内按计费方案税率分组,**税率不同分开多行**;费用项未配置税率时归入该大类"无税率组"(不拆)。 + +### 3.2 各字段取值 + +| 分录字段 | 运输大类行 | 装卸大类行 | 结算合计手动费用行 | +|---|---|---|---| +| 物料编码 | 按项目业务模式/运输方式映射(第 6 节) | `material.loading` 装卸物料 | 非运费类→`material.loading`;运费类→同运输行映射 | +| 计量单位 | 费用项/费用类型匹配合同计费方案计费单位 → 匹配计量单位档案编码 | 同左 | 同左 | +| 数量 | **运单运输量**(`transport_quantity`,未维护兜底 1),按税率拆多行时**每行都取运输量** | 1 | 1 | +| 含税金额 | 组内费用项金额合计 | 组内费用项金额合计 | 合计行 `settlement_amount` | +| 单价 | 金额 ÷ 数量(2 位小数反算) | 金额 ÷ 1 = 金额 | 金额 ÷ 1 = 金额 | +| 不含税金额 | **仅该货物行第一条运输类分录携带**整行 `settlement_amount_no_tax`(拆分行无分类不含税金额,保证不含税合计勾稽),其余行不传 | 不传 | 不传 | +| 运输货物 awrp_transport | 货物名称 | 货物名称 | 不传 | +| 车号 awrp_lpn | 结算明细车号(整单一个车) | 结算明细车号 | 不传 | +| 分录标识 awrp_entry_code | `单据号-货物序号-运输费[-税率]` | `单据号-货物序号-装卸[-税率]` | `结算单号-费用类型[-费用项]`(不变) | + +- 货物序号:货物费用行按行序 1、2、3…;无费用行时为 1。单据号为空兜底明细行 id。 +- 税率后缀:`-9`、`-6.5` 格式(去尾零);税率未配置不拼。 +- entryCode 是金蝶候选键,收票单与应付单分录同键勾稽。 + +### 3.3 残差勾稽 + +货物行结算金额(含税)与费用项金额合计的**差额(残差)并入运输类无税率组**,保证:同一货物行拆出的全部分录含税金额合计 = 该行结算金额。典型场景:历史数据 `fee_items_json` 未含"运输费"项(运费金额在 `freight_amount` 字段)时,自动补一条运输类无税率分录承接差额。 + +### 3.4 三个标准场景 + +**场景 1:同费用大类、多税率(运输费存在 2 种税率)** +运输费 2000 元@9% + 附加运输补贴 1120 元@6%,运单 10 吨(水路-国内)→ 拆 2 行,每行数量均取运输量 10: +- 行 1:物料=水路运输编码,数量 10,金额 2000,单价 200 +- 行 2:物料=水路运输编码,数量 10,金额 1120,单价 112 + +**场景 2:包含装卸大类** +运输费 3120 元@9% + 装卸费 800 元@6%,10 吨 → 拆 2 行: +- 运输行:物料=水路运输编码,数量 10,单价 312,金额 3120 +- 装卸行:物料=装卸编码(XDWL11002004),数量 1,单价 800,金额 800 + +**场景 3:结算单手动添加费用** +手动新增仓储服务费 500 元(非运费类)→ 单独一行:物料=装卸编码,数量 1,单价 500,金额 500。 + +--- + +## 4. 费用大类判定(档案 + 字典优先,关键字兜底) + +判定链路(`isLoadingCategory` / `isFreightCategory`): + +``` +费用项名 → blade_fee_item.fee_category(费用类型编码) + → 字典 fee_category 翻译成费用类型名称 + → 名称含"装卸" → 装卸大类;否则运输大类 +费用项不在档案 → 回退名称关键字判定(含"装卸"=装卸类;含"运费/运输费"=运费类) +``` + +- 判定对象从"费用项名"升级为"档案翻译后的费用类型名称":费用项叫"吊装作业"但档案归到"装卸"类型,也会正确映射装卸物料。 +- 结算合计手动费用行的运费判定:档案分类名称 / `fee_type` / `fee_item` 任一含"运费"或"运输费" → 运费类映射运输物料;否则装卸物料(场景 3)。 +- 档案读取失败直接拦截推送(与计量单位档案口径一致);分类映射缓存在 `KingdeeSyncContext.feeCategoryByName`,批量同步只加载一次。 + +--- + +## 5. 税率取值 + +- **来源**:合同计费方案 JSON(`blade_contract_manage.billing_plan_json`),每条规则的 `taxRate` 字段(百分数文本,"9" = 9%),按规则的 `feeItem`/`feeType` 建 Map(先到先得)。合同保存时已强制校验税率 0~100、最多 2 位小数。 +- **用途**:仅用于**拆行分组**和分录标识后缀,**不传金蝶**——分录 DTO 的 `taxrateid_number` 为待金蝶确认字段,暂不送。 +- **匹配方式**:按费用项名精确匹配;未配置税率的费用项归入无税率组。 +- 结算合计手动费用行不取税率(每行单独一条,不分组)。 + +--- + +## 6. 物料编码映射(Nacos 可配) + +配置前缀 `thirdparty.bfi.material.*`(`BfiProperties.Material`,代码带默认值,Nacos 覆盖即改,无需改代码): + +| 配置项 | 默认值 | 说明 | +|---|---|---| +| `domestic."["公路运输"]"` | XDWL11010001 | 国内道路货物运输服务—公路运输 | +| `domestic."["铁路运输"]"` | XDWL11010002 | 国内道路货物运输服务—铁路运输 | +| `domestic."["水路运输"]"` | XDWL11010003 | 国内道路货物运输服务—水路运输 | +| `domestic."["航空运输"]"` | Item-00001082 | 国内道路货物运输服务—航空运输 | +| `international` | Item-00001081 | 国际货运代理服务费(业务模式/业务类型含"国际") | +| `loading` | XDWL11002004 | 装卸(装卸大类 + 手动添加非运费杂费) | + +映射优先级(运输类):装卸大类 → `loading`;国际业务 → `international`;国内 → `domestic[运输方式]`,**运输方式未配置映射直接拦截报错(无兜底)**。物料编码缺失直接拦截报错。 + +**计量单位取值链路**:费用项/费用类型匹配合同计费方案 `billingUnit` → 按计量单位档案(单位名称匹配)取 `unit_code`;任一环节缺失拦截报错。 + +**⚠️ Nacos 配置坑**:`domestic` 的中文 key **必须用方括号写法** `"[公路运输]": xxx`,裸写中文 key 会导致 Spring Boot 绑定失败、服务无法启动(`No converter found from String to Map`)。 + +--- + +## 7. 消费链路与同源关系 + +| 单据 | 组件/方法 | 分录取数 | +|---|---|---| +| 财务应付单 A102-2 | `KingdeeApBillSyncSupport.pushApBill` | `entryGroups` 逐条转 `BfiApBillSaveEntryDTO` | +| 财务应收单 A302-2 | `KingdeeArBillSyncSupport.pushArBill` | 同一解析(`resolveArEntryGroups` 复用应付解析),逐条转 `BfiArBillSaveEntryDTO` | +| 收票单 A303-2 | `KingdeeReceivedInvoiceSyncSupport.pushReceivedInvoice` | 分摊结算单的同一解析结果,逐条转 `BfiReceivedInvoiceSaveEntryDTO`(无计量单位字段) | +| 付款申请 A103-1/A103-3 | 付款链路 | 同源解析,分录携带 `formalSettlementId` 按结算单勾稽各自应付单 | + +幂等与防重:防重键 `awrp_src_code`(正式结算单号_结算明细单据号拼接)不变;分录标识 `awrp_entry_code` 因拆行规则变化,仅适用于新推送数据。 + +--- + +## 8. 代码位置索引 + +| 逻辑 | 位置 | +|---|---| +| 分录解析入口(多结算单/单结算单) | `KingdeeApBillSyncSupport.resolveEntryGroups` | +| 货物行拆分(货物→大类→税率) | `appendDetailEntries` / `appendCargoLines` | +| 费用项分组纯函数 | `splitFeeItems`(含残差勾稽,单测 `KingdeeEntrySplitTest`) | +| 费用大类判定 | `isLoadingCategory` / `isFreightCategory` / `feeCategoryByName` | +| 物料映射 | `resolveMaterialNumber`(读 `bfiProperties.getMaterial()`) | +| 计量单位解析 | `resolveEntryMeasureunit`(YAML 优先)→ `resolveMeasureunitNumber`(计费方案→档案) | +| 税率/计费单位解析 | `billingRuleUnits`(解析 `billing_plan_json` 一次取两样) | +| 结算合计手动费用行 | `appendSummaryEntry` | +| 配置类 | `org.springblade.transport.config.BfiProperties.Material` | +| Nacos 配置样例 | `doc/nacos/third-party-api.yaml`(thirdParty.bfi.material 段) | + +--- + +## 9. 已知口径与限制 + +1. **运输大类内部多税率**:靠费用项级税率分组实现;同大类多税率时每个税率组一行、每行数量都取运输量。未配置税率的费用项合入无税率组不拆。 +2. **非装卸的杂项费用**(运单自带,如补贴、其他费用):归运输大类、随运输行映射运输物料;金额残差兜底并入运输类无税率组。 +3. **不含税金额**:拆分后仅运输类首行携带整行不含税金额(合计勾稽正确,单行口径不精确);装卸行/手动费用行不传(`e_amount` 非必传)。 +4. **税率不传金蝶**:`taxrateid_number` 待金蝶确认编码规则(如 V13)后再补。 +5. **规格型号**:金蝶保存 DTO 无该字段;车号/货物已有 `awrp_lpn`/`awrp_transport` 传参口径。 +6. **装卸行计量单位**:走合同计费方案匹配(装卸费用项的计费方案规则计费单位 → 计量单位档案),装卸费用项未配计费方案规则时拦截报错,需在合同计费方案中维护。 diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a102-应付单对照.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a102-应付单对照.png new file mode 100644 index 00000000..77fdd786 Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a102-应付单对照.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a103-3-预付款对照.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a103-3-预付款对照.png new file mode 100644 index 00000000..73b5f4f2 Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a103-3-预付款对照.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a103-付款申请对照.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a103-付款申请对照.png new file mode 100644 index 00000000..5aeaf6ce Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a103-付款申请对照.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a301-2-公告同步对照.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a301-2-公告同步对照.png new file mode 100644 index 00000000..4b16d08d Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a301-2-公告同步对照.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a301-3-认领推送对照.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a301-3-认领推送对照.png new file mode 100644 index 00000000..2ecd50fa Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a301-3-认领推送对照.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a301-3-金蝶认领结果.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a301-3-金蝶认领结果.png new file mode 100644 index 00000000..022c9a96 Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a301-3-金蝶认领结果.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a302-应收单对照.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a302-应收单对照.png new file mode 100644 index 00000000..7a39bace Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a302-应收单对照.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a303-1-开票张次对照.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a303-1-开票张次对照.png new file mode 100644 index 00000000..f088fb92 Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a303-1-开票张次对照.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a801-2-发票查看下载.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a801-2-发票查看下载.png new file mode 100644 index 00000000..63cbf98a Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a801-2-发票查看下载.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a801-4-外币汇率推送.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a801-4-外币汇率推送.png new file mode 100644 index 00000000..3bcf8a36 Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a801-4-外币汇率推送.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a902-客商编码对照.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a902-客商编码对照.png new file mode 100644 index 00000000..82271e1c Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a902-客商编码对照.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a902-金蝶客户接收.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a902-金蝶客户接收.png new file mode 100644 index 00000000..7e053b97 Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a902-金蝶客户接收.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a902-附件上传金蝶侧.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a902-附件上传金蝶侧.png new file mode 100644 index 00000000..329d4645 Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a902-附件上传金蝶侧.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-收票金蝶状态列表.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-收票金蝶状态列表.png new file mode 100644 index 00000000..2a30d9f3 Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-收票金蝶状态列表.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).md b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).md new file mode 100644 index 00000000..6eeac324 --- /dev/null +++ b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).md @@ -0,0 +1,641 @@ +# 金蝶(星瀚)BFI 全链路梳理:前端入口 → 后端链路 → 定时任务(业务域汇总 + 操作手册) + +> 整理日期:2026-10-14 +> 代码基线:`tms-api` 当前 master(`805ca9d3`,2026-10-13),全量按当前代码重新核实(上一版总纲基线为 09-24 `50573e7`)。 +> 本文替代《2026-09-27-金蝶(星瀚)BFI接口调用全链路梳理(按业务域汇总·更新版)》作为新总纲: +> - **组织方式改为三层**:每个业务域按「前端入口(页面/按钮)→ 后端链路(触发/顺序/口径/落库)→ 定时任务」梳理; +> - 新增**操作手册**(第 11 节):按业务时序给出端到端操作步骤与异常处理操作; +> - 自 09-27 以来代码有 40+ 提交(09-28~10-13),**收票回写缺陷已修、调试残留已清、新增锁单/解锁、审批自动同步、failed 重推、票池镜像读回切、全票池定时同步、A801-5 黑名单、A801-2/A902 附件接线、收款认领重构**等,接口清单、分录口径、状态机均有实质变化,全文按当前代码重写。 + +--- + +## 目录 + +- [0. 本次更新要点(09-27 → 10-14)](#0-本次更新要点09-27--10-14) +- [1. 集成总览与公共机制](#1-集成总览与公共机制) +- [2. 业务域一:客商档案(A902-1 / A902-2 / A801-5 黑名单)](#2-业务域一客商档案a902-1--a902-2--a801-5-黑名单) +- [3. 业务域二:财务应付与付款(A102-2 / A102-4 / A103-1 / A103-3 / A103-2 / A103-4)](#3-业务域二财务应付与付款) +- [4. 业务域三:财务应收与开票(A302-2 / A302-4 / A303-4 / A303-1 / A303-5)](#4-业务域三财务应收与开票) +- [5. 业务域四:收票与金蝶票池(A801-1 / A303-2 / A302-6 / A801-3 / A801-2 / A902 附件)](#5-业务域四收票与金蝶票池) +- [6. 业务域五:收款认领(A301-2 / A301-3 / A301-1)](#6-业务域五收款认领a301-2--a301-3--a301-1) +- [7. 业务域六:公共取数(A801-4 汇率)](#7-业务域六公共取数a801-4-汇率) +- [8. 全景:接口依赖与先后顺序](#8-全景接口依赖与先后顺序) +- [9. 状态机汇总](#9-状态机汇总) +- [10. 定时任务与配置开关汇总](#10-定时任务与配置开关汇总) +- [11. 操作流程手册](#11-操作流程手册) +- [12. 风险与待确认清单](#12-风险与待确认清单) + +--- + +## 0. 本次更新要点(09-27 → 10-14) + +| # | 变化 | 说明 | +|---|---|---| +| 1 | **⚠️ 旧总纲缺陷清单大面积清零** | A801-3 定时回写防重键已修复(改按收票登记 id,`7b98e33d` 系列);`if(true)` 调试组合块(强制物料/随机取物料)**已全部删除**;客商定时 KS000067 硬编码**已移除**;客商银行 `bankNumber` 硬编码已改真实联行号;A103-3 `e_bebank_number` 改取客商收款账户联行号(与 A103-1 口径统一) | +| 2 | **★新增横切机制一:单据锁定与解锁(10-09/10)** | BFI 新增 `lockBusiData`(出站锁单)与 `POST /kingdee-bfi/unLockNotifyBusiData`(金蝶回调,免登放行)。正式结算单/开票申请/付款申请推送成功后联带锁金蝶单据;金蝶侧删单回调还原本地状态;锁失败定时+手动补偿;列表回显锁状态。专项文档:`2026-10-09-金蝶单据锁定与解锁业务流程….md` | +| 3 | **★新增横切机制二:审批通过自动同步(10-10)** | `KingdeeApprovalSyncListener`(AFTER_COMMIT 事件)监听 4 域审批通过事件,自动调各自 `syncKingdee`;4 个 Nacos 开关默认 false;失败统一落 `failed`,审批接口回显警告文案;MK 流程审批回调统一改走 `service.approve`,OA 路径同样触发。专项文档:`2026-10-05-审批通过自动同步金蝶设计….md` | +| 4 | **★新增横切机制三:failed 单据定时重推 + 推送失败统一落 failed(10-10)** | `KingdeeAutoSyncRetryTask` 定时重推 4 域 failed 单据(不扫 unsynced);结算/开票链路异常也落 `failed`(此前只回滚保持 unsynced),旧总纲 11.2-#3 缺陷关闭 | +| 5 | **★票池架构回切:镜像读 + 全票池定时同步(10-05/10-08)** | 选票接口改读本地镜像表(本地真分页);票号精确查未命中才窄查 A801-1 回源;新增 `InvoiceReceiptKingdeeSyncTask` 全票池瘦身定时同步(invoiceTypes 白名单下推远端、2.5 万窗口硬上限二分时间片、绑定回填);结算单认领确认补上**保存重查闸门**(`assertInvoicesClaimable`)。旧总纲「实时回源、无定时」口径作废 | +| 6 | **★分录拆分口径重写(10-13)** | 应付/应收/收票分录改为**按 货物 → 费用大类(装卸/运输)→ 税率 三级拆分**,一车多货再按货物拆;0 元费用项/货物行跳过;物料编码改 Nacos 配置 `thirdparty.bfi.material.*`(`BFI_MATERIAL_NUMBER_MAP` 常量与 `blade_kingdee_material` 表已废弃);**计量单位当前不送**;`specification`(规格型号)字段本期不传。专项文档:`2026-10-13-金蝶应付应收收票单明细分录拆分与物料映射逻辑梳理.md` | +| 7 | **★收款认领链路重构(09-28~10-01)** | 认领本地化:先本地认领落库,**推送改定时+手动双入口按流水归集**(`ReceiptClaimPushTask`,旧总纲「全部手动、无定时」作废);`awrp_src_code`=分摊结算单号下划线拼接(重试复用幂等);分录单条、`e_corebillno` 传**结算单金蝶内码**(缺内码拦截不推);已推送认领禁止作废;A301-1 回写以真实收款单号覆盖占位单号 | +| 8 | **★A801-5 黑名单查询启用(10-01/10-08)** | 客商提审时按发票纳税人识别号(去重大写)调 A801-5;A 类命中硬拦截,B 类默认放行告警(可配置拦截),**查询异常上抛阻断提审** | +| 9 | **附件链路接线(09-27~10-05)** | A902 附件上传:正式结算/开票申请/付款申请/收票 4 条链路推送成功后联带上传(失败不污染主单,重推补传);A801-2 附件下载:前端统一走 `GET /invoice-receipt/invoice-pool/attachment/download` 流式代理,PDF 版式(`taxPdfUrl`/`pdfurl`)优先,OFD 按 `fileType=4` 识别 | +| 10 | **开票申请 A303-1 口径更新(09-30~10-11)** | 上游来源单据 `awrp_src_con_no`=首张结算单金蝶应收单防重键;特殊票种:纯货物运输行送 E04 否则 00;E04 随单报送货物运输单据体(车牌/起运/到达/货物/种类去重归并);`hsbz` 统一 0(不含税);旅客运输/代收车船税单据体 DTO 已建未接 | +| 11 | **组织编码改取金蝶部门编码(10-01)** | 应付/应收/开票的组织字段取 `SysCache.getDept().getKingdeeDeptCode()`,为空直接拦截(需在部门档案维护金蝶组织编码) | +| 12 | **定时任务加 single-flight 分布式锁(09-28/29)** | 全部金蝶定时任务经 `KingdeeSyncLockSupport.runSingleFlight`(Redis 锁,键含租户 id),多副本与手动触发共用一道闸;付款结果回写开关在 Nacos 样例中已显式开启,其余任务默认关闭 | +| 13 | **付款申请「消失解锁」兜底(09-29)** | A103-2 连续 2 轮查询不到已生成单(Redis 计数,TTL 2100s)→ 自动解锁置 `failed`,可重推 | + +--- + +## 1. 集成总览与公共机制 + +### 1.1 调用架构(前端 → 后端 → 金蝶) + +``` +tms-erp-web(Vue3,src/views/** + src/api/**) + │ HTTP(网关按服务名路由 /blade-transport/**) + ▼ +blade-transport(Controller → ServiceImpl;推送编排 + 状态落库 + 锁单 + 审批事件) + │ IBfiService(参数校验 + Redis 令牌缓存) + ▼ +blade-bfi-api(IBfiClient @FeignClient url=${thirdParty.bfi.baseUrl}) + │ access_token(业务令牌) + Authorization(iPaaS 网关头,BfiRequestInterceptor 注入) + ▼ +iPaaS 网关(/gwzh/BFI,不直连金蝶) + ▼ +金蝶星瀚 /ierp/kapi/... + +反向:金蝶删单 ──▶ POST /blade-transport/kingdee-bfi/unLockNotifyBusiData(免登 skip-url) +定时:blade-transport task/ 包 7 个任务(Spring @Scheduled,全库无 PowerJob/XxlJob 接线) +``` + +- 无 Feign 重试(NEVER_RETRY)、无 fallback;失败抛 `BfiException`,批量循环逐条 catch,单条失败不中断整批。 +- 令牌:Redis `blade:third:bfi:token`,TTL=expires_in−120s;`setIfAbsent` 写入(已有缓存不覆盖)。 +- 配置前缀两套并存(读同一批 Nacos 键):bfi-api `third-party.bfi.*`(baseUrl/认证/令牌),transport `thirdparty.bfi.*`(业务开关/口径/映射)。样例:`doc/nacos/third-party-api.yaml`。 + +### 1.2 接口清单总表(2026-10-14,含在用状态) + +来源:`blade-bfi-api/.../feign/IBfiClient.java`(URL 可被 `thirdParty.bfi.*Url` 覆盖)。 + +| 操作码 | 星瀚端点 | 用途 | 业务侧在用状态 | +|---|---|---|---| +| A100-1 | POST /ierp/kapi/oauth2/getToken | 获取令牌 | ✅ 自动(每次调用前) | +| A100-2 / A100-3 | verifyToken / withdrawToken | 校验/撤回令牌 | ❌ 已实现无调用 | +| A102-2 | POST …/awrp/ap/ap_finapbill/save | 财务应付单保存 | ✅ 正式结算单(应付)推送 | +| A102-4 | GET …/awrp/ap/ap_finapbill/query | 财务应付单查询 | ✅ 预检 / 硬校验 / 批量预热 | +| A103-1 | POST …/awrp/ap/ap/ap_finapbill/push | 应付下推生成付款申请 | ✅ 付款申请推送(单分录) | +| A103-2 | GET …/awrp/ap/ap_payapply/query | 付款申请单查询 | ✅ 推送预检 + 状态回写 + 消失解锁计数 | +| A103-3 | POST …/awrp/ap/ap/ap_payapply/save | 预付款申请保存 | ✅ 预付款直推 | +| A103-4 | GET …/awrp/cas/cas_paybill/query | 付款处理查询 | ✅ 付款记录回写(含 voucher_json) | +| A301-1 | GET …/awrp/cas/cas_recbill/queryRecBill | 收款单查询 | ✅ 认领结果回写(手动) | +| A301-2 | GET …/awrp/cas/cas_claimannounce/query | 收款认领公告查询 | ✅ 流水同步(手动 + 定时认领推送前置重查) | +| A301-3 | POST …/awrp/cas/cas/cas_claimbill/claimbillPush | 收款认领结果推送 | ✅ 定时 + 手动双入口,按流水归集 | +| A302-2 | POST …/awrp/ar/ar_finarbill/save | 财务应收单保存 | ✅ 正式结算单(应收)推送 | +| A302-4 | GET …/awrp/ar/ar_finarbill/query | 财务应收单查询 | ✅ 预检 / 硬校验 / 定时勾稽纠偏 | +| A302-6 | POST …/awrp/ap/ap/ap_invoice/assign | 收票指定应付 | ✅ 收票推送第二步 | +| A303-1 | POST …/awrp/sim/sim_original_bill/save | 开票申请单新增 | ✅ 按发票张次逐张推送 | +| A303-2 | POST …/awrp/ap/ap_invoice/cusBatchSave | 收票单批量保存 | ✅ 收票登记推送 | +| A303-4 | GET …/awrp/sim/sim_original_bill/query | 开票申请单查询 | ✅ 批量 billno 幂等预检 / 附件补传反查星瀚内码 | +| A303-5 | POST /ierp/kapi/app/sim/openApi | 开票发票查询(票易通) | ✅ 定时回写开票结果 | +| A801-1 | POST …/awrp/rim/rim_invoice/query | 全票池发票查询(rim 契约) | ✅ 定时全量同步 + 票号回源 + 保存/认领闸门 + 附件回源 | +| A801-2 | POST …/awrp/bosext/attachment/download | 进项发票文件下载 | ✅ **已接线**:附件查看/下载流式代理 | +| A801-3 | GET …/awrp/ap/ap_invoice/apinvoicequery | 收票单查询 | ✅ 推送预检 + 定时回写(防重键已修复=登记 id) | +| A801-4 | GET …/awrp/base/bd_exrate_tree/Forexquery | 汇率查询 | ✅ 结算页面取汇率 | +| A801-5 | POST …/awrp/bdm/bdm_blacklist_config/blacklistquery | 黑名单查询 | ✅ **新增启用**:客商提审校验 | +| A902 | POST /ierp/kapi/v2/frame/attachment/uploadFile | 附件上传 | ✅ **已接线**:4 条推送链路联带上传 | +| — | POST /API_lockAction_Router | lockBusiData 单据锁定 | ✅ 推送成功后锁单({updateCount,updateResultList} 应答) | +| — | (回调)POST /kingdee-bfi/unLockNotifyBusiData | 解锁/删单通知(unLockNotifyBusiData) | ✅ 金蝶回调本系统(免登放行) | + +> 仍未实现:A901-1 供应商查询、A913 项目、A101-1/2 合同、A102-1/A302-1 暂估单(合同/项目走合同合规系统)。 +> 查询时间窗约束:A103-2/A103-4/A102-4/A302-4/A303-4/A301-1 ≤ 1 个月;A902-1 ≤ 6 个月;A301-2 无时间窗参数(按认领人+分页,时间窗口取 `thirdParty.bfi.claimAnnounce.months` 默认 6)。 + +### 1.3 防重键(候选键)总表(2026-10-14 口径) + +金蝶侧按「来源系统 + 来源单据号」幂等(`awrp_src_sys` + `awrp_src_code`)。组装:`ExternalSrcCodeSupport.buildSrcCode`(下划线拼接、去空白、去重保序)。 + +| 金蝶单据 | awrp_src_code | srcType | 分录键 | 预检接口 | +|---|---|---|---|---| +| 财务应付单 A102-2 | `正式结算单号_结算明细单据号1_…` | TMS财务应付单 | 按 货物-费用大类-税率 拆分行顺序生成 | A102-4 | +| 财务应收单 A302-2 | 同应付口径 | TMS财务应收单 | 同应付口径 | A302-4 | +| 付款申请 A103-1 | `paymentNo` | 配置 `0KzXs` | 固定 `paymentNo-1`(单分录);勾稽键 `awrp_finapbill_entryid`=主结算单应付防重键(本地组装) | A103-2 | +| 预付款申请 A103-3 | 付款申请号口径 | `0KzXs` | `paymentNo-1` | A103-2 | +| 开票申请 A303-1 | billno:单张=`application_no`,多张=`application_no-张次序号`;`awrp_src_con_no`=首张结算单应收防重键 | `0KzXs` | 开票商品行 id | A303-4(批量 billno) | +| 收票单 A303-2 | **收票登记 id** | TMS收票登记 | 与 A102-2 分录同源同口径 | A801-3(预检与回写同键) | +| 收款认领 A301-3 | **分摊结算单号下划线拼接**(重试复用同一编码)★改 | WLTMS | 单条:seq/e_corebillno=第一张结算单**金蝶内码** | A301-1(sourcebillnumber 匹配) | +| 客户 A902-2 | `number`=TMS 客商编号(KS+6位);`societycreditcode` 为金蝶侧候选键 | awrp_datasourcesys=WLTMS | — | A902-1 | + +### 1.4 公共档案取数(应付/应收/收票复用 `KingdeeApBillSyncSupport`) + +| 取数项 | 规则(当前代码) | +|---|---| +| 组织 org/payorg/recorg | 合同/申请部门 → `SysCache.getDept().getKingdeeDeptCode()`(**金蝶部门编码**,为空拦截)★改 | +| 币种 | 合同 `settlement_currency`,RMB→CNY | +| 汇率 exchangerate/exratedate | 结算单页面维护值(A801-4 回填),未维护不送 | +| 往来户 asstact_number | 名称 → 本地 `blade_customer_archive` 匹配取 `customer_code`,查不到报错拦截 | +| 往来类型 asstacttype | **固定 `bd_customer`**★改(按合同类别区分供应商的逻辑已注释停用) | +| 物料编码 | 运输类按 运输方式/国际/装卸 → Nacos `thirdparty.bfi.material.domestic.{railway,highway,waterway,aviation}` / `international` / `loading` 映射(默认 XDWL1101000x / Item-00001081 / XDWL11002004);运输方式未配置直接拦截 | +| 计量单位 | **当前不送**(DTO 字段保留,无 set 调用) | +| 金额 | 含税=结算金额;不含税:明细行有、手工合计费用项行不传 | +| 数量/单价 | 运输类=运单运输量(兜底 1;同税率拆多行每行都取运输量);装卸/杂费=1;单价=金额/数量反算 | +| 0 元行 | 费用项 signum()==0 与 0 元货物行**整行跳过**★新增 | +| 运输货物/车号 | 明细行传 `awrp_transport`(货物名称)/ `awrp_lpn`(车号);合计费用项行不传 | + +### 1.5 三大横切机制(新总纲新增,读业务域前先看) + +#### ① 审批通过自动同步(`KingdeeApprovalSyncListener`) + +``` +业务审批通过(页面「通过」或 MK/OA 流程回调,统一走 service.approve) + ▶ 事务提交后 AFTER_COMMIT 发布 KingdeeBillApprovedEvent + ▶ KingdeeApprovalSyncListener.onBillApproved(@TransactionalEventListener,fallbackExecution=true) + ▶ 按 4 个开关分别调用 syncKingdee(id): + settlement → FormalSettlementServiceImpl.syncKingdee (A102-2 / A302-2) + payment → PaymentApplicationServiceImpl.syncKingdee (A103-1 / A103-3) + invoiceApply→ InvoiceApplicationServiceImpl.syncKingdee (A303-1 按张次) + receipt → InvoiceReceiptServiceImpl 推送 (A303-2 + A302-6) + ▶ 失败:独立事务落 failed + ThreadLocal 失败原因 + ▶ 4 个审批 Controller 统一 pollSyncFailure() 拼警告返回前端:"审批通过,但自动同步金蝶失败:xxx" +``` + +- 开关(默认全 false):`thirdParty.bfi.autoSync.settlement / payment / invoiceApply / receipt`。 +- 客商档案不走事件机制:`CustomerArchiveController.approve` 内直接 `syncKingdee(id)`(失败不阻断审批)。 +- 推送成功后各链路在**同一方法内继续执行 lockAfterPush** 锁金蝶单据(见②)。 + +#### ② 单据锁定与解锁(`KingdeeBillLockService` + `KingdeeBfiPublicController`) + +``` +推送成功 ─▶ lockAfterPush(先落 blade_kingdee_bill_lock failed 占位 → lockBusiData → 翻 locked) +锁失败 ─▶ KingdeeBillLockRetryTask 定时补偿(cron thirdParty.bfi.billLock.retryCron,默认每5分钟,开关默认 false) + + 手动端点 POST /formal-settlement/retry-kingdee-bill-lock(通用,扫 failed 且 try_count<5) +金蝶删单 ─▶ POST /kingdee-bfi/unLockNotifyBusiData(免登,@TenantIgnore) + ├─ SETTLEMENT:作废结算单 + kingdee_sync_status 回 unsynced、清 kingdee_bill_no/id + ├─ INVOICE_APPLY:作废开票申请 + 同上还原 + └─ PAY_APPLY:不作废源单,解锁置 failed、清 kingdee_bill_id/no(可重推) +本地拦截 ─▶ 付款申请/开票申请作废前 checkEditable:locked 即抛「该单据已被金蝶锁定」 + (正式结算单作废未接 checkEditable,靠列表锁状态前端禁用) +列表回显 ─▶ mapActiveLocks() 填充 VO kingdeeLockStatus(locked/failed),前端展示「金蝶锁定中/锁定失败」并禁编辑 +``` + +- 收票单本期**不锁**(`KingdeeLockConstant` 映射预留)。 +- 付款申请另有**消失解锁**:A103-2 连续 2 轮查询不到已生成单(Redis 计数 TTL 2100s)→ 自动解锁置 failed。 + +#### ③ 定时任务 single-flight 分布式锁(`KingdeeSyncLockSupport`) + +- 所有金蝶定时任务经 `runSingleFlight`:RedisLockClient 零等待可重入锁,锁键=前缀+租户 id(无用户上下文用 default);`blade.lock.enabled=false` 降级直跑。 +- 手动端点与定时任务共用同一把锁——**手动触发时若定时正在跑会直接返回**(防重复回写)。 + +--- + +## 2. 业务域一:客商档案(A902-1 / A902-2 / A801-5 黑名单) + +**定位**:全局数据前提。其他链路的往来户编码(`customer_code`)来自 `blade_customer_archive`;金蝶客户编码 = TMS 客商编号。 + +### 2.1 前端入口 + +| 页面 | 操作 | 后端端点 | 触发时机 | +|---|---|---|---| +| 客商档案(`/vehicle/customer-archive`) | 新增/编辑表单录统一社会信用代码(满 18 位防抖) | `GET /bfi/customer/query-by-credit-code`(A902-1) | 回填名称/法人/地址/经营范围等并锁定;金蝶状态 A/B 阻断保存 | +| 同上 | 行内「同步金蝶」链接(仅 approved 且 failed 显示) | `POST /customer-archive/sync-kingdee`(A902-2) | 失败人工重推 | +| 同上 | 行内「审批通过」 | `POST /customer-archive/approve` → 内部 `syncKingdee(id)` | **提审校验 A801-5 黑名单;通过后自动推送 A902-2** | + +### 2.2 后端链路 + +**A902-1 客户查询(3 入口)**:前端回填 / 单条推送前复查(`syncKingdee` 先查一次,C→exist 终态、A/B→failed)/ 定时批量复查(50 信用代码/批翻页取全)。 + +**A801-5 黑名单(★新增,提审拦截)**:`CustomerArchiveServiceImpl.submitApproval` → 取客商各发票 `taxNo` 去重大写 → `queryBlacklist("1", taxNos)`(enable=1,pageSize 上限 2000,循环翻页取全)→ **A 类硬拦截**;B 类默认放行告警(`thirdparty.bfi.blacklist.blockRiskWarning=true` 时拦截);其他类别放行告警;**查询异常上抛阻断提审**。 + +**A902-2 客户保存(3 入口收敛 `pushCustomerToKingdee`)**:审批通过自动(失败不影响审批)/ 人工重推(synced/exist 幂等返回)/ 定时重推(pending=approved 且 none/failed)。先 A902-1 复查(金蝶已有 C 直接置 exist 跳过)再保存。只推新增建档(审批后变更不重推)。参数要点:`entry_groupstandard` 按 `customerKind` internal→1101 / 其余→1102;`entry_bank` 取 `blade_customer_receipt_account`(账号/开户行/CNY/默认 + **真实联行号 cnaps_code**,硬编码已删);`bizfunction` 固定 `1,2,3,4`。 + +**定时附加职责**(`CustomerKingdeeSyncTask`):synced/exist 且启用客商复查 C→exist;金蝶侧停用 → 联动停用 TMS 档案 `status=2`(单向)。 + +**响应存储**:`kingdee_status / kingdee_fail_reason / kingdee_sync_time`。 + +**金蝶侧效果**: + +![金蝶客户列表收到 A902-2 推送的客商(编码=TMS 客商编号 KS000073,客户基本分类标准 1101 内部客户/1102 外部客商)](./2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a902-金蝶客户接收.png) + +![TMS 客商档案与金蝶客户档案对照:编码一致(KS000060)、以统一社会信用代码为两侧关联键](./2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a902-客商编码对照.png) + +--- + +## 3. 业务域二:财务应付与付款 + +### 3.1 链路总览 + +``` +环节一 正式结算单(应付 Tab)审批通过自动同步 / 手动「同步金蝶」 + A102-4 预检 ▶ A102-2 保存应付单 ▶ 回写 kingdee_bill_no + synced ▶ lockBusiData 锁单 +环节二 付款申请审批通过自动同步 / 「批量同步」 + A102-4 硬校验(逐关联结算单应付已生成)▶ A103-2 幂等预检 + ▶ A103-1 下推(预付款走 A103-3)▶ 回写 kingdee_bill_id/no + synced ▶ lockBusiData 锁单 +环节三 PaymentKingdeeResultTask 定时(或手动「同步付款结果」) + A103-2 状态回写(synced→paying→paid/closed;failed 勾稽纠偏;连续2轮查不到→消失解锁置failed) + ▶ A103-4 付款处理查询 ▶ 付款记录落库(含 voucher_json 凭证/银行流水/电子回单) +``` + +**金蝶侧效果**: + +![金蝶财务应付单 AP-202609-800044 与 TMS 正式结算单对照:外部系统单据号=防重键(WLTMS_FJS2026092400001_YF20260912…),应付金额、明细行(物料/车号/运输货物/数量)逐行对应](./2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a102-应付单对照.png) + +> **前置说明(预结算单)**:预结算单(PreSettlement)**不直接推送金蝶**——无金蝶字段与调用,`KingdeeBillApprovedEvent` 也不覆盖它。与金蝶的关联仅三处: +> ① 非 RMB 预结算在页面经 A801-4 带出汇率随单保存,**汇率不自动结转到正式结算单**——A102-2/A302-2 推送取的是正式结算单自身维护的 `exchange_rate/exchange_rate_date`(`KingdeeApBillSyncSupport:272-277`),转正式后须重新维护; +> ② 进度预付付款申请可关联预结算单(`payment_application.pre_settlement_id`),A103-3 汇率日期优先取关联结算单(正式→预结算)维护值,兜底申请日期; +> ③ A103-2 付款 paid 回写时联动履约:回写预结算垫资 `blade_pre_settlement_advance`(paidAmount 整额置满、billStatus=paid、kingdeeAdvanceNo=金蝶付款单号)并刷新预结算汇总与正式结算来源快照。 +> 预结算「预开票」金蝶推送方案(2026-09-29)**未实现**;预结算审批走 MK 流程(`MkProcessBizHandlers.PreSettlementHandler`),直接置 approved、不发布金蝶事件。 + +### 3.2 前端入口 + +| 页面 | 操作 | 后端端点 | 说明 | +|---|---|---|---| +| 正式结算单(`/settlement/formal-settlement`,应付/应收 Tab) | 工具栏「同步金蝶」(单选一条) | `POST /formal-settlement/sync-kingdee?id=` | 30s 超时;成功回写金蝶单号 | +| 同上 | 行内「审批通过」 | `POST /formal-settlement/approve` | 开关开启时**自动同步**;`data=false` 前端提示失败原因 | +| 同上 | 列表「金蝶锁定」标签列 | 无调用(后端 mapActiveLocks 回显) | locked 红色标签禁编辑/作废 | +| 付款申请(`/payment/payment-application`) | 工具栏「批量同步」 | `POST /payment-application/sync-kingdee-batch`(ids) | 整批预热→逐单独立事务,单条失败不中断 | +| 同上 | 工具栏「同步付款结果」 | `POST /payment-application/sync-kingdee-result` | 手动触发 A103-2/A103-4 回写 | +| 同上 | 行内「审批通过」 | `POST /payment-application/approve` | 开关开启时自动下推+锁单 | +| 同上 | 单笔「同步金蝶」 | `POST /payment-application/sync-kingdee?id=` | 端点在;**前端 `handleSyncRow` 未绑定模板(死代码)**,单笔入口实际只有审批自动同步 | + +### 3.3 后端要点(当前口径) + +- **A102-2/A302-2 分录**:结算明细行按「货物→费用大类→税率」拆分 + 手工合计费用项(manualFlag=1,每行一条,0 元跳过);明细行带运输货物/车号;调试覆盖块已删。 +- **A103-1 下推**:单分录 `paymentNo-1`;`e_settlementtype_number` 按付款方式映射(汇票→012-02,其余→02);`e_bebank_number`=客商收款账户联行号(未维护拦截);`awrp_finapbill_entryid`=主结算单应付防重键(本地组装;⚠️ 支撑类注释仍写「A102-4 反查」,注释与代码不一致,见 12 节)。 +- **A103-3 预付款**:`e_bebank_number` 同样取客商收款账户联行号(★已与 A103-1 统一);project 取项目档案真实编码(无则 999)。 + +![TMS 付款申请(FKD2026092400001,申请付款 195.11)与金蝶付款申请单(FKSQ-00000199)对照:核销金额、往来户/往来账户(收款账号)、结算方式(银行转账)逐项对应;金蝶侧由 A103-1 下推自动生成付款计划](./2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a103-付款申请对照.png) + +![预付款对照:TMS 进度预付申请(240.12)下推金蝶生成「其他付款申请」FKSQ-00000219(进项货付款申请),分录预付款金额、往来户、结算方式对应;附件区可见 A902 联带上传的文件](./2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a103-3-预付款对照.png) +- **A103-2 回写状态映射**:`Alreadypay`→paid(终)>`billstatus=D`→closed(终)>`Inpayment`→paying>其余 synced;`entry[].e_paidamt` 汇总→paid_amount(缺失且 paid 兜底=申请金额);paid 联动履约(预结算垫资/结算付款记录);**failed 纳入勾稽纠偏**(查到即纠正 synced);**查不到→消失解锁计数**(仅已生成/付款中参与,连续 2 轮→解锁置 failed)。 +- **A103-4 付款记录**:按付款组织编码分组查询(`org_number` 优先取 A103-2 返回值),窗口最近 1 个月,`sourcebillid`==kingdee_bill_id 优先、`sourcebillnumber` 兜底;upsert `blade_payment_application_record`(actpayamt/paid_date/整单 JSON→voucher_json);清理 MOCK-PAY- 模拟记录。 +- **A902 附件**:推送成功事务提交后联带上传(`KingdeeAttachmentSyncSupport.syncUserAttachments`),失败不污染主单状态,重推补传。 +- **异常落库**:手动/自动同步失败统一独立事务落 `failed` + 原因(截 500 字),由「手动重推/failed 重推任务」续推。 + +--- + +## 4. 业务域三:财务应收与开票 + +### 4.1 链路总览 + +``` +环节一 正式结算单(应收 Tab)审批通过自动同步 / 手动「同步金蝶」 + A302-4 预检 ▶ A302-2 保存应收单 ▶ 回写 kingdee_bill_no + synced ▶ lockBusiData 锁单 +环节二 开票申请审批通过自动同步 / 工具栏「批量同步」(逐单) + A302-4 硬校验(应收已生成)▶ A303-4 批量 billno 幂等预检(部分张次已生成可断链续推) + ▶ A303-1 按发票张次逐张新增(E04/货物运输单据体/A902 附件)▶ 张次+申请单回写 ▶ lockBusiData 逐张锁单 +环节三 PaymentKingdeeResultTask 定时(或手动端点) + A302-4 勾稽纠偏(failed→synced / 补单号) + A303-5 开票发票查询(serialNo 按 application.kingdee_bill_no 逗号拆分)▶ 发票落库(含文件地址)▶ invoiced(终态) +``` + +### 4.2 前端入口 + +| 页面 | 操作 | 后端端点 | 说明 | +|---|---|---|---| +| 正式结算单(应收 Tab) | 同应付:同步金蝶 / 审批通过 / 锁状态列 | 同 3.2 | `settlementType=receivable` 路由 A302-2 | +| 开票申请(`/payment/invoice-application`) | 工具栏「批量同步」 | `POST /invoice-application/sync-kingdee?id=`(逐单 Promise.all) | 仅审批通过单 | +| 同上 | 行内「审批通过」 | `POST /invoice-application/approve` | 开关开启时自动按张次推送 | +| 同上 | 锁定状态列 | 无调用 | 「金蝶锁定中」禁编辑/作废 | +| 正式结算单编辑器「开票发票」区 | 查看/下载发票文件 | 金蝶发票云直链(开票结果回写的 invoicePdfFileUrl 等,不走 A801-2 代理) | 10-02 起落库发票文件地址 | + +### 4.3 后端要点(当前口径) + +**金蝶侧效果**: + +![金蝶财务应收单 AR-260924-012021 与 TMS 正式结算单对照:应收金额、费用合计对应;明细行物料编码/车号/运输货物/数量逐行对应](./2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a302-应收单对照.png) + +![开票申请张次拆分对照:TMS 开票内容第1张/第2张(各含商品行)分别生成金蝶票易通开票申请单——购销方、不含税金额、未关联金额(¥4,386)逐项对应](./2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a303-1-开票张次对照.png) + +- **张次拆分**(`blade_invoice_application_sheet`):每张发票张次推一张金蝶开票申请单;billno=单张取 application_no、多张取 `-张次序号`;分录=本张商品行(按 invoice_sheet_id 分组)。 +- **A303-1 头字段口径(★更新)**:`invoicetype` 数电专票 08xdp / 数电普票 10xdp;`specialtype`:本张商品行**全部**为货物运输类(名称/分类含运费关键字)→ **E04**,否则 **00**;E04 随单报 `freights[]` 货物运输单据体(车牌 licenseplate/起运 startplace/到达 endplace/货物 transportgoods/运输方式 transporttype 字典映射,按「车牌×起运×到达×货物×种类」去重归并,空行剔除);`hsbz` 统一 **0**(不含税);`awrp_src_bt=ar_finarbill` + `awrp_src_con_no`=首张结算单金蝶应收防重键(金蝶按防重键校验源单,传编号会报源单不存在);`awrp_project_number`=999。旅客运输/代收车船税单据体 DTO 已建**未接入**。 +- **A303-5 回写**:按销方税号分组(开票单位→客商档案税号)≤50/批;落 `blade_invoice_application_invoice`(serial_no/invoice_no/invoice_date/invoice_amount/raw_json + 发票文件地址);唯一键防重;有新增发票→invoiced 终态。发票结果不反写结算单(文件地址随发票记录供结算详情查看)。 +- **A902 附件**:开票申请推送成功后联带上传(以本地 kingdeeBillId 定位金蝶单据,星瀚内码缺失时经 A303-4 反查回填)。 + +--- + +## 5. 业务域四:收票与金蝶票池 + +### 5.1 架构现状(★10-05 回切后终态) + +``` +【数据面】InvoiceReceiptKingdeeSyncTask 定时(默认关,每30分钟) + A801-1 全票池增量同步(incrementDays=30)→ invoiceTypes 白名单下推远端逐票种切片轮询 + → 2.5 万窗口硬上限触顶二分时间片递归 → 镜像 blade_kingdee_invoice_pool 批量 upsert(无变化跳写) + → refreshBoundReceipts:未绑定存量登记补绑(已绑定零写入) + 手动端点:POST /invoice-receipt/sync-invoice-pool(同一方法+同一把锁) + +【选票面】收票登记表单 / 结算单认领弹窗(KingdeeInvoicePoolDialog) + GET /invoice-receipt/invoice-pool、GET /formal-settlement/invoice-pool + → 本地镜像真分页;仅「票号精确查 + 无认领/登记过滤 + 本地空页」时窄查 A801-1 回源一次再本地重查 + 行过滤:票号非空 + amountUseStatus=0 + availableAmount>0 + +【闸门】收票登记保存 persistMirrorFromRemote(按票号精确单查+可用性复核+FOR UPDATE upsert) + 结算单认领确认 assertInvoicesClaimable(★10-05 补上:逐票窄查+可用复核,红冲/作废/被占用在此拦截) + +【推送链】审批通过自动推送(receipt 开关)/ 工具栏「批量同步」 + A102-4 预检(应付已生成)→ A801-3 幂等预检(防重键=收票登记 id) + → A303-2 收票单保存(付款方式固定现购)→ pushed → A302-6 指定应付 → assigned(终态) + → A902 附件联带上传;缺票种编码按票号窄查 A801-1 补填 + +【回写面】PaymentKingdeeResultTask.syncKingdeeReceivedInvoiceResult(默认关,每10分钟)/ 手动端点 + A801-3 按「收票登记 id」匹配(★缺陷已修)→ 发票状态异常回收置 failed + / pushed 补偿重发 A302-6 / assigned 缺单号补登;窗口内查不到不改状态 + +【附件面】GET /invoice-receipt/invoice-pool/attachment/download?invoiceNo=(A801-2 流式代理) + 镜像优先、实时回源兜底;pdfurl 优先 taxPdfUrl 兜底,都缺退 downloadUrl(fileType=4 判 OFD) +``` + +### 5.2 前端入口 + +| 页面 | 操作 | 后端端点 | 说明 | +|---|---|---|---| +| 收票登记(`/payment/invoice-receipt`) | 工具栏「批量同步」 | `POST /invoice-receipt/push-kingdee`(ids) | 单笔失败不中断整批,逐单报因 | +| 收票登记表单 | 发票号码放大镜(选票弹窗) | `GET /invoice-receipt/invoice-pool` | 镜像分页 + 回源;确认/保存前防重校验 | +| 收票登记表单 | 附件 查看/下载 | `GET /invoice-receipt/invoice-receipt/invoice-pool/attachment/download`(A801-2 代理) | 120s blob;PDF 优先 | +| 收票登记 | 行内「审批通过」 | `POST /invoice-receipt/approve` | receipt 开关开启时自动推送 | +| 正式结算单编辑器 | 发票认领区「查询可认领发票」/「确认识领」 | `GET /formal-settlement/invoice-pool`、`POST /formal-settlement/claim-invoices` | 确认时走认领闸门;未保存单仅本地暂存 | +| 共享弹窗 `KingdeeInvoicePoolDialog` | 行附件 查看/下载 | 同 A801-2 代理 | 票池弹窗、结算认领行、付款申请发票附件行、收票附件表 4 处复用 | + +### 5.3 后端要点 + +**金蝶侧效果**: + +![TMS 收票管理列表「金蝶状态」列:已指定应付(终态)/推送失败(带失败原因)/未推送,金蝶单据号 AVT-202608-000396 悬浮展示](./2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-收票金蝶状态列表.png) + +![A902 附件上传金蝶侧效果:金蝶财务应付单详情「附件」区收到 TMS 推送单据联带上传的文件;付款计划区同时可见下推生成的付款计划(外部系统来源标识=防重键)](./2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a902-附件上传金蝶侧.png) + +![A801-2 发票查看/下载入口:正式结算单详情「发票信息」区认领的发票行提供 查看/下载(走后端 A801-2 流式代理,PDF 版式优先);付款信息区同时回显付款申请的金蝶单号](./2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a801-2-发票查看下载.png) + +- 推送计划校验:approved、状态≠assigned、已绑定镜像、`checkStatus=2`、**恰好分摊 1 张审批通过应付结算单(一票一应付)**、有结算明细;不满足写 `blockedReason` 不改状态。 +- A801-3 预检命中且状态正常→跳过 A303-2 直接续推指定(断点恢复);发票状态异常(作废2/红冲3/部分红冲7/全部红冲8)→failed 报人工。 +- 落库:`kingdee_received_invoice_no`、状态机 `unsynced→pushed→assigned/failed` →`kingdee_push_status`(唯一金蝶状态来源);终态/失败写 `blade_invoice_receipt_record`;pushed/assigned 禁止本地作废。 +- 全票池同步细节:单查询窗口 MAX_POOL_PAGES×500=2.5 万上限,`updateTime` 单调二分不重不漏;镜像 upsert 轻量比对(流水号/价税合计/税额/可用金额/使用状态)无变化跳写。 + +--- + +## 6. 业务域五:收款认领(A301-2 / A301-3 / A301-1) + +**定位**:业务先行认领。金蝶司库第一轮自动认领到客户维度 → TMS 二次认领到结算单维度 → **本地认领完成后**(定时或手动)回推金蝶生成收款单 → 手动查询回写结果。★09-28 重构后:认领与推送解耦,推送按流水归集、定时+手动双入口。 + +### 6.1 链路总览 + +``` +阶段一 收款流水页「流水同步」(手动) + POST /receipt-flow/sync ▶ A301-2 认领公告查询(claimusers=Nacos thirdParty.bfi.claimAnnounce.claimusers, + ★不再按登录用户姓名;未配置则以空值调用会查不到数据) + ▶ 全量翻页(窗口 months=6)▶ 按 detail_serial_no 幂等 upsert blade_kingdee_receipt_flow + +阶段二 流水详情/批量认领(手动) + POST /receipt-flow/claim ▶ 本地落库(claim + 分摊 + 流水 claimed_amount/claim_status 刷新 + 结算单收款汇总) + ▶ 本地完成即结束,**不调金蝶**(认领本地化★) + +阶段三 推送金蝶(定时 ReceiptClaimPushTask 默认关每10分钟 / 手动流水行内「推送金蝶」) + pushClaimToKingdee ▶ 按认领人逐人重查 A301-2 → 扫「认领完成未推送」流水按流水归集 + ▶ 逐流水:awrp_src_code=分摊结算单号拼接(重试复用幂等)→ A301-3 推送 + (分录单条:e_receivableamt=全部未推送金额;e_corebillno=第一张结算单金蝶内码,缺内码拦截不推) + ▶ 成功回写 kingdee_bill_no(回执 keys.awrp_src_code 占位)+ kingdee_bill_status=pushed + +阶段四 认领记录页「查询金蝶认领结果」(手动) + POST /receipt-claim-record/query-kingdee-result ▶ A301-1 收款单查询(今天回溯 30 天) + ▶ sourcebillnumber==awrp_src_code 匹配 ▶ 真实收款单号覆盖占位单号 + 状态推进(C→approved,D→received 终态) +``` + +### 6.2 前端入口 + +| 页面 | 操作 | 后端端点 | 说明 | +|---|---|---|---| +| 收款流水(`/payment/receipt-flow`) | 工具栏「流水同步」 | `POST /receipt-flow/sync`(A301-2) | 权限 `receipt_flow_sync` | +| 同上 | 流水详情/批量认领表单「确认」 | `POST /receipt-flow/claim` | 选择审批通过应收结算单按比例分摊;已推送金蝶的认领禁止作废(后端硬校验) | +| 同上 | 行内「推送金蝶」 | `POST /receipt-flow/push-kingdee?flowId=` | 确认弹窗后按流水归集推送 | +| 认领记录(`/payment/receipt-claim-record`) | 工具栏「查询金蝶认领结果」 | `POST /receipt-claim-record/query-kingdee-result`(A301-1) | 手动;窗口内查不到不改状态 | + +### 6.3 后端要点 + +**金蝶侧效果**: + +![A301-2 对照:金蝶「认领公告列表」(RLTZ 通知单,待认领/部分认领)与 TMS 收款流水「手动同步流水」拉取的同批公告对照](./2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a301-2-公告同步对照.png) + +![A301-3 对照:TMS 认领记录(RLTZ-20260612-0073 认领 828.00)推送后,金蝶「认领处理列表」生成认领单 RL-20260929-0058(已确认,认领通知单 0073 对应)](./2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a301-3-认领推送对照.png) + +![A301-3 金蝶侧认领结果详情:收款认领结果行「核心单据编号」=结算单星瀚内码(2586341365516352512,即 e_corebillno 传金蝶内码的落地证据)](./2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a301-3-金蝶认领结果.png) + +- 推送闸门:已推送(pushed)流水跳过不重推;`billid`(认领公告 id)缺失拦截「请先同步金蝶收款流水」;结算单金蝶内码缺失拦截不推。 +- 幂等:`awrp_src_code` 重试复用同一编码;作废认领不再生成本地冲单(金蝶单据状态保留原值)。 +- A301-3 字段待金蝶确认项仍在:`recpaytype_number`(常量当前值 "999",注释示例 101 待确认)、`paymenttype`、`recpayer`(金蝶付款人 master id)。 + +--- + +## 7. 业务域六:公共取数(A801-4 汇率) + +| 项 | 说明 | +|---|---| +| 前端入口 | 预结算单/正式结算单编辑器:非 RMB 且选了汇率日期(或币种切换)时自动 `GET /bfi/exchange-rate?currencyCode=&effectdate=`,回填结算汇率并重算本位币金额 | +| 后端 | 按日期查 A801-4 全量汇率(页大小固定 10)→ 本地按 `orgcur_number` 匹配 → 返回 VO;不落库 | +| 下游 | 汇率/汇率日期随结算单落 `blade_formal_settlement.exchange_rate / exchange_rate_date`,随 A102-2/A302-2 推送;A103-1 下推不传(按应付单自动带出) | + +![外币结算汇率推送效果:TMS 结算单结算汇率 6.736700(美元)推送后,金蝶应付单汇率表 6.7367、金额/金额(本位币) ¥9,923.16 自动换算,外部单据类型=TMS财务应付单](./2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册).assets/bfi-effect-a801-4-外币汇率推送.png) + +--- + +## 8. 全景:接口依赖与先后顺序 + +``` + ┌──────── 客商档案(全局前提,软依赖) ────────┐ + │ A801-5 黑名单(提审拦截)→ A902-2 保存(审批自动/人工/定时)→ A902-1 复查 + └───────────────────┬────────────────────────┘ + ▼ +【应付主线】 正式结算单(应付)审批 ─A102-4 预检─▶ A102-2 保存 ─▶ synced ─▶ lockBusiData + │ A902 附件联带上传 (汇率 ← A801-4 页面回填) + │ A102-4 硬校验 + ▼ + 付款申请审批 ─A103-2 预检─▶ A103-1 下推(预付款→A103-3)─▶ synced ─▶ lockBusiData + │ + ▼ + 定时 A103-2 状态回写(纠偏/消失解锁)─▶ A103-4 付款记录(voucher_json) + +【应收主线】 正式结算单(应收)审批 ─A302-4 预检─▶ A302-2 保存 ─▶ synced ─▶ lockBusiData + │ A302-4 硬校验 + ▼ + 开票申请审批 ─A303-4 批量预检─▶ A303-1 逐张次新增(E04/freights/A902 附件)─▶ synced ─▶ 逐张锁单 + │ + ▼ + 定时 A302-4 勾稽纠偏 + A303-5 开票结果 ─▶ invoiced(终态,发票文件落库) + +【收票主线】 定时 A801-1 全票池同步 ─▶ 镜像表 ◀── 选票读镜像(票号未命中窄查回源) + │ 收票登记保存(闸门)/ 结算认领确认(闸门★新增) + ▼ + 收票登记审批 ─A102-4 预检─A801-3 预检(登记id)─▶ A303-2 保存 ─▶ pushed ─▶ A302-6 ─▶ assigned + │(本期不锁单;A902 附件联带上传) + ▼ + 定时 A801-3 回写(异常回收/指定补偿/单号补登,按登记 id 匹配★已修) + +【收款认领主线】 手动 A301-2 公告同步 ─▶ blade_kingdee_receipt_flow + │ 页面认领(本地化,不调金蝶) + ▼ + 定时/手动 A301-3 按流水归集推送(e_corebillno=结算单金蝶内码)─▶ pushed + ▼ + 手动 A301-1 收款单查询 ─▶ 真实收款单号覆盖 + approved/received(终) + +【横切】 审批通过 AFTER_COMMIT 自动同步(4 开关)│ 推送成功→lockBusiData(3 链路) + 金蝶删单→unLockNotifyBusiData 回调还原 │ 锁失败定时/手动补偿 + failed 单据→KingdeeAutoSyncRetryTask 定时重推 +``` + +**硬顺序规则(代码强制)**: + +| # | 规则 | 强制方式 | +|---|---|---| +| 1 | 客商档案先于一切推送(本地 customer_code 必须存在) | 查不到直接报错;金蝶侧审核状态不校验(软依赖) | +| 2 | 客商提审先过 A801-5 黑名单 | A 类命中/查询异常即阻断提审 | +| 3 | A102-2 先于 A103-1 与 A303-2 | A102-4 硬校验/预检 | +| 4 | A302-2 先于 A303-1 与 A301-3(e_corebillno 需金蝶内码) | A302-4 硬校验;内码缺失拦截不推 | +| 5 | A303-2 先于 A302-6 | 同一次推送内串行 | +| 6 | 推送先于回写(A103-2/A103-4、A302-4 勾稽、A303-5、A801-3、A301-1) | 查询键由推送链写入本地 | +| 7 | 锁单在推送成功之后、金蝶删单回调可还原 | lockAfterPush 事务提交后执行;unLockNotifyBusiData 免登回调 | +| 8 | A801-4 汇率查询先于 A102-2/A302-2(如需携带汇率) | 页面联动维护,未维护不传 | + +**断链恢复语义**:本地失败/远端未落单→unsynced 或 failed 可重推;远端成功本地回滚→重推时预检幂等回填(A102-4/A302-4/A103-2/A303-4/A801-3/awrp_src_code 复用);推送成功后状态滞后→定时回写;批量语义→整批预热+逐单独立事务+单条失败不中断。 + +--- + +## 9. 状态机汇总(2026-10-14) + +| 表.字段 | 状态集 | 迁移 | 写入方 | +|---|---|---|---| +| `blade_formal_settlement.kingdee_sync_status` | unsynced / synced / **failed**(★新增写入口) | unsynced→(A102-2/A302-2 成功)→synced;推送异常→failed(独立事务);failed 可手动/定时重推、A302-4 勾稽纠偏回 synced;金蝶删单回调→voided + unsynced 还原 | FormalSettlementServiceImpl | +| `blade_payment_application.kingdee_status` | unsynced / synced / paying / paid(终) / closed(终) / failed | 同前版映射;failed 可纠偏回 synced;**连续 2 轮查不到→消失解锁置 failed**;金蝶删单回调→failed+清单号(不作废源单) | PaymentApplicationServiceImpl + 定时 | +| `blade_invoice_application.kingdee_status` | unsynced / synced / invoiced(终) / **failed**(★新增写入口) | unsynced→(A303-1 全部张次成功)→synced→(A303-5 新发票)→invoiced;推送异常→failed;金蝶删单回调→作废还原 | InvoiceApplicationServiceImpl + 定时 | +| `blade_invoice_receipt.kingdee_push_status` | unsynced / pushed / assigned(终) / failed | 同前版;A801-3 回写匹配键已修复为登记 id(异常回收/补偿/补登生效) | InvoiceReceiptServiceImpl + 定时 | +| `blade_customer_archive.kingdee_status` | none / synced / failed / exist(终) | 同前版;提审新增 A801-5 黑名单拦截(不产生状态) | CustomerArchiveServiceImpl + 定时 | +| `blade_kingdee_bill_lock.lock_status` ★新增 | failed / locked / unlocked | 先落 failed 占位→lockBusiData 成功→locked;补偿重试(try_count<5)或金蝶解锁/删单回调→unlocked | KingdeeBillLockServiceImpl + 定时 + 手动端点 | +| `blade_receipt_claim.kingdee_bill_status` | none / pushed / approved / received(终) / failed / voided | none→(A301-3)→pushed→(A301-1: C)→approved→(D)→received;已推送禁止作废 | ReceiptFlowServiceImpl + ReceiptClaimRecordServiceImpl | +| `blade_kingdee_receipt_flow.claim_status` | unclaimed / partial / claimed | 认领分摊→partial/claimed;全部作废→unclaimed;公告同步不覆盖 | ReceiptFlowServiceImpl | + +--- + +## 10. 定时任务与配置开关汇总 + +全部在 `blade-transport/task/`,Spring `@Scheduled`,统一 single-flight(`KingdeeSyncLockSupport`,`blade.lock.enabled=false` 降级直跑)。 + +| 任务类 | 方法 | 覆盖业务(接口) | cron 键(默认) | 开关键(代码默认) | 手动端点 | +|---|---|---|---|---|---| +| PaymentKingdeeResultTask | syncKingdeePaymentResult | ① A103-2/A103-4 付款结果 ② A302-4 应收勾稽 ③ A303-5 开票结果 | `thirdParty.bfi.payResult.cron`(`0 */10 * * * ?`) | `thirdParty.bfi.payResult.syncEnabled`(false;**Nacos 样例 dev/prod 已显式 true**) | `/payment-application/sync-kingdee-result`、`/formal-settlement/sync-kingdee-ar-result`、`/invoice-application/sync-kingdee-invoice-result` | +| PaymentKingdeeResultTask | syncKingdeeReceivedInvoiceResult | A801-3 收票结果回写 | `thirdParty.bfi.receivedInvoiceResult.cron`(每10分钟) | `…receivedInvoiceResult.syncEnabled`(false) | `/invoice-receipt/sync-kingdee-result` | +| CustomerKingdeeSyncTask | syncKingdeeCustomerData | A902-1 复查 + A902-2 重推 + 金蝶停用联动 | `thirdParty.bfi.customer.cron`(`0 0 2 * * ?`) | `…customer.syncEnabled`(false;KS000067 残留已移除) | `/customer-archive/sync-kingdee` | +| **ReceiptClaimPushTask** ★新增 | pushClaimToKingdee | A301-3 认领推送(按流水归集,前置重查 A301-2) | `thirdParty.bfi.receiptClaimPush.cron`(每10分钟) | `…receiptClaimPush.syncEnabled`(false) | `/receipt-flow/push-kingdee` | +| **InvoiceReceiptKingdeeSyncTask** ★新增 | syncKingdeeInvoicePool | A801-1 全票池瘦身同步 + 绑定回填 | `thirdParty.bfi.invoiceReceipt.cron`(每30分钟) | `…invoiceReceipt.syncEnabled`(false);增量窗口 `…incrementDays`(30);票种白名单 `…invoiceTypes` | `/invoice-receipt/sync-invoice-pool` | +| **KingdeeAutoSyncRetryTask** ★新增 | retryKingdeeFailed | 4 域 failed 单据重推(结算/付款/开票/收票;不扫 unsynced;无次数上限,靠幂等预检防重) | `thirdParty.bfi.autoSync.retryCron`(每10分钟) | `…autoSync.retryEnabled`(false) | 各域 `/sync-kingdee` | +| **KingdeeBillLockRetryTask** ★新增 | retryFailedBillLocks | 锁单失败补偿(failed 且 try_count<5 重调 lockBusiData) | `thirdParty.bfi.billLock.retryCron`(每5分钟) | `…billLock.retryEnabled`(false) | `/formal-settlement/retry-kingdee-bill-lock` | + +**审批自动同步开关(监听器,非定时)**:`thirdParty.bfi.autoSync.settlement / payment / invoiceApply / receipt`(默认全 false)。 +**其他业务键**:`thirdParty.bfi.claimAnnounce.claimusers`(A301-2 认领人,**必配**)、`…claimAnnounce.months`(6)、`thirdparty.bfi.blacklist.blockRiskWarning`(false)、`thirdparty.bfi.material.*`(物料映射)。 + +> ⚠️ Nacos 样例(`doc/nacos/`)仅显式配置了 `payResult.syncEnabled=true`;`receiptClaimPush/invoiceReceipt` 样例仍为 false;`customer/receivedInvoiceResult/autoSync/billLock` 键未出现在样例中——生效值取决于各环境实际 Nacos,缺省走代码 false。**开启生产定时前逐项核对第 12 节风险清单。** + +--- + +## 11. 操作流程手册 + +### 11.1 一次性前置准备(实施/运维) + +| # | 事项 | 操作 | +|---|---|---| +| 1 | 部门金蝶组织编码 | 部门档案维护 `kingdee_dept_code`;未维护的部门推送组织取数会直接拦截 | +| 2 | 主数据 SQL | 执行 `doc/sql/transport/kingdee_master_data_init_20260924.sql`(计量单位档案等);认领链路执行 `blade_receipt_flow_claim_20260924.sql`;10-13 迁移脚本见 `doc/sql/transport/lqj/` | +| 3 | Nacos 业务配置 | `thirdParty.bfi.claimAnnounce.claimusers`(认领人,与金蝶公告 claimusers 一致)、`thirdParty.bfi.invoiceReceipt.invoiceTypes`(票池同步票种白名单)、`thirdparty.bfi.material.*`(物料映射,缺省有默认值但建议显式配置) | +| 4 | 定时开关按需开启 | 见第 10 节表:付款结果回写样例已开;其余按环境显式开启 `syncEnabled/retryEnabled` | +| 5 | 审批自动同步开关 | `thirdParty.bfi.autoSync.settlement/payment/invoiceApply/receipt` 按域开启;未开启时走页面「同步金蝶」手动推送 | +| 6 | 客商收款账户 | 维护 `blade_customer_receipt_account` 联行号(cnaps_code)——付款申请/预付款/客商推送均取真实联行号,缺失拦截 | + +### 11.2 日常业务操作时序(财务/业务) + +``` +① 客商建档 + 新增客商 → 录统一社会信用代码(自动查金蝶回填)→ 上传发票信息 → 提交审批 + (后端自动过 A801-5 黑名单,A 类命中被拦截)→ 审批通过 → 后端自动推送 A902-2 + → 列表 kingdee_status:synced(金蝶受理)→ 定时复查升级 exist(金蝶审核通过) + +② 结算(应付/应收) + 预结算/正式结算单编辑:非 RMB 币种选择汇率日期 → 自动带出金蝶汇率 → 提交审批 + → 审批通过(autoSync.settlement 开启时自动推送;或列表勾选点「同步金蝶」) + → 成功后列表显示金蝶单号 + 「金蝶锁定中」标签(单据已被金蝶锁定,不可编辑/作废) + +③ 付款 + 付款申请(关联已同步结算单)→ 审批通过(autoSync.payment 开启时自动下推;或「批量同步」) + → 后端校验应付已生成 → A103-1 下推/预付款 A103-3 直推 → 锁单 + → 等 A103-2/A103-4 定时回写付款状态与付款记录(或点「同步付款结果」立即回写) + → 状态流转 synced→paying→paid/closed,付款记录含凭证 JSON + +④ 开票 + 开票申请(关联已同步应收结算单)→ 审批通过(autoSync.invoiceApply 开启时自动按张次推送;或「批量同步」) + → 每张发票张次生成一张金蝶开票申请单 → 锁单 → 开票申请列表显示逗号拼接金蝶单号 + → 等 A303-5 定时回写发票结果(或手动端点)→ 状态 invoiced + → 正式结算单详情「开票发票」区查看/下载发票文件 + +⑤ 收票(进项) + 收票登记表单:发票号放大镜从金蝶票池选票(镜像读,票号未命中自动回源)→ 保存(重查闸门校验可用性) + → 分摊到一张审批通过应付结算单 → 审批通过(autoSync.receipt 开启时自动推送;或「批量同步」) + → A303-2 保存→pushed→A302-6 指定应付→assigned(终态) + → 等 A801-3 定时回写(pushed 断点自动补偿重发 A302-6) + +⑥ 收款认领 + 收款流水页「流水同步」拉取金蝶认领公告 → 流水详情/批量认领:选审批通过应收结算单按比例分摊(本地落库) + → 「推送金蝶」(行内手动,或等 ReceiptClaimPushTask 定时)→ 金蝶生成收款单 + → 认领记录页「查询金蝶认领结果」回写真实收款单号与状态(approved/received) + 注意:已推送金蝶的认领不可作废,需金蝶侧人工处置 + +⑦ 查看附件 + 金蝶票池/认领发票/收票附件的「查看/下载」统一走后端 A801-2 代理(PDF 版式优先) +``` + +### 11.3 异常处理操作 + +| 场景 | 现象 | 处理 | +|---|---|---| +| 推送失败(failed) | 列表金蝶状态 failed + 失败原因;审批时前端提示「审批通过,但自动同步金蝶失败:xxx」 | 修复原因后:客商/结算/付款/开票页手动「同步金蝶」重推;或等 `KingdeeAutoSyncRetryTask`(开启后每 10 分钟自动重推 failed) | +| 锁单失败 | 列表「锁定失败」标签;`blade_kingdee_bill_lock` failed 记录 | 等 `KingdeeBillLockRetryTask` 自动补偿(≤5 次);或 `POST /formal-settlement/retry-kingdee-bill-lock` 手动补偿;人工重推会重置计数 | +| 金蝶侧删单 | 金蝶回调 `POST /kingdee-bfi/unLockNotifyBusiData` | 自动还原:结算单/开票申请作废+状态回 unsynced 清单号(可重推);付款申请置 failed 清单号(不作废源单) | +| 单据被锁定后需修改 | 「金蝶锁定中」禁编辑/作废 | 只能在金蝶侧解锁/删单,触发回调还原后再改(无本地强制解锁入口,有意设计) | +| 付款单在金蝶被删且无回调 | A103-2 连续 2 轮(约 20 分钟)查不到 | 自动消失解锁置 failed,按 failed 重推 | +| 断链重推(远端已落单本地未同步) | 重推时预检命中 | 自动幂等回填单号跳过重复推送,无需人工清库 | +| 收票单发票状态异常 | 定时回写发现红冲/作废 → failed「已回收推送状态」 | 人工在金蝶侧处理后重新登记/推送 | +| 票池选不到票 | 镜像无数据 | 检查全票池同步开关与 `invoiceTypes` 白名单;或 `POST /invoice-receipt/sync-invoice-pool` 手动同步;票号精确查会自动回源 | +| 收款流水同步为空 | A301-2 查不到公告 | 检查 Nacos `claimAnnounce.claimusers` 是否与金蝶公告认领人一致 | +| 定时任务不跑/重复跑 | — | 核对 Nacos 开关键名(第 10 节);single-flight 锁保证多副本/手动不并发,`blade.lock.enabled=false` 时无锁 | + +--- + +## 12. 风险与待确认清单(2026-10-14) + +### 12.1 已关闭(旧总纲遗留,当前代码已核实修复) + +| 旧问题 | 状态 | +|---|---| +| A801-3 定时回写按发票号匹配防重键(回写失效) | ✅ 已修复(登记 id,`InvoiceReceiptServiceImpl:1003-1009`) | +| `if(true)` 调试组合块(强制/随机物料) | ✅ 全部删除,真实拆分+配置映射 | +| 客商定时 KS000067 硬编码 | ✅ 已移除 | +| 客商推送 bankNumber 硬编码 / A103-3 e_bebank_number 取名称字段 | ✅ 均改取客商收款账户联行号 | +| 结算/开票失败不落 failed | ✅ 统一落 failed(03b34310) | +| 结算单认领无保存重查闸门 | ✅ 已实现 assertInvoicesClaimable | + +### 12.2 仍需关注 / 联调确认 + +| # | 事项 | 证据/说明 | +|---|---|---| +| 1 | **A103-1 勾稽键口径**:代码传本地组装的应付防重键,`PaymentApplicationServiceImpl:1431-1443` 注释仍写「A102-4 反查 awrp_entry_code_p、禁止自行拼接」——注释与代码矛盾,金蝶侧付款计划分录标识是否等于防重键需联调实测 | 高优先 | +| 2 | **项目编码 999 残留**:全部推送链路(应付/应收/付款/预付款/开票申请)仍传测试值 999;A103-3「取项目档案真实编码」的逻辑已注释停用(详见入参映射表 14-#2) | 上线前处理 | +| 3 | **计量单位不送**:分录 DTO 有字段无赋值——需与金蝶确认不送计量单位是否影响单据校验 | 联调确认 | +| 4 | **A301-3 字段待金蝶确认**:`recpaytype_number`(101)/`paymenttype`/`recpayer` 取值口径 | 联调确认 | +| 5 | **A301-2 claimusers 未配置风险**:缺省空串调 A301-2 查不到数据(无报错),需在 Nacos 必配 | 配置项 | +| 6 | **其他链路不校验客商 kingdee_status**:客商未推送/未审核也会带编码推单(软依赖) | 既有风险 | +| 7 | **正式结算单作废未接 checkEditable**:仅靠前端锁状态标签禁用,接口层可绕过 | 建议补拦截 | +| 8 | **failed 重推无次数上限**:`KingdeeAutoSyncRetryTask` 每 10 分钟扫全量 failed,持续失败的单据会反复调金蝶(靠预检防重但不封顶) | 观察线上量 | +| 9 | **付款申请前端单笔「同步金蝶」按钮未绑定**(`handleSyncRow` 死代码):单笔入口仅剩审批自动同步 | 前端待补 | +| 10 | **旅客运输/代收车船税单据体**:DTO 已建未接(仅 freights 接入) | 按需迭代 | +| 11 | **方案 vs 代码差异(沿旧总纲)**:付款多次下推未实现(单分录单次);附件已落地(A902 4 链路)✅;收款流水同步已有定时(receiptClaimPush)✅;黑名单 A801-5 已实现 ✅ | — | + +--- + +## 附:文档关系 + +- 本文为新总纲(基线 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/01-客商档案列表.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/01-客商档案列表.png new file mode 100644 index 00000000..34d12ccb Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/01-客商档案列表.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/02-客商档案新增-信用代码自动查询.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/02-客商档案新增-信用代码自动查询.png new file mode 100644 index 00000000..bb378c20 Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/02-客商档案新增-信用代码自动查询.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/03-正式结算单-同步金蝶.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/03-正式结算单-同步金蝶.png new file mode 100644 index 00000000..0b7c3764 Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/03-正式结算单-同步金蝶.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/04-付款管理-批量同步.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/04-付款管理-批量同步.png new file mode 100644 index 00000000..ff938893 Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/04-付款管理-批量同步.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/05-开票管理-批量同步.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/05-开票管理-批量同步.png new file mode 100644 index 00000000..7e2232b3 Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/05-开票管理-批量同步.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/06-收票管理-批量同步.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/06-收票管理-批量同步.png new file mode 100644 index 00000000..0b5f0ee2 Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/06-收票管理-批量同步.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/07-收票登记-金蝶票池选票.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/07-收票登记-金蝶票池选票.png new file mode 100644 index 00000000..2437141d Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/07-收票登记-金蝶票池选票.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/08-收款流水-同步与推送.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/08-收款流水-同步与推送.png new file mode 100644 index 00000000..80280656 Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/08-收款流水-同步与推送.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/09-认领记录-查询金蝶认领结果.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/09-认领记录-查询金蝶认领结果.png new file mode 100644 index 00000000..60b481e2 Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/09-认领记录-查询金蝶认领结果.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/10-结算单-汇率自动带出.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/10-结算单-汇率自动带出.png new file mode 100644 index 00000000..624cdb27 Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/10-结算单-汇率自动带出.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/11-预结算单列表.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/11-预结算单列表.png new file mode 100644 index 00000000..5809ec06 Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/11-预结算单列表.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/12-预结算单-汇率字段.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/12-预结算单-汇率字段.png new file mode 100644 index 00000000..9e5436a7 Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/12-预结算单-汇率字段.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a102-应付单对照.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a102-应付单对照.png new file mode 100644 index 00000000..77fdd786 Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a102-应付单对照.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a103-3-预付款对照.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a103-3-预付款对照.png new file mode 100644 index 00000000..73b5f4f2 Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a103-3-预付款对照.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a103-付款申请对照.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a103-付款申请对照.png new file mode 100644 index 00000000..5aeaf6ce Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a103-付款申请对照.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a301-2-公告同步对照.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a301-2-公告同步对照.png new file mode 100644 index 00000000..4b16d08d Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a301-2-公告同步对照.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a301-3-认领推送对照.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a301-3-认领推送对照.png new file mode 100644 index 00000000..2ecd50fa Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a301-3-认领推送对照.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a301-3-金蝶认领结果.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a301-3-金蝶认领结果.png new file mode 100644 index 00000000..022c9a96 Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a301-3-金蝶认领结果.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a302-应收单对照.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a302-应收单对照.png new file mode 100644 index 00000000..7a39bace Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a302-应收单对照.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a303-1-开票张次对照.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a303-1-开票张次对照.png new file mode 100644 index 00000000..f088fb92 Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a303-1-开票张次对照.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a801-2-发票查看下载.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a801-2-发票查看下载.png new file mode 100644 index 00000000..63cbf98a Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a801-2-发票查看下载.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a801-4-外币汇率推送.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a801-4-外币汇率推送.png new file mode 100644 index 00000000..3bcf8a36 Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a801-4-外币汇率推送.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a902-客商编码对照.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a902-客商编码对照.png new file mode 100644 index 00000000..82271e1c Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a902-客商编码对照.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a902-金蝶客户接收.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a902-金蝶客户接收.png new file mode 100644 index 00000000..7e053b97 Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a902-金蝶客户接收.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a902-附件上传金蝶侧.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a902-附件上传金蝶侧.png new file mode 100644 index 00000000..329d4645 Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a902-附件上传金蝶侧.png differ diff --git a/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-收票金蝶状态列表.png b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-收票金蝶状态列表.png new file mode 100644 index 00000000..2a30d9f3 Binary files /dev/null and b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-收票金蝶状态列表.png differ 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 new file mode 100644 index 00000000..d5097a95 --- /dev/null +++ b/doc/docs/业务流程/2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).md @@ -0,0 +1,327 @@ +# 金蝶(星瀚)BFI 对接操作手册(纯操作版) + +> 整理日期:2026-10-14 +> 适用对象:TMS 业务操作人员(财务/业务)、系统管理员、实施运维。 +> 本文只讲**怎么操作**:在哪个页面、点什么按钮、前提是什么、成功的标志是什么、失败了怎么处理。 +> 接口与代码细节见同目录《2026-10-14-金蝶(星瀚)BFI全链路梳理(前端-后端-定时任务·业务域汇总与操作手册)》。 + +--- + +## 1. 全局须知 +金蝶测试环境地址:https://cqcs-test.gxwuzi.com:8022/ierp/login.html + +账号密码: + +​ 13545909224 liuquanjia@123 + +​ 13737128880 websoft5386339 + +### 1.1 金蝶单据状态含义(列表里看到的字段) + +| 状态值 | 含义 | 还会变吗 | +|---|---|---| +| unsynced | 未推送金蝶 | 会(推送成功变 synced) | +| synced | 已推送金蝶,金蝶受理 | 会(继续走后续状态) | +| failed | 推送失败(有失败原因) | 会(重推成功变 synced) | +| paying | 金蝶付款中(仅付款申请) | 会(付款完成变 paid) | +| paid / closed | 已付款 / 已关闭(终态) | 不变 | +| invoiced | 已开票(终态,开票申请) | 不变 | +| assigned | 已指定应付(终态,收票) | 不变 | +| exist | 金蝶已有该客商且审核通过(终态,客商) | 不变 | + +### 1.2 「金蝶锁定」标签是什么 + +结算单、付款申请、开票申请推送成功后,TMS 会**自动锁定金蝶侧单据**。列表出现红色「金蝶锁定中」标签时:**该单据不可修改、不可作废**。要改动只能先在金蝶侧解锁/删单,金蝶会回调 TMS 自动还原本地状态。 + +### 1.3 两条推送路径 + +- **自动**:审批「通过」时系统自动推送金蝶(需管理员开启对应开关,见第 4 节);开启后审批接口会直接返回推送结果,失败时页面提示「审批通过,但自动同步金蝶失败:xxx」。 +- **手动**:列表勾选单据点工具栏「同步金蝶 / 批量同步 / 推送金蝶」按钮。开关没开时用这条路径。 + +两条路径效果完全一样,重复点不会重复推送(系统自动查重)。 + +--- + +## 2. 一次性准备(实施/管理员操作) + +| # | 事项 | 操作人 | 操作内容 | 不做的后果 | +|---|---|---|---|---| +| 1 | 部门金蝶组织编码 | 管理员 | 在部门档案维护「金蝶组织编码」字段 | 该部门单据推送时直接报错拦截 | +| 2 | 客商收款账户联行号 | 财务 | 客商档案维护收款账户(账号/开户行,**联行号必填**) | 付款推送、客商推送报错拦截 | +| 3 | 主数据初始化 SQL | 运维 | 执行 `doc/sql/transport/` 下金蝶相关脚本(计量单位等主数据、认领表、10-13 迁移脚本 `lqj/`) | 对应链路数据缺失 | +| 4 | 认领人配置 | 运维 | Nacos 配 `thirdParty.bfi.claimAnnounce.claimusers`(值须与金蝶认领公告的认领人一致) | 收款流水同步查不到数据(不报错) | +| 5 | 票池票种白名单 | 运维 | Nacos 配 `thirdParty.bfi.invoiceReceipt.invoiceTypes`(要同步的发票种类编码,逗号分隔) | 票池同步不到对应票种 | +| 6 | 物料映射 | 运维 | Nacos 配 `thirdparty.bfi.material.*`(有默认值,建议显式确认) | 推送报「运输方式未配置」类拦截 | +| 7 | 定时任务开关 | 运维 | 按第 4 节清单逐项开启 | 只能全靠手动按钮 | +| 8 | 审批自动同步开关 | 运维 | 按第 4 节 `autoSync.*` 按域开启 | 审批通过后不自动推送,走手动 | + +--- + +## 3. 日常操作流程 + +### 3.1 客商建档与同步 + +**页面:客商管理 → 客商档案** + +![客商档案列表:行内「同步金蝶」链接仅在审批通过且推送失败(failed)的单据上显示;审批通过的客商由系统自动推送](./2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/01-客商档案列表.png) + +1. 点「新增」,录入**统一社会信用代码**(满 18 位自动查金蝶回填工商信息并锁定相关字段)。 + - 若提示金蝶侧该客商「审核中」——等金蝶审核完再保存/推送。 +2. 维护**收款账户(联行号必填)**和发票信息(纳税人识别号用于黑名单校验)。 +3. 提交审批:系统自动做**黑名单校验**。 + - 提交被拦「命中黑名单」→ 该客商为国资委黑名单 A 类,**不能提交**,走线下处置。 +4. 审批「通过」→ 系统自动推送金蝶 → 列表 `kingdee_status` 变 **synced**。 + - 变 **failed** → 看失败原因(悬停/详情),修复后点行内「同步金蝶」重推。 +5. synced 是「金蝶已受理」;金蝶审核通过后由定时任务升级为 **exist**(终态,无需操作)。 + +推送成功后金蝶侧效果(供确认推送结果时对照): + +![金蝶客户列表收到推送的客商:编码=TMS 客商编号(KS000073),客户分类 1101 内部客户/1102 外部客商](./2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a902-金蝶客户接收.png) + +![TMS 客商档案与金蝶客户档案对照:编码一致、以统一社会信用代码为关联键](./2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a902-客商编码对照.png) + +![新增客商档案:统一社会信用代码录入满 18 位自动到金蝶查询并回填工商信息](./2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/02-客商档案新增-信用代码自动查询.png) + +> 注意:审批通过后的**档案变更不会重新推送金蝶**。改了信用代码等关键信息需人工确认金蝶侧一致性。 + +### 3.2 预结算单(正式结算与预付款的前置) + +**页面:结算管理 → 预结算单** + +预结算单**本身不推送金蝶**(列表无金蝶状态字段),它是正式结算和进度预付的前置单据,与金蝶的关联有两处:①非人民币项目在此维护汇率(从金蝶自动带出);②进度预付款在金蝶付讫后自动回写本单的垫资记录。 + +![预结算单列表:工具栏「新增 / 预付申请 / 尾款结算」;草稿行可 编辑/删除/提交,审批中可 通过/驳回](./2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/11-预结算单列表.png) + +1. 制单:「新增」录入结算金额与明细;**非人民币项目**:选择币种和汇率日期 → 系统自动从金蝶带出结算汇率并重算本位币。 + ![预结算单编辑页:汇率日期/结算汇率字段(人民币时禁用为 1.00,外币选择日期后自动带出)](./2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/12-预结算单-汇率字段.png) +2. 提交审批 → 审批「通过」(或驳回后修改重新提交)。 +3. 审批通过后的三个出口: + - **发起预付**:工具栏「预付申请」(限应付且已审批、未转正式)→ 生成进度预付付款申请,走 3.4 预付款推送金蝶; + - **转正式结算**:在正式结算单页面新建时选择本预结算单作为来源(可多张合并)→ 转入 3.3 正式结算推送;转正式后本单被锁定(节点显示「已锁定(正式结算)」并回写正式结算单号); + - **尾款结算**:应付单生成尾款结算单号(同样需先转入正式结算推金蝶)。 +4. 注意:**正式结算单的汇率不自动继承预结算单**,转正式后需在正式结算单编辑时重新选汇率日期带出(见 3.8),否则推送金蝶不带汇率。 +5. 金蝶付款回写联动:进度预付在金蝶付讫(paid)后,系统自动在本单垫资记录回写付款金额与金蝶付款单号,并刷新已付/未付汇总——无需手工登记(异常时管理员可用回写接口手工补)。 + +### 3.3 结算单推送(应付 / 应收) + +**页面:结算管理 → 正式结算单(应付 / 应收 Tab)** + +![正式结算单:应付/应收同页两个 Tab;工具栏「同步金蝶」需先勾选一条审批通过的单据;列表含结算汇率、金蝶单据状态、金蝶锁状态等列](./2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/03-正式结算单-同步金蝶.png) + +前提:结算单已审批通过;非人民币结算单已维护汇率(见 3.8);客商已 synced/exist。 + +1. **自动路径**:审批「通过」时自动推送(开关开启时)。 +2. **手动路径**:勾选一条单据 → 工具栏「同步金蝶」。 + - 按钮是单选操作,一次推一张。 +3. 成功标志:列表出现**金蝶单号**,状态变 **synced**,并出现「金蝶锁定中」标签。 +4. 失败处理:状态变 failed → 看失败原因 → 常见原因: + - 「请先同步金蝶应付单」类 → 关联结算单还没推(应付/应收/收票有先后依赖,见 3.6); + - 客商编码取不到 → 先完成 3.1; + - 部门金蝶组织编码缺失 → 找管理员维护。 +5. 纠偏:如果怀疑推送实际成功但本地状态没更新,等定时勾稽(或联系运维手动触发),系统会自动核对金蝶侧并纠正状态/补单号。 + +推送成功后金蝶侧效果(供确认推送结果时对照): + +![金蝶财务应付单与 TMS 结算单对照:金蝶单上的「外部系统单据号」=TMS 防重键,应付金额与明细行(物料/车号/货物)逐行对应](./2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a102-应付单对照.png) + +![金蝶财务应收单与 TMS 结算单对照:应收金额、费用合计对应,明细行物料/车号/运输货物逐行对应](./2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a302-应收单对照.png) + +### 3.4 付款申请推送与结果回写 + +**页面:收付款管理 → 付款管理** + +![付款管理:工具栏「批量同步」推送付款申请;列表自带「金蝶单据号 / 金蝶单据状态 / 金蝶锁状态 / 附件同步」列——图中首行即「已生成 + 金蝶锁定中 + 附件已上传」的已推送单据](./2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/04-付款管理-批量同步.png) + +前提:付款申请已审批通过,且**关联的结算单已推送金蝶(synced)**。 + +1. 推送: + - 自动路径:审批「通过」时自动下推。 + - 手动路径:勾选**审批通过**的单据 → 工具栏「批量同步」(一次可多张,单张失败不影响其他)。 + - 预付款(项目垫资/进度垫资)走同样的按钮,系统自动走预付款通道。 +2. 成功标志:状态 **synced** + 金蝶单号 + 锁定标签。 +3. 查付款结果: + - 正常等定时任务回写(默认每 10 分钟,开关开启时); + - 急用点工具栏「**同步付款结果**」(勾选单据后显示;若未见到该按钮,说明当前环境以定时回写为主,等下一轮即可)。 +4. 结果状态流转:synced → **paying**(金蝶付款中)→ **paid**(已付款,含付款记录/凭证信息)/ **closed**(关闭)。 +5. 失败与特殊场景: + - failed → 修复后重推(批量同步按钮即可,系统会自动跳过金蝶已有的); + - 单据在金蝶被删:回调后自动置 failed 并清单号,直接重推; + - 金蝶删了但回调丢了:约 20 分钟后系统自动检测到「查不到」并解锁置 failed,再重推。 + +推送成功后金蝶侧效果(供确认推送结果时对照): + +![TMS 付款申请与金蝶付款申请单对照:申请付款金额、收款账号、结算方式逐项对应;金蝶单号 FKSQ-… 与 TMS 列表回写的金蝶单号一致](./2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a103-付款申请对照.png) + +![预付款推送效果:TMS 进度预付在金蝶生成「其他付款申请」,预付款金额、收款账户对应,附件随单上传](./2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a103-3-预付款对照.png) + +> 注意:推送后单据被金蝶锁定,**不能改金额、不能作废**。发现金额错误必须先金蝶侧处理。 + +### 3.5 开票申请推送与开票结果 + +**页面:收付款管理 → 开票管理** + +![开票管理:工具栏「批量同步」逐张推送(按发票张次拆单);搜索区可按「金蝶单据状态」筛选](./2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/05-开票管理-批量同步.png) + +前提:开票申请已审批通过,且**关联结算单的应收单已推送金蝶**。 + +1. 推送:审批「通过」自动推送,或勾选后工具栏「批量同步」(逐张处理)。 + - 系统按**发票张次**拆分:一张申请 3 张发票会生成 3 张金蝶开票申请单。 +2. 成功标志:状态 **synced**,金蝶单号多张时逗号拼接显示;逐张锁定。 +3. 查开票结果:等定时回写(默认每 10 分钟),状态变 **invoiced**(终态)。 +4. 查看发票文件:正式结算单详情 →「开票发票」区 → 查看/下载(PDF 优先)。 + +推送成功后金蝶侧效果(供确认推送结果时对照): + +![开票申请按发票张次拆分:TMS 开票内容第1张/第2张分别生成金蝶开票申请单,购销方、不含税金额逐项对应](./2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a303-1-开票张次对照.png) + +### 3.6 收票登记(进项发票) + +**页面:收付款管理 → 收票管理** + +![收票管理:工具栏「批量同步」推送收票单并指定应付](./2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/06-收票管理-批量同步.png) + +**硬性前提**:一张票只能登记一次;推送前必须已分摊到**恰好一张审批通过的应付结算单**,且该结算单已推送金蝶。 + +1. 选票:表单里点发票号放大镜 → 弹出「查询金蝶票据池」→ 勾选 → 确认(带出票面信息)。 + ![查询金蝶票据池弹窗:按发票号/日期/税号/票种等筛选;每行可「查看/下载」发票原件(PDF 优先);点「选择」带出到表单](./2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/07-收票登记-金蝶票池选票.png) + + ![img.png](./2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/img.png) + - 票池数据来自定时同步(默认每 30 分钟);票号精确搜索时若本地没有会**自动去金蝶现查**。 + - 查不到票 → 等同步或让运维检查票种白名单配置/手动触发同步(见 4.2)。 +2. 保存:系统自动**重查金蝶票池校验可用性**——票已被别人登记/认领/红冲/作废会保存失败并提示原因。 +3. 分摊应付结算单后提交审批。 +4. 推送:审批「通过」自动推送,或勾选 → 工具栏「批量同步」。 +5. 状态流转:synced→(系统内)→ **pushed**(金蝶收票单已生成)→ **assigned**(已指定应付,终态)。 + - 卡在 pushed 不用管:定时任务会自动补发「指定应付」。 + - failed 且提示发票状态异常(红冲/作废)→ 该票在金蝶已不可用,需人工处理,不要重推。 +6. 附件:表单附件区可查看/下载发票原件(系统自动从金蝶取,PDF 优先)。 + +推送成功后金蝶侧状态与效果(供确认推送结果时对照): + +![收票管理列表「金蝶状态」列:已指定应付(终态)/推送失败(含失败原因)/未推送](./2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-收票金蝶状态列表.png) + +![推送的单据附件已上传到金蝶侧单据的「附件」区](./2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a902-附件上传金蝶侧.png) + +![正式结算单详情「发票信息」区:认领的发票可 查看/下载(系统从金蝶取原件,PDF 优先);付款信息区回显付款申请金蝶单号](./2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a801-2-发票查看下载.png) + +### 3.7 收款认领 + +**页面:收付款管理 → 收款流水、认领记录** + +这是「金蝶收到钱 → TMS 认领到结算单 → 回推金蝶」的流程: + +1. **同步流水**:收款流水页 → 工具栏「**手动同步流水**」→ 拉取金蝶认领公告。 + ![收款流水:工具栏「手动同步流水」拉取金蝶认领公告、「批量认领」进入认领;列表展示认领通知单/付款人/对方户名等](./2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/08-收款流水-同步与推送.png) + - 查不到数据先检查认领人配置(第 2 节 #4)。 +2. **认领**:点流水进入详情(或工具栏「批量认领」)→ 选择**审批通过且已推送金蝶的应收结算单**、填分摊金额 → 确认。 + - 认领此时只落本地,不推金蝶。 + - 分摊总额不能超过该流水未认领金额。 +3. **推送金蝶**: + - 手动:流水详情/符合条件的行内「推送金蝶」; + - 自动:定时任务(默认每 10 分钟,开关开启时)自动推送认领完成的流水。 + - 成功标志:流水显示金蝶收款单号、推送状态 pushed。 +4. **回写结果**:认领记录页 → 工具栏「**查询金蝶认领结果**」→ 状态推进 approved(金蝶已审核)/ **received**(已收款,终态)。 + ![认领记录:工具栏「查询金蝶认领结果」回写金蝶收款单状态](./2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/09-认领记录-查询金蝶认领结果.png) +5. **作废限制**:已推送金蝶的认领**不能作废**(提示去金蝶侧人工处置)。未推送的可以作废。 + +推送成功后金蝶侧效果(供确认推送结果时对照): + +![金蝶认领公告与 TMS 收款流水对照:「手动同步流水」拉取的就是金蝶认领公告列表里的 RLTZ 通知单](./2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a301-2-公告同步对照.png) + +![TMS 认领记录推送后,金蝶「认领处理列表」生成对应认领单(认领通知单单号一致)](./2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a301-3-认领推送对照.png) + +![金蝶认领单详情:认领结果行的核心单据编号=TMS 分摊结算单对应的金蝶单据](./2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a301-3-金蝶认领结果.png) + +### 3.8 汇率维护(非人民币结算) + +结算单(预结算/正式结算)编辑时:选择非 RMB 币种 → 选择**汇率日期** → 系统自动从金蝶带出汇率并重算本位币金额。带不出(金蝶该日无汇率)→ 换日期或联系金蝶侧确认。 + +![结算单详情:汇率日期/结算汇率字段由金蝶汇率接口自动带出,本位币金额随之重算](./2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/10-结算单-汇率自动带出.png) + +外币结算推送后,金蝶侧按该汇率自动换算本位币: + +![金蝶侧效果:汇率 6.7367、金额(本位币)自动换算,与 TMS 结算单的本位币合计一致](./2026-10-14-金蝶(星瀚)BFI对接操作手册(纯操作版).assets/bfi-effect-a801-4-外币汇率推送.png) + +--- + +## 4. 定时任务与开关(运维视角) + +### 4.1 任务清单(Nacos `thirdparty.bfi.*` 配置) + +| 任务 | 干什么 | 开关键 | 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` | + +### 4.2 审批自动同步开关(按业务域独立) + +`thirdParty.bfi.autoSync.settlement`(结算单)/ `…payment`(付款)/ `…invoiceApply`(开票)/ `…receipt`(收票),默认全关。**代码默认全部关闭,必须在 Nacos 显式开启**;客商档案审批推送不受此开关控制(始终自动)。 + +### 4.3 运维注意 + +- 所有定时任务带**分布式锁**:多实例部署只跑一份;手动按钮和定时共用锁,定时运行中手动触发会直接跳过(属正常)。 +- 开了「failed 重推」后,持续失败的单据每 10 分钟会重试一次,**修复根因**(配置/数据)比反复观察更重要。 +- 手动触发全票池同步与定时是同一逻辑,数据量大时耐心等待,不要反复点。 + +--- + +## 5. 常见问题与异常处理(FAQ) + +| 现象 | 原因 | 处理 | +|---|---|---| +| 推送结算单不带汇率 | 正式结算单没维护汇率(预结算单的汇率**不自动继承**到正式结算单) | 在正式结算单编辑页重选汇率日期自动带出后再推 | +| 推送报「客商编码」类错误 | 客商未推送金蝶或档案缺数据 | 先完成客商同步(3.1),再推单据 | +| 推送报「请先同步金蝶应付单/应收单」 | 前置单据未推送 | 顺序:结算单(应付/应收) → 付款/开票/收票;先把前置结算单推送 | +| 推送报组织/部门编码类错误 | 部门没维护金蝶组织编码 | 管理员补维护后重推 | +| 推送报联行号类错误 | 客商收款账户联行号缺失 | 财务补维护收款账户后重推 | +| 提交客商审批被拦黑名单 | 纳税人识别号命中国资委黑名单(A 类) | 不可提交,走线下处置;B 类提示仅告警默认放行 | +| 审批通过但提示「自动同步金蝶失败」 | 推送失败但审批已生效 | 看失败原因 → 修复 → 手动「同步金蝶」重推(单据不会丢) | +| 列表「锁定失败」标签 | 推送成功但金蝶锁单接口失败 | 系统自动补偿(≤5 次);急用让运维调手动补偿端点 | +| 单据要改但显示「金蝶锁定中」 | 已推送并被金蝶锁定 | 到金蝶侧解锁/删单 → TMS 自动还原后即可编辑;不可强行作废 | +| 付款状态长时间不更新 | 回写定时未开启或金蝶侧未处理 | 点「同步付款结果」;仍无 → 金蝶侧查付款处理单 | +| 开票申请 synced 但没发票信息 | 开票结果未回写 | 等定时/联系运维手动触发;确认金蝶票易通已开票 | +| 票池选票查不到 | 镜像未同步到该票种 | 检查票种白名单配置;运维手动触发全票池同步;票号精确搜索可触发自动现查 | +| 保存收票登记被拦「可用金额为0」 | 票已被占用/认领/红冲 | 换票或在金蝶确认票据状态 | +| 收票卡在 pushed 不动 | 「指定应付」步骤未完成 | 开启收票回写定时自动补发,或重推一次 | +| 收款流水同步为空 | 认领人配置与金蝶不一致 | 检查 Nacos `claimAnnounce.claimusers` | +| 认领推送报「认领公告id缺失」 | 流水没同步过就认领了 | 先「流水同步」再操作 | +| 认领推送报金蝶内码缺失 | 分摊的结算单没推送金蝶 | 先完成结算单(应收)推送(3.3) | +| 已推送认领想作废 | 金蝶已生成收款单 | 不能作废,去金蝶侧人工处置 | +| 断网/金蝶宕机期间推了一半 | 部分单据 failed | 恢复后 failed 重推任务(或手动)自动续推,系统查重不会重复生成金蝶单据 | + +--- + +## 6. 附:操作入口速查表 + +| 页面 | 按钮/操作 | 作用 | +|---|---|---| +| 客商档案 | (表单)录信用代码 | 自动查金蝶回填工商信息 | +| 客商档案 | 行内「同步金蝶」 | 失败重推客商 | +| 预结算单 | 工具栏「预付申请」/「尾款结算」 | 审批通过后发起进度预付 / 生成尾款结算号(预结算本身不推金蝶) | +| 正式结算单 | 新建时选择来源预结算单 | 预结算转正式结算(转后预结算锁定) | +| 正式结算单 | 工具栏「同步金蝶」(单选) | 推送应付/应收单到金蝶 | +| 正式结算单 | (编辑)汇率日期 | 自动带出金蝶汇率 | +| 正式结算单 | (编辑)发票认领「查询/确认识领」 | 从金蝶票池选票认领到结算单 | +| 付款申请 | 工具栏「批量同步」 | 下推付款申请/预付款到金蝶 | +| 付款申请 | 工具栏「同步付款结果」 | 立即回写付款状态与付款记录 | +| 开票申请 | 工具栏「批量同步」 | 按发票张次推送开票申请 | +| 收票登记 | (表单)发票号放大镜 | 从金蝶票池选票 | +| 收票登记 | 工具栏「批量同步」 | 推送收票单+指定应付 | +| 收票登记 | (表单)附件查看/下载 | 从金蝶取发票原件(PDF 优先) | +| 收款流水 | 工具栏「手动同步流水」 | 拉取金蝶认领公告 | +| 收款流水 | (详情/工具栏「批量认领」)认领 | 本地认领到结算单 | +| 收款流水 | 行内「推送金蝶」 | 推送认领结果到金蝶 | +| 认领记录 | 工具栏「查询金蝶认领结果」 | 回写金蝶收款单状态 | +| 运维专用 | `POST /invoice-receipt/sync-invoice-pool` | 手动全票池同步 | +| 运维专用 | `POST /formal-settlement/retry-kingdee-bill-lock` | 手动补偿锁单 | +| 运维专用 | `POST /formal-settlement/sync-kingdee-ar-result` | 手动应收勾稽纠偏 | +| 运维专用 | `POST /invoice-application/sync-kingdee-invoice-result` | 手动开票结果回写 | + +--- + +> 本文基于 2026-10-14 代码现状整理;系统升级后按钮与提示如有出入,以页面实际为准,并核对全链路总纲文档的更新说明。 diff --git a/doc/docs/业务流程/付款申请同步金蝶(星瀚)技术文档.md b/doc/docs/业务流程/付款申请同步金蝶(星瀚)技术文档.md new file mode 100644 index 00000000..8f2980b7 --- /dev/null +++ b/doc/docs/业务流程/付款申请同步金蝶(星瀚)技术文档.md @@ -0,0 +1,259 @@ +# 付款申请同步金蝶(星瀚)技术文档 + +> 适用系统:TMS(BladeX 微服务平台,服务:blade-transport) +> 对接系统:金蝶云·星瀚(业财融合 BFI,经 iPaaS 网关) +> 关联文档:`doc/docs/TMS业财融合-集成流程图文字版与业务流程梳理.md`、`doc/docs/TMS业财融合方案.docx`、`doc/docs/业财融合系统接口文档.xlsx` +> 最后更新:2026-09-16 + +--- + +## 1. 背景与范围 + +付款申请审核通过后,需将单据推送至金蝶云·星瀚生成付款申请单,并把星瀚返回的**单据内部主键(id)**记录到 `blade_payment_application.kingdee_bill_no`,供后续附件上传、状态查询、付款结果回写使用。 + +- 触发入口:付款申请页面(前端 `tms-erp-web/src/views/payment/payment-application.vue`)"批量同步"按钮,权限码 `payment_application_sync` +- 对接接口:**A103-3 付款申请保存** `POST /ierp/kapi/v2/awrp/ap/ap/ap_payapply/save` +- 覆盖路径:**预付款**(project_advance / progress_advance)与**正式付款**(settlement_payment)本次均走该接口;正式付款按方案文档原规划走 `ap_finapbill/push`(A103-1 应付下推),待后续切换 +- 调用方式:前端 → TMS 后端转发 → 星瀚(OAuth2 凭证仅存后端配置,前端不持有) + +--- + +## 2. 业务流程 + +### 2.1 业财融合整体链路(SC-04 对公付款) + +``` +TMS 付款申请审核通过 + → 批量同步(本文档):推送金蝶生成付款申请单(A103-3) + → 金蝶财务共享审批(黑盒,机器人处理) + → 金蝶付款处理 + → TMS 定时查询回写付款结果(A103-2 付款申请单查询 / A103-4 付款处理查询,待建设) + → 附件推送(A902,凭付款申请单 id)与合同系统履约同步(C106-2,待建设) +``` + +### 2.2 TMS 付款申请状态流转 + +| 阶段 | approvalStatus | 说明 | +| --- | --- | --- | +| 草稿 | `draft` | 可编辑、可删除 | +| 审批中 | `reviewing` | 提交后进入财务审核 | +| 审批通过 | `approved` | **仅此状态允许同步金蝶**;可作废(voided) | +| 已驳回 | `returned` | 驳回后可编辑重新提交 | +| 已作废 | `voided` | 终态,不可同步 | + +### 2.3 批量同步时序 + +``` +前端 payment-application.vue blade-transport 后端 星瀚(BFI) + │ 勾选 approved 记录 │ │ + │ POST /payment-application/ │ │ + │ sync-kingdee-batch [id...] │ │ + │──────────────────────────────────────>│ 逐单循环(单条失败不中断批次) │ + │ │ FOR UPDATE 锁单 + 状态校验 │ + │ │ 组装 BfiPayApplySaveDTO │ + │ │ POST /ierp/kapi/v2/awrp/ap/ │ + │ │ ap/ap_payapply/save ───────>│ + │ │ <── result[]{billStatus,id,...} ─│ + │ │ kingdeeBillNo = id │ + │ │ kingdeeStatus = synced │ + │ │ 下游台账联动(预付/结算付款记录) │ + │ <── R>(逐单结果)────────│ │ +``` + +### 2.4 前置条件与幂等约定 + +| 约定 | 说明 | +| --- | --- | +| 状态校验 | 仅 `approvalStatus = approved` 允许同步 | +| 幂等跳过 | `kingdeeStatus = synced` 的单据直接返回已存单据号,不重复推送 | +| 防重键 | 星瀚侧 `awrp_src_code`(=paymentNo)+ `awrp_src_sys` 组成候选键,重复推送走更新语义;分录行 `awrp_entry_code`(=paymentNo + "-1")不得重复 | +| 失败重推 | 同步失败置 `kingdeeStatus = failed`,可重新勾选同步 | +| 锁单 | 推送成功后金蝶侧锁定,推送字段不允许修改;如需调整须金蝶反审核+删除后重推(B901 LOCK/UNLOCK) | + +--- + +## 3. 接口定义(A103-3 付款申请保存) + +### 3.1 鉴权 + +`access_token` 通过 `POST /ierp/kapi/oauth2/getToken` 获取(参数 clientId / clientSecret / username / accountId / nonce / timestamp),有效期 2 小时,由 `BfiServiceImpl` 自动获取与缓存,业务代码无需关心。 + +### 3.2 请求 + +- URL:`{thirdParty.bfi.baseUrl}/ierp/kapi/v2/awrp/ap/ap/ap_payapply/save` +- Method:`POST`,请求体 `{"data": [ {主表字段, "entry":[分录]} ]}` + +### 3.3 返回结构(示例) + +```json +{ + "data": { + "failCount": "0", + "result": [ + { + "billIndex": 0, + "billStatus": true, + "errors": [], + "id": "2395327167211785216", + "keys": { + "awrp_src_code": "00225", + "awrp_src_sys": "测试系统" + }, + "number": "FKSQ-00000147", + "type": "Add" + } + ], + "successCount": "1" + }, + "errorCode": "0", + "message": null, + "status": true +} +``` + +| 字段 | 含义 | TMS 处理 | +| --- | --- | --- | +| data.successCount / failCount | 成功/失败数量(字符串数字) | `BfiBillSaveResultVO`,辅助 `isAllSuccess()` | +| result[].billIndex | 对应请求 data 数组下标 | 逐单调用恒为 0 | +| result[].billStatus | 单据级成败 | false 时以 errors 抛出业务异常 | +| result[].id | **星瀚付款申请单内部主键** | **写入 `kingdee_bill_no`** | +| result[].number | 星瀚单据编号(FKSQ-xxxx) | 暂不落库,可经 A103-2 查询勾稽 | +| result[].type | Add 新增 / Push 下推 | 仅记录 | +| errorCode / message / status | 接口级状态 | `commonCall` 统一校验,失败抛 `BfiException` | + +--- + +## 4. 字段映射关系 + +### 4.1 主表(`BfiPayApplySaveDTO` ← `PaymentApplication` 实体 / 配置) + +| 接口字段 | 含义 | 来源 | 说明 | +| --- | --- | --- | --- | +| awrp_src_code | 来源单据号(防重键) | `paymentNo` | 付款申请单号 | +| awrp_src_sys | 外部系统编号(防重键) | 配置 `payApply.srcSys` | 默认 `WLTMS` | +| settleorg_number / applyorg_number / payorg_number / purorg_number | 结算/申请/付款/采购组织编码 | 合同所属组织 | `organizationId` → 组织编码 `deptCode`,为空回退组织名称;缺失报错 | +| billtype_number | 单据类型编码 | 配置 `payApply.billtype` | 默认 `ap_payapply_oth_BT_S` 其他付款申请 | +| applydate | 申请日期 | `applyDate` | yyyy-MM-dd,空取当天 | +| applycause | 请款事由 | paymentType 映射 | 见 5.1 | +| paycurrency_number / settlecurrency_number | 付款/结算币种 | 合同结算币种 `settlementCurrency` | 缺失报错 | +| awrp_src_type | 外部单据类型 | 配置 `payApply.srcType` | 默认 `TMS付款申请` | +| exratetable_number | 汇率表编码 | 配置 `payApply.exratetable` | 默认 `01` | +| exratedate | 汇率日期 | 同 applydate | yyyy-MM-dd | +| awrp_textfield1 | 付款渠道 | `paymentMethod` 字典标签 | 见 5.4 | +| awrp_src_bt | 来源单据类型 | 有合同传 `awrp_contract_info` | 可选 | +| awrp_src_con_no | 上游合同号 | `contractNo` | 可选,随 awrp_src_bt 一起传 | + +### 4.2 分录(`BfiPayApplySaveEntryDTO`,单条申请固定 1 行) + +| 接口字段 | 含义 | 来源 | 说明 | +| --- | --- | --- | --- | +| e_paymenttype_number | 付款类型编码 | paymentType 映射 | `201` 正式付款 / `202` 预付款,见 5.1 | +| e_asstacttype | 往来类型 | 固定 `bd_supplier` | 供应商 | +| e_asstact_number | 往来户编码 | payeeName → 客商档案 `customerCode` | 按 fullName/shortName 匹配,未匹配回退 payeeName | +| e_assacct | 往来账户 | `bankAccount` | 收款账号 | +| e_bebank_number | 往来银行编码 | `bankName` | 暂传名称,待银行编码映射 | +| e_applyamount / e_payamount | 申请/应付金额 | `appliedAmount` | 2 位小数 | +| e_expaydate | 期望付款日 | 同 applydate | — | +| project_number | 项目编码 | projectId → `ProjectApply.projectCode` | 无项目传 `999` | +| 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。 + +> 前置校验:推送前按 `contractId` 加载合同,未关联合同、合同不存在、所属组织编码为空、结算币种为空时,同步失败并标记 `kingdeeStatus = failed`。 + +--- + +## 5. 字典与枚举对应关系 + +### 5.1 付款类型 paymentType → 接口编码 + +| paymentType(TMS) | 中文含义 | e_paymenttype_number | applycause 请款事由 | +| --- | --- | --- | --- | +| `project_advance` | 项目预付 | `202` | 项目预付款申请 | +| `progress_advance` | 进度预付 | `202` | 进度预付款申请 | +| `settlement_payment` | 结算付款(正式付款) | `201` | 结算付款申请 | + +### 5.2 审批状态 approvalStatus + +`draft` → `reviewing` → `approved` / `returned`;`approved` → `voided`。仅 `approved` 可同步。 + +### 5.3 金蝶同步状态 kingdeeStatus + +| 值 | 含义 | 触发 | +| --- | --- | --- | +| `unsynced` | 未生成 | 新建单据默认 | +| `synced` | 已生成 | 推送成功,`kingdee_bill_no` = 星瀚单据 id | +| `failed` | 生成失败 | 推送异常/金蝶返回 billStatus=false,可重推 | + +### 5.4 付款渠道 awrp_textfield1 + +`paymentMethod` 存业务字典 `pay_method`(DictBiz)的 key/value,推送时经 `DictBizCache.getList("pay_method")` 翻译为字典标签(如电汇、汇票);未命中字典时原样传递。汇票类付款(标签含"汇票")在 TMS 侧另有台账占用逻辑,与推送无关。 + +### 5.5 其他固定值 + +| 常量 | 值 | 用途 | +| --- | --- | --- | +| 往来类型 | `bd_supplier` | e_asstacttype | +| 上游合同类型 | `awrp_contract_info` | awrp_src_bt | +| 无项目编码 | `999` | project_number | + +--- + +## 6. 配置项(Nacos:`thirdParty.bfi.*`) + +模板见 `doc/nacos/blade-dev.yaml`、`blade-prod.yaml`、`third-party-api.yaml`,支持环境变量覆盖: + +| 配置键 | 环境变量 | 默认值 | 说明 | +| --- | --- | --- | --- | +| thirdParty.bfi.baseUrl | BFI_BASE_URL | — | 星瀚网关地址(已有) | +| thirdParty.bfi.clientId / clientSecret / username / accountId | BFI_* | — | OAuth2 凭证(已有) | +| thirdParty.bfi.payApply.srcSys | BFI_PAY_APPLY_SRC_SYS | WLTMS | 外部系统编号(防重键) | +| thirdParty.bfi.payApply.billtype | BFI_PAY_APPLY_BILLTYPE | ap_payapply_oth_BT_S | 单据类型编码(其他付款申请) | +| thirdParty.bfi.payApply.exratetable | BFI_PAY_APPLY_EXRATE_TABLE | 01 | 汇率表编码 | +| thirdParty.bfi.payApply.srcType | BFI_PAY_APPLY_SRC_TYPE | TMS付款申请 | 外部单据类型 | + +> 组织编码与币种不走配置:分别取自付款申请关联合同的所属组织(`blade_contract_manage.organization_id` → 组织编码,为空回退组织名称)与结算币种(`settlement_currency`)。 + +--- + +## 7. 异常处理与批量语义 + +| 场景 | 行为 | +| --- | --- | +| 单笔同步 `syncKingdee` | 校验失败/推送失败置 `failed` 并抛 `ServiceException`,前端提示原因(含金蝶 errors 明细) | +| 批量同步 `syncKingdeeBatch` | 逐单循环,**单条失败不中断批次**;失败单标记 `failed` 后继续下一单;返回结果列表中附 `付款申请[id]同步失败:原因` | +| 金蝶接口级失败(status=false / errorCode≠0) | `BfiServiceImpl.commonCall` 抛 `BfiException`,同上处理 | +| 金蝶单据级失败(billStatus=false) | 以 `errors` 拼接抛出 | +| 重复点击同步 | 已 `synced` 单据跳过;`failed`/`unsynced` 允许重推(防重键保证金蝶侧幂等) | + +--- + +## 8. result.id 的后续使用(待建设环节) + +| 环节 | 接口 | id 用法 | +| --- | --- | --- | +| 付款申请附件上传 | A902 `/frame/attachment/uploadFile` | `attachmentUploadFileArgs.billPkId` = 该 id | +| 付款申请单状态查询 | A103-2 `ap_payapply/query` | 按 awrp_src_code+awrp_src_sys 查询,返回体含 id/billno/billstatus(A暂存/B已提交/C已审核/D已关闭)/paystatus(未付/部分付/已付),与本地 id 勾稽 | +| 付款处理查询 | A103-4 `cas_paybill/query` | 返回 sourcebillid/sourcebillnumber,与 id/编号核对付款结果 | + +## 9. 遗留事项 + +1. **付款完成模拟待拆除**:同步成功后仍保留 `paidAmount = 申请金额`、`mockPaymentRecords()` 模拟付款记录(单号前缀 `MOCK-PAY-`)、下游预付/结算付款记录直接置 `paid`。真实流程应由 A103-2/A103-4 定时回写驱动,待回写任务建设时移除。 +2. **正式付款路径切换**:按方案文档正式付款应走 A103-1 `ap_finapbill/push`(应付下推,分录需 `awrp_finapbill_entryid` 关联应付行),当前与预付款共用 A103-3。 +3. **银行编码映射**:e_bebank_number 暂传银行名称,待建立银行档案编码映射。 +4. **单据编号 number**(FKSQ-xxxx)暂未落库,如需展示可经 A103-2 查询或后续增加字段。 + +--- + +## 10. 涉及代码位置 + +| 内容 | 位置 | +| --- | --- | +| 同步入口/字段映射 | `blade-service/blade-transport/.../service/impl/PaymentApplicationServiceImpl.java`(syncKingdee / syncKingdeeBatch / pushBfiPayApply) | +| Controller | `blade-service/blade-transport/.../controller/PaymentApplicationController.java`(/sync-kingdee、/sync-kingdee-batch) | +| BFI 客户端 | `blade-third-party-api/blade-bfi-api/.../feign/IBfiClient.java`(PRE_PAY_APPLY_SAVE_URL) | +| BFI 服务封装 | `blade-third-party-api/blade-bfi-api/.../service/impl/BfiServiceImpl.java`(savePrePayApply / commonCall / getToken) | +| 请求/返回模型 | `blade-bfi-api/.../pojo/dto/BfiPayApplySaveDTO.java`、`BfiPayApplySaveEntryDTO.java`;`pojo/vo/BfiBillSaveResultVO.java`、`BfiBillSaveResultItemVO.java` | +| 前端页面 | `tms-erp-web/src/views/payment/payment-application.vue`(handleSync);`src/api/payment/paymentApplication.js`(syncKingdeeBatch) | +| Nacos 配置模板 | `doc/nacos/blade-dev.yaml`、`blade-prod.yaml`、`third-party-api.yaml`(thirdParty.bfi.payApply.*) | diff --git a/doc/nacos/blade-dev.yaml b/doc/nacos/blade-dev.yaml index 4c8551ea..063e4cc1 100644 --- a/doc/nacos/blade-dev.yaml +++ b/doc/nacos/blade-dev.yaml @@ -47,7 +47,8 @@ blade: datasource: dev: # MySql - url: jdbc:mysql://localhost:3306/transport?useSSL=false&useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&transformedBitIsBoolean=true&tinyInt1isBit=false&allowMultiQueries=true&serverTimezone=GMT%2B8&allowPublicKeyRetrieval=true + # rewriteBatchedStatements=true:MyBatis-Plus saveBatch/updateBatchById 依赖该参数才会合并为多值语句,否则仍是逐条往返(仅 MySQL 支持,切换其他库需移除) + url: jdbc:mysql://localhost:3306/transport?useSSL=false&useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&transformedBitIsBoolean=true&tinyInt1isBit=false&allowMultiQueries=true&serverTimezone=GMT%2B8&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true username: root password: root # PostgreSQL @@ -137,6 +138,41 @@ thirdParty: # 微信小程序(手机号一键登录 / openid) appId: ${WECHAT_MINI_APP_ID:} appSecret: ${WECHAT_MINI_APP_SECRET:} + bfi: + # 业财融合(BFI)应用及接口配置,敏感值通过环境变量注入 + baseUrl: ${BFI_BASE_URL:http://172.16.203.134:8000/gwzh/BFI} + authorization: ${BFI_AUTHORIZATION:3F7e8yI6RV8S53mJ9tYa0672r3W4n279} + clientId: ${BFI_CLIENT_ID:WLTMS} + clientSecret: ${BFI_CLIENT_SECRET:XDWLwltmstest@HT2026} + username: ${BFI_USERNAME:XDWL-03024} + accountId: ${BFI_ACCOUNT_ID:2454399096727798784} + language: ${BFI_LANGUAGE:zh_CN} + # 数据来源系统编码(推送金蝶公共参数:客商、付款申请等统一使用),默认 WLTMS(运输TMS) + srcSys: ${BFI_SRC_SYS:WLTMS} + # 付款申请推送配置(A103-3 付款申请保存) + payApply: + # 单据类型编码:ap_payapply_BT_S 采购付款申请、ap_payapply_oth_BT_S 其他付款申请 + billtype: ${BFI_PAY_APPLY_BILLTYPE:ap_payapply_oth_BT_S} + # 组织编码、币种取自付款申请关联合同的所属组织与结算币种 + # 汇率表编码 + exratetable: ${BFI_PAY_APPLY_EXRATE_TABLE:01} + # 外部单据类型 + srcType: ${BFI_PAY_APPLY_SRC_TYPE:0KzXs} + # 金蝶付款结果定时回写(A103-2 付款申请单查询 / A103-4 付款处理查询) + payResult: + # 回写任务开关 + syncEnabled: ${BFI_PAY_RESULT_SYNC_ENABLED:true} + # 调度 cron,默认每10分钟 + cron: ${BFI_PAY_RESULT_CRON:0 */10 * * * ?} + # 收款认领推送金蝶定时任务(A301-3 收款认领结果推送) + receiptClaimPush: + # 推送任务开关,默认关闭(联调稳定后开启) + syncEnabled: ${BFI_RECEIPT_CLAIM_PUSH_SYNC_ENABLED:false} + # 调度 cron,默认每10分钟 + cron: ${BFI_RECEIPT_CLAIM_PUSH_CRON:0 */10 * * * ?} + # single-flight 分布式锁租约秒数,须大于最坏一轮耗时(逐认领人远程查公告+逐条推送), + # 进程异常退出时锁在租约到期后自动释放 + lockLeaseSeconds: ${BFI_RECEIPT_CLAIM_PUSH_LOCK_LEASE_SECONDS:1800} #百度OCR配置,API Key和Secret Key请通过环境变量注入 baidu: diff --git a/doc/nacos/blade-prod.yaml b/doc/nacos/blade-prod.yaml index ab3d3562..a444dde6 100644 --- a/doc/nacos/blade-prod.yaml +++ b/doc/nacos/blade-prod.yaml @@ -35,6 +35,8 @@ blade: #分布式锁配置 lock: ##是否启用分布式锁 + ##开启前必须先补 password(与 spring.data.redis 一致),否则 Redisson 无密码连接会被拒绝, + ##影响的是全部依赖该模板的服务;收票全票池等同步定时任务依赖它做多副本 single-flight enabled: false ##将docker脚本部署的redis服务映射为宿主机ip ##生产环境推荐使用阿里云高可用redis服务并设置密码 @@ -43,7 +45,7 @@ blade: #通用开发生产环境数据库地址(特殊情况可在对应的子工程里配置覆盖) datasource: prod: - url: jdbc:mysql://172.16.203.228:3306/transport?useSSL=false&useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&transformedBitIsBoolean=true&tinyInt1isBit=false&allowMultiQueries=true&serverTimezone=GMT%2B8&allowPublicKeyRetrieval=true + url: jdbc:mysql://172.16.203.228:3306/transport?useSSL=false&useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&transformedBitIsBoolean=true&tinyInt1isBit=false&allowMultiQueries=true&serverTimezone=GMT%2B8&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true username: transport password: ab3acJYSC8A4ySpk @@ -91,7 +93,6 @@ thirdParty: processExecuteUrl: ${MK_PROCESS_EXECUTE_URL:/openapi/sys-lbpm/sysLbpmProcess/openSupport/execute} processDeleteUrl: ${MK_PROCESS_DELETE_URL:/openapi/sys-lbpm/sysLbpmProcess/openSupport/delete} getCurrentNodesUrl: ${MK_CURRENT_NODES_URL:/openapi/sys-lbpm/sysLbpmProcess/openSupport/getCurNodesInfo} - getProcessInfoUrl: ${MK_PROCESS_INFO_URL:/openapi/sys-lbpm/sysLbpmProcess/openSupport/getProcessInfo} getNodeHandlersUrl: ${MK_NODE_HANDLERS_URL:/openapi/sys-lbpm/sysLbpmProcess/openSupport/getNodeHandlerInfos} getManualNodeUrl: ${MK_MANUAL_NODE_URL:/openapi/sys-lbpm/sysLbpmTemplate/openSupport/getManualNode} pushOrgDeptUrl: ${MK_PUSH_ORG_DEPT_URL:/openapi/sys-org/v2/push/orgDept} @@ -108,6 +109,41 @@ thirdParty: mini: appId: ${WECHAT_MINI_APP_ID:} appSecret: ${WECHAT_MINI_APP_SECRET:} + bfi: + # 业财融合(BFI)应用及接口配置,敏感值通过环境变量注入 + baseUrl: ${BFI_BASE_URL:http://172.16.203.134:8000/gwzh/BFI} + authorization: ${BFI_AUTHORIZATION:} + clientId: ${BFI_CLIENT_ID:} + clientSecret: ${BFI_CLIENT_SECRET:} + username: ${BFI_USERNAME:} + accountId: ${BFI_ACCOUNT_ID:} + language: ${BFI_LANGUAGE:zh_CN} + # 数据来源系统编码(推送金蝶公共参数:客商、付款申请等统一使用),默认 WLTMS(运输TMS) + srcSys: ${BFI_SRC_SYS:WLTMS} + # 付款申请推送配置(A103-3 付款申请保存) + payApply: + # 单据类型编码:ap_payapply_BT_S 采购付款申请、ap_payapply_oth_BT_S 其他付款申请 + billtype: ${BFI_PAY_APPLY_BILLTYPE:ap_payapply_oth_BT_S} + # 组织编码、币种取自付款申请关联合同的所属组织与结算币种 + # 汇率表编码 + exratetable: ${BFI_PAY_APPLY_EXRATE_TABLE:01} + # 外部单据类型 + srcType: ${BFI_PAY_APPLY_SRC_TYPE:0KzXs} + # 金蝶付款结果定时回写(A103-2 付款申请单查询 / A103-4 付款处理查询) + payResult: + # 回写任务开关 + syncEnabled: ${BFI_PAY_RESULT_SYNC_ENABLED:true} + # 调度 cron,默认每10分钟 + cron: ${BFI_PAY_RESULT_CRON:0 */10 * * * ?} + # 收款认领推送金蝶定时任务(A301-3 收款认领结果推送) + receiptClaimPush: + # 推送任务开关,默认关闭(联调稳定后开启) + syncEnabled: ${BFI_RECEIPT_CLAIM_PUSH_SYNC_ENABLED:false} + # 调度 cron,默认每10分钟 + cron: ${BFI_RECEIPT_CLAIM_PUSH_CRON:0 */10 * * * ?} + # single-flight 分布式锁租约秒数,须大于最坏一轮耗时(逐认领人远程查公告+逐条推送), + # 进程异常退出时锁在租约到期后自动释放 + lockLeaseSeconds: ${BFI_RECEIPT_CLAIM_PUSH_LOCK_LEASE_SECONDS:1800} #百度OCR配置,API Key和Secret Key请通过环境变量注入 baidu: @@ -120,6 +156,15 @@ baidu: request-timeout: 30s token-refresh-advance: 1m +# 对象存储(blade-file 分片上传 + blade-transport 凭证读写共用) +# endpoint:强烈建议内网 MinIO(http://IP:19000);若只能走网关可填 https://tms-prod.xinghy56.com/oss(代码会自动处理 /oss) +# transform-endpoint:浏览器预签名外网地址 +file: + storage: + minio: + # endpoint: http://内网MinIO地址:端口 + transform-endpoint: https://tms-prod.xinghy56.com/oss + powerjob: worker: server-address: 172.16.203.228:7700 diff --git a/doc/nacos/blade-test.yaml b/doc/nacos/blade-test.yaml index 2839eb5a..dbb91f12 100644 --- a/doc/nacos/blade-test.yaml +++ b/doc/nacos/blade-test.yaml @@ -33,14 +33,14 @@ spring: blade: #分布式锁配置 lock: - ##是否启用分布式锁 - enabled: false + ##是否启用分布式锁(收票全票池同步等定时任务依赖 RedisLockClient 做 single-flight,多副本部署必须开启) + enabled: true ##将docker脚本部署的redis服务映射为宿主机ip address: redis://192.168.0.188:3379 #通用开发生产环境数据库地址(特殊情况可在对应的子工程里配置覆盖) datasource: test: - url: jdbc:mysql://192.168.0.188:3306/bladex?useSSL=false&useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&transformedBitIsBoolean=true&tinyInt1isBit=false&allowMultiQueries=true&serverTimezone=GMT%2B8&allowPublicKeyRetrieval=true + url: jdbc:mysql://192.168.0.188:3306/bladex?useSSL=false&useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&transformedBitIsBoolean=true&tinyInt1isBit=false&allowMultiQueries=true&serverTimezone=GMT%2B8&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true username: root password: root diff --git a/doc/nacos/blade.yaml b/doc/nacos/blade.yaml index 2f80484c..56207ecb 100644 --- a/doc/nacos/blade.yaml +++ b/doc/nacos/blade.yaml @@ -228,6 +228,9 @@ blade: - /customer-archive/public/** - /blade-transport/mk-process/public/** - /mk-process/public/** + # 金蝶单据解锁回调(unLockNotifyBusiData):金蝶侧无令牌调用,免登免授权 + - /blade-transport/kingdee-bfi/** + - /kingdee-bfi/** - /blade-openapi/openApi/mk/process/commonCallback - /openApi/mk/process/commonCallback - /feign/client/businessProcess/getCurrentNodes @@ -254,6 +257,13 @@ blade: - method: POST pattern: /dashboard/submit expression: "hasAnyRole('administrator', 'admin', 'user')" + # 金蝶单据解锁回调(unLockNotifyBusiData):金蝶侧无令牌调用,免登免授权 + - method: POST + pattern: /blade-transport/kingdee-bfi/** + expression: "permitAll()" + - method: POST + pattern: /kingdee-bfi/** + expression: "permitAll()" #基础认证配置 basic: - method: ALL diff --git a/doc/nacos/third-party-api.yaml b/doc/nacos/third-party-api.yaml index 11e17b31..caa5f604 100644 --- a/doc/nacos/third-party-api.yaml +++ b/doc/nacos/third-party-api.yaml @@ -32,3 +32,115 @@ thirdParty: # 微信小程序 AppId / AppSecret(Nacos 配置,勿提交真实 secret) appId: ${WECHAT_MINI_APP_ID:} appSecret: ${WECHAT_MINI_APP_SECRET:} + bfi: + # 业财融合(BFI)开放接口地址 + baseUrl: ${BFI_BASE_URL:http://172.16.203.134:8000/gwzh/BFI} + # 网关认证请求头 Authorization 的值 + authorization: ${BFI_AUTHORIZATION:} + # 第三方应用系统编码,即 appId + clientId: ${BFI_CLIENT_ID:} + # 第三方应用AccessToken认证密钥,即 appSecret,敏感值通过环境变量注入 + clientSecret: ${BFI_CLIENT_SECRET:} + # 访问用户名 + username: ${BFI_USERNAME:} + # 数据中心ID + accountId: ${BFI_ACCOUNT_ID:} + # 语言字串,默认 zh_CN + language: ${BFI_LANGUAGE:zh_CN} + # 数据来源系统编码(推送金蝶公共参数:客商、付款申请等统一使用),默认 WLTMS(运输TMS) + srcSys: ${BFI_SRC_SYS:WLTMS} + # 应付/应收/收票单分录物料映射(A102-2/A302-2/A303-2 共用,未配置使用代码默认值): + # 运输类按项目业务模式映射——国内运输按运单运输方式映射、国际运输映射国际货代服务; + # 装卸大类及手动添加的非运费杂费映射装卸物料 + # 注意:Map 的中文 key 必须用 ["中文"] 方括号写法,否则 Spring Boot 绑定失败、服务启动报错 + material: + # 国内运输:运单运输方式 -> 金蝶物料编码(公路运输/铁路运输/水路运输/航空运输四种) + domestic: + "[公路运输]": ${BFI_MATERIAL_DOMESTIC_ROAD:XDWL11010001} + "[铁路运输]": ${BFI_MATERIAL_DOMESTIC_RAIL:XDWL11010002} + "[水路运输]": ${BFI_MATERIAL_DOMESTIC_WATER:XDWL11010003} + "[航空运输]": ${BFI_MATERIAL_DOMESTIC_AIR:Item-00001082} + # 国际运输业务模式物料编码(国际货运代理服务) + international: ${BFI_MATERIAL_INTERNATIONAL:Item-00001081} + # 装卸大类、手动添加非运费杂费物料编码(装卸) + loading: ${BFI_MATERIAL_LOADING:XDWL11002004} + # 付款申请推送配置(A103-3 付款申请保存) + payApply: + # 单据类型编码:ap_payapply_BT_S 采购付款申请、ap_payapply_oth_BT_S 其他付款申请 + billtype: ${BFI_PAY_APPLY_BILLTYPE:ap_payapply_oth_BT_S} + # 组织编码、币种取自付款申请关联合同的所属组织与结算币种 + # 汇率表编码 + exratetable: ${BFI_PAY_APPLY_EXRATE_TABLE:01} + # 外部单据类型 + srcType: ${BFI_PAY_APPLY_SRC_TYPE:0KzXs} + # 金蝶付款结果定时回写(A103-2 付款申请单查询 / A103-4 付款处理查询) + payResult: + # 回写任务开关 + syncEnabled: ${BFI_PAY_RESULT_SYNC_ENABLED:true} + # 调度 cron,默认每10分钟(:01 起步,错峰避让其他金蝶任务) + cron: ${BFI_PAY_RESULT_CRON:0 1/10 * * * ?} + # 金蝶收票单结果定时回写(A801-3 异常回收/指定补偿/单号补登) + receivedInvoiceResult: + # 回写任务开关,默认关闭(联调稳定后开启) + syncEnabled: ${BFI_RECEIVED_INVOICE_RESULT_SYNC_ENABLED:false} + # 调度 cron,默认每10分钟(:08 起步,错峰避让其他金蝶任务) + cron: ${BFI_RECEIVED_INVOICE_RESULT_CRON:0 8/10 * * * ?} + # 收款认领推送金蝶定时任务(A301-3 收款认领结果推送) + receiptClaimPush: + # 推送任务开关,默认关闭(联调稳定后开启) + syncEnabled: ${BFI_RECEIPT_CLAIM_PUSH_SYNC_ENABLED:false} + # 调度 cron,默认每10分钟(:04 起步,错峰避让其他金蝶任务) + cron: ${BFI_RECEIPT_CLAIM_PUSH_CRON:0 4/10 * * * ?} + # single-flight 分布式锁租约秒数,须大于最坏一轮耗时(逐认领人远程查公告+逐条推送), + # 进程异常退出时锁在租约到期后自动释放 + lockLeaseSeconds: ${BFI_RECEIPT_CLAIM_PUSH_LOCK_LEASE_SECONDS:1800} + # 金蝶单据锁定定时补偿 + billLock: + # 锁定/解锁总开关(推送后锁定+解锁回调+补偿重锁共用),默认开启保持既有行为 + enabled: ${BFI_BILL_LOCK_ENABLED:true} + # 锁定补偿开关,默认关闭(安全兜底) + retryEnabled: ${BFI_BILL_LOCK_RETRY_ENABLED:false} + # 补偿调度 cron,默认每5分钟(:02 起步,错峰避让其他金蝶任务) + retryCron: ${BFI_BILL_LOCK_RETRY_CRON:0 2/5 * * * ?} + # 收款认领公告查询配置(A301-2) + claimAnnounce: + # 查询时间窗口(月数) + months: ${BFI_CLAIM_ANNOUNCE_MONTHS:6} + # 认领人:配置后公告查询固定使用该值,不再按当前登录用户姓名查询 + claimusers: ${BFI_CLAIM_ANNOUNCE_CLAIMUSERS:} + # 金蝶全票池发票定时同步(A801-1 窗口拉取+镜像 upsert,选票统一查本地) + invoiceReceipt: + # 同步任务开关,默认关闭(上线先手动 POST /invoice-receipt/sync-invoice-pool 跑一次全窗口铺底) + syncEnabled: ${BFI_INVOICE_RECEIPT_SYNC_ENABLED:false} + # 调度 cron,默认每30分钟(:16 起步,错峰避让其他金蝶任务) + cron: ${BFI_INVOICE_RECEIPT_CRON:0 16/30 * * * ?} + # 增量窗口天数(按票池更新时间滚动) + incrementDays: ${BFI_INVOICE_RECEIPT_INCREMENT_DAYS:30} + # 同步发票种类白名单(逗号分隔票易通编码,如 26,27 数电普票/专票),为空不限制 + invoiceTypes: ${BFI_INVOICE_RECEIPT_INVOICE_TYPES:26,27} + # 补偿绑定单轮限量(未绑定登记按发票号码精确补查条数上限) + compensateLimit: ${BFI_INVOICE_RECEIPT_COMPENSATE_LIMIT:200} + # single-flight 分布式锁租约秒数,须大于最坏一轮耗时;进程异常退出时锁在租约到期后自动释放 + lockLeaseSeconds: ${BFI_INVOICE_RECEIPT_LOCK_LEASE_SECONDS:1800} + # 审批通过自动同步金蝶(事件触发:结算单A102-2/A302-2、付款申请A103-1/A103-3、开票申请A303-1、收票A303-2+A302-6) + autoSync: + # 结算单审批后自动推送开关 + settlement: ${BFI_AUTO_SYNC_SETTLEMENT:false} + # 付款申请审批后自动推送开关 + payment: ${BFI_AUTO_SYNC_PAYMENT:false} + # 开票申请审批后自动推送开关 + invoiceApply: ${BFI_AUTO_SYNC_INVOICE_APPLY:false} + # 收票登记审批后自动推送开关 + receipt: ${BFI_AUTO_SYNC_RECEIPT:false} + # failed 单据定时重推开关(仅重推生成失败,不扫 unsynced 存量) + retryEnabled: ${BFI_AUTO_SYNC_RETRY_ENABLED:false} + # 重推调度 cron,默认每10分钟(:05 起步,错峰避让其他金蝶任务) + retryCron: ${BFI_AUTO_SYNC_RETRY_CRON:0 5/10 * * * ?} + # 客商金蝶数据定时同步(A902-1 复查 / A902-2 推送) + customer: + # 同步任务开关,默认关闭 + syncEnabled: ${BFI_CUSTOMER_SYNC_ENABLED:false} + # 同步调度 cron,默认每天 02:00 执行 + cron: ${BFI_CUSTOMER_SYNC_CRON:0 0 2 * * ?} + # 同步 single-flight 锁租约秒数,须大于最坏一轮耗时(复查 + 批量推送 + 停用联动) + lockLeaseSeconds: ${BFI_CUSTOMER_SYNC_LOCK_LEASE_SECONDS:1800} diff --git a/doc/sql/bladex/bladex.mysql.all.create.sql b/doc/sql/bladex/bladex.mysql.all.create.sql index 8a7cb64e..67ea9959 100644 --- a/doc/sql/bladex/bladex.mysql.all.create.sql +++ b/doc/sql/bladex/bladex.mysql.all.create.sql @@ -1261,11 +1261,11 @@ CREATE TABLE `blade_user` ( `real_name` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '真名', `avatar` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '头像', `email` varchar(45) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '邮箱', - `phone` varchar(45) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '手机', + `phone` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '手机', `birthday` datetime NULL DEFAULT NULL COMMENT '生日', `sex` int NULL DEFAULT NULL COMMENT '性别', `role_id` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '角色id', - `dept_id` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '部门id', + `dept_id` varchar(2000) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '部门id', `post_id` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '岗位id', `leader_id` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '主管id', `is_leader` int NULL DEFAULT 0 COMMENT '是否主管', diff --git a/doc/sql/transport/blade_customer_archive_kingdee_20260917.sql b/doc/sql/transport/blade_customer_archive_kingdee_20260917.sql new file mode 100644 index 00000000..2f9c3a36 --- /dev/null +++ b/doc/sql/transport/blade_customer_archive_kingdee_20260917.sql @@ -0,0 +1,24 @@ +-- 客商档案:金蝶(星瀚)协同字段 +-- kingdee_status:none未推送 / exist金蝶已有档案(A902-1查到已审核,跳过推送) / synced已推送成功 / failed推送失败或金蝶审核中 +-- 金蝶客户编码与 TMS 客商编号(customer_code)一致(A902-2 number 传入客商编号),无需单独存储 + +ALTER TABLE `blade_customer_archive` + ADD COLUMN `kingdee_status` varchar(20) NOT NULL DEFAULT 'none' COMMENT '金蝶协同状态:none未推送/exist金蝶已有/synced已推送/failed推送失败' AFTER `approved_time`, + ADD COLUMN `kingdee_fail_reason` varchar(500) NULL DEFAULT NULL COMMENT '金蝶推送失败原因' AFTER `kingdee_status`, + ADD COLUMN `kingdee_sync_time` datetime NULL DEFAULT NULL COMMENT '金蝶推送时间' AFTER `kingdee_fail_reason`; + +CREATE INDEX idx_kingdee_status ON blade_customer_archive (kingdee_status); + +-- 列表「同步金蝶」按钮权限(审批通过且未推送/推送失败时可点;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 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 +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_invoice_application_invoice_20260920.sql b/doc/sql/transport/blade_invoice_application_invoice_20260920.sql new file mode 100644 index 00000000..12e77271 --- /dev/null +++ b/doc/sql/transport/blade_invoice_application_invoice_20260920.sql @@ -0,0 +1,22 @@ +-- 开票申请金蝶发票结果表(A303-5 定时回写落库,发票号唯一幂等) + +CREATE TABLE IF NOT EXISTS `blade_invoice_application_invoice` ( + `id` bigint(20) NOT NULL COMMENT '主键', + `tenant_id` varchar(12) NOT NULL DEFAULT '000000' COMMENT '租户ID', + `create_user` bigint(20) DEFAULT NULL COMMENT '创建人', + `create_dept` bigint(20) DEFAULT NULL COMMENT '创建部门', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_user` bigint(20) DEFAULT NULL COMMENT '修改人', + `update_time` datetime DEFAULT NULL COMMENT '修改时间', + `status` int(11) DEFAULT '1' COMMENT '状态', + `is_deleted` int(11) DEFAULT '0' COMMENT '是否删除', + `invoice_application_id` bigint(20) NOT NULL COMMENT '开票申请ID', + `serial_no` varchar(64) DEFAULT NULL COMMENT '金蝶开票申请单编号(A303-5 serialNo)', + `invoice_no` varchar(64) NOT NULL COMMENT '发票号码', + `invoice_date` datetime DEFAULT NULL COMMENT '开票日期', + `invoice_amount` decimal(18,2) DEFAULT NULL COMMENT '价税合计', + `raw_json` text COMMENT 'A303-5返回原文JSON留档', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_application_invoice` (`tenant_id`,`invoice_application_id`,`invoice_no`), + KEY `idx_iai_application_id` (`invoice_application_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='开票申请金蝶发票结果'; diff --git a/doc/sql/transport/blade_invoice_application_invoice_urls_20260930.sql b/doc/sql/transport/blade_invoice_application_invoice_urls_20260930.sql new file mode 100644 index 00000000..849813f8 --- /dev/null +++ b/doc/sql/transport/blade_invoice_application_invoice_urls_20260930.sql @@ -0,0 +1,65 @@ +-- ============================================================ +-- blade_invoice_application_invoice_urls_20260930.sql +-- ============================================================ +-- 开票申请金蝶发票结果表补充发票文件下载地址(A303-5 返回,正式结算单详情查看/下载发票): +-- invoice_file_url 板式文件下载地址(数电票为OFD,航信百望税盘普通电票为PDF,电子专票/UKey电票为OFD) +-- invoice_image_url PDF图片预览地址 +-- invoice_pdf_file_url 数电PDF下载地址(发票云5.0.025版本返回) +-- invoice_xml_file_url 数电XML下载地址(发票云5.0.025版本返回) +-- 以下结构升级支持重复执行 + +-- ① blade_invoice_application_invoice.invoice_file_url +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_invoice_application_invoice` ADD COLUMN `invoice_file_url` varchar(1024) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''板式文件下载地址(数电票为OFD,普通电票为PDF,电子专票为OFD)'' AFTER `invoice_amount`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_invoice_application_invoice' + AND COLUMN_NAME = 'invoice_file_url' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- ② blade_invoice_application_invoice.invoice_image_url +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_invoice_application_invoice` ADD COLUMN `invoice_image_url` varchar(1024) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''PDF图片预览地址'' AFTER `invoice_file_url`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_invoice_application_invoice' + AND COLUMN_NAME = 'invoice_image_url' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- ③ blade_invoice_application_invoice.invoice_pdf_file_url +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_invoice_application_invoice` ADD COLUMN `invoice_pdf_file_url` varchar(1024) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''数电PDF下载地址(发票云5.0.025版本返回)'' AFTER `invoice_image_url`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_invoice_application_invoice' + AND COLUMN_NAME = 'invoice_pdf_file_url' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- ④ blade_invoice_application_invoice.invoice_xml_file_url +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_invoice_application_invoice` ADD COLUMN `invoice_xml_file_url` varchar(1024) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''数电XML下载地址(发票云5.0.025版本返回)'' AFTER `invoice_pdf_file_url`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_invoice_application_invoice' + AND COLUMN_NAME = 'invoice_xml_file_url' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; diff --git a/doc/sql/transport/blade_invoice_application_line_tax_code_20260921.sql b/doc/sql/transport/blade_invoice_application_line_tax_code_20260921.sql new file mode 100644 index 00000000..dd06c8f5 --- /dev/null +++ b/doc/sql/transport/blade_invoice_application_line_tax_code_20260921.sql @@ -0,0 +1,21 @@ +-- 应收结算与开票申请同步金蝶(A303-1 开票申请推送) +-- 开票申请商品行新增字段: +-- tax_classification_code 税收分类编码(开票项目主数据 blade_invoice_item.tax_classification_code, +-- 新增/编辑开票申请选择开票内容时快照落库,A303-1 分录 taxratecodeid.number 取数来源) +-- 销方名称/销方税号不走 Nacos 配置与表结构:销方名称取开票申请开票单位快照(issuer_name,结算单收款方带出), +-- 销方税号按开票单位查客商档案统一社会信用代码(unified_credit_code) +-- 开票申请基础建表脚本 blade_invoice_application_20260821.sql 保持不动,本脚本支持重复执行 + +-- 以下结构升级支持重复执行 +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_invoice_application_line` ADD COLUMN `tax_classification_code` varchar(30) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''税收分类编码(开票内容快照)'' AFTER `goods_name`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_invoice_application_line' + AND COLUMN_NAME = 'tax_classification_code' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; diff --git a/doc/sql/transport/blade_invoice_application_sheet_kingdee_bill_no_20260924.sql b/doc/sql/transport/blade_invoice_application_sheet_kingdee_bill_no_20260924.sql new file mode 100644 index 00000000..ec7bf6f3 --- /dev/null +++ b/doc/sql/transport/blade_invoice_application_sheet_kingdee_bill_no_20260924.sql @@ -0,0 +1,50 @@ +-- ============================================================ +-- blade_invoice_application_sheet_kingdee_bill_no_20260924.sql +-- ============================================================ +-- 开票申请同步金蝶按发票张次拆分开票(A303-1 一张发票张次一张开票申请单) +-- blade_invoice_application_sheet.kingdee_bill_no 本张发票对应的金蝶开票申请单编号(A303-1 返回,A303-5 serialNo 查询入口) +-- blade_invoice_application.kingdee_bill_no 多张发票时逗号拼接全部金蝶单号,列加宽至 varchar(500) 防溢出 +-- blade_invoice_application_record.kingdee_bill_no 操作记录的同一字段同步加宽,避免多张单号拼接超长导致插入失败 +-- 以下结构升级支持重复执行 + +-- ① blade_invoice_application_sheet.kingdee_bill_no +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_invoice_application_sheet` ADD COLUMN `kingdee_bill_no` varchar(100) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''金蝶开票申请单编号(本张发票对应)'' AFTER `invoice_amount`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_invoice_application_sheet' + AND COLUMN_NAME = 'kingdee_bill_no' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- ② blade_invoice_application.kingdee_bill_no 加宽 +SET @ddl_sql = ( + SELECT IF(CHARACTER_MAXIMUM_LENGTH < 500, + 'ALTER TABLE `blade_invoice_application` MODIFY COLUMN `kingdee_bill_no` varchar(500) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''金蝶开票申请单编号(多张发票逗号拼接)''', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_invoice_application' + AND COLUMN_NAME = 'kingdee_bill_no' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- ③ blade_invoice_application_record.kingdee_bill_no 加宽 +SET @ddl_sql = ( + SELECT IF(CHARACTER_MAXIMUM_LENGTH < 500, + 'ALTER TABLE `blade_invoice_application_record` MODIFY COLUMN `kingdee_bill_no` varchar(500) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''金蝶开票申请单编号(多张发票逗号拼接)''', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_invoice_application_record' + AND COLUMN_NAME = 'kingdee_bill_no' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; \ No newline at end of file diff --git a/doc/sql/transport/blade_invoice_receipt_drop_kingdee_sync_fields_20260923.sql b/doc/sql/transport/blade_invoice_receipt_drop_kingdee_sync_fields_20260923.sql new file mode 100644 index 00000000..2665393a --- /dev/null +++ b/doc/sql/transport/blade_invoice_receipt_drop_kingdee_sync_fields_20260923.sql @@ -0,0 +1,33 @@ +-- 收票管理金蝶功能精简:删除 blade_invoice_receipt 冗余同步字段 +-- kingdee_bill_no 金蝶单据号(冗余,等于票池镜像 serialNo,可经 kingdee_invoice_pool_id 关联查询) +-- kingdee_status 金蝶同步状态(冗余,手动同步功能已下线,推送状态 kingdee_push_status 为唯一状态源) +-- 金蝶状态展示口径:kingdee_push_status 四态经 kingdeePushStatusName 展示(未推送/已保存收票单/已指定应付/推送失败) +-- 以下结构升级支持重复执行 + +-- ① blade_invoice_receipt.kingdee_bill_no +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; + +-- ② blade_invoice_receipt.kingdee_status(索引 idx_invoice_receipt_kingdee 随列自动删除) +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_invoice_receipt_kingdee_push_20260922.sql b/doc/sql/transport/blade_invoice_receipt_kingdee_push_20260922.sql new file mode 100644 index 00000000..fee3d8f5 --- /dev/null +++ b/doc/sql/transport/blade_invoice_receipt_kingdee_push_20260922.sql @@ -0,0 +1,94 @@ +-- 收票管理 图05 环节②③:推送金蝶收票单(A303-2 收票单批量保存 + A302-6 收票指定应付 + A801-3 定时回写) +-- 票据池镜像表 blade_kingdee_invoice_pool 新增: +-- invoice_type_code 票易通原始发票类型编码(A801-1 invoiceType,如 26/27,A303-2 invoicetypef7_number 取数) +-- items_json A801-1 发票明细行JSON(品名/数量/单价/金额/税率/税额/税收分类编码,A303-2 分录取数来源) +-- 收票登记表 blade_invoice_receipt 新增: +-- kingdee_received_invoice_no 金蝶收票单单据编号(A303-2 返回/幂等预检命中回填) +-- kingdee_push_status 收票单推送状态(unsynced 未推送 / pushed 已保存 / assigned 已指定应付 / failed 推送失败) +-- kingdee_push_msg 推送失败原因(金蝶侧删除/红冲/查验不通过等异常回收提示人工) +-- kingdee_push_time 最近一次推送/回写时间 +-- 以下结构升级支持重复执行 + +-- ① blade_kingdee_invoice_pool.invoice_type_code +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_invoice_pool` ADD COLUMN `invoice_type_code` varchar(8) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''票易通原始发票类型编码(A801-1 invoiceType,如26/27)'' AFTER `invoice_type`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_kingdee_invoice_pool' + AND COLUMN_NAME = 'invoice_type_code' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- ② blade_kingdee_invoice_pool.items_json +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_invoice_pool` ADD COLUMN `items_json` longtext COLLATE utf8mb4_general_ci COMMENT ''A801-1发票明细行JSON(A303-2分录取数来源)'' AFTER `attachments_json`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_kingdee_invoice_pool' + AND COLUMN_NAME = 'items_json' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- ③ blade_invoice_receipt.kingdee_received_invoice_no +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_invoice_receipt` ADD COLUMN `kingdee_received_invoice_no` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''金蝶收票单单据编号(A303-2生成)'' AFTER `kingdee_bill_no`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_invoice_receipt' + AND COLUMN_NAME = 'kingdee_received_invoice_no' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- ④ blade_invoice_receipt.kingdee_push_status +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_invoice_receipt` ADD COLUMN `kingdee_push_status` varchar(16) COLLATE utf8mb4_general_ci NOT NULL DEFAULT ''unsynced'' COMMENT ''收票单推送状态(unsynced未推送/pushed已保存/assigned已指定应付/failed推送失败)'' AFTER `kingdee_status`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_invoice_receipt' + AND COLUMN_NAME = 'kingdee_push_status' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- ⑤ blade_invoice_receipt.kingdee_push_msg +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_invoice_receipt` ADD COLUMN `kingdee_push_msg` varchar(500) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''收票单推送失败原因'' AFTER `kingdee_push_status`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_invoice_receipt' + AND COLUMN_NAME = 'kingdee_push_msg' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- ⑥ blade_invoice_receipt.kingdee_push_time +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_invoice_receipt` ADD COLUMN `kingdee_push_time` datetime DEFAULT NULL COMMENT ''最近一次收票单推送/回写时间'' AFTER `kingdee_push_msg`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_invoice_receipt' + AND COLUMN_NAME = 'kingdee_push_time' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; diff --git a/doc/sql/transport/blade_kingdee_attachment_sync_20260924.sql b/doc/sql/transport/blade_kingdee_attachment_sync_20260924.sql new file mode 100644 index 00000000..aa6a3e58 --- /dev/null +++ b/doc/sql/transport/blade_kingdee_attachment_sync_20260924.sql @@ -0,0 +1,75 @@ +-- ① blade_kingdee_attachment_sync 建表【修复索引超长问题】 +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'CREATE TABLE `blade_kingdee_attachment_sync` ( + `id` bigint NOT NULL COMMENT ''主键'', + `tenant_id` varchar(12) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''租户ID'', + `biz_type` varchar(32) COLLATE utf8mb4_general_ci NOT NULL COMMENT ''业务单据类型(pay_application/formal_settlement_ap/formal_settlement_ar/invoice_receipt/invoice_application)'', + `biz_id` bigint NOT NULL COMMENT ''业务单据id'', + `attachment_key` varchar(768) COLLATE utf8mb4_general_ci NOT NULL COMMENT ''附件幂等键(用户附件=附件url;发票文件=A801-2的path)'', + `attachment_name` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''附件名称(展示/排查用)'', + `kingdee_path` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''金蝶侧文件路径(A902 返回 data.path)'', + `sync_status` tinyint NOT NULL DEFAULT 0 COMMENT ''同步状态:1 成功 / 0 失败'', + `fail_reason` varchar(500) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''失败原因(超限/下载失败/金蝶报错)'', + `sync_time` datetime 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 NULL COMMENT ''业务状态'', + `is_deleted` int DEFAULT 0 COMMENT ''是否已删除'', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_biz_attachment` (`biz_type`,`biz_id`,`attachment_key`(256)), + KEY `idx_biz_type_biz_id` (`biz_type`,`biz_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT=''金蝶附件同步表(A902)''', + 'SELECT 1') + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_kingdee_attachment_sync' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- ② blade_formal_settlement.kingdee_bill_id +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_formal_settlement` ADD COLUMN `kingdee_bill_id` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''金蝶单据id(星瀚内部主键,A902 附件上传定位用)'' AFTER `kingdee_bill_no`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_formal_settlement' + AND COLUMN_NAME = 'kingdee_bill_id' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- ③ blade_invoice_receipt.kingdee_received_invoice_id +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_invoice_receipt` ADD COLUMN `kingdee_received_invoice_id` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''金蝶收票单id(星瀚内部主键,A902 附件上传定位用)'' AFTER `kingdee_received_invoice_no`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_invoice_receipt' + AND COLUMN_NAME = 'kingdee_received_invoice_id' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- ④ blade_invoice_application.kingdee_bill_id +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_invoice_application` ADD COLUMN `kingdee_bill_id` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''金蝶开票申请单id(星瀚内部主键,A902 附件上传定位用)'' AFTER `kingdee_bill_no`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_invoice_application' + AND COLUMN_NAME = 'kingdee_bill_id' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; diff --git a/doc/sql/transport/blade_kingdee_bill_lock_20261009.sql b/doc/sql/transport/blade_kingdee_bill_lock_20261009.sql new file mode 100644 index 00000000..98c6ac33 --- /dev/null +++ b/doc/sql/transport/blade_kingdee_bill_lock_20261009.sql @@ -0,0 +1,32 @@ +-- 金蝶单据锁定记录表:lockBusiData 锁定 / unLockNotifyBusiData 解锁回调 / 锁定补偿 +-- 一行 = 一张金蝶目标单:锁定留痕(人/时间/失败原因)+ 当前锁状态(编辑守卫/回调定位/补偿扫表) +-- lock_status:failed-锁定失败(补偿重试对象)/ locked-金蝶锁定中 / unlocked-已解锁(终态) + +CREATE TABLE `blade_kingdee_bill_lock` ( + `id` bigint NOT NULL COMMENT '主键', + `tenant_id` varchar(12) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '000000' COMMENT '租户ID', + `source_bill_type` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'WLTMS单据类型:SETTLEMENT正式结算单/INVOICE_APPLY开票申请单(预留PAY_APPLY付款申请单/RECEIVED_INVOICE收票单)', + `source_bill_id` bigint NOT NULL COMMENT 'WLTMS业务主键', + `source_bill_no` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'WLTMS单号', + `target_bill_type` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '金蝶单据类型:ApFin_other_BT_S应付单/arfin_other_BT_S应收单/cdm_paybill_bank_elect_BT_S开票单等固定值', + `target_bill_no` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '金蝶单据编号(开票申请按发票张次逐张一行)', + `lock_status` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '锁状态:failed锁定失败/locked金蝶锁定中/unlocked已解锁', + `operation_type` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '最近动作来源操作类型:SAVE单据新增/DELETE单据删除', + `try_count` int DEFAULT '0' COMMENT '锁定重试次数(补偿任务用,超上限停试)', + `lock_time` datetime DEFAULT NULL COMMENT '锁定时间', + `unlock_time` datetime DEFAULT NULL COMMENT '解锁时间', + `lock_operator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '推单发起人(锁定操作人)', + `notify_operator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '回调操作人(金蝶侧操作人)', + `fail_reason` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci 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 NULL COMMENT '状态', + `is_deleted` int DEFAULT '0' COMMENT '是否已删除', + PRIMARY KEY (`id`), + INDEX `idx_kbl_source` (`source_bill_type`, `source_bill_id`), + INDEX `idx_kbl_target` (`target_bill_type`, `target_bill_no`), + INDEX `idx_kbl_status` (`lock_status`, `try_count`) +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '金蝶单据锁定记录' ROW_FORMAT = DYNAMIC; diff --git a/doc/sql/transport/blade_kingdee_invoice_pool_a8011_20260920.sql b/doc/sql/transport/blade_kingdee_invoice_pool_a8011_20260920.sql new file mode 100644 index 00000000..d499ee03 --- /dev/null +++ b/doc/sql/transport/blade_kingdee_invoice_pool_a8011_20260920.sql @@ -0,0 +1,61 @@ +-- 收票管理:金蝶全票池发票同步(A801-1 查询 + 批量同步 + 定时绑定关联) +-- 票据池镜像表新增金蝶票池字段: +-- serial_no 发票流水号(票池唯一标识,A801-1 serialNo,同步回填 kingdee_bill_no) +-- buyer_tax_no 购方税号(A801-1 buyerTaxNo) +-- saler_tax_no 销方税号(A801-1 salerTaxNo) +-- check_status 查验状态(A801-1 checkStatus,0 未查验 / 1 通过 / 2 不通过) +-- 由手动批量同步与定时绑定任务按 tenant_id + invoice_no upsert 回填; +-- 发票号码唯一索引 uk_kingdee_invoice_pool_no(tenant_id, invoice_no)建表时已存在,无需新增 + +-- 以下结构升级支持重复执行 +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_invoice_pool` ADD COLUMN `serial_no` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''金蝶发票流水号(票池唯一标识)'' AFTER `invoice_no`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_kingdee_invoice_pool' + AND COLUMN_NAME = 'serial_no' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_invoice_pool` ADD COLUMN `buyer_tax_no` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''购方税号'' AFTER `receiver_name`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_kingdee_invoice_pool' + AND COLUMN_NAME = 'buyer_tax_no' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_invoice_pool` ADD COLUMN `saler_tax_no` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''销方税号'' AFTER `issuer_name`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_kingdee_invoice_pool' + AND COLUMN_NAME = 'saler_tax_no' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_invoice_pool` ADD COLUMN `check_status` varchar(8) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''查验状态(0未查验/1通过/2不通过)'' AFTER `kingdee_status`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_kingdee_invoice_pool' + AND COLUMN_NAME = 'check_status' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; diff --git a/doc/sql/transport/blade_kingdee_invoice_pool_available_20261005.sql b/doc/sql/transport/blade_kingdee_invoice_pool_available_20261005.sql new file mode 100644 index 00000000..98a36137 --- /dev/null +++ b/doc/sql/transport/blade_kingdee_invoice_pool_available_20261005.sql @@ -0,0 +1,46 @@ +-- 金蝶票池选票回切镜像读:镜像表新增可用过滤列 + 认领过滤索引 +-- available_amount 剩余可用金额(A801-1 availableAmount,金蝶可用税额口径,选票本地过滤 >0) +-- amount_use_status 金额使用状态(A801-1 amountUseStatus,'0' 未使用可选 / '2' 全部使用) +-- 由定时同步(InvoiceReceiptKingdeeSyncTask)/ 手动同步端点 / 票号回源 / 收票保存重查 统一按 tenant_id + invoice_no upsert 回填; +-- 存量存根行两列为 NULL,不参与选票(可用过滤为 NULL 排除),随增量窗口同步自然回填 +-- 认领过滤 EXISTS 子查询补 (tenant_id, invoice_no) 索引,避免统一查询全表扫 + +-- 以下结构升级支持重复执行 +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_invoice_pool` ADD COLUMN `available_amount` decimal(18,2) DEFAULT NULL COMMENT ''剩余可用金额(A801-1 availableAmount,金蝶可用税额口径)'' AFTER `source_updated_time`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_kingdee_invoice_pool' + AND COLUMN_NAME = 'available_amount' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_invoice_pool` ADD COLUMN `amount_use_status` varchar(8) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''金额使用状态(0 未使用 / 2 全部使用)'' AFTER `available_amount`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_kingdee_invoice_pool' + AND COLUMN_NAME = 'amount_use_status' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +SET @idx_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_formal_settlement_invoice` ADD INDEX `idx_settlement_invoice_no` (`tenant_id`, `invoice_no`)', + 'SELECT 1') + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_formal_settlement_invoice' + AND INDEX_NAME = 'idx_settlement_invoice_no' +); +PREPARE idx_stmt FROM @idx_sql; +EXECUTE idx_stmt; +DEALLOCATE PREPARE idx_stmt; diff --git a/doc/sql/transport/blade_kingdee_invoice_pool_prune_20260922.sql b/doc/sql/transport/blade_kingdee_invoice_pool_prune_20260922.sql new file mode 100644 index 00000000..45a3d772 --- /dev/null +++ b/doc/sql/transport/blade_kingdee_invoice_pool_prune_20260922.sql @@ -0,0 +1,17 @@ +-- 金蝶票池镜像降级为登记存根(2026-09-22 票池改回实时查询收尾) +-- 背景:选票链路已改为实时查询 A801-1,blade_kingdee_invoice_pool 仅保留被收票登记引用的镜像行(登记存根), +-- 历史全量同步落库的未被引用镜像行属于退役数据,予以清理。 +-- 幂等:可重复执行,第二次影响 0 行。 +-- 注意:执行前必须先向用户报盘点数字并获确认(dev 库清理口径:事务 + 回滚 SQL + mysqldump 文本兜底)。 + +-- 执行前先跑盘点(只读): +-- SELECT COUNT(*) AS prune_rows FROM blade_kingdee_invoice_pool p +-- LEFT JOIN blade_invoice_receipt r ON r.kingdee_invoice_pool_id = p.id AND r.is_deleted = 0 +-- WHERE r.id IS NULL; +-- SELECT COUNT(*) AS keep_rows FROM blade_kingdee_invoice_pool p +-- INNER JOIN blade_invoice_receipt r ON r.kingdee_invoice_pool_id = p.id AND r.is_deleted = 0; + +-- 票池镜像降级为登记存根:删除未被任何未删收票登记引用的镜像行(实时查询模式上线前执行) +-- DELETE p FROM blade_kingdee_invoice_pool p +-- LEFT JOIN blade_invoice_receipt r ON r.kingdee_invoice_pool_id = p.id AND r.is_deleted = 0 +-- WHERE r.id IS NULL; diff --git a/doc/sql/transport/blade_payment_application_kingdee_bill_id_20260917.sql b/doc/sql/transport/blade_payment_application_kingdee_bill_id_20260917.sql new file mode 100644 index 00000000..5b230785 --- /dev/null +++ b/doc/sql/transport/blade_payment_application_kingdee_bill_id_20260917.sql @@ -0,0 +1,17 @@ +-- 付款管理:金蝶付款结果定时回写(A103-2 付款申请单查询 / A103-4 付款处理查询) +-- 新增 kingdee_bill_id:保存 A103-1/A103-3 推送返回的星瀚单据内部主键(A902 附件上传等使用); +-- kingdee_bill_no 字段语义调整为保存金蝶单据编号(billno,如 FKSQ-xxxx),由定时回写任务回填 + +-- 以下结构升级支持重复执行 +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_payment_application` ADD COLUMN `kingdee_bill_id` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''金蝶单据id(星瀚内部主键)'' AFTER `kingdee_bill_no`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_payment_application' + AND COLUMN_NAME = 'kingdee_bill_id' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; 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 new file mode 100644 index 00000000..2b96a193 --- /dev/null +++ b/doc/sql/transport/blade_project_apply_undertake_dept_multi_20261014.sql @@ -0,0 +1,64 @@ +-- 项目立项平台公司多选: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 b2208fbf..62a9ecd2 100644 --- a/doc/sql/transport/blade_project_contract_management.sql +++ b/doc/sql/transport/blade_project_contract_management.sql @@ -18,8 +18,8 @@ CREATE TABLE `blade_project_apply` ( `project_type` varchar(50) DEFAULT NULL COMMENT '项目类型', `business_dept_id` bigint(20) DEFAULT NULL COMMENT '业务部门ID', `business_dept_name` varchar(100) DEFAULT NULL COMMENT '业务部门', - `undertake_dept_id` bigint(20) DEFAULT NULL COMMENT '承办部门ID', - `undertake_dept_name` varchar(100) DEFAULT NULL COMMENT '承办部门', + `undertake_dept_id` varchar(500) DEFAULT NULL COMMENT '平台公司ID,多个逗号分隔', + `undertake_dept_name` varchar(500) DEFAULT NULL COMMENT '平台公司,多个顿号分隔', `project_source` varchar(50) DEFAULT NULL COMMENT '项目由来', `source_remark` varchar(500) DEFAULT NULL COMMENT '项目由来说明', `fund_limit` decimal(18,2) DEFAULT NULL COMMENT '项目资金使用额度(万元)', @@ -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='项目立项'; diff --git a/doc/sql/transport/blade_receipt_flow_claim_20260924.sql b/doc/sql/transport/blade_receipt_flow_claim_20260924.sql new file mode 100644 index 00000000..16779503 --- /dev/null +++ b/doc/sql/transport/blade_receipt_flow_claim_20260924.sql @@ -0,0 +1,103 @@ +-- 收款认领金蝶对接(A301-2 认领公告同步 / A301-3 认领结果推送 / A301-1 收款单回写) +-- 支持重复执行,兼容已执行 DDL 的场景 + +-- 1. 收款流水表新增金蝶认领公告字段 +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_receipt_flow` ADD COLUMN `bill_id` bigint(20) DEFAULT NULL COMMENT ''金蝶认领公告id(A301-2返回,A301-3推送必填)'' AFTER `source_updated_time`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_kingdee_receipt_flow' AND COLUMN_NAME = 'bill_id' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_receipt_flow` ADD COLUMN `payer_id` varchar(100) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''金蝶付款人id(A301-3推送用)'' AFTER `bill_id`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_kingdee_receipt_flow' AND COLUMN_NAME = 'payer_id' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_receipt_flow` ADD COLUMN `kingdee_claim_status` varchar(30) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''公告认领状态(A301-2 claimstatus:0未认领/1已认领)'' AFTER `payer_id`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_kingdee_receipt_flow' AND COLUMN_NAME = 'kingdee_claim_status' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_receipt_flow` ADD COLUMN `claim_me` tinyint(1) DEFAULT NULL COMMENT ''我已认领(A301-2 claimme)'' AFTER `kingdee_claim_status`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_kingdee_receipt_flow' AND COLUMN_NAME = 'claim_me' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_receipt_flow` ADD COLUMN `unclaimed_amount` decimal(18,2) NOT NULL DEFAULT ''0.00'' COMMENT ''未认领金额(A301-2 unclaimamount)'' AFTER `claim_me`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_kingdee_receipt_flow' AND COLUMN_NAME = 'unclaimed_amount' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_receipt_flow` ADD COLUMN `fee` decimal(18,2) DEFAULT NULL COMMENT ''手续费(A301-2 fee)'' AFTER `unclaimed_amount`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_kingdee_receipt_flow' AND COLUMN_NAME = 'fee' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_receipt_flow` ADD COLUMN `payee_name` varchar(200) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''收款人(A301-2 recpayee)'' AFTER `fee`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_kingdee_receipt_flow' AND COLUMN_NAME = 'payee_name' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- 2. 认领主单新增来源单据编码(A301-3 awrp_src_code,A301-1 回写匹配键) +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_receipt_claim` ADD COLUMN `awrp_src_code` varchar(100) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''金蝶认领结果推送来源单据编码(awrp_src_code)'' AFTER `kingdee_bill_status`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_receipt_claim' AND COLUMN_NAME = 'awrp_src_code' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_receipt_claim` ADD KEY `idx_receipt_claim_src_code` (`tenant_id`,`awrp_src_code`)', + 'SELECT 1') + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_receipt_claim' AND INDEX_NAME = 'idx_receipt_claim_src_code' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; \ No newline at end of file diff --git a/doc/sql/transport/blade_receipt_flow_push_20260928.sql b/doc/sql/transport/blade_receipt_flow_push_20260928.sql new file mode 100644 index 00000000..f4f4c542 --- /dev/null +++ b/doc/sql/transport/blade_receipt_flow_push_20260928.sql @@ -0,0 +1,136 @@ +-- 收款认领推送金蝶重构(认领本地化 / A301-3 定时+手动推送 / 按流水归集) +-- 支持重复执行,兼容 20260924 脚本已执行/未执行的环境,与该脚本无执行顺序要求 +-- 变更点: +-- 1. 收款流水表新增推送域字段(推送状态/失败原因/推送时间/推送编码/金蝶收款单号/回写状态) +-- 2. 认领主单移除 awrp_src_code 及其索引(推送编码上移至流水:分摊结算单号拼接) +-- 3. 收款流水表新增定时任务扫描索引与 A301-1 回写匹配索引 + +-- 1. 收款流水表:推送状态 +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_receipt_flow` ADD COLUMN `push_status` varchar(20) COLLATE utf8mb4_general_ci NOT NULL DEFAULT ''none'' COMMENT ''推送状态(none未推送/pushed已推送/failed推送失败)''', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_kingdee_receipt_flow' AND COLUMN_NAME = 'push_status' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- 2. 收款流水表:最近一次推送失败原因 +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_receipt_flow` ADD COLUMN `push_fail_reason` varchar(1000) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''最近一次推送失败原因(成功后清空)''', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_kingdee_receipt_flow' AND COLUMN_NAME = 'push_fail_reason' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- 3. 收款流水表:首次推送成功时间 +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_receipt_flow` ADD COLUMN `pushed_time` datetime DEFAULT NULL COMMENT ''首次推送金蝶成功时间''', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_kingdee_receipt_flow' AND COLUMN_NAME = 'pushed_time' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- 4. 收款流水表:推送来源单据编码(A301-3 awrp_src_code,分摊结算单号拼接,A301-1 回写匹配键) +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_receipt_flow` ADD COLUMN `awrp_src_code` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''金蝶推送来源单据编码(分摊结算单号下划线拼接,A301-1回写匹配键)''', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_kingdee_receipt_flow' AND COLUMN_NAME = 'awrp_src_code' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- 5. 收款流水表:金蝶收款单号(推送响应回填 / A301-1 回写推进) +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_receipt_flow` ADD COLUMN `kingdee_bill_no` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''金蝶收款单号''', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_kingdee_receipt_flow' AND COLUMN_NAME = 'kingdee_bill_no' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- 6. 收款流水表:金蝶回写状态(A301-1 回写生命周期) +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_receipt_flow` ADD COLUMN `kingdee_bill_status` varchar(20) COLLATE utf8mb4_general_ci NOT NULL DEFAULT ''none'' COMMENT ''金蝶回写状态(none未推送/pushed已推送/approved审核通过/received已收款/failed失败)''', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_kingdee_receipt_flow' AND COLUMN_NAME = 'kingdee_bill_status' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- 7. 认领主单:删除 awrp_src_code 索引(列上移至流水表;0924 未执行过则本就无此索引) +SET @ddl_sql = ( + SELECT IF(COUNT(*) > 0, + 'ALTER TABLE `blade_receipt_claim` DROP KEY `idx_receipt_claim_src_code`', + 'SELECT 1') + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_receipt_claim' AND INDEX_NAME = 'idx_receipt_claim_src_code' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- 8. 认领主单:删除 awrp_src_code 列(0924 未执行过则本就无此列) +SET @ddl_sql = ( + SELECT IF(COUNT(*) > 0, + 'ALTER TABLE `blade_receipt_claim` DROP COLUMN `awrp_src_code`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_receipt_claim' AND COLUMN_NAME = 'awrp_src_code' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- 9. 收款流水表:定时任务扫描索引(push_status IN (none,failed) AND claim_status='claimed' AND bill_id 非空) +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_receipt_flow` ADD KEY `idx_kingdee_receipt_flow_push_scan` (`tenant_id`,`push_status`,`claim_status`)', + 'SELECT 1') + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_kingdee_receipt_flow' AND INDEX_NAME = 'idx_kingdee_receipt_flow_push_scan' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- 10. 收款流水表:A301-1 回写匹配索引(sourcebillnumber = awrp_src_code) +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_receipt_flow` ADD KEY `idx_kingdee_receipt_flow_src_code` (`tenant_id`,`awrp_src_code`)', + 'SELECT 1') + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_kingdee_receipt_flow' AND INDEX_NAME = 'idx_kingdee_receipt_flow_src_code' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- 11. 按钮权限:收款流水页「推送金蝶」(父级菜单 2090000000000001260 收款流水,排序 4) +INSERT INTO `blade_menu` + (`id`,`parent_id`,`code`,`name`,`alias`,`path`,`source`,`sort`,`category`,`action`,`is_open`,`component`,`remark`,`is_deleted`) +VALUES + (2090000000000001264,2090000000000001260,'receipt_flow_push','推送金蝶','receipt_flow_push','','',4,2,0,1,NULL,'',0) +ON DUPLICATE KEY UPDATE + `name`=VALUES(`name`), + `alias`=VALUES(`alias`), + `is_deleted`=0; 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..6578be14 --- /dev/null +++ b/doc/sql/transport/blade_settlement_import_batch_20261015.sql @@ -0,0 +1,60 @@ +-- 导入结算单批次表 + 菜单 +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数组', + `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_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) +ON DUPLICATE KEY UPDATE `name`=VALUES(`name`),`path`=VALUES(`path`),`is_deleted`=0; 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_id_expand_20261014.sql b/doc/sql/transport/blade_user_dept_id_expand_20261014.sql new file mode 100644 index 00000000..63d78877 --- /dev/null +++ b/doc/sql/transport/blade_user_dept_id_expand_20261014.sql @@ -0,0 +1,9 @@ +-- 用户多部门聚合字段扩容,避免 OA/IAM 同步时 dept_id 写入超长导致 Data truncation +-- 部署前先执行本脚本,再发布后端 + +ALTER TABLE `blade_user` + MODIFY COLUMN `dept_id` varchar(2000) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '部门id'; + +-- 若库中已有 dept_codes 列,请同步执行以下语句(无该列时可跳过) +ALTER TABLE `blade_user` + MODIFY COLUMN `dept_codes` varchar(2000) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '部门编码'; diff --git a/doc/sql/transport/blade_user_phone_expand_20261014.sql b/doc/sql/transport/blade_user_phone_expand_20261014.sql new file mode 100644 index 00000000..a0d376d0 --- /dev/null +++ b/doc/sql/transport/blade_user_phone_expand_20261014.sql @@ -0,0 +1,5 @@ +-- 用户手机号字段扩容:SM2 加密后 Hex 远超 varchar(45),避免 IAM/OA 同步插入失败 +-- 部署前先执行本脚本,再发布后端 + +ALTER TABLE `blade_user` + MODIFY COLUMN `phone` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '手机'; diff --git a/doc/sql/transport/lqj/20261013/01_blade_payment_application_kingdee_bill_id_20260917.sql b/doc/sql/transport/lqj/20261013/01_blade_payment_application_kingdee_bill_id_20260917.sql new file mode 100644 index 00000000..5b230785 --- /dev/null +++ b/doc/sql/transport/lqj/20261013/01_blade_payment_application_kingdee_bill_id_20260917.sql @@ -0,0 +1,17 @@ +-- 付款管理:金蝶付款结果定时回写(A103-2 付款申请单查询 / A103-4 付款处理查询) +-- 新增 kingdee_bill_id:保存 A103-1/A103-3 推送返回的星瀚单据内部主键(A902 附件上传等使用); +-- kingdee_bill_no 字段语义调整为保存金蝶单据编号(billno,如 FKSQ-xxxx),由定时回写任务回填 + +-- 以下结构升级支持重复执行 +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_payment_application` ADD COLUMN `kingdee_bill_id` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''金蝶单据id(星瀚内部主键)'' AFTER `kingdee_bill_no`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_payment_application' + AND COLUMN_NAME = 'kingdee_bill_id' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; diff --git a/doc/sql/transport/lqj/20261013/02_blade_customer_archive_kingdee_20260917.sql b/doc/sql/transport/lqj/20261013/02_blade_customer_archive_kingdee_20260917.sql new file mode 100644 index 00000000..1850aa03 --- /dev/null +++ b/doc/sql/transport/lqj/20261013/02_blade_customer_archive_kingdee_20260917.sql @@ -0,0 +1,16 @@ +-- 客商档案:金蝶(星瀚)协同字段 +-- kingdee_status:none未推送 / exist金蝶已有档案(A902-1查到已审核,跳过推送) / synced已推送成功 / failed推送失败或金蝶审核中 +-- 金蝶客户编码与 TMS 客商编号(customer_code)一致(A902-2 number 传入客商编号),无需单独存储 + +ALTER TABLE `blade_customer_archive` + ADD COLUMN `kingdee_status` varchar(20) NOT NULL DEFAULT 'none' COMMENT '金蝶协同状态:none未推送/exist金蝶已有/synced已推送/failed推送失败' AFTER `approved_time`, + ADD COLUMN `kingdee_fail_reason` varchar(500) NULL DEFAULT NULL COMMENT '金蝶推送失败原因' AFTER `kingdee_status`, + ADD COLUMN `kingdee_sync_time` datetime NULL DEFAULT NULL COMMENT '金蝶推送时间' AFTER `kingdee_fail_reason`; + +CREATE INDEX idx_kingdee_status ON blade_customer_archive (kingdee_status); + +-- 列表「同步金蝶」按钮权限(审批通过后推送失败的重推入口;parent_id 通过菜单 code 关联,避免写死 id) +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 +FROM blade_menu m +WHERE m.code = 'customer_archive' AND m.is_deleted = 0; diff --git a/doc/sql/transport/lqj/20261013/03_blade_kingdee_invoice_pool_a8011_20260920.sql b/doc/sql/transport/lqj/20261013/03_blade_kingdee_invoice_pool_a8011_20260920.sql new file mode 100644 index 00000000..d499ee03 --- /dev/null +++ b/doc/sql/transport/lqj/20261013/03_blade_kingdee_invoice_pool_a8011_20260920.sql @@ -0,0 +1,61 @@ +-- 收票管理:金蝶全票池发票同步(A801-1 查询 + 批量同步 + 定时绑定关联) +-- 票据池镜像表新增金蝶票池字段: +-- serial_no 发票流水号(票池唯一标识,A801-1 serialNo,同步回填 kingdee_bill_no) +-- buyer_tax_no 购方税号(A801-1 buyerTaxNo) +-- saler_tax_no 销方税号(A801-1 salerTaxNo) +-- check_status 查验状态(A801-1 checkStatus,0 未查验 / 1 通过 / 2 不通过) +-- 由手动批量同步与定时绑定任务按 tenant_id + invoice_no upsert 回填; +-- 发票号码唯一索引 uk_kingdee_invoice_pool_no(tenant_id, invoice_no)建表时已存在,无需新增 + +-- 以下结构升级支持重复执行 +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_invoice_pool` ADD COLUMN `serial_no` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''金蝶发票流水号(票池唯一标识)'' AFTER `invoice_no`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_kingdee_invoice_pool' + AND COLUMN_NAME = 'serial_no' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_invoice_pool` ADD COLUMN `buyer_tax_no` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''购方税号'' AFTER `receiver_name`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_kingdee_invoice_pool' + AND COLUMN_NAME = 'buyer_tax_no' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_invoice_pool` ADD COLUMN `saler_tax_no` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''销方税号'' AFTER `issuer_name`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_kingdee_invoice_pool' + AND COLUMN_NAME = 'saler_tax_no' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_invoice_pool` ADD COLUMN `check_status` varchar(8) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''查验状态(0未查验/1通过/2不通过)'' AFTER `kingdee_status`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_kingdee_invoice_pool' + AND COLUMN_NAME = 'check_status' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; diff --git a/doc/sql/transport/lqj/20261013/04_blade_invoice_application_line_tax_code_20260921.sql b/doc/sql/transport/lqj/20261013/04_blade_invoice_application_line_tax_code_20260921.sql new file mode 100644 index 00000000..dd06c8f5 --- /dev/null +++ b/doc/sql/transport/lqj/20261013/04_blade_invoice_application_line_tax_code_20260921.sql @@ -0,0 +1,21 @@ +-- 应收结算与开票申请同步金蝶(A303-1 开票申请推送) +-- 开票申请商品行新增字段: +-- tax_classification_code 税收分类编码(开票项目主数据 blade_invoice_item.tax_classification_code, +-- 新增/编辑开票申请选择开票内容时快照落库,A303-1 分录 taxratecodeid.number 取数来源) +-- 销方名称/销方税号不走 Nacos 配置与表结构:销方名称取开票申请开票单位快照(issuer_name,结算单收款方带出), +-- 销方税号按开票单位查客商档案统一社会信用代码(unified_credit_code) +-- 开票申请基础建表脚本 blade_invoice_application_20260821.sql 保持不动,本脚本支持重复执行 + +-- 以下结构升级支持重复执行 +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_invoice_application_line` ADD COLUMN `tax_classification_code` varchar(30) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''税收分类编码(开票内容快照)'' AFTER `goods_name`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_invoice_application_line' + AND COLUMN_NAME = 'tax_classification_code' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; diff --git a/doc/sql/transport/lqj/20261013/05_blade_invoice_application_invoice_20260920.sql b/doc/sql/transport/lqj/20261013/05_blade_invoice_application_invoice_20260920.sql new file mode 100644 index 00000000..12e77271 --- /dev/null +++ b/doc/sql/transport/lqj/20261013/05_blade_invoice_application_invoice_20260920.sql @@ -0,0 +1,22 @@ +-- 开票申请金蝶发票结果表(A303-5 定时回写落库,发票号唯一幂等) + +CREATE TABLE IF NOT EXISTS `blade_invoice_application_invoice` ( + `id` bigint(20) NOT NULL COMMENT '主键', + `tenant_id` varchar(12) NOT NULL DEFAULT '000000' COMMENT '租户ID', + `create_user` bigint(20) DEFAULT NULL COMMENT '创建人', + `create_dept` bigint(20) DEFAULT NULL COMMENT '创建部门', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_user` bigint(20) DEFAULT NULL COMMENT '修改人', + `update_time` datetime DEFAULT NULL COMMENT '修改时间', + `status` int(11) DEFAULT '1' COMMENT '状态', + `is_deleted` int(11) DEFAULT '0' COMMENT '是否删除', + `invoice_application_id` bigint(20) NOT NULL COMMENT '开票申请ID', + `serial_no` varchar(64) DEFAULT NULL COMMENT '金蝶开票申请单编号(A303-5 serialNo)', + `invoice_no` varchar(64) NOT NULL COMMENT '发票号码', + `invoice_date` datetime DEFAULT NULL COMMENT '开票日期', + `invoice_amount` decimal(18,2) DEFAULT NULL COMMENT '价税合计', + `raw_json` text COMMENT 'A303-5返回原文JSON留档', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_application_invoice` (`tenant_id`,`invoice_application_id`,`invoice_no`), + KEY `idx_iai_application_id` (`invoice_application_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='开票申请金蝶发票结果'; diff --git a/doc/sql/transport/lqj/20261013/06_blade_invoice_receipt_kingdee_push_20260922.sql b/doc/sql/transport/lqj/20261013/06_blade_invoice_receipt_kingdee_push_20260922.sql new file mode 100644 index 00000000..fee3d8f5 --- /dev/null +++ b/doc/sql/transport/lqj/20261013/06_blade_invoice_receipt_kingdee_push_20260922.sql @@ -0,0 +1,94 @@ +-- 收票管理 图05 环节②③:推送金蝶收票单(A303-2 收票单批量保存 + A302-6 收票指定应付 + A801-3 定时回写) +-- 票据池镜像表 blade_kingdee_invoice_pool 新增: +-- invoice_type_code 票易通原始发票类型编码(A801-1 invoiceType,如 26/27,A303-2 invoicetypef7_number 取数) +-- items_json A801-1 发票明细行JSON(品名/数量/单价/金额/税率/税额/税收分类编码,A303-2 分录取数来源) +-- 收票登记表 blade_invoice_receipt 新增: +-- kingdee_received_invoice_no 金蝶收票单单据编号(A303-2 返回/幂等预检命中回填) +-- kingdee_push_status 收票单推送状态(unsynced 未推送 / pushed 已保存 / assigned 已指定应付 / failed 推送失败) +-- kingdee_push_msg 推送失败原因(金蝶侧删除/红冲/查验不通过等异常回收提示人工) +-- kingdee_push_time 最近一次推送/回写时间 +-- 以下结构升级支持重复执行 + +-- ① blade_kingdee_invoice_pool.invoice_type_code +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_invoice_pool` ADD COLUMN `invoice_type_code` varchar(8) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''票易通原始发票类型编码(A801-1 invoiceType,如26/27)'' AFTER `invoice_type`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_kingdee_invoice_pool' + AND COLUMN_NAME = 'invoice_type_code' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- ② blade_kingdee_invoice_pool.items_json +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_invoice_pool` ADD COLUMN `items_json` longtext COLLATE utf8mb4_general_ci COMMENT ''A801-1发票明细行JSON(A303-2分录取数来源)'' AFTER `attachments_json`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_kingdee_invoice_pool' + AND COLUMN_NAME = 'items_json' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- ③ blade_invoice_receipt.kingdee_received_invoice_no +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_invoice_receipt` ADD COLUMN `kingdee_received_invoice_no` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''金蝶收票单单据编号(A303-2生成)'' AFTER `kingdee_bill_no`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_invoice_receipt' + AND COLUMN_NAME = 'kingdee_received_invoice_no' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- ④ blade_invoice_receipt.kingdee_push_status +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_invoice_receipt` ADD COLUMN `kingdee_push_status` varchar(16) COLLATE utf8mb4_general_ci NOT NULL DEFAULT ''unsynced'' COMMENT ''收票单推送状态(unsynced未推送/pushed已保存/assigned已指定应付/failed推送失败)'' AFTER `kingdee_status`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_invoice_receipt' + AND COLUMN_NAME = 'kingdee_push_status' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- ⑤ blade_invoice_receipt.kingdee_push_msg +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_invoice_receipt` ADD COLUMN `kingdee_push_msg` varchar(500) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''收票单推送失败原因'' AFTER `kingdee_push_status`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_invoice_receipt' + AND COLUMN_NAME = 'kingdee_push_msg' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- ⑥ blade_invoice_receipt.kingdee_push_time +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_invoice_receipt` ADD COLUMN `kingdee_push_time` datetime DEFAULT NULL COMMENT ''最近一次收票单推送/回写时间'' AFTER `kingdee_push_msg`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_invoice_receipt' + AND COLUMN_NAME = 'kingdee_push_time' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; diff --git a/doc/sql/transport/lqj/20261013/07_blade_kingdee_invoice_pool_prune_20260922.sql b/doc/sql/transport/lqj/20261013/07_blade_kingdee_invoice_pool_prune_20260922.sql new file mode 100644 index 00000000..45a3d772 --- /dev/null +++ b/doc/sql/transport/lqj/20261013/07_blade_kingdee_invoice_pool_prune_20260922.sql @@ -0,0 +1,17 @@ +-- 金蝶票池镜像降级为登记存根(2026-09-22 票池改回实时查询收尾) +-- 背景:选票链路已改为实时查询 A801-1,blade_kingdee_invoice_pool 仅保留被收票登记引用的镜像行(登记存根), +-- 历史全量同步落库的未被引用镜像行属于退役数据,予以清理。 +-- 幂等:可重复执行,第二次影响 0 行。 +-- 注意:执行前必须先向用户报盘点数字并获确认(dev 库清理口径:事务 + 回滚 SQL + mysqldump 文本兜底)。 + +-- 执行前先跑盘点(只读): +-- SELECT COUNT(*) AS prune_rows FROM blade_kingdee_invoice_pool p +-- LEFT JOIN blade_invoice_receipt r ON r.kingdee_invoice_pool_id = p.id AND r.is_deleted = 0 +-- WHERE r.id IS NULL; +-- SELECT COUNT(*) AS keep_rows FROM blade_kingdee_invoice_pool p +-- INNER JOIN blade_invoice_receipt r ON r.kingdee_invoice_pool_id = p.id AND r.is_deleted = 0; + +-- 票池镜像降级为登记存根:删除未被任何未删收票登记引用的镜像行(实时查询模式上线前执行) +-- DELETE p FROM blade_kingdee_invoice_pool p +-- LEFT JOIN blade_invoice_receipt r ON r.kingdee_invoice_pool_id = p.id AND r.is_deleted = 0 +-- WHERE r.id IS NULL; diff --git a/doc/sql/transport/lqj/20261013/08_blade_invoice_receipt_drop_kingdee_sync_fields_20260923.sql b/doc/sql/transport/lqj/20261013/08_blade_invoice_receipt_drop_kingdee_sync_fields_20260923.sql new file mode 100644 index 00000000..2665393a --- /dev/null +++ b/doc/sql/transport/lqj/20261013/08_blade_invoice_receipt_drop_kingdee_sync_fields_20260923.sql @@ -0,0 +1,33 @@ +-- 收票管理金蝶功能精简:删除 blade_invoice_receipt 冗余同步字段 +-- kingdee_bill_no 金蝶单据号(冗余,等于票池镜像 serialNo,可经 kingdee_invoice_pool_id 关联查询) +-- kingdee_status 金蝶同步状态(冗余,手动同步功能已下线,推送状态 kingdee_push_status 为唯一状态源) +-- 金蝶状态展示口径:kingdee_push_status 四态经 kingdeePushStatusName 展示(未推送/已保存收票单/已指定应付/推送失败) +-- 以下结构升级支持重复执行 + +-- ① blade_invoice_receipt.kingdee_bill_no +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; + +-- ② blade_invoice_receipt.kingdee_status(索引 idx_invoice_receipt_kingdee 随列自动删除) +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/lqj/20261013/09_blade_kingdee_attachment_sync_20260924.sql b/doc/sql/transport/lqj/20261013/09_blade_kingdee_attachment_sync_20260924.sql new file mode 100644 index 00000000..aa6a3e58 --- /dev/null +++ b/doc/sql/transport/lqj/20261013/09_blade_kingdee_attachment_sync_20260924.sql @@ -0,0 +1,75 @@ +-- ① blade_kingdee_attachment_sync 建表【修复索引超长问题】 +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'CREATE TABLE `blade_kingdee_attachment_sync` ( + `id` bigint NOT NULL COMMENT ''主键'', + `tenant_id` varchar(12) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''租户ID'', + `biz_type` varchar(32) COLLATE utf8mb4_general_ci NOT NULL COMMENT ''业务单据类型(pay_application/formal_settlement_ap/formal_settlement_ar/invoice_receipt/invoice_application)'', + `biz_id` bigint NOT NULL COMMENT ''业务单据id'', + `attachment_key` varchar(768) COLLATE utf8mb4_general_ci NOT NULL COMMENT ''附件幂等键(用户附件=附件url;发票文件=A801-2的path)'', + `attachment_name` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''附件名称(展示/排查用)'', + `kingdee_path` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''金蝶侧文件路径(A902 返回 data.path)'', + `sync_status` tinyint NOT NULL DEFAULT 0 COMMENT ''同步状态:1 成功 / 0 失败'', + `fail_reason` varchar(500) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''失败原因(超限/下载失败/金蝶报错)'', + `sync_time` datetime 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 NULL COMMENT ''业务状态'', + `is_deleted` int DEFAULT 0 COMMENT ''是否已删除'', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_biz_attachment` (`biz_type`,`biz_id`,`attachment_key`(256)), + KEY `idx_biz_type_biz_id` (`biz_type`,`biz_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT=''金蝶附件同步表(A902)''', + 'SELECT 1') + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_kingdee_attachment_sync' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- ② blade_formal_settlement.kingdee_bill_id +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_formal_settlement` ADD COLUMN `kingdee_bill_id` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''金蝶单据id(星瀚内部主键,A902 附件上传定位用)'' AFTER `kingdee_bill_no`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_formal_settlement' + AND COLUMN_NAME = 'kingdee_bill_id' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- ③ blade_invoice_receipt.kingdee_received_invoice_id +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_invoice_receipt` ADD COLUMN `kingdee_received_invoice_id` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''金蝶收票单id(星瀚内部主键,A902 附件上传定位用)'' AFTER `kingdee_received_invoice_no`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_invoice_receipt' + AND COLUMN_NAME = 'kingdee_received_invoice_id' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- ④ blade_invoice_application.kingdee_bill_id +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_invoice_application` ADD COLUMN `kingdee_bill_id` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''金蝶开票申请单id(星瀚内部主键,A902 附件上传定位用)'' AFTER `kingdee_bill_no`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_invoice_application' + AND COLUMN_NAME = 'kingdee_bill_id' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; diff --git a/doc/sql/transport/lqj/20261013/10_blade_invoice_application_sheet_kingdee_bill_no_20260924.sql b/doc/sql/transport/lqj/20261013/10_blade_invoice_application_sheet_kingdee_bill_no_20260924.sql new file mode 100644 index 00000000..ec7bf6f3 --- /dev/null +++ b/doc/sql/transport/lqj/20261013/10_blade_invoice_application_sheet_kingdee_bill_no_20260924.sql @@ -0,0 +1,50 @@ +-- ============================================================ +-- blade_invoice_application_sheet_kingdee_bill_no_20260924.sql +-- ============================================================ +-- 开票申请同步金蝶按发票张次拆分开票(A303-1 一张发票张次一张开票申请单) +-- blade_invoice_application_sheet.kingdee_bill_no 本张发票对应的金蝶开票申请单编号(A303-1 返回,A303-5 serialNo 查询入口) +-- blade_invoice_application.kingdee_bill_no 多张发票时逗号拼接全部金蝶单号,列加宽至 varchar(500) 防溢出 +-- blade_invoice_application_record.kingdee_bill_no 操作记录的同一字段同步加宽,避免多张单号拼接超长导致插入失败 +-- 以下结构升级支持重复执行 + +-- ① blade_invoice_application_sheet.kingdee_bill_no +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_invoice_application_sheet` ADD COLUMN `kingdee_bill_no` varchar(100) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''金蝶开票申请单编号(本张发票对应)'' AFTER `invoice_amount`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_invoice_application_sheet' + AND COLUMN_NAME = 'kingdee_bill_no' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- ② blade_invoice_application.kingdee_bill_no 加宽 +SET @ddl_sql = ( + SELECT IF(CHARACTER_MAXIMUM_LENGTH < 500, + 'ALTER TABLE `blade_invoice_application` MODIFY COLUMN `kingdee_bill_no` varchar(500) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''金蝶开票申请单编号(多张发票逗号拼接)''', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_invoice_application' + AND COLUMN_NAME = 'kingdee_bill_no' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- ③ blade_invoice_application_record.kingdee_bill_no 加宽 +SET @ddl_sql = ( + SELECT IF(CHARACTER_MAXIMUM_LENGTH < 500, + 'ALTER TABLE `blade_invoice_application_record` MODIFY COLUMN `kingdee_bill_no` varchar(500) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''金蝶开票申请单编号(多张发票逗号拼接)''', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_invoice_application_record' + AND COLUMN_NAME = 'kingdee_bill_no' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; \ No newline at end of file diff --git a/doc/sql/transport/lqj/20261013/11_blade_receipt_flow_claim_20260924.sql b/doc/sql/transport/lqj/20261013/11_blade_receipt_flow_claim_20260924.sql new file mode 100644 index 00000000..16779503 --- /dev/null +++ b/doc/sql/transport/lqj/20261013/11_blade_receipt_flow_claim_20260924.sql @@ -0,0 +1,103 @@ +-- 收款认领金蝶对接(A301-2 认领公告同步 / A301-3 认领结果推送 / A301-1 收款单回写) +-- 支持重复执行,兼容已执行 DDL 的场景 + +-- 1. 收款流水表新增金蝶认领公告字段 +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_receipt_flow` ADD COLUMN `bill_id` bigint(20) DEFAULT NULL COMMENT ''金蝶认领公告id(A301-2返回,A301-3推送必填)'' AFTER `source_updated_time`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_kingdee_receipt_flow' AND COLUMN_NAME = 'bill_id' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_receipt_flow` ADD COLUMN `payer_id` varchar(100) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''金蝶付款人id(A301-3推送用)'' AFTER `bill_id`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_kingdee_receipt_flow' AND COLUMN_NAME = 'payer_id' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_receipt_flow` ADD COLUMN `kingdee_claim_status` varchar(30) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''公告认领状态(A301-2 claimstatus:0未认领/1已认领)'' AFTER `payer_id`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_kingdee_receipt_flow' AND COLUMN_NAME = 'kingdee_claim_status' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_receipt_flow` ADD COLUMN `claim_me` tinyint(1) DEFAULT NULL COMMENT ''我已认领(A301-2 claimme)'' AFTER `kingdee_claim_status`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_kingdee_receipt_flow' AND COLUMN_NAME = 'claim_me' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_receipt_flow` ADD COLUMN `unclaimed_amount` decimal(18,2) NOT NULL DEFAULT ''0.00'' COMMENT ''未认领金额(A301-2 unclaimamount)'' AFTER `claim_me`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_kingdee_receipt_flow' AND COLUMN_NAME = 'unclaimed_amount' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_receipt_flow` ADD COLUMN `fee` decimal(18,2) DEFAULT NULL COMMENT ''手续费(A301-2 fee)'' AFTER `unclaimed_amount`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_kingdee_receipt_flow' AND COLUMN_NAME = 'fee' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_receipt_flow` ADD COLUMN `payee_name` varchar(200) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''收款人(A301-2 recpayee)'' AFTER `fee`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_kingdee_receipt_flow' AND COLUMN_NAME = 'payee_name' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- 2. 认领主单新增来源单据编码(A301-3 awrp_src_code,A301-1 回写匹配键) +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_receipt_claim` ADD COLUMN `awrp_src_code` varchar(100) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''金蝶认领结果推送来源单据编码(awrp_src_code)'' AFTER `kingdee_bill_status`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_receipt_claim' AND COLUMN_NAME = 'awrp_src_code' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_receipt_claim` ADD KEY `idx_receipt_claim_src_code` (`tenant_id`,`awrp_src_code`)', + 'SELECT 1') + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_receipt_claim' AND INDEX_NAME = 'idx_receipt_claim_src_code' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; \ No newline at end of file diff --git a/doc/sql/transport/lqj/20261013/12_blade_receipt_flow_push_20260928.sql b/doc/sql/transport/lqj/20261013/12_blade_receipt_flow_push_20260928.sql new file mode 100644 index 00000000..f4f4c542 --- /dev/null +++ b/doc/sql/transport/lqj/20261013/12_blade_receipt_flow_push_20260928.sql @@ -0,0 +1,136 @@ +-- 收款认领推送金蝶重构(认领本地化 / A301-3 定时+手动推送 / 按流水归集) +-- 支持重复执行,兼容 20260924 脚本已执行/未执行的环境,与该脚本无执行顺序要求 +-- 变更点: +-- 1. 收款流水表新增推送域字段(推送状态/失败原因/推送时间/推送编码/金蝶收款单号/回写状态) +-- 2. 认领主单移除 awrp_src_code 及其索引(推送编码上移至流水:分摊结算单号拼接) +-- 3. 收款流水表新增定时任务扫描索引与 A301-1 回写匹配索引 + +-- 1. 收款流水表:推送状态 +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_receipt_flow` ADD COLUMN `push_status` varchar(20) COLLATE utf8mb4_general_ci NOT NULL DEFAULT ''none'' COMMENT ''推送状态(none未推送/pushed已推送/failed推送失败)''', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_kingdee_receipt_flow' AND COLUMN_NAME = 'push_status' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- 2. 收款流水表:最近一次推送失败原因 +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_receipt_flow` ADD COLUMN `push_fail_reason` varchar(1000) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''最近一次推送失败原因(成功后清空)''', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_kingdee_receipt_flow' AND COLUMN_NAME = 'push_fail_reason' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- 3. 收款流水表:首次推送成功时间 +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_receipt_flow` ADD COLUMN `pushed_time` datetime DEFAULT NULL COMMENT ''首次推送金蝶成功时间''', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_kingdee_receipt_flow' AND COLUMN_NAME = 'pushed_time' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- 4. 收款流水表:推送来源单据编码(A301-3 awrp_src_code,分摊结算单号拼接,A301-1 回写匹配键) +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_receipt_flow` ADD COLUMN `awrp_src_code` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''金蝶推送来源单据编码(分摊结算单号下划线拼接,A301-1回写匹配键)''', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_kingdee_receipt_flow' AND COLUMN_NAME = 'awrp_src_code' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- 5. 收款流水表:金蝶收款单号(推送响应回填 / A301-1 回写推进) +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_receipt_flow` ADD COLUMN `kingdee_bill_no` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''金蝶收款单号''', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_kingdee_receipt_flow' AND COLUMN_NAME = 'kingdee_bill_no' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- 6. 收款流水表:金蝶回写状态(A301-1 回写生命周期) +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_receipt_flow` ADD COLUMN `kingdee_bill_status` varchar(20) COLLATE utf8mb4_general_ci NOT NULL DEFAULT ''none'' COMMENT ''金蝶回写状态(none未推送/pushed已推送/approved审核通过/received已收款/failed失败)''', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_kingdee_receipt_flow' AND COLUMN_NAME = 'kingdee_bill_status' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- 7. 认领主单:删除 awrp_src_code 索引(列上移至流水表;0924 未执行过则本就无此索引) +SET @ddl_sql = ( + SELECT IF(COUNT(*) > 0, + 'ALTER TABLE `blade_receipt_claim` DROP KEY `idx_receipt_claim_src_code`', + 'SELECT 1') + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_receipt_claim' AND INDEX_NAME = 'idx_receipt_claim_src_code' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- 8. 认领主单:删除 awrp_src_code 列(0924 未执行过则本就无此列) +SET @ddl_sql = ( + SELECT IF(COUNT(*) > 0, + 'ALTER TABLE `blade_receipt_claim` DROP COLUMN `awrp_src_code`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_receipt_claim' AND COLUMN_NAME = 'awrp_src_code' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- 9. 收款流水表:定时任务扫描索引(push_status IN (none,failed) AND claim_status='claimed' AND bill_id 非空) +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_receipt_flow` ADD KEY `idx_kingdee_receipt_flow_push_scan` (`tenant_id`,`push_status`,`claim_status`)', + 'SELECT 1') + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_kingdee_receipt_flow' AND INDEX_NAME = 'idx_kingdee_receipt_flow_push_scan' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- 10. 收款流水表:A301-1 回写匹配索引(sourcebillnumber = awrp_src_code) +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_receipt_flow` ADD KEY `idx_kingdee_receipt_flow_src_code` (`tenant_id`,`awrp_src_code`)', + 'SELECT 1') + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_kingdee_receipt_flow' AND INDEX_NAME = 'idx_kingdee_receipt_flow_src_code' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- 11. 按钮权限:收款流水页「推送金蝶」(父级菜单 2090000000000001260 收款流水,排序 4) +INSERT INTO `blade_menu` + (`id`,`parent_id`,`code`,`name`,`alias`,`path`,`source`,`sort`,`category`,`action`,`is_open`,`component`,`remark`,`is_deleted`) +VALUES + (2090000000000001264,2090000000000001260,'receipt_flow_push','推送金蝶','receipt_flow_push','','',4,2,0,1,NULL,'',0) +ON DUPLICATE KEY UPDATE + `name`=VALUES(`name`), + `alias`=VALUES(`alias`), + `is_deleted`=0; diff --git a/doc/sql/transport/lqj/20261013/13_blade_invoice_application_invoice_urls_20260930.sql b/doc/sql/transport/lqj/20261013/13_blade_invoice_application_invoice_urls_20260930.sql new file mode 100644 index 00000000..849813f8 --- /dev/null +++ b/doc/sql/transport/lqj/20261013/13_blade_invoice_application_invoice_urls_20260930.sql @@ -0,0 +1,65 @@ +-- ============================================================ +-- blade_invoice_application_invoice_urls_20260930.sql +-- ============================================================ +-- 开票申请金蝶发票结果表补充发票文件下载地址(A303-5 返回,正式结算单详情查看/下载发票): +-- invoice_file_url 板式文件下载地址(数电票为OFD,航信百望税盘普通电票为PDF,电子专票/UKey电票为OFD) +-- invoice_image_url PDF图片预览地址 +-- invoice_pdf_file_url 数电PDF下载地址(发票云5.0.025版本返回) +-- invoice_xml_file_url 数电XML下载地址(发票云5.0.025版本返回) +-- 以下结构升级支持重复执行 + +-- ① blade_invoice_application_invoice.invoice_file_url +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_invoice_application_invoice` ADD COLUMN `invoice_file_url` varchar(1024) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''板式文件下载地址(数电票为OFD,普通电票为PDF,电子专票为OFD)'' AFTER `invoice_amount`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_invoice_application_invoice' + AND COLUMN_NAME = 'invoice_file_url' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- ② blade_invoice_application_invoice.invoice_image_url +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_invoice_application_invoice` ADD COLUMN `invoice_image_url` varchar(1024) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''PDF图片预览地址'' AFTER `invoice_file_url`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_invoice_application_invoice' + AND COLUMN_NAME = 'invoice_image_url' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- ③ blade_invoice_application_invoice.invoice_pdf_file_url +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_invoice_application_invoice` ADD COLUMN `invoice_pdf_file_url` varchar(1024) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''数电PDF下载地址(发票云5.0.025版本返回)'' AFTER `invoice_image_url`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_invoice_application_invoice' + AND COLUMN_NAME = 'invoice_pdf_file_url' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- ④ blade_invoice_application_invoice.invoice_xml_file_url +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_invoice_application_invoice` ADD COLUMN `invoice_xml_file_url` varchar(1024) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''数电XML下载地址(发票云5.0.025版本返回)'' AFTER `invoice_pdf_file_url`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_invoice_application_invoice' + AND COLUMN_NAME = 'invoice_xml_file_url' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; diff --git a/doc/sql/transport/lqj/20261013/14_blade_kingdee_invoice_pool_available_20261005.sql b/doc/sql/transport/lqj/20261013/14_blade_kingdee_invoice_pool_available_20261005.sql new file mode 100644 index 00000000..98a36137 --- /dev/null +++ b/doc/sql/transport/lqj/20261013/14_blade_kingdee_invoice_pool_available_20261005.sql @@ -0,0 +1,46 @@ +-- 金蝶票池选票回切镜像读:镜像表新增可用过滤列 + 认领过滤索引 +-- available_amount 剩余可用金额(A801-1 availableAmount,金蝶可用税额口径,选票本地过滤 >0) +-- amount_use_status 金额使用状态(A801-1 amountUseStatus,'0' 未使用可选 / '2' 全部使用) +-- 由定时同步(InvoiceReceiptKingdeeSyncTask)/ 手动同步端点 / 票号回源 / 收票保存重查 统一按 tenant_id + invoice_no upsert 回填; +-- 存量存根行两列为 NULL,不参与选票(可用过滤为 NULL 排除),随增量窗口同步自然回填 +-- 认领过滤 EXISTS 子查询补 (tenant_id, invoice_no) 索引,避免统一查询全表扫 + +-- 以下结构升级支持重复执行 +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_invoice_pool` ADD COLUMN `available_amount` decimal(18,2) DEFAULT NULL COMMENT ''剩余可用金额(A801-1 availableAmount,金蝶可用税额口径)'' AFTER `source_updated_time`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_kingdee_invoice_pool' + AND COLUMN_NAME = 'available_amount' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_kingdee_invoice_pool` ADD COLUMN `amount_use_status` varchar(8) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''金额使用状态(0 未使用 / 2 全部使用)'' AFTER `available_amount`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_kingdee_invoice_pool' + AND COLUMN_NAME = 'amount_use_status' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +SET @idx_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_formal_settlement_invoice` ADD INDEX `idx_settlement_invoice_no` (`tenant_id`, `invoice_no`)', + 'SELECT 1') + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_formal_settlement_invoice' + AND INDEX_NAME = 'idx_settlement_invoice_no' +); +PREPARE idx_stmt FROM @idx_sql; +EXECUTE idx_stmt; +DEALLOCATE PREPARE idx_stmt; diff --git a/doc/sql/transport/lqj/20261013/15_blade_kingdee_bill_lock_20261009.sql b/doc/sql/transport/lqj/20261013/15_blade_kingdee_bill_lock_20261009.sql new file mode 100644 index 00000000..98c6ac33 --- /dev/null +++ b/doc/sql/transport/lqj/20261013/15_blade_kingdee_bill_lock_20261009.sql @@ -0,0 +1,32 @@ +-- 金蝶单据锁定记录表:lockBusiData 锁定 / unLockNotifyBusiData 解锁回调 / 锁定补偿 +-- 一行 = 一张金蝶目标单:锁定留痕(人/时间/失败原因)+ 当前锁状态(编辑守卫/回调定位/补偿扫表) +-- lock_status:failed-锁定失败(补偿重试对象)/ locked-金蝶锁定中 / unlocked-已解锁(终态) + +CREATE TABLE `blade_kingdee_bill_lock` ( + `id` bigint NOT NULL COMMENT '主键', + `tenant_id` varchar(12) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '000000' COMMENT '租户ID', + `source_bill_type` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'WLTMS单据类型:SETTLEMENT正式结算单/INVOICE_APPLY开票申请单(预留PAY_APPLY付款申请单/RECEIVED_INVOICE收票单)', + `source_bill_id` bigint NOT NULL COMMENT 'WLTMS业务主键', + `source_bill_no` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'WLTMS单号', + `target_bill_type` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '金蝶单据类型:ApFin_other_BT_S应付单/arfin_other_BT_S应收单/cdm_paybill_bank_elect_BT_S开票单等固定值', + `target_bill_no` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '金蝶单据编号(开票申请按发票张次逐张一行)', + `lock_status` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '锁状态:failed锁定失败/locked金蝶锁定中/unlocked已解锁', + `operation_type` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '最近动作来源操作类型:SAVE单据新增/DELETE单据删除', + `try_count` int DEFAULT '0' COMMENT '锁定重试次数(补偿任务用,超上限停试)', + `lock_time` datetime DEFAULT NULL COMMENT '锁定时间', + `unlock_time` datetime DEFAULT NULL COMMENT '解锁时间', + `lock_operator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '推单发起人(锁定操作人)', + `notify_operator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '回调操作人(金蝶侧操作人)', + `fail_reason` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci 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 NULL COMMENT '状态', + `is_deleted` int DEFAULT '0' COMMENT '是否已删除', + PRIMARY KEY (`id`), + INDEX `idx_kbl_source` (`source_bill_type`, `source_bill_id`), + INDEX `idx_kbl_target` (`target_bill_type`, `target_bill_no`), + INDEX `idx_kbl_status` (`lock_status`, `try_count`) +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '金蝶单据锁定记录' ROW_FORMAT = DYNAMIC; diff --git a/doc/sql/transport/transport.sql b/doc/sql/transport/transport.sql index b2d174cd..c2bc3826 100644 --- a/doc/sql/transport/transport.sql +++ b/doc/sql/transport/transport.sql @@ -5422,11 +5422,11 @@ CREATE TABLE `blade_user` ( `real_name` varchar(10) DEFAULT NULL COMMENT '真名', `avatar` varchar(500) DEFAULT NULL COMMENT '头像', `email` varchar(45) DEFAULT NULL COMMENT '邮箱', - `phone` varchar(45) DEFAULT NULL COMMENT '手机', + `phone` varchar(500) DEFAULT NULL COMMENT '手机', `birthday` datetime DEFAULT NULL COMMENT '生日', `sex` int(11) DEFAULT NULL COMMENT '性别', `role_id` varchar(1000) DEFAULT NULL COMMENT '角色id', - `dept_id` varchar(1000) DEFAULT NULL COMMENT '部门id', + `dept_id` varchar(2000) DEFAULT NULL COMMENT '部门id', `post_id` varchar(1000) DEFAULT NULL COMMENT '岗位id', `leader_id` varchar(1000) DEFAULT NULL COMMENT '主管id', `is_leader` int(11) DEFAULT '0' COMMENT '是否主管', diff --git a/pom.xml b/pom.xml index c4d7d9b5..f064dd95 100644 --- a/pom.xml +++ b/pom.xml @@ -154,6 +154,11 @@ blade-track-api ${revision} + + org.springblade + blade-bfi-api + ${revision} + org.springblade blade-wechat-api