!1 tms对接金蝶

Merge pull request !1 from mindless/feature/payment-apply-bfi-sync
This commit is contained in:
2026-10-14 09:27:06 +00:00
committed by Gitee
287 changed files with 37880 additions and 490 deletions
+2
View File
@@ -34,3 +34,5 @@ Thumbs.db
.claude
/outputs/
/.codex-work/
.serena/
.workbuddy/
+82
View File
@@ -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 回写的单据生命周期:已推送/审核通过/已收款)
@@ -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();
}
}
@@ -0,0 +1,242 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* 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<String, Collection<String>> 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<String, String> 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<String, Collection<String>> 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<String, Collection<String>> headers) {
Collection<String> 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<String> 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;
}
}
@@ -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<List<CargoType>> getCargoTypes();
/**
* 获取计量单位档案(金蝶推送按计费单位名称匹配取计量单位编码)
*
* @return 计量单位集合
*/
@GetMapping(MEASUREMENT_UNITS)
R<List<MeasurementUnit>> getMeasurementUnits();
/**
* 获取角色权限标识集合(按钮编号)
*
@@ -326,4 +336,12 @@ public interface ISysClient {
@GetMapping(PERMISSIONS)
R<List<String>> getPermissions(@RequestParam("roleId") String roleId);
/**
* 获取启用的开票项目(含税收分类编码,开票申请推送金蝶按分类+简称匹配取数)
*
* @return 开票项目集合
*/
@GetMapping(INVOICE_ITEMS)
R<List<InvoiceItem>> getInvoiceItems();
}
@@ -174,9 +174,19 @@ public class ISysClientFallback implements ISysClient {
return R.fail("获取数据失败");
}
@Override
public R<List<MeasurementUnit>> getMeasurementUnits() {
return R.fail("获取数据失败");
}
@Override
public R<List<String>> getPermissions(String roleId) {
return R.fail("获取数据失败");
}
@Override
public R<List<InvoiceItem>> getInvoiceItems() {
return R.fail("获取数据失败");
}
}
@@ -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;
@@ -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;
@@ -0,0 +1,116 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to the License, and the software
* must not be used for illegal purposes.
* <p>
* 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;
}
@@ -0,0 +1,70 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for their own commercial purposes.
* <p>
* 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.
* <p>
* 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.
* <p>
* 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)
* <p>
* 推送失败不抛异常跨事务边界(保证失败状态落库不被回滚),以本结果对象向调用方报告成败与原因。
*
* @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;
}
}
@@ -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;
}
@@ -21,6 +21,8 @@ import java.time.LocalDateTime;
/**
* 正式结算单实体类
* <p>
* 结算单主表,明细快照见 {@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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -0,0 +1,80 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments from this software for such purposes.
* Copyright of this software remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is not liable for any claims arising from secondary or illegal development.
* <p>
* 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;
/**
* 开票申请金蝶发票结果实体类
* <p>
* 定时任务经 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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -0,0 +1,84 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* 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;
}
@@ -0,0 +1,87 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement obtained after purchasing a license from BladeX.
* <p>
* 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;
/**
* 金蝶单据锁定记录实体类
* <p>
* 一行 = 一张金蝶目标单:推送成功后 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;
}
@@ -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;
}
@@ -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;
}
@@ -2,18 +2,24 @@
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* 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.
* <p>
* 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.
* <p>
* 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.
* <p>
* 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.
* <p>
* 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.
* <p>
* 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.
* <p>
* 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;
}
@@ -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;
@@ -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,6 +19,7 @@ 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.springframework.format.annotation.DateTimeFormat;
import java.io.Serial;
@@ -39,6 +41,15 @@ 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) private String preSettlementNos;
@TableField(exist = false) private String preSettlementNo;
@TableField(exist = false) private List<FormalSettlementSource> sources;
@@ -46,6 +57,8 @@ public class FormalSettlementVO extends FormalSettlement {
@TableField(exist = false) private List<FormalSettlementSummaryFee> summaryFees;
@TableField(exist = false) private List<FormalSettlementPayment> payments;
@TableField(exist = false) private List<FormalSettlementInvoice> invoices;
@TableField(exist = false) @Schema(description = "开票申请经金蝶回写的发票结果(含文件地址,只读展示)")
private List<InvoiceApplicationInvoice> issuedInvoices;
@TableField(exist = false) private List<PaymentApplicationVO> paymentApplications;
@TableField(exist = false) private List<SettlementAdjustmentVO> adjustments;
@TableField(exist = false) private List<FormalSettlementChangeRecord> changeRecords;
@@ -30,6 +30,7 @@ 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;
@@ -48,9 +49,16 @@ public class InvoiceApplicationVO extends InvoiceApplication {
@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;
@TableField(exist = false) private String createUserName;
@TableField(exist = false) private List<InvoiceApplicationSettlement> settlements;
@TableField(exist = false) private List<InvoiceApplicationSheetVO> sheets;
@TableField(exist = false) private List<InvoiceApplicationDetail> details;
@TableField(exist = false) private List<InvoiceApplicationRecord> records;
@TableField(exist = false) private List<InvoiceApplicationInvoice> invoices;
}
@@ -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<InvoiceReceiptSettlement> settlements;
@@ -0,0 +1,157 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* 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;
}
@@ -43,6 +43,12 @@ 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;
@TableField(exist = false) private List<PaymentApplicationInvoice> invoices;
@TableField(exist = false) private List<PaymentApplicationRecord> paymentRecords;
@TableField(exist = false) private List<PaymentApplicationSettlement> settlements;
@@ -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<ReceiptClaimSettlement> settlements;
@TableField(exist = false)
@@ -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<ReceiptFlowRecord> claimRecords = new ArrayList<>();
}
@@ -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<List<MeasurementUnit>> getMeasurementUnits() {
return R.data(measurementUnitService.list(Wrappers.<MeasurementUnit>lambdaQuery()
.eq(MeasurementUnit::getIsDeleted, 0)
.orderByAsc(MeasurementUnit::getUnitCode)));
}
@Override
@GetMapping(PERMISSIONS)
public R<List<String>> getPermissions(String roleId) {
return R.data(menuService.permissionCodes(roleId));
}
@Override
@GetMapping(INVOICE_ITEMS)
public R<List<InvoiceItem>> getInvoiceItems() {
return R.data(invoiceItemService.list(Wrappers.<InvoiceItem>lambdaQuery()
.eq(InvoiceItem::getStatus, 1)
.eq(InvoiceItem::getIsDeleted, 0)
.orderByAsc(InvoiceItem::getCategoryName, InvoiceItem::getShortName)));
}
}
+9
View File
@@ -47,10 +47,19 @@
<groupId>org.springblade</groupId>
<artifactId>blade-process-api</artifactId>
</dependency>
<dependency>
<groupId>org.springblade</groupId>
<artifactId>blade-bfi-api</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
@@ -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 {
@@ -0,0 +1,413 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* 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.*:
* <p>
* srcSys 为客商、付款申请等各业务推送金蝶的公共参数(外部系统编号);
* customer、payApply 为各业务专属参数;未配置时使用字段默认值。
* <p>
* 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<String, String> 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 {
/**
* 锁定补偿开关,默认关闭(安全兜底)
*/
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;
}
}
@@ -0,0 +1,63 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* 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() {
}
}
@@ -0,0 +1,176 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.constant;
import java.util.Map;
import org.springblade.core.log.exception.ServiceException;
/**
* 金蝶附件同步(A902)常量
* <p>entityNumber/controlKey 映射以常量维护,二期客商(资质附件)等金蝶确认后补充条目即可,不改架构</p>
*
* @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<String, String> 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<String, String> 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() {
}
}
@@ -0,0 +1,253 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* 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<String, String> 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<String, String> 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<String> BFI_INVOICE_FREIGHT_KEYWORDS = List.of("货物运输", "运费");
/** A303-1 货物运输单据体运输工具种类合法值:字典 transport_type 翻译名称命中则直送,否则兜底其它运输工具。 */
public static final List<String> 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<String, String> 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<String, String> billtypeMap, String name) {
return billtypeMap.get(name);
}
private KingdeeBfiConstant() {
}
}
@@ -0,0 +1,91 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement obtained after purchasing a license from BladeX.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.constant;
import java.util.Map;
/**
* 金蝶单据锁定/解锁(lockBusiData / unLockNotifyBusiData)固定值。
* <p>lockBusiData 由 WLTMS 调金蝶(源系统 WLTMS、目标系统 XH),
* unLockNotifyBusiData 由金蝶调 WLTMS(源系统 XH、目标系统 WLTMS);
* WLTMS 单据类型自定义,金蝶目标单据类型为固定值</p>
*
* @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<String, String> 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() {
}
}
@@ -0,0 +1,54 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.constant;
/**
* 运输业务固定值。
*
* @author Chill
*/
public final class TransportBusiConstant {
/** 付款类型:结算付款(关联正式结算单的正式付款)。 */
public static final String PAYMENT_TYPE_SETTLEMENT = "settlement_payment";
/** 审批状态:草稿(允许编辑、删除)。 */
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() {
}
}
@@ -0,0 +1,75 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* 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<BfiCustomerVO> 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<BfiCustomerVO> page = bfiService.queryCustomer(param);
List<BfiCustomerVO> rows = page == null ? null : page.getRows();
return R.data(rows == null || rows.isEmpty() ? null : rows.get(0));
}
}
@@ -0,0 +1,76 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* 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<BfiExchangeRateVO> getBfiExchangeRate(
@RequestParam String currencyCode,
@RequestParam(required = false) String effectdate) {
LocalDate date = StringUtil.isBlank(effectdate) ? LocalDate.now() : LocalDate.parse(effectdate);
List<BfiExchangeRateVO> 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);
}
}
@@ -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,21 @@ 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) {
try {
customerArchiveService.syncKingdee(id);
} catch (Exception exception) {
log.error("客商审批通过后推送金蝶异常,id={}", id, exception);
}
}
return R.status(result);
}
/**
@@ -174,6 +184,16 @@ public class CustomerArchiveController extends BladeController {
return R.status(customerArchiveService.removeDraft(ids));
}
/**
* 推送金蝶(审批通过后失败重推入口)
*/
@PostMapping("/sync-kingdee")
@ApiOperationSupport(order = 12)
@Operation(summary = "推送金蝶", description = "传入id,仅审批通过的客商可推送,已推送/金蝶已有自动幂等跳过")
public R<CustomerArchive> syncKingdee(@Parameter(description = "主键", required = true) @RequestParam Long id) {
return R.data(customerArchiveService.syncKingdee(id));
}
/**
* 评分明细模板
*/
@@ -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<String> 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<String> syncKingdeeArResult() { return R.data(formalSettlementService.syncKingdeeArResult()); }
@PostMapping("/retry-kingdee-bill-lock")
@ApiOperationSupport(order = 17)
@Operation(summary = "手动补偿金蝶单据锁定(扫锁定失败锁记录重调lockBusiData,应付/应收/开票通用)")
public R<String> retryKingdeeBillLock() {
return R.data(kingdeeBillLockService.retryFailedLocks());
}
@GetMapping("/detail-fees")
@ApiOperationSupport(order = 16)
@Operation(summary = "正式结算货物费用快照")
public R<List<FormalSettlementDetailFee>> detailFees(@RequestParam Long detailId) {
return R.data(formalSettlementService.detailFees(detailId));
@@ -250,4 +281,11 @@ public class FormalSettlementController extends BladeController {
public R<List<Map<String, Object>>> receiptClaims(@RequestParam Long formalSettlementId) {
return R.data(receiptFlowService.settlementClaims(formalSettlementId));
}
@GetMapping("/invoice-pool")
@ApiOperationSupport(order = 21)
@Operation(summary = "金蝶票据池镜像分页查询(认领查询)")
public R<IPage<KingdeeInvoicePoolVO>> invoicePool(KingdeeInvoicePoolQueryDTO query) {
return R.data(invoiceReceiptService.invoicePoolPage(query));
}
}
@@ -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<String> syncKingdee(@RequestParam Long id) {
return R.data(invoiceApplicationService.syncKingdee(id));
}
@PostMapping("/sync-kingdee-invoice-result")
@ApiOperationSupport(order = 13)
@Operation(summary = "手动回写金蝶开票结果(A303-5)")
public R<String> syncKingdeeInvoiceResult() {
return R.data(invoiceApplicationService.syncKingdeeInvoiceResult());
}
}
@@ -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<List<KingdeeInvoicePool>> invoicePool(@RequestParam(required = false) String keyword) {
return R.data(invoiceReceiptService.invoicePool(keyword));
@Operation(summary = "金蝶票据池镜像分页查询(含认领/登记标记)")
public R<IPage<KingdeeInvoicePoolVO>> invoicePool(KingdeeInvoicePoolQueryDTO query) {
return R.data(invoiceReceiptService.invoicePoolPage(query));
}
@PostMapping("/sync-invoice-pool")
@ApiOperationSupport(order = 13)
@Operation(summary = "手动触发金蝶全票池发票同步(窗口拉取+镜像全列upsert)")
public R<String> 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<String> syncKingdee(@RequestParam Long id) {
return R.data(invoiceReceiptService.syncKingdee(id));
@PostMapping("/push-kingdee")
@ApiOperationSupport(order = 15)
@Operation(summary = "批量推送金蝶收票单(A303-2 收票单保存 + A302-6 收票指定应付)")
public R<String> pushKingdee(@RequestBody List<Long> ids) {
return R.data(invoiceReceiptService.pushKingdeeReceivedInvoice(ids));
}
@PostMapping("/sync-kingdee-result")
@ApiOperationSupport(order = 16)
@Operation(summary = "手动触发金蝶收票单结果回写(A801-3 异常回收/指定补偿)")
public R<String> syncKingdeeResult() {
return R.data(invoiceReceiptService.syncKingdeeReceivedInvoiceResult());
}
}
@@ -0,0 +1,65 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement obtained after purchasing a license from BladeX.
* <p>
* 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;
/**
* 金蝶单据锁定回调 控制器
* <p>
* 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);
}
}
}
@@ -29,6 +29,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.PaymentApplicationSaveRequest;
import org.springblade.transport.pojo.dto.PaymentApplicationStatusRequest;
import org.springblade.transport.pojo.vo.PaymentApplicationReferenceAmountVO;
@@ -81,7 +82,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 +113,8 @@ public class PaymentApplicationController extends BladeController {
public R<List<String>> syncKingdeeBatch(@RequestBody List<Long> ids) {
return R.data(paymentApplicationService.syncKingdeeBatch(ids));
}
@PostMapping("/sync-kingdee-result")
@ApiOperationSupport(order = 12)
@Operation(summary = "手动回写金蝶付款结果(A103-2/A103-4)")
public R<String> syncKingdeeResult() { return R.data(paymentApplicationService.syncKingdeePaymentResult()); }
}
@@ -84,7 +84,15 @@ public class ReceiptClaimRecordController extends BladeController {
@PostMapping("/void")
@ApiOperationSupport(order = 4)
@Operation(summary = "作废认领记录并生成金蝶认领冲单")
public R<String> voidClaim(@RequestParam Long id) {
return R.data(receiptClaimRecordService.voidClaim(id));
public R<Void> voidClaim(@RequestParam Long id) {
receiptClaimRecordService.voidClaim(id);
return R.success("作废成功");
}
@PostMapping("/query-kingdee-result")
@ApiOperationSupport(order = 5)
@Operation(summary = "查询金蝶认领结果(A301-1 收款单查询回写)")
public R<Integer> queryKingdeeResult() {
return R.data(receiptClaimRecordService.refreshKingdeeResult());
}
}
@@ -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<Integer> sync(@RequestBody(required = false) ReceiptFlowSyncRequest request) {
return R.data(receiptFlowService.sync(request));
@Operation(summary = "手动同步金蝶收款流水(A301-2 收款认领公告查询)")
public R<Integer> sync() {
return R.data(receiptFlowService.sync());
}
@PostMapping("/push-kingdee")
@ApiOperationSupport(order = 6)
@Operation(summary = "推送金蝶收款认领(A301-3 收款认领结果推送,仅认领完成流水可推送)")
public R<ReceiptClaimPushResult> pushKingdee(@RequestParam Long flowId) {
ReceiptClaimPushResult result = receiptFlowService.pushClaimToKingdee(flowId);
return result.isSuccess() ? R.data(result) : R.fail(result.getMessage());
}
}
@@ -133,13 +133,15 @@ public class ReceivablePayableDetailController extends BladeController {
@ApiOperationSupport(order = 8)
@Operation(summary = "转结算候选明细")
public R<IPage<Map<String, Object>>> 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));
}
@@ -0,0 +1,44 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments from this software for such purposes.
* Copyright of this software remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.event;
/**
* 单据审批通过事件:审批事务提交后由 {@code KingdeeApprovalSyncListener} 消费,
* 自动推送对应单据至金蝶(星瀚),省掉页面手动点「同步金蝶」。
* <p>
* 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";
}
@@ -111,4 +111,7 @@ public class CustomerArchiveExcel implements Serializable {
@ExcelProperty("审核通过时间")
private LocalDateTime approvedTime;
@ExcelProperty("金蝶同步状态")
private String kingdeeStatusName;
}
@@ -0,0 +1,140 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments from this software for such purposes.
* Copyright of this software remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is not liable for any claims arising from secondary or illegal development.
* <p>
* 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 方法推送,
* 与客商档案审批后自动推送同线程模式。
* <p>
* 不变式:审批结果不受推送影响——监听器整体 try/catch 吞异常,推送异常只落 failed + 日志;
* 失败单据由 {@code KingdeeAutoSyncRetryTask} 定时重推、手动按钮兜底。
* <p>
* 推送失败原因通过 {@link #pollSyncFailure()} 传递给审批接口响应:AFTER_COMMIT 监听与 approve
* 同线程同请求,Controller 在 approve 返回后取走原因拼进提示文案;OA 回调路径无人取走,
* 由监听入口清空兜底,不会串单。
* <p>
* ponytail: 同步推送占用审批请求线程,若 BFI 网关大面积变慢导致审批接口可感知变慢,
* 再把路由调用提交到守护线程池(MkProcessMessageService 已有同款裸 ScheduledExecutorService 模式)。
* <p>
* 域开关(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<String> 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);
}
}
@@ -48,6 +48,9 @@
<result column="current_processor" property="currentProcessor"/>
<result column="approved_time" property="approvedTime"/>
<result column="review_status" property="reviewStatus"/>
<result column="kingdee_status" property="kingdeeStatus"/>
<result column="kingdee_fail_reason" property="kingdeeFailReason"/>
<result column="kingdee_sync_time" property="kingdeeSyncTime"/>
</resultMap>
<select id="selectCustomerArchivePage" resultMap="customerArchiveResultMap">
@@ -96,6 +99,10 @@
current_node,
current_processor,
approved_time,
kingdee_status,
kingdee_fail_reason,
kingdee_sync_time
approved_time,
(
SELECT s.review_status
FROM blade_customer_credit_score s
@@ -0,0 +1,32 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments from this software for such purposes.
* Copyright of this software remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.springblade.transport.pojo.entity.InvoiceApplicationInvoice;
/**
* 开票申请金蝶发票结果 Mapper
*
* @author Chill
*/
public interface InvoiceApplicationInvoiceMapper extends BaseMapper<InvoiceApplicationInvoice> {
}
@@ -0,0 +1,39 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.springblade.transport.pojo.entity.KingdeeAttachmentSync;
/**
* 金蝶附件同步 Mapper
*
* @author Chill
*/
@Mapper
public interface KingdeeAttachmentSyncMapper extends BaseMapper<KingdeeAttachmentSync> {
}
@@ -0,0 +1,18 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement obtained after purchasing a license from BladeX.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.springblade.transport.pojo.entity.KingdeeBillLock;
/** 金蝶单据锁定记录 Mapper @author Chill */
@Mapper
public interface KingdeeBillLockMapper extends BaseMapper<KingdeeBillLock> {
}
@@ -26,8 +26,12 @@
package org.springblade.transport.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.springblade.transport.pojo.dto.KingdeeInvoicePoolQueryDTO;
import org.springblade.transport.pojo.entity.KingdeeInvoicePool;
import org.springblade.transport.pojo.vo.KingdeeInvoicePoolVO;
/**
* 金蝶进项发票票据池 Mapper 接口
@@ -36,4 +40,15 @@ import org.springblade.transport.pojo.entity.KingdeeInvoicePool;
*/
@Mapper
public interface KingdeeInvoicePoolMapper extends BaseMapper<KingdeeInvoicePool> {
/**
* 金蝶票据池镜像统一分页查询:仅出可用票(剩余可用金额>0 且未使用),认领/登记标记由 EXISTS 子查询直出
*
* @param page 分页参数
* @param query 统一查询条件(已在上层完成 trim 与日期格式校验)
* @param tenantId 当前租户
* @return 统一视图分页
*/
IPage<KingdeeInvoicePoolVO> selectInvoicePoolPage(IPage<KingdeeInvoicePoolVO> page,
@Param("query") KingdeeInvoicePoolQueryDTO query, @Param("tenantId") String tenantId);
}
@@ -0,0 +1,86 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="org.springblade.transport.mapper.KingdeeInvoicePoolMapper">
<!-- 已认领:任一有效正式结算单发票行出现过(结算单作废不删发票行,须关联主单剔除作废与已删;自定义 SQL 无租户插件,手工带租户与逻辑删除) -->
<sql id="claimedExists">
EXISTS (SELECT 1 FROM blade_formal_settlement_invoice c
WHERE c.is_deleted = 0 AND c.tenant_id = p.tenant_id AND c.invoice_no = p.invoice_no
AND EXISTS (SELECT 1 FROM blade_formal_settlement s
WHERE s.id = c.formal_settlement_id AND s.is_deleted = 0
AND s.approval_status != 'voided'))
</sql>
<!-- 已登记:存在非作废收票登记 -->
<sql id="registeredExists">
EXISTS (SELECT 1 FROM blade_invoice_receipt r
WHERE r.is_deleted = 0 AND r.tenant_id = p.tenant_id
AND r.approval_status != 'voided' AND r.invoice_no = p.invoice_no)
</sql>
<!-- 统一视图列:价税合计与可认领/默认认领金额同源;剩余可用金额为金蝶可用税额口径(仅展示);镜像 invoice_type 落库即中文名 -->
<sql id="invoicePoolVoColumns">
p.id, p.invoice_no, p.serial_no,
DATE_FORMAT(p.invoice_date, '%Y-%m-%d') AS invoice_date,
p.invoice_type, p.tax_rate, p.invoice_amount, p.tax_amount,
p.receiver_name, p.buyer_tax_no, p.issuer_name, p.saler_tax_no,
p.bank_name, p.bank_account, p.issuing_bank, p.phone,
p.customer_emails, p.department_emails,
p.kingdee_bill_no, p.kingdee_status, p.check_status, p.attachments_json,
p.invoice_amount AS available_invoice_amount,
p.invoice_amount AS matched_amount,
p.available_amount AS available_tax_amount,
<include refid="claimedExists"/> AS claimed,
<include refid="registeredExists"/> AS registered
</sql>
<!-- 选票只出可用票:剩余可用金额>0 且金额使用状态未使用(与实时方案 isUsablePoolRow 同口径,本地化执行) -->
<sql id="usableFilter">
AND p.available_amount &gt; 0 AND p.amount_use_status = '0'
</sql>
<select id="selectInvoicePoolPage" resultType="org.springblade.transport.pojo.vo.KingdeeInvoicePoolVO">
SELECT
<include refid="invoicePoolVoColumns"/>
FROM blade_kingdee_invoice_pool p
WHERE p.is_deleted = 0 AND p.status = 1 AND p.tenant_id = #{tenantId}
<include refid="usableFilter"/>
<if test="query.invoiceNo != null and query.invoiceNo != ''">
AND p.invoice_no LIKE CONCAT('%', #{query.invoiceNo}, '%')
</if>
<if test="query.salerName != null and query.salerName != ''">
AND p.issuer_name LIKE CONCAT('%', #{query.salerName}, '%')
</if>
<if test="query.buyerName != null and query.buyerName != ''">
AND p.receiver_name LIKE CONCAT('%', #{query.buyerName}, '%')
</if>
<if test="query.invoiceType != null and query.invoiceType != ''">
AND p.invoice_type_code = #{query.invoiceType}
</if>
<if test="query.salerTaxNo != null and query.salerTaxNo != ''">
AND p.saler_tax_no = #{query.salerTaxNo}
</if>
<if test="query.buyerTaxNo != null and query.buyerTaxNo != ''">
AND p.buyer_tax_no = #{query.buyerTaxNo}
</if>
<if test="query.invoiceDateStart != null and query.invoiceDateStart != ''">
AND p.invoice_date &gt;= #{query.invoiceDateStart}
</if>
<if test="query.invoiceDateEnd != null and query.invoiceDateEnd != ''">
AND p.invoice_date &lt;= #{query.invoiceDateEnd}
</if>
<if test="query.claimed != null and query.claimed">
AND <include refid="claimedExists"/>
</if>
<if test="query.claimed != null and !query.claimed">
AND NOT <include refid="claimedExists"/>
</if>
<if test="query.registered != null and query.registered">
AND <include refid="registeredExists"/>
</if>
<if test="query.registered != null and !query.registered">
AND NOT <include refid="registeredExists"/>
</if>
ORDER BY p.source_updated_time DESC, p.create_time DESC
</select>
</mapper>
@@ -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 @@
<if test="query.kingdeeBillStatus != null and query.kingdeeBillStatus != ''">
AND c.kingdee_bill_status = #{query.kingdeeBillStatus}
</if>
<if test="query.pushStatus != null and query.pushStatus != ''">
AND f.push_status = #{query.pushStatus}
</if>
</sql>
<select id="selectClaimRecordPage" resultType="org.springblade.transport.pojo.vo.ReceiptClaimRecordVO">
@@ -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
@@ -188,6 +188,29 @@ public interface ICustomerArchiveService extends BaseService<CustomerArchive> {
*/
boolean removeDraft(String ids);
/**
* 推送金蝶(A902-1 复查 + A902-2 保存),幂等:已推送/金蝶已有直接返回
*
* @param id 主键
* @return 更新后的客商档案
*/
CustomerArchive syncKingdee(Long id);
/**
* 定时同步金蝶客商数据:待推送客商自动推送 + 已同步客商使用状态回写
*
* @return 同步汇总说明
*/
String syncKingdeeCustomerData();
/**
* 金蝶侧停用联动:停用客商档案并记录变更(仅当前启用的客商生效)
*
* @param id 主键
* @return 是否执行停用
*/
boolean disableCustomerFromKingdee(Long id);
/**
* 基于启用评分量化表生成评分记录
*
@@ -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<FormalSettlement>
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<FormalSettlementDetailFee> detailFees(Long detailId);
void adjustDetail(PreSettlementDetailAdjustRequest request);
void refreshPaymentSummary(Long settlementId);
/**
* 批量刷新收款汇总:固定数次查询完成 N 张结算单的重算,避免逐单刷新的 N+1
*
* @param settlementIds 结算单id集合
*/
void refreshPaymentSummaries(Collection<Long> settlementIds);
void refreshPaymentSummariesForPreSettlement(Long preSettlementId);
String applyPayment(FormalSettlementPaymentRequest request);
List<String> applyPayments(FormalSettlementBatchPaymentRequest request);
@@ -55,4 +55,41 @@ public interface IInvoiceApplicationService extends BaseService<InvoiceApplicati
void returnBill(InvoiceApplicationStatusRequest request);
void voidBill(InvoiceApplicationStatusRequest request);
String syncKingdee(Long id);
/**
* 金蝶删单回调作废(unLockNotifyBusiData DELETE 分发):作废开票申请、同步信息还原
* (kingdeeStatus→unsynced,清单据编号/同步时间)并刷新关联结算单开票状态,操作人取金蝶回调报文
*
* @param id 开票申请id
* @param operator 金蝶侧操作人
*/
void voidByKingdee(Long id, String operator);
/**
* 单据级事务执行金蝶推送(由 syncKingdee 经自代理调用):
* A303-1 保存并回写开票申请单单据编号与星瀚内码(kingdeeBillId)
*
* @param id 开票申请id
* @return 金蝶开票申请单编号
*/
String doSyncKingdee(Long id);
/**
* 独立事务落"生成失败"状态并记失败原因(在推送事务回滚、行锁释放后执行,与付款/收票口径统一);
* 仅更新审批通过且未同步/未开票的单据,其余自动跳过,保持原状态
*
* @param id 开票申请id
* @param reason 失败原因(写操作流水留档)
*/
void markKingdeeSyncFailed(Long id, String reason);
/**
* 重推金蝶生成失败(kingdeeStatus=failed 且审批通过)的开票申请:逐单独立成败,失败不中断。
* 只扫 failed 不扫 unsynced——存量"审批通过但从未推送"的历史单不自动补推,仍走手动按钮
*
* @return 执行摘要
*/
String retryKingdeeFailed();
String syncKingdeeInvoiceResult();
}
@@ -26,13 +26,16 @@
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.InvoiceReceiptSaveRequest;
import org.springblade.transport.pojo.dto.InvoiceReceiptStatusRequest;
import org.springblade.transport.pojo.dto.KingdeeInvoicePoolQueryDTO;
import org.springblade.transport.pojo.entity.InvoiceReceipt;
import org.springblade.transport.pojo.entity.KingdeeInvoicePool;
import org.springblade.transport.pojo.vo.InvoiceReceiptVO;
import org.springblade.transport.pojo.vo.KingdeeInvoicePoolVO;
import java.util.Collection;
import java.util.List;
import java.util.Map;
@@ -47,7 +50,43 @@ public interface IInvoiceReceiptService extends BaseService<InvoiceReceipt> {
InvoiceReceiptVO detail(Long id);
List<KingdeeInvoicePool> invoicePool(String keyword);
/**
* 金蝶票据池镜像统一查询(本地真分页,收票登记选票与正式结算单认领共用)。
* <p>仅出可用票(剩余可用金额>0 且金额使用状态未用),认领/登记三态过滤在 SQL 内直出;
* 票号精确查本地未命中时窄查一次 A801-1 回源入库后重查,兜底增量窗口缝隙内的当日新票。
*
* @param query 统一查询条件
* @return 镜像统一视图分页
*/
IPage<KingdeeInvoicePoolVO> invoicePoolPage(KingdeeInvoicePoolQueryDTO query);
/**
* 金蝶全票池定时同步(瘦身版):滚动增量窗口分页拉取 A801-1 + 镜像批量全列 upsert。
* <p>无绑定回填/漏绑补偿/白名单(保存即写存根绑定,登记侧无需定时绑定);
* single-flight 锁防多副本并发,手动触发与定时任务共用。
*
* @return 执行摘要
*/
String syncKingdeeInvoicePool();
/**
* 结算单认领确认闸门:按票号集合逐张重查 A801-1 并按选票同口径复核可用性,
* 选中到确认之间票被认领/使用/红冲在此拦截(与收票保存重查同闸门)
*
* @param invoiceNos 待认领发票号码集合
*/
void assertInvoicesClaimable(Collection<String> invoiceNos);
/**
* 金蝶票池发票文件下载(A801-2 流式回源,前端查看/下载代理端点)。
* <p>按发票号定位金蝶侧文件:本地镜像优先(票号同租户唯一,租户隔离由 MP 插件保证),
* 镜像未落库或无文件时实时回源 A801-1 取 downloadUrl;经 A801-2 下载后流式透传浏览器,
* 不落盘不进堆。写流前的业务失败以 JSON(R.fail)写入响应,前端按 Content-Type 识别提示。
*
* @param invoiceNo 发票号码
* @param response 响应对象(文件流写出口)
*/
void downloadPoolAttachment(String invoiceNo, HttpServletResponse response);
List<Map<String, Object>> settlementCandidates(String keyword, Long receiptId);
@@ -65,5 +104,40 @@ public interface IInvoiceReceiptService extends BaseService<InvoiceReceipt> {
void voidBill(InvoiceReceiptStatusRequest request);
String syncKingdee(Long id);
/**
* 批量推送金蝶收票单(图05 环节②③:A303-2 收票单批量保存 + A302-6 收票指定应付)。
* <p>逐单独立成败:前置校验(审批通过、已绑定镜像、查验通过、恰好分摊一张应付结算单)→
* A102-4 预检财务应付单已生成 → A801-3 幂等预检(命中跳过保存续推指定)→ A303-2 保存 →
* 立即 A302-6 指定应付;成功置 assigned 并锁单,失败置 failed 提示人工处理后重推。
*
* @param ids 收票登记id列表
* @return 执行摘要(逐单失败原因)
*/
String pushKingdeeReceivedInvoice(List<Long> ids);
/**
* 自动推送路径批量推送金蝶收票单(审批通过事件 / 定时重推专用):
* 前置拦截(未绑定镜像/查验未通过/前置应付单未生成等)一并置 failed + 原因,纳入定时重推循环
*
* @param ids 收票登记id列表
* @param autoSync 自动推送标记(前置拦截置 failed)
* @return 执行摘要(逐单失败原因)
*/
String pushKingdeeReceivedInvoice(List<Long> ids, boolean autoSync);
/**
* 重推金蝶推送失败(kingdeePushStatus=failed 且审批通过)的收票登记:逐单独立成败,失败不中断。
* 只扫 failed 不扫 unsynced——存量"审批通过但从未推送"的历史单不自动补推,仍走手动按钮
*
* @return 执行摘要
*/
String retryKingdeeFailed();
/**
* 定时回写金蝶收票单结果(A801-3):金蝶侧删除/红冲/作废 → 回收为 failed 提示人工(不自动作废登记);
* pushed(保存成功后中断)→ 指定补偿;assigned 仅补登缺失的收票单号
*
* @return 执行摘要
*/
String syncKingdeeReceivedInvoiceResult();
}
@@ -0,0 +1,37 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* 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<KingdeeAttachmentSync> {
}
@@ -0,0 +1,78 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement obtained after purchasing a license from BladeX.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.service;
import com.baomidou.mybatisplus.extension.service.IService;
import org.springblade.thirdparty.bfi.pojo.dto.BfiBillLockDTO;
import org.springblade.transport.pojo.entity.KingdeeBillLock;
import java.util.List;
import java.util.Map;
/**
* 金蝶单据锁定记录服务
*
* @author Chill
*/
public interface IKingdeeBillLockService extends IService<KingdeeBillLock> {
/**
* 推送金蝶成功后锁单(由各推送链路在主单事务提交后调用):
* 每张金蝶目标单一行锁记录(先落 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<String> targetBillNos);
/**
* 金蝶解锁回调(unLockNotifyBusiData):校验来源/目标系统与 lockAction,
* targetBillNo 支持逗号拼接批量,逐张按 targetBillType+targetBillNo 定位最新 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 -&gt; 最新锁记录,无活跃锁的源单不在 Map 中
*/
Map<Long, KingdeeBillLock> mapActiveLocks(String sourceBillType, List<Long> sourceBillIds);
/**
* 锁定补偿:扫 failed 且 try_count 未达重试上限(RETRY_MAX_COUNT)的锁记录,逐张重调 lockBusiData
* (接口 1对1:单次一个源单号对一个金蝶单号,单张成败互不影响;定时任务定时触发,亦可人工触发);
* 成功翻 locked,失败 try_count+1 并记失败原因;
* 计数烧满上限后停止自动补偿(人工重推「同步金蝶」会重置计数重新接管)。锁记录落库均为批量更新
*
* @return 补偿摘要(待补偿/成功张数),供调用方记日志或页面提示
*/
String retryFailedLocks();
}
@@ -21,6 +21,8 @@ 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;
@@ -41,5 +43,85 @@ public interface IPaymentApplicationService extends BaseService<PaymentApplicati
void returnBill(PaymentApplicationStatusRequest request);
void voidBill(PaymentApplicationStatusRequest request);
String syncKingdee(Long id);
/**
* 同步单笔付款申请到金蝶(携带批量共享上下文,消除重复档案查询),编排层无事务:
* 单据级事务在 {@link #doSyncKingdee(Long, KingdeeSyncContext)} 中,失败状态落库在事务回滚后执行
*
* @param id 付款申请id
* @param context 批量共享上下文(档案缓存),单笔调用传 {@link KingdeeSyncContext#empty()}
* @return 金蝶单据id(存量数据兜底返回金蝶单据编号)
*/
String syncKingdee(Long id, KingdeeSyncContext context);
List<String> syncKingdeeBatch(List<Long> 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);
}
@@ -45,5 +45,12 @@ public interface IReceiptClaimRecordService extends BaseService<ReceiptClaim> {
void updateAttachments(ReceiptClaimAttachmentsRequest request);
String voidClaim(Long id);
void voidClaim(Long id);
/**
* A301-1 收款单查询回写认领结果:按来源单据编码匹配金蝶收款单,推进金蝶单据状态
*
* @return 回写条数
*/
int refreshKingdeeResult();
}
@@ -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<KingdeeReceiptFlow> {
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();
}
@@ -70,7 +70,8 @@ public interface IReceivablePayableDetailService extends BaseService<ReceivableP
void transferSettlement(ReceivablePayableTransferRequest request);
IPage<Map<String, Object>> transferCandidates(IPage<?> page, String contractName, String batchNo,
IPage<Map<String, Object>> transferCandidates(IPage<?> page, String documentNo, String contractNo,
String contractName, String batchNo,
String generateStartDate, String generateEndDate, String settlementBillType,
String settlementType);
@@ -0,0 +1,56 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* 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)。
* <p>单笔同步使用 {@link #empty()},各缓存为空时自动回退为逐单查询,行为与原逻辑一致。</p>
*
* @author Chill
*/
public record KingdeeSyncContext(Map<Long, ContractManage> contractCache,
Map<String, String> customerCodeCache, Map<Long, ProjectApply> projectCache,
Map<String, String> apBillNoCache,
Map<Long, List<FormalSettlementDetail>> settlementDetailCache,
Map<Long, FormalSettlement> settlementCache,
Map<Long, CustomerReceiptAccount> receiptAccountCache,
Map<String, String> feeCategoryByName) {
/** 空上下文:缓存可写但初始为空,首条记录命中后全批复用 */
public static KingdeeSyncContext empty() {
return new KingdeeSyncContext(new HashMap<>(), new HashMap<>(), new HashMap<>(),
new HashMap<>(), new HashMap<>(), new HashMap<>(), new HashMap<>(), new HashMap<>());
}
}
@@ -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<BillPaymentMapper, BillPayment>
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 BaseServiceImpl<BillPaymentMapper, B
public Long saveDraft(BillPaymentSaveRequest request) {
validateRequest(request);
BillPayment entity = request.getId() == null ? new BillPayment() : locked(request.getId());
if (entity.getId() != null && !List.of(DRAFT, RETURNED).contains(entity.getApprovalStatus())) {
if (entity.getId() != null && !List.of(TransportBusiConstant.DRAFT, TransportBusiConstant.RETURNED).contains(entity.getApprovalStatus())) {
throw new ServiceException("当前状态不可编辑");
}
Long deptId = Func.firstLong(AuthUtil.getDeptId());
@@ -119,7 +115,7 @@ public class BillPaymentServiceImpl extends BaseServiceImpl<BillPaymentMapper, B
validateLedgerAmount(ledger, usedAmount, deptId);
if (entity.getId() == null) {
entity.setPaymentNo(nextNo());
entity.setApprovalStatus(DRAFT);
entity.setApprovalStatus(TransportBusiConstant.DRAFT);
entity.setCurrentNode("草稿");
}
entity.setBillLedgerId(ledger.getId());
@@ -141,7 +137,7 @@ public class BillPaymentServiceImpl extends BaseServiceImpl<BillPaymentMapper, B
@Transactional(rollbackFor = Exception.class)
public void removeDraft(Long id) {
BillPayment entity = locked(id);
if (!DRAFT.equals(entity.getApprovalStatus())) {
if (!TransportBusiConstant.DRAFT.equals(entity.getApprovalStatus())) {
throw new ServiceException("仅草稿状态的汇票付款允许删除");
}
removeById(entity);
@@ -151,11 +147,11 @@ public class BillPaymentServiceImpl extends BaseServiceImpl<BillPaymentMapper, B
@Transactional(rollbackFor = Exception.class)
public void submit(BillPaymentStatusRequest request) {
BillPayment entity = locked(requiredId(request));
if (!List.of(DRAFT, RETURNED).contains(entity.getApprovalStatus())) {
if (!List.of(TransportBusiConstant.DRAFT, TransportBusiConstant.RETURNED).contains(entity.getApprovalStatus())) {
throw new ServiceException("当前状态不允许提交");
}
validateStoredAmount(entity);
entity.setApprovalStatus(REVIEWING);
entity.setApprovalStatus(TransportBusiConstant.REVIEWING);
entity.setCurrentNode("财务审核");
entity.setCurrentProcessor(AuthUtil.getUserName());
updateById(entity);
@@ -165,14 +161,14 @@ public class BillPaymentServiceImpl extends BaseServiceImpl<BillPaymentMapper, B
@Transactional(rollbackFor = Exception.class)
public void approve(BillPaymentStatusRequest request) {
BillPayment entity = locked(requiredId(request));
if (!REVIEWING.equals(entity.getApprovalStatus())) {
if (!TransportBusiConstant.REVIEWING.equals(entity.getApprovalStatus())) {
throw new ServiceException("仅审批中的汇票付款允许审核");
}
BillLedger ledger = lockedBill(entity.getBillLedgerId());
validateLedgerAmount(ledger, entity.getUsedAmount(), entity.getDeptId());
BillLedgerUsage exists = usageMapper.selectOne(Wrappers.<BillLedgerUsage>lambdaQuery()
.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 BaseServiceImpl<BillPaymentMapper, B
usage.setUsedAmount(usedAmount);
usage.setUseDeptId(entity.getDeptId());
usage.setUseDeptName(entity.getDeptName());
usage.setUsageStatus(APPROVED);
usage.setUsageStatus(TransportBusiConstant.APPROVED);
usage.setStatus(1);
usageMapper.insert(usage);
entity.setAvailableBalance(ledger.getAvailableBalance());
entity.setApprovalStatus(APPROVED);
entity.setApprovalStatus(TransportBusiConstant.APPROVED);
entity.setCurrentNode("审批通过");
entity.setCurrentProcessor(AuthUtil.getUserName());
updateById(entity);
@@ -202,10 +198,10 @@ public class BillPaymentServiceImpl extends BaseServiceImpl<BillPaymentMapper, B
@Transactional(rollbackFor = Exception.class)
public void returnBill(BillPaymentStatusRequest request) {
BillPayment entity = locked(requiredId(request));
if (!REVIEWING.equals(entity.getApprovalStatus())) {
if (!TransportBusiConstant.REVIEWING.equals(entity.getApprovalStatus())) {
throw new ServiceException("仅审批中的汇票付款允许驳回");
}
entity.setApprovalStatus(RETURNED);
entity.setApprovalStatus(TransportBusiConstant.RETURNED);
entity.setCurrentNode("已驳回");
entity.setCurrentProcessor(AuthUtil.getUserName());
entity.setRemark(request.getReason() == null ? entity.getRemark() : limit(request.getReason(), 200, "驳回原因"));
@@ -216,12 +212,12 @@ public class BillPaymentServiceImpl extends BaseServiceImpl<BillPaymentMapper, B
@Transactional(rollbackFor = Exception.class)
public void voidBill(BillPaymentStatusRequest request) {
BillPayment entity = locked(requiredId(request));
if (!APPROVED.equals(entity.getApprovalStatus())) {
if (!TransportBusiConstant.APPROVED.equals(entity.getApprovalStatus())) {
throw new ServiceException("仅审批通过的汇票付款允许作废");
}
BillLedgerUsage usage = usageMapper.selectOne(Wrappers.<BillLedgerUsage>lambdaQuery()
.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<BillPaymentMapper, B
usage.setUsageStatus(RELEASED);
usageMapper.updateById(usage);
entity.setAvailableBalance(ledger.getAvailableBalance());
entity.setApprovalStatus(VOIDED);
entity.setApprovalStatus(TransportBusiConstant.VOIDED);
entity.setCurrentNode("已作废");
entity.setCurrentProcessor(AuthUtil.getUserName());
entity.setRemark(request.getReason() == null ? entity.getRemark() : limit(request.getReason(), 200, "作废原因"));
@@ -28,6 +28,7 @@ import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.toolkit.Db;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springblade.core.log.exception.ServiceException;
@@ -39,9 +40,21 @@ import org.springblade.core.tenant.annotation.TenantIgnore;
import org.springblade.core.tool.jackson.JsonUtil;
import org.springblade.core.tool.utils.BeanUtil;
import org.springblade.core.tool.utils.Func;
import org.springblade.system.pojo.entity.UserCustomerScope;
import org.springblade.thirdparty.bfi.constant.BfiConstant;
import org.springblade.thirdparty.bfi.pojo.dto.BfiCustomerQueryDTO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultErrorVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultItemVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiBillSaveResultVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiBlacklistVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiCustomerVO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiPageVO;
import org.springblade.thirdparty.bfi.service.IBfiService;
import org.springblade.thirdparty.bfi.util.BfiUtil;
import org.springblade.system.cache.SysCache;
import org.springblade.system.cache.UserCache;
import org.springblade.transport.excel.CustomerArchiveExcel;
import org.springblade.transport.config.BfiProperties;
import org.springblade.transport.mapper.CreditRatingStandardMapper;
import org.springblade.transport.mapper.CreditScoreCategoryMapper;
import org.springblade.transport.mapper.CreditScoreItemMapper;
@@ -71,17 +84,17 @@ import org.springblade.transport.pojo.entity.CustomerInvoiceContact;
import org.springblade.transport.pojo.entity.CustomerInvoiceInfo;
import org.springblade.transport.pojo.entity.CustomerReceiptAccount;
import org.springblade.transport.pojo.entity.PaymentApplication;
import org.springblade.transport.pojo.vo.CreditRatingStandardVO;
import org.springblade.transport.pojo.vo.CustomerArchiveVO;
import org.springblade.transport.pojo.vo.CustomerChangeRecordVO;
import org.springblade.transport.pojo.vo.CustomerContactVO;
import org.springblade.transport.pojo.vo.CustomerCreditScoreDetailVO;
import org.springblade.transport.pojo.vo.CustomerCreditScoreVO;
import org.springblade.transport.pojo.vo.CreditRatingStandardVO;
import org.springblade.transport.pojo.vo.CustomerInvoiceContactVO;
import org.springblade.transport.pojo.vo.CustomerInvoiceInfoVO;
import org.springblade.transport.pojo.vo.CustomerReceiptAccountVO;
import org.springblade.transport.service.ICustomerArchiveService;
import org.springblade.system.pojo.entity.UserCustomerScope;
import org.springblade.transport.support.KingdeeSyncLockSupport;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -101,6 +114,7 @@ import java.util.Objects;
import java.util.Set;
import java.util.TreeMap;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
* 客商档案 服务实现类
@@ -112,17 +126,76 @@ import java.util.stream.Collectors;
@RequiredArgsConstructor
public class CustomerArchiveServiceImpl extends BaseServiceImpl<CustomerArchiveMapper, CustomerArchive> 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 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 +220,13 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl<CustomerArchiveM
private final CreditRatingStandardMapper standardMapper;
private final UserCustomerScopeMapper userCustomerScopeMapper;
private final PaymentApplicationMapper paymentApplicationMapper;
private final IBfiService bfiService;
private final CustomerBfiConverter customerBfiConverter;
private final KingdeeSyncLockSupport kingdeeSyncLockSupport;
private final BfiProperties bfiProperties;
/** 金蝶客商数据同步 single-flight 锁键前缀(多副本定时任务与手动触发共用) */
private static final String CUSTOMER_SYNC_LOCK_PREFIX = "transport:customer-archive:kingdee-customer-sync:";
@Override
public IPage<CustomerArchiveVO> selectCustomerArchivePage(IPage<CustomerArchiveVO> page, CustomerArchiveVO customer) {
@@ -222,6 +302,7 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl<CustomerArchiveM
validateBase(detail);
validateScoreForApproval(detail);
validateApproval(detail);
validateBlacklist(detail);
CustomerArchive before = Objects.requireNonNull(BeanUtil.copyProperties(detail, CustomerArchive.class));
CustomerArchive after = copyCustomer(before);
after.setApprovalStatus(APPROVAL_REVIEWING);
@@ -417,6 +498,365 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl<CustomerArchiveM
return updateById(after);
}
@Override
@Transactional(rollbackFor = Exception.class)
public CustomerArchive syncKingdee(Long id) {
CustomerArchive customer = ensureCustomerAccessible(id);
if (!Objects.equals(customer.getApprovalStatus(), APPROVAL_APPROVED)) {
throw new ServiceException("仅审批通过的客商允许推送金蝶");
}
if (List.of(KINGDEE_STATUS_SYNCED, KINGDEE_STATUS_EXIST).contains(customer.getKingdeeStatus())) {
// 幂等:已推送或金蝶已有档案,直接返回
return customer;
}
CustomerArchive after = copyCustomer(customer);
after.setKingdeeSyncTime(LocalDateTime.now());
BfiCustomerVO kingdeeCustomer = queryKingdeeCustomer(customer.getUnifiedCreditCode());
if (kingdeeCustomer != null && BfiConstant.BILL_STATUS_AUDITED.equals(kingdeeCustomer.getStatus())) {
// 金蝶已有已审核档案:跳过推送
after.setKingdeeStatus(KINGDEE_STATUS_EXIST);
after.setKingdeeFailReason(null);
addChangeRecord(id, "金蝶已存在该客商档案,跳过推送", customer, after);
updateById(after);
return after;
}
if (kingdeeCustomer != null) {
// 金蝶侧暂存/已提交:审核中,置失败待重推
after.setKingdeeStatus(KINGDEE_STATUS_FAILED);
after.setKingdeeFailReason("金蝶侧该客商审核中,请稍后重推");
addChangeRecord(id, "推送金蝶失败:金蝶侧客商审核中", customer, after);
updateById(after);
return after;
}
pushCustomerToKingdee(id, customer, after, detail(id));
updateById(after);
return after;
}
/**
* 组装 A902-2 报文推送金蝶并回写协同状态;detail 由调用方准备,
* 定时同步批量组装报文数据,避免逐条 detail 查询的 N+1 问题
*/
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);
}
} 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);
}
}
/**
* A902-1 按统一社会信用代码查询金蝶客商,无数据返回 null
*/
private BfiCustomerVO queryKingdeeCustomer(String unifiedCreditCode) {
BfiCustomerQueryDTO param = new BfiCustomerQueryDTO();
param.setPageNo(1);
param.setPageSize(1);
param.setSocietycreditcode(List.of(unifiedCreditCode));
BfiPageVO<BfiCustomerVO> page = bfiService.queryCustomer(param);
List<BfiCustomerVO> 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<CustomerReceiptAccountVO> receiptAccounts) {
CustomerArchiveVO detail = Objects.requireNonNull(BeanUtil.copyProperties(customer, CustomerArchiveVO.class));
detail.setReceiptAccounts(receiptAccounts);
return detail;
}
/**
* 定时同步变更集中落库:档案批量更新 + 变更记录批量插入,失败仅记录日志,不影响汇总返回
*/
private void flushSyncChanges(List<CustomerArchive> updateList, List<CustomerChangeRecord> 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<CustomerArchive> approvedList = list(Wrappers.<CustomerArchive>lambdaQuery()
.eq(CustomerArchive::getApprovalStatus, APPROVAL_APPROVED)
.orderByAsc(CustomerArchive::getId));
// ① 待推送客商:未推送/推送失败,单条失败不中断批次
List<CustomerArchive> pendingList = approvedList.stream()
.filter(customer -> KINGDEE_STATUS_NONE.equals(customer.getKingdeeStatus())
|| KINGDEE_STATUS_FAILED.equals(customer.getKingdeeStatus()))
.toList();
// ② 停用联动候选:已同步/金蝶已有且启用的客商
List<CustomerArchive> 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<CustomerArchive> 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<CustomerArchive> updateList = new ArrayList<>();
List<CustomerChangeRecord> changeRecords = new ArrayList<>();
// 批量 A902-1 复查待推送客商:金蝶已审核直接建档(exist)、审核中置 failed,仅不存在金蝶档案的才逐条推送,避免逐条复查的 N+1 调用
Map<String, BfiCustomerVO> pendingKingdeeMap = queryKingdeeCustomerMap(pendingList.stream()
.map(CustomerArchive::getUnifiedCreditCode)
.filter(Func::isNotEmpty)
.map(code -> code.trim().toUpperCase())
.distinct()
.toList());
List<CustomerArchive> 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<Long, List<CustomerReceiptAccountVO>> receiptAccountsMap = loadReceiptAccountsMap(pushList.stream()
.map(CustomerArchive::getId)
.toList());
for (CustomerArchive customer : pushList) {
try {
CustomerArchiveVO detail = buildSyncArchiveVO(customer,
receiptAccountsMap.getOrDefault(customer.getId(), new ArrayList<>()));
CustomerArchive after = copyCustomer(customer);
after.setKingdeeSyncTime(LocalDateTime.now());
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);
}
}
// ②③ 共用:对已推送(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<String> reviewCreditCodes = Stream.concat(syncedList.stream(), enabledList.stream())
.map(CustomerArchive::getUnifiedCreditCode)
.filter(Func::isNotEmpty)
.map(code -> code.trim().toUpperCase())
.distinct()
.toList();
Map<String, BfiCustomerVO> 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<String, BfiCustomerVO> queryKingdeeCustomerMap(List<String> creditCodes) {
Map<String, BfiCustomerVO> result = new HashMap<>();
for (int start = 0; start < creditCodes.size(); start += KINGDEE_CUSTOMER_QUERY_BATCH) {
List<String> 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<BfiCustomerVO> 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 +925,7 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl<CustomerArchiveM
applyFixedOwnerDept(customer);
customer.setAccessType(Func.isEmpty(customer.getAccessType()) ? ACCESS_TEMPORARY : customer.getAccessType());
customer.setApprovalStatus(Func.isEmpty(customer.getApprovalStatus()) ? APPROVAL_DRAFT : customer.getApprovalStatus());
customer.setKingdeeStatus(Func.isEmpty(customer.getKingdeeStatus()) ? KINGDEE_STATUS_NONE : customer.getKingdeeStatus());
if (Func.isEmpty(customer.getStatus())) {
customer.setStatus(STATUS_ENABLED);
}
@@ -591,6 +1032,47 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl<CustomerArchiveM
}
}
/**
* 国资委黑名单校验(A801-5):客商提审进入准入审批前实时查询金蝶黑名单,
* 以客商发票信息中的纳税人识别号(number)作为查询条件按税号精确匹配;命中禁止交易类(A)硬拦截,
* 风险警示类(B)默认放行告警、可经 {@code thirdparty.bfi.blacklist.blockRiskWarning} 配置拦截,
* 其他类别放行告警;金蝶黑名单查询异常时向上抛出,阻断提审
*
* @param customer 客商
*/
private void validateBlacklist(CustomerArchiveVO customer) {
List<CustomerInvoiceInfoVO> invoices = customer.getInvoices();
if (Func.isEmpty(invoices)) {
return;
}
List<String> taxNos = invoices.stream()
.map(CustomerInvoiceInfoVO::getTaxNo)
.filter(Func::isNotEmpty)
.map(taxNo -> taxNo.trim().toUpperCase())
.distinct()
.toList();
if (Func.isEmpty(taxNos)) {
return;
}
List<BfiBlacklistVO> 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<CustomerArchive> unifiedCreditCodeQuery = Wrappers.<CustomerArchive>lambdaQuery()
.eq(CustomerArchive::getUnifiedCreditCode, customer.getUnifiedCreditCode())
@@ -854,12 +1336,26 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl<CustomerArchiveM
}
private List<CustomerReceiptAccountVO> loadReceiptAccounts(Long customerId) {
return loadReceiptAccountsMap(List.of(customerId)).getOrDefault(customerId, new ArrayList<>());
}
/**
* 按客商ID集合批量查询收款账户(键为客商ID),供定时同步批量组装金蝶报文等场景使用,避免逐条查询
*
* @param customerIds 客商ID集合
* @return 客商ID -> 收款账户列表(默认一条扣款排序)
*/
private Map<Long, List<CustomerReceiptAccountVO>> loadReceiptAccountsMap(List<Long> customerIds) {
if (Func.isEmpty(customerIds)) {
return Map.of();
}
return receiptAccountMapper.selectList(Wrappers.<CustomerReceiptAccount>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<CustomerInvoiceInfoVO> loadInvoices(Long customerId) {
@@ -1306,11 +1802,10 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl<CustomerArchiveM
}
private void addChangeRecord(Long customerId, String content, CustomerArchive before, CustomerArchive after) {
Map<String, Object> beforeSnapshot = customerSnapshot(before);
Map<String, Object> 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 +1814,35 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl<CustomerArchiveM
Map<String, Object> 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<String, Object> beforeSnapshot,
/**
* 收集变更记录供定时同步集中批量插入(无实质变更不收集),避免逐条 insert
*/
private void collectChangeRecord(List<CustomerChangeRecord> 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<String, Object> beforeSnapshot = customerSnapshot(before);
Map<String, Object> afterSnapshot = customerSnapshot(after);
addDetailSnapshot(beforeSnapshot, null);
addDetailSnapshot(afterSnapshot, null);
return buildChangeRecord(customerId, content, beforeSnapshot, afterSnapshot);
}
private CustomerChangeRecord buildChangeRecord(Long customerId, String content, Map<String, Object> beforeSnapshot,
Map<String, Object> afterSnapshot) {
for (String field : afterSnapshot.keySet()) {
beforeSnapshot.putIfAbsent(field, null);
@@ -1343,7 +1863,7 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl<CustomerArchiveM
}
}
if (changedFields.isEmpty()) {
return;
return null;
}
CustomerChangeRecord record = new CustomerChangeRecord();
record.setId(IdWorker.getId());
@@ -1355,7 +1875,7 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl<CustomerArchiveM
record.setAfterData(JsonUtil.toJson(afterData));
record.setChangeUserName(AuthUtil.getUserName());
record.setStatus(STATUS_ENABLED);
changeRecordMapper.insert(record);
return record;
}
private boolean isSameSnapshotValue(Object beforeValue, Object afterValue) {
@@ -1486,6 +2006,10 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl<CustomerArchiveM
snapshot.put("当前节点", customer.getCurrentNode());
snapshot.put("当前处理人", customer.getCurrentProcessor());
snapshot.put("审核通过时间", customer.getApprovedTime());
// 金蝶协同字段纳入快照:金蝶推送/审核确认/失败重推等状态变更需要落变更记录,否则前后快照一致会被判定为无实质变更而跳过
snapshot.put("金蝶协同状态", customer.getKingdeeStatus());
snapshot.put("金蝶失败原因", customer.getKingdeeFailReason());
snapshot.put("金蝶同步时间", customer.getKingdeeSyncTime());
snapshot.put("状态", customer.getStatus());
return snapshot;
}
@@ -1572,6 +2096,7 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl<CustomerArchiveM
excel.setLegalPerson(customer.getLegalPerson());
excel.setCreateUserName(UserCache.getUserRealName(customer.getCreateUser()));
excel.setCreateTime(customer.getCreateTime());
excel.setKingdeeStatusName(kingdeeStatusName(customer.getKingdeeStatus()));
return excel;
}
@@ -1592,6 +2117,19 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl<CustomerArchiveM
return "草稿";
}
private String kingdeeStatusName(String kingdeeStatus) {
if (Objects.equals(kingdeeStatus, KINGDEE_STATUS_SYNCED)) {
return "已推送";
}
if (Objects.equals(kingdeeStatus, KINGDEE_STATUS_EXIST)) {
return "金蝶已有";
}
if (Objects.equals(kingdeeStatus, KINGDEE_STATUS_FAILED)) {
return "推送失败";
}
return "未推送";
}
private void validateLength(String value, int maxLength, String message) {
if (Func.isNotEmpty(value) && value.length() > maxLength) {
throw new ServiceException(message);
@@ -0,0 +1,282 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* 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<BfiCustomerBankEntryDTO> 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<BfiCustomerLinkmanEntryDTO> 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<DictBiz> 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<DictBiz> dictList = DictBizCache.getList("scope_of_business");
Map<String, String> 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;
}
}
@@ -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,24 @@ 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.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 +111,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 +129,31 @@ import java.util.stream.Collectors;
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlementMapper, FormalSettlement>
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;
@@ -135,6 +171,12 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
private final IContractManageService contractManageService;
private final IPreSettlementService preSettlementService;
private final IWaybillService waybillService;
private final KingdeeApBillSyncSupport kingdeeApBillSyncSupport;
private final KingdeeArBillSyncSupport kingdeeArBillSyncSupport;
private final KingdeeAttachmentSyncSupport kingdeeAttachmentSyncSupport;
private final KingdeeSyncLockSupport kingdeeSyncLockSupport;
private final ApplicationEventPublisher eventPublisher;
private final BfiProperties bfiProperties;
@Override
public IPage<FormalSettlementVO> selectPage(IPage<FormalSettlement> page, FormalSettlementVO query) {
@@ -163,13 +205,66 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
if (ids.isEmpty()) wrapper.eq(FormalSettlement::getId, -1L); else wrapper.in(FormalSettlement::getId, ids);
}
IPage<FormalSettlement> result = page(page, wrapper.orderByDesc(FormalSettlement::getCreateTime));
return result.convert(this::toVO);
IPage<FormalSettlementVO> 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<FormalSettlementVO> rows) {
if (Func.isEmpty(rows)) {
return;
}
Map<Long, KingdeeBillLock> 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());
}
}
/**
* 填充附件同步聚合态(无附件/未上传/部分未传/已上传):按结算类型分业务类型一次 GROUP BY 查询,避免 N+1
*
* @param rows 本页 VO 列表
*/
private void fillAttachmentSyncStatus(List<FormalSettlementVO> rows) {
if (Func.isEmpty(rows)) {
return;
}
Map<Long, Integer> apSuccessCounts = kingdeeAttachmentSyncSupport.successCountByBizId(
KingdeeAttachmentConstant.BIZ_FORMAL_SETTLEMENT_AP, rows.stream()
.filter(row -> !"receivable".equals(row.getSettlementType())).map(FormalSettlementVO::getId).toList());
Map<Long, Integer> 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<Long, Integer> 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<PreSettlementVO> candidatePreSettlements(IPage<PreSettlement> page, PreSettlementVO query) {
IPage<PreSettlement> result = preSettlementMapper.selectPage(page, Wrappers.<PreSettlement>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 +303,7 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
public FormalSettlementVO detail(Long id) {
FormalSettlement settlement = existing(id);
FormalSettlementVO vo = toVO(settlement);
fillKingdeeLockStatus(List.of(vo));
List<FormalSettlementSource> sources = sourceMapper.selectList(Wrappers.<FormalSettlementSource>lambdaQuery()
.eq(FormalSettlementSource::getFormalSettlementId, id).orderByAsc(FormalSettlementSource::getCreateTime));
vo.setSources(sources);
@@ -219,6 +315,7 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
vo.setPayments(paymentMapper.selectList(Wrappers.<FormalSettlementPayment>lambdaQuery()
.eq(FormalSettlementPayment::getFormalSettlementId, id).orderByDesc(FormalSettlementPayment::getCreateTime)));
vo.setInvoices(listInvoices(settlement));
vo.setIssuedInvoices(listIssuedInvoices(id));
Set<Long> relationApplicationIds = paymentApplicationSettlementMapper.selectList(
Wrappers.<PaymentApplicationSettlement>lambdaQuery()
.select(PaymentApplicationSettlement::getPaymentApplicationId)
@@ -233,7 +330,7 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
query.or(wrapper -> 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 +401,7 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
.toList();
Map<Long, InvoiceReceipt> 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 +433,31 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
return normalizedInvoiceNo.isEmpty() ? fallback : normalizedInvoiceNo;
}
/**
* 开票申请经金蝶回写的发票结果(A303-5 落库,含板式文件/数电PDF等文件地址):
* 通过结算单与开票申请的关联关系查出,仅保留有效(非作废未删除)开票申请的发票,
* 只读展示于详情页,不落 blade_formal_settlement_invoice、不参与匹配金额计算
*/
private List<InvoiceApplicationInvoice> listIssuedInvoices(Long settlementId) {
List<Long> applicationIds = invoiceApplicationSettlementMapper.selectList(
Wrappers.<InvoiceApplicationSettlement>lambdaQuery()
.select(InvoiceApplicationSettlement::getInvoiceApplicationId)
.eq(InvoiceApplicationSettlement::getFormalSettlementId, settlementId)
).stream().map(InvoiceApplicationSettlement::getInvoiceApplicationId)
.filter(Objects::nonNull).distinct().toList();
if (applicationIds.isEmpty()) return List.of();
Set<Long> 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.<InvoiceApplicationInvoice>lambdaQuery()
.in(InvoiceApplicationInvoice::getInvoiceApplicationId, activeApplicationIds)
.orderByAsc(InvoiceApplicationInvoice::getInvoiceApplicationId)
.orderByAsc(InvoiceApplicationInvoice::getCreateTime));
}
@Override
@Transactional(rollbackFor = Exception.class)
public Long saveDraft(FormalSettlementSaveRequest request) {
@@ -387,7 +509,7 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
}
if (creating || restoring) {
if (Func.isEmpty(settlement.getFormalSettlementNo())) settlement.setFormalSettlementNo(nextNo(settlementType));
settlement.setApprovalStatus(DRAFT);
settlement.setApprovalStatus(TransportBusiConstant.DRAFT);
settlement.setCurrentNode("草稿");
settlement.setSourceType(sources.isEmpty() ? "应收应付" : directDetails.isEmpty() ? "预结算合并" : "混合来源");
settlement.setInvoiceStatus("unreceived");
@@ -443,27 +565,30 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
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 +596,345 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
}
@Override
public String syncKingdee(Long id) {
@Transactional(rollbackFor = Exception.class)
public void voidByKingdee(Long id, String operator) {
FormalSettlement settlement = existing(id);
if (!APPROVED.equals(settlement.getApprovalStatus())) throw new ServiceException("仅审批通过的正式结算单允许同步金蝶");
log.info("正式结算单[{}]金蝶删单回调废除:金蝶操作人={}", settlement.getFormalSettlementNo(), operator);
// 作废并还原同步信息为推送前状态:updateById 忽略 null 字段,清空字段须显式 set null
this.update(Wrappers.<FormalSettlement>lambdaUpdate()
.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.<FormalSettlement>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<Long> failedIds = list(Wrappers.<FormalSettlement>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<Long> failedIds) {
if (Func.isEmpty(failedIds)) {
return "无生成失败单据";
}
int success = 0;
List<String> 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<FormalSettlementDetail> details = detailMapper.selectList(Wrappers.<FormalSettlementDetail>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<FormalSettlementDetail> details = detailMapper.selectList(Wrappers.<FormalSettlementDetail>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<KingdeeApBillSyncSupport.BfiEntryGroup> 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<KingdeeApBillSyncSupport.BfiEntryGroup> 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()} 在分布式锁保护下调用。
* <p>分批与事务说明:
* ① 结算明细按 {@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<FormalSettlement> settlements = list(Wrappers.<FormalSettlement>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<Long> settlementIds = settlements.stream().map(FormalSettlement::getId).toList();
Map<Long, List<FormalSettlementDetail>> detailsBySettlement = new HashMap<>();
kingdeeApBillSyncSupport.batchForeach(settlementIds, AR_RESULT_DETAIL_QUERY_BATCH, batch ->
detailsBySettlement.putAll(detailMapper.selectList(Wrappers.<FormalSettlementDetail>lambdaQuery()
.in(FormalSettlementDetail::getFormalSettlementId, batch)
.eq(FormalSettlementDetail::getIsDeleted, 0)
.orderByAsc(FormalSettlementDetail::getLineNo)).stream()
.collect(Collectors.groupingBy(FormalSettlementDetail::getFormalSettlementId))));
// 组装防重键(与 A302-2 保存传参一致),同一防重键对应一张财务应收单
Map<String, FormalSettlement> 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<String, String> existBillNos = kingdeeArBillSyncSupport.queryArBillExistNos(new ArrayList<>(settlementBySrcCode.keySet()));
// 勾稽回写聚合后经 updateBatchById 批量更新,避免逐单 update
// (this 自调用下 MP 事务注解不生效,executeBatch 自开 BATCH SqlSession 统一提交、异常整体回滚,等效原子写)
List<FormalSettlement> toUpdateSettlements = new ArrayList<>();
int corrected = 0;
for (Map.Entry<String, FormalSettlement> 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 +986,8 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
if (!invoiceNumbers.add(invoiceNo)) throw new ServiceException("发票号" + invoiceNo + "重复");
matchedTotal = matchedTotal.add(money(item.getMatchedAmount()));
}
// 认领闸门:确认前按票号重查金蝶复核可用性,选中到确认之间票被认领/使用/红冲在此拦截
invoiceReceiptService.assertInvoicesClaimable(invoiceNumbers);
if (matchedTotal.compareTo(money(settlement.getSettlementAmount())) > 0) {
throw new ServiceException("发票匹配结算单金额合计不能超过结算金额");
}
@@ -594,12 +1049,12 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
}
private String createPayment(FormalSettlement settlement, BigDecimal appliedAmount, String remark) {
if (!APPROVED.equals(settlement.getApprovalStatus())) throw new ServiceException("仅审批通过的正式结算单允许发起付款申请");
if (!TransportBusiConstant.APPROVED.equals(settlement.getApprovalStatus())) throw new ServiceException("仅审批通过的正式结算单允许发起付款申请");
if (!"payable".equals(settlement.getSettlementType())) throw new ServiceException("仅应付正式结算单允许发起付款申请");
long activePaymentCount = paymentMapper.selectCount(Wrappers.<FormalSettlementPayment>lambdaQuery()
.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 +1065,7 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
payment.setPaymentType("final");
payment.setAppliedAmount(amount);
payment.setPaidAmount(BigDecimal.ZERO);
payment.setBillStatus(REVIEWING);
payment.setBillStatus(TransportBusiConstant.REVIEWING);
payment.setRemark(limit(remark, 200));
paymentMapper.insert(payment);
refreshPaymentSummary(settlement.getId());
@@ -962,15 +1417,104 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
updateById(settlement);
}
/**
* 批量刷新收款汇总:固定数次查询完成 N 张结算单的重算,避免逐单 refreshPaymentSummary 的 N+1。
* 计算口径与单笔版一致(数据由批量查询供给,纯计算复用 calculatePaymentSummary 重载)。
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void refreshPaymentSummaries(Collection<Long> settlementIds) {
List<Long> ids = settlementIds == null ? List.of() : settlementIds.stream()
.filter(Objects::nonNull).distinct().toList();
if (ids.isEmpty()) {
return;
}
// 结算单一次锁行查询替代逐单 selectOne(id 升序加锁,与逐单版加锁顺序一致防死锁)
List<FormalSettlement> settlements = baseMapper.selectList(Wrappers.<FormalSettlement>lambdaQuery()
.in(FormalSettlement::getId, ids)
.orderByAsc(FormalSettlement::getId)
.last("FOR UPDATE"));
if (settlements.isEmpty()) {
return;
}
List<Long> 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<Long, List<PaymentApplicationSettlement>> relationsBySettlement = paymentApplicationSettlementMapper.selectList(
Wrappers.<PaymentApplicationSettlement>lambdaQuery()
.in(PaymentApplicationSettlement::getFormalSettlementId, settledIds)
.eq(PaymentApplicationSettlement::getIsDeleted, 0)).stream()
.collect(Collectors.groupingBy(PaymentApplicationSettlement::getFormalSettlementId));
// 付款申请(按结算单直挂,payment_type=结算)
Map<Long, List<PaymentApplication>> applicationsBySettlement = paymentApplicationMapper.selectList(
Wrappers.<PaymentApplication>lambdaQuery()
.in(PaymentApplication::getSettlementId, settledIds)
.eq(PaymentApplication::getPaymentType, TransportBusiConstant.PAYMENT_TYPE_SETTLEMENT)
.eq(PaymentApplication::getIsDeleted, 0)).stream()
.collect(Collectors.groupingBy(PaymentApplication::getSettlementId));
// 关系表引用的付款申请状态(全量一次)
Set<Long> relationApplicationIds = relationsBySettlement.values().stream()
.flatMap(List::stream).map(PaymentApplicationSettlement::getPaymentApplicationId)
.filter(Objects::nonNull).collect(Collectors.toSet());
Map<Long, String> relationStatuses = relationApplicationIds.isEmpty() ? Map.of()
: paymentApplicationMapper.selectList(Wrappers.<PaymentApplication>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<Long, List<FormalSettlementSource>> sourcesBySettlement = hasPayable
? sourceMapper.selectList(Wrappers.<FormalSettlementSource>lambdaQuery()
.in(FormalSettlementSource::getFormalSettlementId, settledIds)
.eq(FormalSettlementSource::getIsDeleted, 0)).stream()
.collect(Collectors.groupingBy(FormalSettlementSource::getFormalSettlementId))
: Map.of();
// 收款认领分摊聚合(仅应收结算单需要)
Map<Long, BigDecimal> receiptClaimPaidBySettlement = hasReceivable
? receiptClaimSettlementMapper.selectList(Wrappers.<ReceiptClaimSettlement>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<PaymentApplicationSettlement> relations = relationsBySettlement
.getOrDefault(settlement.getId(), List.of());
// 直挂付款申请需排除该结算单关系表已引用的申请(与单笔版 notIn 语义一致)
Set<Long> ownRelationApplicationIds = relations.stream()
.map(PaymentApplicationSettlement::getPaymentApplicationId)
.filter(Objects::nonNull).collect(Collectors.toSet());
List<PaymentApplication> 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.<FormalSettlementSource>lambdaQuery()
.eq(FormalSettlementSource::getPreSettlementId, preSettlementId)
.eq(FormalSettlementSource::getIsDeleted, 0)).stream()
.map(FormalSettlementSource::getFormalSettlementId).distinct()
.forEach(this::refreshPaymentSummary);
List<Long> settlementIds = sourceMapper.selectList(Wrappers.<FormalSettlementSource>lambdaQuery()
.eq(FormalSettlementSource::getPreSettlementId, preSettlementId)
.eq(FormalSettlementSource::getIsDeleted, 0)).stream()
.map(FormalSettlementSource::getFormalSettlementId).filter(Objects::nonNull).distinct().toList();
refreshPaymentSummaries(settlementIds);
}
private List<FormalSettlementSummaryFee> listSummaryFees(Long settlementId) {
@@ -1056,7 +1600,7 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
sourceMapper.insert(relation);
int reserved = preSettlementMapper.update(null, Wrappers.<PreSettlement>lambdaUpdate()
.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 +1717,65 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
}
private PaymentSummary calculatePaymentSummary(FormalSettlement settlement) {
LambdaQueryWrapper<PaymentApplication> query = Wrappers.<PaymentApplication>lambdaQuery()
.eq(PaymentApplication::getSettlementId, settlement.getId())
.eq(PaymentApplication::getPaymentType, "settlement_payment")
.eq(PaymentApplication::getIsDeleted, 0);
List<PaymentApplicationSettlement> relations = paymentApplicationSettlementMapper.selectList(
Wrappers.<PaymentApplicationSettlement>lambdaQuery()
.eq(PaymentApplicationSettlement::getFormalSettlementId, settlement.getId())
.eq(PaymentApplicationSettlement::getIsDeleted, 0));
Set<Long> relationApplicationIds = relations.stream().map(PaymentApplicationSettlement::getPaymentApplicationId)
.filter(Objects::nonNull).collect(Collectors.toSet());
LambdaQueryWrapper<PaymentApplication> query = Wrappers.<PaymentApplication>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<PaymentApplication> 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<Long, String> relationStatuses = relationApplicationIds.isEmpty() ? Map.of() : paymentApplicationMapper.selectList(
Wrappers.<PaymentApplication>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<FormalSettlementSource> sources = sourceMapper.selectList(Wrappers.<FormalSettlementSource>lambdaQuery()
.eq(FormalSettlementSource::getFormalSettlementId, settlement.getId())
.eq(FormalSettlementSource::getIsDeleted, 0));
List<FormalSettlementSource> sources = data.sources() != null ? data.sources()
: sourceMapper.selectList(Wrappers.<FormalSettlementSource>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.<ReceiptClaimSettlement>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.<ReceiptClaimSettlement>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 +1794,20 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
private record PaymentSummary(BigDecimal appliedAmount, BigDecimal paidAmount) {
}
/**
* 收款汇总计算的供给数据(批量场景一次查询多张结算单后按单分发)
*
* @param relations 结算-付款申请关系行
* @param directApplications 直挂付款申请(已排除关系表引用的申请)
* @param relationStatuses 付款申请id → 审批状态
* @param sources 预收来源行(仅应付结算单;null 表示由纯计算按需查询)
* @param receiptClaimPaid 收款认领分摊聚合(仅应收结算单;null 表示由纯计算按需查询)
*/
private record PaymentSummaryData(List<PaymentApplicationSettlement> relations,
List<PaymentApplication> directApplications, Map<Long, String> relationStatuses,
List<FormalSettlementSource> sources, BigDecimal receiptClaimPaid) {
}
private SettlementAdjustmentVO toAdjustmentVO(SettlementAdjustment entity, String kingdeeBillNo) {
SettlementAdjustmentVO vo = Objects.requireNonNull(
BeanUtil.copyProperties(entity, SettlementAdjustmentVO.class));
@@ -1250,12 +1819,15 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
}
private String approvalStatusName(String value) {
return switch (value == null ? "" : value) {
case DRAFT -> "草稿";
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 +1835,7 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
private PreSettlement availableSource(Long id) {
PreSettlement source = preSettlementMapper.selectById(id);
if (source == null || Objects.equals(source.getIsDeleted(), 1)) throw new ServiceException("预结算单不存在");
if (!APPROVED.equals(source.getApprovalStatus())) throw new ServiceException("仅审批通过的预结算单可生成正式结算");
if (!TransportBusiConstant.APPROVED.equals(source.getApprovalStatus())) throw new ServiceException("仅审批通过的预结算单可生成正式结算");
if (Func.isNotEmpty(source.getFormalSettlementNo())) throw new ServiceException("预结算单" + source.getPreSettlementNo() + "已被正式结算占用");
return source;
}
@@ -1285,15 +1857,15 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
private FormalSettlement editable(Long id) {
FormalSettlement entity = existing(id);
if (!DRAFT.equals(entity.getApprovalStatus()) && !RETURNED.equals(entity.getApprovalStatus())) throw new ServiceException("仅草稿或已驳回的正式结算单允许编辑");
if (!TransportBusiConstant.DRAFT.equals(entity.getApprovalStatus()) && !TransportBusiConstant.RETURNED.equals(entity.getApprovalStatus())) throw new ServiceException("仅草稿或已驳回的正式结算单允许编辑");
return entity;
}
private void changeStatus(Long id, String expected, String target, String node, String reason) {
FormalSettlement settlement = existing(id);
if (!expected.equals(settlement.getApprovalStatus()) && !(DRAFT.equals(expected) && RETURNED.equals(settlement.getApprovalStatus()))) throw new ServiceException("当前状态不允许该操作");
if (!expected.equals(settlement.getApprovalStatus()) && !(TransportBusiConstant.DRAFT.equals(expected) && TransportBusiConstant.RETURNED.equals(settlement.getApprovalStatus()))) throw new ServiceException("当前状态不允许该操作");
settlement.setApprovalStatus(target); settlement.setCurrentNode(node); settlement.setCurrentProcessor(AuthUtil.getUserName());
if (RETURNED.equals(target)) settlement.setVoidReason(limit(reason, 200));
if (TransportBusiConstant.RETURNED.equals(target)) settlement.setVoidReason(limit(reason, 200));
updateById(settlement);
saveChange(settlement.getId(), "结算单基本信息", null, "调整", node, reason);
}
@@ -0,0 +1,44 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* 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<KingdeeAttachmentSyncMapper, KingdeeAttachmentSync>
implements IKingdeeAttachmentSyncService {
}
@@ -0,0 +1,360 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement obtained after purchasing a license from BladeX.
* <p>
* 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.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.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;
/**
* 金蝶单据锁定记录服务实现
* <p>
* 锁定:推送链路事务提交后逐张 LOCK(接口 1对1:单次一个源单号对一个金蝶单号,每张先落 failed 行占位再调
* lockBusiData,成功翻 locked;单张成败互不影响),失败由补偿定时任务重试;落库统一批量更新。
* 解锁:金蝶 unLockNotifyBusiData 回调(支持逗号拼接批量),
* DELETE 时分发废除源单并还原关联数据。
* 与结算单/开票申请服务互相依赖,为避免构造器循环依赖,单据服务经 {@code SpringUtil.getBean} 运行时获取
* (与本工程 doSyncKingdee 自代理同一惯用法)
*
* @author Chill
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class KingdeeBillLockServiceImpl extends ServiceImpl<KingdeeBillLockMapper, KingdeeBillLock> 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<String> targetBillNos) {
if (Func.isEmpty(targetBillNos)) {
return null;
}
String operator = operatorName();
// 待锁目标单:已 locked 幂等跳过(人工重推「同步金蝶」幂等命中路径不会重复锁);
// 其余(新目标单/failed 重锁/同单重推)先落 failed 行占位,保证补偿扫表无遗漏
List<KingdeeBillLock> 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<KingdeeBillLock> lockedRecords = new ArrayList<>();
List<KingdeeBillLock> 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) {
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.getTargetBillNo())) {
throw new ServiceException("金蝶解锁回调参数非法:targetBillNo不能为空");
}
// 批量回调:targetBillNo 逗号拼接,逐张处理;未匹配锁定中记录的幂等跳过(重复通知防金蝶重试风暴)
int processed = 0;
for (String targetBillNo : Func.toStrList(param.getTargetBillNo())) {
KingdeeBillLock lockRecord = this.getOne(Wrappers.<KingdeeBillLock>lambdaQuery()
.eq(KingdeeBillLock::getTargetBillType, param.getTargetBillType())
.eq(KingdeeBillLock::getTargetBillNo, targetBillNo)
.orderByDesc(KingdeeBillLock::getId)
.last("limit 1"), false);
if (lockRecord == null || !KingdeeLockConstant.STATUS_LOCKED.equals(lockRecord.getLockStatus())) {
log.warn("金蝶解锁回调未匹配锁定中的锁记录(幂等跳过):targetBillType={},targetBillNo={}",
param.getTargetBillType(), targetBillNo);
continue;
}
lockRecord.setLockStatus(KingdeeLockConstant.STATUS_UNLOCKED);
lockRecord.setOperationType(param.getOperationType());
lockRecord.setUnlockTime(LocalDateTime.now());
lockRecord.setNotifyOperator(param.getOperator());
this.updateById(lockRecord);
processed++;
log.info("金蝶单据[{}:{}]解锁回调落库:operationType={},操作人={}",
lockRecord.getSourceBillType(), lockRecord.getSourceBillNo(), param.getOperationType(), param.getOperator());
if (KingdeeLockConstant.OPERATION_DELETE.equals(param.getOperationType())) {
this.restoreOnKingdeeDelete(lockRecord, param.getOperator());
}
}
return processed;
}
/**
* 金蝶删单(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.<KingdeeBillLock>lambdaQuery()
.eq(KingdeeBillLock::getSourceBillType, sourceBillType)
.eq(KingdeeBillLock::getSourceBillId, sourceBillId)
.eq(KingdeeBillLock::getLockStatus, KingdeeLockConstant.STATUS_LOCKED));
if (lockedCount > 0) {
throw new ServiceException("该单据已被金蝶锁定,不可修改");
}
}
@Override
public Map<Long, KingdeeBillLock> mapActiveLocks(String sourceBillType, List<Long> sourceBillIds) {
if (Func.isEmpty(sourceBillIds)) {
return Map.of();
}
List<KingdeeBillLock> lockRecords = this.list(Wrappers.<KingdeeBillLock>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));
}
/**
* 锁定补偿:扫 failed 且 try_count 未达重试上限(RETRY_MAX_COUNT)的锁记录,逐张重调 lockBusiData
* (接口 1对1:单次一个源单号对一个金蝶单号,单张成败互不影响,由定时任务定时触发,亦可人工触发)。
* <p>成功翻 locked(补锁时间、清失败原因);失败 try_count+1 并记失败原因,计数烧满上限后不再自动重试
* (人工重推「同步金蝶」重置计数重新接管);成败落库均为批量更新(updateBatchById),避免逐张 update
*/
@Override
public String retryFailedLocks() {
List<KingdeeBillLock> failedList = this.list(Wrappers.<KingdeeBillLock>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<KingdeeBillLock> lockedRecords = new ArrayList<>();
List<KingdeeBillLock> 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.<KingdeeBillLock>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);
}
}
@@ -236,6 +236,7 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
result.put("partyA", contract.getPartyA());
result.put("partyB", contract.getPartyB());
result.put("contractCategory", contract.getContractCategory());
result.put("settlementCurrency", contract.getSettlementCurrency());
result.put("fundDemand", project == null ? null : project.getFundDemand());
result.put("settlementMode", project == null ? null : project.getSettlementMode());
result.put("settlementType", contractSettlementType(contract));
@@ -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<ReceiptClaimMapper, ReceiptClaim>
@@ -72,12 +84,21 @@ public class ReceiptClaimRecordServiceImpl extends BaseServiceImpl<ReceiptClaimM
private static final String CLAIMED = "claimed";
private static final String VOIDED = "voided";
private static final String APPROVED = "approved";
/**
* A301-1 收款单查询回看窗口天数(金蝶约束业务日期范围不可超过1个月)
*/
private static final int RESULT_WINDOW_DAYS = 30;
/**
* A301-1 收款单查询分页大小
*/
private static final int RESULT_PAGE_SIZE = 200;
private final ReceiptClaimSettlementMapper claimSettlementMapper;
private final IFormalSettlementService formalSettlementService;
private final KingdeeReceiptFlowMapper receiptFlowMapper;
private final FormalSettlementMapper formalSettlementMapper;
private final ReceiptFlowRecordMapper recordMapper;
private final IBfiService bfiService;
@Override
public IPage<ReceiptClaimRecordVO> selectPage(IPage<ReceiptClaimRecordVO> page,
@@ -163,7 +184,7 @@ public class ReceiptClaimRecordServiceImpl extends BaseServiceImpl<ReceiptClaimM
@Override
@Transactional(rollbackFor = Exception.class)
public String voidClaim(Long id) {
public void voidClaim(Long id) {
ReceiptClaim claim = baseMapper.selectOne(Wrappers.<ReceiptClaim>lambdaQuery()
.eq(ReceiptClaim::getId, id)
.eq(ReceiptClaim::getClaimerId, AuthUtil.getUserId())
@@ -182,6 +203,10 @@ public class ReceiptClaimRecordServiceImpl extends BaseServiceImpl<ReceiptClaimM
if (flow == null || Objects.equals(flow.getIsDeleted(), 1)) {
throw new ServiceException("关联收款流水不存在");
}
// 已推送金蝶的认领不允许作废:金蝶收款单已生成,冲销/调整由人工在金蝶处置
if (KingdeeBfiConstant.RECEIPT_CLAIM_PUSH_PUSHED.equals(flow.getPushStatus())) {
throw new ServiceException("该认领已推送金蝶,不允许作废,请在金蝶侧人工处置");
}
List<ReceiptClaimSettlement> relations = claimSettlementMapper.selectList(
Wrappers.<ReceiptClaimSettlement>lambdaQuery()
@@ -194,29 +219,47 @@ public class ReceiptClaimRecordServiceImpl extends BaseServiceImpl<ReceiptClaimM
List<ReceiptClaimSettlement> orderedRelations = relations.stream()
.sorted(Comparator.comparing(ReceiptClaimSettlement::getFormalSettlementId))
.toList();
for (ReceiptClaimSettlement relation : orderedRelations) {
FormalSettlement settlement = formalSettlementMapper.selectOne(
// 批量锁行关联结算单(一次 IN 查询替代逐单 selectOne,id 升序加锁防死锁)
List<Long> settlementIds = orderedRelations.stream()
.map(ReceiptClaimSettlement::getFormalSettlementId)
.filter(Objects::nonNull)
.distinct()
.toList();
if (settlementIds.isEmpty()) {
throw new ServiceException("关联结算单不存在");
}
Map<Long, FormalSettlement> settlements = formalSettlementMapper.selectList(
Wrappers.<FormalSettlement>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.<ReceiptClaimSettlement>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<ReceiptClaimM
operationRecord.setOperationAmount(money(claim.getClaimAmount()));
operationRecord.setOperatorName(Func.isEmpty(AuthUtil.getUserName()) ? "system"
: AuthUtil.getUserName());
operationRecord.setContent("生成金蝶认领冲单:" + kingdeeBillNo + ",状态:审核通过");
operationRecord.setContent("作废认领,回退认领金额 " + money(claim.getClaimAmount()));
recordMapper.insert(operationRecord);
return kingdeeBillNo;
}
/**
* A301-1 收款单查询回写认领结果:按来源单据编码匹配金蝶收款单,推进流水金蝶回写状态
* <p>
* 回写口径:A301-1 契约本身不支持按来源单号过滤,故按近 30 天业务日期窗口全量翻页后,
* 以金蝶收款单 sourcebillnumber == 流水 awrp_src_code(A301-3 推送时按流水生成、重试复用)在本地匹配;
* 仅推进金蝶回写状态与补登收款单号,不改本地认领金额/分摊;名下未作废认领主单冗余展示字段级联刷新。
*
* @return 回写条数
*/
@Override
public int refreshKingdeeResult() {
List<KingdeeReceiptFlow> pending = receiptFlowMapper.selectList(
Wrappers.<KingdeeReceiptFlow>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<String> 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<BfiRecBillVO> 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<String, BfiRecBillVO> 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.<ReceiptClaim>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<ReceiptClaimM
record.setKingdeeBillStatusName(switch (record.getKingdeeBillStatus() == null
? "" : record.getKingdeeBillStatus()) {
case APPROVED -> "审核通过";
case "received" -> "已收款";
case "pushed" -> "已推送";
case "failed" -> "处理失败";
default -> "未生成";
});
@@ -253,17 +399,25 @@ public class ReceiptClaimRecordServiceImpl extends BaseServiceImpl<ReceiptClaimM
return VOIDED.equals(claimStatus) ? VOIDED : CLAIMED;
}
/**
* 生成本地金蝶认领冲单号(KDCX + 时间戳 + 认领id尾号6位)。
* 当前作废流程不生成冲单(调用点已注释保留),后续迭代接金蝶冲销时放开即可。
*/
@SuppressWarnings("unused")
private String buildKingdeeBillNo(Long claimId) {
String time = DateTimeFormatter.ofPattern("yyyyMMddHHmmss").format(LocalDateTime.now());
String suffix = String.valueOf(claimId);
return "KDCX" + time + suffix.substring(Math.max(0, suffix.length() - 6));
}
private String amountClaimStatus(BigDecimal claimedAmount, BigDecimal receiptAmount) {
/**
* 累计认领金额 → 流水认领状态:对照公告未认领金额判定(与收款流水侧口径一致)
*/
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 paidAmount, BigDecimal settlementAmount) {
@@ -745,10 +745,13 @@ public class ReceivablePayableDetailServiceImpl
}
@Override
public IPage<Map<String, Object>> transferCandidates(IPage<?> page, String contractName, String batchNo,
public IPage<Map<String, Object>> 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");
@@ -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<SettlementAdjustmentMapper, SettlementAdjustment>
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<SettlementA
@Override
public List<Map<String, Object>> candidateFormalSettlements(String keyword) {
List<FormalSettlement> rows = formalMapper.selectList(Wrappers.<FormalSettlement>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<SettlementA
FormalSettlement formal = request.getFormalSettlementId() == null
? null : adjustableFormalSettlement(request.getFormalSettlementId());
if (adjustment.getId() == null) adjustment.setAdjustmentNo(nextNo());
adjustment.setApprovalStatus(DRAFT);
adjustment.setApprovalStatus(TransportBusiConstant.DRAFT);
adjustment.setCurrentNode("草稿");
adjustment.setCurrentProcessor(AuthUtil.getUserName());
adjustment.setApprovedTime(null);
@@ -185,7 +181,7 @@ public class SettlementAdjustmentServiceImpl extends BaseServiceImpl<SettlementA
@Transactional(rollbackFor = Exception.class)
public void submit(SettlementAdjustmentStatusRequest request) {
SettlementAdjustment item = existing(request.getId());
if (!DRAFT.equals(item.getApprovalStatus())) throw new ServiceException("仅草稿状态的调整单允许提交");
if (!TransportBusiConstant.DRAFT.equals(item.getApprovalStatus())) throw new ServiceException("仅草稿状态的调整单允许提交");
FormalSettlement formal = adjustableFormalSettlement(item.getFormalSettlementId());
List<SettlementAdjustmentDetail> details = detailMapper.selectList(
Wrappers.<SettlementAdjustmentDetail>lambdaQuery()
@@ -204,15 +200,15 @@ public class SettlementAdjustmentServiceImpl extends BaseServiceImpl<SettlementA
limit(detail.getRemark(), 200);
}
limit(item.getRemark(), 200);
changeStatus(item.getId(), DRAFT, REVIEWING, "审批中", null);
changeStatus(item.getId(), TransportBusiConstant.DRAFT, TransportBusiConstant.REVIEWING, "审批中", null);
}
@Override public void returnBill(SettlementAdjustmentStatusRequest request) { changeStatus(request.getId(), REVIEWING, RETURNED, "已驳回", limit(request.getReason(), 200)); }
@Override public void returnBill(SettlementAdjustmentStatusRequest request) { changeStatus(request.getId(), TransportBusiConstant.REVIEWING, TransportBusiConstant.RETURNED, "已驳回", limit(request.getReason(), 200)); }
@Override
@Transactional(rollbackFor = Exception.class)
public String repush(Long adjustmentId) {
SettlementAdjustment adjustment = existing(adjustmentId);
if (!APPROVED.equals(adjustment.getApprovalStatus())) throw new ServiceException("仅审批通过的结算调整单允许重新推送");
if (!TransportBusiConstant.APPROVED.equals(adjustment.getApprovalStatus())) throw new ServiceException("仅审批通过的结算调整单允许重新推送");
FormalSettlement formal = formalMapper.selectById(adjustment.getFormalSettlementId());
if (formal == null || !"synced".equals(formal.getKingdeeSyncStatus())) throw new ServiceException("关联正式结算单尚未推送金蝶,无需重新推送");
String kingdeeNo = "K3AP" + DateTimeFormatter.ofPattern("yyyyMMddHHmmss").format(LocalDateTime.now());
@@ -224,7 +220,7 @@ public class SettlementAdjustmentServiceImpl extends BaseServiceImpl<SettlementA
@Transactional(rollbackFor = Exception.class)
public void approve(SettlementAdjustmentStatusRequest request) {
SettlementAdjustment adjustment = existing(request.getId());
if (!REVIEWING.equals(adjustment.getApprovalStatus())) throw new ServiceException("仅审批中的结算调整单允许审核");
if (!TransportBusiConstant.REVIEWING.equals(adjustment.getApprovalStatus())) throw new ServiceException("仅审批中的结算调整单允许审核");
FormalSettlement formal = adjustableFormalSettlement(adjustment.getFormalSettlementId());
for (SettlementAdjustmentDetail item : detailMapper.selectList(Wrappers.<SettlementAdjustmentDetail>lambdaQuery().eq(SettlementAdjustmentDetail::getAdjustmentId, adjustment.getId()))) {
if (money(item.getAdjustmentAmountTax()).signum() == 0) continue;
@@ -261,7 +257,7 @@ public class SettlementAdjustmentServiceImpl extends BaseServiceImpl<SettlementA
saveFormalChange(formal.getId(), "合计费用项", null, "调整",
"调整单" + adjustment.getAdjustmentNo() + "调整金额【" + money(adjustment.getAdjustmentAmount()) + "】",
adjustment.getRemark());
adjustment.setKingdeeSyncStatus(formal.getKingdeeSyncStatus()); adjustment.setApprovalStatus(APPROVED); adjustment.setCurrentNode("审批通过"); adjustment.setCurrentProcessor(AuthUtil.getUserName()); adjustment.setApprovedTime(LocalDateTime.now()); updateById(adjustment);
adjustment.setKingdeeSyncStatus(formal.getKingdeeSyncStatus()); adjustment.setApprovalStatus(TransportBusiConstant.APPROVED); adjustment.setCurrentNode("审批通过"); adjustment.setCurrentProcessor(AuthUtil.getUserName()); adjustment.setApprovedTime(LocalDateTime.now()); updateById(adjustment);
}
private void saveFormalChange(Long settlementId, String changeType, Integer lineNo,
@@ -280,7 +276,7 @@ public class SettlementAdjustmentServiceImpl extends BaseServiceImpl<SettlementA
private void changeStatus(Long id, String from, String to, String node, String reason) { SettlementAdjustment item = existing(id); if (!from.equals(item.getApprovalStatus())) throw new ServiceException("当前状态不允许此操作"); item.setApprovalStatus(to); item.setCurrentNode(node); if (reason != null) item.setRemark(reason); updateById(item); }
private SettlementAdjustment existing(Long id) { SettlementAdjustment item = getById(id); if (item == null || Objects.equals(item.getIsDeleted(), 1)) throw new ServiceException("结算调整单不存在"); return item; }
private SettlementAdjustment editable(Long id) { SettlementAdjustment item = existing(id); if (!(DRAFT.equals(item.getApprovalStatus()) || RETURNED.equals(item.getApprovalStatus()))) throw new ServiceException("仅草稿或驳回的调整单可编辑"); return item; }
private SettlementAdjustment editable(Long id) { SettlementAdjustment item = existing(id); if (!(TransportBusiConstant.DRAFT.equals(item.getApprovalStatus()) || TransportBusiConstant.RETURNED.equals(item.getApprovalStatus()))) throw new ServiceException("仅草稿或驳回的调整单可编辑"); return item; }
private void applyFormalSnapshot(SettlementAdjustment adjustment, FormalSettlement formal, Long formalSettlementId) {
adjustment.setFormalSettlementId(formalSettlementId);
if (formal == null) {
@@ -299,7 +295,7 @@ public class SettlementAdjustmentServiceImpl extends BaseServiceImpl<SettlementA
private FormalSettlement adjustableFormalSettlement(Long formalSettlementId) {
if (formalSettlementId == null) throw new ServiceException("关联正式结算单不存在");
FormalSettlement formal = formalMapper.selectById(formalSettlementId);
if (formal == null || !APPROVED.equals(formal.getApprovalStatus())) {
if (formal == null || !TransportBusiConstant.APPROVED.equals(formal.getApprovalStatus())) {
throw new ServiceException("仅审批通过的正式结算单可调整");
}
if (isSettlementCompleted(formal)) {
@@ -331,15 +327,15 @@ public class SettlementAdjustmentServiceImpl extends BaseServiceImpl<SettlementA
Wrappers.<FormalSettlementPayment>lambdaQuery()
.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.<PaymentApplication>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<PaymentApplicationSettlement> relations = paymentApplicationSettlementMapper.selectList(
Wrappers.<PaymentApplicationSettlement>lambdaQuery()
@@ -355,9 +351,9 @@ public class SettlementAdjustmentServiceImpl extends BaseServiceImpl<SettlementA
Wrappers.<PaymentApplication>lambdaQuery()
.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 BaseServiceImpl<SettlementA
formalSummaryFeeMapper.updateById(summaryFee);
}
private SettlementAdjustmentVO toVO(SettlementAdjustment item) { SettlementAdjustmentVO vo = new SettlementAdjustmentVO(); org.springframework.beans.BeanUtils.copyProperties(item, vo); vo.setCreateUserName(UserCache.getUserRealName(item.getCreateUser())); vo.setApprovalStatusName(statusName(item.getApprovalStatus())); vo.setSettlementTypeName(typeName(item.getSettlementType())); return vo; }
private String statusName(String value) { return Map.of(DRAFT, "草稿", REVIEWING, "审批中", APPROVED, "审批通过", RETURNED, "已驳回").getOrDefault(value, value); }
private String statusName(String value) { return Map.of(TransportBusiConstant.DRAFT, "草稿", TransportBusiConstant.REVIEWING, "审批中", TransportBusiConstant.APPROVED, "审批通过", TransportBusiConstant.RETURNED, "已驳回").getOrDefault(value, value); }
private String typeName(String value) { return "receivable".equals(value) ? "应收" : "payable".equals(value) ? "应付" : ""; }
private synchronized String nextNo() { String prefix = "TZ" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); long count = count(Wrappers.<SettlementAdjustment>lambdaQuery().likeRight(SettlementAdjustment::getAdjustmentNo, prefix)); return prefix + String.format("%04d", count + 1); }
private BigDecimal money(BigDecimal value) { return value == null ? BigDecimal.ZERO : value; }
@@ -0,0 +1,64 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments from this software for such purposes.
* Copyright of this software remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is not liable for any claims arising from secondary or illegal development.
* <p>
* 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;
/**
* 外部系统来源单据号组装工具
* <p>
* 统一外部单据号(如金蝶 awrp_src_code)的拼接规则:主单据号 + 子单据号列表按下划线拼接;
* 自动去空白、去重,便于后续各链路通用编码与扩展处理
*
* @author Chill
*/
public class ExternalSrcCodeSupport {
/**
* 外部单据号拼接分隔符
*/
public static final String SEPARATOR = "_";
private ExternalSrcCodeSupport() {
}
/**
* 组装外部系统来源单据号:主单据号 + 子单据号按下划线拼接
* <p>各部分自动去首尾空格、过滤空白并去重;主单据号为空时仅拼接子单据号</p>
*
* @param mainCode 主单据号(如付款申请号)
* @param partCodes 子单据号列表(如结算明细单据号)
* @return 拼接后的外部来源单据号,无有效值时返回空串
*/
public static String buildSrcCode(String mainCode, Collection<String> partCodes) {
List<String> 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);
}
}
@@ -0,0 +1,291 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments from this software for such purposes.
* Copyright of this software remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is not liable for any claims arising from secondary or illegal development.
* <p>
* 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)同步支撑组件。
* <p>应收(receivable)正式结算单「同步金蝶」推送财务应收单(A302-2),推送前经 A302-4 按防重键幂等预检,
* 与应付链路(A102-2/A102-4,{@link KingdeeApBillSyncSupport})同一套逻辑;防重键组装、A302-4 查询、
* A302-2 组装保存与应收分录解析收口在本组件,档案取数(合同/组织/币种/客商/货物类型/不含税回链等)
* 复用应付支撑组件的公共方法。</p>
*
* @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<FormalSettlementDetail> settlementDetails) {
return ExternalSrcCodeSupport.buildSrcCode(settlement.getFormalSettlementNo(),
kingdeeApBillSyncSupport.settlementDocumentNos(settlementDetails));
}
// ==================== A302-4 财务应收单查询 ====================
/**
* A302-4 批量查询已生成财务应收单:来源单据号批量 + 外部系统编号(防重键),自动翻页,
* 用于推送前幂等预检与定时任务应收勾稽纠偏(awrp_src_code 支持多值,整批一次查询消除逐单远程查询 N+1)
*
* @param awrpSrcCodes 来源单据号(正式结算单号_结算明细单据号拼接)批次
* @return 已生成财务应收单的 来源单据号->单据编号 映射
*/
public Map<String, String> queryArBillExistNos(List<String> awrpSrcCodes) {
Map<String, String> 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<KingdeeApBillSyncSupport.BfiEntryGroup> 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<BfiArBillSaveEntryDTO> 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<KingdeeApBillSyncSupport.BfiEntryGroup> resolveArEntryGroups(FormalSettlement settlement,
List<FormalSettlementDetail> settlementDetails,
KingdeeSyncContext context) {
return kingdeeApBillSyncSupport.resolveEntryGroups(settlement, settlementDetails, context);
}
}
@@ -0,0 +1,463 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* 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 附件上传到指定单据)。
* <p>设计不变式:
* 1. 附件上传晚于主单推送成功、拿到并落库 billPkId 之后(由各模块接入点保证,本组件在事务提交后调用);
* 2. 附件失败永不回滚、不污染主单同步状态(逐附件独立 try/catch);
* 3. 同 (biz_type, biz_id, attachment_key) 最多一条成功记录(唯一键 + 成功集合幂等跳过);
* 4. 不自动重试,重推主单时自动补传;5. 单文件软限制 20MB,超限/下载失败记原因跳过。</p>
* <p>附件来源两类:用户附件(attachmentsJson 数组项,按 url 下载,幂等键=url)与
* 票池镜像发票文件(attachmentsJson 对象 {source,path,name},经 A801-2 下载回传,幂等键=path,
* path 变化(红冲重开)视为新文件自动触发重传)。</p>
*
* @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<String> 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<KingdeeAttachmentItem> 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<String> successKeys = successKeys(bizType, bizId);
// 载入既有同步记录(含失败记录,重试成功时覆盖为成功)
Map<String, KingdeeAttachmentSync> 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<String> failReasons = new ArrayList<>();
List<KingdeeAttachmentSync> 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.<KingdeeAttachmentSync>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<KingdeeAttachmentItem> parseUserAttachments(String attachmentsJson) {
Object parsed = parseJson(attachmentsJson);
if (parsed == null) {
return List.of();
}
List<?> rows = parsed instanceof List<?> list ? list : List.of(parsed);
List<KingdeeAttachmentItem> 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<KingdeeAttachmentItem> parseInvoicePoolFile(String attachmentsJson) {
Object parsed = parseJson(attachmentsJson);
if (parsed == null) {
return List.of();
}
List<?> rows = parsed instanceof List<?> list ? list : List.of(parsed);
List<KingdeeAttachmentItem> 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<Long, Integer> successCountByBizId(String bizType, Collection<Long> bizIds) {
if (Func.isEmpty(bizIds)) {
return Map.of();
}
QueryWrapper<KingdeeAttachmentSync> wrapper = new QueryWrapper<KingdeeAttachmentSync>()
.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<Long, Integer> result = new HashMap<>();
for (Map<String, Object> 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<String> successKeys(String bizType, Long bizId) {
return attachmentSyncMapper.selectList(Wrappers.<KingdeeAttachmentSync>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<String, KingdeeAttachmentSync> existingByKey(String bizType, Long bizId) {
Map<String, KingdeeAttachmentSync> result = new LinkedHashMap<>();
for (KingdeeAttachmentSync record : attachmentSyncMapper.selectList(Wrappers.<KingdeeAttachmentSync>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<InputStream> 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);
}
}
@@ -0,0 +1,294 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* 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 收票单查询)。
* <p>收票登记「推送金蝶」由 {@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}。</p>
*
* @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<String> 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) -&gt; 收票单行(同键多行取修改时间最新)
*/
public Map<String, BfiReceivedInvoiceVO> queryReceivedInvoiceBySrcCodes(String buyerName, Collection<String> srcCodes,
LocalDateTime windowStart, LocalDateTime windowEnd) {
Map<String, BfiReceivedInvoiceVO> 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<BfiReceivedInvoiceVO> 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<KingdeeApBillSyncSupport.BfiEntryGroup> 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<BfiReceivedInvoiceSaveEntryDTO> buildEntries(List<KingdeeApBillSyncSupport.BfiEntryGroup> entryGroups) {
List<BfiReceivedInvoiceSaveEntryDTO> 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;
}
}
@@ -0,0 +1,93 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments from this software for such purposes.
* Copyright of this software remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is not liable for any claims arising from secondary or illegal development.
* <p>
* 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):
* 多副本部署的定时任务与手动触发端点共用一道闸,同一轮同步全局最多一份在跑。
* <p>零等待抢锁:已有轮次进行中则立刻放弃本轮并返回跳过摘要(定时任务下轮幂等重试,手动触发稍后再试);
* 租约须大于最坏一轮耗时(含翻页远程查询),否则租约到期后另一副本会并发起跑;进程异常退出时锁在租约到期后自动释放;
* 未启用 blade.lock.enabled 时降级为直接执行(多副本各自起跑,联调环境可接受,生产须开启)。
*
* @author Chill
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class KingdeeSyncLockSupport {
/** 分布式锁客户端,仅在 blade.lock.enabled=true 时存在,缺失时同步降级为无 single-flight 保护 */
private final ObjectProvider<RedisLockClient> redisLockClientProvider;
/**
* 在 single-flight 分布式锁保护下执行一轮同步
*
* @param lockPrefix 锁键前缀(业务侧隔离,组件内自动追加租户标识)
* @param leaseSeconds 锁租约秒数,须大于最坏一轮耗时
* @param taskName 同步任务名,用于日志与跳过摘要
* @param action 同步主体
* @return 同步摘要;已有轮次进行中时返回跳过摘要
*/
public String runSingleFlight(String lockPrefix, long leaseSeconds, String taskName, Supplier<String> 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;
}
}
@@ -0,0 +1,70 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* 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;
/**
* 金蝶客商数据定时同步任务
* <p>
* 对应业财融合「客商数据定时同步子流程」(图01,ERP 泳道):
* ① 待推送客商(审批通过且未推送/推送失败)自动执行 A902-1 复查 + A902-2 推送;
* ② 已同步客商回写金蝶使用状态,金蝶禁用(enable=0)联动停用客商档案。
* A901-1 供应商查询待金蝶 SDK(blade-bfi-api)提供能力后扩展。
* 业务逻辑收敛在 {@link ICustomerArchiveService#syncKingdeeCustomerData()},便于手动触发联调。
* <p>
* 调度配置(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);
}
}
}

Some files were not shown because too many files have changed in this diff Show More