增加产品价和红线价重计费功能

This commit is contained in:
wuweihua
2026-10-15 16:09:05 +08:00
parent 41ce05a08a
commit 071e7f0c55
9 changed files with 615 additions and 25 deletions
@@ -41,6 +41,7 @@ import com.bms.price.domain.vo.BatchAddIncomeBillFeeRequest;
import com.bms.price.domain.vo.BillingTaskSubmitRequest;
import com.bms.price.domain.vo.ChargeWeightImportRow;
import com.bms.price.domain.vo.ChargeWeightTemplateRequest;
import com.bms.price.domain.vo.RecalcBillPriceRequest;
import com.bms.price.domain.vo.UpdateChargeWeightRequest;
import com.bms.price.domain.vo.UpdateIncomeBillFeeRequest;
import com.bms.price.domain.vo.TmsPriceBillRequest;
@@ -303,6 +304,53 @@ public class BillingController extends BaseController
return success(task);
}
/** Async recalc product_amount only. Poll /bill/recalcPrice/task/{taskId}. */
@PreAuthorize("@ss.hasPermi('price:billing:recalc')")
@Log(title = "Billing recalc product amount", businessType = BusinessType.UPDATE)
@PostMapping("/bill/recalcProduct")
public AjaxResult submitRecalcProductAmount(@RequestBody RecalcBillPriceRequest request)
{
if (request == null)
{
request = new RecalcBillPriceRequest();
}
request.setMode("PRODUCT");
return successTask(prcOrderBillService.submitRecalcBillPrice(request));
}
/** Async recalc redline_amount only. Poll /bill/recalcPrice/task/{taskId}. */
@PreAuthorize("@ss.hasPermi('price:billing:recalc')")
@Log(title = "Billing recalc redline amount", businessType = BusinessType.UPDATE)
@PostMapping("/bill/recalcRedline")
public AjaxResult submitRecalcRedlineAmount(@RequestBody RecalcBillPriceRequest request)
{
if (request == null)
{
request = new RecalcBillPriceRequest();
}
request.setMode("REDLINE");
return successTask(prcOrderBillService.submitRecalcBillPrice(request));
}
@PreAuthorize("@ss.hasPermi('price:billing:recalc')")
@GetMapping("/bill/recalcPrice/task/{taskId}")
public AjaxResult getRecalcBillPriceTask(@PathVariable String taskId)
{
BatchAddIncomeBillFeeTask task = prcOrderBillService.getRecalcBillPriceTask(taskId);
if (task == null)
{
return error(MessageUtils.message("price.bill.recalc.taskNotExists"));
}
return success(task);
}
private AjaxResult successTask(String taskId)
{
Map<String, Object> payload = new HashMap<String, Object>();
payload.put("taskId", taskId);
return success(payload);
}
@PreAuthorize("@ss.hasPermi('price:billing:feeEdit')")
@Log(title = "Billing income fee", businessType = BusinessType.UPDATE)
@PutMapping("/bill/fee")
@@ -187,6 +187,14 @@ price.bill.fee.batch.noMatch=未查询到匹配的计费订单,无法批量增
price.bill.fee.batch.filterRequired=请先在计费列表设置查询条件
price.bill.fee.batch.selectedRequired=请勾选要增加费用的订单
price.bill.fee.batch.scopeRequired=请选择添加范围
price.bill.recalc.taskNotExists=重算价格任务不存在或已过期
price.bill.recalc.tooMany=匹配订单超过上限 {0} 条,请缩小筛选范围
price.bill.recalc.noMatch=未查询到已计费订单,无法重算产品价/红线价
price.bill.recalc.filterRequired=请先在计费列表设置查询条件
price.bill.recalc.selectedRequired=请勾选要重算的订单
price.bill.recalc.modeRequired=请指定重算类型(PRODUCT/产品价或 REDLINE/红线价)
price.bill.recalc.scopeRequired=请选择重算范围
price.bill.recalc.quoteEmpty=计价结果为空,无法更新产品价/红线价
price.bill.fee.surchargeNotFound=附加费配置不存在或已停用
price.bill.fee.notFound=费用明细不存在
price.bill.fee.notManual=仅手动增加的费用可编辑或删除
@@ -0,0 +1,28 @@
package com.bms.price.domain.vo;
import java.io.Serializable;
import java.util.List;
import lombok.Data;
/** Batch recalc product_amount or redline_amount only (existing bills). */
@Data
public class RecalcBillPriceRequest implements Serializable
{
private static final long serialVersionUID = 1L;
/** PRODUCT | REDLINE */
private String mode;
/** ALL = filters; SELECTED = billIds. */
private String scopeMode;
private List<String> billIds;
private List<String> orderNos;
private List<String> trackingNos;
private List<String> transferNos;
private List<String> customerCodes;
private List<String> productCodes;
private String countryCode;
private String creditArrearsStatus;
private String beginBizDate;
private String endBizDate;
}
@@ -10,6 +10,7 @@ import com.bms.price.domain.vo.BatchAddIncomeBillFeePreview;
import com.bms.price.domain.vo.BatchAddIncomeBillFeeRequest;
import com.bms.price.domain.vo.ChargeWeightImportRow;
import com.bms.price.domain.vo.ChargeWeightTemplateRequest;
import com.bms.price.domain.vo.RecalcBillPriceRequest;
import com.bms.price.domain.vo.UpdateIncomeBillFeeRequest;
import com.bms.price.domain.vo.TmsPriceBillRequest;
import com.bms.price.domain.vo.TmsPriceQuoteResult;
@@ -111,4 +112,21 @@ public interface IPrcOrderBillService
* otherwise fill orderNo/customer from list filters (data-scoped).
*/
List<ChargeWeightImportRow> buildChargeWeightTemplateRows(ChargeWeightTemplateRequest request);
/**
* Submit async recalc of product_amount or redline_amount (request.mode).
* Existing bills only; no AR restriction; sales amounts untouched.
*
* @return taskId
*/
String submitRecalcBillPrice(RecalcBillPriceRequest request);
/** Poll Redis progress for product/redline recalc task. */
BatchAddIncomeBillFeeTask getRecalcBillPriceTask(String taskId);
/**
* Recalc and persist product_amount or redline_amount by mode (PRODUCT/REDLINE).
* Requires existing prc_order_bill; skips AR editable guard.
*/
void recalcBillPriceByBillId(String billId, String mode);
}
@@ -49,10 +49,12 @@ import com.bms.price.domain.vo.BatchAddIncomeBillFeePreview;
import com.bms.price.domain.vo.BatchAddIncomeBillFeeRequest;
import com.bms.price.domain.vo.ChargeWeightImportRow;
import com.bms.price.domain.vo.ChargeWeightTemplateRequest;
import com.bms.price.domain.vo.RecalcBillPriceRequest;
import com.bms.price.domain.vo.UpdateIncomeBillFeeRequest;
import com.bms.price.task.PrcBatchAddIncomeFeeAsyncRunner;
import com.bms.price.task.PrcChargeWeightImportAsyncRunner;
import com.bms.price.task.PrcChargeWeightImportAsyncRunner.ChargeWeightImportWorkItem;
import com.bms.price.task.PrcRecalcBillPriceAsyncRunner;
import com.bms.price.domain.vo.TmsPriceBillRequest;
import com.bms.price.domain.vo.TmsOrderBillStatusResult;
import com.bms.price.domain.vo.TmsPriceQuoteFeeLine;
@@ -137,9 +139,13 @@ public class PrcOrderBillServiceImpl implements IPrcOrderBillService
@Autowired
private PrcChargeWeightImportAsyncRunner chargeWeightImportAsyncRunner;
@Autowired
private PrcRecalcBillPriceAsyncRunner recalcBillPriceAsyncRunner;
private static final int BATCH_ADD_FEE_MAX = 5000;
private static final int BATCH_ADD_FEE_PREVIEW_LIMIT = 20;
private static final int CHARGE_WEIGHT_IMPORT_MAX = 5000;
private static final int RECALC_PRICE_MAX = 5000;
@Override
@Transactional(rollbackFor = Exception.class)
@@ -591,34 +597,208 @@ public class PrcOrderBillServiceImpl implements IPrcOrderBillService
return batchAddIncomeFeeAsyncRunner.get(taskId);
}
/**
* Resolve matched bills with data scope.
* scopeMode required: SELECTED uses billIds; ALL uses list filters (at least one required).
*/
@Override
public String submitRecalcBillPrice(RecalcBillPriceRequest request)
{
if (request == null)
{
throw new ServiceException(MessageUtils.message("price.bill.recalc.filterRequired"));
}
String mode = normalizeRecalcMode(request.getMode());
List<PrcOrderBill> bills = resolveListScopedBills(request.getScopeMode(), request.getBillIds(),
request.getOrderNos(), request.getTrackingNos(), request.getTransferNos(),
request.getCustomerCodes(), request.getProductCodes(), request.getCountryCode(),
request.getCreditArrearsStatus(), request.getBeginBizDate(), request.getEndBizDate(),
"price.bill.recalc.filterRequired", "price.bill.recalc.scopeRequired",
"price.bill.recalc.selectedRequired");
if (bills.isEmpty())
{
throw new ServiceException(MessageUtils.message("price.bill.recalc.noMatch"));
}
if (bills.size() > RECALC_PRICE_MAX)
{
throw new ServiceException(MessageUtils.message("price.bill.recalc.tooMany",
Integer.valueOf(RECALC_PRICE_MAX)));
}
List<String> billIds = new ArrayList<String>();
for (PrcOrderBill bill : bills)
{
if (bill != null && StringUtils.isNotEmpty(bill.getBillId()))
{
billIds.add(bill.getBillId());
}
}
if (billIds.isEmpty())
{
throw new ServiceException(MessageUtils.message("price.bill.recalc.noMatch"));
}
String taskId = IdUtils.fastSimpleUUID();
recalcBillPriceAsyncRunner.put(taskId, BatchAddIncomeBillFeeTask.STATUS_PENDING, 0, "queued", null,
billIds.size(), 0, 0);
recalcBillPriceAsyncRunner.run(taskId, mode, billIds);
return taskId;
}
@Override
public BatchAddIncomeBillFeeTask getRecalcBillPriceTask(String taskId)
{
if (StringUtils.isEmpty(taskId) || recalcBillPriceAsyncRunner == null)
{
return null;
}
return recalcBillPriceAsyncRunner.get(taskId);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void recalcBillPriceByBillId(String billId, String mode)
{
boolean productMode = "PRODUCT".equals(normalizeRecalcMode(mode));
if (StringUtils.isEmpty(billId))
{
throw new ServiceException(MessageUtils.message("price.bill.billIdRequired"));
}
PrcOrderBill bill = orderBillMapper.selectPrcOrderBillById(billId.trim());
if (bill == null)
{
throw new ServiceException(MessageUtils.message("price.bill.notFound"));
}
TmsPriceBillRequest req = buildBillRequestFromBill(bill);
if (StringUtils.isEmpty(req.getBizDate()))
{
throw new ServiceException(MessageUtils.message("price.bill.bizDateRequired"));
}
TmsPriceQuoteResult result = tmsPriceQuoteService.calculate(req);
if (result == null)
{
throw new ServiceException(MessageUtils.message("price.bill.recalc.quoteEmpty"));
}
persistProductOrRedlineOnly(bill, result, productMode);
}
private static String normalizeRecalcMode(String mode)
{
String m = StringUtils.nvl(mode, "").trim().toUpperCase();
if (!"PRODUCT".equals(m) && !"REDLINE".equals(m))
{
throw new ServiceException(MessageUtils.message("price.bill.recalc.modeRequired"));
}
return m;
}
/** Update income header + fee snapshots for product or redline only. */
private void persistProductOrRedlineOnly(PrcOrderBill bill, TmsPriceQuoteResult result, boolean productMode)
{
PrcIncomeBill incomeBill = incomeBillMapper.selectByBillId(bill.getBillId());
boolean insert = incomeBill == null;
if (insert)
{
incomeBill = new PrcIncomeBill();
incomeBill.setIncomeBillId(UUID.randomUUID().toString());
incomeBill.setBillId(bill.getBillId());
incomeBill.setCurrency(StringUtils.isEmpty(bill.getCurrency()) ? "CNY" : bill.getCurrency());
incomeBill.setSalesAmount(nz(bill.getIncomeTotal()));
incomeBill.setIncomeTotal(nz(bill.getIncomeTotal()));
incomeBill.setBelongSaler(bill.getBelongSaler());
incomeBill.setBelongSalerCode(bill.getBelongSalerCode());
incomeBill.setSettlementMan(bill.getSettlementMan());
incomeBill.setSettlementManCode(bill.getSettlementManCode());
incomeBill.setCreateBy(bill.getCreateBy());
}
if (productMode)
{
incomeBill.setProductAmount(result.getProductAmount());
}
else
{
incomeBill.setRedlineAmount(result.getRedlineAmount());
}
if (insert)
{
incomeBillMapper.insertPrcIncomeBill(incomeBill);
}
else
{
incomeBill.setUpdateBy(bill.getUpdateBy());
incomeBillMapper.updatePrcIncomeBill(incomeBill);
}
List<PrcIncomeBillFee> fees = incomeBillFeeMapper.selectByBillId(bill.getBillId());
if (fees == null || fees.isEmpty() || result.getFeeDetails() == null)
{
return;
}
java.util.Map<String, TmsPriceQuoteFeeLine> quoteByKey = new java.util.HashMap<String, TmsPriceQuoteFeeLine>();
for (TmsPriceQuoteFeeLine line : result.getFeeDetails())
{
if (line != null && StringUtils.isNotEmpty(line.getFeeType()))
{
quoteByKey.put(feeUniqueKey(line.getFeeType(), line.getScId()), line);
}
}
for (PrcIncomeBillFee fee : fees)
{
if (fee == null || StringUtils.isEmpty(fee.getFeeType()))
{
continue;
}
TmsPriceQuoteFeeLine line = quoteByKey.get(feeUniqueKey(fee.getFeeType(), fee.getScId()));
if (line == null)
{
continue;
}
if (productMode)
{
fee.setProductAmount(line.getProductAmount());
}
else
{
fee.setRedlineAmount(line.getRedlineAmount());
}
incomeBillFeeMapper.updatePrcIncomeBillFee(fee);
}
}
private List<PrcOrderBill> resolveBatchAddFeeBills(BatchAddIncomeBillFeeRequest request)
{
if (request == null)
{
throw new ServiceException(MessageUtils.message("price.bill.fee.batch.filterRequired"));
}
String scopeMode = StringUtils.nvl(request.getScopeMode(), "").trim().toUpperCase();
return resolveListScopedBills(request.getScopeMode(), request.getBillIds(),
request.getOrderNos(), request.getTrackingNos(), request.getTransferNos(),
request.getCustomerCodes(), request.getProductCodes(), request.getCountryCode(),
request.getCreditArrearsStatus(), request.getBeginBizDate(), request.getEndBizDate(),
"price.bill.fee.batch.filterRequired", "price.bill.fee.batch.scopeRequired",
"price.bill.fee.batch.selectedRequired");
}
/**
* Shared list-scope resolve for batch fee / recalc price.
* scopeMode: SELECTED uses billIds; ALL uses filters (at least one required).
*/
private List<PrcOrderBill> resolveListScopedBills(String scopeModeRaw, List<String> billIdsRaw,
List<String> orderNosRaw, List<String> trackingNosRaw, List<String> transferNosRaw,
List<String> customerCodesRaw, List<String> productCodesRaw, String countryCodeRaw,
String creditArrearsStatusRaw, String beginBizDateRaw, String endBizDateRaw,
String msgFilter, String msgScope, String msgSelected)
{
String scopeMode = StringUtils.nvl(scopeModeRaw, "").trim().toUpperCase();
if (!"ALL".equals(scopeMode) && !"SELECTED".equals(scopeMode))
{
throw new ServiceException(MessageUtils.message("price.bill.fee.batch.scopeRequired"));
throw new ServiceException(MessageUtils.message(msgScope));
}
List<String> orderNos = normalizeCodeList(request.getOrderNos());
List<String> trackingNos = normalizeCodeList(request.getTrackingNos());
List<String> transferNos = normalizeCodeList(request.getTransferNos());
List<String> customerCodes = normalizeCodeList(request.getCustomerCodes());
List<String> productCodes = normalizeCodeList(request.getProductCodes());
String creditArrearsStatus = StringUtils.isEmpty(request.getCreditArrearsStatus()) ? null
: request.getCreditArrearsStatus().trim();
String countryCode = StringUtils.isEmpty(request.getCountryCode()) ? null
: request.getCountryCode().trim().toUpperCase();
String beginBizDate = StringUtils.isEmpty(request.getBeginBizDate()) ? null
: request.getBeginBizDate().trim();
String endBizDate = StringUtils.isEmpty(request.getEndBizDate()) ? null
: request.getEndBizDate().trim();
List<String> orderNos = normalizeCodeList(orderNosRaw);
List<String> trackingNos = normalizeCodeList(trackingNosRaw);
List<String> transferNos = normalizeCodeList(transferNosRaw);
List<String> customerCodes = normalizeCodeList(customerCodesRaw);
List<String> productCodes = normalizeCodeList(productCodesRaw);
String creditArrearsStatus = StringUtils.isEmpty(creditArrearsStatusRaw) ? null
: creditArrearsStatusRaw.trim();
String countryCode = StringUtils.isEmpty(countryCodeRaw) ? null
: countryCodeRaw.trim().toUpperCase();
String beginBizDate = StringUtils.isEmpty(beginBizDateRaw) ? null : beginBizDateRaw.trim();
String endBizDate = StringUtils.isEmpty(endBizDateRaw) ? null : endBizDateRaw.trim();
boolean hasFilter = (orderNos != null && !orderNos.isEmpty())
|| (trackingNos != null && !trackingNos.isEmpty())
|| (transferNos != null && !transferNos.isEmpty())
@@ -630,15 +810,15 @@ public class PrcOrderBillServiceImpl implements IPrcOrderBillService
|| StringUtils.isNotEmpty(endBizDate);
if (!hasFilter)
{
throw new ServiceException(MessageUtils.message("price.bill.fee.batch.filterRequired"));
throw new ServiceException(MessageUtils.message(msgFilter));
}
PrcOrderBill query = new PrcOrderBill();
if ("SELECTED".equals(scopeMode))
{
List<String> billIds = normalizeCodeList(request.getBillIds());
List<String> billIds = normalizeCodeList(billIdsRaw);
if (billIds == null || billIds.isEmpty())
{
throw new ServiceException(MessageUtils.message("price.bill.fee.batch.selectedRequired"));
throw new ServiceException(MessageUtils.message(msgSelected));
}
query.setBillIds(billIds);
}
@@ -660,7 +840,6 @@ public class PrcOrderBillServiceImpl implements IPrcOrderBillService
query.getParams().put("endBizDate", endBizDate);
}
}
// Use self proxy so @DataScope applies
List<PrcOrderBill> list = self.selectBillList(query);
return list == null ? new ArrayList<PrcOrderBill>() : list;
}
@@ -0,0 +1,114 @@
package com.bms.price.task;
import java.util.List;
import java.util.concurrent.TimeUnit;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import com.bms.common.core.redis.RedisCache;
import com.bms.common.utils.StringUtils;
import com.bms.price.domain.BatchAddIncomeBillFeeTask;
import com.bms.price.service.IPrcOrderBillService;
/** Async: recalc product_amount or redline_amount only (no AR guard). */
@Component
public class PrcRecalcBillPriceAsyncRunner
{
private static final Logger log = LoggerFactory.getLogger(PrcRecalcBillPriceAsyncRunner.class);
public static final String CACHE_KEY_PREFIX = "price:billing:recalcPrice:task:";
public static final int TTL_MINUTES = 120;
@Autowired
@Lazy
private IPrcOrderBillService orderBillService;
@Autowired
private RedisCache redisCache;
@Async("threadPoolTaskExecutor")
public void run(String taskId, String mode, List<String> billIds)
{
int total = billIds == null ? 0 : billIds.size();
int success = 0;
int fail = 0;
StringBuilder failTips = new StringBuilder();
try
{
put(taskId, BatchAddIncomeBillFeeTask.STATUS_RUNNING, 1, "running", null, total, 0, 0);
if (total == 0)
{
put(taskId, BatchAddIncomeBillFeeTask.STATUS_FAIL, 100, "failed", "no bills", 0, 0, 0);
return;
}
for (int i = 0; i < total; i++)
{
String billId = billIds.get(i);
try
{
orderBillService.recalcBillPriceByBillId(billId, mode);
success++;
}
catch (Exception e)
{
fail++;
appendFail(failTips, billId, e);
log.warn("recalc price failed taskId={} mode={} billId={}", taskId, mode, billId, e);
}
int percent = Math.min(99, (int) Math.floor(100.0 * (i + 1) / total));
put(taskId, BatchAddIncomeBillFeeTask.STATUS_RUNNING, percent,
"processing " + (i + 1) + "/" + total, null, total, success, fail);
}
String msg = "success=" + success + ", fail=" + fail;
put(taskId, BatchAddIncomeBillFeeTask.STATUS_SUCCESS, 100, msg,
fail > 0 && failTips.length() > 0 ? failTips.toString() : null, total, success, fail);
log.info("recalc price done taskId={} mode={} total={} success={} fail={}",
taskId, mode, total, success, fail);
}
catch (Exception e)
{
log.error("recalc price aborted taskId={} mode={}", taskId, mode, e);
put(taskId, BatchAddIncomeBillFeeTask.STATUS_FAIL, 100, "failed", errorMessage(e), total, success, fail);
}
}
public void put(String taskId, String status, int percent, String message, String error,
int total, int success, int fail)
{
BatchAddIncomeBillFeeTask task = new BatchAddIncomeBillFeeTask();
task.setTaskId(taskId);
task.setStatus(status);
task.setPercent(percent);
task.setMessage(message);
task.setError(error);
task.setTotal(total);
task.setSuccess(success);
task.setFail(fail);
redisCache.setCacheObject(CACHE_KEY_PREFIX + taskId, task, TTL_MINUTES, TimeUnit.MINUTES);
}
public BatchAddIncomeBillFeeTask get(String taskId)
{
return redisCache.getCacheObject(CACHE_KEY_PREFIX + taskId);
}
private static void appendFail(StringBuilder failTips, String billId, Exception e)
{
if (failTips.length() >= 800)
{
return;
}
if (failTips.length() > 0)
{
failTips.append("; ");
}
failTips.append(billId).append(":").append(errorMessage(e));
}
private static String errorMessage(Exception e)
{
return StringUtils.isEmpty(e.getMessage()) ? e.getClass().getSimpleName() : e.getMessage();
}
}
@@ -0,0 +1,27 @@
-- billing list: recalc product/redline amount (async; no AR restriction)
SET NAMES utf8mb4;
INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
SELECT 2332, N'重计费', m.menu_id, 22, '', NULL, 1, 0, 'F', '0', '0', 'price:billing:recalc', '#', 'admin', sysdate(), N'计费列表异步重算产品价/红线价(已出账也可)'
FROM (
SELECT menu_id FROM sys_menu
WHERE perms = 'price:billing:bill' AND menu_type = 'C'
UNION ALL
SELECT menu_id FROM sys_menu
WHERE component = 'price/billing/bill' AND menu_type = 'C'
) m
WHERE NOT EXISTS (SELECT 1 FROM sys_menu x WHERE x.menu_id = 2332 OR x.perms = 'price:billing:recalc')
LIMIT 1;
INSERT INTO sys_role_menu (role_id, menu_id)
SELECT 1, 2332
FROM DUAL
WHERE EXISTS (SELECT 1 FROM sys_menu WHERE menu_id = 2332)
AND NOT EXISTS (SELECT 1 FROM sys_role_menu rm WHERE rm.role_id = 1 AND rm.menu_id = 2332);
-- cleanup old split permissions if previously inserted
DELETE FROM sys_role_menu WHERE menu_id = 2333;
DELETE FROM sys_menu WHERE menu_id = 2333 OR perms = 'price:billing:recalcRedline';
UPDATE sys_menu SET menu_name = N'重计费', perms = 'price:billing:recalc',
remark = N'计费列表异步重算产品价/红线价(已出账也可)'
WHERE menu_id = 2332 OR perms IN ('price:billing:recalcProduct', 'price:billing:recalc');