feat(员工代收款): 新增员工代收款账单闭环
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m20s

- 新增 6 张表与成对迁移 000212,扩展企业微信审批场景业务类型白名单
- 后台线下套餐订单与两条代理线下充值入账路径在来源成功事务内建账,来源唯一键幂等
- 核销申请、审批尝试记录、账单分摊预占与驳回重提,审批业务类型 employee_collection_approval
- 企业微信终态消费幂等:通过转已核销、驳回释放预占、通过后撤销不回滚并转异常终态
- 退款成功事务内按 bill_id+refund_id 幂等冲销账单或仅写退款关联提示
- 线下收款方式字典、账单查询/统计/关闭、申请查询与代办权限,均写入事务内审计

OpenSpec Change: add-employee-collection-bills
This commit is contained in:
2026-09-10 18:24:05 +08:00
parent dc4e0d4103
commit ce24d5612e
54 changed files with 5975 additions and 465 deletions

View File

@@ -0,0 +1,671 @@
package employeecollection
import (
"context"
"fmt"
"sort"
"strings"
"time"
"github.com/bytedance/sonic"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
employeecollectiondomain "github.com/break/junhong_cmp_fiber/internal/domain/employeecollection"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// ApplicationAllocationCommand 描述核销申请中单张账单的本次分摊。
type ApplicationAllocationCommand struct {
BillID uint
Amount int64
}
// SubmitApplicationCommand 描述创建或重提核销申请的稳定输入。
type SubmitApplicationCommand struct {
PaymentMethodID uint
PaidAmount int64
PayerName string
PaidAt time.Time
ExternalTransactionNo string
PaymentVoucherKeys []string
Remark string
ActingReason string
Allocations []ApplicationAllocationCommand
}
// ApplicationSubmitResult 返回已原子保存的申请、审批尝试记录与分摊。
type ApplicationSubmitResult struct {
Application *model.EmployeeCollectionApplication
Attempt *model.EmployeeCollectionApplicationAttempt
Allocations []*model.EmployeeCollectionApplicationAllocation
Bills []*model.EmployeeCollectionBill
InstanceID uint
InstanceStatus int
}
// ApplicationService 创建与重提核销申请。
// 申请、审批尝试记录、审批实例与账单预占在同一事务完成;任一校验失败都不留下半成品事实。
type ApplicationService struct {
db *gorm.DB
approval approvalapp.Port
audit ApplicationAuditWriter
}
// NewApplicationService 创建核销申请用例。
func NewApplicationService(db *gorm.DB, approval approvalapp.Port, audit ApplicationAuditWriter) *ApplicationService {
return &ApplicationService{db: db, approval: approval, audit: audit}
}
// Create 为本人可见账单创建核销申请;超级管理员可为账单欠款人代办并必须填写代办原因。
func (s *ApplicationService) Create(ctx context.Context, command SubmitApplicationCommand) (*ApplicationSubmitResult, error) {
if err := s.ensureReady(); err != nil {
return nil, err
}
caller, err := currentApplicationCaller(ctx)
if err != nil {
return nil, err
}
return s.submit(ctx, caller, 0, command)
}
// Resubmit 修改并重提已驳回的核销申请,新增审批尝试记录与新的企业微信审批实例。
func (s *ApplicationService) Resubmit(ctx context.Context, applicationID uint, command SubmitApplicationCommand) (*ApplicationSubmitResult, error) {
if err := s.ensureReady(); err != nil {
return nil, err
}
caller, err := currentApplicationCaller(ctx)
if err != nil {
return nil, err
}
if applicationID == 0 {
return nil, errors.New(errors.CodeEmployeeCollectionApplicationNotFound)
}
return s.submit(ctx, caller, applicationID, command)
}
// ensureReady 校验用例依赖完整,缺失时失败关闭,避免绕过企业微信终审。
func (s *ApplicationService) ensureReady() error {
if s == nil || s.db == nil || s.approval == nil || s.audit == nil {
return errors.New(errors.CodeServiceUnavailable, "核销申请能力尚未配置")
}
return nil
}
// applicationCaller 是发起核销申请的真实操作者。
type applicationCaller struct {
AccountID uint
AccountName string
IsAdmin bool
}
// currentApplicationCaller 从上下文取当前操作者,未认证时拒绝。
func currentApplicationCaller(ctx context.Context) (applicationCaller, error) {
accountID := middleware.GetUserIDFromContext(ctx)
if accountID == 0 {
return applicationCaller{}, errors.New(errors.CodeUnauthorized)
}
return applicationCaller{
AccountID: accountID,
AccountName: middleware.GetUsernameFromContext(ctx),
IsAdmin: middleware.GetUserTypeFromContext(ctx) == constants.UserTypeSuperAdmin,
}, nil
}
// submit 在同一事务内完成校验、加锁、写申请、写审批尝试记录、创建审批实例与账单预占。
// 加锁次序全仓统一为「申请行 → 账单行ID 升序)」,与审批终态消费者保持一致,避免死锁。
func (s *ApplicationService) submit(
ctx context.Context,
caller applicationCaller,
applicationID uint,
command SubmitApplicationCommand,
) (*ApplicationSubmitResult, error) {
if command.PaymentMethodID == 0 {
return nil, errors.New(errors.CodeInvalidParam, "核销申请必须选择线下收款方式")
}
allocationCommands, err := sortedAllocationCommands(command.Allocations)
if err != nil {
return nil, err
}
correlationID := "employee_collection:application:" + uuid.NewString()
preparation, err := s.approval.Prepare(ctx, approvalapp.PrepareRequest{
BusinessType: constants.ApprovalBusinessTypeEmployeeCollection,
SubmitterAccountID: caller.AccountID, CorrelationID: correlationID,
})
if err != nil {
return nil, err
}
var result *ApplicationSubmitResult
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var existing *model.EmployeeCollectionApplication
if applicationID != 0 {
existing, err = lockApplication(ctx, tx, applicationID)
if err != nil {
return err
}
if err := employeecollectiondomain.ValidateApplicationResubmit(existing.Status); err != nil {
return err
}
if existing.ApplicantAccountID != caller.AccountID && !caller.IsAdmin {
return errors.New(errors.CodeEmployeeCollectionApplicationNotFound)
}
}
bills, err := lockBillsInAscendingOrder(ctx, tx, allocationCommands)
if err != nil {
return err
}
paymentMethod, err := loadEnabledPaymentMethod(ctx, tx, command.PaymentMethodID)
if err != nil {
return err
}
applicantAccountID, err := resolveApplicantAccountID(caller, existing, bills)
if err != nil {
return err
}
acting := applicantAccountID != caller.AccountID
normalized, err := employeecollectiondomain.NormalizeApplicationInput(employeecollectiondomain.ApplicationInput{
PaidAmount: command.PaidAmount, PayerName: command.PayerName, PaidAt: command.PaidAt,
ExternalTransactionNo: command.ExternalTransactionNo, Remark: command.Remark,
ActingReason: command.ActingReason, PaymentVoucherKeys: command.PaymentVoucherKeys,
}, acting)
if err != nil {
return err
}
candidates := make([]employeecollectiondomain.AllocationCandidate, 0, len(allocationCommands))
for _, item := range allocationCommands {
bill := bills[item.BillID]
candidates = append(candidates, employeecollectiondomain.AllocationCandidate{
BillID: item.BillID, BillStatus: bill.Status, Amount: item.Amount,
Available: billAmounts(bill).Available(),
})
}
if err := employeecollectiondomain.ValidateAllocations(normalized.PaidAmount, candidates); err != nil {
return err
}
application, beforeData, err := prepareApplication(
ctx, tx, caller, existing, applicantAccountID, paymentMethod, normalized)
if err != nil {
return err
}
attemptNo, err := nextAttemptNo(ctx, tx, application.ID)
if err != nil {
return err
}
attempt, err := buildAttempt(ctx, tx, application.ID, attemptNo, caller, paymentMethod, normalized, bills, allocationCommands)
if err != nil {
return err
}
submitterSnapshot, requestSnapshot, err := approvalSnapshots(application.ID, caller, paymentMethod, normalized, bills, allocationCommands)
if err != nil {
return err
}
reference, err := s.approval.CreateInTx(ctx, tx, approvalapp.CreateRequest{
Preparation: preparation, BusinessType: constants.ApprovalBusinessTypeEmployeeCollection,
BusinessID: attempt.ID, SubmitterAccountID: caller.AccountID,
SubmitterSnapshot: submitterSnapshot, RequestSnapshot: requestSnapshot,
CorrelationID: correlationID,
})
if err != nil {
return err
}
if err := attachAttemptInstance(ctx, tx, attempt, reference.InstanceID); err != nil {
return err
}
if err := updateApplicationLatest(ctx, tx, application, attempt, reference.InstanceID); err != nil {
return err
}
allocations, reservedBills, err := createAllocations(ctx, tx, application.ID, attempt.ID, caller, bills, allocationCommands)
if err != nil {
return err
}
submitEventID, err := composeAuditEventID(
"employee_collection", "application", uintText(application.ID), "attempt", intText(attempt.AttemptNo), "submit")
if err != nil {
return err
}
if err := s.audit.WriteEmployeeCollectionApplication(ctx, tx, ApplicationAudit{
EventID: submitEventID,
ActionCode: constants.AuditActionEmployeeCollectionApplicationSubmitted,
Summary: submitSummary(existing != nil),
Application: application, Attempt: attempt, Allocations: allocations, Bills: reservedBills,
BeforeData: beforeData, AfterData: applicationAuditSnapshot(application),
CorrelationID: correlationID,
}); err != nil {
return err
}
result = &ApplicationSubmitResult{
Application: application, Attempt: attempt, Allocations: allocations,
Bills: reservedBills, InstanceID: reference.InstanceID, InstanceStatus: reference.Status,
}
return nil
})
if err != nil {
return nil, err
}
return result, nil
}
// submitSummary 区分首次提交与重提的审计摘要。
func submitSummary(resubmit bool) string {
if resubmit {
return "重提员工代收款核销申请"
}
return "提交员工代收款核销申请"
}
// sortedAllocationCommands 校验分摊入参基本形态并按账单 ID 升序返回,保证锁序唯一。
func sortedAllocationCommands(items []ApplicationAllocationCommand) ([]ApplicationAllocationCommand, error) {
if len(items) == 0 {
return nil, errors.New(errors.CodeInvalidParam, "核销申请至少需要一个账单分摊")
}
if len(items) > constants.EmployeeCollectionAllocationMaxCount {
return nil, errors.New(errors.CodeInvalidParam, "核销申请账单分摊数量超出限制")
}
seen := make(map[uint]struct{}, len(items))
for _, item := range items {
if item.BillID == 0 {
return nil, errors.New(errors.CodeInvalidParam, "账单分摊缺少目标账单")
}
if item.Amount <= 0 {
return nil, errors.New(errors.CodeEmployeeCollectionAllocationAmountInvalid)
}
if _, exists := seen[item.BillID]; exists {
return nil, errors.New(errors.CodeInvalidParam, "同一账单不能重复分摊")
}
seen[item.BillID] = struct{}{}
}
sorted := append([]ApplicationAllocationCommand(nil), items...)
sort.Slice(sorted, func(i, j int) bool { return sorted[i].BillID < sorted[j].BillID })
return sorted, nil
}
// lockApplication 以行锁读取核销申请,未找到返回稳定不存在错误。
func lockApplication(ctx context.Context, tx *gorm.DB, id uint) (*model.EmployeeCollectionApplication, error) {
var application model.EmployeeCollectionApplication
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
First(&application, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeEmployeeCollectionApplicationNotFound)
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定核销申请失败")
}
return &application, nil
}
// lockBillsInAscendingOrder 按账单 ID 升序逐行加锁并返回账单事实。
// 单条 `WHERE id IN (...) ORDER BY id FOR UPDATE` 在 PostgreSQL 中先取行加锁再排序,
// 无法保证加锁次序;因此对每个账单各发一条只锁一行的语句,由调用方保证 ID 升序且不重复。
func lockBillsInAscendingOrder(
ctx context.Context,
tx *gorm.DB,
items []ApplicationAllocationCommand,
) (map[uint]*model.EmployeeCollectionBill, error) {
bills := make(map[uint]*model.EmployeeCollectionBill, len(items))
for _, item := range items {
var bill model.EmployeeCollectionBill
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
First(&bill, item.BillID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeEmployeeCollectionBillNotFound)
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定员工代收款账单失败")
}
bills[item.BillID] = &bill
}
return bills, nil
}
// loadEnabledPaymentMethod 读取启用中的线下收款方式字典项作为冻结来源。
func loadEnabledPaymentMethod(ctx context.Context, tx *gorm.DB, id uint) (*model.EmployeeCollectionPaymentMethod, error) {
var paymentMethod model.EmployeeCollectionPaymentMethod
if err := tx.WithContext(ctx).First(&paymentMethod, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeEmployeeCollectionPaymentMethodNotFound)
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询线下收款方式失败")
}
if paymentMethod.Status != constants.EmployeeCollectionPaymentMethodStatusEnabled {
return nil, errors.New(errors.CodeEmployeeCollectionPaymentMethodDisabled)
}
return &paymentMethod, nil
}
// resolveApplicantAccountID 依据所选账单确定申请人。
// 新建:非超级管理员只能选择本人欠款账单;超级管理员代办时全部账单必须属于同一欠款人。
// 重提:被选账单必须仍属于原申请人,申请人的其他越权访问与不存在返回同一错误。
func resolveApplicantAccountID(
caller applicationCaller,
existing *model.EmployeeCollectionApplication,
bills map[uint]*model.EmployeeCollectionBill,
) (uint, error) {
if existing != nil {
applicant := existing.ApplicantAccountID
for _, bill := range bills {
if bill.DebtorAccountID != applicant {
return 0, errors.New(errors.CodeEmployeeCollectionBillNotFound)
}
}
return applicant, nil
}
applicant := uint(0)
for _, bill := range bills {
if !caller.IsAdmin && bill.DebtorAccountID != caller.AccountID {
return 0, errors.New(errors.CodeEmployeeCollectionBillNotFound)
}
if applicant == 0 {
applicant = bill.DebtorAccountID
continue
}
if applicant != bill.DebtorAccountID {
return 0, errors.New(errors.CodeInvalidParam, "代办核销申请时全部账单必须属于同一欠款人")
}
}
return applicant, nil
}
// billAmounts 将账单持久化事实映射为领域金额事实。
func billAmounts(bill *model.EmployeeCollectionBill) employeecollectiondomain.BillAmounts {
return employeecollectiondomain.BillAmounts{
Receivable: bill.ReceivableAmount, Received: bill.ReceivedAmount, Reserved: bill.ReservedAmount,
Closed: bill.Status == constants.EmployeeCollectionBillStatusClosed,
}
}
// nextAttemptNo 返回该申请的下一条审批尝试序号;申请行已加锁,序号在同一事务内唯一。
func nextAttemptNo(ctx context.Context, tx *gorm.DB, applicationID uint) (int, error) {
var row struct {
MaxAttemptNo int
}
if err := tx.WithContext(ctx).Model(&model.EmployeeCollectionApplicationAttempt{}).
Select("COALESCE(MAX(attempt_no), 0) AS max_attempt_no").
Where("application_id = ?", applicationID).Scan(&row).Error; err != nil {
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询核销审批尝试序号失败")
}
return row.MaxAttemptNo + 1, nil
}
// prepareApplication 新建或就地更新核销申请,返回申请事实与变更前快照。
// 重提使用 expected-status 条件更新,状态已变化时返回冲突。
func prepareApplication(
ctx context.Context,
tx *gorm.DB,
caller applicationCaller,
existing *model.EmployeeCollectionApplication,
applicantAccountID uint,
paymentMethod *model.EmployeeCollectionPaymentMethod,
normalized employeecollectiondomain.NormalizedApplicationInput,
) (*model.EmployeeCollectionApplication, map[string]any, error) {
actingOperatorID := uint(0)
if applicantAccountID != caller.AccountID {
actingOperatorID = caller.AccountID
}
if existing == nil {
application := &model.EmployeeCollectionApplication{
ApplicantAccountID: applicantAccountID, ActingOperatorID: actingOperatorID,
ActingReason: normalized.ActingReason,
PaymentMethodID: paymentMethod.ID, PaymentMethodCode: paymentMethod.Code,
PaymentMethodName: paymentMethod.Name, PaidAmount: normalized.PaidAmount,
PayerName: normalized.PayerName, PaidAt: normalized.PaidAt,
ExternalTransactionNo: normalized.ExternalTransactionNo,
PaymentVoucherKeys: model.StringJSONBArray(normalized.PaymentVoucherKeys),
Remark: normalized.Remark,
Status: constants.EmployeeCollectionApplicationStatusPending,
Creator: caller.AccountID, Updater: caller.AccountID,
}
if err := tx.WithContext(ctx).Create(application).Error; err != nil {
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "创建核销申请失败")
}
return application, nil, nil
}
beforeData := applicationAuditSnapshot(existing)
expectedStatus := existing.Status
result := tx.WithContext(ctx).Model(&model.EmployeeCollectionApplication{}).
Where("id = ? AND status = ?", existing.ID, expectedStatus).
Updates(map[string]any{
"acting_operator_id": actingOperatorID,
"acting_reason": normalized.ActingReason,
"payment_method_id": paymentMethod.ID,
"payment_method_code": paymentMethod.Code,
"payment_method_name": paymentMethod.Name,
"paid_amount": normalized.PaidAmount,
"payer_name": normalized.PayerName,
"paid_at": normalized.PaidAt,
"external_transaction_no": normalized.ExternalTransactionNo,
"payment_voucher_keys": model.StringJSONBArray(normalized.PaymentVoucherKeys),
"remark": normalized.Remark,
"status": constants.EmployeeCollectionApplicationStatusPending,
"decided_at": nil,
"terminal_reason": "",
"updater": caller.AccountID,
})
if result.Error != nil {
return nil, nil, errors.Wrap(errors.CodeDatabaseError, result.Error, "更新核销申请失败")
}
if result.RowsAffected != 1 {
return nil, nil, errors.New(errors.CodeConflict, "核销申请状态已变化,请刷新后重试")
}
existing.ActingOperatorID = actingOperatorID
existing.ActingReason = normalized.ActingReason
existing.PaymentMethodID = paymentMethod.ID
existing.PaymentMethodCode = paymentMethod.Code
existing.PaymentMethodName = paymentMethod.Name
existing.PaidAmount = normalized.PaidAmount
existing.PayerName = normalized.PayerName
existing.PaidAt = normalized.PaidAt
existing.ExternalTransactionNo = normalized.ExternalTransactionNo
existing.PaymentVoucherKeys = model.StringJSONBArray(normalized.PaymentVoucherKeys)
existing.Remark = normalized.Remark
existing.Status = constants.EmployeeCollectionApplicationStatusPending
existing.DecidedAt = nil
existing.TerminalReason = ""
existing.Updater = caller.AccountID
return existing, beforeData, nil
}
// buildAttempt 新增一条不可变审批尝试记录,冻结当次收款方式、外部付款、附件与账单分摊快照。
func buildAttempt(
ctx context.Context,
tx *gorm.DB,
applicationID uint,
attemptNo int,
caller applicationCaller,
paymentMethod *model.EmployeeCollectionPaymentMethod,
normalized employeecollectiondomain.NormalizedApplicationInput,
bills map[uint]*model.EmployeeCollectionBill,
items []ApplicationAllocationCommand,
) (*model.EmployeeCollectionApplicationAttempt, error) {
snapshot, err := allocationSnapshot(bills, items)
if err != nil {
return nil, err
}
attempt := &model.EmployeeCollectionApplicationAttempt{
ApplicationID: applicationID, AttemptNo: attemptNo,
PaymentMethodID: paymentMethod.ID, PaymentMethodCode: paymentMethod.Code, PaymentMethodName: paymentMethod.Name,
PaidAmount: normalized.PaidAmount, PayerName: normalized.PayerName, PaidAt: normalized.PaidAt,
ExternalTransactionNo: normalized.ExternalTransactionNo,
PaymentVoucherKeys: model.StringJSONBArray(normalized.PaymentVoucherKeys),
Remark: normalized.Remark, SubmittedByAccountID: caller.AccountID,
ActingReason: normalized.ActingReason, AllocationSnapshot: snapshot,
}
if err := tx.WithContext(ctx).Create(attempt).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建核销审批尝试记录失败")
}
return attempt, nil
}
// allocationSnapshot 生成账单分摊快照,只保存账单摘要与金额,不含付款凭证内容。
func allocationSnapshot(
bills map[uint]*model.EmployeeCollectionBill,
items []ApplicationAllocationCommand,
) ([]byte, error) {
entries := make([]map[string]any, 0, len(items))
for _, item := range items {
bill := bills[item.BillID]
entries = append(entries, map[string]any{
"bill_id": bill.ID, "source_type": bill.SourceType, "source_no": bill.SourceNo,
"bill_status": bill.Status, "receivable_amount": bill.ReceivableAmount,
"received_amount": bill.ReceivedAmount, "reserved_amount": bill.ReservedAmount,
"amount": item.Amount,
})
}
payload, err := sonic.Marshal(entries)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "序列化账单分摊快照失败")
}
return payload, nil
}
// approvalSnapshots 生成通用审批的提交人快照与企业微信表单业务快照。
// 表单快照必须包含审批人核验所需的付款信息,因此保留经人工确认的完整外部流水号。
func approvalSnapshots(
applicationID uint,
caller applicationCaller,
paymentMethod *model.EmployeeCollectionPaymentMethod,
normalized employeecollectiondomain.NormalizedApplicationInput,
bills map[uint]*model.EmployeeCollectionBill,
items []ApplicationAllocationCommand,
) ([]byte, []byte, error) {
submitterSnapshot, err := sonic.Marshal(map[string]any{
"account_id": caller.AccountID, "account_name": caller.AccountName,
})
if err != nil {
return nil, nil, errors.Wrap(errors.CodeInternalError, err, "编码核销申请提交人快照失败")
}
requestSnapshot, err := sonic.Marshal(map[string]any{
constants.ApprovalFieldCollectionApplicationID: applicationID,
constants.ApprovalFieldCollectionPaymentMethod: paymentMethod.Name,
constants.ApprovalFieldCollectionPaidAmount: formatAmountYuan(normalized.PaidAmount),
constants.ApprovalFieldCollectionPaidAmountCent: normalized.PaidAmount,
constants.ApprovalFieldCollectionPayerName: normalized.PayerName,
constants.ApprovalFieldCollectionPaidAt: normalized.PaidAt.Format(time.RFC3339),
constants.ApprovalFieldCollectionExternalTransactionNo: normalized.ExternalTransactionNo,
constants.ApprovalFieldPaymentVoucherKey: normalized.PaymentVoucherKeys,
constants.ApprovalFieldRemark: normalized.Remark,
constants.ApprovalFieldSubmitterID: caller.AccountID,
constants.ApprovalFieldSubmitterName: caller.AccountName,
constants.ApprovalFieldCollectionBillCount: len(items),
constants.ApprovalFieldCollectionBillSummary: allocationSummary(bills, items),
})
if err != nil {
return nil, nil, errors.Wrap(errors.CodeInternalError, err, "编码核销审批业务快照失败")
}
return submitterSnapshot, requestSnapshot, nil
}
// allocationSummary 生成给审批人阅读的账单分摊摘要。
func allocationSummary(bills map[uint]*model.EmployeeCollectionBill, items []ApplicationAllocationCommand) string {
parts := make([]string, 0, len(items))
for _, item := range items {
bill := bills[item.BillID]
parts = append(parts, fmt.Sprintf("账单%d%s应收%s 本次分摊%s",
bill.ID, bill.SourceNo, formatAmountYuan(bill.ReceivableAmount), formatAmountYuan(item.Amount)))
}
return strings.Join(parts, "")
}
// formatAmountYuan 将分金额格式化为元字符串,仅用于展示与审批表单。
func formatAmountYuan(amount int64) string {
return fmt.Sprintf("%d.%02d", amount/100, amount%100)
}
// attachAttemptInstance 把审批实例 ID 回写到本次审批尝试记录,写入一次后不可修改。
func attachAttemptInstance(ctx context.Context, tx *gorm.DB, attempt *model.EmployeeCollectionApplicationAttempt, instanceID uint) error {
result := tx.WithContext(ctx).Model(&model.EmployeeCollectionApplicationAttempt{}).
Where("id = ? AND approval_instance_id IS NULL", attempt.ID).
Update("approval_instance_id", instanceID)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "关联核销审批实例失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "核销审批实例关联已变化")
}
attempt.ApprovalInstanceID = &instanceID
return nil
}
// updateApplicationLatest 更新申请的最新审批尝试与审批实例引用,仅用于展示。
func updateApplicationLatest(
ctx context.Context,
tx *gorm.DB,
application *model.EmployeeCollectionApplication,
attempt *model.EmployeeCollectionApplicationAttempt,
instanceID uint,
) error {
result := tx.WithContext(ctx).Model(&model.EmployeeCollectionApplication{}).
Where("id = ?", application.ID).
Updates(map[string]any{
"latest_attempt_id": attempt.ID, "latest_approval_instance_id": instanceID,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新核销申请最新审批实例失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "核销申请最新审批实例更新已变化")
}
application.LatestAttemptID = attempt.ID
application.LatestApprovalInstanceID = instanceID
return nil
}
// createAllocations 写入分摊行并按账单 ID 升序预占余额。
// 预占使用条件更新并要求 RowsAffected 为 1避免并发申请超额占用同一账单。
func createAllocations(
ctx context.Context,
tx *gorm.DB,
applicationID uint,
attemptID uint,
caller applicationCaller,
bills map[uint]*model.EmployeeCollectionBill,
items []ApplicationAllocationCommand,
) ([]*model.EmployeeCollectionApplicationAllocation, []*model.EmployeeCollectionBill, error) {
allocations := make([]*model.EmployeeCollectionApplicationAllocation, 0, len(items))
reservedBills := make([]*model.EmployeeCollectionBill, 0, len(items))
for _, item := range items {
bill := bills[item.BillID]
allocation := &model.EmployeeCollectionApplicationAllocation{
ApplicationID: applicationID, AttemptID: attemptID, BillID: bill.ID,
Amount: item.Amount, Status: constants.EmployeeCollectionAllocationStatusPending,
Creator: caller.AccountID, Updater: caller.AccountID,
}
if err := tx.WithContext(ctx).Create(allocation).Error; err != nil {
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "写入核销分摊失败")
}
result := tx.WithContext(ctx).Model(&model.EmployeeCollectionBill{}).
Where("id = ? AND reserved_amount + ? <= receivable_amount - received_amount", bill.ID, item.Amount).
Updates(map[string]any{
"reserved_amount": gorm.Expr("reserved_amount + ?", item.Amount),
"updater": caller.AccountID,
})
if result.Error != nil {
return nil, nil, errors.Wrap(errors.CodeDatabaseError, result.Error, "预占账单可核销余额失败")
}
if result.RowsAffected != 1 {
return nil, nil, errors.New(errors.CodeEmployeeCollectionAllocationExceeded)
}
bill.ReservedAmount += item.Amount
allocations = append(allocations, allocation)
reservedBills = append(reservedBills, bill)
}
return allocations, reservedBills, nil
}
// applicationAuditSnapshot 生成申请审计快照,外部交易流水号按脱敏值记录。
func applicationAuditSnapshot(application *model.EmployeeCollectionApplication) map[string]any {
return map[string]any{
"id": application.ID, "applicant_account_id": application.ApplicantAccountID,
"acting_operator_id": application.ActingOperatorID,
"payment_method_id": application.PaymentMethodID, "payment_method_code": application.PaymentMethodCode,
"paid_amount": application.PaidAmount, "payer_name": application.PayerName,
"external_transaction_no_masked": employeecollectiondomain.MaskExternalTransactionNo(application.ExternalTransactionNo),
"voucher_count": len(application.PaymentVoucherKeys),
"status": application.Status,
"latest_attempt_id": application.LatestAttemptID,
"latest_approval_instance_id": application.LatestApprovalInstanceID,
}
}