package employeecollection import ( "context" "strconv" "strings" "time" "unicode/utf8" "github.com/bytedance/sonic" "gorm.io/datatypes" "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" "github.com/break/junhong_cmp_fiber/pkg/utils" ) // billDateLayout 是核销申请列表筛选使用的自然日格式。 // 账单列表与统计已改用统一时间筛选(start_time/end_time 严格解析),不再使用本格式; // 核销申请列表端点不在本次统一时间筛选的受影响端点表内,按 As-Is 保留其既有日期语义。 const billDateLayout = "2006-01-02" // 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 } // 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 } operations, err := q.billOperations(ctx, bill.ID) if err != nil { return nil, err } sourceOrder := q.billSourceSnapshot(ctx, &bill) return &dto.EmployeeCollectionBillDetailResponse{ Bill: billResponse, Refunds: refunds, Allocations: allocations, Applications: applications, Operations: operations, SourceOrder: sourceOrder, }, nil } // billOperations 读取该账单维度的既有审计事实投影。 // // 审计事实按资源读取而非按平台事件列表读取:账单详情对欠款人本人可见, // 因此不得复用仅超级管理员与平台账号可用的平台审计查询(那会让本人详情的操作日志恒为空)。 // 只投影动作、操作账号名称快照、操作时间与白名单化的前后值摘要,不返回凭证内容与完整收款原文。 func (q *BillQuery) billOperations(ctx context.Context, billID uint) ([]*dto.EmployeeCollectionBillOperationLogResponse, error) { resourceID := strconv.FormatUint(uint64(billID), 10) var rows []billOperationRow if err := q.db.WithContext(ctx).Table("tb_audit_event AS event"). Select("event.id, event.action_code, event.action_name, event.summary, event.actor_name, "+ "event.occurred_at, event.result, resource.relation, resource.before_data, resource.after_data"). Joins("JOIN tb_audit_event_resource AS resource ON resource.audit_event_id = event.id"). Where("resource.resource_type = ? AND resource.resource_id = ?", constants.AuditResourceEmployeeCollectionBill, resourceID). Order("event.occurred_at ASC, event.id ASC"). Scan(&rows).Error; err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询账单操作日志失败") } result := make([]*dto.EmployeeCollectionBillOperationLogResponse, 0, len(rows)) seen := make(map[uint]int, len(rows)) for index := range rows { row := rows[index] // 同一事件可能同时以主要资源与关联资源引用同一张账单:按事件去重并优先保留主要资源的前后值。 if position, exists := seen[row.EventID]; exists && row.Relation != "primary" { continue } else if exists { result[position] = billOperationResponse(row) continue } seen[row.EventID] = len(result) result = append(result, billOperationResponse(row)) } return result, nil } // billOperationRow 是账单操作日志的审计行扫描结果。 type billOperationRow struct { EventID uint `gorm:"column:id"` ActionCode string `gorm:"column:action_code"` ActionName string `gorm:"column:action_name"` Summary string `gorm:"column:summary"` ActorName string `gorm:"column:actor_name"` OccurredAt time.Time `gorm:"column:occurred_at"` Result string `gorm:"column:result"` Relation string `gorm:"column:relation"` BeforeData datatypes.JSON `gorm:"column:before_data"` AfterData datatypes.JSON `gorm:"column:after_data"` } // billOperationResponse 把审计行投影为对外操作日志。 func billOperationResponse(row billOperationRow) *dto.EmployeeCollectionBillOperationLogResponse { return &dto.EmployeeCollectionBillOperationLogResponse{ ActionCode: row.ActionCode, ActionName: row.ActionName, Summary: row.Summary, OperatorName: row.ActorName, OccurredAt: row.OccurredAt, Result: row.Result, BeforeSummary: billOperationSummary(row.BeforeData), AfterSummary: billOperationSummary(row.AfterData), } } // billOperationSummaryKeys 是操作日志允许展示的账单字段白名单。 // 采用白名单而非黑名单:审计前后值随用例演进会加入新字段,黑名单一旦漏项就会把 // 收款凭证或完整收款原文带出接口。 var billOperationSummaryKeys = []string{ "bill_id", "source_type", "source_no", "status", "receivable_amount", "received_amount", "reserved_amount", "closed_reason", "closed_at", "refund_id", "outcome", "reduced_amount", "application_id", "attempt_no", "amount", "decided_at", "terminal_reason", "payment_method_code", "payment_method_name", "paid_amount", } // billOperationSummary 按白名单提取审计前后值摘要;不可解析时返回空摘要而不阻断详情。 func billOperationSummary(raw datatypes.JSON) map[string]any { summary := make(map[string]any) if len(raw) == 0 { return summary } decoded := make(map[string]any) if err := sonic.Unmarshal(raw, &decoded); err != nil { return summary } for _, key := range billOperationSummaryKeys { if value, exists := decoded[key]; exists { summary[key] = value } } return summary } // billSourceSnapshot 读取来源订单或来源充值记录的只读快照投影。 // // 来源记录不可读(已删除或查询失败)或字段缺失时返回空值,且 MUST NOT 阻断详情响应: // 账单本身是权威事实,来源快照只是补充解释,缺来源不应让账单详情不可查看。 func (q *BillQuery) billSourceSnapshot(ctx context.Context, bill *model.EmployeeCollectionBill) *dto.EmployeeCollectionBillSourceSnapshotResponse { snapshot := &dto.EmployeeCollectionBillSourceSnapshotResponse{ SourceType: bill.SourceType, SourceTypeName: constants.GetEmployeeCollectionSourceTypeName(bill.SourceType), SourceNo: bill.SourceNo, } switch bill.SourceType { case constants.EmployeeCollectionSourceTypeOrder: var order model.Order if err := q.db.WithContext(ctx). Select("id", "order_no", "actual_paid_amount", "asset_identifier", "created_at"). Where("id = ?", bill.SourceID).First(&order).Error; err != nil { return snapshot } snapshot.SourceNo = order.OrderNo if order.ActualPaidAmount != nil { snapshot.Amount = *order.ActualPaidAmount } snapshot.AssetIdentifier = order.AssetIdentifier createdAt := order.CreatedAt snapshot.CreatedAt = &createdAt case constants.EmployeeCollectionSourceTypeRecharge: var record model.AgentRechargeRecord if err := q.db.WithContext(ctx). Select("id", "recharge_no", "amount", "created_at"). Where("id = ?", bill.SourceID).First(&record).Error; err != nil { return snapshot } snapshot.SourceNo = record.RechargeNo snapshot.Amount = record.Amount createdAt := record.CreatedAt snapshot.CreatedAt = &createdAt } return snapshot } // 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(ctx, q.db, query, filter) } // billFilter 账单列表与统计共用的筛选条件,屏蔽两种请求 DTO 的字段差异。 type billFilter struct { BillID *uint SourceType *string SourceNo *string Status *int DebtorAccountID *uint DebtorKeyword *string CustomerKeyword *string CustomerID *uint StartTime *string EndTime *string DecidedStart *string DecidedEnd *string } // billFilterOfList 投影账单列表请求的筛选字段。 func billFilterOfList(request dto.EmployeeCollectionBillListRequest) billFilter { return billFilter{ BillID: request.BillID, SourceType: request.SourceType, SourceNo: request.SourceNo, Status: request.Status, DebtorAccountID: request.DebtorAccountID, DebtorKeyword: request.DebtorKeyword, CustomerKeyword: request.CustomerKeyword, CustomerID: request.CustomerID, StartTime: request.StartTime, EndTime: request.EndTime, DecidedStart: request.DecidedStartTime, DecidedEnd: request.DecidedEndTime, } } // billFilterOfStatistics 投影账单统计请求的筛选字段。 func billFilterOfStatistics(request dto.EmployeeCollectionBillStatisticsRequest) billFilter { return billFilter{ BillID: request.BillID, SourceType: request.SourceType, SourceNo: request.SourceNo, Status: request.Status, DebtorAccountID: request.DebtorAccountID, DebtorKeyword: request.DebtorKeyword, CustomerKeyword: request.CustomerKeyword, CustomerID: request.CustomerID, StartTime: request.StartTime, EndTime: request.EndTime, DecidedStart: request.DecidedStartTime, DecidedEnd: request.DecidedEndTime, } } // 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 应用账单列表与统计共用的筛选条件。 // db 仅用于构造相关子查询,不得携带外层筛选,避免子查询被账单条件污染。 func applyBillFilters(ctx context.Context, db, query *gorm.DB, request billFilter) (*gorm.DB, error) { if request.BillID != nil && *request.BillID > 0 { // 账单编号即账单记录标识(主键),不新增编号列,也不按前缀或范围匹配。 query = query.Where("tb_employee_collection_bill.id = ?", *request.BillID) } 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) } debtorKeyword, err := billKeyword(request.DebtorKeyword, "欠款人姓名或账号") if err != nil { return nil, err } if debtorKeyword != "" { pattern := "%" + debtorKeyword + "%" // 欠款人姓名或账号:优先匹配账单创建时冻结的账号名称快照; // 同时匹配后台账号当前登录名,使账号改名后仍可按“账号”口径查到既有账单。 query = query.Where( "tb_employee_collection_bill.debtor_snapshot->>'account_name' ILIKE ? OR EXISTS ("+ "SELECT 1 FROM tb_account AS debtor_account WHERE debtor_account.deleted_at IS NULL "+ "AND debtor_account.id = tb_employee_collection_bill.debtor_account_id "+ "AND debtor_account.username ILIKE ?)", pattern, pattern) } customerKeyword, err := billKeyword(request.CustomerKeyword, "客户名称") if err != nil { return nil, err } if customerKeyword != "" { pattern := "%" + customerKeyword + "%" // 客户名称:来源订单取买家昵称快照;代理线下充值的快照只冻结店铺ID, // 因此按店铺ID关联既有店铺表取当前店铺名称,避免为筛选新增冗余快照列。 query = query.Where( "tb_employee_collection_bill.customer_snapshot->>'buyer_nickname' ILIKE ? OR EXISTS ("+ "SELECT 1 FROM tb_shop AS customer_shop WHERE customer_shop.deleted_at IS NULL "+ "AND customer_shop.id::text = tb_employee_collection_bill.customer_snapshot->>'shop_id' "+ "AND customer_shop.shop_name ILIKE ?)", pattern, pattern) } // 产生时间:统一严格解析器 + 闭区间(含两端);旧参数名与旧格式不再接受。 startTime, endTime, err := utils.ParseTimeRange(optionalTimeValue(request.StartTime), optionalTimeValue(request.EndTime)) if err != nil { return nil, err } if startTime != nil { query = query.Where("created_at >= ?", *startTime) } if endTime != nil { query = query.Where("created_at <= ?", *endTime) } // 核销通过时间:经「状态为已通过的分摊」关联到申请表的审批终态时间(decided_at)。 // MUST NOT 使用分摊表的释放时间:该列同时承担「审批通过转为已核销」与「驳回或释放预占」两种语义。 decidedStart, decidedEnd, err := utils.ParseTimeRange(optionalTimeValue(request.DecidedStart), optionalTimeValue(request.DecidedEnd)) if err != nil { return nil, err } if decidedStart != nil || decidedEnd != nil { sub := db.WithContext(ctx). Model(&model.EmployeeCollectionApplicationAllocation{}). Select("1"). Joins("JOIN tb_employee_collection_application AS decided_application ON decided_application.id = tb_employee_collection_application_allocation.application_id"). Where("tb_employee_collection_application_allocation.bill_id = tb_employee_collection_bill.id"). Where("tb_employee_collection_application_allocation.status = ?", constants.EmployeeCollectionAllocationStatusApproved). Where("decided_application.decided_at IS NOT NULL") if decidedStart != nil { sub = sub.Where("decided_application.decided_at >= ?", *decidedStart) } if decidedEnd != nil { sub = sub.Where("decided_application.decided_at <= ?", *decidedEnd) } query = query.Where("EXISTS (?)", sub) } return query, nil } // billKeywordMaxLength 限制模糊筛选的输入长度,避免超长子串匹配放大查询代价。 const billKeywordMaxLength = 50 // billKeyword 归一化模糊筛选输入:去两端的空白后按子串匹配,空值表示不筛选。 // 超长输入按参数非法拒绝,与 DTO 的长度上限一致,避免查询层成为绕过入口。 func billKeyword(value *string, fieldName string) (string, error) { if value == nil { return "", nil } trimmed := strings.TrimSpace(*value) if trimmed == "" { return "", nil } if utf8.RuneCountInString(trimmed) > billKeywordMaxLength { return "", errors.New(errors.CodeInvalidParam, fieldName+"筛选最长 "+strconv.Itoa(billKeywordMaxLength)+" 字符") } return trimmed, nil } // optionalTimeValue 读取可选时间筛选值:nil 与空串等价(该端不筛选)。 // MUST NOT 去空白:统一时间筛选契约把前后带空白的取值视为格式非法, // 在这里 trim 会把本应被拒绝的取值静默接受,与既有拒绝集不一致。 func optionalTimeValue(value *string) string { if value == nil { return "" } return *value } // 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, "解析账单客户快照失败") } } // 未核销金额 = 应收 − 已核销;剩余可核销金额 = 应收 − 已核销 − 审批中预占。 // 两者并列返回:前者是运营口径,后者是服务端并发核销防超额校验使用的口径。 // 已关闭账单的未核销余额作废,两者都为 0。 unsettled := int64(0) remaining := int64(0) if bill.Status != constants.EmployeeCollectionBillStatusClosed { unsettled = bill.ReceivableAmount - bill.ReceivedAmount if unsettled < 0 { unsettled = 0 } 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, UnsettledAmount: unsettled, 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 }