// Package employeecollection 收口员工代收款账单的金额、状态与预占不变量。 // 只依赖标准库、领域常量和稳定错误,不依赖传输、持久化或外部 SDK。 package employeecollection import ( "strings" "unicode/utf8" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" ) // BillAmounts 描述一张员工代收款账单的应收、已核销、审批中预占与关闭事实。 type BillAmounts struct { // Receivable 表示应收金额(分),来源成功事务判定后不允许为负。 Receivable int64 // Received 表示企业微信最终通过后累计的已核销金额(分)。 Received int64 // Reserved 表示审批中分摊预占的金额(分)。 Reserved int64 // Closed 表示账单是否已关闭;已关闭账单的可核销余额为 0。 Closed bool } // NewBillAmounts 依据来源应收金额构造初始账单金额事实。 // 应收金额必须大于零,避免零元账单立即成为已核销。 func NewBillAmounts(receivable int64) (BillAmounts, error) { amounts := BillAmounts{Receivable: receivable} if err := amounts.Validate(); err != nil { return BillAmounts{}, err } return amounts, nil } // Validate 校验账单金额不变量:应收为正,已核销与预占非负且合计不超过应收。 func (a BillAmounts) Validate() error { if a.Receivable <= 0 { return errors.New(errors.CodeInvalidParam, "账单应收金额必须大于零") } if a.Received < 0 || a.Reserved < 0 { return errors.New(errors.CodeInvalidParam, "账单已核销与预占金额不能为负") } if a.Received+a.Reserved > a.Receivable { return errors.New(errors.CodeInvalidParam, "账单已核销与预占金额合计不能超过应收金额") } return nil } // Available 返回账单当前可被新分摊占用的金额;已关闭账单始终返回 0。 func (a BillAmounts) Available() int64 { if a.Closed { return 0 } available := a.Receivable - a.Received - a.Reserved if available < 0 { return 0 } return available } // DerivedStatus 依据金额推导未关闭账单的核销状态。 // 调用方必须自行区分已关闭账单,关闭状态不可由金额推导。 func (a BillAmounts) DerivedStatus() int { switch { case a.Received <= 0: return constants.EmployeeCollectionBillStatusPending case a.Received >= a.Receivable: return constants.EmployeeCollectionBillStatusSettled default: return constants.EmployeeCollectionBillStatusPartial } } // Reserve 在审批中预占指定金额,返回预占后的新金额事实。 // 分摊金额必须大于零且不超过当前可核销余额。 func (a BillAmounts) Reserve(amount int64) (BillAmounts, error) { if amount <= 0 { return BillAmounts{}, errors.New(errors.CodeEmployeeCollectionAllocationAmountInvalid) } if err := a.ensureSettleable(); err != nil { return BillAmounts{}, err } if amount > a.Available() { return BillAmounts{}, errors.New(errors.CodeEmployeeCollectionAllocationExceeded) } a.Reserved += amount return a, nil } // Release 释放指定金额的审批中预占,返回释放后的新金额事实。 func (a BillAmounts) Release(amount int64) (BillAmounts, error) { if amount <= 0 { return BillAmounts{}, errors.New(errors.CodeEmployeeCollectionAllocationAmountInvalid) } if amount > a.Reserved { return BillAmounts{}, errors.New(errors.CodeInternalError, "释放的预占金额超过账单当前预占") } a.Reserved -= amount return a, nil } // Approve 将指定金额从审批中预占转入已核销,返回通过后的新金额事实。 func (a BillAmounts) Approve(amount int64) (BillAmounts, error) { if amount <= 0 { return BillAmounts{}, errors.New(errors.CodeEmployeeCollectionAllocationAmountInvalid) } if amount > a.Reserved { return BillAmounts{}, errors.New(errors.CodeInternalError, "通过的分摊金额超过账单当前预占") } a.Reserved -= amount a.Received += amount return a, nil } // ReduceReceivable 按来源订单退款金额冲减应收,仅在账单不存在任何已通过或审批中分摊时允许。 func (a BillAmounts) ReduceReceivable(amount int64) (BillAmounts, error) { if amount <= 0 { return BillAmounts{}, errors.New(errors.CodeInvalidParam, "冲减金额必须大于零") } if a.Received > 0 || a.Reserved > 0 { return BillAmounts{}, errors.New(errors.CodeEmployeeCollectionBillNotSettleable, "账单存在分摊,不能冲减应收") } if amount >= a.Receivable { return BillAmounts{}, errors.New(errors.CodeInvalidParam, "冲减金额必须小于账单应收金额") } a.Receivable -= amount return a, nil } // ensureSettleable 校验账单允许产生新的审批中分摊。 func (a BillAmounts) ensureSettleable() error { if a.Closed { return errors.New(errors.CodeEmployeeCollectionBillClosed) } if a.DerivedStatus() == constants.EmployeeCollectionBillStatusSettled { return errors.New(errors.CodeEmployeeCollectionBillNotSettleable) } return nil } // BillCloseInput 描述关闭一张账单前的事实。 type BillCloseInput struct { // Status 表示账单当前持久化状态。 Status int // PendingAllocations 表示账单上仍处于审批中(预占)的分摊数量。 PendingAllocations int64 // Reason 表示关闭原因,必填。 Reason string } // ValidateBillClose 校验关闭账单的前置条件。 // 已关闭账单返回账单已关闭,已核销账单不允许关闭,存在审批中分摊时拒绝关闭,关闭原因必填。 func ValidateBillClose(input BillCloseInput) error { if input.Status == constants.EmployeeCollectionBillStatusClosed { return errors.New(errors.CodeEmployeeCollectionBillClosed) } if input.Status == constants.EmployeeCollectionBillStatusSettled { return errors.New(errors.CodeEmployeeCollectionBillNotSettleable, "已核销账单没有未核销余额,不能关闭") } if input.Status != constants.EmployeeCollectionBillStatusPending && input.Status != constants.EmployeeCollectionBillStatusPartial { return errors.New(errors.CodeConflict, "账单当前状态不允许关闭") } if input.PendingAllocations > 0 { return errors.New(errors.CodeEmployeeCollectionApplicationPending) } if trimmed := strings.TrimSpace(input.Reason); trimmed == "" { return errors.New(errors.CodeInvalidParam, "关闭原因必填") } else if utf8.RuneCountInString(trimmed) > constants.EmployeeCollectionRemarkMaxLength { return errors.New(errors.CodeInvalidParam, "关闭原因长度超出限制") } return nil }