Files
junhong_cmp_fiber/internal/query/employeecollection/bill.go
break 69b37eb89b
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m24s
docs(员工代收款): 补充审批中通过后撤销兜底语义并清理死参数
- spec.md 增补「审批中收到通过后撤销」场景与规范条文:释放该次尝试全部审批中预占、转异常终态并记录原因、保留审计、禁止自动重提
- design.md「企业微信审批结果消费」补充审批中命中该决策的兜底处理与理由(避免申请永久停在审批中且预占永久占用账单)
- query/employeecollection 删除 approvalStatusOfAttempts 恒为 true 的 withOpinion 形参、修正失真注释,行为不变

OpenSpec Change: add-employee-collection-bills
2026-09-10 18:45:43 +08:00

498 lines
20 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"
"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
}