feat(员工代收款): 新增员工代收款账单闭环
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m20s

- 新增 6 张表与成对迁移 000212,扩展企业微信审批场景业务类型白名单
- 后台线下套餐订单与两条代理线下充值入账路径在来源成功事务内建账,来源唯一键幂等
- 核销申请、审批尝试记录、账单分摊预占与驳回重提,审批业务类型 employee_collection_approval
- 企业微信终态消费幂等:通过转已核销、驳回释放预占、通过后撤销不回滚并转异常终态
- 退款成功事务内按 bill_id+refund_id 幂等冲销账单或仅写退款关联提示
- 线下收款方式字典、账单查询/统计/关闭、申请查询与代办权限,均写入事务内审计

OpenSpec Change: add-employee-collection-bills
This commit is contained in:
2026-09-10 18:24:05 +08:00
parent dc4e0d4103
commit ce24d5612e
54 changed files with 5975 additions and 465 deletions

View File

@@ -0,0 +1,308 @@
package employeecollection
import (
"context"
"gorm.io/gorm"
"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"
)
// ApplicationQuery 查询员工代收款核销申请列表与详情。
// Query 只做权限范围、筛选、分页与 DTO 投影,不修改任何状态。
type ApplicationQuery struct {
db *gorm.DB
}
// NewApplicationQuery 创建核销申请查询。
func NewApplicationQuery(db *gorm.DB) *ApplicationQuery {
return &ApplicationQuery{db: db}
}
// List 分页查询当前可见范围内的核销申请。
func (q *ApplicationQuery) List(
ctx context.Context,
request dto.EmployeeCollectionApplicationListRequest,
) (*dto.EmployeeCollectionApplicationListResponse, error) {
query, err := q.applyApplicationScope(ctx, 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 applications []model.EmployeeCollectionApplication
if err := query.Order("created_at DESC, id DESC").
Offset((page - 1) * pageSize).Limit(pageSize).
Find(&applications).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询核销申请列表失败")
}
list := make([]*dto.EmployeeCollectionApplicationResponse, 0, len(applications))
for index := range applications {
list = append(list, ProjectApplication(&applications[index]))
}
return &dto.EmployeeCollectionApplicationListResponse{
List: list, Total: total, Page: page, PageSize: pageSize,
}, nil
}
// Detail 返回当前可见范围内的核销申请详情,包含全部分摊与审批尝试历史。
// 不可见与不存在返回同一错误,不产生可枚举差异。
func (q *ApplicationQuery) Detail(ctx context.Context, id uint) (*dto.EmployeeCollectionApplicationDetailResponse, error) {
if q == nil || q.db == nil || id == 0 {
return nil, errors.New(errors.CodeEmployeeCollectionApplicationNotFound)
}
scoped, err := applyApplicationVisibility(ctx, q.db.WithContext(ctx).Model(&model.EmployeeCollectionApplication{}))
if err != nil {
return nil, err
}
var application model.EmployeeCollectionApplication
if err := scoped.Where("id = ?", id).First(&application).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeEmployeeCollectionApplicationNotFound)
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询核销申请详情失败")
}
allocations, err := q.applicationAllocations(ctx, application.ID)
if err != nil {
return nil, err
}
attempts, err := q.applicationAttemptList(ctx, application.ID)
if err != nil {
return nil, err
}
return &dto.EmployeeCollectionApplicationDetailResponse{
Application: ProjectApplication(&application), Allocations: allocations, Attempts: attempts,
}, nil
}
// applyApplicationScope 生成同时应用可见性与筛选条件的核销申请查询。
func (q *ApplicationQuery) applyApplicationScope(
ctx context.Context,
request dto.EmployeeCollectionApplicationListRequest,
) (*gorm.DB, error) {
if q == nil || q.db == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "核销申请查询尚未配置")
}
query, err := applyApplicationVisibility(ctx, q.db.WithContext(ctx).Model(&model.EmployeeCollectionApplication{}))
if err != nil {
return nil, err
}
if request.ApplicantAccountID != nil && *request.ApplicantAccountID > 0 {
query = query.Where("applicant_account_id = ?", *request.ApplicantAccountID)
}
if request.PaymentMethodID != nil && *request.PaymentMethodID > 0 {
query = query.Where("payment_method_id = ?", *request.PaymentMethodID)
}
if request.Status != nil {
switch *request.Status {
case constants.EmployeeCollectionApplicationStatusPending,
constants.EmployeeCollectionApplicationStatusApproved,
constants.EmployeeCollectionApplicationStatusRejected,
constants.EmployeeCollectionApplicationStatusRevoked:
query = query.Where("status = ?", *request.Status)
default:
return nil, errors.New(errors.CodeInvalidParam, "不支持的核销申请状态")
}
}
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
}
// applyApplicationVisibility 应用核销申请可见性:超级管理员见全部,其他账号仅见本人申请。
func applyApplicationVisibility(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("applicant_account_id = ?", accountID), nil
}
// applicationAllocations 读取申请的全部分摊并附带目标账单只读摘要。
func (q *ApplicationQuery) applicationAllocations(
ctx context.Context,
applicationID uint,
) ([]*dto.EmployeeCollectionApplicationAllocationResponse, error) {
var allocations []model.EmployeeCollectionApplicationAllocation
if err := q.db.WithContext(ctx).Where("application_id = ?", applicationID).
Order("bill_id ASC, id ASC").Find(&allocations).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询核销申请分摊失败")
}
billIDs := make([]uint, 0, len(allocations))
for index := range allocations {
billIDs = append(billIDs, allocations[index].BillID)
}
bills := make(map[uint]model.EmployeeCollectionBill, len(billIDs))
if len(billIDs) > 0 {
var loaded []model.EmployeeCollectionBill
if err := q.db.WithContext(ctx).Where("id IN ?", billIDs).Find(&loaded).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询核销分摊目标账单失败")
}
for index := range loaded {
bills[loaded[index].ID] = loaded[index]
}
}
result := make([]*dto.EmployeeCollectionApplicationAllocationResponse, 0, len(allocations))
for index := range allocations {
allocation := allocations[index]
bill := bills[allocation.BillID]
result = append(result, &dto.EmployeeCollectionApplicationAllocationResponse{
ID: allocation.ID, BillID: allocation.BillID, Amount: allocation.Amount,
Status: allocation.Status, StatusName: constants.GetEmployeeCollectionAllocationStatusName(allocation.Status),
ReleasedAt: allocation.ReleasedAt,
BillSourceType: bill.SourceType, BillSourceNo: bill.SourceNo,
BillReceivableAmount: bill.ReceivableAmount, BillReceivedAmount: bill.ReceivedAmount,
BillReservedAmount: bill.ReservedAmount,
BillStatus: bill.Status, BillStatusName: constants.GetEmployeeCollectionBillStatusName(bill.Status),
CreatedAt: allocation.CreatedAt,
})
}
return result, nil
}
// applicationAttemptList 读取申请的全部审批尝试记录与审批实例状态。
func (q *ApplicationQuery) applicationAttemptList(
ctx context.Context,
applicationID uint,
) ([]*dto.EmployeeCollectionBillAttemptResponse, error) {
var attempts []model.EmployeeCollectionApplicationAttempt
if err := q.db.WithContext(ctx).Where("application_id = ?", applicationID).
Order("attempt_no ASC, id ASC").Find(&attempts).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询核销审批尝试记录失败")
}
approvalStatus, approvalOpinions, err := approvalStatusOfAttempts(ctx, q.db, attempts, true)
if err != nil {
return nil, err
}
return attemptResponses(applicationID, attempts, approvalStatus, approvalOpinions)
}
// ProjectApplication 将核销申请事实投影为对外响应,附件只返回对象键引用。
func ProjectApplication(application *model.EmployeeCollectionApplication) *dto.EmployeeCollectionApplicationResponse {
if application == nil {
return nil
}
vouchers := []string(application.PaymentVoucherKeys)
if vouchers == nil {
vouchers = []string{}
}
return &dto.EmployeeCollectionApplicationResponse{
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),
LatestAttemptID: application.LatestAttemptID,
LatestApprovalInstanceID: application.LatestApprovalInstanceID,
DecidedAt: application.DecidedAt, TerminalReason: application.TerminalReason,
CreatedAt: application.CreatedAt, UpdatedAt: application.UpdatedAt,
}
}
// ApplicationSubmitProjection 是创建或重提核销申请返回的投影输入。
type ApplicationSubmitProjection struct {
Application *model.EmployeeCollectionApplication
Attempt *model.EmployeeCollectionApplicationAttempt
Allocations []*model.EmployeeCollectionApplicationAllocation
Bills []*model.EmployeeCollectionBill
InstanceID uint
InstanceStatus int
}
// ProjectApplicationSubmit 组装创建或重提核销申请的响应。
// 附件只返回对象键引用,审批状态取本次创建的通用审批实例状态,不返回对象存储内容。
func ProjectApplicationSubmit(projection ApplicationSubmitProjection) (*dto.EmployeeCollectionApplicationSubmitResponse, error) {
allocations, err := projectSubmitAllocations(projection.Allocations, projection.Bills)
if err != nil {
return nil, err
}
var attempts []*dto.EmployeeCollectionBillAttemptResponse
if projection.Attempt != nil {
approvalStatus := map[uint]int{}
if projection.InstanceID > 0 {
approvalStatus[projection.InstanceID] = projection.InstanceStatus
}
attempts, err = attemptResponses(
projection.Attempt.ApplicationID,
[]model.EmployeeCollectionApplicationAttempt{*projection.Attempt},
approvalStatus, map[uint]string{},
)
if err != nil {
return nil, err
}
}
response := &dto.EmployeeCollectionApplicationSubmitResponse{
Application: ProjectApplication(projection.Application),
Allocations: allocations,
ApprovalInstanceID: projection.InstanceID,
ApprovalStatus: projection.InstanceStatus,
ApprovalStatusName: constants.GetApprovalStatusName(projection.InstanceStatus),
}
if len(attempts) > 0 {
response.Attempt = attempts[0]
}
return response, nil
}
// projectSubmitAllocations 将分摊与目标账单摘要投影为响应。
func projectSubmitAllocations(
allocations []*model.EmployeeCollectionApplicationAllocation,
bills []*model.EmployeeCollectionBill,
) ([]*dto.EmployeeCollectionApplicationAllocationResponse, error) {
billByID := make(map[uint]*model.EmployeeCollectionBill, len(bills))
for _, bill := range bills {
if bill != nil {
billByID[bill.ID] = bill
}
}
result := make([]*dto.EmployeeCollectionApplicationAllocationResponse, 0, len(allocations))
for _, allocation := range allocations {
if allocation == nil {
continue
}
bill := billByID[allocation.BillID]
if bill == nil {
return nil, errors.New(errors.CodeInternalError, "核销分摊缺少目标账单事实")
}
result = append(result, &dto.EmployeeCollectionApplicationAllocationResponse{
ID: allocation.ID, BillID: allocation.BillID, Amount: allocation.Amount,
Status: allocation.Status, StatusName: constants.GetEmployeeCollectionAllocationStatusName(allocation.Status),
ReleasedAt: allocation.ReleasedAt,
BillSourceType: bill.SourceType, BillSourceNo: bill.SourceNo,
BillReceivableAmount: bill.ReceivableAmount, BillReceivedAmount: bill.ReceivedAmount,
BillReservedAmount: bill.ReservedAmount,
BillStatus: bill.Status, BillStatusName: constants.GetEmployeeCollectionBillStatusName(bill.Status),
CreatedAt: allocation.CreatedAt,
})
}
return result, nil
}

View File

@@ -0,0 +1,502 @@
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, true)
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 批量读取审批尝试记录关联的通用审批实例状态。
// withOpinion 为真时同时读取终态决策快照并提取审批意见;列表路径传 false避免批量加载渠道快照。
func approvalStatusOfAttempts(
ctx context.Context,
db *gorm.DB,
attempts []model.EmployeeCollectionApplicationAttempt,
withOpinion bool,
) (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"}
if withOpinion {
columns = append(columns, "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
}

View File

@@ -0,0 +1,99 @@
// Package employeecollection 提供员工代收款账单与线下收款方式字典的只读投影。
// Query 只做权限范围、筛选、分页与 DTO 投影,不修改任何状态。
package employeecollection
import (
"context"
"strings"
"gorm.io/gorm"
"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"
)
// PaymentMethodQuery 查询线下收款方式字典。
type PaymentMethodQuery struct {
db *gorm.DB
}
// NewPaymentMethodQuery 创建线下收款方式字典查询。
func NewPaymentMethodQuery(db *gorm.DB) *PaymentMethodQuery {
return &PaymentMethodQuery{db: db}
}
// List 分页查询线下收款方式。
// 超级管理员可查询全部字典项;其他后台账号仅返回启用项,保证停用方式不可用于新申请。
func (q *PaymentMethodQuery) List(
ctx context.Context,
request dto.EmployeeCollectionPaymentMethodListRequest,
) (*dto.EmployeeCollectionPaymentMethodListResponse, error) {
if q == nil || q.db == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "线下收款方式查询尚未配置")
}
isSuperAdmin := middleware.GetUserTypeFromContext(ctx) == constants.UserTypeSuperAdmin
if !isSuperAdmin && middleware.GetUserIDFromContext(ctx) == 0 {
return nil, errors.New(errors.CodeUnauthorized)
}
page, pageSize := normalizePage(request.Page, request.PageSize)
query := q.db.WithContext(ctx).Model(&model.EmployeeCollectionPaymentMethod{})
if !isSuperAdmin {
query = query.Where("status = ?", constants.EmployeeCollectionPaymentMethodStatusEnabled)
} else if request.Enabled != nil {
status := constants.EmployeeCollectionPaymentMethodStatusDisabled
if *request.Enabled {
status = constants.EmployeeCollectionPaymentMethodStatusEnabled
}
query = query.Where("status = ?", status)
}
if keyword := strings.TrimSpace(request.Keyword); keyword != "" {
pattern := "%" + keyword + "%"
query = query.Where("code LIKE ? OR name LIKE ?", pattern, pattern)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询线下收款方式总数失败")
}
var items []model.EmployeeCollectionPaymentMethod
if err := query.Order("sort_order ASC, id ASC").
Offset((page - 1) * pageSize).Limit(pageSize).
Find(&items).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询线下收款方式列表失败")
}
list := make([]*dto.EmployeeCollectionPaymentMethodResponse, 0, len(items))
for index := range items {
list = append(list, toPaymentMethodResponse(&items[index]))
}
return &dto.EmployeeCollectionPaymentMethodListResponse{
List: list, Total: total, Page: page, PageSize: pageSize,
}, nil
}
// toPaymentMethodResponse 将字典项投影为对外响应,只暴露既有对象键与稳定字段。
func toPaymentMethodResponse(paymentMethod *model.EmployeeCollectionPaymentMethod) *dto.EmployeeCollectionPaymentMethodResponse {
return &dto.EmployeeCollectionPaymentMethodResponse{
ID: paymentMethod.ID, Code: paymentMethod.Code, Name: paymentMethod.Name,
Enabled: paymentMethod.Status == constants.EmployeeCollectionPaymentMethodStatusEnabled,
Sort: paymentMethod.SortOrder, Remark: paymentMethod.Remark,
CreatedAt: paymentMethod.CreatedAt, UpdatedAt: paymentMethod.UpdatedAt,
}
}
// normalizePage 归一化分页参数,执行 DefaultPageSize 与 MaxPageSize 上限。
func normalizePage(page, pageSize int) (int, int) {
if page <= 0 {
page = constants.DefaultPage
}
if pageSize <= 0 {
pageSize = constants.DefaultPageSize
}
if pageSize > constants.MaxPageSize {
pageSize = constants.MaxPageSize
}
return page, pageSize
}