Files
junhong_cmp_fiber/internal/query/employeecollection/bill.go
break 5ed6b39deb
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
feat(收口): 补齐 8 月迭代缺口并同步 Spec 与证据链
- 新增六对成对迁移 000232–000237:H5 弹窗类型、退款结算标识与申请人备注、优先轮询事实字段与两个新终态、通道阈值命中留痕、手机号最近解绑人、提现资格校验留痕
- 退款:原因必填与申请人备注、来源支付与渠道流水冻结、线下处理流水号补录审计、按订单查询可选退款方式、企微审批材料补齐且新增字段缺失映射即明确失败
- 优先轮询:人工关闭、有效期到期独立周期任务、失败与过期人工重触发、事实字段与异常重试查询、资产解析端点只读投影
- 通道阈值:命中事实同事务留痕与命中记录查询;员工账单:列表筛选与详情投影;商户池:列表投影与统计周期语义;H5:弹窗类型与类别排序
- 手机号:有效关联数量与最近解绑人、短信验证码失败次数限制;导出:佣金明细十五列与报表序号列
- 时间筛选:三处新增筛选纳入统一严格解析契约,员工账单产生时间参数改名
- 同步 12 份主 Spec 需求、两端点与异步任务证据链,门禁 context-health 与 OpenSpec 校验通过
2026-09-18 15:34:29 +08:00

729 lines
32 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}