✨ 新增导入结算单,并修复运单超管组织过滤与计量单位软删恢复

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-16 00:56:54 +08:00
parent 0518bf94bf
commit d7bf82b159
23 changed files with 2639 additions and 17 deletions
+2
View File
@@ -36,3 +36,5 @@ Thumbs.db
/.codex-work/
.serena/
.workbuddy/
/data/
/output/
@@ -0,0 +1,107 @@
/**
* 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.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.util.List;
import java.util.Map;
/**
* 导入结算单请求(列表筛选 + 确认/草稿入参)
*
* @author Chill
*/
@Data
@Schema(description = "导入结算单请求")
public class SettlementImportBatchRequest implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@Schema(description = "主键")
private Long id;
@Schema(description = "批次号")
private String batchNo;
@Schema(description = "项目ID")
private Long projectId;
@Schema(description = "项目名称")
private String projectName;
@Schema(description = "结算类型 receivable/payable")
private String settlementType;
@Schema(description = "结算单类型 formal/pre,应收仅 formal")
private String settlementBillType;
@Schema(description = "客户合同ID")
private Long customerContractId;
@Schema(description = "客户合同名称")
private String customerContractName;
@Schema(description = "客户名称")
private String customerName;
@Schema(description = "承运商合同ID")
private Long carrierContractId;
@Schema(description = "承运商合同名称")
private String carrierContractName;
@Schema(description = "承运商名称")
private String carrierName;
@Schema(description = "运单模式:new=新运单,history=历史运单")
private String waybillMode;
@Schema(description = "导入状态")
private String importStatus;
@Schema(description = "备注")
private String remark;
@Schema(description = "动态费用列名")
private List<String> feeItemNames;
@Schema(description = "导入明细行")
private List<Map<String, Object>> rows;
@Schema(description = "分批追加")
private Boolean append;
@Schema(description = "列表筛选:项目名称")
private String projectNameLike;
@Schema(description = "列表筛选:创建人")
private Long createUser;
}
@@ -0,0 +1,51 @@
/**
* 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.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
/**
* 导入结算单作废请求
*
* @author Chill
*/
@Data
@Schema(description = "导入结算单作废请求")
public class SettlementImportVoidRequest implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@Schema(description = "批次主键")
private Long id;
@Schema(description = "作废原因")
private String reason;
}
@@ -0,0 +1,115 @@
/**
* 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;
/**
* 导入结算单批次
*
* @author Chill
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("blade_settlement_import_batch")
@Schema(description = "导入结算单批次")
public class SettlementImportBatch extends TenantEntity {
@Serial
private static final long serialVersionUID = 1L;
@Schema(description = "批次号")
private String batchNo;
@Schema(description = "项目ID")
private Long projectId;
@Schema(description = "项目名称")
private String projectName;
@Schema(description = "结算类型 receivable/payable")
private String settlementType;
@Schema(description = "结算单类型 formal/pre")
private String settlementBillType;
@Schema(description = "客户合同ID")
private Long customerContractId;
@Schema(description = "客户合同名称")
private String customerContractName;
@Schema(description = "客户名称")
private String customerName;
@Schema(description = "承运商合同ID")
private Long carrierContractId;
@Schema(description = "承运商合同名称")
private String carrierContractName;
@Schema(description = "承运商名称")
private String carrierName;
@Schema(description = "运单模式 new")
private String waybillMode;
@Schema(description = "导入状态 draft/completed/voided")
private String importStatus;
@Schema(description = "运单数量")
private Integer waybillCount;
@Schema(description = "明细数量")
private Integer detailCount;
@Schema(description = "正式结算单ID")
private Long formalSettlementId;
@Schema(description = "正式结算单号")
private String formalSettlementNo;
@Schema(description = "预结算单ID")
private Long preSettlementId;
@Schema(description = "预结算单号")
private String preSettlementNo;
@Schema(description = "动态费用列名JSON")
private String feeItemNamesJson;
@Schema(description = "备注")
private String remark;
@Schema(description = "关联运单导入批次ID")
private Long waybillImportBatchId;
}
@@ -249,6 +249,9 @@ public class Waybill extends TenantEntity {
@Schema(description = "导入批次ID")
private Long importBatchId;
@Schema(description = "导入结算单批次ID")
private Long settlementImportBatchId;
@Schema(description = "关联单号")
private String relationNo;
@@ -0,0 +1,69 @@
/**
* 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 io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.springblade.transport.pojo.entity.SettlementImportBatch;
import java.io.Serial;
import java.util.List;
/**
* 导入结算单批次视图
*
* @author Chill
*/
@Data
@EqualsAndHashCode(callSuper = true)
@Schema(description = "导入结算单批次视图")
public class SettlementImportBatchVO extends SettlementImportBatch {
@Serial
private static final long serialVersionUID = 1L;
@Schema(description = "创建人姓名")
private String createUserName;
@Schema(description = "更新人姓名")
private String updateUserName;
@Schema(description = "导入状态显示名")
private String importStatusName;
@Schema(description = "结算类型显示名")
private String settlementTypeName;
@Schema(description = "结算单类型显示名")
private String settlementBillTypeName;
@Schema(description = "运单模式显示名")
private String waybillModeName;
@Schema(description = "动态费用列名")
private List<String> feeItemNames;
}
@@ -34,6 +34,26 @@ import java.util.List;
*/
public interface MeasurementUnitMapper extends BaseMapper<MeasurementUnit> {
/**
* 按编码查询计量单位(包含逻辑删除记录,用于新增恢复)。
*/
MeasurementUnit selectByUnitCodeIncludingDeleted(@Param("unitCode") String unitCode);
/**
* 按名称查询计量单位(包含逻辑删除记录,用于新增恢复)。
*/
MeasurementUnit selectByUnitNameIncludingDeleted(@Param("unitName") String unitName);
/**
* 恢复逻辑删除计量单位。
*/
int restoreById(@Param("id") Long id);
/**
* 释放已软删除记录占用的单位名称,避免唯一索引冲突。
*/
int releaseUnitName(@Param("id") Long id, @Param("unitName") String unitName);
/**
* 自定义分页
*
@@ -19,6 +19,34 @@
<result column="remark" property="remark"/>
</resultMap>
<select id="selectByUnitCodeIncludingDeleted" resultType="org.springblade.system.pojo.entity.MeasurementUnit">
SELECT *
FROM blade_measurement_unit
WHERE unit_code = #{unitCode}
LIMIT 1
</select>
<select id="selectByUnitNameIncludingDeleted" resultType="org.springblade.system.pojo.entity.MeasurementUnit">
SELECT *
FROM blade_measurement_unit
WHERE unit_name = #{unitName}
LIMIT 1
</select>
<update id="restoreById">
UPDATE blade_measurement_unit
SET is_deleted = 0
WHERE id = #{id}
AND is_deleted = 1
</update>
<update id="releaseUnitName">
UPDATE blade_measurement_unit
SET unit_name = #{unitName}
WHERE id = #{id}
AND is_deleted = 1
</update>
<select id="selectMeasurementUnitPage" resultMap="measurementUnitResultMap">
SELECT
mmu.id,
@@ -62,10 +62,42 @@ public class MeasurementUnitServiceImpl extends BaseServiceImpl<MeasurementUnitM
@Transactional(rollbackFor = Exception.class)
public boolean submit(MeasurementUnit measurementUnit) {
prepare(measurementUnit);
prepareSubmitTarget(measurementUnit);
validate(measurementUnit);
return saveOrUpdate(measurementUnit);
}
/**
* 编码或名称已存在且为软删除时:取消软删除并复用原主键,后续按更新落库;
* 存在且未删除时仍由唯一校验提示「已存在」。
*/
private void prepareSubmitTarget(MeasurementUnit measurementUnit) {
if (Func.isNotEmpty(measurementUnit.getId())) {
return;
}
MeasurementUnit existingByCode = baseMapper.selectByUnitCodeIncludingDeleted(measurementUnit.getUnitCode());
MeasurementUnit existingByName = baseMapper.selectByUnitNameIncludingDeleted(measurementUnit.getUnitName());
MeasurementUnit toRestore = null;
if (existingByCode != null && Objects.equals(existingByCode.getIsDeleted(), 1)) {
toRestore = existingByCode;
} else if (existingByName != null && Objects.equals(existingByName.getIsDeleted(), 1)
&& (existingByCode == null || Objects.equals(existingByCode.getId(), existingByName.getId()))) {
toRestore = existingByName;
}
if (toRestore == null) {
return;
}
// 复用编码恢复时,若另一条软删除记录占用了目标名称,先释放名称唯一索引
if (existingByName != null && Objects.equals(existingByName.getIsDeleted(), 1)
&& !Objects.equals(existingByName.getId(), toRestore.getId())) {
String releasedName = existingByName.getUnitName() + "_DEL_" + existingByName.getId();
baseMapper.releaseUnitName(existingByName.getId(), releasedName);
}
baseMapper.restoreById(toRestore.getId());
measurementUnit.setId(toRestore.getId());
measurementUnit.setIsDeleted(0);
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean changeStatus(Long id, Integer status) {
@@ -0,0 +1,147 @@
/**
* 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 com.baomidou.mybatisplus.core.metadata.IPage;
import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import lombok.AllArgsConstructor;
import org.springblade.core.boot.ctrl.BladeController;
import org.springblade.core.mp.support.Condition;
import org.springblade.core.mp.support.Query;
import org.springblade.core.secure.annotation.PreAuth;
import org.springblade.core.tool.api.R;
import org.springblade.transport.pojo.dto.SettlementImportBatchRequest;
import org.springblade.transport.pojo.dto.SettlementImportVoidRequest;
import org.springblade.transport.pojo.vo.SettlementImportBatchVO;
import org.springblade.transport.pojo.vo.WaybillImportValidateResultVO;
import org.springblade.transport.service.ISettlementImportBatchService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
import java.util.Map;
/**
* 导入结算单控制器
*
* @author Chill
*/
@RestController
@AllArgsConstructor
@PreAuth(menu = "settlement_import")
@RequestMapping("settlement-import")
@Tag(name = "导入结算单", description = "导入结算单管理")
public class SettlementImportController extends BladeController {
private final ISettlementImportBatchService settlementImportBatchService;
@GetMapping("/options")
@ApiOperationSupport(order = 1)
@Operation(summary = "筛选项与表单选项")
public R<Map<String, List<Map<String, Object>>>> options() {
return R.data(settlementImportBatchService.options());
}
@GetMapping("/next-code")
@ApiOperationSupport(order = 2)
@Operation(summary = "下一个批次号")
public R<String> nextCode() {
return R.data(settlementImportBatchService.nextBatchNo());
}
@GetMapping("/list")
@ApiOperationSupport(order = 3)
@Operation(summary = "导入结算单批次分页")
public R<IPage<SettlementImportBatchVO>> list(SettlementImportBatchRequest request, Query query) {
return R.data(settlementImportBatchService.page(Condition.getPage(query), request));
}
@GetMapping("/detail")
@ApiOperationSupport(order = 4)
@Operation(summary = "导入结算单批次详情")
public R<SettlementImportBatchVO> detail(@RequestParam Long id) {
return R.data(settlementImportBatchService.detail(id));
}
@GetMapping("/export-template")
@ApiOperationSupport(order = 5)
@Operation(summary = "导出导入模板")
public void exportTemplate(HttpServletResponse response) {
settlementImportBatchService.exportTemplate(response);
}
@PostMapping("/parse")
@ApiOperationSupport(order = 6)
@Operation(summary = "解析导入 Excel")
public R<Map<String, Object>> parse(@RequestPart("file") MultipartFile file) {
return R.data(settlementImportBatchService.parse(file));
}
@PostMapping("/validate")
@ApiOperationSupport(order = 7)
@Operation(summary = "校验导入数据")
public R<WaybillImportValidateResultVO> validate(@RequestBody SettlementImportBatchRequest request) {
return R.data(settlementImportBatchService.validate(request));
}
@PostMapping("/export-failures")
@ApiOperationSupport(order = 8)
@Operation(summary = "导出失败明细")
public void exportFailures(@RequestBody SettlementImportBatchRequest request, HttpServletResponse response) {
settlementImportBatchService.exportFailures(request, null, response);
}
@PostMapping("/draft")
@ApiOperationSupport(order = 9)
@Operation(summary = "保存草稿")
public R<SettlementImportBatchVO> draft(@RequestBody SettlementImportBatchRequest request) {
return R.data(settlementImportBatchService.saveDraft(request));
}
@PostMapping("/confirm")
@ApiOperationSupport(order = 10)
@Operation(summary = "确认导入结算单")
public R<SettlementImportBatchVO> confirm(@RequestBody SettlementImportBatchRequest request) {
return R.data(settlementImportBatchService.confirm(request));
}
@PostMapping("/void")
@ApiOperationSupport(order = 11)
@Operation(summary = "作废导入结算单批次")
public R voidBatch(@RequestBody SettlementImportVoidRequest request) {
settlementImportBatchService.voidBatch(request);
return R.success("作废成功");
}
}
@@ -0,0 +1,379 @@
/**
* 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.excel;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.DataFormatter;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.ss.usermodel.WorkbookFactory;
import org.springblade.common.excel.ImportCellReadException;
import org.springblade.core.log.exception.ServiceException;
import org.springframework.web.multipart.MultipartFile;
import java.io.InputStream;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
/**
* 导入结算单 Excel 读取器(Apache POI)。
* <p>
* 定位「*运输费」或「运输费」列,其后直到「结算金额」为动态费用列。
*
* @author Chill
*/
public final class SettlementImportFeeReader {
private static final DataFormatter DATA_FORMATTER = new DataFormatter(Locale.CHINA);
private SettlementImportFeeReader() {
}
/**
* 解析结果
*/
public static final class ParseResult {
private final List<Map<String, Object>> rows;
private final List<String> feeItemNames;
private final List<String> warnings;
public ParseResult(List<Map<String, Object>> rows, List<String> feeItemNames, List<String> warnings) {
this.rows = rows;
this.feeItemNames = feeItemNames;
this.warnings = warnings;
}
public List<Map<String, Object>> getRows() {
return rows;
}
public List<String> getFeeItemNames() {
return feeItemNames;
}
public List<String> getWarnings() {
return warnings;
}
}
public static ParseResult read(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw new ServiceException("上传文件不能为空");
}
try (InputStream inputStream = file.getInputStream(); Workbook workbook = WorkbookFactory.create(inputStream)) {
Sheet sheet = resolveDataSheet(workbook);
Row headerRow = sheet.getRow(sheet.getFirstRowNum());
if (headerRow == null) {
throw new ServiceException("未识别到表头,请使用标准导入结算单模板");
}
Map<Integer, String> headers = readHeaderMap(headerRow);
int transportFeeColumn = findTransportFeeColumn(headers);
if (transportFeeColumn < 0) {
throw new ServiceException("未找到「*运输费」或「运输费」列");
}
int settlementAmountColumn = findColumnExact(headers, "结算金额");
List<Integer> feeColumns = new ArrayList<>();
List<String> feeItemNames = new ArrayList<>();
int endColumn = settlementAmountColumn > transportFeeColumn
? settlementAmountColumn
: headers.keySet().stream().mapToInt(Integer::intValue).max().orElse(transportFeeColumn) + 1;
for (int column = transportFeeColumn + 1; column < endColumn; column++) {
String name = headers.get(column);
if (name == null || name.isBlank() || "结算金额".equals(name)) {
continue;
}
feeColumns.add(column);
feeItemNames.add(name);
}
List<Map<String, Object>> rows = new ArrayList<>();
int firstDataRow = sheet.getFirstRowNum() + 1;
int lastRow = sheet.getLastRowNum();
for (int rowIndex = firstDataRow; rowIndex <= lastRow; rowIndex++) {
Row row = sheet.getRow(rowIndex);
if (row == null) {
continue;
}
Map<String, Object> mapped = mapRow(headers, feeColumns, row, rowIndex);
if (mapped != null) {
rows.add(mapped);
}
}
return new ParseResult(rows, feeItemNames, List.of());
} catch (ServiceException exception) {
throw exception;
} catch (Exception exception) {
throw new ServiceException("读取导入结算单 Excel 失败:" + exception.getMessage());
}
}
/** 优先取含「*运输费/运输费」表头的工作表,否则取第一个非空表。 */
private static Sheet resolveDataSheet(Workbook workbook) {
Sheet fallback = null;
for (int index = 0; index < workbook.getNumberOfSheets(); index++) {
Sheet sheet = workbook.getSheetAt(index);
if (sheet == null) {
continue;
}
Row headerRow = sheet.getRow(sheet.getFirstRowNum());
if (headerRow == null) {
continue;
}
Map<Integer, String> headers = readHeaderMap(headerRow);
if (findTransportFeeColumn(headers) >= 0) {
return sheet;
}
if (fallback == null && !headers.isEmpty()) {
fallback = sheet;
}
}
if (fallback != null) {
return fallback;
}
throw new ServiceException("Excel 中未找到有效工作表");
}
private static Map<Integer, String> readHeaderMap(Row headerRow) {
Map<Integer, String> headers = new LinkedHashMap<>();
short lastCell = headerRow.getLastCellNum();
for (int column = 0; column < lastCell; column++) {
String value = normalizeHeader(cellString(headerRow.getCell(column)));
if (value != null && !value.isBlank()) {
headers.put(column, value);
}
}
return headers;
}
/** 去掉 BOM/不可见空白,全角星号转半角,便于匹配「*运输费」。 */
private static String normalizeHeader(String raw) {
if (raw == null) {
return null;
}
String text = raw.replace("\uFEFF", "")
.replace('\u00A0', ' ')
.replace('*', '*')
.trim();
return text.isEmpty() ? null : text;
}
private static Map<String, Object> mapRow(Map<Integer, String> headers, List<Integer> feeColumns,
Row row, int excelRowIndex) {
Map<String, Object> mapped = new LinkedHashMap<>();
Map<String, BigDecimal> feeItems = new LinkedHashMap<>();
for (Map.Entry<Integer, String> entry : headers.entrySet()) {
int column = entry.getKey();
String trimmedHeader = entry.getValue();
String cell = cellString(row.getCell(column));
if (feeColumns.contains(column)) {
feeItems.put(trimmedHeader, decimal(cell, trimmedHeader, excelRowIndex));
continue;
}
String field = FIXED_HEADER_MAP.get(trimmedHeader);
if (field == null) {
continue;
}
if ("transportFee".equals(field) || "unitPrice".equals(field)
|| "quantity".equals(field) || "mileage".equals(field)
|| "settlementAmount".equals(field)) {
mapped.put(field, decimal(cell, trimmedHeader, excelRowIndex));
} else if ("serialNumber".equals(field)) {
mapped.put(field, intValue(cell));
} else {
mapped.put(field, blankToNull(cell));
}
mapped.put(trimmedHeader, mapped.get(field));
}
boolean hasBusinessValue = mapped.values().stream().anyMatch(value -> {
if (value == null) {
return false;
}
if (value instanceof String text) {
return !text.isBlank();
}
return true;
}) || feeItems.values().stream().anyMatch(amount -> amount != null && amount.compareTo(BigDecimal.ZERO) != 0);
if (!hasBusinessValue) {
return null;
}
BigDecimal transportFee = toDecimal(mapped.get("transportFee"));
BigDecimal feeItemTotal = feeItems.values().stream()
.reduce(BigDecimal.ZERO, BigDecimal::add)
.setScale(2, RoundingMode.HALF_UP);
BigDecimal settlementAmount = transportFee.add(feeItemTotal).setScale(2, RoundingMode.HALF_UP);
mapped.put("settlementAmount", settlementAmount);
mapped.put("结算金额", settlementAmount);
mapped.put("feeItems", feeItems);
mapped.put("freight", transportFee);
mapped.put("运费", transportFee);
mapped.put("*运输费", transportFee);
mapped.put("运输费", transportFee);
mapped.put("otherFeeTotal", feeItemTotal);
mapped.put("其他费用合计", feeItemTotal);
mapped.put("freightTotal", settlementAmount);
mapped.put("运费合计", settlementAmount);
if (mapped.get("unitPrice") != null) {
mapped.put("单价", mapped.get("unitPrice"));
mapped.put("运输单价", mapped.get("unitPrice"));
}
return mapped;
}
private static int findTransportFeeColumn(Map<Integer, String> headers) {
int starred = findColumnExact(headers, "*运输费");
if (starred >= 0) {
return starred;
}
return findColumnExact(headers, "运输费");
}
private static int findColumnExact(Map<Integer, String> headers, String header) {
return headers.entrySet().stream()
.filter(entry -> header.equals(entry.getValue()))
.mapToInt(Map.Entry::getKey)
.findFirst()
.orElse(-1);
}
private static String cellString(Cell cell) {
if (cell == null) {
return null;
}
String text = DATA_FORMATTER.formatCellValue(cell);
if (text == null) {
return null;
}
text = text.trim();
return text.isEmpty() ? null : text;
}
private static BigDecimal decimal(String cell, String columnName, Integer excelRowIndex) {
if (cell == null || cell.isBlank()) {
return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
}
String normalized = cell.trim().replace(",", "");
try {
return new BigDecimal(normalized).setScale(2, RoundingMode.HALF_UP);
} catch (NumberFormatException exception) {
throw new ImportCellReadException(excelRowIndex, columnName, cell.trim());
}
}
private static Integer intValue(String cell) {
if (cell == null || cell.isBlank()) {
return null;
}
try {
return new BigDecimal(cell.trim().replace(",", "")).intValue();
} catch (NumberFormatException exception) {
return null;
}
}
private static String blankToNull(String cell) {
if (cell == null) {
return null;
}
String text = cell.trim();
return text.isEmpty() ? null : text;
}
private static BigDecimal toDecimal(Object value) {
if (value == null) {
return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
}
if (value instanceof BigDecimal bigDecimal) {
return bigDecimal;
}
try {
return new BigDecimal(String.valueOf(value).trim()).setScale(2, RoundingMode.HALF_UP);
} catch (NumberFormatException exception) {
return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
}
}
private static final Map<String, String> FIXED_HEADER_MAP = buildFixedHeaderMap();
private static Map<String, String> buildFixedHeaderMap() {
Map<String, String> map = new LinkedHashMap<>();
map.put("序号", "serialNumber");
map.put("原始单号", "originalNo");
map.put("配载标识号", "loadingIdentifier");
map.put("*车牌号/船班号/船次号/班次号", "vehicleNo");
map.put("车牌号/船班号/船次号/班次号", "vehicleNo");
map.put("*车牌号/航班号/船号/班列号", "vehicleNo");
map.put("车牌号/航班号/船号/班列号", "vehicleNo");
map.put("*运输方式", "transportType");
map.put("运输方式", "transportType");
map.put("运输类型", "transportType");
map.put("*运输类型", "transportType");
map.put("司机/船长姓名", "driverName");
map.put("司机/船长手机号", "driverPhone");
map.put("*发货地址", "departureAddress");
map.put("发货地址", "departureAddress");
map.put("发货联系人", "departureContact");
map.put("发货联系人电话", "departurePhone");
map.put("*到货地址", "arrivalAddress");
map.put("到货地址", "arrivalAddress");
map.put("收货联系人", "arrivalContact");
map.put("收货联系人电话", "arrivalPhone");
map.put("*货物名称", "cargoName");
map.put("货物名称", "cargoName");
map.put("*货物类型", "cargoType");
map.put("货物类型", "cargoType");
map.put("包装", "packageType");
map.put("*数量", "quantity");
map.put("数量", "quantity");
map.put("*数量单位", "quantityUnit");
map.put("数量单位", "quantityUnit");
map.put("规格", "specification");
map.put("型号", "model");
map.put("里程(km)", "mileage");
map.put("里程", "mileage");
map.put("运输单价", "unitPrice");
map.put("单价", "unitPrice");
map.put("*运输费", "transportFee");
map.put("运输费", "transportFee");
map.put("结算金额", "settlementAmount");
map.put("*实际发货时间", "actualStartDate");
map.put("实际发货时间", "actualStartDate");
map.put("*实际完成时间", "actualEndDate");
map.put("实际完成时间", "actualEndDate");
map.put("预计发货时间", "planStartDate");
map.put("预计完成时间", "planEndDate");
map.put("备注", "remark");
map.put("同一运单标识号", "waybillIdentifier");
return map;
}
}
@@ -0,0 +1,121 @@
/**
* 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.excel;
import cn.idev.excel.annotation.ExcelIgnore;
import cn.idev.excel.annotation.ExcelProperty;
import cn.idev.excel.annotation.write.style.ColumnWidth;
import cn.idev.excel.annotation.write.style.ContentRowHeight;
import cn.idev.excel.annotation.write.style.HeadRowHeight;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
/**
* 导入结算单 Excel 模板(固定列 + 运输单价 + *运输费 + 示例费用项)
*
* @author Chill
*/
@Data
@ColumnWidth(22)
@HeadRowHeight(28)
@ContentRowHeight(18)
public class SettlementImportTemplateExcel implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@ExcelProperty("序号")
private Integer serialNumber;
@ExcelProperty("原始单号")
private String originalNo;
@ExcelProperty("配载标识号")
private String loadingIdentifier;
@ExcelProperty("*车牌号/航班号/船号/班列号")
private String vehicleNo;
@ExcelProperty("*运输方式")
private String transportType;
@ExcelProperty("司机/船长姓名")
private String driverName;
@ExcelProperty("司机/船长手机号")
private String driverPhone;
@ExcelProperty("*发货地址")
private String departureAddress;
@ExcelProperty("发货联系人")
private String departureContact;
@ExcelProperty("发货联系人电话")
private String departurePhone;
@ExcelProperty("*到货地址")
private String arrivalAddress;
@ExcelProperty("收货联系人")
private String arrivalContact;
@ExcelProperty("收货联系人电话")
private String arrivalPhone;
@ExcelProperty("*货物名称")
private String cargoName;
@ExcelProperty("*货物类型")
private String cargoType;
@ExcelProperty("包装")
private String packageType;
@ExcelProperty("*数量")
private BigDecimal quantity;
@ExcelProperty("*数量单位")
private String quantityUnit;
@ExcelProperty("规格")
private String specification;
@ExcelProperty("型号")
private String model;
@ExcelProperty("里程(km)")
private BigDecimal mileage;
@ExcelProperty("运输单价")
private BigDecimal unitPrice;
@ExcelProperty("*运输费")
private BigDecimal transportFee;
@ExcelProperty("装卸费")
private BigDecimal loadingFee;
@ExcelProperty("过路费")
private BigDecimal tollFee;
@ExcelProperty("结算金额")
private BigDecimal settlementAmount;
@ExcelProperty("*实际发货时间")
private String actualStartDate;
@ExcelProperty("*实际完成时间")
private String actualEndDate;
@ExcelProperty("预计发货时间")
private String planStartDate;
@ExcelProperty("预计完成时间")
private String planEndDate;
@ExcelProperty("备注")
private String remark;
@ExcelProperty("同一运单标识号")
private String waybillIdentifier;
/** 导入失败原因(不导出到模板,仅用于失败明细) */
@ExcelIgnore
private String errorMessage;
}
@@ -101,8 +101,7 @@
approved_time,
kingdee_status,
kingdee_fail_reason,
kingdee_sync_time
approved_time,
kingdee_sync_time,
(
SELECT s.review_status
FROM blade_customer_credit_score s
@@ -0,0 +1,50 @@
/**
* 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.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.springblade.transport.pojo.entity.SettlementImportBatch;
/**
* 导入结算单批次 Mapper
*
* @author Chill
*/
@Mapper
public interface SettlementImportBatchMapper extends BaseMapper<SettlementImportBatch> {
/**
* 查询指定租户、指定日期前缀下已使用的最大批次流水号。
* 不过滤逻辑删除数据,避免唯一索引仍占用历史编号时重复生成。
*/
@Select("SELECT COALESCE(MAX(CAST(SUBSTRING(batch_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) "
+ "FROM blade_settlement_import_batch WHERE tenant_id = #{tenantId} "
+ "AND batch_no LIKE CONCAT(#{prefix}, '%')")
Long selectMaxDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix);
}
@@ -101,4 +101,19 @@ public interface IReceivablePayableDetailService extends BaseService<ReceivableP
/** 关闭总单调度后生成总单客户合同应收,并按所属运单记录的承运商合同生成应付。 */
void generateForClosedMasterOrder(MasterOrder masterOrder);
/**
* 结算导入:按导入单据构建/刷新「结算导入」来源的应收应付明细(运输费+费用项金额以导入数据为准)。
* <p>
* 历史运单若已有系统生成明细,不覆盖原明细,另建结算导入明细;运单已进入结算则拒绝重复导入。
* feeRows 每项含 waybillId、transportFee、unitPrice、feeItems(Map)、remark。
* </p>
*
* @param settlementType 结算类型 receivable/payable
* @param contractId 结算合同ID
* @param feeRows 费用行
* @return 明细主键列表(新建或刷新后的结算导入明细)
*/
List<Long> createDetailsFromSettlementImport(String settlementType, Long contractId,
List<Map<String, Object>> feeRows);
}
@@ -0,0 +1,69 @@
/**
* 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 com.baomidou.mybatisplus.core.metadata.IPage;
import jakarta.servlet.http.HttpServletResponse;
import org.springblade.core.mp.base.BaseService;
import org.springblade.transport.pojo.dto.SettlementImportBatchRequest;
import org.springblade.transport.pojo.dto.SettlementImportVoidRequest;
import org.springblade.transport.pojo.entity.SettlementImportBatch;
import org.springblade.transport.pojo.vo.SettlementImportBatchVO;
import org.springblade.transport.pojo.vo.WaybillImportValidateResultVO;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
import java.util.Map;
/**
* 导入结算单批次服务
*
* @author Chill
*/
public interface ISettlementImportBatchService extends BaseService<SettlementImportBatch> {
Map<String, List<Map<String, Object>>> options();
String nextBatchNo();
IPage<SettlementImportBatchVO> page(IPage<SettlementImportBatch> page, SettlementImportBatchRequest request);
SettlementImportBatchVO detail(Long id);
void exportTemplate(HttpServletResponse response);
Map<String, Object> parse(MultipartFile file);
WaybillImportValidateResultVO validate(SettlementImportBatchRequest request);
void exportFailures(SettlementImportBatchRequest request, MultipartFile file, HttpServletResponse response);
SettlementImportBatchVO saveDraft(SettlementImportBatchRequest request);
SettlementImportBatchVO confirm(SettlementImportBatchRequest request);
void voidBatch(SettlementImportVoidRequest request);
}
@@ -437,9 +437,11 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
} else if (Func.isNotEmpty(contractManage.getOrganizationId())) {
queryWrapper.eq(ContractManage::getOrganizationId, contractManage.getOrganizationId());
}
queryWrapper.like(Func.isNotEmpty(contractManage.getContractNo()), ContractManage::getContractNo, contractManage.getContractNo())
queryWrapper.eq(Func.isNotEmpty(contractManage.getProjectId()), ContractManage::getProjectId, contractManage.getProjectId())
.like(Func.isNotEmpty(contractManage.getContractNo()), ContractManage::getContractNo, contractManage.getContractNo())
.like(Func.isNotEmpty(contractManage.getContractName()), ContractManage::getContractName, contractManage.getContractName())
.like(Func.isNotEmpty(contractManage.getProjectName()), ContractManage::getProjectName, contractManage.getProjectName())
.like(Func.isNotEmpty(contractManage.getProjectName()) && Func.isEmpty(contractManage.getProjectId()),
ContractManage::getProjectName, contractManage.getProjectName())
.like(Func.isNotEmpty(contractManage.getOrganizationName()), ContractManage::getOrganizationName, contractManage.getOrganizationName())
.eq(Func.isNotEmpty(contractManage.getContractCategory()), ContractManage::getContractCategory, contractManage.getContractCategory())
.eq(Func.isNotEmpty(contractManage.getSignType()), ContractManage::getSignType, contractManage.getSignType())
@@ -847,6 +847,186 @@ public class ReceivablePayableDetailServiceImpl
}
}
private static final String SOURCE_SETTLEMENT_IMPORT = "结算导入";
@Override
@Transactional(rollbackFor = Exception.class)
public List<Long> createDetailsFromSettlementImport(String settlementType, Long contractId,
List<Map<String, Object>> feeRows) {
if (Func.isEmpty(settlementType) || Func.isEmpty(contractId)) {
throw new ServiceException("结算类型与合同不能为空");
}
if (Func.isEmpty(feeRows)) {
return List.of();
}
ContractManage contract = contractManageService.getById(contractId);
if (contract == null || Objects.equals(contract.getIsDeleted(), 1)) {
throw new ServiceException("合同不存在");
}
String targetSettlementType = settlementType(settlementType);
Long currentUserId = AuthUtil.getUserId();
Date generateTime = new Date();
List<Long> createdIds = new ArrayList<>();
for (Map<String, Object> feeRow : feeRows) {
if (feeRow == null) {
continue;
}
Long waybillId = toLong(feeRow.get("waybillId"));
if (waybillId == null) {
continue;
}
Waybill waybill = waybillService.getById(waybillId);
if (waybill == null || Objects.equals(waybill.getIsDeleted(), 1)) {
throw new ServiceException("运单不存在:" + waybillId);
}
assertWaybillNotSettledForImport(waybill, targetSettlementType);
BigDecimal transportFee = money(decimal(feeRow.get("transportFee")));
BigDecimal unitPrice = money(decimal(feeRow.get("unitPrice")));
Map<String, BigDecimal> feeItems = normalizeFeeItems(toFeeItemMap(feeRow.get("feeItems")));
BigDecimal feeItemTotal = feeItems.values().stream().map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal afterAmount = transportFee.add(feeItemTotal).setScale(2, RoundingMode.HALF_UP);
ReceivablePayableCargoFee cargoFee = buildSettlementImportCargoFee(
waybill, transportFee, unitPrice, feeItems, afterAmount, stringValue(feeRow.get("remark")));
ReceivablePayableDetail importDetail = getOne(Wrappers.<ReceivablePayableDetail>lambdaQuery()
.eq(ReceivablePayableDetail::getWaybillId, waybillId)
.eq(ReceivablePayableDetail::getSettlementType, targetSettlementType)
.eq(ReceivablePayableDetail::getSourceType, SOURCE_SETTLEMENT_IMPORT)
.eq(ReceivablePayableDetail::getIsDeleted, 0)
.orderByDesc(ReceivablePayableDetail::getCreateTime)
.last("limit 1"), false);
if (importDetail != null) {
refreshSettlementImportDetail(importDetail, waybill, contract, targetSettlementType,
cargoFee, unitPrice, stringValue(feeRow.get("remark")), currentUserId, generateTime);
createdIds.add(importDetail.getId());
continue;
}
ReceivablePayableDetail detail = buildDetail(waybill, contract, targetSettlementType,
List.of(cargoFee), unitPrice);
detail.setSourceType(SOURCE_SETTLEMENT_IMPORT);
detail.setSettlementStatus("pending");
if (Func.isNotEmpty(stringValue(feeRow.get("remark")))) {
detail.setRemark(stringValue(feeRow.get("remark")));
}
saveAutomaticDetail(detail, List.of(cargoFee), false, currentUserId, generateTime);
createdIds.add(detail.getId());
}
return createdIds;
}
/** 运单在该结算类型下已进入预/正式结算时,禁止再导入。 */
private void assertWaybillNotSettledForImport(Waybill waybill, String settlementType) {
List<ReceivablePayableDetail> details = list(Wrappers.<ReceivablePayableDetail>lambdaQuery()
.eq(ReceivablePayableDetail::getWaybillId, waybill.getId())
.eq(ReceivablePayableDetail::getSettlementType, settlementType)
.eq(ReceivablePayableDetail::getIsDeleted, 0));
for (ReceivablePayableDetail detail : details) {
String settlementStatus = Func.toStr(detail.getSettlementStatus(), "pending");
if (Func.isNotEmpty(detail.getFormalSettlementNo())
|| Func.isNotEmpty(detail.getPreSettlementNo())
|| List.of("pre_settled", "formal_settled", "closed").contains(settlementStatus)) {
throw new ServiceException("运单" + Func.toStr(waybill.getWaybillNo(), String.valueOf(waybill.getId()))
+ "已进入结算,无法重复导入");
}
}
}
private ReceivablePayableCargoFee buildSettlementImportCargoFee(Waybill waybill, BigDecimal transportFee,
BigDecimal unitPrice, Map<String, BigDecimal> feeItems,
BigDecimal afterAmount, String remark) {
ReceivablePayableCargoFee cargoFee = new ReceivablePayableCargoFee();
cargoFee.setWaybillId(waybill.getId());
cargoFee.setLineNo("0001");
cargoFee.setDataSource(FEE_SOURCE_MANUAL);
cargoFee.setCargoName(waybill.getCargoName());
cargoFee.setCargoType(waybill.getCargoType());
cargoFee.setSpecification(waybill.getSpecification());
cargoFee.setModel(waybill.getModel());
cargoFee.setBillingFactor("按重量");
cargoFee.setBillingType("固定单价");
cargoFee.setTransportQuantity(money(waybill.getQuantity()));
cargoFee.setQuantityUnit(waybill.getQuantityUnit());
cargoFee.setPriceUnit(waybill.getPriceUnit());
cargoFee.setUnitPrice(unitPrice);
cargoFee.setMileage(normalizeGeneratedMileage(waybill.getMileage()));
cargoFee.setFreightAmount(transportFee);
cargoFee.setFeeItemsJson(JsonUtil.toJson(feeItems));
cargoFee.setOriginalAmount(afterAmount);
cargoFee.setAdjustAmount(BigDecimal.ZERO.setScale(2));
cargoFee.setAfterAmount(afterAmount);
cargoFee.setRemark(remark);
return cargoFee;
}
/** 刷新已有结算导入明细:费用项目与金额始终覆盖为本次导入数据。 */
private void refreshSettlementImportDetail(ReceivablePayableDetail detail, Waybill waybill, ContractManage contract,
String settlementType, ReceivablePayableCargoFee cargoFee,
BigDecimal unitPrice, String remark, Long currentUserId, Date generateTime) {
ReceivablePayableDetail rebuilt = buildDetail(waybill, contract, settlementType, List.of(cargoFee), unitPrice);
detail.setFreightAmount(rebuilt.getFreightAmount());
detail.setOtherFeeAmount(rebuilt.getOtherFeeAmount());
detail.setTotalAmount(rebuilt.getTotalAmount());
detail.setUnitPrice(rebuilt.getUnitPrice());
detail.setCurrency(rebuilt.getCurrency());
detail.setFeeItemsJson(rebuilt.getFeeItemsJson());
detail.setContractId(rebuilt.getContractId());
detail.setContractNo(rebuilt.getContractNo());
detail.setContractName(rebuilt.getContractName());
detail.setCustomerName(rebuilt.getCustomerName());
detail.setSourceType(SOURCE_SETTLEMENT_IMPORT);
detail.setSettlementStatus("pending");
if (Func.isNotEmpty(remark)) {
detail.setRemark(remark);
}
updateById(detail);
cargoFeeMapper.delete(Wrappers.<ReceivablePayableCargoFee>lambdaQuery()
.eq(ReceivablePayableCargoFee::getDetailId, detail.getId()));
ReceivablePayableCargoFee copy = BeanUtil.copyProperties(cargoFee, ReceivablePayableCargoFee.class);
copy.setId(null);
copy.setDetailId(detail.getId());
fillGeneratedAuditFields(copy, currentUserId, generateTime);
cargoFeeMapper.insert(copy);
}
@SuppressWarnings("unchecked")
private Map<String, BigDecimal> toFeeItemMap(Object value) {
Map<String, BigDecimal> result = new LinkedHashMap<>();
if (!(value instanceof Map<?, ?> raw)) {
return result;
}
raw.forEach((key, amount) -> {
if (key == null) {
return;
}
result.put(String.valueOf(key), decimal(amount));
});
return result;
}
private Long toLong(Object value) {
if (value == null || "".equals(value)) {
return null;
}
if (value instanceof Number number) {
return number.longValue();
}
try {
return Long.valueOf(String.valueOf(value).trim());
} catch (NumberFormatException exception) {
return null;
}
}
private String stringValue(Object value) {
if (value == null) {
return null;
}
String text = String.valueOf(value).trim();
return text.isEmpty() ? null : text;
}
@Override
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.REQUIRES_NEW,
rollbackFor = Exception.class)
@@ -1211,15 +1211,14 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
@Override
public List<WaybillExcel> exportWaybill(WaybillVO waybill, String ids) {
TransportBusinessSupport.validateAllDept(waybill.getAllDept(), "运单管理");
List<Long> idList = Func.toLongList(ids);
LambdaQueryWrapper<Waybill> queryWrapper;
if (Func.isNotEmpty(idList)) {
queryWrapper = Wrappers.<Waybill>lambdaQuery().eq(Waybill::getIsDeleted, 0);
if (!Objects.equals(waybill.getAllDept(), 1)) {
queryWrapper.eq(Waybill::getDeptId, TransportBusinessSupport.currentDeptId("运单管理"));
} else if (Func.isNotEmpty(waybill.getDeptId())) {
queryWrapper.eq(Waybill::getDeptId, waybill.getDeptId());
List<Long> deptIdList = TransportBusinessSupport.resolveDeptFilterIds(
waybill.getAllDept(), waybill.getDeptId(), "运单管理");
if (Func.isNotEmpty(deptIdList)) {
queryWrapper.eq(Waybill::getDeptId, deptIdList.get(0));
}
queryWrapper.in(Waybill::getId, idList).orderByDesc(Waybill::getCreateTime);
} else {
@@ -1604,12 +1603,12 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
}
private LambdaQueryWrapper<Waybill> buildQuery(WaybillVO waybill) {
TransportBusinessSupport.validateAllDept(waybill.getAllDept(), "运单管理");
LambdaQueryWrapper<Waybill> queryWrapper = Wrappers.<Waybill>lambdaQuery().eq(Waybill::getIsDeleted, 0);
if (!Objects.equals(waybill.getAllDept(), 1)) {
queryWrapper.eq(Waybill::getDeptId, TransportBusinessSupport.currentDeptId("运单管理"));
} else if (Func.isNotEmpty(waybill.getDeptId())) {
queryWrapper.eq(Waybill::getDeptId, waybill.getDeptId());
// 与常用线路等模块一致:超管默认不按组织过滤;普通用户 allDept!=1 时强制当前组织
List<Long> deptIdList = TransportBusinessSupport.resolveDeptFilterIds(
waybill.getAllDept(), waybill.getDeptId(), "运单管理");
if (Func.isNotEmpty(deptIdList)) {
queryWrapper.eq(Waybill::getDeptId, deptIdList.get(0));
}
if (Func.isNotEmpty(waybill.getWaybillNo())) {
queryWrapper.eq(Waybill::getWaybillNo, waybill.getWaybillNo());
@@ -9,8 +9,16 @@ ALTER TABLE `blade_customer_archive`
CREATE INDEX idx_kingdee_status ON blade_customer_archive (kingdee_status);
-- 列表「同步金蝶」按钮权限(审批通过后推送失败的重推入口;parent_id 通过菜单 code 关联,避免写死 id)
-- 列表「同步金蝶」按钮权限(审批通过且未推送/推送失败时可点;parent_id 通过菜单 code 关联)
INSERT INTO blade_menu (id, parent_id, code, name, alias, path, source, sort, category, action, is_open, component, remark, is_deleted)
SELECT 2075431800000000001, m.id, 'customer_archive_sync', '同步金蝶', 'customer_archive_sync', '', '', 1, 2, 0, 1, '', '', 0
SELECT 2077600000000000011, m.id, 'customer_archive_sync', '同步金蝶', 'customer_archive_sync', '', '', 10, 2, 0, 1, NULL, '', 0
FROM blade_menu m
WHERE m.code = 'customer_archive' AND m.is_deleted = 0;
WHERE m.code = 'customer_archive' AND m.is_deleted = 0
LIMIT 1
ON DUPLICATE KEY UPDATE
parent_id = VALUES(parent_id),
name = VALUES(name),
alias = VALUES(alias),
sort = VALUES(sort),
category = VALUES(category),
is_deleted = 0;
@@ -0,0 +1,22 @@
-- 修复「同步金蝶」按钮菜单:按 code 幂等补齐/纠正,避免权限勾了但不生效
-- 执行后需:角色重新勾选「同步金蝶」→ 用户重新登录
INSERT INTO blade_menu (id, parent_id, code, name, alias, path, source, sort, category, action, is_open, component, remark, is_deleted)
SELECT 2077600000000000011, m.id, 'customer_archive_sync', '同步金蝶', 'customer_archive_sync', '', '', 10, 2, 0, 1, NULL, '', 0
FROM blade_menu m
WHERE m.code = 'customer_archive' AND m.is_deleted = 0
AND NOT EXISTS (
SELECT 1 FROM blade_menu x WHERE x.code = 'customer_archive_sync' AND x.is_deleted = 0
)
LIMIT 1;
UPDATE blade_menu child
INNER JOIN blade_menu parent ON parent.code = 'customer_archive' AND parent.is_deleted = 0
SET
child.parent_id = parent.id,
child.name = '同步金蝶',
child.alias = 'customer_archive_sync',
child.category = 2,
child.sort = 10,
child.is_deleted = 0
WHERE child.code = 'customer_archive_sync';
@@ -0,0 +1,60 @@
-- 导入结算单批次表 + 菜单
CREATE TABLE IF NOT EXISTS `blade_settlement_import_batch` (
`id` bigint NOT NULL COMMENT '主键',
`tenant_id` varchar(12) NOT NULL DEFAULT '000000' COMMENT '租户ID',
`batch_no` varchar(64) NOT NULL COMMENT '批次号 DJS+yyyyMMdd+序号',
`project_id` bigint DEFAULT NULL COMMENT '项目ID',
`project_name` varchar(200) DEFAULT NULL COMMENT '项目名称',
`settlement_type` varchar(30) NOT NULL COMMENT 'receivable/payable',
`settlement_bill_type` varchar(20) NOT NULL DEFAULT 'formal' COMMENT '结算单类型 formal/pre',
`customer_contract_id` bigint DEFAULT NULL COMMENT '客户合同ID',
`customer_contract_name` varchar(200) DEFAULT NULL COMMENT '客户合同名称',
`customer_name` varchar(200) DEFAULT NULL COMMENT '客户名称',
`carrier_contract_id` bigint DEFAULT NULL COMMENT '承运商合同ID',
`carrier_contract_name` varchar(200) DEFAULT NULL COMMENT '承运商合同名称',
`carrier_name` varchar(200) DEFAULT NULL COMMENT '承运商名称',
`waybill_mode` varchar(20) DEFAULT 'new' COMMENT '运单模式 new/history',
`import_status` varchar(32) NOT NULL COMMENT 'draft/completed/voided',
`waybill_count` int DEFAULT 0 COMMENT '运单数量',
`detail_count` int DEFAULT 0 COMMENT '明细数量',
`formal_settlement_id` bigint DEFAULT NULL COMMENT '正式结算单ID',
`formal_settlement_no` varchar(100) DEFAULT NULL COMMENT '正式结算单号',
`pre_settlement_id` bigint DEFAULT NULL COMMENT '预结算单ID',
`pre_settlement_no` varchar(100) DEFAULT NULL COMMENT '预结算单号',
`fee_item_names_json` longtext COMMENT '动态费用列名JSON数组',
`remark` varchar(200) DEFAULT NULL COMMENT '备注',
`waybill_import_batch_id` bigint DEFAULT NULL COMMENT '关联底层运单导入批次,可空',
`create_user` bigint DEFAULT NULL COMMENT '创建人',
`create_dept` bigint DEFAULT NULL COMMENT '创建部门',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_user` bigint DEFAULT NULL COMMENT '更新人',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
`status` int DEFAULT 1 COMMENT '业务状态',
`is_deleted` int DEFAULT 0 COMMENT '是否已删除',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_settlement_import_batch_no` (`tenant_id`,`batch_no`),
KEY `idx_settlement_import_project` (`project_id`),
KEY `idx_settlement_import_formal` (`formal_settlement_id`),
KEY `idx_settlement_import_pre` (`pre_settlement_id`),
KEY `idx_settlement_import_waybill_batch` (`waybill_import_batch_id`),
KEY `idx_settlement_import_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='导入结算单批次';
-- 已建表环境增量字段(列已存在可忽略报错)
ALTER TABLE `blade_settlement_import_batch` ADD COLUMN `settlement_bill_type` varchar(20) NOT NULL DEFAULT 'formal' COMMENT '结算单类型 formal/pre' AFTER `settlement_type`;
ALTER TABLE `blade_settlement_import_batch` ADD COLUMN `pre_settlement_id` bigint DEFAULT NULL COMMENT '预结算单ID' AFTER `formal_settlement_no`;
ALTER TABLE `blade_settlement_import_batch` ADD COLUMN `pre_settlement_no` varchar(100) DEFAULT NULL COMMENT '预结算单号' AFTER `pre_settlement_id`;
ALTER TABLE `blade_settlement_import_batch` ADD KEY `idx_settlement_import_pre` (`pre_settlement_id`);
-- 运单关联导入结算批次(可选,若列已存在可忽略报错)
ALTER TABLE `blade_waybill` ADD COLUMN `settlement_import_batch_id` bigint DEFAULT NULL COMMENT '导入结算单批次ID';
ALTER TABLE `blade_waybill` ADD KEY `idx_waybill_settlement_import_batch_id` (`settlement_import_batch_id`);
INSERT INTO `blade_menu` (`id`,`parent_id`,`code`,`name`,`alias`,`path`,`source`,`sort`,`category`,`action`,`is_open`,`component`,`remark`,`is_deleted`) VALUES
(2090000000000002080,2090000000000001000,'settlement_import','导入结算单','settlement_import','/settlement/settlement-import','iconfont icon-caidanguanli',6,1,0,1,NULL,'',0),
(2090000000000002081,2090000000000002080,'settlement_import_view','查看','settlement_import_view','','',1,2,0,1,NULL,'',0),
(2090000000000002082,2090000000000002080,'settlement_import_add','新增','settlement_import_add','','',2,2,0,1,NULL,'',0),
(2090000000000002083,2090000000000002080,'settlement_import_detail','详情','settlement_import_detail','','',3,2,0,1,NULL,'',0),
(2090000000000002084,2090000000000002080,'settlement_import_void','作废','settlement_import_void','','',4,2,0,1,NULL,'',0),
(2090000000000002085,2090000000000002080,'settlement_import_template','下载模板','settlement_import_template','','',5,2,0,1,NULL,'',0)
ON DUPLICATE KEY UPDATE `name`=VALUES(`name`),`path`=VALUES(`path`),`is_deleted`=0;