This commit is contained in:
luojiaquan
2026-09-22 09:53:09 +08:00
18 changed files with 366 additions and 53 deletions
@@ -1,12 +1,17 @@
package com.ruoyi.web.controller.ar;
import java.util.List;
import java.util.Date;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.validation.annotation.Validated;
import org.springframework.beans.propertyeditors.CustomDateEditor;
import org.springframework.web.bind.annotation.InitBinder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
@@ -31,6 +36,7 @@ import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.common.utils.uuid.IdUtils;
@@ -49,12 +55,23 @@ public class ArBillController extends BaseController {
@Autowired
private IArBillService billService;
/**
* 查询参数中的日期必须严格按 yyyy-MM-dd 解析,禁止非法日期自动滚动到其他日期。
*/
@InitBinder
public void initDateBinder(WebDataBinder binder) {
java.text.SimpleDateFormat formatter = new java.text.SimpleDateFormat("yyyy-MM-dd");
formatter.setLenient(false);
binder.registerCustomEditor(Date.class, new CustomDateEditor(formatter, true));
}
/**
* 查询账单列表;分页参数由 BaseController.startPage 交给 MyBatis PageHelper 处理。
*/
@PreAuthorize("@ss.hasPermi('ar:bill:list')")
@GetMapping("/list")
public TableDataInfo list(ArBillQuery query) {
public TableDataInfo list(ArBillQuery query, HttpServletRequest request) {
rejectMultiValueParameter(request, "customerId");
startPage();
List<ArBill> list = billService.selectArBillList(query);
return getDataTable(list);
@@ -104,7 +121,8 @@ public class ArBillController extends BaseController {
*/
@PreAuthorize("@ss.hasPermi('ar:bill:generate')")
@GetMapping("/generate/preview")
public AjaxResult preview(ArBillGenerateQuery query) {
public AjaxResult preview(ArBillGenerateQuery query, HttpServletRequest request) {
rejectMultiValueParameter(request, "customerId");
startPage();
List<PrcOrderBill> list = billService.previewBill(query);
TableDataInfo pageData = getDataTable(list);
@@ -252,4 +270,17 @@ public class ArBillController extends BaseController {
if (StringUtils.isNotEmpty(headerRequestId)) return headerRequestId;
return IdUtils.fastSimpleUUID();
}
/**
* 禁止把 customerId 以数组参数或重复参数提交,避免参数绑定失败后悄悄退化为无客户条件查询。
*/
private void rejectMultiValueParameter(HttpServletRequest request, String parameterName) {
if (request.getParameterMap().containsKey(parameterName + "[]")) {
throw new ServiceException("查询参数[" + parameterName + "]格式不正确,请传入单个客户ID");
}
String[] values = request.getParameterValues(parameterName);
if (values != null && values.length > 1) {
throw new ServiceException("查询参数[" + parameterName + "]只能传入一个值");
}
}
}
@@ -0,0 +1,49 @@
package com.ruoyi.common.config.serializer;
import java.io.IOException;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.time.format.ResolverStyle;
import java.util.Date;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonToken;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.exc.InvalidFormatException;
/**
* 严格解析 yyyy-MM-dd 日期,避免 SimpleDateFormat 的宽松解析把非法日期自动滚动成其他日期。
*/
public class StrictDateDeserializer extends JsonDeserializer<Date>
{
private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("uuuu-MM-dd")
.withResolverStyle(ResolverStyle.STRICT);
@Override
public Date deserialize(JsonParser parser, DeserializationContext context) throws IOException
{
if (!parser.hasToken(JsonToken.VALUE_STRING))
{
throw InvalidFormatException.from(parser, "日期必须使用 yyyy-MM-dd 格式", parser.getValueAsString(), Date.class);
}
String value = parser.getText();
if (value == null || value.trim().isEmpty())
{
return null;
}
try
{
LocalDate date = LocalDate.parse(value.trim(), FORMATTER);
return Date.from(date.atStartOfDay(ZoneId.systemDefault()).toInstant());
}
catch (DateTimeParseException ex)
{
throw InvalidFormatException.from(parser, "日期必须是有效的 yyyy-MM-dd 日期", value, Date.class);
}
}
}
@@ -6,6 +6,7 @@ import org.slf4j.LoggerFactory;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.validation.BindException;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingPathVariableException;
import org.springframework.web.bind.annotation.ExceptionHandler;
@@ -13,11 +14,9 @@ import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import com.ruoyi.common.constant.HttpStatus;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.text.Convert;
import com.ruoyi.common.exception.DemoModeException;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.common.utils.html.EscapeUtil;
/**
* 全局异常处理器
@@ -81,13 +80,19 @@ public class GlobalExceptionHandler
public AjaxResult handleMethodArgumentTypeMismatchException(MethodArgumentTypeMismatchException e, HttpServletRequest request)
{
String requestURI = request.getRequestURI();
String value = Convert.toStr(e.getValue());
if (StringUtils.isNotEmpty(value))
{
value = EscapeUtil.clean(value);
}
log.error("请求参数类型不匹配'{}',发生系统异常.", requestURI, e);
return AjaxResult.error(String.format("请求参数类型不匹配,参数[%s]要求类型为:'%s',但输入值为:'%s'", e.getName(), e.getRequiredType().getName(), value));
return AjaxResult.error(String.format("请求参数[%s]格式不正确,请检查输入内容", e.getName()));
}
/**
* 请求体无法解析时只返回用户可处理的提示,避免把 Jackson 或数据库内部信息直接返回前端。
*/
@ExceptionHandler(HttpMessageNotReadableException.class)
public AjaxResult handleHttpMessageNotReadableException(HttpMessageNotReadableException e,
HttpServletRequest request)
{
log.error("请求地址'{}',请求体格式不正确.", request.getRequestURI(), e);
return AjaxResult.error("请求参数格式不正确,请检查日期、金额等字段");
}
/**
@@ -98,7 +103,7 @@ public class GlobalExceptionHandler
{
String requestURI = request.getRequestURI();
log.error("请求地址'{}',发生未知异常.", requestURI, e);
return AjaxResult.error(e.getMessage());
return AjaxResult.error("系统处理异常,请稍后重试");
}
/**
@@ -109,7 +114,7 @@ public class GlobalExceptionHandler
{
String requestURI = request.getRequestURI();
log.error("请求地址'{}',发生系统异常.", requestURI, e);
return AjaxResult.error(e.getMessage());
return AjaxResult.error("系统处理异常,请稍后重试");
}
/**
@@ -119,7 +124,7 @@ public class GlobalExceptionHandler
public AjaxResult handleBindException(BindException e)
{
log.error(e.getMessage(), e);
String message = e.getAllErrors().get(0).getDefaultMessage();
String message = e.getAllErrors().isEmpty() ? "请求参数校验失败" : e.getAllErrors().get(0).getDefaultMessage();
return AjaxResult.error(message);
}
@@ -130,7 +135,8 @@ public class GlobalExceptionHandler
public Object handleMethodArgumentNotValidException(MethodArgumentNotValidException e)
{
log.error(e.getMessage(), e);
String message = e.getBindingResult().getFieldError().getDefaultMessage();
String message = e.getBindingResult().getFieldError() == null
? "请求参数校验失败" : e.getBindingResult().getFieldError().getDefaultMessage();
return AjaxResult.error(message);
}
@@ -21,6 +21,8 @@ public class ArBillDetail implements Serializable {
private Integer detailNo;
private String orderNo;
private String trackingNo;
/** 转单号快照;账单生成时从来源计费订单复制,保证历史账单可追溯。 */
private String transferNo;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date receiveTime;
private String productCode;
@@ -17,9 +17,9 @@ public class ArBillGenerateQuery
private String currency;
/** 订单号或运单号关键词,按 OR 条件模糊查询。 */
/** 订单号、运单号或转单号关键词,按 OR 条件模糊查询。 */
private String orderOrTrackingNo;
/** 全选模式下,排除弹窗使用的附加订单号或运单号关键词。 */
/** 全选模式下,排除弹窗使用的附加订单号、运单号或转单号关键词。 */
private String excludeOrderOrTrackingNo;
}
@@ -3,8 +3,11 @@ package com.ruoyi.ar.domain.vo;
import java.util.Date;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import com.ruoyi.common.config.serializer.StrictDateDeserializer;
import com.ruoyi.ar.enums.ArBillSelectionMode;
import lombok.Data;
@@ -15,11 +18,13 @@ public class ArBillGenerateRequest
@NotNull(message = "客户不能为空")
private Long customerId;
@NotBlank(message = "账单名称不能为空")
@Size(max = 100, message = "账单名称不能超过100个字符")
private String billName;
private String customerCode;
private String customerName;
@NotNull(message = "账单截止日不能为空")
@JsonFormat(pattern = "yyyy-MM-dd")
@JsonDeserialize(using = StrictDateDeserializer.class)
private Date billPeriodEnd;
/** 手动模式选中的来源订单 ID;全选模式为空。 */
private List<String> sourceBillIds;
@@ -28,13 +33,15 @@ public class ArBillGenerateRequest
/** 全选模式中用户明确排除的来源订单 ID。 */
private List<String> excludedSourceBillIds;
@JsonFormat(pattern = "yyyy-MM-dd")
@JsonDeserialize(using = StrictDateDeserializer.class)
private Date billDate;
private String currency = "CNY";
/** 订单号或运单号关键词,生成时用于再次校验筛选条件。 */
/** 订单号、运单号或转单号关键词,生成时用于再次校验筛选条件。 */
private String orderOrTrackingNo;
private String bankAccountName;
private String bankAccountNo;
private String bankName;
@Size(max = 500, message = "备注不能超过500个字符")
private String remark;
/** 前端幂等号;重复提交同一请求时复用原任务。 */
private String requestId;
@@ -90,6 +90,9 @@ public class ArBalanceWriteoffServiceImpl implements IArBalanceWriteoffService {
throw new ServiceException("核销金额必须大于0");
}
amount = amount.setScale(MONEY_SCALE, RoundingMode.HALF_UP);
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
throw new ServiceException("核销金额保留两位小数后必须大于0");
}
// 1. 行锁锁定账单(加锁顺序:账单 -> 账户,与反核销保持一致)
ArBill bill = billMapper.selectArBillByIdForUpdate(cmd.getArBillId());
@@ -984,6 +984,7 @@ public class ArBillServiceImpl implements IArBillService {
detail.setDetailNo(detailNo);
detail.setOrderNo(source.getOrderNo());
detail.setTrackingNo(source.getTrackingNo());
detail.setTransferNo(source.getTransferNo());
detail.setReceiveTime(source.getOrderTime());
detail.setProductCode(source.getProductCode());
detail.setProductName(source.getProductName());
@@ -1631,15 +1632,23 @@ public class ArBillServiceImpl implements IArBillService {
}
private void validatePeriod(ArBillGenerateRequest request) {
if (request == null || request.getCustomerId() == null || StringUtils.isEmpty(request.getBillName())
if (request == null || request.getCustomerId() == null || request.getCustomerId() <= 0
|| StringUtils.isEmpty(request.getBillName())
|| request.getBillName().trim().isEmpty()
|| request.getBillPeriodEnd() == null) {
throw new ServiceException("客户、账单名称和账单截止日不能为空");
}
if (request.getBillName().trim().length() > 100) {
throw new ServiceException("账单名称不能超过100个字符");
}
if (StringUtils.isNotEmpty(request.getRemark()) && request.getRemark().trim().length() > 500) {
throw new ServiceException("备注不能超过500个字符");
}
}
private void validatePreviewQuery(ArBillGenerateQuery query) {
if (query == null || query.getCustomerId() == null || query.getBillPeriodEnd() == null) {
if (query == null || query.getCustomerId() == null || query.getCustomerId() <= 0
|| query.getBillPeriodEnd() == null) {
throw new ServiceException("客户和账单截止日不能为空");
}
}
@@ -11,6 +11,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<result property="detailNo" column="detail_no"/>
<result property="orderNo" column="order_no"/>
<result property="trackingNo" column="tracking_no"/>
<result property="transferNo" column="transfer_no"/>
<result property="receiveTime" column="receive_time"/>
<result property="productCode" column="product_code"/>
<result property="productName" column="product_name"/>
@@ -32,7 +33,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
</resultMap>
<select id="selectByBillId" parameterType="long" resultMap="ArBillDetailResult">
select detail_id, ar_bill_id, prc_order_bill_id, detail_no, order_no, tracking_no, receive_time,
select detail_id, ar_bill_id, prc_order_bill_id, detail_no, order_no, tracking_no, transfer_no, receive_time,
product_code, product_name, goods_description, country_code, country_name, dest_postcode,
actual_weight, volume_weight, charge_weight, pkg_length, pkg_width, pkg_height,
currency, total_amount, remark, create_by, create_time
@@ -40,7 +41,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
</select>
<select id="selectByBillIdAndSourceIds" resultMap="ArBillDetailResult">
select detail_id, ar_bill_id, prc_order_bill_id, detail_no, order_no, tracking_no, receive_time,
select detail_id, ar_bill_id, prc_order_bill_id, detail_no, order_no, tracking_no, transfer_no, receive_time,
product_code, product_name, goods_description, country_code, country_name, dest_postcode,
actual_weight, volume_weight, charge_weight, pkg_length, pkg_width, pkg_height,
currency, total_amount, remark, create_by, create_time
@@ -76,7 +77,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
</resultMap>
<select id="selectByBillIdWithFees" parameterType="long" resultMap="ArBillDetailWithFeesResult">
select d.detail_id, d.ar_bill_id, d.prc_order_bill_id, d.detail_no, d.order_no, d.tracking_no, d.receive_time,
select d.detail_id, d.ar_bill_id, d.prc_order_bill_id, d.detail_no, d.order_no, d.tracking_no, d.transfer_no, d.receive_time,
d.product_code, d.product_name, d.goods_description, d.country_code, d.country_name, d.dest_postcode,
d.actual_weight, d.volume_weight, d.charge_weight, d.pkg_length, d.pkg_width, d.pkg_height,
d.currency, d.total_amount, d.remark, d.create_by, d.create_time,
@@ -94,7 +95,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
</select>
<select id="selectExportPage" resultMap="ArBillDetailResult">
select detail_id, ar_bill_id, prc_order_bill_id, detail_no, order_no, tracking_no, receive_time,
select detail_id, ar_bill_id, prc_order_bill_id, detail_no, order_no, tracking_no, transfer_no, receive_time,
product_code, product_name, goods_description, country_code, country_name, dest_postcode,
actual_weight, volume_weight, charge_weight, pkg_length, pkg_width, pkg_height,
currency, total_amount, remark, create_by, create_time
@@ -105,14 +106,14 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
</select>
<insert id="insert" parameterType="ArBillDetail" useGeneratedKeys="true" keyProperty="detailId">
insert into ar_bill_detail (ar_bill_id, prc_order_bill_id, detail_no, order_no, tracking_no, receive_time,
insert into ar_bill_detail (ar_bill_id, prc_order_bill_id, detail_no, order_no, tracking_no, transfer_no, receive_time,
product_code,
product_name, goods_description, country_code, country_name, dest_postcode,
actual_weight,
volume_weight, charge_weight, pkg_length, pkg_width, pkg_height, currency,
total_amount,
remark, create_by, create_time)
values (#{arBillId}, #{prcOrderBillId}, #{detailNo}, #{orderNo}, #{trackingNo}, #{receiveTime},
values (#{arBillId}, #{prcOrderBillId}, #{detailNo}, #{orderNo}, #{trackingNo}, #{transferNo}, #{receiveTime},
#{productCode}, #{productName}, #{goodsDescription}, #{countryCode}, #{countryName},
#{destPostcode}, #{actualWeight}, #{volumeWeight}, #{chargeWeight}, #{pkgLength}, #{pkgWidth},
#{pkgHeight}, #{currency}, #{totalAmount}, #{remark}, #{createBy}, sysdate())
@@ -120,14 +121,14 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<insert id="insertBatch" parameterType="java.util.List" useGeneratedKeys="true" keyProperty="detailId">
insert into ar_bill_detail (
ar_bill_id, prc_order_bill_id, detail_no, order_no, tracking_no, receive_time, product_code,
ar_bill_id, prc_order_bill_id, detail_no, order_no, tracking_no, transfer_no, receive_time, product_code,
product_name, goods_description, country_code, country_name, dest_postcode, actual_weight,
volume_weight, charge_weight, pkg_length, pkg_width, pkg_height, currency, total_amount,
remark, create_by, create_time
) values
<foreach collection="list" item="item" separator=",">
(#{item.arBillId}, #{item.prcOrderBillId}, #{item.detailNo}, #{item.orderNo}, #{item.trackingNo},
#{item.receiveTime}, #{item.productCode}, #{item.productName}, #{item.goodsDescription},
#{item.transferNo}, #{item.receiveTime}, #{item.productCode}, #{item.productName}, #{item.goodsDescription},
#{item.countryCode}, #{item.countryName}, #{item.destPostcode}, #{item.actualWeight},
#{item.volumeWeight}, #{item.chargeWeight}, #{item.pkgLength}, #{item.pkgWidth}, #{item.pkgHeight},
#{item.currency}, #{item.totalAmount}, #{item.remark}, #{item.createBy}, sysdate())
@@ -225,7 +225,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
</select>
<!-- rename_bill_tables.sql 已将需求文档中的 prc_bill 映射为本项目的 prc_order_bill。 -->
<!-- 生成弹窗预览:按客户、截止日、币种和订单/运单号 OR 条件分页查询未入账订单。 -->
<!-- 生成弹窗预览:按客户、截止日、币种和订单号/运单号/转单号 OR 条件分页查询未入账订单。 -->
<select id="selectPrcOrderBillByCustomerPeriod" resultMap="PrcOrderBillResult">
<include refid="selectPrcOrderBillVo"/>
where customer_id = #{customerId}
@@ -234,11 +234,13 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
</if>
<if test="orderOrTrackingNo != null and orderOrTrackingNo != ''">
and (order_no like concat('%', #{orderOrTrackingNo}, '%')
or tracking_no like concat('%', #{orderOrTrackingNo}, '%'))
or tracking_no like concat('%', #{orderOrTrackingNo}, '%')
or transfer_no like concat('%', #{orderOrTrackingNo}, '%'))
</if>
<if test="excludeOrderOrTrackingNo != null and excludeOrderOrTrackingNo != ''">
and (order_no like concat('%', #{excludeOrderOrTrackingNo}, '%')
or tracking_no like concat('%', #{excludeOrderOrTrackingNo}, '%'))
or tracking_no like concat('%', #{excludeOrderOrTrackingNo}, '%')
or transfer_no like concat('%', #{excludeOrderOrTrackingNo}, '%'))
</if>
and biz_date &lt;= #{cutoffDate}
and ar_bill_status = #{arBillStatus}
@@ -263,11 +265,13 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
</if>
<if test="orderOrTrackingNo != null and orderOrTrackingNo != ''">
and (order_no like concat('%', #{orderOrTrackingNo}, '%')
or tracking_no like concat('%', #{orderOrTrackingNo}, '%'))
or tracking_no like concat('%', #{orderOrTrackingNo}, '%')
or transfer_no like concat('%', #{orderOrTrackingNo}, '%'))
</if>
<if test="excludeOrderOrTrackingNo != null and excludeOrderOrTrackingNo != ''">
and (order_no like concat('%', #{excludeOrderOrTrackingNo}, '%')
or tracking_no like concat('%', #{excludeOrderOrTrackingNo}, '%'))
or tracking_no like concat('%', #{excludeOrderOrTrackingNo}, '%')
or transfer_no like concat('%', #{excludeOrderOrTrackingNo}, '%'))
</if>
and biz_date &lt;= #{cutoffDate}
and ar_bill_status = #{arBillStatus}
@@ -283,7 +287,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
</if>
<if test="orderOrTrackingNo != null and orderOrTrackingNo != ''">
and (order_no like concat('%', #{orderOrTrackingNo}, '%')
or tracking_no like concat('%', #{orderOrTrackingNo}, '%'))
or tracking_no like concat('%', #{orderOrTrackingNo}, '%')
or transfer_no like concat('%', #{orderOrTrackingNo}, '%'))
</if>
and biz_date &lt;= #{cutoffDate}
and ar_bill_status = #{arBillStatus}
@@ -339,7 +344,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
</if>
<if test="orderOrTrackingNo != null and orderOrTrackingNo != ''">
and (order_no like concat('%', #{orderOrTrackingNo}, '%')
or tracking_no like concat('%', #{orderOrTrackingNo}, '%'))
or tracking_no like concat('%', #{orderOrTrackingNo}, '%')
or transfer_no like concat('%', #{orderOrTrackingNo}, '%'))
</if>
<if test="currency != null and currency != ''">
and currency = #{currency}
+2
View File
@@ -76,6 +76,7 @@ CREATE TABLE IF NOT EXISTS ar_bill_detail (
detail_no INT(11) NOT NULL COMMENT '账单内顺序号',
order_no VARCHAR(64) NOT NULL COMMENT '订单号快照',
tracking_no VARCHAR(64) DEFAULT NULL COMMENT '运单号快照',
transfer_no VARCHAR(64) DEFAULT NULL COMMENT '转单号快照',
receive_time DATETIME DEFAULT NULL COMMENT '收件或入库时间',
product_code VARCHAR(50) NOT NULL COMMENT '产品代码快照',
product_name VARCHAR(100) DEFAULT NULL COMMENT '产品名称快照',
@@ -98,6 +99,7 @@ CREATE TABLE IF NOT EXISTS ar_bill_detail (
UNIQUE KEY uk_ar_bill_detail_source (ar_bill_id, prc_order_bill_id),
UNIQUE KEY uk_ar_bill_detail_no (ar_bill_id, detail_no),
KEY idx_ar_bill_detail_source (prc_order_bill_id),
KEY idx_ar_bill_detail_bill_transfer_no (ar_bill_id, transfer_no),
CONSTRAINT fk_ar_bill_detail_bill FOREIGN KEY (ar_bill_id) REFERENCES ar_bill (ar_bill_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='应收账单订单快照';
@@ -0,0 +1,49 @@
-- 应收账单转单号快照迁移
-- 适用于已经执行过 ar/ar_bill.sql 的存量数据库。
-- 本脚本可重复执行,不删除账单明细,不覆盖已经存在的转单号快照。
SET NAMES utf8mb4;
SET @db_name := DATABASE();
-- 1. 账单明细保存生成账单时的转单号快照。
SET @has_transfer_no := (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db_name
AND TABLE_NAME = 'ar_bill_detail'
AND COLUMN_NAME = 'transfer_no'
);
SET @sql := IF(@has_transfer_no = 0,
'ALTER TABLE ar_bill_detail ADD COLUMN transfer_no VARCHAR(64) DEFAULT NULL COMMENT ''转单号快照'' AFTER tracking_no',
'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 2. 详情按账单查询转单号时使用组合索引。
SET @has_transfer_index := (
SELECT COUNT(*)
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = @db_name
AND TABLE_NAME = 'ar_bill_detail'
AND INDEX_NAME = 'idx_ar_bill_detail_bill_transfer_no'
);
SET @sql := IF(@has_transfer_index = 0,
'ALTER TABLE ar_bill_detail ADD KEY idx_ar_bill_detail_bill_transfer_no (ar_bill_id, transfer_no)',
'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 3. 历史数据尽力回填,仅填充空值,不覆盖账单生成时已经保存的快照。
-- 如果来源订单后续修改过转单号,回填值仅代表当前来源表值,不能视为严格历史值。
UPDATE ar_bill_detail d
JOIN prc_order_bill p ON p.bill_id = d.prc_order_bill_id
SET d.transfer_no = p.transfer_no
WHERE d.transfer_no IS NULL
AND p.transfer_no IS NOT NULL;
-- 4. 执行后核验字段和回填数量。
SELECT
COUNT(*) AS detail_count,
SUM(CASE WHEN transfer_no IS NOT NULL AND TRIM(transfer_no) <> '' THEN 1 ELSE 0 END) AS transfer_no_count
FROM ar_bill_detail;
@@ -0,0 +1,99 @@
-- ================================================================
-- BMS 应收账单导出模板恢复脚本
--
-- 用途:恢复应收账单导出功能使用的内置默认模板配置。
-- 特点:
-- 1. 可重复执行,不删除现有模板数据。
-- 2. 模板表不存在时自动创建;模板记录已存在时不会重复插入。
-- 3. storage_key 必须与后端 resources 下的模板路径一致。
-- ================================================================
SET NAMES utf8mb4;
-- 1. 创建导出模板表(表已存在时不会重复创建)
CREATE TABLE IF NOT EXISTS ar_export_template (
template_id BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '导出模板ID',
template_code VARCHAR(50) NOT NULL COMMENT '模板编码,例如 AR_CUSTOMER_BILL',
template_name VARCHAR(100) NOT NULL COMMENT '模板名称',
business_type VARCHAR(50) NOT NULL COMMENT '业务类型,例如 AR_BILL',
template_version VARCHAR(50) NOT NULL COMMENT '模板版本,已启用版本不可覆盖',
storage_type VARCHAR(20) NOT NULL DEFAULT 'CLASSPATH' COMMENT '存储类型:CLASSPATH=应用内置资源,FILE=服务器文件,OSS=对象存储,MINIO=MinIO对象存储',
storage_key VARCHAR(500) NOT NULL COMMENT '模板文件存储Key或classpath资源路径',
file_hash VARCHAR(128) DEFAULT NULL COMMENT '模板文件SHA-256校验值',
status VARCHAR(20) NOT NULL DEFAULT 'ENABLED' COMMENT '状态:ENABLED=启用,DISABLED=停用',
is_default CHAR(1) NOT NULL DEFAULT '0' COMMENT '是否默认模板:1是0否',
effective_start DATE DEFAULT NULL COMMENT '生效开始日期',
effective_end DATE DEFAULT NULL COMMENT '生效结束日期',
remark VARCHAR(500) DEFAULT NULL COMMENT '备注',
create_by VARCHAR(64) NOT NULL COMMENT '创建人',
create_time DATETIME NOT NULL COMMENT '创建时间',
update_by VARCHAR(64) DEFAULT NULL COMMENT '更新人',
update_time DATETIME DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (template_id),
UNIQUE KEY uk_ar_export_template_code_version (business_type, template_code, template_version),
KEY idx_ar_export_template_default (business_type, status, is_default, effective_start, effective_end)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='应收账单导出模板管理';
-- 2. 恢复系统内置的应收账单默认模板
-- 模板文件位置:
-- bms-back/ruoyi-system/src/main/resources/templates/ar/ar_bill_template.xlsx
INSERT INTO ar_export_template (
template_code,
template_name,
business_type,
template_version,
storage_type,
storage_key,
file_hash,
status,
is_default,
effective_start,
effective_end,
remark,
create_by,
create_time,
update_by,
update_time
)
SELECT
'AR_CUSTOMER_BILL',
'应收账单默认模板',
'AR_BILL',
'1.0',
'CLASSPATH',
'templates/ar/ar_bill_template.xlsx',
'735002ac3561e0579b724c0612a2c5ce2d6725bc7fd39c450b0aeff70911a45e',
'ENABLED',
'1',
NULL,
NULL,
'系统内置默认模板',
'system',
NOW(),
'system',
NOW()
FROM DUAL
WHERE NOT EXISTS (
SELECT 1
FROM ar_export_template
WHERE business_type = 'AR_BILL'
AND template_code = 'AR_CUSTOMER_BILL'
AND template_version = '1.0'
);
-- 3. 执行后核验:应返回 1 条启用的默认模板记录
SELECT
template_id,
template_code,
template_name,
business_type,
template_version,
storage_type,
storage_key,
status,
is_default,
file_hash
FROM ar_export_template
WHERE business_type = 'AR_BILL'
AND template_code = 'AR_CUSTOMER_BILL'
AND template_version = '1.0';