This commit is contained in:
2026-08-11 09:25:41 +08:00
parent 66b3a0cc9b
commit 540a6f5d88
18 changed files with 757 additions and 90 deletions
@@ -0,0 +1,13 @@
package com.baiyue.finance;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/** Finance 独立服务启动入口。首期只承载人工账单、收款和成本台账。 */
@SpringBootApplication
public class BaiyueFinanceApplication {
public static void main(String[] args) {
SpringApplication.run(BaiyueFinanceApplication.class, args);
}
}
@@ -0,0 +1,64 @@
package com.baiyue.finance.controller;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.finance.service.FinanceService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@Tag(name = "人工财务台账")
@RestController
@RequestMapping("/api/platform/v1/finance")
public class PlatformFinanceController {
private final FinanceService service;
public PlatformFinanceController(FinanceService service) { this.service = service; }
@GetMapping("/customers/{tenantId}/terms")
@PreAuthorize("hasAuthority('finance:customer-terms:view')")
@Operation(summary = "查询客户账期")
public ApiResponse<Map<String, Object>> terms(@PathVariable long tenantId) { return ApiResponse.success(service.customerTerms(tenantId)); }
@PutMapping("/customers/{tenantId}/terms")
@PreAuthorize("hasAuthority('finance:customer-terms:update')")
@BusinessAudit(event = "FINANCE_CUSTOMER_TERMS_UPDATE", resourceType = "CUSTOMER")
public ApiResponse<Map<String, Object>> saveTerms(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long tenantId, @RequestHeader("Idempotency-Key") String key, @RequestBody TermsRequest request) {
return ApiResponse.success(service.saveCustomerTerms(tenantId, user.userId(), request.customerName(), request.creditDays(), key));
}
@PostMapping("/customers/{tenantId}/bills")
@PreAuthorize("hasAuthority('finance:bill:create')")
@BusinessAudit(event = "FINANCE_BILL_CREATE", resourceType = "FINANCE_BILL")
public ApiResponse<Map<String, Object>> createBill(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long tenantId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody FinanceService.BillRequest request) {
return ApiResponse.success(service.createBill(tenantId, user.userId(), request == null ? "客户" + tenantId : request.billNo(), request, key));
}
@PostMapping("/customers/{tenantId}/bills/{billId}/issue")
@PreAuthorize("hasAuthority('finance:bill:issue')")
@BusinessAudit(event = "FINANCE_BILL_ISSUE", resourceType = "FINANCE_BILL")
public ApiResponse<Map<String, Object>> issueBill(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long tenantId, @PathVariable long billId, @RequestHeader("Idempotency-Key") String key) {
return ApiResponse.success(service.issueBill(tenantId, user.userId(), billId, key));
}
@PostMapping("/customers/{tenantId}/receipts")
@PreAuthorize("hasAuthority('finance:receipt:create')")
@BusinessAudit(event = "FINANCE_RECEIPT_CREATE", resourceType = "FINANCE_RECEIPT")
public ApiResponse<Map<String, Object>> receipt(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long tenantId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody FinanceService.ReceiptRequest request) {
return ApiResponse.success(service.createReceipt(tenantId, user.userId(), request, key));
}
@PostMapping("/customers/{tenantId}/costs")
@PreAuthorize("hasAuthority('finance:cost:create')")
@BusinessAudit(event = "FINANCE_COST_CREATE", resourceType = "FINANCE_COST")
public ApiResponse<Map<String, Object>> cost(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long tenantId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody FinanceService.CostRequest request) {
return ApiResponse.success(service.createCost(tenantId, user.userId(), request, key));
}
public record TermsRequest(String customerName, Integer creditDays) {}
}
@@ -0,0 +1,137 @@
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 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;
public FinanceService(JdbcTemplate jdbc, ObjectMapper objectMapper) {
this.jdbc = jdbc;
this.objectMapper = objectMapper;
}
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);
}
@Transactional
public Map<String, Object> saveCustomerTerms(long tenantId, long operatorId, String customerName,
Integer creditDays, String idempotencyKey) {
requireCustomer(tenantId);
reserve(tenantId, operatorId, "CUSTOMER_TERMS_SAVE", idempotencyKey);
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);
return customerTerms(tenantId);
}
@Transactional
public Map<String, Object> createBill(long tenantId, long operatorId, String customerName, BillRequest request,
String idempotencyKey) {
requireCustomer(tenantId); reserve(tenantId, operatorId, "BILL_CREATE", idempotencyKey);
if (request.lines() == null || request.lines().isEmpty()) throw bad("账单至少需要一条费用明细");
BigDecimal total = request.lines().stream().map(BillLine::amount).reduce(BigDecimal.ZERO, BigDecimal::add);
if (total.signum() < 0) throw bad("账单金额不能为负数");
int days = ((Number) customerTerms(tenantId).get("credit_days")).intValue();
LocalDate billDate = request.billDate() == null ? LocalDate.now() : request.billDate();
KeyHolder holder = new GeneratedKeyHolder();
jdbc.update(connection -> {
PreparedStatement ps = connection.prepareStatement("INSERT INTO fin_bill(tenant_id,bill_no,subject_id,customer_name_snapshot,currency_code,bill_date,due_date,total_amount,outstanding_amount,remark,created_by,updated_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)", Statement.RETURN_GENERATED_KEYS);
ps.setLong(1, tenantId); ps.setString(2, request.billNo()); ps.setLong(3, request.subjectId()); ps.setString(4, customerName);
ps.setString(5, "CNY"); ps.setObject(6, billDate); ps.setObject(7, billDate.plusDays(days)); ps.setBigDecimal(8, total); ps.setBigDecimal(9, total);
ps.setString(10, request.remark()); ps.setLong(11, operatorId); ps.setLong(12, operatorId); return ps;
}, holder);
long billId = holder.getKey().longValue();
for (BillLine line : request.lines()) {
jdbc.update("INSERT INTO fin_bill_line(tenant_id,bill_id,source_type,source_id,source_version,source_no,order_id,inbound_id,fee_item_code,fee_item_name,amount) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
tenantId, billId, line.sourceType(), line.sourceId(), line.sourceVersion(), line.sourceNo(), line.orderId(), line.inboundId(), line.feeItemCode(), line.feeItemName(), line.amount());
}
return jdbc.queryForMap("SELECT * FROM fin_bill WHERE id=?", billId);
}
@Transactional
public Map<String, Object> issueBill(long tenantId, long operatorId, long billId, String idempotencyKey) {
requireCustomer(tenantId); reserve(tenantId, operatorId, "BILL_ISSUE", idempotencyKey);
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'",
LocalDateTime.now(), operatorId, operatorId, billId, tenantId);
if (updated != 1) throw bad("账单不存在或已出账");
return jdbc.queryForMap("SELECT * FROM fin_bill WHERE id=? AND tenant_id=?", billId, tenantId);
}
@Transactional
public Map<String, Object> createReceipt(long tenantId, long operatorId, ReceiptRequest request, String idempotencyKey) {
requireCustomer(tenantId); reserve(tenantId, operatorId, "RECEIPT_CREATE", idempotencyKey);
if (request.allocations() == null || request.allocations().isEmpty()) throw bad("收款至少需要分配一张账单");
BigDecimal total = request.allocations().stream().map(ReceiptAllocation::amount).reduce(BigDecimal.ZERO, BigDecimal::add);
if (total.signum() <= 0) throw bad("收款金额必须大于0");
for (ReceiptAllocation allocation : request.allocations()) {
Map<String, Object> bill = jdbc.queryForMap("SELECT outstanding_amount,status FROM fin_bill WHERE id=? AND tenant_id=? FOR UPDATE", allocation.billId(), tenantId);
BigDecimal outstanding = new BigDecimal(bill.get("outstanding_amount").toString());
if ("VOIDED".equals(bill.get("status")) || allocation.amount().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,updated_by) VALUES(?,?,?,?,?,?,?,?,?,?,?)", Statement.RETURN_GENERATED_KEYS);
ps.setLong(1, tenantId); ps.setString(2, request.receiptNo()); 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.setLong(11, operatorId); return ps;
}, holder);
long receiptId = holder.getKey().longValue();
for (ReceiptAllocation allocation : request.allocations()) {
jdbc.update("INSERT INTO fin_receipt_bill(tenant_id,receipt_id,bill_id,applied_amount,created_by) VALUES(?,?,?,?,?)", tenantId, receiptId, allocation.billId(), allocation.amount(), 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=?, version=version+1 WHERE id=? AND tenant_id=?", allocation.amount(), allocation.amount(), allocation.amount(), operatorId, allocation.billId(), tenantId);
}
return jdbc.queryForMap("SELECT * FROM fin_receipt WHERE id=?", receiptId);
}
@Transactional
public Map<String, Object> createCost(long tenantId, long operatorId, CostRequest request, String idempotencyKey) {
requireCustomer(tenantId); reserve(tenantId, operatorId, "COST_CREATE", idempotencyKey);
if (!"TICKET".equals(request.costType()) && !"CONTAINER".equals(request.costType())) throw bad("成本类型只能是TICKET或CONTAINER");
if (request.amount() == null || request.amount().signum() <= 0) throw bad("成本金额必须大于0");
jdbc.update("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(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
tenantId, request.costNo(), request.costType(), request.orderId(), request.inboundId(), request.containerId(), request.supplierId(), request.supplierName(), request.feeItemCode(), request.feeItemName(), request.amount(), json(request.voucherFileIds()), request.remark(), operatorId, operatorId);
return jdbc.queryForMap("SELECT * FROM fin_cost WHERE tenant_id=? AND cost_no=?", tenantId, request.costNo());
}
private void reserve(long tenantId, long operatorId, String operation, String key) {
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),'SUCCEEDED')", tenantId, operatorId, operation, key, key);
}
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); } }
public record BillRequest(String billNo, long subjectId, LocalDate billDate, String remark, List<BillLine> lines) {}
public record BillLine(String sourceType, long sourceId, int sourceVersion, String sourceNo, Long orderId, Long inboundId, String feeItemCode, String feeItemName, BigDecimal 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) {}
}
@@ -0,0 +1,6 @@
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: ${FINANCE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_finance?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true}
username: ${FINANCE_DB_USERNAME:root}
password: ${FINANCE_DB_PASSWORD:123456}
@@ -0,0 +1,32 @@
server:
port: ${FINANCE_PORT:9203}
spring:
profiles:
active: ${SPRING_PROFILES_ACTIVE:dev}
application:
name: finance-service
config:
import:
- optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true
- optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true
cloud:
nacos:
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848}
username: ${NACOS_USERNAME:nacos}
password: ${NACOS_PASSWORD:nacos}
discovery:
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848}
enabled: ${NACOS_DISCOVERY_ENABLED:true}
register-enabled: ${NACOS_REGISTER_ENABLED:true}
group: ${NACOS_GROUP:BAIYUE}
namespace: ${NACOS_NAMESPACE:public}
config:
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848}
namespace: ${NACOS_NAMESPACE:public}
management.endpoints.web.exposure.include: health,info,prometheus
logging:
file:
name: "${LOGGING_FILE_NAME:${LOG_PATH:./logs}/${spring.application.name}.log}"
+227
View File
@@ -0,0 +1,227 @@
CREATE DATABASE IF NOT EXISTS baiyue_finance
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
USE baiyue_finance;
CREATE TABLE IF NOT EXISTS fin_subject (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
subject_code VARCHAR(64) NOT NULL,
subject_name VARCHAR(200) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
version INT UNSIGNED NOT NULL DEFAULT 0,
created_by BIGINT UNSIGNED NOT NULL DEFAULT 0,
updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_fin_subject_code (subject_code)
) ENGINE=InnoDB COMMENT='平台财务主体;首期仅一个有效主体';
CREATE TABLE IF NOT EXISTS fin_customer_settlement_profile (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
customer_name_snapshot VARCHAR(200) NOT NULL,
credit_days INT UNSIGNED NOT NULL DEFAULT 30 COMMENT '默认30个自然日',
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
version INT UNSIGNED NOT NULL DEFAULT 0,
created_by BIGINT UNSIGNED NOT NULL,
updated_by BIGINT UNSIGNED NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_fin_customer_profile (tenant_id),
CONSTRAINT chk_fin_customer_profile_tenant CHECK (tenant_id > 0),
CONSTRAINT chk_fin_customer_profile_days CHECK (credit_days <= 3650)
) ENGINE=InnoDB COMMENT='客户结算配置;一客户一个结算对象';
CREATE TABLE IF NOT EXISTS fin_bill (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
tenant_id BIGINT UNSIGNED NOT NULL,
bill_no VARCHAR(64) NOT NULL,
subject_id BIGINT UNSIGNED NOT NULL,
customer_name_snapshot VARCHAR(200) NOT NULL,
currency_code CHAR(3) NOT NULL DEFAULT 'CNY',
bill_date DATE NOT NULL,
due_date DATE NOT NULL,
status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT 'DRAFT/ISSUED/PARTIALLY_PAID/PAID/VOIDED',
total_amount DECIMAL(20,6) NOT NULL DEFAULT 0,
received_amount DECIMAL(20,6) NOT NULL DEFAULT 0,
outstanding_amount DECIMAL(20,6) NOT NULL DEFAULT 0,
remark VARCHAR(500) NULL,
issued_at DATETIME(3) NULL,
issued_by BIGINT UNSIGNED NULL,
voided_at DATETIME(3) NULL,
voided_by BIGINT UNSIGNED NULL,
void_reason VARCHAR(500) NULL,
version INT UNSIGNED NOT NULL DEFAULT 0,
created_by BIGINT UNSIGNED NOT NULL,
updated_by BIGINT UNSIGNED NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_fin_bill_no (tenant_id, bill_no),
KEY idx_fin_bill_customer_status_date (tenant_id, status, bill_date),
CONSTRAINT chk_fin_bill_tenant CHECK (tenant_id > 0),
CONSTRAINT chk_fin_bill_currency CHECK (currency_code = 'CNY'),
CONSTRAINT chk_fin_bill_amounts CHECK (total_amount >= 0 AND received_amount >= 0 AND outstanding_amount >= 0)
) ENGINE=InnoDB COMMENT='客户CNY账单;首期账单即应收台账';
CREATE TABLE IF NOT EXISTS fin_bill_line (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
tenant_id BIGINT UNSIGNED NOT NULL,
bill_id BIGINT UNSIGNED NOT NULL,
source_type VARCHAR(32) NOT NULL DEFAULT 'FIRSTLEG_FEE',
source_id BIGINT UNSIGNED NOT NULL,
source_version INT UNSIGNED NOT NULL,
source_no VARCHAR(64) NULL,
order_id BIGINT UNSIGNED NULL,
inbound_id BIGINT UNSIGNED NULL,
fee_item_code VARCHAR(64) NOT NULL,
fee_item_name VARCHAR(200) NOT NULL,
amount DECIMAL(20,6) NOT NULL,
currency_code CHAR(3) NOT NULL DEFAULT 'CNY',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_fin_bill_line_source (source_type, source_id, source_version),
KEY idx_fin_bill_line_bill (tenant_id, bill_id),
CONSTRAINT chk_fin_bill_line_tenant CHECK (tenant_id > 0),
CONSTRAINT chk_fin_bill_line_currency CHECK (currency_code = 'CNY'),
CONSTRAINT chk_fin_bill_line_amount CHECK (amount >= 0)
) ENGINE=InnoDB COMMENT='账单费用来源快照明细';
CREATE TABLE IF NOT EXISTS fin_receipt (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
tenant_id BIGINT UNSIGNED NOT NULL,
receipt_no VARCHAR(64) NOT NULL,
subject_id BIGINT UNSIGNED NOT NULL,
currency_code CHAR(3) NOT NULL DEFAULT 'CNY',
received_amount DECIMAL(20,6) NOT NULL,
received_at DATETIME(3) NOT NULL,
payment_method VARCHAR(32) NULL,
voucher_no VARCHAR(128) NULL,
voucher_file_ids JSON NOT NULL COMMENT 'File服务文件ID字符串数组',
status VARCHAR(16) NOT NULL DEFAULT 'CONFIRMED' COMMENT 'CONFIRMED/VOIDED',
remark VARCHAR(500) NULL,
voided_at DATETIME(3) NULL,
voided_by BIGINT UNSIGNED NULL,
void_reason VARCHAR(500) NULL,
version INT UNSIGNED NOT NULL DEFAULT 0,
created_by BIGINT UNSIGNED NOT NULL,
updated_by BIGINT UNSIGNED NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_fin_receipt_no (tenant_id, receipt_no),
KEY idx_fin_receipt_customer_time (tenant_id, received_at),
CONSTRAINT chk_fin_receipt_tenant CHECK (tenant_id > 0),
CONSTRAINT chk_fin_receipt_currency CHECK (currency_code = 'CNY'),
CONSTRAINT chk_fin_receipt_amount CHECK (received_amount > 0)
) ENGINE=InnoDB COMMENT='平台财务人工登记收款及凭证';
CREATE TABLE IF NOT EXISTS fin_receipt_bill (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
tenant_id BIGINT UNSIGNED NOT NULL,
receipt_id BIGINT UNSIGNED NOT NULL,
bill_id BIGINT UNSIGNED NOT NULL,
applied_amount DECIMAL(20,6) NOT NULL,
created_by BIGINT UNSIGNED NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_fin_receipt_bill_pair (receipt_id, bill_id),
KEY idx_fin_receipt_bill_bill (tenant_id, bill_id),
CONSTRAINT chk_fin_receipt_bill_tenant CHECK (tenant_id > 0),
CONSTRAINT chk_fin_receipt_bill_amount CHECK (applied_amount > 0)
) ENGINE=InnoDB COMMENT='人工收款对应账单金额;UI不使用复杂核销术语';
CREATE TABLE IF NOT EXISTS fin_cost (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
tenant_id BIGINT UNSIGNED NOT NULL,
cost_no VARCHAR(64) NOT NULL,
cost_type VARCHAR(24) NOT NULL COMMENT 'TICKET/CONTAINER',
order_id BIGINT UNSIGNED NULL,
inbound_id BIGINT UNSIGNED NULL,
container_id BIGINT UNSIGNED NULL,
supplier_id BIGINT UNSIGNED NULL,
supplier_name_snapshot VARCHAR(200) NULL,
fee_item_code VARCHAR(64) NOT NULL,
fee_item_name VARCHAR(200) NOT NULL,
currency_code CHAR(3) NOT NULL DEFAULT 'CNY',
cost_amount DECIMAL(20,6) NOT NULL,
voucher_file_ids JSON NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'DRAFT' COMMENT 'DRAFT/CONFIRMED/VOIDED',
remark VARCHAR(500) NULL,
confirmed_at DATETIME(3) NULL,
confirmed_by BIGINT UNSIGNED NULL,
voided_at DATETIME(3) NULL,
voided_by BIGINT UNSIGNED NULL,
void_reason VARCHAR(500) NULL,
version INT UNSIGNED NOT NULL DEFAULT 0,
created_by BIGINT UNSIGNED NOT NULL,
updated_by BIGINT UNSIGNED NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_fin_cost_no (tenant_id, cost_no),
KEY idx_fin_cost_ticket (tenant_id, order_id, inbound_id),
KEY idx_fin_cost_container (tenant_id, container_id),
CONSTRAINT chk_fin_cost_tenant CHECK (tenant_id > 0),
CONSTRAINT chk_fin_cost_currency CHECK (currency_code = 'CNY'),
CONSTRAINT chk_fin_cost_amount CHECK (cost_amount > 0),
CONSTRAINT chk_fin_cost_target CHECK (
(cost_type = 'TICKET' AND order_id IS NOT NULL AND container_id IS NULL)
OR (cost_type = 'CONTAINER' AND container_id IS NOT NULL)
)
) ENGINE=InnoDB COMMENT='人工成本;单票直接归属,整柜按体积分摊';
CREATE TABLE IF NOT EXISTS fin_cost_allocation (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
tenant_id BIGINT UNSIGNED NOT NULL,
cost_id BIGINT UNSIGNED NOT NULL,
order_id BIGINT UNSIGNED NOT NULL,
inbound_id BIGINT UNSIGNED NULL,
volume_cbm DECIMAL(20,6) NOT NULL,
total_volume_cbm DECIMAL(20,6) NOT NULL,
allocation_rate DECIMAL(20,10) NOT NULL,
allocated_amount DECIMAL(20,6) NOT NULL,
rounding_adjustment TINYINT NOT NULL DEFAULT 0 COMMENT '1表示尾差归集行',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_fin_cost_allocation_order (cost_id, order_id),
KEY idx_fin_cost_allocation_query (tenant_id, order_id),
CONSTRAINT chk_fin_cost_allocation_volume CHECK (volume_cbm >= 0 AND total_volume_cbm > 0),
CONSTRAINT chk_fin_cost_allocation_amount CHECK (allocated_amount >= 0)
) ENGINE=InnoDB COMMENT='整柜成本按有效体积分摊的可审计结果';
CREATE TABLE IF NOT EXISTS fin_idempotency_record (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
tenant_id BIGINT UNSIGNED NOT NULL,
operator_id BIGINT UNSIGNED NOT NULL,
operation VARCHAR(64) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
request_hash CHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL COMMENT 'PROCESSING/SUCCEEDED/FAILED',
result_resource_id BIGINT UNSIGNED NULL,
error_code VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_fin_idempotency (tenant_id, operator_id, operation, idempotency_key)
) ENGINE=InnoDB COMMENT='Finance人工写操作幂等记录';
CREATE TABLE IF NOT EXISTS fin_audit_log (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
tenant_id BIGINT UNSIGNED NOT NULL,
resource_type VARCHAR(32) NOT NULL,
resource_id BIGINT UNSIGNED NOT NULL,
action VARCHAR(64) NOT NULL,
reason VARCHAR(500) NULL,
before_data JSON NULL,
after_data JSON NULL,
operator_id BIGINT UNSIGNED NOT NULL,
operator_name_snapshot VARCHAR(128) NOT NULL,
request_id VARCHAR(128) NULL,
trace_id VARCHAR(128) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
KEY idx_fin_audit_resource (tenant_id, resource_type, resource_id, created_at),
KEY idx_fin_audit_operator (operator_id, created_at)
) ENGINE=InnoDB COMMENT='Finance不可变业务审计明细';