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,502 @@
package employeecollection
import (
"context"
"strconv"
"strings"
"time"
"github.com/bytedance/sonic"
"gorm.io/gorm"
employeecollectiondomain "github.com/break/junhong_cmp_fiber/internal/domain/employeecollection"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// billDateLayout 是账单筛选使用的自然日格式。
const billDateLayout = "2006-01-02"
// BillQuery 查询员工代收款账单列表、统计与详情。
// 列表与统计共用 applyBillScope 生成的同一个 *gorm.DB禁止两处各写一套筛选。
type BillQuery struct {
db *gorm.DB
}
// NewBillQuery 创建员工代收款账单查询。
func NewBillQuery(db *gorm.DB) *BillQuery {
return &BillQuery{db: db}
}
// List 分页查询当前可见范围内的员工代收款账单。
func (q *BillQuery) List(ctx context.Context, request dto.EmployeeCollectionBillListRequest) (*dto.EmployeeCollectionBillListResponse, error) {
query, err := q.applyBillScope(ctx, billFilterOfList(request))
if err != nil {
return nil, err
}
page, pageSize := normalizePage(request.Page, request.PageSize)
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询员工代收款账单总数失败")
}
var bills []model.EmployeeCollectionBill
if err := query.Order("created_at DESC, id DESC").
Offset((page - 1) * pageSize).Limit(pageSize).
Find(&bills).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询员工代收款账单列表失败")
}
list := make([]*dto.EmployeeCollectionBillResponse, 0, len(bills))
for index := range bills {
item, err := ProjectBill(&bills[index])
if err != nil {
return nil, err
}
list = append(list, item)
}
return &dto.EmployeeCollectionBillListResponse{
List: list, Total: total, Page: page, PageSize: pageSize,
}, nil
}
// Statistics 汇总当前可见范围内的应收、已核销、未核销金额与待处理账单数。
// 使用与列表完全相同的可见性与筛选条件。
func (q *BillQuery) Statistics(
ctx context.Context,
request dto.EmployeeCollectionBillStatisticsRequest,
) (*dto.EmployeeCollectionBillStatisticsResponse, error) {
query, err := q.applyBillScope(ctx, billFilterOfStatistics(request))
if err != nil {
return nil, err
}
var row struct {
ReceivableTotal int64
ReceivedTotal int64
UnsettledTotal int64
PendingBillCount int64
}
if err := query.Select(
`COALESCE(SUM(receivable_amount), 0) AS receivable_total,
COALESCE(SUM(received_amount), 0) AS received_total,
COALESCE(SUM(CASE WHEN status = ? THEN 0 ELSE receivable_amount - received_amount END), 0) AS unsettled_total,
COALESCE(SUM(CASE WHEN status IN (?, ?) THEN 1 ELSE 0 END), 0) AS pending_bill_count`,
constants.EmployeeCollectionBillStatusClosed,
constants.EmployeeCollectionBillStatusPending,
constants.EmployeeCollectionBillStatusPartial,
).Scan(&row).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询员工代收款账单统计失败")
}
return &dto.EmployeeCollectionBillStatisticsResponse{
ReceivableTotal: row.ReceivableTotal, ReceivedTotal: row.ReceivedTotal,
UnsettledTotal: row.UnsettledTotal, PendingBillCount: row.PendingBillCount,
}, nil
}
// Detail 返回当前可见范围内的账单详情,包含退款冲销、分摊与申请审批历史。
// 不可见与不存在返回同一错误,不产生可枚举差异。
func (q *BillQuery) Detail(ctx context.Context, id uint) (*dto.EmployeeCollectionBillDetailResponse, error) {
if q == nil || q.db == nil || id == 0 {
return nil, errors.New(errors.CodeEmployeeCollectionBillNotFound)
}
scoped, err := applyBillVisibility(ctx, q.db.WithContext(ctx).Model(&model.EmployeeCollectionBill{}))
if err != nil {
return nil, err
}
var bill model.EmployeeCollectionBill
if err := scoped.Where("id = ?", id).First(&bill).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeEmployeeCollectionBillNotFound)
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询员工代收款账单详情失败")
}
billResponse, err := ProjectBill(&bill)
if err != nil {
return nil, err
}
refunds, err := q.billRefunds(ctx, bill.ID)
if err != nil {
return nil, err
}
allocations, applicationIDs, err := q.billAllocations(ctx, bill.ID)
if err != nil {
return nil, err
}
applications, err := q.billApplications(ctx, applicationIDs)
if err != nil {
return nil, err
}
return &dto.EmployeeCollectionBillDetailResponse{
Bill: billResponse, Refunds: refunds, Allocations: allocations, Applications: applications,
}, nil
}
// applyBillScope 生成同时应用可见性与筛选条件的账单查询。
// 列表、统计与关闭后的可见性校验都必须走这一处,避免筛选或数据范围分叉。
func (q *BillQuery) applyBillScope(ctx context.Context, filter billFilter) (*gorm.DB, error) {
if q == nil || q.db == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "员工代收款账单查询尚未配置")
}
query, err := applyBillVisibility(ctx, q.db.WithContext(ctx).Model(&model.EmployeeCollectionBill{}))
if err != nil {
return nil, err
}
return applyBillFilters(query, filter)
}
// billFilter 账单列表与统计共用的筛选条件,屏蔽两种请求 DTO 的字段差异。
type billFilter struct {
SourceType *string
SourceNo *string
Status *int
DebtorAccountID *uint
CustomerID *uint
CreatedFrom *string
CreatedTo *string
}
// billFilterOfList 投影账单列表请求的筛选字段。
func billFilterOfList(request dto.EmployeeCollectionBillListRequest) billFilter {
return billFilter{
SourceType: request.SourceType, SourceNo: request.SourceNo, Status: request.Status,
DebtorAccountID: request.DebtorAccountID, CustomerID: request.CustomerID,
CreatedFrom: request.CreatedFrom, CreatedTo: request.CreatedTo,
}
}
// billFilterOfStatistics 投影账单统计请求的筛选字段。
func billFilterOfStatistics(request dto.EmployeeCollectionBillStatisticsRequest) billFilter {
return billFilter{
SourceType: request.SourceType, SourceNo: request.SourceNo, Status: request.Status,
DebtorAccountID: request.DebtorAccountID, CustomerID: request.CustomerID,
CreatedFrom: request.CreatedFrom, CreatedTo: request.CreatedTo,
}
}
// applyBillVisibility 应用账单可见性:超级管理员见全部,其他账号仅见本人欠款账单。
func applyBillVisibility(ctx context.Context, query *gorm.DB) (*gorm.DB, error) {
if middleware.GetUserTypeFromContext(ctx) == constants.UserTypeSuperAdmin {
return query, nil
}
accountID := middleware.GetUserIDFromContext(ctx)
if accountID == 0 {
return nil, errors.New(errors.CodeUnauthorized)
}
return query.Where("debtor_account_id = ?", accountID), nil
}
// applyBillFilters 应用账单列表与统计共用的筛选条件。
func applyBillFilters(query *gorm.DB, request billFilter) (*gorm.DB, error) {
if request.DebtorAccountID != nil && *request.DebtorAccountID > 0 {
query = query.Where("debtor_account_id = ?", *request.DebtorAccountID)
}
if request.SourceType != nil {
sourceType := strings.TrimSpace(*request.SourceType)
switch sourceType {
case "":
case constants.EmployeeCollectionSourceTypeOrder, constants.EmployeeCollectionSourceTypeRecharge:
query = query.Where("source_type = ?", sourceType)
default:
return nil, errors.New(errors.CodeInvalidParam, "不支持的账单来源类型")
}
}
if request.SourceNo != nil {
if sourceNo := strings.TrimSpace(*request.SourceNo); sourceNo != "" {
query = query.Where("source_no = ?", sourceNo)
}
}
if request.Status != nil {
switch *request.Status {
case constants.EmployeeCollectionBillStatusPending, constants.EmployeeCollectionBillStatusPartial,
constants.EmployeeCollectionBillStatusSettled, constants.EmployeeCollectionBillStatusClosed:
query = query.Where("status = ?", *request.Status)
default:
return nil, errors.New(errors.CodeInvalidParam, "不支持的账单状态")
}
}
if request.CustomerID != nil && *request.CustomerID > 0 {
customerID := strconv.FormatUint(uint64(*request.CustomerID), 10)
query = query.Where("customer_snapshot->>'buyer_id' = ? OR customer_snapshot->>'shop_id' = ?", customerID, customerID)
}
if request.CreatedFrom != nil {
from, err := parseBillDate(*request.CreatedFrom)
if err != nil {
return nil, err
}
if from != nil {
query = query.Where("created_at >= ?", *from)
}
}
if request.CreatedTo != nil {
to, err := parseBillDate(*request.CreatedTo)
if err != nil {
return nil, err
}
if to != nil {
query = query.Where("created_at < ?", to.AddDate(0, 0, 1))
}
}
return query, nil
}
// parseBillDate 解析自然日筛选值;空值表示不筛选。
func parseBillDate(value string) (*time.Time, error) {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return nil, nil
}
parsed, err := time.ParseInLocation(billDateLayout, trimmed, time.Local)
if err != nil {
return nil, errors.New(errors.CodeInvalidParam, "账单筛选日期格式必须为 YYYY-MM-DD")
}
return &parsed, nil
}
// billRefunds 读取账单的来源订单退款冲销关联。
func (q *BillQuery) billRefunds(ctx context.Context, billID uint) ([]*dto.EmployeeCollectionBillRefundResponse, error) {
var refunds []model.EmployeeCollectionBillRefund
if err := q.db.WithContext(ctx).Where("bill_id = ?", billID).Order("id ASC").Find(&refunds).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询账单退款冲销关联失败")
}
result := make([]*dto.EmployeeCollectionBillRefundResponse, 0, len(refunds))
for index := range refunds {
item := refunds[index]
result = append(result, &dto.EmployeeCollectionBillRefundResponse{
ID: item.ID, RefundID: item.RefundID, SourceOrderID: item.SourceOrderID,
RefundAmount: item.RefundAmount, BillReceivableAmount: item.BillReceivableAmount,
Outcome: item.Outcome, OutcomeName: constants.GetEmployeeCollectionRefundOutcomeName(item.Outcome),
ReducedAmount: item.ReducedAmount, CreatedAt: item.CreatedAt,
})
}
return result, nil
}
// billAllocations 读取账单分摊,并返回涉及的去重申请 ID。
func (q *BillQuery) billAllocations(ctx context.Context, billID uint) ([]*dto.EmployeeCollectionBillAllocationResponse, []uint, error) {
var allocations []model.EmployeeCollectionApplicationAllocation
if err := q.db.WithContext(ctx).Where("bill_id = ?", billID).Order("id ASC").Find(&allocations).Error; err != nil {
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询账单分摊失败")
}
applicationIDs := make([]uint, 0, len(allocations))
seen := make(map[uint]struct{}, len(allocations))
for index := range allocations {
if _, exists := seen[allocations[index].ApplicationID]; !exists {
seen[allocations[index].ApplicationID] = struct{}{}
applicationIDs = append(applicationIDs, allocations[index].ApplicationID)
}
}
applicationStatus := make(map[uint]int, len(applicationIDs))
if len(applicationIDs) > 0 {
var applications []model.EmployeeCollectionApplication
if err := q.db.WithContext(ctx).Select("id", "status").
Where("id IN ?", applicationIDs).Find(&applications).Error; err != nil {
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询账单分摊所属申请失败")
}
for index := range applications {
applicationStatus[applications[index].ID] = applications[index].Status
}
}
result := make([]*dto.EmployeeCollectionBillAllocationResponse, 0, len(allocations))
for index := range allocations {
item := allocations[index]
status := applicationStatus[item.ApplicationID]
result = append(result, &dto.EmployeeCollectionBillAllocationResponse{
ID: item.ID, ApplicationID: item.ApplicationID,
ApplicationStatus: status, ApplicationStatusName: constants.GetEmployeeCollectionApplicationStatusName(status),
AttemptID: item.AttemptID, Amount: item.Amount,
Status: item.Status, StatusName: constants.GetEmployeeCollectionAllocationStatusName(item.Status),
ReleasedAt: item.ReleasedAt, CreatedAt: item.CreatedAt,
})
}
return result, applicationIDs, nil
}
// billApplications 读取涉及该账单的申请及其审批尝试历史。
func (q *BillQuery) billApplications(ctx context.Context, applicationIDs []uint) ([]*dto.EmployeeCollectionBillApplicationResponse, error) {
if len(applicationIDs) == 0 {
return []*dto.EmployeeCollectionBillApplicationResponse{}, nil
}
var applications []model.EmployeeCollectionApplication
if err := q.db.WithContext(ctx).Where("id IN ?", applicationIDs).Order("id ASC").Find(&applications).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询账单关联核销申请失败")
}
attemptsByApplication, approvalStatus, approvalOpinions, err := q.applicationAttempts(ctx, applicationIDs)
if err != nil {
return nil, err
}
result := make([]*dto.EmployeeCollectionBillApplicationResponse, 0, len(applications))
for index := range applications {
application := applications[index]
vouchers := []string(application.PaymentVoucherKeys)
if vouchers == nil {
vouchers = []string{}
}
attempts, err := attemptResponses(
application.ID, attemptsByApplication[application.ID], approvalStatus, approvalOpinions)
if err != nil {
return nil, err
}
result = append(result, &dto.EmployeeCollectionBillApplicationResponse{
ID: application.ID, ApplicantAccountID: application.ApplicantAccountID,
ActingOperatorID: application.ActingOperatorID, ActingReason: application.ActingReason,
PaymentMethodID: application.PaymentMethodID, PaymentMethodCode: application.PaymentMethodCode,
PaymentMethodName: application.PaymentMethodName, PaidAmount: application.PaidAmount,
PayerName: application.PayerName, PaidAt: application.PaidAt,
ExternalTransactionNo: application.ExternalTransactionNo, PaymentVoucherKeys: vouchers,
Remark: application.Remark, Status: application.Status,
StatusName: constants.GetEmployeeCollectionApplicationStatusName(application.Status),
LatestApprovalInstanceID: application.LatestApprovalInstanceID,
DecidedAt: application.DecidedAt, TerminalReason: application.TerminalReason,
CreatedAt: application.CreatedAt,
Attempts: attempts,
})
}
return result, nil
}
// applicationAttempts 读取申请的审批尝试记录、审批实例状态与审批意见。
func (q *BillQuery) applicationAttempts(
ctx context.Context,
applicationIDs []uint,
) (map[uint][]model.EmployeeCollectionApplicationAttempt, map[uint]int, map[uint]string, error) {
var attempts []model.EmployeeCollectionApplicationAttempt
if err := q.db.WithContext(ctx).Where("application_id IN ?", applicationIDs).
Order("application_id ASC, attempt_no ASC").Find(&attempts).Error; err != nil {
return nil, nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询审批尝试记录失败")
}
approvalStatus, approvalOpinions, err := approvalStatusOfAttempts(ctx, q.db, attempts, true)
if err != nil {
return nil, nil, nil, err
}
grouped := make(map[uint][]model.EmployeeCollectionApplicationAttempt, len(applicationIDs))
for index := range attempts {
grouped[attempts[index].ApplicationID] = append(grouped[attempts[index].ApplicationID], attempts[index])
}
return grouped, approvalStatus, approvalOpinions, nil
}
// approvalStatusOfAttempts 批量读取审批尝试记录关联的通用审批实例状态。
// withOpinion 为真时同时读取终态决策快照并提取审批意见;列表路径传 false避免批量加载渠道快照。
func approvalStatusOfAttempts(
ctx context.Context,
db *gorm.DB,
attempts []model.EmployeeCollectionApplicationAttempt,
withOpinion bool,
) (map[uint]int, map[uint]string, error) {
instanceIDs := make([]uint, 0, len(attempts))
for index := range attempts {
if attempts[index].ApprovalInstanceID != nil {
instanceIDs = append(instanceIDs, *attempts[index].ApprovalInstanceID)
}
}
approvalStatus := make(map[uint]int, len(instanceIDs))
approvalOpinions := make(map[uint]string, len(instanceIDs))
if len(instanceIDs) == 0 {
return approvalStatus, approvalOpinions, nil
}
columns := []string{"id", "status"}
if withOpinion {
columns = append(columns, "decision_snapshot")
}
var instances []model.ApprovalInstance
if err := db.WithContext(ctx).Select(columns).
Where("id IN ?", instanceIDs).Find(&instances).Error; err != nil {
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询审批实例状态失败")
}
for index := range instances {
approvalStatus[instances[index].ID] = instances[index].Status
if opinion := employeecollectiondomain.ExtractApprovalOpinion(instances[index].DecisionSnapshot); opinion != "" {
approvalOpinions[instances[index].ID] = opinion
}
}
return approvalStatus, approvalOpinions, nil
}
// attemptResponses 投影审批尝试记录,附件只返回对象键引用与审批实例引用。
func attemptResponses(
applicationID uint,
attempts []model.EmployeeCollectionApplicationAttempt,
approvalStatus map[uint]int,
approvalOpinions map[uint]string,
) ([]*dto.EmployeeCollectionBillAttemptResponse, error) {
result := make([]*dto.EmployeeCollectionBillAttemptResponse, 0, len(attempts))
for index := range attempts {
attempt := attempts[index]
if attempt.ApplicationID != applicationID {
continue
}
vouchers := []string(attempt.PaymentVoucherKeys)
if vouchers == nil {
vouchers = []string{}
}
snapshot := make([]map[string]any, 0)
if len(attempt.AllocationSnapshot) > 0 {
if err := sonic.Unmarshal(attempt.AllocationSnapshot, &snapshot); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "解析审批尝试分摊快照失败")
}
}
var status *int
statusName := ""
opinion := ""
if attempt.ApprovalInstanceID != nil {
if value, ok := approvalStatus[*attempt.ApprovalInstanceID]; ok {
status = &value
statusName = constants.GetApprovalStatusName(value)
}
opinion = approvalOpinions[*attempt.ApprovalInstanceID]
}
result = append(result, &dto.EmployeeCollectionBillAttemptResponse{
ID: attempt.ID, AttemptNo: attempt.AttemptNo,
PaymentMethodID: attempt.PaymentMethodID, PaymentMethodCode: attempt.PaymentMethodCode,
PaymentMethodName: attempt.PaymentMethodName, PaidAmount: attempt.PaidAmount,
PayerName: attempt.PayerName, PaidAt: attempt.PaidAt,
ExternalTransactionNo: attempt.ExternalTransactionNo, PaymentVoucherKeys: vouchers,
Remark: attempt.Remark, SubmittedByAccountID: attempt.SubmittedByAccountID,
ActingReason: attempt.ActingReason, AllocationSnapshot: snapshot,
ApprovalInstanceID: attempt.ApprovalInstanceID,
ApprovalStatus: status, ApprovalStatusName: statusName,
ApprovalOpinion: opinion, CreatedAt: attempt.CreatedAt,
})
}
return result, nil
}
// ProjectBill 将账单事实投影为对外响应;关闭用例与查询共用同一投影。
func ProjectBill(bill *model.EmployeeCollectionBill) (*dto.EmployeeCollectionBillResponse, error) {
if bill == nil {
return nil, errors.New(errors.CodeEmployeeCollectionBillNotFound)
}
debtorSnapshot := make(map[string]any)
if len(bill.DebtorSnapshot) > 0 {
if err := sonic.Unmarshal(bill.DebtorSnapshot, &debtorSnapshot); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "解析账单欠款人快照失败")
}
}
customerSnapshot := make(map[string]any)
if len(bill.CustomerSnapshot) > 0 {
if err := sonic.Unmarshal(bill.CustomerSnapshot, &customerSnapshot); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "解析账单客户快照失败")
}
}
remaining := int64(0)
if bill.Status != constants.EmployeeCollectionBillStatusClosed {
remaining = bill.ReceivableAmount - bill.ReceivedAmount - bill.ReservedAmount
if remaining < 0 {
remaining = 0
}
}
return &dto.EmployeeCollectionBillResponse{
ID: bill.ID, SourceType: bill.SourceType,
SourceTypeName: constants.GetEmployeeCollectionSourceTypeName(bill.SourceType),
SourceID: bill.SourceID, SourceNo: bill.SourceNo,
DebtorAccountID: bill.DebtorAccountID, DebtorSnapshot: debtorSnapshot,
CustomerSnapshot: customerSnapshot,
ReceivableAmount: bill.ReceivableAmount, ReceivedAmount: bill.ReceivedAmount,
ReservedAmount: bill.ReservedAmount, RemainingAmount: remaining,
Status: bill.Status, StatusName: constants.GetEmployeeCollectionBillStatusName(bill.Status),
ApprovalPending: bill.ReservedAmount > 0,
ClosedReason: bill.ClosedReason, ClosedAt: bill.ClosedAt,
CreatedAt: bill.CreatedAt, UpdatedAt: bill.UpdatedAt,
}, nil
}