提单费用均摊处理

This commit is contained in:
liuminghui
2026-11-10 17:24:52 +08:00
parent 3c49175cea
commit fbc138f041
23 changed files with 1355 additions and 32 deletions
@@ -16,10 +16,12 @@ import com.bms.common.core.domain.AjaxResult;
import com.bms.common.core.page.TableDataInfo;
import com.bms.common.enums.BusinessType;
import com.bms.common.utils.StringUtils;
import com.bms.price.airbill.domain.SrvBillAirAllocCommand;
import com.bms.price.airbill.domain.SrvBillAirFeeLog;
import com.bms.price.airbill.domain.SrvBillAirFeeSave;
import com.bms.price.domain.vo.SrvBillAirQuery;
import com.bms.price.domain.vo.SrvBillAirVo;
import com.bms.price.service.ISrvBillAirAllocService;
import com.bms.price.service.ISrvBillAirFeeService;
import com.bms.price.service.ISrvBillAirService;
@@ -36,6 +38,9 @@ public class SrvBillAirController extends BaseController
@Autowired
private ISrvBillAirFeeService srvBillAirFeeService;
@Autowired
private ISrvBillAirAllocService srvBillAirAllocService;
/**
* 提单列表
*/
@@ -94,4 +99,57 @@ public class SrvBillAirController extends BaseController
{
return success(srvBillAirFeeService.save(command));
}
/** 已分摊的费用改为已确定 */
@PreAuthorize("@ss.hasPermi('price:airBill:feeConfirm')")
@Log(title = "提单费用确定", businessType = BusinessType.UPDATE)
@PostMapping("/fee/confirm")
public AjaxResult confirmFee(@RequestParam("billCode") String billCode, @RequestParam("feeId") Long feeId)
{
return success(srvBillAirFeeService.confirm(billCode, feeId));
}
/** 作废已分摊或已确定的费用,并逻辑删除已分摊到订单的明细 */
@PreAuthorize("@ss.hasPermi('price:airBill:feeVoid')")
@Log(title = "提单费用作废", businessType = BusinessType.DELETE)
@PostMapping("/fee/void")
public AjaxResult voidFee(@RequestParam("billCode") String billCode, @RequestParam("feeId") Long feeId)
{
return success(srvBillAirFeeService.voidFee(billCode, feeId));
}
/** 勾选提单后,查出需要分摊到订单的费用 */
@PreAuthorize("@ss.hasPermi('price:airBill:fee')")
@GetMapping("/fee/alloc/preview")
public AjaxResult allocPreview(@RequestParam("billCodes") String billCodes)
{
return success(srvBillAirAllocService.preview(splitCodes(billCodes)));
}
/** 按重量或订单数把费用分摊到订单,并把费用状态改为已分摊 */
@PreAuthorize("@ss.hasPermi('price:airBill:fee')")
@Log(title = "提单费用分摊", businessType = BusinessType.UPDATE)
@PostMapping("/fee/alloc")
public AjaxResult allocate(@RequestBody SrvBillAirAllocCommand command)
{
List<String> billCodes = command == null ? null : command.getBillCodes();
return success(srvBillAirAllocService.allocate(billCodes));
}
private static List<String> splitCodes(String billCodes)
{
List<String> codes = new ArrayList<>();
if (StringUtils.isEmpty(billCodes))
{
return codes;
}
for (String part : billCodes.split("[,,\\s]+"))
{
if (StringUtils.isNotEmpty(part))
{
codes.add(part.trim());
}
}
return codes;
}
}
@@ -0,0 +1,11 @@
package com.bms.price.airbill.domain;
import java.util.List;
import lombok.Data;
/** 提单费用分摊请求。一次最多 5 个提单号。 */
@Data
public class SrvBillAirAllocCommand
{
private List<String> billCodes;
}
@@ -0,0 +1,45 @@
package com.bms.price.airbill.domain;
import java.math.BigDecimal;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Data;
/** 待分摊的一条提单费用,供费用处理弹框展示。 */
@Data
public class SrvBillAirAllocFee
{
private Long id;
private String billCode;
/** 提单主键 srv_bill_air.guid,写入订单费用的 source_id */
private String ladingId;
/** 费用阶段,字典 bms_fee_level */
private String feeLevel;
/** 费用状态,字典 bill_fee_status */
private String billFeeStatus;
private String feeName;
private BigDecimal price;
private String currency;
/** 分摊维度:1订单 2重量 */
private String allocDim;
/** 提单实际重量。只在按重量分摊时按提单号填入,不返回页面 */
@JsonIgnore
private BigDecimal actualWeight;
/** 干线打板重量。只在按重量分摊时按提单号填入,不返回页面 */
@JsonIgnore
private BigDecimal bandBoxWeight;
private Integer orderCount;
/** 不能分摊时的原因。空表示可以分摊 */
private String blockReason;
}
@@ -0,0 +1,19 @@
package com.bms.price.airbill.domain;
import java.math.BigDecimal;
import lombok.Data;
/** 提单下参与分摊的一票订单。 */
@Data
public class SrvBillAirAllocOrder
{
private String billCode;
private String orderNo;
/** 计费订单 ID,对应 prc_order_bill.bill_id */
private String billId;
/** 计费重,没有计费重时依次取实际重量、收货重量 */
private BigDecimal chargeWeight;
}
@@ -17,6 +17,12 @@ public class SrvBillAirFeeLog
public static final String DELETE = "3";
/** 已分摊费用确定。日志不记录字段变化。 */
public static final String CONFIRM = "4";
/** 已分摊或已确定费用作废。 */
public static final String VOID = "5";
private Long logId;
private String billCode;
@@ -19,6 +19,9 @@ public class PrcPayBillFee implements Serializable
private String feeType;
private String feeName;
private Long scId;
/** 费用所属阶段:1 a段,2 b段,3 c段,4 d段 */
private String feeLevel;
private BigDecimal amount;
private String currency;
private String roundMode;
@@ -38,4 +38,21 @@ public interface PrcPayBillFeeMapper
* @return 影响行数
*/
public int deleteByBillId(String billId);
/**
* 逻辑删除某一条提单费用分摊到订单上的明细。
*
* @param feeId 提单费用 ID,保存在 sc_id
* @return 影响行数
*/
public int markLadingFeeDeleted(@Param("feeId") Long feeId);
/**
* 这条提单费用分摊到的订单里,取一个已经生成应付账单的订单号。
* 是否出账看 prc_order_bill_ext.is_meet:1 已生成。
*
* @param feeId 提单费用 ID,保存在 sc_id
* @return 订单号,没有已出账订单时为空
*/
public String selectBilledOrderNo(@Param("feeId") Long feeId);
}
@@ -0,0 +1,56 @@
package com.bms.price.mapper;
import java.util.Date;
import java.util.List;
import org.apache.ibatis.annotations.Param;
import com.bms.price.airbill.domain.SrvBillAirAllocFee;
import com.bms.price.airbill.domain.SrvBillAirAllocOrder;
import com.bms.price.domain.PrcPayBill;
/**
* 提单费用分摊到订单。
*/
public interface SrvBillAirAllocMapper
{
/**
* 查询这些提单上尚未分摊、且标记为需要分摊的费用。
*
* @param billCodes 提单号
* @return 待分摊费用
*/
List<SrvBillAirAllocFee> selectPendingFees(@Param("billCodes") List<String> billCodes);
/**
* 按提单号取出实际重量和打板重量,只供按重量分摊计算。
*
* @param billCodes 提单号
* @return 提单号、实际重量、打板重量
*/
List<SrvBillAirAllocFee> selectLadingWeight(@Param("billCodes") List<String> billCodes);
/**
* 按提单号查出关联订单。订单号在扩展表,计费单号和重量在订单表。
*
* @param billCodes 提单号,对应 prc_order_bill_ext.air_no_old
* @return 订单
*/
List<SrvBillAirAllocOrder> selectOrders(@Param("billCodes") List<String> billCodes);
/**
* 按计费订单号取出已有的应付主表。
*
* @param billIds 计费订单 ID
* @return 应付主表编号
*/
List<PrcPayBill> selectPayBills(@Param("billIds") List<String> billIds);
/**
* 把仍是未分摊的费用改为已分摊。已经不是未分摊的行不会被改到。
*
* @param ids 提单费用 ID
* @param updateBy 操作人
* @param updateTime 操作时间
* @return 实际改到的行数
*/
int markAllocated(@Param("ids") List<Long> ids, @Param("updateBy") String updateBy, @Param("updateTime") Date updateTime);
}
@@ -22,6 +22,14 @@ public interface SrvBillAirFeeMapper
int deleteFee(@Param("id") Long id, @Param("billCode") String billCode, @Param("updateBy") String updateBy,
@Param("updateTime") java.util.Date updateTime);
/** 已分摊改为已确定。 */
int confirmFee(@Param("id") Long id, @Param("billCode") String billCode, @Param("updateBy") String updateBy,
@Param("updateTime") java.util.Date updateTime);
/** 已分摊或已确定的费用作废:状态改为已作废,并逻辑删除。 */
int voidFee(@Param("id") Long id, @Param("billCode") String billCode, @Param("updateBy") String updateBy,
@Param("updateTime") java.util.Date updateTime);
int insertLog(SrvBillAirFeeLog log);
List<SrvBillAirFeeLog> selectLogs(String billCode);
@@ -0,0 +1,26 @@
package com.bms.price.service;
import java.util.List;
import com.bms.price.airbill.domain.SrvBillAirAllocFee;
/**
* 把提单上待分摊的费用均摊到关联订单。
*/
public interface ISrvBillAirAllocService
{
/**
* 查出勾选提单上需要分摊、且尚未分摊的费用。
*
* @param billCodes 提单号,最多 5 个
* @return 费用和不能分摊的原因
*/
List<SrvBillAirAllocFee> preview(List<String> billCodes);
/**
* 按费用上的分摊维度把金额摊到每个订单,并改费用状态为已分摊。整次处理在一个事务里,写入按 1000 条一批。
*
* @param billCodes 提单号,最多 5 个
* @return 给页面的处理结果
*/
String allocate(List<String> billCodes);
}
@@ -16,4 +16,22 @@ public interface ISrvBillAirFeeService
/** 只写入有变动的行,并按行记录修改前后。返回给页面的提示。 */
String save(SrvBillAirFeeSave command);
/**
* 已分摊的费用确认为已确定。
*
* @param billCode 提单号
* @param feeId 费用 ID
* @return 给页面的提示
*/
String confirm(String billCode, Long feeId);
/**
* 作废已分摊或已确定的费用。订单上的分摊明细逻辑删除,这条提单费用也逻辑删除。
*
* @param billCode 提单号
* @param feeId 费用 ID
* @return 给页面的提示
*/
String voidFee(String billCode, Long feeId);
}
@@ -0,0 +1,472 @@
package com.bms.price.service.impl;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.alibaba.fastjson2.JSON;
import com.bms.common.core.domain.entity.SysUser;
import com.bms.common.exception.ServiceException;
import com.bms.common.utils.DateUtils;
import com.bms.common.utils.SecurityUtils;
import com.bms.price.airbill.domain.SrvBillAirAllocFee;
import com.bms.price.airbill.domain.SrvBillAirAllocOrder;
import com.bms.price.airbill.domain.SrvBillAirFeeLog;
import com.bms.price.domain.PrcPayBill;
import com.bms.price.domain.PrcPayBillFee;
import com.bms.price.mapper.SrvBillAirAllocMapper;
import com.bms.price.mapper.SrvBillAirFeeMapper;
import com.bms.price.mapper.SrvBillAirMapper;
import com.bms.price.mapper.PrcPayBillFeeMapper;
import com.bms.price.service.ISrvBillAirAllocService;
/**
* 提单费用分摊。只处理标记为分摊、状态仍是未分摊的费用。
*/
@Service
public class SrvBillAirAllocServiceImpl implements ISrvBillAirAllocService
{
private static final int MAX_BILLS = 5;
private static final int BATCH_SIZE = 1000;
private static final String DIM_ORDER = "1";
private static final String DIM_WEIGHT = "2";
private static final String FEE_TYPE = "LADING";
private static final String FEE_SOURCE = "LADING";
private static final BigDecimal ZERO = BigDecimal.ZERO;
@Autowired
private SrvBillAirAllocMapper allocMapper;
@Autowired
private SrvBillAirMapper billMapper;
@Autowired
private SrvBillAirFeeMapper feeMapper;
@Autowired
private PrcPayBillFeeMapper payBillFeeMapper;
@Override
public List<SrvBillAirAllocFee> preview(List<String> billCodes)
{
List<String> codes = normalize(billCodes);
List<SrvBillAirAllocFee> fees = dedupe(allocMapper.selectPendingFees(codes));
fillWeight(fees);
Map<String, List<SrvBillAirAllocOrder>> orders = ordersOf(codes);
for (SrvBillAirAllocFee fee : fees)
{
List<SrvBillAirAllocOrder> rows = orders.getOrDefault(fee.getBillCode(), List.of());
fee.setOrderCount(rows.size());
fee.setBlockReason(blockReason(fee, rows));
}
return fees;
}
@Override
@Transactional(rollbackFor = Exception.class)
public String allocate(List<String> billCodes)
{
List<String> codes = normalize(billCodes);
List<SrvBillAirAllocFee> fees = dedupe(allocMapper.selectPendingFees(codes));
fillWeight(fees);
if (fees.isEmpty())
{
throw new ServiceException("所选提单没有需要分摊的费用");
}
Map<String, List<SrvBillAirAllocOrder>> orders = ordersOf(codes);
for (SrvBillAirAllocFee fee : fees)
{
List<SrvBillAirAllocOrder> rows = orders.getOrDefault(fee.getBillCode(), List.of());
fee.setOrderCount(rows.size());
String reason = blockReason(fee, rows);
if (!reason.isEmpty())
{
throw new ServiceException(fee.getBillCode() + " " + blankName(fee) + ":" + reason);
}
}
Map<String, String> payBillIds = payBillIds(orders);
List<PrcPayBillFee> batch = new ArrayList<>(BATCH_SIZE);
int lineCount = 0;
for (SrvBillAirAllocFee fee : fees)
{
List<SrvBillAirAllocOrder> rows = orders.get(fee.getBillCode());
fee.setOrderCount(rows.size());
boolean byWeight = DIM_WEIGHT.equals(fee.getAllocDim());
List<BigDecimal> shares = split(byWeight ? weightAmount(fee) : fee.getPrice(), rows, byWeight);
for (int i = 0; i < rows.size(); i++)
{
batch.add(toOrderFee(fee, rows.get(i), shares.get(i), payBillIds));
if (batch.size() == BATCH_SIZE)
{
lineCount += flush(batch);
}
}
}
lineCount += flush(batch);
Date now = DateUtils.getNowDate();
String operator = SecurityUtils.getUsername();
int updated = allocMapper.markAllocated(feeIds(fees), operator, now);
if (updated != fees.size())
{
throw new ServiceException("费用状态已变化,请重新打开后再分摊");
}
writeLogs(fees, operator, now);
return "已将 " + fees.size() + " 条费用分摊为 " + lineCount + " 笔订单费用";
}
/**
* 按重量分摊的费用,按提单号补上实际重量和打板重量。按订单数量分摊的不查、不填。
* 这两个值只参与计算,不返回给页面。
*/
private void fillWeight(List<SrvBillAirAllocFee> fees)
{
List<String> codes = new ArrayList<>();
for (SrvBillAirAllocFee fee : fees)
{
if (fee != null && DIM_WEIGHT.equals(fee.getAllocDim()) && fee.getBillCode() != null && !codes.contains(fee.getBillCode()))
{
codes.add(fee.getBillCode());
}
}
if (codes.isEmpty())
{
return;
}
Map<String, SrvBillAirAllocFee> weights = new LinkedHashMap<>();
for (SrvBillAirAllocFee row : allocMapper.selectLadingWeight(codes))
{
if (row != null && row.getBillCode() != null)
{
weights.put(row.getBillCode(), row);
}
}
for (SrvBillAirAllocFee fee : fees)
{
if (fee == null || !DIM_WEIGHT.equals(fee.getAllocDim()))
{
continue;
}
SrvBillAirAllocFee weight = weights.get(fee.getBillCode());
if (weight == null)
{
continue;
}
fee.setActualWeight(weight.getActualWeight());
fee.setBandBoxWeight(weight.getBandBoxWeight());
}
}
/** 提单号去重、去空白,并限制单次 5 条。 */
private List<String> normalize(List<String> billCodes)
{
LinkedHashSet<String> codes = new LinkedHashSet<>();
if (billCodes != null)
{
for (String raw : billCodes)
{
if (raw == null)
{
continue;
}
for (String part : raw.split("[,,\\s]+"))
{
if (!part.isBlank())
{
codes.add(part.trim());
}
}
}
}
if (codes.isEmpty())
{
throw new ServiceException("请勾选提单");
}
if (codes.size() > MAX_BILLS)
{
throw new ServiceException("单次最多处理5条提单");
}
for (String code : codes)
{
if (billMapper.selectSrvBillAirByBillCode(code) == null)
{
throw new ServiceException("提单不存在:" + code);
}
}
return new ArrayList<>(codes);
}
private Map<String, List<SrvBillAirAllocOrder>> ordersOf(List<String> codes)
{
Map<String, List<SrvBillAirAllocOrder>> grouped = new LinkedHashMap<>();
for (SrvBillAirAllocOrder order : allocMapper.selectOrders(codes))
{
if (order.getBillId() == null || order.getBillId().isBlank())
{
continue;
}
grouped.computeIfAbsent(order.getBillCode(), key -> new ArrayList<>()).add(order);
}
return grouped;
}
private Map<String, String> payBillIds(Map<String, List<SrvBillAirAllocOrder>> orders)
{
List<String> billIds = new ArrayList<>();
for (List<SrvBillAirAllocOrder> rows : orders.values())
{
for (SrvBillAirAllocOrder order : rows)
{
billIds.add(order.getBillId());
}
}
Map<String, String> payIds = new LinkedHashMap<>();
for (int from = 0; from < billIds.size(); from += BATCH_SIZE)
{
List<String> chunk = billIds.subList(from, Math.min(from + BATCH_SIZE, billIds.size()));
for (PrcPayBill payBill : allocMapper.selectPayBills(chunk))
{
if (payBill.getBillId() != null && payBill.getPayBillId() != null)
{
payIds.put(payBill.getBillId(), payBill.getPayBillId());
}
}
}
return payIds;
}
private String blockReason(SrvBillAirAllocFee fee, List<SrvBillAirAllocOrder> orders)
{
if (fee.getPrice() == null)
{
return "费用没有价格";
}
if (fee.getPrice().signum() < 0)
{
return "费用价格不能小于0";
}
if (fee.getCurrency() == null || fee.getCurrency().isBlank())
{
return "费用没有币种";
}
if (fee.getLadingId() == null || fee.getLadingId().isBlank())
{
return "提单缺少主键";
}
if (!DIM_ORDER.equals(fee.getAllocDim()) && !DIM_WEIGHT.equals(fee.getAllocDim()))
{
return "未选择分摊维度";
}
if (orders == null || orders.isEmpty())
{
return "提单下没有关联订单";
}
if (DIM_WEIGHT.equals(fee.getAllocDim()))
{
if (fee.getActualWeight() == null || fee.getActualWeight().signum() <= 0)
{
return "提单没有实际重量,无法按重量分摊";
}
if (fee.getBandBoxWeight() == null || fee.getBandBoxWeight().signum() <= 0)
{
return "干线没有打板重量,无法按重量分摊";
}
if (totalWeight(orders).signum() <= 0)
{
return "订单没有可用重量,无法按重量分摊";
}
}
return "";
}
private PrcPayBillFee toOrderFee(SrvBillAirAllocFee fee, SrvBillAirAllocOrder order, BigDecimal amount, Map<String, String> payBillIds)
{
PrcPayBillFee row = new PrcPayBillFee();
row.setFeeId(UUID.randomUUID().toString());
row.setBillId(order.getBillId());
row.setPayBillId(payBillIds.get(order.getBillId()));
row.setFeeType(FEE_TYPE);
row.setFeeName(blankName(fee));
row.setScId(fee.getId() == null ? 0L : fee.getId());
row.setFeeLevel(fee.getFeeLevel());
row.setAmount(amount);
row.setCurrency(fee.getCurrency().trim());
row.setRoundMode("HALF_UP");
row.setRemark(remark(fee));
row.setFeeSource(FEE_SOURCE);
row.setSourceId(fee.getLadingId().trim());
row.setIsDeleted("0");
return row;
}
private int flush(List<PrcPayBillFee> batch)
{
if (batch.isEmpty())
{
return 0;
}
int rows = batch.size();
payBillFeeMapper.insertBatch(batch);
batch.clear();
return rows;
}
private void writeLogs(List<SrvBillAirAllocFee> fees, String operator, Date now)
{
SysUser user = SecurityUtils.getLoginUser().getUser();
String name = user.getNickName() == null || user.getNickName().isBlank() ? operator : user.getNickName();
for (SrvBillAirAllocFee fee : fees)
{
SrvBillAirFeeLog log = new SrvBillAirFeeLog();
log.setBillCode(fee.getBillCode());
log.setFeeId(fee.getId());
log.setOperationType(SrvBillAirFeeLog.UPDATE);
log.setSummary("分摊到 " + fee.getOrderCount() + " 票订单");
log.setChangeContent(JSON.toJSONString(List.of(
new SrvBillAirFeeLog.FieldChange("billFeeStatus", "费用状态", "未分摊", "已分摊"))));
log.setCreateBy(operator);
log.setCreateByName(name);
log.setCreateTime(now);
feeMapper.insertLog(log);
}
}
/**
* 按重量分摊时,先算出这一票提单实际可分摊的金额。
* 百分比 = 提单实际重量 srv_bill_air.actual_weight / 干线打板重量 srv_bill_air_flight.band_box_weight。
* 可分摊金额 = 费用价格 × 百分比,保留两位,四舍五入。
* 调用前须已确认实际重量和打板重量都大于 0。
*
* @param fee 待分摊的提单费用,带有实际重量和打板重量
* @return 按百分比算出的可分摊金额
*/
private static BigDecimal weightAmount(SrvBillAirAllocFee fee)
{
BigDecimal price = fee.getPrice().setScale(2, RoundingMode.HALF_UP);
return price.multiply(fee.getActualWeight()).divide(fee.getBandBoxWeight(), 2, RoundingMode.HALF_UP);
}
/**
* 把金额拆到每一票订单,结果保留两位。
* 按订单数量:每一票先向下取整,差额补在最后一票,合计等于费用价格。
* 按重量:传入的金额已是「费用价格 × 提单实际重量 / 干线打板重量」。
* 再按订单重量占这些订单总重量的比例分摊;前面的订单向下取整,差额补在最后一票有重量的订单上,合计等于这笔金额。
* 订单重量优先取计费重,没有计费重时取实际重量,再没有取收货重量。
*
* @param price 本次要拆开的金额。按重量时是百分比计算后的可分摊金额
* @param orders 该提单下的订单
* @param byWeight true 按订单重量占比,false 按订单数量均分
* @return 与订单顺序一致的分摊金额
*/
private static List<BigDecimal> split(BigDecimal price, List<SrvBillAirAllocOrder> orders, boolean byWeight)
{
BigDecimal total = price.setScale(2, RoundingMode.HALF_UP);
int count = orders.size();
List<BigDecimal> shares = new ArrayList<>(count);
if (!byWeight)
{
BigDecimal each = total.divide(BigDecimal.valueOf(count), 2, RoundingMode.DOWN);
BigDecimal used = ZERO;
for (int i = 0; i < count - 1; i++)
{
shares.add(each);
used = used.add(each);
}
shares.add(total.subtract(used));
return shares;
}
BigDecimal sum = totalWeight(orders);
int last = count - 1;
for (int i = count - 1; i >= 0; i--)
{
if (weight(orders.get(i)).signum() > 0)
{
last = i;
break;
}
}
BigDecimal used = ZERO;
for (int i = 0; i < count; i++)
{
if (i == last)
{
shares.add(null);
continue;
}
BigDecimal share = total.multiply(weight(orders.get(i))).divide(sum, 2, RoundingMode.DOWN);
shares.add(share);
used = used.add(share);
}
shares.set(last, total.subtract(used));
return shares;
}
private static BigDecimal totalWeight(List<SrvBillAirAllocOrder> orders)
{
BigDecimal sum = ZERO;
for (SrvBillAirAllocOrder order : orders)
{
sum = sum.add(weight(order));
}
return sum;
}
private static BigDecimal weight(SrvBillAirAllocOrder order)
{
return order.getChargeWeight() == null ? ZERO : order.getChargeWeight();
}
private static List<SrvBillAirAllocFee> dedupe(List<SrvBillAirAllocFee> fees)
{
Map<Long, SrvBillAirAllocFee> unique = new LinkedHashMap<>();
if (fees != null)
{
for (SrvBillAirAllocFee fee : fees)
{
if (fee != null && fee.getId() != null)
{
unique.putIfAbsent(fee.getId(), fee);
}
}
}
return new ArrayList<>(unique.values());
}
private static List<Long> feeIds(List<SrvBillAirAllocFee> fees)
{
List<Long> ids = new ArrayList<>();
for (SrvBillAirAllocFee fee : fees)
{
ids.add(fee.getId());
}
return ids;
}
private static String blankName(SrvBillAirAllocFee fee)
{
if (fee.getFeeName() != null && !fee.getFeeName().isBlank())
{
return fee.getFeeName().trim();
}
return "提单费用";
}
private static String remark(SrvBillAirAllocFee fee)
{
String dim = DIM_WEIGHT.equals(fee.getAllocDim()) ? "重量" : "订单";
return "提单" + fee.getBillCode() + "按" + dim + "分摊";
}
}
@@ -22,6 +22,7 @@ import com.bms.price.airbill.domain.SrvBillAirFee;
import com.bms.price.airbill.domain.SrvBillAirFeeLog;
import com.bms.price.airbill.domain.SrvBillAirFeeSave;
import com.bms.price.domain.PrcSurcharge;
import com.bms.price.mapper.PrcPayBillFeeMapper;
import com.bms.price.mapper.SrvBillAirFeeMapper;
import com.bms.price.mapper.SrvBillAirMapper;
import com.bms.price.service.IPrcSurchargeService;
@@ -42,11 +43,18 @@ public class SrvBillAirFeeServiceImpl implements ISrvBillAirFeeService
private static final String STATUS_DICT = "bill_fee_status";
private static final String STATUS_ALLOCATED = "1";
private static final String STATUS_CONFIRMED = "3";
private static final String CURRENCY_DICT = "bms_currency";
@Autowired
private SrvBillAirFeeMapper feeMapper;
@Autowired
private PrcPayBillFeeMapper payBillFeeMapper;
@Autowired
private IPrcSurchargeService surchargeService;
@@ -121,6 +129,10 @@ public class SrvBillAirFeeServiceImpl implements ISrvBillAirFeeService
{
throw new ServiceException("费用已删除或不属于该提单");
}
if (locked(current))
{
throw new ServiceException("已分摊或已确定的费用不能删除,请使用作废");
}
int rows = feeMapper.deleteFee(current.getId(), billCode, operator.account, now);
if (rows > 0)
{
@@ -143,6 +155,10 @@ public class SrvBillAirFeeServiceImpl implements ISrvBillAirFeeService
{
throw new ServiceException("费用已删除或不属于该提单");
}
if (locked(current))
{
throw new ServiceException("已分摊或已确定的费用不能修改");
}
SrvBillAirFee next = normalize(incoming, levelLabels, allocLabels, dimLabels, currencyLabels);
next.setBillFeeStatus(current.getBillFeeStatus() == null || current.getBillFeeStatus().isBlank() ? "0" : current.getBillFeeStatus());
Map<String, String> feeNames = feeNames(current, next);
@@ -187,11 +203,90 @@ public class SrvBillAirFeeServiceImpl implements ISrvBillAirFeeService
return "已保存:新增 " + inserted + " 条,修改 " + updated + " 条,删除 " + removed + " 条";
}
@Override
@Transactional(rollbackFor = Exception.class)
public String confirm(String billCode, Long feeId)
{
String code = requireBill(billCode);
if (feeId == null)
{
throw new ServiceException("请选择要确定的费用");
}
SrvBillAirFee current = liveFee(feeId, code);
if (current == null)
{
throw new ServiceException("费用已删除或不属于该提单");
}
if (!STATUS_ALLOCATED.equals(current.getBillFeeStatus()))
{
throw new ServiceException("只有已分摊的费用可以确定");
}
Operator operator = operator();
Date now = DateUtils.getNowDate();
if (feeMapper.confirmFee(feeId, code, operator.account, now) != 1)
{
throw new ServiceException("费用状态已变化,请刷新后再确定");
}
writePlainLog(code, feeId, SrvBillAirFeeLog.CONFIRM, "确定费用", operator, now);
return "费用已确定";
}
@Override
@Transactional(rollbackFor = Exception.class)
public String voidFee(String billCode, Long feeId)
{
String code = requireBill(billCode);
if (feeId == null)
{
throw new ServiceException("请选择要作废的费用");
}
SrvBillAirFee current = liveFee(feeId, code);
if (current == null)
{
throw new ServiceException("费用已删除或不属于该提单");
}
if (!locked(current))
{
throw new ServiceException("只有已分摊或已确定的费用可以作废");
}
String billedOrderNo = payBillFeeMapper.selectBilledOrderNo(feeId);
if (billedOrderNo != null)
{
String orderNo = billedOrderNo.trim();
if (orderNo.isEmpty())
{
orderNo = "未知订单";
}
throw new ServiceException("该费用存在单笔或多笔订单已出账单-如:" + orderNo + ",不可作废");
}
Operator operator = operator();
Date now = DateUtils.getNowDate();
payBillFeeMapper.markLadingFeeDeleted(feeId);
if (feeMapper.voidFee(feeId, code, operator.account, now) != 1)
{
throw new ServiceException("费用状态已变化,请刷新后再作废");
}
Map<String, String> levelLabels = dictLabels(LEVEL_DICT, "费用阶段");
Map<String, String> allocLabels = dictLabels(ALLOC_DICT, "是否分摊");
Map<String, String> dimLabels = dictLabels(ALLOC_DIM_DICT, "分摊维度");
Map<String, String> statusLabels = dictLabels(STATUS_DICT, "费用状态");
Map<String, String> currencyLabels = dictLabels(CURRENCY_DICT, "币种");
writeLog(code, feeId, SrvBillAirFeeLog.VOID, current, null, feeNames(current, null),
levelLabels, allocLabels, dimLabels, statusLabels, currencyLabels, operator, now);
return "费用已作废";
}
private SrvBillAirFee liveFee(Long id, String billCode)
{
return feeMapper.selectLive(id, billCode);
}
private static boolean locked(SrvBillAirFee fee)
{
String status = fee == null ? "" : text(fee.getBillFeeStatus());
return STATUS_ALLOCATED.equals(status) || STATUS_CONFIRMED.equals(status);
}
private SrvBillAirFee normalize(SrvBillAirFee raw, Map<String, String> levelLabels,
Map<String, String> allocLabels, Map<String, String> dimLabels, Map<String, String> currencyLabels)
{
@@ -270,6 +365,22 @@ public class SrvBillAirFeeServiceImpl implements ISrvBillAirFeeService
return fee;
}
/**
* 费用日志组装
* @param billCode
* @param feeId
* @param operation
* @param before
* @param after
* @param feeLabels
* @param levelLabels
* @param allocLabels
* @param dimLabels
* @param statusLabels
* @param currencyLabels
* @param operator
* @param now
*/
private void writeLog(String billCode, Long feeId, String operation, SrvBillAirFee before, SrvBillAirFee after,
Map<String, String> feeLabels, Map<String, String> levelLabels, Map<String, String> allocLabels,
Map<String, String> dimLabels, Map<String, String> statusLabels, Map<String, String> currencyLabels, Operator operator, Date now)
@@ -287,6 +398,21 @@ public class SrvBillAirFeeServiceImpl implements ISrvBillAirFeeService
feeMapper.insertLog(log);
}
/** 确定费用不比较字段,更改前和更改后都留空。 */
private void writePlainLog(String billCode, Long feeId, String operation, String summary, Operator operator, Date now)
{
SrvBillAirFeeLog log = new SrvBillAirFeeLog();
log.setBillCode(billCode);
log.setFeeId(feeId);
log.setOperationType(operation);
log.setSummary(summary);
log.setChangeContent("[]");
log.setCreateBy(operator.account);
log.setCreateByName(operator.name);
log.setCreateTime(now);
feeMapper.insertLog(log);
}
private static List<SrvBillAirFeeLog.FieldChange> diff(SrvBillAirFee before, SrvBillAirFee after,
Map<String, String> feeLabels, Map<String, String> levelLabels, Map<String, String> allocLabels,
Map<String, String> dimLabels, Map<String, String> statusLabels, Map<String, String> currencyLabels)
@@ -323,7 +449,9 @@ public class SrvBillAirFeeServiceImpl implements ISrvBillAirFeeService
private static String summary(String operation, List<SrvBillAirFeeLog.FieldChange> changes)
{
String action = SrvBillAirFeeLog.INSERT.equals(operation) ? "新增费用"
: SrvBillAirFeeLog.DELETE.equals(operation) ? "删除费用" : "修改费用";
: SrvBillAirFeeLog.DELETE.equals(operation) ? "删除费用"
: SrvBillAirFeeLog.CONFIRM.equals(operation) ? "确定费用"
: SrvBillAirFeeLog.VOID.equals(operation) ? "作废费用" : "修改费用";
StringBuilder text = new StringBuilder(action);
for (SrvBillAirFeeLog.FieldChange change : changes)
{
@@ -11,7 +11,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<result property="feeType" column="fee_type" />
<result property="feeName" column="fee_name" />
<result property="scId" column="sc_id" />
<result property="feeLevel" column="fee_level" />
<result property="amount" column="amount" />
<result property="feeSource" column="fee_source" />
<result property="currency" column="currency" />
<result property="roundMode" column="round_mode" />
<result property="remark" column="remark" />
@@ -21,8 +23,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
</resultMap>
<select id="selectByBillId" parameterType="String" resultMap="PrcPayBillFeeResult">
select fee_id, bill_id, pay_bill_id, fee_type, fee_name, sc_id, amount, currency, round_mode, remark,
source_id, is_deleted, create_time
select fee_id, bill_id, pay_bill_id, fee_type, fee_name, sc_id, fee_level, amount, currency, round_mode, remark,
fee_source, source_id, is_deleted, create_time
from prc_pay_bill_fee
where bill_id = #{billId}
and is_deleted = '0'
@@ -31,25 +33,46 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<insert id="insertPrcPayBillFee" parameterType="PrcPayBillFee">
insert into prc_pay_bill_fee (
fee_id, bill_id, pay_bill_id, fee_type, fee_name, sc_id, amount, currency, round_mode, remark,
source_id, is_deleted, create_time
fee_id, bill_id, pay_bill_id, fee_type, fee_name, sc_id, fee_level, amount, currency, round_mode, remark,
fee_source, source_id, is_deleted, create_time
) values (
#{feeId}, #{billId}, #{payBillId}, #{feeType}, #{feeName}, #{scId}, #{amount}, #{currency}, #{roundMode}, #{remark},
#{sourceId}, ifnull(#{isDeleted}, '0'), sysdate()
#{feeId}, #{billId}, #{payBillId}, #{feeType}, #{feeName}, #{scId}, #{feeLevel}, #{amount}, #{currency}, #{roundMode}, #{remark},
ifnull(#{feeSource}, 'CALC'), #{sourceId}, ifnull(#{isDeleted}, '0'), sysdate()
)
</insert>
<insert id="insertBatch" parameterType="java.util.List">
insert into prc_pay_bill_fee (
fee_id, bill_id, pay_bill_id, fee_type, fee_name, sc_id, amount, currency, round_mode, remark,
source_id, is_deleted, create_time
fee_id, bill_id, pay_bill_id, fee_type, fee_name, sc_id, fee_level, amount, currency, round_mode, remark,
fee_source, source_id, is_deleted, create_time
) values
<foreach collection="list" item="item" separator=",">
(#{item.feeId}, #{item.billId}, #{item.payBillId}, #{item.feeType}, #{item.feeName}, #{item.scId}, #{item.amount},
#{item.currency}, #{item.roundMode}, #{item.remark}, #{item.sourceId}, ifnull(#{item.isDeleted}, '0'), sysdate())
(#{item.feeId}, #{item.billId}, #{item.payBillId}, #{item.feeType}, #{item.feeName}, #{item.scId}, #{item.feeLevel},
#{item.amount}, #{item.currency}, #{item.roundMode}, #{item.remark}, ifnull(#{item.feeSource}, 'CALC'),
#{item.sourceId}, ifnull(#{item.isDeleted}, '0'), sysdate())
</foreach>
</insert>
<select id="selectBilledOrderNo" resultType="String">
select e.order_no
from prc_pay_bill_fee f
inner join prc_order_bill_ext e on e.bill_id = f.bill_id
where f.fee_source = 'LADING'
and f.sc_id = #{feeId}
and ifnull(f.is_deleted, '0') = '0'
and e.is_meet = 1
order by e.order_no
limit 1
</select>
<update id="markLadingFeeDeleted">
update prc_pay_bill_fee
set is_deleted = '1'
where fee_source = 'LADING'
and sc_id = #{feeId}
and ifnull(is_deleted, '0') = '0'
</update>
<delete id="deleteByBillId" parameterType="String">
delete from prc_pay_bill_fee
where bill_id = #{billId}
@@ -0,0 +1,61 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.bms.price.mapper.SrvBillAirAllocMapper">
<select id="selectPendingFees" resultType="com.bms.price.airbill.domain.SrvBillAirAllocFee">
select f.id, f.bill_code as billCode, a.guid as ladingId, f.fee_level as feeLevel,
f.bill_fee_status as billFeeStatus, sc.sc_name as feeName, f.price, f.currency,
f.alloc_dim as allocDim
from srv_bill_air_fee f
left join srv_bill_air a on a.bill_code = f.bill_code
left join prc_surcharge sc on sc.sc_id = f.fee_type and ifnull(sc.is_del, '0') = '0'
where f.del_flag = '0'
and f.alloc_flag = '1'
and f.bill_fee_status = '0'
and f.bill_code in
<foreach collection="billCodes" item="code" open="(" separator="," close=")">#{code}</foreach>
order by f.bill_code, f.id
</select>
<select id="selectLadingWeight" resultType="com.bms.price.airbill.domain.SrvBillAirAllocFee">
select a.bill_code as billCode, a.actual_weight as actualWeight, fl.band_box_weight as bandBoxWeight
from srv_bill_air a
left join srv_bill_air_flight fl on fl.bill_code = a.bill_code
where a.bill_code in
<foreach collection="billCodes" item="code" open="(" separator="," close=")">#{code}</foreach>
</select>
<select id="selectOrders" resultType="com.bms.price.airbill.domain.SrvBillAirAllocOrder">
select trim(e.air_no_old) as billCode, e.order_no as orderNo, e.bill_id as billId,
case
when ifnull(b.charge_weight, 0) &gt; 0 then b.charge_weight
when ifnull(b.actual_weight, 0) &gt; 0 then b.actual_weight
when ifnull(b.receive_weight, 0) &gt; 0 then b.receive_weight
else 0
end as chargeWeight
from prc_order_bill_ext e
inner join prc_order_bill b on b.bill_id = e.bill_id
where trim(e.air_no_old) in
<foreach collection="billCodes" item="code" open="(" separator="," close=")">#{code}</foreach>
order by e.air_no_old, e.order_no, e.bill_id
</select>
<select id="selectPayBills" resultType="com.bms.price.domain.PrcPayBill">
select bill_id as billId, pay_bill_id as payBillId
from prc_pay_bill
where bill_id in
<foreach collection="billIds" item="id" open="(" separator="," close=")">#{id}</foreach>
</select>
<update id="markAllocated">
update srv_bill_air_fee
set bill_fee_status = '1',
update_by = #{updateBy},
update_time = #{updateTime}
where del_flag = '0'
and alloc_flag = '1'
and bill_fee_status = '0'
and id in
<foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
</update>
</mapper>
@@ -12,6 +12,7 @@
left join prc_surcharge sc on sc.sc_id = f.fee_type and ifnull(sc.is_del, '0') = '0'
where f.bill_code = #{billCode}
and f.del_flag = '0'
and ifnull(f.bill_fee_status, '0') &lt;&gt; '2'
order by f.id
</select>
@@ -26,6 +27,7 @@
where f.id = #{id}
and f.bill_code = #{billCode}
and f.del_flag = '0'
and ifnull(f.bill_fee_status, '0') &lt;&gt; '2'
</select>
<insert id="insertFee" useGeneratedKeys="true" keyProperty="id">
@@ -52,6 +54,30 @@
where id = #{id}
and bill_code = #{billCode}
and del_flag = '0'
and ifnull(bill_fee_status, '0') not in ('1', '3')
</update>
<update id="confirmFee">
update srv_bill_air_fee
set bill_fee_status = '3',
update_by = #{updateBy},
update_time = #{updateTime}
where id = #{id}
and bill_code = #{billCode}
and del_flag = '0'
and bill_fee_status = '1'
</update>
<update id="voidFee">
update srv_bill_air_fee
set del_flag = '1',
bill_fee_status = '2',
update_by = #{updateBy},
update_time = #{updateTime}
where id = #{id}
and bill_code = #{billCode}
and del_flag = '0'
and bill_fee_status in ('1', '3')
</update>
<update id="deleteFee">
@@ -62,6 +88,7 @@
where id = #{id}
and bill_code = #{billCode}
and del_flag = '0'
and ifnull(bill_fee_status, '0') not in ('1', '3')
</update>
<insert id="insertLog" useGeneratedKeys="true" keyProperty="logId">
+45
View File
@@ -32,6 +32,51 @@ WHERE NOT EXISTS (
SELECT 1 FROM sys_dict_data d WHERE d.dict_type = 'bill_fee_status' AND d.dict_value = v.dict_value
);
INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, remark)
SELECT 4, N'已确定', '3', 'bill_fee_status', '', 'primary', 'N', '0', 'admin', sysdate(), N'分摊结果确认后'
FROM DUAL WHERE NOT EXISTS (
SELECT 1 FROM sys_dict_data d WHERE d.dict_type = 'bill_fee_status' AND d.dict_value = '3'
);
SET @sql = (
SELECT IF(
COUNT(*) > 0,
'ALTER TABLE srv_bill_air_fee MODIFY COLUMN bill_fee_status char(1) NOT NULL DEFAULT ''0'' COMMENT ''提单费用状态,字典 bill_fee_status:0未分摊 1已分摊 2已作废 3已确定''',
'SELECT 1'
)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'srv_bill_air_fee' AND COLUMN_NAME = 'bill_fee_status'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @sql = (
SELECT IF(
COUNT(*) = 0,
'ALTER TABLE srv_bill_air_fee ADD COLUMN del_flag char(1) NOT NULL DEFAULT ''0'' COMMENT ''逻辑删除:0未删除 1已删除''',
'SELECT 1'
)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'srv_bill_air_fee' AND COLUMN_NAME = 'del_flag'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @sql = (
SELECT IF(
COUNT(*) = 0,
'ALTER TABLE prc_pay_bill_fee ADD COLUMN is_deleted char(1) NOT NULL DEFAULT ''0'' COMMENT ''是否删除:0未删除 1已删除''',
'SELECT 1'
)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'prc_pay_bill_fee' AND COLUMN_NAME = 'is_deleted'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @sql = (
SELECT IF(
COUNT(*) = 0,
+23
View File
@@ -38,3 +38,26 @@ WHERE rm.menu_id IN (2520, 2522)
AND NOT EXISTS (
SELECT 1 FROM sys_role_menu x WHERE x.role_id = rm.role_id AND x.menu_id = fee.menu_id
);
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 2630, N'确定费用', 2520, 4, '', NULL, 1, 0, 'F', '0', '0', 'price:airBill:feeConfirm', '#', 'admin', sysdate(), N'已分摊的提单费用确定'
WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_id = 2630 OR perms = 'price:airBill:feeConfirm');
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 2631, N'作废费用', 2520, 5, '', NULL, 1, 0, 'F', '0', '0', 'price:airBill:feeVoid', '#', 'admin', sysdate(), N'作废已分摊或已确定的提单费用'
WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_id = 2631 OR perms = 'price:airBill:feeVoid');
INSERT INTO sys_role_menu (role_id, menu_id)
SELECT 1, m.menu_id
FROM sys_menu m
WHERE m.perms IN ('price:airBill:feeConfirm', 'price:airBill:feeVoid')
AND NOT EXISTS (SELECT 1 FROM sys_role_menu rm WHERE rm.role_id = 1 AND rm.menu_id = m.menu_id);
INSERT INTO sys_role_menu (role_id, menu_id)
SELECT DISTINCT rm.role_id, btn.menu_id
FROM sys_role_menu rm
JOIN sys_menu fee ON fee.menu_id = rm.menu_id AND fee.perms = 'price:airBill:fee'
JOIN sys_menu btn ON btn.perms IN ('price:airBill:feeConfirm', 'price:airBill:feeVoid')
WHERE NOT EXISTS (
SELECT 1 FROM sys_role_menu x WHERE x.role_id = rm.role_id AND x.menu_id = btn.menu_id
);
+37
View File
@@ -36,6 +36,43 @@ export function listAirBillFeeLog(query) {
})
}
/** 勾选提单后,查询需要分摊到订单的费用 */
export function previewAirBillAlloc(billCodes) {
return request({
url: '/price/airBill/fee/alloc/preview',
method: 'get',
params: { billCodes: (billCodes || []).join(',') }
})
}
/** 按重量或订单数把费用分摊到订单 */
export function allocateAirBillFee(billCodes) {
return request({
url: '/price/airBill/fee/alloc',
method: 'post',
data: { billCodes },
timeout: 120000
})
}
/** 已分摊的费用改为已确定 */
export function confirmAirBillFee(billCode, feeId) {
return request({
url: '/price/airBill/fee/confirm',
method: 'post',
params: { billCode, feeId }
})
}
/** 作废已分摊或已确定的费用,并逻辑删除订单上的分摊明细 */
export function voidAirBillFee(billCode, feeId) {
return request({
url: '/price/airBill/fee/void',
method: 'post',
params: { billCode, feeId }
})
}
/** 只提交新增、修改、删除的费用 */
export function saveAirBillFee(data) {
return request({
+28 -3
View File
@@ -238,6 +238,11 @@ const props = defineProps({
type: Boolean,
default: true
},
// 最多勾选条数,0 表示不限制
selectionMax: {
type: Number,
default: 0
},
// 是否显示序号列
showIndex: {
type: Boolean,
@@ -436,10 +441,30 @@ function resetQuery() {
getList()
}
// 选中变化
// 选中变化。超过上限时只保留先勾到的行,并同步虚拟表和普通表的勾选状态。
let cappingSelection = false
function handleSelectionChange(val) {
selectionList.value = val
emit('selection-change', val)
if (cappingSelection) return
const rows = Array.isArray(val) ? val.slice() : []
const max = Number(props.selectionMax) || 0
if (max > 0 && rows.length > max) {
const kept = rows.slice(0, max)
cappingSelection = true
selectionList.value = kept
emit('selection-change', kept)
proxy.$modal.msgWarning(`单次最多勾选 ${max} 条`)
nextTick(() => {
const table = tableRef.value
if (table?.toggleRowSelection) {
const keys = new Set(kept.map((row) => virtualRowId(row)))
tableData.value.forEach((row) => table.toggleRowSelection(row, keys.has(virtualRowId(row))))
}
cappingSelection = false
})
return
}
selectionList.value = rows
emit('selection-change', rows)
}
const selectionIds = computed(() =>
selectionList.value.map((r) => (props.rowKey ? r[props.rowKey] : r.id)).filter((v) => v !== undefined)
@@ -11,7 +11,20 @@
:page-size="100"
:height="tableHeight"
virtual
selection
:selection-max="5"
>
<template #toolbar="{ selection }">
<el-col :span="1.5">
<el-button
type="primary"
plain
:disabled="!selection.length"
v-hasPermi="['price:airBill:fee']"
@click="openAlloc(selection)"
>费用处理</el-button>
</el-col>
</template>
<template #search-billCodes="{ model }">
<el-input
v-model="model.billCodes"
@@ -55,6 +68,7 @@
<air-bill-detail v-model="open" :title="title" :loading="loading" :detail="detail" />
<air-bill-fee-dialog ref="feeRef" />
<air-bill-alloc-dialog ref="allocRef" @done="tableRef?.refresh()" />
</div>
</template>
@@ -62,9 +76,11 @@
import { listAirBill, getAirBill } from '@/api/price/airBill'
import AirBillDetail from './components/AirBillDetail.vue'
import AirBillFeeDialog from './components/AirBillFeeDialog.vue'
import AirBillAllocDialog from './components/AirBillAllocDialog.vue'
const tableRef = ref()
const feeRef = ref()
const allocRef = ref()
const tableHeight = ref(480)
let tableResizeObserver = null
@@ -171,6 +187,10 @@ function openFee(row) {
feeRef.value?.open(row)
}
function openAlloc(rows) {
allocRef.value?.open((rows || []).map(row => row.billCode))
}
function openDetail(row) {
if (!row?.billCode) return
open.value = true
@@ -0,0 +1,154 @@
<template>
<el-dialog v-model="visible" width="980px" append-to-body destroy-on-close @closed="reset">
<template #header>
<div class="alloc-title">
<span>费用处理</span>
<span class="alloc-hint">本次操作是将费用均摊到订单</span>
</div>
</template>
<div v-loading="loading">
<div class="alloc-summary">
已选 {{ billCodes.length }} 个提单,待分摊 {{ rows.length }} 条费用
</div>
<el-alert
v-if="blockText"
:title="blockText"
type="warning"
:closable="false"
show-icon
class="alloc-alert"
/>
<el-table :data="rows" border size="small" max-height="420" empty-text="所选提单没有需要分摊的费用">
<el-table-column prop="billCode" label="提单号" min-width="150" show-overflow-tooltip />
<el-table-column label="费用阶段" width="150" align="center">
<template #default="{ row }">
<dict-tag :options="bms_fee_level" :value="row.feeLevel" />
</template>
</el-table-column>
<el-table-column label="费用状态" width="100" align="center">
<template #default="{ row }">
<dict-tag :options="bill_fee_status" :value="row.billFeeStatus || '0'" />
</template>
</el-table-column>
<el-table-column prop="feeName" label="费用项" min-width="140" show-overflow-tooltip />
<el-table-column label="价格" width="110" align="right">
<template #default="{ row }">{{ priceText(row.price) }}</template>
</el-table-column>
<el-table-column label="币种" width="90" align="center">
<template #default="{ row }">
<dict-tag v-if="row.currency" :options="bms_currency" :value="row.currency" />
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="分摊维度" width="100" align="center">
<template #default="{ row }">
<dict-tag :options="bill_fee_alloc_dim" :value="row.allocDim" />
</template>
</el-table-column>
<el-table-column prop="orderCount" label="订单数" width="80" align="center" />
</el-table>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :disabled="!canSubmit" :loading="saving" @click="submit">确定</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { previewAirBillAlloc, allocateAirBillFee } from '@/api/price/airBill'
import { useDict } from '@/utils/dict'
const emit = defineEmits(['done'])
const { proxy } = getCurrentInstance()
const { bms_fee_level, bill_fee_status, bms_currency, bill_fee_alloc_dim } = useDict(
'bms_fee_level', 'bill_fee_status', 'bms_currency', 'bill_fee_alloc_dim'
)
const visible = ref(false)
const loading = ref(false)
const saving = ref(false)
const billCodes = ref([])
const rows = ref([])
const blockText = computed(() => {
const blocked = rows.value.filter(row => row.blockReason)
if (!blocked.length) return ''
return blocked.slice(0, 3).map(row => `${row.billCode || '-'} ${row.feeName || '费用'}:${row.blockReason}`).join(';')
})
const canSubmit = computed(() => !loading.value && rows.value.length > 0 && !blockText.value)
function open(codes) {
const picked = [...new Set((codes || []).map(code => String(code || '').trim()).filter(Boolean))]
if (!picked.length) {
proxy.$modal.msgWarning('请先勾选提单')
return
}
if (picked.length > 5) {
proxy.$modal.msgWarning('单次最多勾选 5 条提单')
return
}
billCodes.value = picked
rows.value = []
visible.value = true
loading.value = true
previewAirBillAlloc(picked).then(res => {
rows.value = res.data || []
}).finally(() => {
loading.value = false
})
}
function priceText(value) {
if (value === null || value === undefined || value === '') return '-'
const number = Number(value)
return Number.isFinite(number) ? number.toFixed(2) : value
}
async function submit() {
if (!canSubmit.value) return
try {
await proxy.$modal.confirm('确认将费用分摊到订单吗?确定后会按重量或订单数量摊到每一票,费用状态改为已分摊。')
} catch {
return
}
saving.value = true
try {
const res = await allocateAirBillFee(billCodes.value)
proxy.$modal.msgSuccess(res.msg || '分摊完成')
visible.value = false
emit('done')
} finally {
saving.value = false
}
}
function reset() {
loading.value = false
saving.value = false
billCodes.value = []
rows.value = []
}
defineExpose({ open })
</script>
<style scoped>
.alloc-title {
display: flex;
align-items: baseline;
gap: 12px;
}
.alloc-hint {
font-size: 12px;
font-weight: 400;
color: var(--el-text-color-secondary);
}
.alloc-summary {
margin-bottom: 12px;
color: var(--el-text-color-regular);
}
.alloc-alert {
margin-bottom: 12px;
}
</style>
@@ -2,14 +2,14 @@
<el-dialog v-model="visible" :title="title" width="1360px" append-to-body destroy-on-close @closed="reset">
<div v-loading="loading">
<div class="fee-toolbar">
<el-button type="primary" plain icon="Plus" @click="addRow">新增</el-button>
<el-button type="primary" plain icon="Plus" v-hasPermi="['price:airBill:fee']" @click="addRow">新增</el-button>
<span class="fee-hint">只保存本次新增、修改和删除的费用,未改动的行不会重写。</span>
</div>
<el-table :data="rows" border size="small" max-height="320" row-key="rowKey" empty-text="暂无费用,点击新增">
<el-table-column min-width="180">
<template #header><span class="col-label"><i v-if="allocRequired" class="req-star">*</i>费用阶段</span></template>
<template #default="{ row }">
<el-select v-model="row.feeLevel" filterable clearable placeholder="请选择" style="width: 100%">
<el-select v-model="row.feeLevel" filterable clearable placeholder="请选择" style="width: 100%" :disabled="locked(row)">
<el-option v-for="item in bms_fee_level" :key="item.value" :label="item.label" :value="String(item.value)" />
</el-select>
</template>
@@ -17,7 +17,7 @@
<el-table-column width="120">
<template #header><span class="col-label"><i class="req-star">*</i>是否分摊</span></template>
<template #default="{ row }">
<el-select v-model="row.allocFlag" placeholder="请选择" style="width: 100%" @change="changeAlloc(row)">
<el-select v-model="row.allocFlag" placeholder="请选择" style="width: 100%" :disabled="locked(row)" @change="changeAlloc(row)">
<el-option v-for="item in bill_fee_alloc" :key="item.value" :label="item.label" :value="String(item.value)" />
</el-select>
</template>
@@ -25,7 +25,7 @@
<el-table-column v-if="allocRequired" min-width="150">
<template #header><span class="col-label"><i class="req-star">*</i>分摊维度(票)</span></template>
<template #default="{ row }">
<el-select v-if="String(row.allocFlag) === '1'" v-model="row.allocDim" placeholder="请选择" style="width: 100%">
<el-select v-if="String(row.allocFlag) === '1'" v-model="row.allocDim" placeholder="请选择" style="width: 100%" :disabled="locked(row)">
<el-option v-for="item in bill_fee_alloc_dim" :key="item.value" :label="item.label" :value="String(item.value)" />
</el-select>
</template>
@@ -39,7 +39,7 @@
<template #header><span class="col-label"><i class="req-star">*</i>费用项</span></template>
<template #default="{ row }">
<el-select v-model="row.feeType" filterable remote clearable reserve-keyword placeholder="输入费用名称"
:remote-method="searchSurcharge" :loading="surchargeLoading" style="width: 100%" @change="name => onFeeChange(row, name)">
:remote-method="searchSurcharge" :loading="surchargeLoading" :disabled="locked(row)" style="width: 100%" @change="name => onFeeChange(row, name)">
<el-option v-for="item in surchargeOptions" :key="item.scId" :label="item.scName" :value="item.scId" />
</el-select>
</template>
@@ -47,25 +47,27 @@
<el-table-column width="150">
<template #header><span class="col-label"><i class="req-star">*</i>价格</span></template>
<template #default="{ row }">
<el-input-number v-model="row.price" :min="0" :precision="4" :controls="false" :value-on-clear="null" placeholder="价格" style="width: 100%" />
<el-input-number v-model="row.price" :min="0" :precision="4" :controls="false" :value-on-clear="null" :disabled="locked(row)" placeholder="价格" style="width: 100%" />
</template>
</el-table-column>
<el-table-column width="140">
<template #header><span class="col-label"><i class="req-star">*</i>收费币种</span></template>
<template #default="{ row }">
<el-select v-model="row.currency" filterable clearable placeholder="请选择" style="width: 100%">
<el-select v-model="row.currency" filterable clearable placeholder="请选择" style="width: 100%" :disabled="locked(row)">
<el-option v-for="item in bms_currency" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</template>
</el-table-column>
<el-table-column label="备注" min-width="180">
<template #default="{ row }">
<el-input v-model="row.remark" maxlength="500" clearable placeholder="备注" />
<el-input v-model="row.remark" maxlength="500" clearable placeholder="备注" :disabled="locked(row)" />
</template>
</el-table-column>
<el-table-column label="操作" width="80" align="center">
<el-table-column label="操作" width="180" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="danger" @click="removeRow(row)">删除</el-button>
<el-button v-if="row.billFeeStatus === '1'" v-hasPermi="['price:airBill:feeConfirm']" link type="primary" :disabled="acting" @click="confirmRow(row)">确定</el-button>
<el-button v-if="row.billFeeStatus === '1' || row.billFeeStatus === '3'" v-hasPermi="['price:airBill:feeVoid']" link type="danger" :disabled="acting" @click="voidRow(row)">作废</el-button>
<el-button v-if="!locked(row)" v-hasPermi="['price:airBill:fee']" link type="danger" @click="removeRow(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
@@ -77,14 +79,14 @@
<el-table-column label="操作时间" width="168">
<template #default="{ row }">{{ logTime(row) }}</template>
</el-table-column>
<el-table-column label="操作类型" width="90" align="center">
<el-table-column label="操作类型" width="110" align="center">
<template #default="{ row }">
<el-tag size="small" :type="logType(row.operationType)">{{ logAction(row.operationType) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="更改前" min-width="200">
<template #default="{ row }">
<div v-if="blankSide(row, 'old')" class="log-empty">空</div>
<div v-if="blankSide(row, 'old')" class="log-empty">{{ emptyText(row) }}</div>
<div v-else class="log-change">
<div v-for="change in row.changes" :key="change.field">{{ change.fieldLabel || change.field }}:{{ showValue(change.oldValue) }}</div>
</div>
@@ -92,7 +94,7 @@
</el-table-column>
<el-table-column label="更改后" min-width="200">
<template #default="{ row }">
<div v-if="blankSide(row, 'new')" class="log-empty">空</div>
<div v-if="blankSide(row, 'new')" class="log-empty">{{ emptyText(row) }}</div>
<div v-else class="log-change">
<div v-for="change in row.changes" :key="change.field">{{ change.fieldLabel || change.field }}:{{ showValue(change.newValue) }}</div>
</div>
@@ -117,13 +119,13 @@
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :disabled="!dirty" :loading="saving" @click="save">保存</el-button>
<el-button type="primary" v-hasPermi="['price:airBill:fee']" :disabled="!dirty" :loading="saving" @click="save">保存</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { listAirBillFee, listAirBillFeeLog, saveAirBillFee } from '@/api/price/airBill'
import { listAirBillFee, listAirBillFeeLog, saveAirBillFee, confirmAirBillFee, voidAirBillFee } from '@/api/price/airBill'
import { listSurchargeOptions } from '@/api/price/surcharge'
import { parseTime } from '@/utils/bms'
@@ -134,6 +136,7 @@ const visible = ref(false)
const loading = ref(false)
const logLoading = ref(false)
const saving = ref(false)
const acting = ref(false)
const billCode = ref('')
const rows = ref([])
const deletedIds = ref([])
@@ -172,6 +175,7 @@ function reset() {
surchargeOptions.value = []
surchargeLoading.value = false
saving.value = false
acting.value = false
loading.value = false
logLoading.value = false
}
@@ -307,6 +311,37 @@ function changeAlloc(row) {
if (String(row.allocFlag) !== '1') row.allocDim = ''
}
function locked(row) {
return row?.billFeeStatus === '1' || row?.billFeeStatus === '3'
}
function confirmRow(row) {
if (!row?.id || acting.value) return
acting.value = true
confirmAirBillFee(billCode.value, row.id).then(res => {
proxy.$modal.msgSuccess(res.msg || '费用已确定')
load()
}).finally(() => {
acting.value = false
})
}
async function voidRow(row) {
if (!row?.id || acting.value) return
try {
await proxy.$modal.confirm('确认作废这条费用吗?已分摊到订单的费用明细会逻辑删除,这条提单费用也会逻辑删除。')
} catch {
return
}
acting.value = true
voidAirBillFee(billCode.value, row.id).then(res => {
proxy.$modal.msgSuccess(res.msg || '费用已作废')
load()
}).finally(() => {
acting.value = false
})
}
function removeRow(row) {
if (row.id != null && !deletedIds.value.some(id => String(id) === String(row.id))) {
deletedIds.value.push(row.id)
@@ -404,15 +439,21 @@ function save() {
}
function logAction(type) {
return { 1: '新增', 2: '修改', 3: '删除' }[String(type)] || '维护'
return { 1: '新增', 2: '修改', 3: '删除', 4: '确定费用', 5: '作废' }[String(type)] || '维护'
}
function logType(type) {
if (String(type) === '1') return 'success'
if (String(type) === '3') return 'danger'
const value = String(type)
if (value === '1' || value === '4') return 'success'
if (value === '3' || value === '5') return 'danger'
return 'warning'
}
function emptyText(row) {
const value = String(row?.operationType)
return value === '4' || value === '5' ? '无' : '空'
}
function logTime(item) {
return parseTime(item.createTime) || ''
}