359 lines
32 KiB
Java
359 lines
32 KiB
Java
package com.baiyue.finance.service;
|
|
|
|
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.fasterxml.jackson.databind.SerializationFeature;
|
|
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 java.math.BigDecimal;
|
|
import java.sql.PreparedStatement;
|
|
import java.sql.Statement;
|
|
import java.time.LocalDate;
|
|
import java.time.LocalDateTime;
|
|
import java.util.List;
|
|
import java.util.Map;
|
|
|
|
/** 首期人工财务台账:账单、收款和成本,不承载钱包、发票或复杂核销。 */
|
|
@Service
|
|
public class FinanceService {
|
|
private final JdbcTemplate jdbc;
|
|
private final ObjectMapper objectMapper;
|
|
private final FinanceVoucherValidator voucherValidator;
|
|
private final FinanceSupplierValidator supplierValidator;
|
|
|
|
public FinanceService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceVoucherValidator voucherValidator,
|
|
FinanceSupplierValidator supplierValidator) {
|
|
this.jdbc = jdbc;
|
|
this.objectMapper = objectMapper;
|
|
this.voucherValidator = voucherValidator;
|
|
this.supplierValidator = supplierValidator;
|
|
}
|
|
|
|
public Map<String, Object> customerTerms(long tenantId) {
|
|
requireCustomer(tenantId);
|
|
List<Map<String, Object>> rows = jdbc.queryForList(
|
|
"SELECT tenant_id, customer_name_snapshot, credit_days, status FROM fin_customer_settlement_profile WHERE tenant_id=?",
|
|
tenantId);
|
|
return rows.isEmpty() ? Map.of("tenantId", tenantId, "creditDays", 30, "status", "ACTIVE") : rows.get(0);
|
|
}
|
|
|
|
public List<Map<String, Object>> listBills(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=? 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);
|
|
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> 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("账单不存在");
|
|
return bill(tenantId, billId);
|
|
}
|
|
|
|
public List<Map<String,Object>> billRevisions(long customerId, long billId) {
|
|
requireCustomer(customerId);
|
|
Number count = jdbc.queryForObject("SELECT COUNT(*) FROM fin_bill WHERE id=? AND tenant_id=?", Number.class, billId, customerId);
|
|
if (count == null || count.intValue() != 1) throw bad("账单不存在或不属于客户");
|
|
return jdbc.queryForList("SELECT id,bill_id,version,reason,bill_snapshot,line_snapshot,created_by,created_by_name,created_at FROM fin_bill_revision WHERE tenant_id=? AND bill_id=? ORDER BY version DESC,id DESC", customerId, billId);
|
|
}
|
|
|
|
public List<Map<String, Object>> listReceipts(Long tenantId) {
|
|
String sql = "SELECT r.id,r.tenant_id,r.receipt_no,r.received_amount,r.received_at,r.payment_method,r.voucher_no,r.voucher_file_ids,r.status,rb.bill_id,rb.applied_amount FROM fin_receipt r LEFT JOIN fin_receipt_bill rb ON rb.receipt_id=r.id";
|
|
return tenantId == null ? jdbc.queryForList(sql + " ORDER BY r.received_at DESC,r.id DESC") : jdbc.queryForList(sql + " WHERE r.tenant_id=? ORDER BY r.received_at DESC,r.id DESC", tenantId);
|
|
}
|
|
|
|
public List<Map<String, Object>> listCosts(Long tenantId) {
|
|
String sql = "SELECT * FROM fin_cost";
|
|
return tenantId == null ? jdbc.queryForList(sql + " ORDER BY created_at DESC,id DESC") : jdbc.queryForList(sql + " WHERE tenant_id=? ORDER BY created_at DESC,id DESC", tenantId);
|
|
}
|
|
|
|
@Transactional
|
|
public Map<String, Object> saveCustomerTerms(long tenantId, long operatorId, String customerName,
|
|
Integer creditDays, String idempotencyKey) {
|
|
requireCustomer(tenantId);
|
|
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(?,?,?,?,?) "
|
|
+ "ON DUPLICATE KEY UPDATE customer_name_snapshot=VALUES(customer_name_snapshot), credit_days=VALUES(credit_days), updated_by=VALUES(updated_by), version=version+1",
|
|
tenantId, StringUtils.hasText(customerName) ? customerName : "客户" + tenantId, days, operatorId, operatorId);
|
|
complete(tenantId, operatorId, "CUSTOMER_TERMS_SAVE", idempotencyKey, tenantId);
|
|
return customerTerms(tenantId);
|
|
}
|
|
|
|
@Transactional
|
|
public Map<String, Object> createReceipt(long tenantId, long operatorId, ReceiptRequest request, String 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 total = byBill.values().stream().reduce(BigDecimal.ZERO, BigDecimal::add);
|
|
if (total.signum() <= 0) throw bad("收款金额必须大于0");
|
|
for (var entry : byBill.entrySet()) {
|
|
Map<String, Object> bill = jdbc.queryForMap("SELECT outstanding_amount,status FROM fin_bill WHERE id=? AND tenant_id=? FOR UPDATE", entry.getKey(), tenantId);
|
|
BigDecimal outstanding = new BigDecimal(bill.get("outstanding_amount").toString());
|
|
if ("DRAFT".equals(bill.get("status")) || "VOIDED".equals(bill.get("status"))
|
|
|| entry.getValue().compareTo(outstanding) > 0) {
|
|
throw bad("只能分配已出账且未作废账单,且金额不得超过未收余额");
|
|
}
|
|
}
|
|
KeyHolder holder = new GeneratedKeyHolder();
|
|
jdbc.update(connection -> {
|
|
PreparedStatement ps = connection.prepareStatement("INSERT INTO fin_receipt(tenant_id,receipt_no,subject_id,received_amount,received_at,payment_method,voucher_no,voucher_file_ids,remark,created_by,created_by_name,updated_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)", Statement.RETURN_GENERATED_KEYS);
|
|
ps.setLong(1, tenantId); ps.setString(2, temporaryNo("RCPT")); ps.setLong(3, request.subjectId()); ps.setBigDecimal(4, total); ps.setObject(5, request.receivedAt() == null ? LocalDateTime.now() : request.receivedAt()); ps.setString(6, request.paymentMethod()); ps.setString(7, request.voucherNo()); ps.setString(8, json(request.voucherFileIds())); ps.setString(9, request.remark()); ps.setLong(10, operatorId); ps.setString(11, FinanceOperatorSnapshot.currentName(operatorId)); ps.setLong(12, operatorId); return ps;
|
|
}, holder);
|
|
long receiptId = holder.getKey().longValue();
|
|
jdbc.update("UPDATE fin_receipt SET receipt_no=? WHERE id=?", stableNo("RCPT", receiptId), receiptId);
|
|
for (var entry : byBill.entrySet()) {
|
|
BigDecimal applied = entry.getValue();
|
|
jdbc.update("INSERT INTO fin_receipt_bill(tenant_id,receipt_id,bill_id,applied_amount,created_by) VALUES(?,?,?,?,?)", tenantId, receiptId, entry.getKey(), applied, operatorId);
|
|
jdbc.update("UPDATE fin_bill SET received_amount=received_amount+?, outstanding_amount=outstanding_amount-?, status=CASE WHEN outstanding_amount=0 THEN 'PAID' ELSE 'PARTIALLY_PAID' END, updated_by=?, updated_by_name=?, version=version+1 WHERE id=? AND tenant_id=?", applied, applied, operatorId, FinanceOperatorSnapshot.currentName(operatorId), entry.getKey(), tenantId);
|
|
Integer settlementCount = jdbc.queryForObject(
|
|
"SELECT COUNT(*) FROM fin_settlement WHERE tenant_id=? AND direction='RECEIVABLE' AND reconciliation_statement_id=? AND status<>'VOIDED'",
|
|
Integer.class, tenantId, entry.getKey());
|
|
if (settlementCount != null && settlementCount > 0) {
|
|
int settlementUpdated = jdbc.update(
|
|
"UPDATE fin_settlement SET paid_amount=paid_amount+?,outstanding_amount=outstanding_amount-?,payment_status=CASE WHEN outstanding_amount=0 THEN 'PAID' ELSE 'PARTIALLY_PAID' END,status=CASE WHEN outstanding_amount=0 THEN 'PAID' ELSE 'PARTIALLY_PAID' END,updated_by=?,version=version+1 WHERE tenant_id=? AND direction='RECEIVABLE' AND reconciliation_statement_id=? AND status IN ('ISSUED','PARTIALLY_PAID') AND outstanding_amount>=?",
|
|
applied, applied, operatorId, tenantId, entry.getKey(), applied);
|
|
if (settlementUpdated != 1) throw new BizException(ResultCode.CONFLICT, "应收结算单状态或余额已变化");
|
|
}
|
|
}
|
|
complete(tenantId, operatorId, "RECEIPT_CREATE", idempotencyKey, receiptId);
|
|
return jdbc.queryForMap("SELECT * FROM fin_receipt WHERE id=?", receiptId);
|
|
}
|
|
|
|
private void validateVoucherFiles(long tenantId, List<String> fileIds) {
|
|
voucherValidator.validate(tenantId, fileIds, "财务凭证");
|
|
}
|
|
|
|
@Transactional
|
|
public Map<String, Object> createCost(long tenantId, long operatorId, CostRequest request, String 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, Map.of("cost", request, "containerOrders", containerOrders == null ? List.of() : containerOrders));
|
|
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");
|
|
String supplierName = null;
|
|
if (request.supplierId() != null) {
|
|
if (request.supplierId() <= 0) throw bad("供应商ID无效");
|
|
supplierName = supplierValidator.requireActive(request.supplierId(), null).name();
|
|
} else if (org.springframework.util.StringUtils.hasText(request.supplierName())) {
|
|
throw bad("供应商名称必须来自Warehouse供应商主数据");
|
|
}
|
|
validateVoucherFiles(tenantId, request.voucherFileIds());
|
|
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("整柜成本必须指定柜次及柜内有效订单体积");
|
|
if ("SINGLE_ORDER".equals(request.costType())) validateSingleOrder(tenantId, request.orderId());
|
|
validateContainerOrders(tenantId, request.containerId(), containerOrders);
|
|
String supplierNameSnapshot = supplierName;
|
|
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,created_by_name,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, supplierNameSnapshot); 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.setString(15, FinanceOperatorSnapshot.currentName(operatorId)); ps.setLong(16, 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 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("柜次、订单、客户归属或体积无效");
|
|
}
|
|
// The Firstleg snapshot is the authoritative ownership check for
|
|
// container costs. Fee sources may still be PENDING while a
|
|
// completed loading plan is already eligible for allocation.
|
|
}
|
|
}
|
|
|
|
private void validateSingleOrder(long tenantId, long orderId) {
|
|
Number exists = jdbc.queryForObject("SELECT COUNT(*) FROM fin_fee_source WHERE customer_id=? AND order_id=? AND status='AVAILABLE'", Number.class, tenantId, orderId);
|
|
if (exists != null && exists.intValue() == 0) 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, FinanceOperatorSnapshot.requiredName(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 regularShare = amount.multiply(rate).setScale(6, java.math.RoundingMode.HALF_UP);
|
|
BigDecimal share = tail ? amount.subtract(allocated).setScale(6, java.math.RoundingMode.HALF_UP) : regularShare;
|
|
boolean roundingAdjustment = tail && share.compareTo(regularShare) != 0;
|
|
allocated = allocated.add(share);
|
|
result.add(new CostAllocation(row, totalVolume, rate, share, roundingAdjustment));
|
|
}
|
|
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");
|
|
String hash = hash(new IdempotencyRequest(httpMethod(operation), requestPath(operation), request));
|
|
List<Map<String,Object>> rows = jdbc.queryForList("SELECT request_hash,status,result_resource_id,updated_at 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();
|
|
if ("FAILED".equals(row.get("status"))) {
|
|
int recovered = jdbc.update("UPDATE fin_idempotency_record SET status='PROCESSING',error_code=NULL WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=? AND status='FAILED'",
|
|
tenantId, operatorId, operation, key);
|
|
if (recovered == 1) return null;
|
|
}
|
|
LocalDateTime updatedAt = idempotencyUpdatedAt(row.get("updated_at"));
|
|
if ("PROCESSING".equals(row.get("status")) && updatedAt != null && updatedAt.isBefore(LocalDateTime.now().minusMinutes(5))) {
|
|
int recovered = jdbc.update("UPDATE fin_idempotency_record SET updated_at=CURRENT_TIMESTAMP(3) WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=? AND status='PROCESSING' AND updated_at=?",
|
|
tenantId, operatorId, operation, key, updatedAt);
|
|
if (recovered == 1) return null;
|
|
}
|
|
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) {
|
|
jdbc.update("UPDATE fin_idempotency_record SET status='SUCCEEDED',result_resource_id=? WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=? AND status='PROCESSING'", resourceId, tenantId, operatorId, operation, key);
|
|
}
|
|
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 static LocalDateTime idempotencyUpdatedAt(Object value) {
|
|
if (value instanceof LocalDateTime dateTime) return dateTime;
|
|
if (value instanceof java.sql.Timestamp timestamp) return timestamp.toLocalDateTime();
|
|
return null;
|
|
}
|
|
private static String httpMethod(String operation) {
|
|
return switch (operation) {
|
|
case "CUSTOMER_TERMS_SAVE", "BILL_UPDATE" -> "PUT";
|
|
default -> "POST";
|
|
};
|
|
}
|
|
private static String requestPath(String operation) {
|
|
return switch (operation) {
|
|
case "CUSTOMER_TERMS_SAVE" -> "/api/platform/finance/v1/customers/{customerId}/terms";
|
|
case "RECEIPT_CREATE" -> "/api/platform/finance/v1/receipts";
|
|
case "COST_CREATE" -> "/api/platform/finance/v1/cost-entries";
|
|
case "BILL_UPDATE" -> "/api/platform/finance/v1/bills/{billId}";
|
|
case "BILL_ADJUST" -> "/api/platform/finance/v1/bills/{billId}/adjust";
|
|
case "BILL_VOID" -> "/api/platform/finance/v1/bills/{billId}/void";
|
|
default -> throw new IllegalArgumentException("未知财务幂等操作: " + operation);
|
|
};
|
|
}
|
|
/** 固定请求映射键序,避免服务重启后拒绝同一财务命令。 */
|
|
private String hash(Object request) {
|
|
try {
|
|
byte[] canonical = objectMapper.writer().with(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS)
|
|
.writeValueAsBytes(request == null ? List.of() : request);
|
|
return java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(canonical));
|
|
} 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); }
|
|
private String json(Object value) { try { return objectMapper.writeValueAsString(value == null ? List.of() : value); } catch (JsonProcessingException e) { throw new IllegalArgumentException("凭证字段格式错误", e); } }
|
|
|
|
@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("账单不属于指定客户"); 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;
|
|
Object credit = customerTerms(tenantId).get("credit_days");
|
|
if (credit == null) credit = customerTerms(tenantId).get("creditDays");
|
|
LocalDate dueDate = effectiveDate.plusDays(credit == null ? 30 : ((Number) credit).intValue());
|
|
int changed = jdbc.update("UPDATE fin_bill SET bill_date=?,due_date=?,version=version+1,updated_by=?,updated_by_name=? WHERE id=? AND tenant_id=? AND status='DRAFT' AND version=?", effectiveDate,dueDate,operatorId,FinanceOperatorSnapshot.currentName(operatorId),billId,tenantId,expectedVersion);
|
|
if (changed != 1) throw bad("仅草稿账单可修改且版本必须匹配");
|
|
jdbc.update("UPDATE fin_bill SET total_amount=?, outstanding_amount=? WHERE id=? AND tenant_id=?", total, total, billId, tenantId);
|
|
complete(tenantId, operatorId, "BILL_UPDATE", key, billId);
|
|
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); 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=?,updated_by_name=? WHERE id=? AND tenant_id=? AND status IN ('ISSUED','PARTIALLY_PAID','PAID') AND version=?",reason,total,outstanding,status,operatorId,FinanceOperatorSnapshot.currentName(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); 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=?,updated_by_name=?,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,FinanceOperatorSnapshot.currentName(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,created_by_name) VALUES(?,?,?,?,?,?,?,?)",tenantId,billId,version,reason,json(bill),json(lines),operatorId,FinanceOperatorSnapshot.currentName(operatorId));
|
|
}
|
|
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()); }
|
|
|
|
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) {}
|
|
private record IdempotencyRequest(String method, String path, Object body) {}
|
|
}
|