|
|
|
@@ -4,12 +4,14 @@ import com.baiyue.common.core.api.ResultCode;
|
|
|
|
|
import com.baiyue.common.core.exception.BizException;
|
|
|
|
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
|
|
|
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
|
|
|
import com.baiyue.finance.client.FinanceFileClient;
|
|
|
|
|
import org.springframework.jdbc.core.JdbcTemplate;
|
|
|
|
|
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
|
|
|
|
import org.springframework.jdbc.support.KeyHolder;
|
|
|
|
|
import org.springframework.stereotype.Service;
|
|
|
|
|
import org.springframework.transaction.annotation.Transactional;
|
|
|
|
|
import org.springframework.util.StringUtils;
|
|
|
|
|
import org.springframework.beans.factory.annotation.Autowired;
|
|
|
|
|
|
|
|
|
|
import java.math.BigDecimal;
|
|
|
|
|
import java.sql.PreparedStatement;
|
|
|
|
@@ -24,6 +26,10 @@ import java.util.Map;
|
|
|
|
|
public class FinanceService {
|
|
|
|
|
private final JdbcTemplate jdbc;
|
|
|
|
|
private final ObjectMapper objectMapper;
|
|
|
|
|
@Autowired(required = false)
|
|
|
|
|
private FinanceFileClient fileClient;
|
|
|
|
|
@org.springframework.beans.factory.annotation.Value("${baiyue.security.internal-service-token:}")
|
|
|
|
|
private String fileToken;
|
|
|
|
|
|
|
|
|
|
public FinanceService(JdbcTemplate jdbc, ObjectMapper objectMapper) {
|
|
|
|
|
this.jdbc = jdbc;
|
|
|
|
@@ -43,6 +49,11 @@ public class FinanceService {
|
|
|
|
|
return jdbc.queryForList("SELECT id,bill_no,customer_name_snapshot,currency_code,bill_date,due_date,status,total_amount,received_amount,outstanding_amount,version FROM fin_bill WHERE tenant_id=? ORDER BY bill_date DESC,id DESC", tenantId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public List<Map<String, Object>> listClientBills(long tenantId) {
|
|
|
|
|
requireCustomer(tenantId);
|
|
|
|
|
return jdbc.queryForList("SELECT id,bill_no,customer_name_snapshot,currency_code,bill_date,due_date,status,total_amount,received_amount,outstanding_amount,version FROM fin_bill WHERE tenant_id=? AND status IN ('ISSUED','PARTIALLY_PAID','PAID') ORDER BY bill_date DESC,id DESC", tenantId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public Map<String, Object> bill(long tenantId, long billId) {
|
|
|
|
|
requireCustomer(tenantId);
|
|
|
|
|
Map<String, Object> result = jdbc.queryForMap("SELECT * FROM fin_bill WHERE tenant_id=? AND id=?", tenantId, billId);
|
|
|
|
@@ -50,6 +61,13 @@ public class FinanceService {
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public Map<String, Object> clientBill(long tenantId, long billId) {
|
|
|
|
|
requireCustomer(tenantId);
|
|
|
|
|
Map<String, Object> result = jdbc.queryForMap("SELECT * FROM fin_bill WHERE tenant_id=? AND id=? AND status IN ('ISSUED','PARTIALLY_PAID','PAID')", tenantId, billId);
|
|
|
|
|
result.put("lines", jdbc.queryForList("SELECT * FROM fin_bill_line WHERE tenant_id=? AND bill_id=? ORDER BY id", tenantId, billId));
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public Map<String, Object> billById(long billId) {
|
|
|
|
|
Long tenantId = jdbc.queryForObject("SELECT tenant_id FROM fin_bill WHERE id=?", Long.class, billId);
|
|
|
|
|
if (tenantId == null) throw bad("账单不存在");
|
|
|
|
@@ -77,7 +95,8 @@ public class FinanceService {
|
|
|
|
|
public Map<String, Object> saveCustomerTerms(long tenantId, long operatorId, String customerName,
|
|
|
|
|
Integer creditDays, String idempotencyKey) {
|
|
|
|
|
requireCustomer(tenantId);
|
|
|
|
|
reserve(tenantId, operatorId, "CUSTOMER_TERMS_SAVE", idempotencyKey);
|
|
|
|
|
Long replay = reserve(tenantId, operatorId, "CUSTOMER_TERMS_SAVE", idempotencyKey, Map.of("customerName", String.valueOf(customerName), "creditDays", creditDays == null ? 30 : creditDays));
|
|
|
|
|
if (replay != null) return customerTerms(tenantId);
|
|
|
|
|
int days = creditDays == null ? 30 : creditDays;
|
|
|
|
|
if (days < 0 || days > 3650) throw bad("账期必须在0到3650天之间");
|
|
|
|
|
jdbc.update("INSERT INTO fin_customer_settlement_profile(tenant_id,customer_name_snapshot,credit_days,created_by,updated_by) VALUES(?,?,?,?,?) "
|
|
|
|
@@ -90,7 +109,8 @@ public class FinanceService {
|
|
|
|
|
@Transactional
|
|
|
|
|
public Map<String, Object> createBill(long tenantId, long operatorId, String customerName, BillRequest request,
|
|
|
|
|
String idempotencyKey) {
|
|
|
|
|
requireCustomer(tenantId); reserve(tenantId, operatorId, "BILL_CREATE", idempotencyKey);
|
|
|
|
|
requireCustomer(tenantId); Long replay = reserve(tenantId, operatorId, "BILL_CREATE", idempotencyKey, request);
|
|
|
|
|
if (replay != null) return bill(tenantId, replay);
|
|
|
|
|
if (request.lines() == null || request.lines().isEmpty()) throw bad("账单至少需要一条费用明细");
|
|
|
|
|
request.lines().forEach(line -> { if (line.sourceId() <= 0 || line.sourceVersion() <= 0) throw bad("费用来源版本无效"); if (line.amount() == null || line.amount().signum() < 0) throw bad("费用金额不能为负数"); });
|
|
|
|
|
BigDecimal total = request.lines().stream().map(BillLine::amount).reduce(BigDecimal.ZERO, BigDecimal::add);
|
|
|
|
@@ -118,7 +138,8 @@ public class FinanceService {
|
|
|
|
|
|
|
|
|
|
@Transactional
|
|
|
|
|
public Map<String, Object> issueBill(long tenantId, long operatorId, long billId, int expectedVersion, String idempotencyKey) {
|
|
|
|
|
requireCustomer(tenantId); reserve(tenantId, operatorId, "BILL_ISSUE", idempotencyKey);
|
|
|
|
|
requireCustomer(tenantId); Long replay = reserve(tenantId, operatorId, "BILL_ISSUE", idempotencyKey, Map.of("billId",billId,"expectedVersion",expectedVersion));
|
|
|
|
|
if (replay != null) return bill(tenantId, replay);
|
|
|
|
|
int updated = jdbc.update("UPDATE fin_bill SET status='ISSUED', issued_at=?, issued_by=?, updated_by=?, version=version+1 WHERE id=? AND tenant_id=? AND status='DRAFT' AND version=?",
|
|
|
|
|
LocalDateTime.now(), operatorId, operatorId, billId, tenantId, expectedVersion);
|
|
|
|
|
if (updated != 1) throw bad("账单不存在或已出账");
|
|
|
|
@@ -128,13 +149,17 @@ public class FinanceService {
|
|
|
|
|
|
|
|
|
|
@Transactional
|
|
|
|
|
public Map<String, Object> createReceipt(long tenantId, long operatorId, ReceiptRequest request, String idempotencyKey) {
|
|
|
|
|
requireCustomer(tenantId); reserve(tenantId, operatorId, "RECEIPT_CREATE", idempotencyKey);
|
|
|
|
|
requireCustomer(tenantId); Long replay = reserve(tenantId, operatorId, "RECEIPT_CREATE", idempotencyKey, request);
|
|
|
|
|
if (replay != null) return jdbc.queryForMap("SELECT * FROM fin_receipt WHERE id=? AND tenant_id=?", replay, tenantId);
|
|
|
|
|
validateVoucherFiles(tenantId, request.voucherFileIds());
|
|
|
|
|
if (request.allocations() == null || request.allocations().isEmpty()) throw bad("收款至少需要分配一张账单");
|
|
|
|
|
if (request.allocations().stream().anyMatch(a -> a == null || a.amount() == null || a.amount().signum() <= 0)) {
|
|
|
|
|
throw bad("分配金额必须大于0");
|
|
|
|
|
}
|
|
|
|
|
long distinctBills = request.allocations().stream().map(ReceiptAllocation::billId).distinct().count();
|
|
|
|
|
if (distinctBills != request.allocations().size()) throw bad("同一收款中账单ID不能重复");
|
|
|
|
|
Map<Long, BigDecimal> byBill = request.allocations().stream().collect(
|
|
|
|
|
java.util.stream.Collectors.toMap(ReceiptAllocation::billId, ReceiptAllocation::amount, BigDecimal::add));
|
|
|
|
|
java.util.stream.Collectors.toMap(ReceiptAllocation::billId, ReceiptAllocation::amount));
|
|
|
|
|
BigDecimal total = byBill.values().stream().reduce(BigDecimal.ZERO, BigDecimal::add);
|
|
|
|
|
if (total.signum() <= 0) throw bad("收款金额必须大于0");
|
|
|
|
|
for (var entry : byBill.entrySet()) {
|
|
|
|
@@ -161,25 +186,111 @@ public class FinanceService {
|
|
|
|
|
return jdbc.queryForMap("SELECT * FROM fin_receipt WHERE id=?", receiptId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void validateVoucherFiles(long tenantId, List<String> fileIds) {
|
|
|
|
|
if (fileIds == null || fileIds.isEmpty()) throw bad("收款凭证不能为空");
|
|
|
|
|
if (fileClient == null) return;
|
|
|
|
|
for (String raw : fileIds) {
|
|
|
|
|
long id; try { id = Long.parseLong(raw); } catch (Exception ex) { throw bad("凭证文件ID无效"); }
|
|
|
|
|
var response = fileClient.metadata(fileToken, tenantId, tenantId, id);
|
|
|
|
|
var metadata = response == null ? null : response.data();
|
|
|
|
|
if (metadata == null || metadata.fileId() == null || !tenantIdEquals(metadata.tenantId(), tenantId)
|
|
|
|
|
|| !"FINANCE_VOUCHER".equals(metadata.businessType()) || metadata.contentType() == null
|
|
|
|
|
|| !metadata.contentType().toLowerCase(java.util.Locale.ROOT).startsWith("image/")) {
|
|
|
|
|
throw bad("凭证文件不存在、归属不匹配或不是有效图片");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
private static boolean tenantIdEquals(Long actual, long expected) { return actual != null && actual == expected; }
|
|
|
|
|
|
|
|
|
|
@Transactional
|
|
|
|
|
public Map<String, Object> createCost(long tenantId, long operatorId, CostRequest request, String idempotencyKey) {
|
|
|
|
|
requireCustomer(tenantId); reserve(tenantId, operatorId, "COST_CREATE", idempotencyKey);
|
|
|
|
|
return createCost(tenantId, operatorId, request, List.of(), idempotencyKey);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Creates a cost and, for a container-wide cost, persists the immutable
|
|
|
|
|
* volume snapshot used to allocate it. The snapshot is supplied by the
|
|
|
|
|
* Firstleg adapter because Finance must not join the Firstleg database.
|
|
|
|
|
*/
|
|
|
|
|
@Transactional
|
|
|
|
|
public Map<String, Object> createCost(long tenantId, long operatorId, CostRequest request,
|
|
|
|
|
List<ContainerOrderVolume> containerOrders, String idempotencyKey) {
|
|
|
|
|
requireCustomer(tenantId); Long replay = reserve(tenantId, operatorId, "COST_CREATE", idempotencyKey, request);
|
|
|
|
|
if (replay != null) return jdbc.queryForMap("SELECT * FROM fin_cost WHERE id=? AND tenant_id=?", replay, tenantId);
|
|
|
|
|
if (!"SINGLE_ORDER".equals(request.costType()) && !"CONTAINER_SHARED".equals(request.costType())) throw bad("成本类型只能是SINGLE_ORDER或CONTAINER_SHARED");
|
|
|
|
|
if (request.amount() == null || request.amount().signum() <= 0) throw bad("成本金额必须大于0");
|
|
|
|
|
if ("SINGLE_ORDER".equals(request.costType()) && (request.orderId() == null || request.containerId() != null)) throw bad("单票成本必须指定订单且不能指定柜次");
|
|
|
|
|
if ("CONTAINER_SHARED".equals(request.costType()) && (request.containerId() == null || containerOrders == null || containerOrders.isEmpty())) throw bad("整柜成本必须指定柜次及柜内有效订单体积");
|
|
|
|
|
validateContainerOrders(tenantId, request.containerId(), containerOrders);
|
|
|
|
|
KeyHolder holder = new GeneratedKeyHolder();
|
|
|
|
|
jdbc.update(connection -> { PreparedStatement ps = connection.prepareStatement("INSERT INTO fin_cost(tenant_id,cost_no,cost_type,order_id,inbound_id,container_id,supplier_id,supplier_name_snapshot,fee_item_code,fee_item_name,cost_amount,voucher_file_ids,remark,created_by,updated_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", Statement.RETURN_GENERATED_KEYS);
|
|
|
|
|
ps.setLong(1, tenantId); ps.setString(2, temporaryNo("COST")); ps.setString(3, request.costType()); ps.setObject(4, request.orderId()); ps.setObject(5, request.inboundId()); ps.setObject(6, request.containerId()); ps.setObject(7, request.supplierId()); ps.setString(8, request.supplierName()); ps.setString(9, request.feeItemCode()); ps.setString(10, request.feeItemName()); ps.setBigDecimal(11, request.amount()); ps.setString(12, json(request.voucherFileIds())); ps.setString(13, request.remark()); ps.setLong(14, operatorId); ps.setLong(15, operatorId); return ps; }, holder);
|
|
|
|
|
long costId = holder.getKey().longValue();
|
|
|
|
|
jdbc.update("UPDATE fin_cost SET cost_no=? WHERE id=?", stableNo("COST", costId), costId);
|
|
|
|
|
if ("CONTAINER_SHARED".equals(request.costType())) {
|
|
|
|
|
persistContainerAllocations(tenantId, operatorId, costId, request.amount(), containerOrders);
|
|
|
|
|
}
|
|
|
|
|
complete(tenantId, operatorId, "COST_CREATE", idempotencyKey, costId);
|
|
|
|
|
return jdbc.queryForMap("SELECT * FROM fin_cost WHERE id=?", costId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void reserve(long tenantId, long operatorId, String operation, String key) {
|
|
|
|
|
private void validateContainerOrders(long tenantId, Long containerId, List<ContainerOrderVolume> rows) {
|
|
|
|
|
if (rows == null) return;
|
|
|
|
|
java.util.Set<Long> seen = new java.util.HashSet<>();
|
|
|
|
|
for (ContainerOrderVolume row : rows) {
|
|
|
|
|
if (row == null || row.orderId() <= 0 || row.containerId() != containerId || row.customerId() != tenantId
|
|
|
|
|
|| row.volumeCbm() == null || row.volumeCbm().signum() <= 0 || !seen.add(row.orderId())) {
|
|
|
|
|
throw bad("柜次、订单、客户归属或体积无效");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void persistContainerAllocations(long tenantId, long operatorId, long costId, BigDecimal amount,
|
|
|
|
|
List<ContainerOrderVolume> rows) {
|
|
|
|
|
for (CostAllocation allocation : allocateContainerCost(amount, rows)) {
|
|
|
|
|
ContainerOrderVolume row = allocation.order();
|
|
|
|
|
jdbc.update("INSERT INTO fin_cost_allocation(tenant_id,cost_id,order_id,inbound_id,volume_cbm,total_volume_cbm,allocation_rate,allocated_amount,rounding_adjustment) VALUES(?,?,?,?,?,?,?,?,?)",
|
|
|
|
|
tenantId, costId, row.orderId(), row.inboundId(), row.volumeCbm(), allocation.totalVolumeCbm(),
|
|
|
|
|
allocation.rate(), allocation.amount(), allocation.roundingAdjustment() ? 1 : 0);
|
|
|
|
|
if (allocation.roundingAdjustment()) {
|
|
|
|
|
jdbc.update("INSERT INTO fin_audit_log(tenant_id,resource_type,resource_id,action,reason,before_data,after_data,operator_id,operator_name_snapshot) VALUES(?,?,?,?,?,?,?,?,?)",
|
|
|
|
|
tenantId, "FIN_COST_ALLOCATION", costId, "ROUNDING_ADJUSTMENT", "尾差归集到最后一票", null,
|
|
|
|
|
json(Map.of("orderId", row.orderId(), "allocatedAmount", allocation.amount(), "totalAmount", amount)), operatorId, "operator-" + operatorId);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public static List<CostAllocation> allocateContainerCost(BigDecimal amount, List<ContainerOrderVolume> rows) {
|
|
|
|
|
if (amount == null || amount.signum() <= 0 || rows == null || rows.isEmpty()) throw bad("分摊金额和订单不能为空");
|
|
|
|
|
BigDecimal totalVolume = rows.stream().map(ContainerOrderVolume::volumeCbm)
|
|
|
|
|
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
|
|
|
|
if (totalVolume.signum() <= 0) throw bad("柜内有效订单总体积必须大于0");
|
|
|
|
|
java.util.ArrayList<CostAllocation> result = new java.util.ArrayList<>(rows.size());
|
|
|
|
|
BigDecimal allocated = BigDecimal.ZERO;
|
|
|
|
|
for (int i = 0; i < rows.size(); i++) {
|
|
|
|
|
ContainerOrderVolume row = rows.get(i);
|
|
|
|
|
boolean tail = i == rows.size() - 1;
|
|
|
|
|
BigDecimal rate = row.volumeCbm().divide(totalVolume, 10, java.math.RoundingMode.HALF_UP);
|
|
|
|
|
BigDecimal share = tail ? amount.subtract(allocated).setScale(6, java.math.RoundingMode.HALF_UP)
|
|
|
|
|
: amount.multiply(rate).setScale(6, java.math.RoundingMode.HALF_UP);
|
|
|
|
|
allocated = allocated.add(share);
|
|
|
|
|
result.add(new CostAllocation(row, totalVolume, rate, share, tail));
|
|
|
|
|
}
|
|
|
|
|
return List.copyOf(result);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private Long reserve(long tenantId, long operatorId, String operation, String key, Object request) {
|
|
|
|
|
if (!StringUtils.hasText(key) || key.length() > 128) throw bad("必须提供有效的 Idempotency-Key");
|
|
|
|
|
Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM fin_idempotency_record WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=?", Integer.class, tenantId, operatorId, operation, key);
|
|
|
|
|
if (count != null && count > 0) throw new BizException(ResultCode.CONFLICT, "相同请求已提交");
|
|
|
|
|
jdbc.update("INSERT INTO fin_idempotency_record(tenant_id,operator_id,operation,idempotency_key,request_hash,status) VALUES(?,?,?,?,SHA2(?,256),'PROCESSING')", tenantId, operatorId, operation, key, key);
|
|
|
|
|
String hash = hash(request);
|
|
|
|
|
List<Map<String,Object>> rows = jdbc.queryForList("SELECT request_hash,status,result_resource_id FROM fin_idempotency_record WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=?", tenantId, operatorId, operation, key);
|
|
|
|
|
if (!rows.isEmpty()) {
|
|
|
|
|
Map<String,Object> row = rows.get(0);
|
|
|
|
|
if (!hash.equals(row.get("request_hash"))) throw new BizException(ResultCode.CONFLICT, "Idempotency-Key已用于其他请求");
|
|
|
|
|
if ("SUCCEEDED".equals(row.get("status")) && row.get("result_resource_id") != null) return ((Number) row.get("result_resource_id")).longValue();
|
|
|
|
|
throw new BizException(ResultCode.CONFLICT, "相同请求正在处理中");
|
|
|
|
|
}
|
|
|
|
|
jdbc.update("INSERT INTO fin_idempotency_record(tenant_id,operator_id,operation,idempotency_key,request_hash,status) VALUES(?,?,?,?,?,'PROCESSING')", tenantId, operatorId, operation, key, hash);
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void complete(long tenantId, long operatorId, String operation, String key, long resourceId) {
|
|
|
|
@@ -187,6 +298,7 @@ public class FinanceService {
|
|
|
|
|
}
|
|
|
|
|
private static String temporaryNo(String prefix) { return prefix + "-PENDING-" + java.util.UUID.randomUUID(); }
|
|
|
|
|
private static String stableNo(String prefix, long id) { return prefix + "-" + String.format(java.util.Locale.ROOT, "%020d", id); }
|
|
|
|
|
private String hash(Object request) { try { byte[] bytes = java.security.MessageDigest.getInstance("SHA-256").digest(json(request).getBytes(java.nio.charset.StandardCharsets.UTF_8)); return java.util.HexFormat.of().formatHex(bytes); } catch (Exception ex) { throw new IllegalStateException("请求哈希失败", ex); } }
|
|
|
|
|
|
|
|
|
|
private static void requireCustomer(long tenantId) { if (tenantId <= 0) throw bad("客户范围无效"); }
|
|
|
|
|
private static BizException bad(String message) { return new BizException(ResultCode.BAD_REQUEST, message); }
|
|
|
|
@@ -194,7 +306,7 @@ public class FinanceService {
|
|
|
|
|
|
|
|
|
|
@Transactional
|
|
|
|
|
public Map<String,Object> updateBill(long operatorId, long billId, long requestedCustomerId, LocalDate billDate, List<com.baiyue.finance.controller.PlatformFinanceController.BillLineInput> lines, int expectedVersion, String key) {
|
|
|
|
|
Long tenantId = billTenant(billId); if (tenantId != requestedCustomerId) throw bad("账单不属于指定客户"); reserve(tenantId, operatorId, "BILL_UPDATE", key);
|
|
|
|
|
Long tenantId = billTenant(billId); if (tenantId != requestedCustomerId) throw bad("账单不属于指定客户"); Long replay = reserve(tenantId, operatorId, "BILL_UPDATE", key, Map.of("billId",billId,"customerId",requestedCustomerId,"billDate",String.valueOf(billDate),"expectedVersion",expectedVersion,"lines",String.valueOf(lines))); if (replay != null) return bill(tenantId,replay);
|
|
|
|
|
replaceDraftLines(tenantId, billId, lines);
|
|
|
|
|
BigDecimal total = lineTotal(lines);
|
|
|
|
|
LocalDate effectiveDate = billDate == null ? LocalDate.now() : billDate;
|
|
|
|
@@ -208,15 +320,15 @@ public class FinanceService {
|
|
|
|
|
return bill(tenantId,billId);
|
|
|
|
|
}
|
|
|
|
|
@Transactional
|
|
|
|
|
public Map<String,Object> adjustBill(long operatorId,long billId,List<com.baiyue.finance.controller.PlatformFinanceController.BillLineInput> lines,int expectedVersion,String reason,String key){ Long tenantId=billTenant(billId); reserve(tenantId,operatorId,"BILL_ADJUST",key); if (lines == null || lines.isEmpty()) throw bad("调整后至少需要一条费用明细"); Map<String,Object> current = bill(tenantId,billId); snapshot(tenantId,billId,expectedVersion,reason,current,operatorId); BigDecimal total = lineTotal(lines); BigDecimal received = new BigDecimal(current.get("received_amount").toString()); if (total.compareTo(received) < 0) throw bad("调整金额不能低于已收金额"); BigDecimal outstanding=total.subtract(received); String status=outstanding.signum()==0 ? "PAID" : (received.signum()==0 ? "ISSUED" : "PARTIALLY_PAID"); int c=jdbc.update("UPDATE fin_bill SET remark=?,total_amount=?,outstanding_amount=?,status=?,version=version+1,updated_by=? WHERE id=? AND tenant_id=? AND status IN ('ISSUED','PARTIALLY_PAID','PAID') AND version=?",reason,total,outstanding,status,operatorId,billId,tenantId,expectedVersion); if(c!=1) throw bad("账单状态或版本不允许调整"); jdbc.update("DELETE FROM fin_bill_line WHERE tenant_id=? AND bill_id=?",tenantId,billId); insertLines(tenantId,billId,lines); complete(tenantId,operatorId,"BILL_ADJUST",key,billId); return bill(tenantId,billId); }
|
|
|
|
|
public Map<String,Object> adjustBill(long operatorId,long billId,List<com.baiyue.finance.controller.PlatformFinanceController.BillLineInput> lines,int expectedVersion,String reason,String key){ Long tenantId=billTenant(billId); Long replay=reserve(tenantId,operatorId,"BILL_ADJUST",key,Map.of("billId",billId,"expectedVersion",expectedVersion,"reason",reason,"lines",lines)); if(replay!=null) return bill(tenantId,replay); if (lines == null || lines.isEmpty()) throw bad("调整后至少需要一条费用明细"); Map<String,Object> current = bill(tenantId,billId); snapshot(tenantId,billId,expectedVersion,reason,current,operatorId); BigDecimal total = lineTotal(lines); BigDecimal received = new BigDecimal(current.get("received_amount").toString()); if (total.compareTo(received) < 0) throw bad("调整金额不能低于已收金额"); BigDecimal outstanding=total.subtract(received); String status=outstanding.signum()==0 ? "PAID" : (received.signum()==0 ? "ISSUED" : "PARTIALLY_PAID"); int c=jdbc.update("UPDATE fin_bill SET remark=?,total_amount=?,outstanding_amount=?,status=?,version=version+1,updated_by=? WHERE id=? AND tenant_id=? AND status IN ('ISSUED','PARTIALLY_PAID','PAID') AND version=?",reason,total,outstanding,status,operatorId,billId,tenantId,expectedVersion); if(c!=1) throw bad("账单状态或版本不允许调整"); jdbc.update("DELETE FROM fin_bill_line WHERE tenant_id=? AND bill_id=?",tenantId,billId); insertLines(tenantId,billId,lines); complete(tenantId,operatorId,"BILL_ADJUST",key,billId); return bill(tenantId,billId); }
|
|
|
|
|
@Transactional
|
|
|
|
|
public Map<String,Object> voidBill(long operatorId,long billId,int expectedVersion,String reason,String key){ Long tenantId=billTenant(billId); reserve(tenantId,operatorId,"BILL_VOID",key); if (!StringUtils.hasText(reason)) throw bad("作废原因不能为空"); int c=jdbc.update("UPDATE fin_bill SET status='VOIDED',voided_at=?,voided_by=?,void_reason=?,updated_by=?,version=version+1 WHERE id=? AND tenant_id=? AND status IN ('DRAFT','ISSUED') AND received_amount=0 AND version=?",LocalDateTime.now(),operatorId,reason.trim(),operatorId,billId,tenantId,expectedVersion); if(c!=1) throw bad("已有收款、账单状态或版本不允许作废"); complete(tenantId,operatorId,"BILL_VOID",key,billId); return bill(tenantId,billId); }
|
|
|
|
|
public Map<String,Object> voidBill(long operatorId,long billId,int expectedVersion,String reason,String key){ Long tenantId=billTenant(billId); Long replay=reserve(tenantId,operatorId,"BILL_VOID",key,Map.of("billId",billId,"expectedVersion",expectedVersion,"reason",reason)); if(replay!=null) return bill(tenantId,replay); if (!StringUtils.hasText(reason)) throw bad("作废原因不能为空"); int c=jdbc.update("UPDATE fin_bill SET status='VOIDED',voided_at=?,voided_by=?,void_reason=?,updated_by=?,version=version+1 WHERE id=? AND tenant_id=? AND status IN ('DRAFT','ISSUED') AND received_amount=0 AND version=?",LocalDateTime.now(),operatorId,reason.trim(),operatorId,billId,tenantId,expectedVersion); if(c!=1) throw bad("已有收款、账单状态或版本不允许作废"); complete(tenantId,operatorId,"BILL_VOID",key,billId); return bill(tenantId,billId); }
|
|
|
|
|
private long billTenant(long billId){ Long t=jdbc.queryForObject("SELECT tenant_id FROM fin_bill WHERE id=?",Long.class,billId); if(t==null) throw bad("账单不存在"); return t; }
|
|
|
|
|
private void snapshot(long tenantId,long billId,int version,String reason,Map<String,Object> bill,long operatorId){
|
|
|
|
|
var lines=jdbc.queryForList("SELECT * FROM fin_bill_line WHERE tenant_id=? AND bill_id=? ORDER BY id",tenantId,billId);
|
|
|
|
|
jdbc.update("INSERT INTO fin_bill_revision(tenant_id,bill_id,version,reason,bill_snapshot,line_snapshot,created_by) VALUES(?,?,?,?,?,?,?)",tenantId,billId,version,reason,json(bill),json(lines),operatorId);
|
|
|
|
|
}
|
|
|
|
|
private BigDecimal lineTotal(List<com.baiyue.finance.controller.PlatformFinanceController.BillLineInput> lines) { if (lines == null || lines.isEmpty()) throw bad("账单至少需要一条费用明细"); return lines.stream().map(l -> { if (l.amount() == null || l.amount().signum() < 0) throw bad("费用金额不能为负数"); return l.amount(); }).reduce(BigDecimal.ZERO, BigDecimal::add); }
|
|
|
|
|
private BigDecimal lineTotal(List<com.baiyue.finance.controller.PlatformFinanceController.BillLineInput> lines) { if (lines == null || lines.isEmpty()) throw bad("账单至少需要一条费用明细"); java.util.Set<String> sources = new java.util.HashSet<>(); return lines.stream().map(l -> { if (l.sourceFeeId() <= 0 || l.sourceFeeVersion() <= 0) throw bad("费用来源版本无效"); if (!sources.add(l.sourceFeeId()+":"+l.sourceFeeVersion())) throw bad("重复费用来源不允许合并"); if (l.amount() == null || l.amount().signum() < 0) throw bad("费用金额不能为负数"); return l.amount(); }).reduce(BigDecimal.ZERO, BigDecimal::add); }
|
|
|
|
|
private void replaceDraftLines(long tenantId, long billId, List<com.baiyue.finance.controller.PlatformFinanceController.BillLineInput> lines) { lineTotal(lines); Map<String,Object> current=bill(tenantId,billId); if (!"DRAFT".equals(current.get("status"))) throw bad("仅草稿账单可修改"); jdbc.update("DELETE FROM fin_bill_line WHERE tenant_id=? AND bill_id=?",tenantId,billId); insertLines(tenantId,billId,lines); }
|
|
|
|
|
private void insertLines(long tenantId,long billId,List<com.baiyue.finance.controller.PlatformFinanceController.BillLineInput> lines) { for (var line: lines) jdbc.update("INSERT INTO fin_bill_line(tenant_id,bill_id,source_type,source_id,source_version,order_id,inbound_id,fee_item_code,fee_item_name,amount) VALUES(?,?,?,?,?,?,?,?,?,?)",tenantId,billId,"FIRSTLEG_FEE",line.sourceFeeId(),line.sourceFeeVersion(),line.orderId(),line.inboundId(),"MANUAL",line.feeItemName(),line.amount()); }
|
|
|
|
|
|
|
|
|
@@ -225,4 +337,7 @@ public class FinanceService {
|
|
|
|
|
public record ReceiptRequest(String receiptNo, long subjectId, LocalDateTime receivedAt, String paymentMethod, String voucherNo, List<String> voucherFileIds, String remark, List<ReceiptAllocation> allocations) {}
|
|
|
|
|
public record ReceiptAllocation(long billId, BigDecimal amount) {}
|
|
|
|
|
public record CostRequest(String costNo, String costType, Long orderId, Long inboundId, Long containerId, Long supplierId, String supplierName, String feeItemCode, String feeItemName, BigDecimal amount, List<String> voucherFileIds, String remark) {}
|
|
|
|
|
public record ContainerOrderVolume(long orderId, Long inboundId, long containerId, long customerId, BigDecimal volumeCbm) {}
|
|
|
|
|
public record CostAllocation(ContainerOrderVolume order, BigDecimal totalVolumeCbm, BigDecimal rate,
|
|
|
|
|
BigDecimal amount, boolean roundingAdjustment) {}
|
|
|
|
|
}
|
|
|
|
|