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