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