All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m24s
- spec.md 增补「审批中收到通过后撤销」场景与规范条文:释放该次尝试全部审批中预占、转异常终态并记录原因、保留审计、禁止自动重提 - design.md「企业微信审批结果消费」补充审批中命中该决策的兜底处理与理由(避免申请永久停在审批中且预占永久占用账单) - query/employeecollection 删除 approvalStatusOfAttempts 恒为 true 的 withOpinion 形参、修正失真注释,行为不变 OpenSpec Change: add-employee-collection-bills
309 lines
12 KiB
Go
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
|
|
}
|