✨ 应收账单保存计费失败状态并导出说明及黄色标注

This commit is contained in:
caojia
2026-10-14 17:50:03 +08:00
parent 996d854492
commit e4438d6f01
8 changed files with 157 additions and 51 deletions
@@ -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("<col\\b([^>]*)/\\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<PrcIncomeBillFee> sourceFees = sourceFeeMapper.selectUnbilledByBillIds(
currentSourceBillIds, ArIncomeBillFeeStatus.UNBILLED.getCode());
if (sourceFees == null || sourceFees.isEmpty()) {
throw new ServiceException("当前批次没有可生成的未出账费用");
if (sourceFees == null) {
sourceFees = new ArrayList<>();
}
Map<String, Integer> sourceFeeCounts = new HashMap<>();
List<String> 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<ArBillDetail> 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<String, ArBillDetail> 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("<sheetData");
int sheetDataOpenEnd = templateXml.indexOf('>', sheetDataStart);
int sheetDataEnd = templateXml.indexOf("</sheetData>", 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 r=\"").append(rowNumber).append("\" ht=\"24.0\" customHeight=\"true\" spans=\"1:20\">");
row.append("<row r=\"").append(rowNumber).append("\" ht=\"24.0\" customHeight=\"true\" spans=\"1:21\">");
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("</row>");
writeUtf8(output, row.toString());
}
private void appendOoxmlCell(StringBuilder row, String reference, Object value) {
row.append("<c r=\"").append(reference).append("\" s=\"").append(TEMPLATE_DETAIL_STYLE_INDEX).append("\"");
private String exportBillingFailDescription(Integer billingFailStatus) {
BillBillingFailStatus status = BillBillingFailStatus.of(billingFailStatus);
if (status == null) {
return "";
}
return switch (status) {
case PRODUCT -> "未匹配产品";
case COUNTRY -> "未匹配国家";
case WEIGHT -> "未匹配重量段";
case SUCCESS -> "";
};
}
private void appendOoxmlCell(StringBuilder row, String reference, Object value, int styleIndex) {
row.append("<c r=\"").append(reference).append("\" s=\"").append(styleIndex).append("\"");
if (value instanceof Number) {
row.append(" t=\"n\"><v>").append(numberText((Number) value)).append("</v></c>");
} else {