diff --git a/bms-back/bms-system/src/main/java/com/bms/ar/domain/ArBillDetail.java b/bms-back/bms-system/src/main/java/com/bms/ar/domain/ArBillDetail.java index 4acbd3e1..0140aab1 100644 --- a/bms-back/bms-system/src/main/java/com/bms/ar/domain/ArBillDetail.java +++ b/bms-back/bms-system/src/main/java/com/bms/ar/domain/ArBillDetail.java @@ -28,6 +28,8 @@ public class ArBillDetail implements Serializable { private String productCode; private String productName; private String goodsDescription; + /** 生成账单时的计费失败状态快照:0成功/1未匹配产品/2未匹配国家/3未匹配重量段。 */ + private Integer billingFailStatus; private String countryCode; private String countryName; private String destPostcode; diff --git a/bms-back/bms-system/src/main/java/com/bms/ar/service/impl/ArBillServiceImpl.java b/bms-back/bms-system/src/main/java/com/bms/ar/service/impl/ArBillServiceImpl.java index 1d115e9d..268d332f 100644 --- a/bms-back/bms-system/src/main/java/com/bms/ar/service/impl/ArBillServiceImpl.java +++ b/bms-back/bms-system/src/main/java/com/bms/ar/service/impl/ArBillServiceImpl.java @@ -28,6 +28,10 @@ import java.util.zip.ZipInputStream; import java.util.zip.ZipOutputStream; import jakarta.servlet.http.HttpServletResponse; +import org.apache.poi.ss.usermodel.FillPatternType; +import org.apache.poi.ss.usermodel.IndexedColors; +import org.apache.poi.xssf.model.StylesTable; +import org.apache.poi.xssf.usermodel.XSSFCellStyle; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.ApplicationEventPublisher; import org.springframework.core.io.ClassPathResource; @@ -85,6 +89,7 @@ import com.bms.common.utils.uuid.IdUtils; import com.bms.price.domain.PrcIncomeBill; import com.bms.price.domain.PrcIncomeBillFee; import com.bms.price.domain.PrcOrderBill; +import com.bms.price.enums.BillBillingFailStatus; import com.bms.price.mapper.PrcIncomeBillMapper; import com.bms.price.mapper.PrcIncomeBillFeeMapper; import com.bms.price.mapper.PrcOrderBillMapper; @@ -105,7 +110,7 @@ public class ArBillServiceImpl implements IArBillService { */ private static final int TEMPLATE_DATA_START_ROW = 5; private static final int TEMPLATE_FOOTER_START_ROW = 5; - private static final int TEMPLATE_COLUMN_COUNT = 20; + private static final int TEMPLATE_COLUMN_COUNT = 21; /** 客户订单号、运单号位于 Excel 的 C、D 列,按给定样例长度并额外保留两字符边距。 */ private static final int CUSTOMER_ORDER_NO_COLUMN_INDEX = 3; private static final int TRACKING_NO_COLUMN_INDEX = 4; @@ -135,6 +140,7 @@ public class ArBillServiceImpl implements IArBillService { */ private static final int TEMPLATE_DETAIL_STYLE_INDEX = 31; private static final String TEMPLATE_SHEET_XML = "xl/worksheets/sheet1.xml"; + private static final String TEMPLATE_STYLES_XML = "xl/styles.xml"; private static final Pattern TEMPLATE_COLUMN_PATTERN = Pattern.compile("]*)/\\s*>", Pattern.DOTALL); private static final Pattern TEMPLATE_COLUMN_MIN_PATTERN = Pattern.compile("\\bmin\\s*=\\s*[\"'](\\d+)[\"']"); private static final Pattern TEMPLATE_COLUMN_MAX_PATTERN = Pattern.compile("\\bmax\\s*=\\s*[\"'](\\d+)[\"']"); @@ -682,8 +688,8 @@ public class ArBillServiceImpl implements IArBillService { // 先抓取本批次的未出账费用快照,后续只更新和复制这些费用,避免把同订单已有 BILLED 费用重复带入新账单。 List sourceFees = sourceFeeMapper.selectUnbilledByBillIds( currentSourceBillIds, ArIncomeBillFeeStatus.UNBILLED.getCode()); - if (sourceFees == null || sourceFees.isEmpty()) { - throw new ServiceException("当前批次没有可生成的未出账费用"); + if (sourceFees == null) { + sourceFees = new ArrayList<>(); } Map sourceFeeCounts = new HashMap<>(); List sourceFeeIds = new ArrayList<>(sourceFees.size()); @@ -691,9 +697,12 @@ public class ArBillServiceImpl implements IArBillService { sourceFeeCounts.merge(sourceFee.getBillId(), 1, Integer::sum); sourceFeeIds.add(sourceFee.getFeeId()); } - for (String sourceBillId : currentSourceBillIds) { - if (!sourceFeeCounts.containsKey(sourceBillId)) { - throw new ServiceException("来源订单没有可生成的未出账费用:" + sourceBillId); + for (PrcOrderBill source : sources) { + BillBillingFailStatus billingFailStatus = BillBillingFailStatus.of(source.getBillingFailStatus()); + if (!sourceFeeCounts.containsKey(source.getBillId()) + && (billingFailStatus == null || billingFailStatus == BillBillingFailStatus.SUCCESS + || nvl(source.getIncomeTotal()).compareTo(BigDecimal.ZERO) != 0)) { + throw new ServiceException("来源订单没有可生成的未出账费用:" + source.getBillId()); } } @@ -702,12 +711,14 @@ public class ArBillServiceImpl implements IArBillService { if (claimedCount != currentSourceBillIds.size()) { throw new ServiceException("部分计费订单已被其他账单占用,请重新查询后生成"); } - int billedFeeCount = sourceFeeMapper.updateArBillStatusByFeeIds(sourceFeeIds, - ArIncomeBillFeeStatus.UNBILLED.getCode(), ArIncomeBillFeeStatus.BILLED.getCode()); - if (billedFeeCount != sourceFeeIds.size()) { - throw new ServiceException("部分费用状态已发生变化,请重新查询后生成"); + // 纯失败订单批次没有费用,不能执行空 IN 更新。 + if (!sourceFeeIds.isEmpty()) { + int billedFeeCount = sourceFeeMapper.updateArBillStatusByFeeIds(sourceFeeIds, + ArIncomeBillFeeStatus.UNBILLED.getCode(), ArIncomeBillFeeStatus.BILLED.getCode()); + if (billedFeeCount != sourceFeeIds.size()) { + throw new ServiceException("部分费用状态已发生变化,请重新查询后生成"); + } } - sourceBillMapper.refreshArBillStatusByBillIds(currentSourceBillIds); int detailNo = (task.getProcessedCount() == null ? 0 : task.getProcessedCount()) + 1; List details = new ArrayList<>(sources.size()); @@ -716,6 +727,8 @@ public class ArBillServiceImpl implements IArBillService { detailNo++, task.getCreateBy())); } detailMapper.insertBatch(details); + // 失败订单先落快照,再汇总占用状态,避免无费用订单被错误恢复为 UNBILLED。 + sourceBillMapper.refreshArBillStatusByBillIds(currentSourceBillIds); Map detailsBySourceBill = new HashMap<>(); for (ArBillDetail detail : detailMapper.selectByBillIdAndSourceIds(bill.getArBillId(), currentSourceBillIds)) { @@ -1165,6 +1178,7 @@ public class ArBillServiceImpl implements IArBillService { detail.setProductCode(source.getProductCode()); detail.setProductName(source.getProductName()); detail.setGoodsDescription(source.getOrderRemark()); + detail.setBillingFailStatus(source.getBillingFailStatus()); detail.setCountryCode(source.getCountryCode()); detail.setCountryName(source.getCountryName()); detail.setDestPostcode(source.getDestPostcode()); @@ -1262,9 +1276,16 @@ public class ArBillServiceImpl implements IArBillService { } /** - * 复制模板 ZIP 包,仅替换 sheet1.xml,保持模板样式、合并单元格和其他资源不变。 + * 复制模板 ZIP 包,替换明细工作表并追加黄色明细样式,保留模板其他资源。 */ private void writeOoxmlWorkbook(ArBill bill, ArExportTemplate template, Path target) throws IOException { + // 样式表单独读取;不加载整张工作簿,且不依赖 styles.xml 和 sheet1.xml 在 ZIP 中的先后顺序。 + StylesTable styles = readExportStyles(template); + XSSFCellStyle failedDetailStyle = styles.createCellStyle(); + failedDetailStyle.cloneStyleFrom(styles.getStyleAt(TEMPLATE_DETAIL_STYLE_INDEX)); + failedDetailStyle.setFillForegroundColor(IndexedColors.YELLOW.getIndex()); + failedDetailStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND); + int failedDetailStyleIndex = Short.toUnsignedInt(failedDetailStyle.getIndex()); try (InputStream input = openTemplateStream(template); ZipInputStream templateZip = new ZipInputStream(input); OutputStream fileOutput = Files.newOutputStream(target); ZipOutputStream output = new ZipOutputStream(fileOutput)) { boolean replaced = false; @@ -1272,8 +1293,10 @@ public class ArBillServiceImpl implements IArBillService { ZipEntry outputEntry = new ZipEntry(entry.getName()); output.putNextEntry(outputEntry); if (TEMPLATE_SHEET_XML.equals(entry.getName())) { - writeOoxmlSheet(readUtf8(templateZip), bill, output); + writeOoxmlSheet(readUtf8(templateZip), bill, output, failedDetailStyleIndex); replaced = true; + } else if (TEMPLATE_STYLES_XML.equals(entry.getName())) { + styles.writeTo(output); } else if (!entry.isDirectory()) { templateZip.transferTo(output); } @@ -1286,6 +1309,22 @@ public class ArBillServiceImpl implements IArBillService { } } + private StylesTable readExportStyles(ArExportTemplate template) throws IOException { + try (InputStream input = openTemplateStream(template); ZipInputStream templateZip = new ZipInputStream(input)) { + for (ZipEntry entry; (entry = templateZip.getNextEntry()) != null; ) { + if (TEMPLATE_STYLES_XML.equals(entry.getName())) { + StylesTable styles = new StylesTable(templateZip); + if (styles.getNumCellStyles() <= TEMPLATE_DETAIL_STYLE_INDEX) { + throw new IOException("应收账单导出模板缺少明细样式: " + TEMPLATE_DETAIL_STYLE_INDEX); + } + return styles; + } + templateZip.closeEntry(); + } + } + throw new IOException("应收账单导出模板缺少样式表: " + TEMPLATE_STYLES_XML); + } + private InputStream openTemplateStream(ArExportTemplate template) throws IOException { String storageType = StringUtils.isEmpty(template.getStorageType()) ? "CLASSPATH" : template.getStorageType(); String storageKey = StringUtils.isEmpty(template.getStorageKey()) ? EXPORT_TEMPLATE_RESOURCE : template.getStorageKey(); @@ -1313,7 +1352,8 @@ public class ArBillServiceImpl implements IArBillService { /** * 以 XML 流方式写入表头、分页明细和银行信息,避免 POI 在大数据量下移动行。 */ - private void writeOoxmlSheet(String templateXml, ArBill bill, OutputStream output) throws IOException { + private void writeOoxmlSheet(String templateXml, ArBill bill, OutputStream output, int failedDetailStyleIndex) + throws IOException { int sheetDataStart = templateXml.indexOf("', sheetDataStart); int sheetDataEnd = templateXml.indexOf("", sheetDataOpenEnd); @@ -1332,7 +1372,7 @@ public class ArBillServiceImpl implements IArBillService { writeUtf8(output, headerRow); writeUtf8(output, templateRows.get(2)); String customerRow = replaceCellXml(templateRows.get(3), "C3", bill.getCustomerName(), false); - // 账单所属期区域为 O3:T3 合并单元格,必须写入左上角 O3 才能显示。 + // 账单所属期区域为 O3:U3 合并单元格,必须写入左上角 O3 才能显示。 customerRow = replaceCellXml(customerRow, "O3", exportReceiveTimeRange(receiveTimeRange), false); writeUtf8(output, customerRow); writeUtf8(output, templateRows.get(4)); @@ -1346,7 +1386,7 @@ public class ArBillServiceImpl implements IArBillService { } attachFees(details); for (ArBillDetail detail : details) { - writeOoxmlDetailRow(output, rowIndex++, detail); + writeOoxmlDetailRow(output, rowIndex++, detail, failedDetailStyleIndex); } lastDetailNo = details.get(details.size() - 1).getDetailNo(); } @@ -1490,7 +1530,10 @@ public class ArBillServiceImpl implements IArBillService { } } - private void writeOoxmlDetailRow(OutputStream output, int rowNumber, ArBillDetail detail) throws IOException { + private void writeOoxmlDetailRow(OutputStream output, int rowNumber, ArBillDetail detail, int failedDetailStyleIndex) + throws IOException { + String failureDescription = exportBillingFailDescription(detail.getBillingFailStatus()); + int styleIndex = failureDescription.isEmpty() ? TEMPLATE_DETAIL_STYLE_INDEX : failedDetailStyleIndex; Object[] values = { detail.getDetailNo(), detail.getReceiveTime() == null ? "" : new SimpleDateFormat("yyyy-MM-dd").format(detail.getReceiveTime()), @@ -1500,19 +1543,33 @@ public class ArBillServiceImpl implements IArBillService { detail.getDestPostcode(), decimal(detail.getReceiveWeight(), 3), decimal(detail.getPkgLength(), 2), decimal(detail.getPkgHeight(), 2), decimal(detail.getPkgWidth(), 2), decimal(detail.getVolumeWeight(), 3), decimal(detail.getChargeWeight(), 3), exportUnitPrice(detail), exportProcessingFee(detail), - exportFormula(detail), exportFeeType(), detail.getCurrency(), detail.getTotalAmount(), exportRemark(detail) + exportFormula(detail), exportFeeType(), detail.getCurrency(), detail.getTotalAmount(), exportRemark(detail), + failureDescription }; StringBuilder row = new StringBuilder(2048); - row.append(""); + row.append(""); for (int index = 0; index < TEMPLATE_COLUMN_COUNT; index++) { - appendOoxmlCell(row, (char) ('A' + index) + String.valueOf(rowNumber), values[index]); + appendOoxmlCell(row, (char) ('A' + index) + String.valueOf(rowNumber), values[index], styleIndex); } row.append(""); writeUtf8(output, row.toString()); } - private void appendOoxmlCell(StringBuilder row, String reference, Object value) { - row.append(" "未匹配产品"; + case COUNTRY -> "未匹配国家"; + case WEIGHT -> "未匹配重量段"; + case SUCCESS -> ""; + }; + } + + private void appendOoxmlCell(StringBuilder row, String reference, Object value, int styleIndex) { + row.append("").append(numberText((Number) value)).append(""); } else { diff --git a/bms-back/bms-system/src/main/java/com/bms/price/mapper/PrcOrderBillMapper.java b/bms-back/bms-system/src/main/java/com/bms/price/mapper/PrcOrderBillMapper.java index 7c572de5..a0082249 100644 --- a/bms-back/bms-system/src/main/java/com/bms/price/mapper/PrcOrderBillMapper.java +++ b/bms-back/bms-system/src/main/java/com/bms/price/mapper/PrcOrderBillMapper.java @@ -78,10 +78,10 @@ public interface PrcOrderBillMapper int releaseArBillOrdersByArBillId(@Param("arBillId") Long arBillId, @Param("operator") String operator, @Param("fromStatus") String fromStatus, @Param("toStatus") String toStatus); - /** 根据费用明细状态汇总订单的 UNBILLED/PARTIAL/BILLED 状态。 */ + /** 根据费用明细状态及无费用失败订单的有效账单快照汇总订单出账状态。 */ int refreshArBillStatusByBillIds(@Param("billIds") List billIds); - /** 按应收账单关联的来源订单批量汇总状态,适用于大账单清理和作废。 */ + /** 清理或作废时批量释放来源订单;无费用失败订单的占用判断排除当前账单。 */ int refreshArBillStatusByArBillId(@Param("arBillId") Long arBillId); public int insertPrcOrderBill(PrcOrderBill bill); diff --git a/bms-back/bms-system/src/main/resources/mapper/ar/ArBillDetailMapper.xml b/bms-back/bms-system/src/main/resources/mapper/ar/ArBillDetailMapper.xml index c550f68f..a6a98865 100644 --- a/bms-back/bms-system/src/main/resources/mapper/ar/ArBillDetailMapper.xml +++ b/bms-back/bms-system/src/main/resources/mapper/ar/ArBillDetailMapper.xml @@ -16,6 +16,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + @@ -41,7 +42,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" 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, receive_weight, volume_weight, charge_weight, freight_rate, registration_fee, pkg_length, pkg_width, pkg_height, - currency, total_amount, product_amount, redline_amount, remark, create_by, create_time + currency, total_amount, product_amount, redline_amount, billing_fail_status, remark, create_by, create_time from ar_bill_detail where ar_bill_id = #{arBillId} order by detail_no @@ -49,7 +50,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" 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, receive_weight, volume_weight, charge_weight, freight_rate, registration_fee, pkg_length, pkg_width, pkg_height, - currency, total_amount, product_amount, redline_amount, remark, create_by, create_time + currency, total_amount, product_amount, redline_amount, billing_fail_status, remark, create_by, create_time from ar_bill_detail where ar_bill_id = #{arBillId} and prc_order_bill_id in @@ -87,7 +88,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" 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.receive_weight, d.volume_weight, d.charge_weight, d.freight_rate, d.registration_fee, d.pkg_length, d.pkg_width, d.pkg_height, - d.currency, d.total_amount, d.product_amount, d.redline_amount, d.remark, d.create_by, d.create_time, + d.currency, d.total_amount, d.product_amount, d.redline_amount, d.billing_fail_status, d.remark, d.create_by, d.create_time, f.detail_fee_id as fee_detail_id, f.detail_id as fee_parent_detail_id, f.prc_income_bill_fee_id as fee_prc_income_bill_fee_id, f.fee_no, f.fee_type, f.fee_name, f.sc_id as fee_sc_id, f.quantity as fee_quantity, @@ -106,7 +107,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" 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, receive_weight, volume_weight, charge_weight, freight_rate, registration_fee, pkg_length, pkg_width, pkg_height, - currency, total_amount, product_amount, redline_amount, remark, create_by, create_time + currency, total_amount, product_amount, redline_amount, billing_fail_status, remark, create_by, create_time from ar_bill_detail where ar_bill_id = #{arBillId} and detail_no > #{lastDetailNo} order by detail_no @@ -125,12 +126,12 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" product_name, goods_description, country_code, country_name, dest_postcode, actual_weight, receive_weight, volume_weight, charge_weight, freight_rate, registration_fee, pkg_length, pkg_width, pkg_height, currency, - total_amount, product_amount, redline_amount, + total_amount, product_amount, redline_amount, billing_fail_status, remark, create_by, create_time) values (#{arBillId}, #{prcOrderBillId}, #{detailNo}, #{orderNo}, #{trackingNo}, #{transferNo}, #{receiveTime}, #{productCode}, #{productName}, #{goodsDescription}, #{countryCode}, #{countryName}, #{destPostcode}, #{actualWeight}, #{receiveWeight}, #{volumeWeight}, #{chargeWeight}, #{freightRate}, #{registrationFee}, #{pkgLength}, #{pkgWidth}, - #{pkgHeight}, #{currency}, #{totalAmount}, #{productAmount}, #{redlineAmount}, #{remark}, #{createBy}, sysdate()) + #{pkgHeight}, #{currency}, #{totalAmount}, #{productAmount}, #{redlineAmount}, #{billingFailStatus}, #{remark}, #{createBy}, sysdate()) @@ -138,7 +139,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" 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, receive_weight, volume_weight, charge_weight, freight_rate, registration_fee, pkg_length, pkg_width, pkg_height, currency, total_amount, - product_amount, redline_amount, + product_amount, redline_amount, billing_fail_status, remark, create_by, create_time ) values @@ -146,7 +147,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" #{item.transferNo}, #{item.receiveTime}, #{item.productCode}, #{item.productName}, #{item.goodsDescription}, #{item.countryCode}, #{item.countryName}, #{item.destPostcode}, #{item.actualWeight}, #{item.receiveWeight}, #{item.volumeWeight}, #{item.chargeWeight}, #{item.freightRate}, #{item.registrationFee}, #{item.pkgLength}, #{item.pkgWidth}, #{item.pkgHeight}, - #{item.currency}, #{item.totalAmount}, #{item.productAmount}, #{item.redlineAmount}, + #{item.currency}, #{item.totalAmount}, #{item.productAmount}, #{item.redlineAmount}, #{item.billingFailStatus}, #{item.remark}, #{item.createBy}, sysdate()) diff --git a/bms-back/bms-system/src/main/resources/mapper/price/PrcOrderBillMapper.xml b/bms-back/bms-system/src/main/resources/mapper/price/PrcOrderBillMapper.xml index b60f668d..9f6de43c 100644 --- a/bms-back/bms-system/src/main/resources/mapper/price/PrcOrderBillMapper.xml +++ b/bms-back/bms-system/src/main/resources/mapper/price/PrcOrderBillMapper.xml @@ -118,7 +118,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" where f.bill_id = p.bill_id and f.ar_bill_status = #{feeArBillStatus}) as income_total, p.pay_total, p.currency, p.ar_bill_status, - p.customer_active_status, p.credit_arrears_status, p.refund_status, p.round_mode, + p.customer_active_status, p.credit_arrears_status, p.refund_status, p.billing_fail_status, p.round_mode, p.created_by, p.create_time, p.updated_by, p.update_time from prc_order_bill p left join prc_order_bill_ext e on e.bill_id = p.bill_id @@ -358,6 +358,29 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + + + and ( + exists ( + select 1 from prc_income_bill_fee f + where f.bill_id = p.bill_id and f.ar_bill_status = #{feeArBillStatus} + ) + or ( + p.billing_fail_status in (1, 2, 3) + and not exists ( + select 1 from prc_income_bill_fee f where f.bill_id = p.bill_id + ) + and not exists ( + select 1 from ar_bill_detail d + inner join ar_bill b on b.ar_bill_id = d.ar_bill_id + where d.prc_order_bill_id = p.bill_id + and d.billing_fail_status in (1, 2, 3) + and b.status <> 'VOID' and b.generation_status <> 'FAILED' + ) + ) + ) + +