Files
junhong_cmp_fiber/internal/query/employeecollection/application.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

309 lines
12 KiB
Go

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)
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
}