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) 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 批量读取审批尝试记录关联的通用审批实例状态与终态审批意见。 func approvalStatusOfAttempts( ctx context.Context, db *gorm.DB, attempts []model.EmployeeCollectionApplicationAttempt, ) (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", "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 }