From 6298a9061fc4c94ab601e22b2c33a4caa028bd6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E8=BF=9B?= Date: Mon, 12 Oct 2026 00:03:46 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=E7=AC=AC1=E6=89=B9=20M8=E3=80=81M13?= =?UTF-8?q?=E3=80=81M14=20=E5=AE=A1=E6=89=B9=E6=8F=90=E4=BA=A4=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E7=BB=88=E6=80=81=E4=B8=8E=E9=80=80=E6=AC=BE=E5=85=B3?= =?UTF-8?q?=E9=97=AD=E7=B1=BB=E7=BB=88=E6=80=81=E5=AE=9A=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 审批核心在实例进入提交失败的同一事务内写 submission_failed 决策投递与终态事件,消费入口单独放行 - 核销(4)、退款(7)、扫码注册(3)、资料资格(4)、线下充值(7)、佣金提现(5)新增“审批提交失败”终态并释放预占/冻结 - 三处恢复入口不再重放提交失败;退款关闭类终态按审批尝试定位退款单(M13) - 核销列表与详情批量返回审批提交状态;筛选、字段说明、状态名称、退款导出加入新状态 - 迁移 000239 放宽五张业务表状态约束与决策投递取值约束(测试库已执行 up/down/up) - OpenSpec Change fix-approval-submission-failure-terminal:18/26,测试部署后端到端验证与存量补数据待完成 --- .../agentrecharge/approval_decision.go | 8 +- .../approval/submission_failure.go | 44 +++++++++ .../qualification_approval.go | 36 +++++-- .../distributionwithdrawal/registration.go | 4 +- .../registration_approval.go | 33 +++++-- .../distributionwithdrawal/withdrawal.go | 21 +++- .../withdrawal_approval.go | 57 ++++++++--- .../employeecollection/application.go | 4 +- .../employeecollection/approval_decision.go | 20 +++- .../application/refundapproval/recovery.go | 14 +-- .../domain/employeecollection/application.go | 6 +- internal/exporter/refund_scene.go | 2 +- internal/model/agent_distribution.go | 2 +- internal/model/dto/agent_distribution_dto.go | 12 +-- internal/model/dto/agent_recharge_dto.go | 8 +- .../model/dto/commission_withdrawal_dto.go | 6 +- internal/model/dto/employee_collection_dto.go | 10 +- internal/model/dto/refund_dto.go | 4 +- internal/model/financial.go | 2 +- internal/model/refund.go | 4 +- .../query/distributionwithdrawal/query.go | 2 +- .../query/employeecollection/application.go | 12 ++- .../application_submission.go | 71 ++++++++++++++ internal/service/refund/approval_decision.go | 39 ++++++-- internal/service/refund/service.go | 3 +- ...approval_submission_failed_status.down.sql | 33 +++++++ ...w_approval_submission_failed_status.up.sql | 35 +++++++ .../.openspec.yaml | 2 + .../design.md | 53 ++++++++++ .../proposal.md | 33 +++++++ .../agent-distribution-withdrawal/spec.md | 50 ++++++++++ .../specs/agent-funds-commission/spec.md | 13 +++ .../specs/employee-collection-bill/spec.md | 46 +++++++++ .../specs/external-integration/spec.md | 29 ++++++ .../specs/order-refund-exchange/spec.md | 97 +++++++++++++++++++ .../tasks.md | 43 ++++++++ pkg/constants/agent_distribution.go | 4 + pkg/constants/approval.go | 11 +++ pkg/constants/employee_collection.go | 4 + pkg/constants/iot.go | 10 +- pkg/constants/wallet.go | 6 +- pkg/constants/withdrawal_qualification.go | 4 + 42 files changed, 810 insertions(+), 87 deletions(-) create mode 100644 internal/application/approval/submission_failure.go create mode 100644 internal/query/employeecollection/application_submission.go create mode 100644 migrations/000239_allow_approval_submission_failed_status.down.sql create mode 100644 migrations/000239_allow_approval_submission_failed_status.up.sql create mode 100644 openspec/changes/fix-approval-submission-failure-terminal/.openspec.yaml create mode 100644 openspec/changes/fix-approval-submission-failure-terminal/design.md create mode 100644 openspec/changes/fix-approval-submission-failure-terminal/proposal.md create mode 100644 openspec/changes/fix-approval-submission-failure-terminal/specs/agent-distribution-withdrawal/spec.md create mode 100644 openspec/changes/fix-approval-submission-failure-terminal/specs/agent-funds-commission/spec.md create mode 100644 openspec/changes/fix-approval-submission-failure-terminal/specs/employee-collection-bill/spec.md create mode 100644 openspec/changes/fix-approval-submission-failure-terminal/specs/external-integration/spec.md create mode 100644 openspec/changes/fix-approval-submission-failure-terminal/specs/order-refund-exchange/spec.md create mode 100644 openspec/changes/fix-approval-submission-failure-terminal/tasks.md diff --git a/internal/application/agentrecharge/approval_decision.go b/internal/application/agentrecharge/approval_decision.go index 402343a6..4fb1457d 100644 --- a/internal/application/agentrecharge/approval_decision.go +++ b/internal/application/agentrecharge/approval_decision.go @@ -34,7 +34,7 @@ func NewApprovalDecisionHandler( return &ApprovalDecisionHandler{db: db, posting: posting, audit: audit, billCreation: billCreation} } -// Handle 幂等处理标准审批终态;只有 approved 首次入账,其他终态不修改钱包。 +// Handle 幂等处理标准审批终态;只有 approved 首次入账,其他终态(含审批提交失败)不修改钱包。 func (h *ApprovalDecisionHandler) Handle(ctx context.Context, event approvalapp.TerminalDecisionEvent) error { if h == nil || h.db == nil || h.posting == nil || h.audit == nil { return errors.New(errors.CodeInternalError, "员工线下代充值审批终态能力未配置") @@ -70,6 +70,12 @@ func (h *ApprovalDecisionHandler) Handle(ctx context.Context, event approvalapp. return h.closeOfflineRecharge(ctx, tx, &record, event, constants.RechargeStatusClosed, "企业微信审批已删除") case constants.ApprovalDecisionRevokedAfterApproved: return nil + case constants.ApprovalDecisionSubmissionFailed: + reason, failed, err := approvalapp.ConfirmSubmissionFailed(ctx, tx, event.InstanceID) + if err != nil || !failed { + return err + } + return h.closeOfflineRecharge(ctx, tx, &record, event, constants.RechargeStatusSubmissionFailed, reason) default: return errors.New(errors.CodeInvalidParam, "不支持的线下代充值审批终态") } diff --git a/internal/application/approval/submission_failure.go b/internal/application/approval/submission_failure.go new file mode 100644 index 00000000..088251d1 --- /dev/null +++ b/internal/application/approval/submission_failure.go @@ -0,0 +1,44 @@ +package approval + +import ( + "context" + "strings" + + "gorm.io/gorm" + + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// ConfirmSubmissionFailed 在业务消费事务内锁定审批实例并确认其仍为“提交失败”,返回可对外展示的失败原因。 +// 实例已不是提交失败(例如被恢复重放)时 ok 为 false,业务消费者应不改变业务单。 +// 原因只返回固定白名单文案,禁止透传企微原文或内部错误。 +func ConfirmSubmissionFailed(ctx context.Context, tx *gorm.DB, instanceID uint) (string, bool, error) { + var row struct { + Status int `gorm:"column:status"` + LastError string `gorm:"column:last_error"` + } + result := tx.WithContext(ctx).Raw( + `SELECT ai.status, COALESCE(wc.last_error, '') AS last_error + FROM tb_approval_instance ai + LEFT JOIN tb_wecom_approval_context wc ON wc.approval_instance_id = ai.id + WHERE ai.id = ? FOR UPDATE OF ai`, instanceID).Scan(&row) + if result.Error != nil { + return "", false, errors.Wrap(errors.CodeDatabaseError, result.Error, "锁定提交失败审批实例失败") + } + if result.RowsAffected == 0 { + return "", false, errors.New(errors.CodeNotFound, "审批实例不存在") + } + if row.Status != constants.ApprovalStatusSubmissionFailed { + return "", false, nil + } + return SubmissionFailureReason(row.LastError), true, nil +} + +// SubmissionFailureReason 把企微提交失败原文转换为稳定白名单文案。 +func SubmissionFailureReason(lastError string) string { + if strings.TrimSpace(lastError) == "" { + return constants.ApprovalSubmissionFailedStatusName + } + return constants.ApprovalSubmissionFailedReason +} diff --git a/internal/application/distributionwithdrawal/qualification_approval.go b/internal/application/distributionwithdrawal/qualification_approval.go index bc95516e..7ae3602a 100644 --- a/internal/application/distributionwithdrawal/qualification_approval.go +++ b/internal/application/distributionwithdrawal/qualification_approval.go @@ -67,6 +67,14 @@ func (h *QualificationApprovalHandler) Handle(ctx context.Context, event approva constants.ApprovalDecisionDeleted, constants.ApprovalDecisionRevokedAfterApproved: return h.applyRejected(ctx, tx, &version, shop, before, event) + case constants.ApprovalDecisionSubmissionFailed: + reason, failed, err := approvalapp.ConfirmSubmissionFailed(ctx, tx, event.InstanceID) + if err != nil || !failed { + return err + } + return h.closeVersion(ctx, tx, &version, shop, before, event, + constants.WithdrawalQualificationStatusSubmissionFailed, constants.AuditActionWithdrawalQualificationRejected, + "submission_failed", "提现资料资格审批提交失败:"+reason) default: return errors.New(errors.CodeInvalidParam, "不支持的提现资料资格审批终态") } @@ -169,26 +177,42 @@ func (h *QualificationApprovalHandler) applyRejected( shop *model.Shop, before map[string]any, event approvalapp.TerminalDecisionEvent, +) error { + return h.closeVersion(ctx, tx, version, shop, before, event, + constants.WithdrawalQualificationStatusRejected, constants.AuditActionWithdrawalQualificationRejected, + "rejected", "企业微信未通过提现资料资格") +} + +// closeVersion 以待审批状态条件更新把资料版本置为未生效终态(已驳回或审批提交失败),不影响其他版本。 +func (h *QualificationApprovalHandler) closeVersion( + ctx context.Context, + tx *gorm.DB, + version *model.WithdrawalQualification, + shop *model.Shop, + before map[string]any, + event approvalapp.TerminalDecisionEvent, + targetStatus int, + actionCode, eventSuffix, summary string, ) error { now := time.Now().UTC() result := tx.WithContext(ctx).Model(&model.WithdrawalQualification{}). Where("id = ? AND status = ?", version.ID, constants.WithdrawalQualificationStatusPending). Updates(map[string]any{ - "status": constants.WithdrawalQualificationStatusRejected, + "status": targetStatus, "decided_at": now, "updater": 0, }) if result.Error != nil { - return errors.Wrap(errors.CodeDatabaseError, result.Error, "标记提现资料资格版本已驳回失败") + return errors.Wrap(errors.CodeDatabaseError, result.Error, "结束提现资料资格版本失败") } if result.RowsAffected != 1 { return errors.New(errors.CodeConflict, "提现资料资格版本状态已变化") } - version.Status = constants.WithdrawalQualificationStatusRejected + version.Status = targetStatus version.DecidedAt = &now return h.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{ - EventID: "withdrawal-qualification:" + uintText(version.ID) + ":rejected", - ActionCode: constants.AuditActionWithdrawalQualificationRejected, - Summary: "企业微信未通过提现资料资格", + EventID: "withdrawal-qualification:" + uintText(version.ID) + ":" + eventSuffix, + ActionCode: actionCode, + Summary: summary, CorrelationID: event.CorrelationID, Qualification: version, Shop: shop, BeforeData: before, AfterData: qualificationAuditSnapshot(version), }) diff --git a/internal/application/distributionwithdrawal/registration.go b/internal/application/distributionwithdrawal/registration.go index 6819704e..01d2bc94 100644 --- a/internal/application/distributionwithdrawal/registration.go +++ b/internal/application/distributionwithdrawal/registration.go @@ -96,8 +96,8 @@ func (s *RegistrationService) RecoverApproval(ctx context.Context, id uint) erro branch = "unknown" recoveryErr = s.recovery.EnqueueUnknownConfirm(ctx, tx, instanceID) } else if instance.Status == constants.ApprovalStatusSubmissionFailed && wc.SubmissionStatus == constants.WeComSubmissionStatusFailed { - branch = "replay" - _, recoveryErr = s.recovery.RecoverSubmissionEvent(ctx, tx, instanceID) + // 提交失败已由决策终止注册记录,不再重放。 + return errors.New(errors.CodeConflict, constants.ApprovalSubmissionFailedNotRecoverableMessage) } else if instance.Status == constants.ApprovalStatusSubmitting || instance.Status == constants.ApprovalStatusPending || wc.SubmissionStatus == constants.WeComSubmissionStatusReady || wc.SubmissionStatus == constants.WeComSubmissionStatusSending { } else { return errors.New(errors.CodeConflict, "当前审批提交状态不允许恢复") diff --git a/internal/application/distributionwithdrawal/registration_approval.go b/internal/application/distributionwithdrawal/registration_approval.go index 23b1a37a..1c71d495 100644 --- a/internal/application/distributionwithdrawal/registration_approval.go +++ b/internal/application/distributionwithdrawal/registration_approval.go @@ -74,6 +74,14 @@ func (h *DistributionApprovalHandler) Handle(ctx context.Context, event approval case constants.ApprovalDecisionRevokedAfterApproved: // 注册记录无已建立的对外资金事实;通过后撤销按驳回处理并保留渠道决策痕迹。 return h.applyRejected(ctx, tx, registration, event) + case constants.ApprovalDecisionSubmissionFailed: + reason, failed, err := approvalapp.ConfirmSubmissionFailed(ctx, tx, event.InstanceID) + if err != nil || !failed { + return err + } + return h.closeRegistration(ctx, tx, registration, event, + constants.AgentDistributionRegistrationStatusSubmissionFailed, reason, + "submission_failed", "扫码注册审批提交失败,未创建任何实体") default: return errors.New(errors.CodeInvalidParam, "不支持的代理分销注册审批终态") } @@ -190,29 +198,42 @@ func (h *DistributionApprovalHandler) applyRejected( tx *gorm.DB, registration *model.AgentDistributionRegistration, event approvalapp.TerminalDecisionEvent, +) error { + return h.closeRegistration(ctx, tx, registration, event, + constants.AgentDistributionRegistrationStatusRejected, rejectionReason(event.Decision), + "rejected", "企业微信未通过扫码注册,未创建任何实体") +} + +// closeRegistration 以待审批状态条件更新把注册记录置为不创建实体的终态(已驳回或审批提交失败)。 +func (h *DistributionApprovalHandler) closeRegistration( + ctx context.Context, + tx *gorm.DB, + registration *model.AgentDistributionRegistration, + event approvalapp.TerminalDecisionEvent, + targetStatus int, + reason, eventSuffix, summary string, ) error { now := time.Now().UTC() - reason := rejectionReason(event.Decision) result := tx.WithContext(ctx).Model(&model.AgentDistributionRegistration{}). Where("id = ? AND status = ?", registration.ID, constants.AgentDistributionRegistrationStatusPending). Updates(map[string]any{ - "status": constants.AgentDistributionRegistrationStatusRejected, + "status": targetStatus, "reject_reason": reason, "decided_at": now, "updater": 0, }) if result.Error != nil { - return errors.Wrap(errors.CodeDatabaseError, result.Error, "标记扫码注册记录已驳回失败") + return errors.Wrap(errors.CodeDatabaseError, result.Error, "结束扫码注册记录失败") } if result.RowsAffected != 1 { return errors.New(errors.CodeConflict, "扫码注册记录状态已变化") } before := registration.Status - registration.Status = constants.AgentDistributionRegistrationStatusRejected + registration.Status = targetStatus registration.RejectReason = reason registration.DecidedAt = &now return h.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{ - EventID: "agent-distribution:" + uintText(registration.ID) + ":rejected", + EventID: "agent-distribution:" + uintText(registration.ID) + ":" + eventSuffix, ActionCode: constants.AuditActionAgentDistributionRegistrationRejected, - Summary: "企业微信未通过扫码注册,未创建任何实体", + Summary: summary, CorrelationID: event.CorrelationID, Registration: registration, BeforeData: map[string]any{"status": before}, AfterData: registrationAuditSnapshot(registration), diff --git a/internal/application/distributionwithdrawal/withdrawal.go b/internal/application/distributionwithdrawal/withdrawal.go index dce8b2a5..f07a5b6d 100644 --- a/internal/application/distributionwithdrawal/withdrawal.go +++ b/internal/application/distributionwithdrawal/withdrawal.go @@ -145,8 +145,8 @@ func (s *WithdrawalService) submit( if request.ShopID != submitter.ShopID { return errors.New(errors.CodeNotFound, "提现申请不存在") } - if request.Status != constants.WithdrawalStatusRejected { - return errors.New(errors.CodeConflict, "仅已被驳回的提现申请可重提") + if !isWithdrawalResubmittable(request.Status) { + return errors.New(errors.CodeConflict, "仅已被驳回或审批提交失败的提现申请可重提") } if request.ApprovalInstanceID == nil { return errors.New(errors.CodeConflict, "存量提现申请不支持企业微信重提") @@ -579,7 +579,20 @@ func nextWithdrawalAttemptNo(ctx context.Context, tx *gorm.DB, requestID uint) ( return row.MaxAttemptNo + 1, nil } -// updateWithdrawalRequestForResubmit 以已驳回状态条件更新申请为最新尝试的镜像。 +// withdrawalResubmittableStatuses 允许重提的提现状态:已驳回与审批提交失败,两者冻结均已释放。 +var withdrawalResubmittableStatuses = []int{constants.WithdrawalStatusRejected, constants.WithdrawalStatusSubmissionFailed} + +// isWithdrawalResubmittable 判断提现申请当前状态是否允许重提。 +func isWithdrawalResubmittable(status int) bool { + for _, allowed := range withdrawalResubmittableStatuses { + if status == allowed { + return true + } + } + return false +} + +// updateWithdrawalRequestForResubmit 以已驳回或审批提交失败状态条件更新申请为最新尝试的镜像。 func updateWithdrawalRequestForResubmit( ctx context.Context, tx *gorm.DB, @@ -591,7 +604,7 @@ func updateWithdrawalRequestForResubmit( accountInfo []byte, ) error { result := tx.WithContext(ctx).Model(&model.CommissionWithdrawalRequest{}). - Where("id = ? AND status = ?", request.ID, constants.WithdrawalStatusRejected). + Where("id = ? AND status IN ?", request.ID, withdrawalResubmittableStatuses). Updates(map[string]any{ "amount": input.Amount, "fee": fee, "fee_rate": policy.FeeRate, "actual_amount": actualAmount, "withdrawal_method": input.WithdrawalMethod, "account_info": accountInfo, diff --git a/internal/application/distributionwithdrawal/withdrawal_approval.go b/internal/application/distributionwithdrawal/withdrawal_approval.go index ce60ed40..f2213a6c 100644 --- a/internal/application/distributionwithdrawal/withdrawal_approval.go +++ b/internal/application/distributionwithdrawal/withdrawal_approval.go @@ -83,6 +83,16 @@ func (h *WithdrawalApprovalHandler) Handle(ctx context.Context, event approvalap return h.applyClosed(ctx, tx, request, &attempt, event) case constants.ApprovalDecisionRevokedAfterApproved: return h.applyRevoked(ctx, tx, request, &attempt, event) + case constants.ApprovalDecisionSubmissionFailed: + reason, failed, err := approvalapp.ConfirmSubmissionFailed(ctx, tx, event.InstanceID) + if err != nil || !failed { + return err + } + return h.closeAttempt(ctx, tx, request, &attempt, event, withdrawalCloseOutcome{ + status: constants.WithdrawalStatusSubmissionFailed, reason: reason, eventSuffix: "submission_failed", + summary: "佣金提现审批提交失败,已释放本次尝试冻结余额", + releaseRemark: "审批提交失败,释放提现冻结,单号:", + }) default: return errors.New(errors.CodeInvalidParam, "不支持的提现申请审批终态") } @@ -163,6 +173,32 @@ func (h *WithdrawalApprovalHandler) applyClosed( request *model.CommissionWithdrawalRequest, attempt *model.CommissionWithdrawalRequestAttempt, event approvalapp.TerminalDecisionEvent, +) error { + return h.closeAttempt(ctx, tx, request, attempt, event, withdrawalCloseOutcome{ + status: constants.WithdrawalStatusRejected, reason: rejectionReason(event.Decision), eventSuffix: "closed", + summary: "企业微信未通过佣金提现,已释放本次尝试冻结余额", + releaseRemark: "企业微信未通过,释放提现冻结,单号:", + }) +} + +// withdrawalCloseOutcome 描述提现尝试未通过时的终态、原因、审计与释放流水文案。 +type withdrawalCloseOutcome struct { + status int + reason string + eventSuffix string + summary string + releaseRemark string +} + +// closeAttempt 在同一事务内仅一次释放本次尝试冻结、写释放流水,并把申请从待审核置为未通过终态。 +// 幂等守卫为「尝试已结算时间仍为空」的条件更新且影响行数为 1。 +func (h *WithdrawalApprovalHandler) closeAttempt( + ctx context.Context, + tx *gorm.DB, + request *model.CommissionWithdrawalRequest, + attempt *model.CommissionWithdrawalRequestAttempt, + event approvalapp.TerminalDecisionEvent, + outcome withdrawalCloseOutcome, ) error { if attempt.ReleasedAt != nil { return nil @@ -186,36 +222,36 @@ func (h *WithdrawalApprovalHandler) applyClosed( if !ok { return errors.New(errors.CodeConflict, "提现冻结余额与尝试记录不一致,请人工核对") } - reason := rejectionReason(event.Decision) + reason := outcome.reason result := tx.WithContext(ctx).Model(&model.CommissionWithdrawalRequest{}). Where("id = ? AND status = ?", request.ID, constants.WithdrawalStatusPending). Updates(map[string]any{ - "status": constants.WithdrawalStatusRejected, + "status": outcome.status, "processed_at": now, "reject_reason": reason, "updater": 0, }) if result.Error != nil { - return errors.Wrap(errors.CodeDatabaseError, result.Error, "标记提现申请已驳回失败") + return errors.Wrap(errors.CodeDatabaseError, result.Error, "结束提现申请失败") } if result.RowsAffected != 1 { return errors.New(errors.CodeConflict, "提现申请状态已变化") } frozenBefore := wallet.FrozenBalance + attempt.Amount wallet.FrozenBalance = frozenBefore - attempt.Amount - transaction, err := recordWithdrawalReleaseTransaction(ctx, tx, wallet, request, attempt, frozenBefore) + transaction, err := recordWithdrawalReleaseTransaction(ctx, tx, wallet, request, attempt, outcome.releaseRemark) if err != nil { return err } attempt.ReleasedAt = &now before := map[string]any{"status": constants.WithdrawalStatusPending, "frozen_balance": frozenBefore} - request.Status = constants.WithdrawalStatusRejected + request.Status = outcome.status request.ProcessedAt = &now request.RejectReason = reason return h.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{ - EventID: "commission-withdrawal:" + uintText(request.ID) + ":attempt:" + intText(attempt.AttemptNo) + ":closed", + EventID: "commission-withdrawal:" + uintText(request.ID) + ":attempt:" + intText(attempt.AttemptNo) + ":" + outcome.eventSuffix, ActionCode: constants.AuditActionCommissionWithdrawalAttemptClosed, - Summary: "企业微信未通过佣金提现,已释放本次尝试冻结余额", + Summary: outcome.summary, CorrelationID: event.CorrelationID, Withdrawal: request, Attempt: attempt, Wallet: wallet, Transaction: transaction, BeforeData: before, AfterData: withdrawalAuditSnapshot(request, attempt, wallet), @@ -336,16 +372,16 @@ func recordWithdrawalDeductTransaction( return transaction, nil } -// recordWithdrawalReleaseTransaction 写入驳回时的钱包流水,仅冻结余额减少。 +// recordWithdrawalReleaseTransaction 写入未通过(驳回或审批提交失败)时的钱包流水,仅冻结余额减少。 func recordWithdrawalReleaseTransaction( ctx context.Context, tx *gorm.DB, wallet *model.AgentWallet, request *model.CommissionWithdrawalRequest, attempt *model.CommissionWithdrawalRequestAttempt, - frozenBefore int64, + remarkPrefix string, ) (*model.AgentWalletTransaction, error) { - remark := "企业微信未通过,释放提现冻结,单号:" + request.WithdrawalNo + remark := remarkPrefix + request.WithdrawalNo refType := constants.ReferenceTypeWithdrawal refID := request.ID transaction := &model.AgentWalletTransaction{ @@ -360,6 +396,5 @@ func recordWithdrawalReleaseTransaction( if err := tx.WithContext(ctx).Create(transaction).Error; err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建提现释放钱包流水失败") } - _ = frozenBefore return transaction, nil } diff --git a/internal/application/employeecollection/application.go b/internal/application/employeecollection/application.go index 073128ed..403a9e46 100644 --- a/internal/application/employeecollection/application.go +++ b/internal/application/employeecollection/application.go @@ -161,8 +161,8 @@ func (s *ApplicationService) RecoverApproval(ctx context.Context, applicationID action = "unknown" recoveryErr = s.recovery.EnqueueUnknownConfirm(ctx, tx, instance.ID) } else if instance.Status == constants.ApprovalStatusSubmissionFailed && wc.SubmissionStatus == constants.WeComSubmissionStatusFailed { - action = "replay" - _, recoveryErr = s.recovery.RecoverSubmissionEvent(ctx, tx, instance.ID) + // 提交失败已由决策终止业务单,重放会造成“业务单终态、企微又生成审批”的分裂。 + return errors.New(errors.CodeConflict, constants.ApprovalSubmissionFailedNotRecoverableMessage) } else if instance.Status != constants.ApprovalStatusSubmitting && instance.Status != constants.ApprovalStatusPending && wc.SubmissionStatus != constants.WeComSubmissionStatusReady && wc.SubmissionStatus != constants.WeComSubmissionStatusSending { return errors.New(errors.CodeConflict, "当前审批提交状态不允许恢复") } diff --git a/internal/application/employeecollection/approval_decision.go b/internal/application/employeecollection/approval_decision.go index af3566da..581a5daa 100644 --- a/internal/application/employeecollection/approval_decision.go +++ b/internal/application/employeecollection/approval_decision.go @@ -70,6 +70,13 @@ func (h *ApprovalDecisionHandler) Handle(ctx context.Context, event approvalapp. constants.EmployeeCollectionApplicationStatusRevoked, "企业微信审批已删除") case constants.ApprovalDecisionRevokedAfterApproved: return h.applyRevoked(ctx, tx, application, &attempt, event) + case constants.ApprovalDecisionSubmissionFailed: + reason, failed, err := approvalapp.ConfirmSubmissionFailed(ctx, tx, event.InstanceID) + if err != nil || !failed { + return err + } + return h.applyClosed(ctx, tx, application, &attempt, event, + constants.EmployeeCollectionApplicationStatusSubmissionFailed, reason) default: return errors.New(errors.CodeInvalidParam, "不支持的核销申请审批终态") } @@ -138,7 +145,7 @@ func (h *ApprovalDecisionHandler) applyApproved( }) } -// applyClosed 处理最终驳回与渠道撤销、删除:释放全部预占并把申请置为对应终态。 +// applyClosed 处理最终驳回、渠道撤销、删除与审批提交失败:释放全部预占并把申请置为对应终态。 func (h *ApprovalDecisionHandler) applyClosed( ctx context.Context, tx *gorm.DB, @@ -168,7 +175,8 @@ func (h *ApprovalDecisionHandler) applyClosed( } } terminalReason := "" - if targetStatus == constants.EmployeeCollectionApplicationStatusRevoked { + if targetStatus == constants.EmployeeCollectionApplicationStatusRevoked || + targetStatus == constants.EmployeeCollectionApplicationStatusSubmissionFailed { terminalReason = reason } result := tx.WithContext(ctx).Model(&model.EmployeeCollectionApplication{}). @@ -196,10 +204,14 @@ func (h *ApprovalDecisionHandler) applyClosed( if err != nil { return err } + summary := "企业微信审批未通过,核销申请预占已释放" + if targetStatus == constants.EmployeeCollectionApplicationStatusSubmissionFailed { + summary = "审批提交失败,核销申请终止,预占已释放" + } return h.audit.WriteEmployeeCollectionApplication(ctx, tx, ApplicationAudit{ EventID: closedEventID, ActionCode: constants.AuditActionEmployeeCollectionApplicationRejected, - Summary: "企业微信审批未通过,核销申请预占已释放", + Summary: summary, Application: application, Attempt: attempt, Allocations: allocations, Bills: bills, BeforeData: map[string]any{"status": before}, AfterData: applicationAuditSnapshot(application), CorrelationID: event.CorrelationID, @@ -495,6 +507,8 @@ func decisionEventSuffix(decision string) string { return "cancelled" case constants.ApprovalDecisionDeleted: return "deleted" + case constants.ApprovalDecisionSubmissionFailed: + return "submission_failed" default: return "closed" } diff --git a/internal/application/refundapproval/recovery.go b/internal/application/refundapproval/recovery.go index 36c9f6aa..701e4b7b 100644 --- a/internal/application/refundapproval/recovery.go +++ b/internal/application/refundapproval/recovery.go @@ -103,17 +103,11 @@ func (s *RecoveryService) Execute(ctx context.Context, refundID uint) error { } return nil } - if instance.Status != constants.ApprovalStatusSubmissionFailed || context.SubmissionStatus != constants.WeComSubmissionStatusFailed { - return errors.New(errors.CodeConflict, "当前审批提交状态不允许恢复") + if instance.Status == constants.ApprovalStatusSubmissionFailed && context.SubmissionStatus == constants.WeComSubmissionStatusFailed { + // 提交失败已由决策终止退款单,不再重放;须在卡管重新发起。 + return errors.New(errors.CodeConflict, constants.ApprovalSubmissionFailedNotRecoverableMessage) } - branch = "replay" - if s.audit != nil { - if err := s.audit.WriteRecoveryRequest(ctx, tx, &instance, actorID, branch); err != nil { - return err - } - } - _, err = s.recovery.RecoverSubmissionEvent(ctx, tx, instance.ID) - return err + return errors.New(errors.CodeConflict, "当前审批提交状态不允许恢复") }) } func fillMissingSnapshot(instance *model.ApprovalInstance, attempt *model.RefundRequestAttempt) ([]string, error) { diff --git a/internal/domain/employeecollection/application.go b/internal/domain/employeecollection/application.go index a1a5550a..677a2544 100644 --- a/internal/domain/employeecollection/application.go +++ b/internal/domain/employeecollection/application.go @@ -87,9 +87,11 @@ func NormalizeApplicationInput(input ApplicationInput, acting bool) (NormalizedA } // ValidateApplicationResubmit 校验申请当前状态允许修改并重提。 -// 只有企业微信最终驳回的申请可以修改重提;已通过、审批中与异常终态一律拒绝。 +// 只有企业微信最终驳回或审批提交失败的申请可以修改重提;已通过、审批中与异常终态一律拒绝。 func ValidateApplicationResubmit(status int) error { - if status == constants.EmployeeCollectionApplicationStatusRejected { + // 审批提交失败与驳回同属“预占已释放、可修改后重提”的终态。 + if status == constants.EmployeeCollectionApplicationStatusRejected || + status == constants.EmployeeCollectionApplicationStatusSubmissionFailed { return nil } return errors.New(errors.CodeEmployeeCollectionApplicationStatusInvalid) diff --git a/internal/exporter/refund_scene.go b/internal/exporter/refund_scene.go index 7b910381..13a18de2 100644 --- a/internal/exporter/refund_scene.go +++ b/internal/exporter/refund_scene.go @@ -251,7 +251,7 @@ func formatRefundProcessingStatus(status int, commissionDeducted, assetReset boo switch status { case model.RefundStatusPending, model.RefundStatusReturned: return "待审批" - case model.RefundStatusRejected: + case model.RefundStatusRejected, model.RefundStatusSubmissionFailed: return "无需处理" case model.RefundStatusApproved: if commissionDeducted && assetReset { diff --git a/internal/model/agent_distribution.go b/internal/model/agent_distribution.go index 1b47a27a..b6f826a4 100644 --- a/internal/model/agent_distribution.go +++ b/internal/model/agent_distribution.go @@ -19,7 +19,7 @@ type AgentDistributionRegistration struct { City string `gorm:"column:city;type:varchar(50);not null;default:'';comment:城市快照" json:"city"` District string `gorm:"column:district;type:varchar(50);not null;default:'';comment:区县快照" json:"district"` Address string `gorm:"column:address;type:varchar(255);not null;default:'';comment:详细地址快照" json:"address"` - Status int `gorm:"column:status;type:smallint;not null;default:0;comment:状态 0-待审批 1-已通过 2-已驳回" json:"status"` + Status int `gorm:"column:status;type:smallint;not null;default:0;comment:状态 0-待审批 1-已通过 2-已驳回 3-审批提交失败" json:"status"` ApprovalInstanceID *uint `gorm:"column:approval_instance_id;comment:关联的通用审批实例ID" json:"approval_instance_id,omitempty"` RejectReason string `gorm:"column:reject_reason;type:varchar(500);not null;default:'';comment:企业微信驳回原因" json:"reject_reason"` DecidedAt *time.Time `gorm:"column:decided_at;type:timestamptz;comment:审批终态到达时间" json:"decided_at,omitempty"` diff --git a/internal/model/dto/agent_distribution_dto.go b/internal/model/dto/agent_distribution_dto.go index 86797633..cd1318f0 100644 --- a/internal/model/dto/agent_distribution_dto.go +++ b/internal/model/dto/agent_distribution_dto.go @@ -20,7 +20,7 @@ type CreateAgentDistributionRegistrationReq struct { // 仅返回注册记录标识与待审批状态,不返回任何账号凭证或审批实例明细。 type CreateAgentDistributionRegistrationResp struct { ID uint `json:"id" description:"待审批注册记录ID"` - Status int `json:"status" description:"注册记录状态 (0:待审批, 1:已通过, 2:已驳回)"` + Status int `json:"status" description:"注册记录状态 (0:待审批, 1:已通过, 2:已驳回, 3:审批提交失败)"` StatusName string `json:"status_name" description:"状态名称(中文)"` } @@ -43,7 +43,7 @@ type SubmitWithdrawalQualificationReq struct { // SubmitWithdrawalQualificationResp 提现资料资格提交响应。 type SubmitWithdrawalQualificationResp struct { ID uint `json:"id" description:"资料版本ID"` - Status int `json:"status" description:"资料版本状态 (0:待审批, 1:已通过有效, 2:已驳回, 3:已失效)"` + Status int `json:"status" description:"资料版本状态 (0:待审批, 1:已通过有效, 2:已驳回, 3:已失效, 4:审批提交失败)"` StatusName string `json:"status_name" description:"状态名称(中文)"` } @@ -63,7 +63,7 @@ type WithdrawalQualificationListReq struct { ShopID uint `json:"-" params:"shop_id" path:"shop_id" validate:"required" required:"true" description:"店铺ID(路径参数,必填;查询前校验当前账号对该店铺的数据范围)"` Page int `query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码(默认1)"` PageSize int `query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量(默认20,最大100)"` - Status *int `query:"status" validate:"omitempty,oneof=0 1 2 3" enum:"0,1,2,3" description:"资料版本状态 (0:待审批, 1:已通过有效, 2:已驳回, 3:已失效)"` + Status *int `query:"status" validate:"omitempty,oneof=0 1 2 3 4" enum:"0,1,2,3,4" description:"资料版本状态 (0:待审批, 1:已通过有效, 2:已驳回, 3:已失效, 4:审批提交失败)"` } // WithdrawalQualificationItem 提现资料资格版本项。 @@ -83,7 +83,7 @@ type WithdrawalQualificationItem struct { ShopFrontFileKey string `json:"shop_front_file_key,omitempty" description:"门头照附件对象存储 Key"` InvoiceFileKey string `json:"invoice_file_key,omitempty" description:"发票附件对象存储 Key"` InvoiceTitle string `json:"invoice_title,omitempty" description:"发票抬头"` - Status int `json:"status" description:"状态 (0:待审批, 1:已通过有效, 2:已驳回, 3:已失效)"` + Status int `json:"status" description:"状态 (0:待审批, 1:已通过有效, 2:已驳回, 3:已失效, 4:审批提交失败)"` StatusName string `json:"status_name" description:"状态名称(中文)"` InvalidReason string `json:"invalid_reason,omitempty" description:"失效或作废原因"` InvalidatedAt string `json:"invalidated_at,omitempty" description:"失效时间"` @@ -158,7 +158,7 @@ type ShopWithdrawalRequestDetailResp struct { type AgentDistributionRegistrationListReq struct { Page int `query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码(默认1)"` PageSize int `query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量(默认20,最大100)"` - Status *int `query:"status" validate:"omitempty,oneof=0 1 2" enum:"0,1,2" description:"注册业务状态 (0:待审批, 1:已通过, 2:已驳回)"` + Status *int `query:"status" validate:"omitempty,oneof=0 1 2 3" enum:"0,1,2,3" description:"注册业务状态 (0:待审批, 1:已通过, 2:已驳回, 3:审批提交失败)"` StartTime string `query:"start_time" description:"申请开始时间(RFC3339,闭区间)"` EndTime string `query:"end_time" description:"申请结束时间(RFC3339,闭区间)"` DecidedStartTime string `query:"decided_start_time" description:"决定开始时间(RFC3339,闭区间)"` @@ -173,7 +173,7 @@ type AgentDistributionRegistrationListReq struct { // 该投影不包含密码哈希、验证码及企业微信内部响应。 type AgentDistributionRegistrationItem struct { ID uint `json:"id" description:"注册申请ID"` - Status int `json:"status" description:"注册业务状态 (0:待审批, 1:已通过, 2:已驳回)"` + Status int `json:"status" description:"注册业务状态 (0:待审批, 1:已通过, 2:已驳回, 3:审批提交失败)"` StatusName string `json:"status_name" description:"注册业务状态名称"` ShopName string `json:"shop_name" description:"申请店铺名称"` ShopCode string `json:"shop_code" description:"申请店铺编号"` diff --git a/internal/model/dto/agent_recharge_dto.go b/internal/model/dto/agent_recharge_dto.go index 9015c23b..51db8606 100644 --- a/internal/model/dto/agent_recharge_dto.go +++ b/internal/model/dto/agent_recharge_dto.go @@ -23,7 +23,7 @@ type AgentRechargeOnlineResponse struct { RechargeSourceName string `json:"recharge_source_name" description:"充值来源名称(中文)"` Amount int64 `json:"amount" description:"在线充值金额(分),范围10000分~100000000分"` QRContent string `json:"qr_content" description:"支付链接(HTTPS URL),由前端渲染二维码"` - Status int `json:"status" description:"状态 (1:待支付, 2:已支付, 3:已完成, 4:已关闭, 5:已退款, 6:已驳回)"` + Status int `json:"status" description:"状态 (1:待支付, 2:已支付, 3:已完成, 4:已关闭, 5:已退款, 6:已驳回, 7:审批提交失败)"` StatusName string `json:"status_name" description:"状态名称(中文)"` } @@ -40,7 +40,7 @@ type AgentRechargePaymentStatusResponse struct { RechargeNo string `json:"recharge_no" description:"充值单号(ARCH前缀)"` RechargeSource string `json:"recharge_source" description:"充值来源 (platform_offline:平台线下代充, agent_online:代理在线自充)"` RechargeSourceName string `json:"recharge_source_name" description:"充值来源名称(中文)"` - Status int `json:"status" description:"充值状态 (1:待支付, 2:已支付, 3:已完成, 4:已关闭, 5:已退款, 6:已驳回)"` + Status int `json:"status" description:"充值状态 (1:待支付, 2:已支付, 3:已完成, 4:已关闭, 5:已退款, 6:已驳回, 7:审批提交失败)"` StatusName string `json:"status_name" description:"充值状态名称(中文)"` PaymentStatus int `json:"payment_status" description:"支付状态 (0:待支付, 1:已支付, 2:已失败, 3:已退款)"` PaymentStatusName string `json:"payment_status_name" description:"支付状态名称(中文)"` @@ -104,7 +104,7 @@ type AgentRechargeResponse struct { OtherVoucherKey []string `json:"other_voucher_key" description:"其他凭证对象存储Key列表,仅线下充值有值;不返回凭证内容"` PaymentVoucherKey []string `json:"payment_voucher_key" description:"支付凭证对象存储Key列表(线下支付时存在,最多5个)"` Remark string `json:"remark,omitempty" description:"运营备注"` - Status int `json:"status" description:"状态 (1:待支付, 2:已支付, 3:已完成, 4:已关闭, 5:已退款, 6:已驳回)"` + Status int `json:"status" description:"状态 (1:待支付, 2:已支付, 3:已完成, 4:已关闭, 5:已退款, 6:已驳回, 7:审批提交失败)"` StatusName string `json:"status_name" description:"状态名称(中文)"` RejectionReason *string `json:"rejection_reason,omitempty" description:"驳回原因,仅 status=6 时有值"` SubmitterID uint `json:"submitter_id" description:"提交人账号ID"` @@ -135,7 +135,7 @@ type AgentRechargeListRequest struct { Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码,默认1"` PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页条数,默认20,最大100"` ShopID *uint `json:"shop_id" query:"shop_id" description:"按店铺ID过滤"` - Status *int `json:"status" query:"status" description:"按状态过滤 (1:待支付, 2:已支付, 3:已完成, 4:已关闭, 5:已退款, 6:已驳回)"` + Status *int `json:"status" query:"status" description:"按状态过滤 (1:待支付, 2:已支付, 3:已完成, 4:已关闭, 5:已退款, 6:已驳回, 7:审批提交失败)"` RechargeSource string `json:"recharge_source" query:"recharge_source" validate:"omitempty,oneof=platform_offline agent_online" description:"按充值来源过滤 (platform_offline:平台线下代充, agent_online:代理在线自充)"` StartTime string `json:"start_time" query:"start_time" description:"创建时间起始(带时区的 RFC3339 秒级时间,闭区间含该时刻,如 2026-09-01T00:00:00+08:00)"` EndTime string `json:"end_time" query:"end_time" description:"创建时间结束(带时区的 RFC3339 秒级时间,闭区间含该时刻,如 2026-09-30T23:59:59+08:00)"` diff --git a/internal/model/dto/commission_withdrawal_dto.go b/internal/model/dto/commission_withdrawal_dto.go index b4a5bc44..07bb3f58 100644 --- a/internal/model/dto/commission_withdrawal_dto.go +++ b/internal/model/dto/commission_withdrawal_dto.go @@ -4,7 +4,7 @@ package dto type WithdrawalRequestListReq struct { Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码(默认1)"` PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量(默认20,最大100)"` - Status *int `json:"status" query:"status" validate:"omitempty,min=1,max=4" minimum:"1" maximum:"4" description:"状态 (1:待审核, 2:已通过, 3:已拒绝, 4:已到账)"` + Status *int `json:"status" query:"status" validate:"omitempty,min=1,max=5" minimum:"1" maximum:"5" description:"状态 (1:待审核, 2:已通过, 3:已拒绝, 4:已到账, 5:审批提交失败)"` WithdrawalNo string `json:"withdrawal_no" query:"withdrawal_no" validate:"omitempty,max=50" maxLength:"50" description:"提现单号(精确查询)"` ShopName string `json:"shop_name" query:"shop_name" validate:"omitempty,max=100" maxLength:"100" description:"店铺名称(模糊查询)"` StartTime string `json:"start_time" query:"start_time" description:"申请时间起始(带时区的 RFC3339 秒级时间,闭区间含该时刻,如 2026-09-01T00:00:00+08:00)"` @@ -19,7 +19,7 @@ type WithdrawalRequestItem struct { FeeRate int64 `json:"fee_rate" description:"手续费比率(基点,100=1%)"` Fee int64 `json:"fee" description:"手续费(分)"` ActualAmount int64 `json:"actual_amount" description:"实际到账金额(分)"` - Status int `json:"status" description:"状态 (1:待审核, 2:已通过, 3:已拒绝, 4:已到账)"` + Status int `json:"status" description:"状态 (1:待审核, 2:已通过, 3:已拒绝, 4:已到账, 5:审批提交失败)"` StatusName string `json:"status_name" description:"状态名称"` ShopID uint `json:"shop_id" description:"店铺ID"` ShopName string `json:"shop_name" description:"店铺名称"` @@ -68,7 +68,7 @@ type RejectWithdrawalReq struct { type WithdrawalApprovalResp struct { ID uint `json:"id" description:"提现申请ID"` WithdrawalNo string `json:"withdrawal_no" description:"提现单号"` - Status int `json:"status" description:"状态 (1:待审核, 2:已通过, 3:已拒绝, 4:已到账)"` + Status int `json:"status" description:"状态 (1:待审核, 2:已通过, 3:已拒绝, 4:已到账, 5:审批提交失败)"` StatusName string `json:"status_name" description:"状态名称"` ProcessedAt string `json:"processed_at" description:"处理时间"` } diff --git a/internal/model/dto/employee_collection_dto.go b/internal/model/dto/employee_collection_dto.go index b762e140..ea45ec93 100644 --- a/internal/model/dto/employee_collection_dto.go +++ b/internal/model/dto/employee_collection_dto.go @@ -146,7 +146,7 @@ type EmployeeCollectionBillRefundResponse struct { type EmployeeCollectionBillAllocationResponse struct { ID uint `json:"id" description:"分摊ID"` ApplicationID uint `json:"application_id" description:"核销申请ID"` - ApplicationStatus int `json:"application_status" description:"申请状态 (0:审批中, 1:已通过, 2:已驳回, 3:已撤销或已关闭)"` + ApplicationStatus int `json:"application_status" description:"申请状态 (0:审批中, 1:已通过, 2:已驳回, 3:已撤销或已关闭, 4:审批提交失败)"` ApplicationStatusName string `json:"application_status_name" description:"申请状态中文名称"` AttemptID uint `json:"attempt_id" description:"所属审批尝试记录ID"` Amount int64 `json:"amount" description:"本次分摊金额(分)"` @@ -194,7 +194,7 @@ type EmployeeCollectionBillApplicationResponse struct { ExternalTransactionNo string `json:"external_transaction_no" description:"外部交易流水号"` PaymentVoucherKeys []string `json:"payment_voucher_keys" description:"支付凭证对象存储Key列表,仅返回对象键引用"` Remark string `json:"remark" description:"申请备注"` - Status int `json:"status" description:"申请状态 (0:审批中, 1:已通过, 2:已驳回, 3:已撤销或已关闭)"` + Status int `json:"status" description:"申请状态 (0:审批中, 1:已通过, 2:已驳回, 3:已撤销或已关闭, 4:审批提交失败)"` StatusName string `json:"status_name" description:"申请状态中文名称"` LatestApprovalInstanceID uint `json:"latest_approval_instance_id" description:"最新通用审批实例ID,仅用于展示"` DecidedAt *time.Time `json:"decided_at,omitempty" description:"审批终态到达时间"` @@ -267,7 +267,7 @@ type SubmitEmployeeCollectionApplicationRequest struct { type EmployeeCollectionApplicationListRequest struct { Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码,默认1"` PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页条数,默认20,最大100"` - Status *int `json:"status" query:"status" validate:"omitempty,oneof=0 1 2 3" enum:"0,1,2,3" description:"按申请状态过滤 (0:审批中, 1:已通过, 2:已驳回, 3:已撤销或已关闭)"` + Status *int `json:"status" query:"status" validate:"omitempty,oneof=0 1 2 3 4" enum:"0,1,2,3,4" description:"按申请状态过滤 (0:审批中, 1:已通过, 2:已驳回, 3:已撤销或已关闭, 4:审批提交失败)"` ApplicantAccountID *uint `json:"applicant_account_id" query:"applicant_account_id" description:"按申请人账号ID过滤;非超级管理员固定为当前账号,该参数被忽略"` PaymentMethodID *uint `json:"payment_method_id" query:"payment_method_id" description:"按线下收款方式字典ID过滤"` CreatedFrom *string `json:"created_from" query:"created_from" description:"创建时间起始(YYYY-MM-DD,含当日 00:00:00)"` @@ -289,11 +289,11 @@ type EmployeeCollectionApplicationResponse struct { ExternalTransactionNo string `json:"external_transaction_no" description:"人工确认的外部交易流水号,申请人可原样核对与重用"` PaymentVoucherKeys []string `json:"payment_voucher_keys" description:"支付凭证对象存储Key列表,仅返回对象键引用"` Remark string `json:"remark" description:"申请备注"` - Status int `json:"status" description:"申请状态 (0:审批中, 1:已通过, 2:已驳回, 3:已撤销或已关闭)"` + Status int `json:"status" description:"申请状态 (0:审批中, 1:已通过, 2:已驳回, 3:已撤销或已关闭, 4:审批提交失败)"` StatusName string `json:"status_name" description:"申请状态中文名称"` LatestAttemptID uint `json:"latest_attempt_id" description:"最新审批尝试记录ID,仅用于展示"` LatestApprovalInstanceID uint `json:"latest_approval_instance_id" description:"最新通用审批实例ID,仅用于展示"` - ApprovalSubmissionStatus int `json:"approval_submission_status" description:"审批提交状态"` + ApprovalSubmissionStatus int `json:"approval_submission_status" description:"最新审批实例的企业微信提交状态 (0:待提交, 1:提交中, 2:已提交, 3:提交失败, 4:结果未知);无审批上下文为 0"` ApprovalSubmissionName string `json:"approval_submission_name" description:"审批提交状态中文名称"` ApprovalFailureSummary string `json:"approval_failure_summary,omitempty" description:"脱敏审批失败摘要"` Recoverable bool `json:"recoverable" description:"是否允许恢复原审批"` diff --git a/internal/model/dto/refund_dto.go b/internal/model/dto/refund_dto.go index 4a034ad3..bc4acd42 100644 --- a/internal/model/dto/refund_dto.go +++ b/internal/model/dto/refund_dto.go @@ -56,7 +56,7 @@ type ReturnRefundRequest struct { type RefundListRequest struct { Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码(默认1)"` PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量(默认20,最大100)"` - Status *int `json:"status" query:"status" validate:"omitempty,min=1,max=6" minimum:"1" maximum:"6" description:"状态 (1:待审批, 2:已通过, 3:已拒绝, 4:已退回, 5:原路退款处理中, 6:原路退款失败)"` + Status *int `json:"status" query:"status" validate:"omitempty,min=1,max=7" minimum:"1" maximum:"7" description:"状态 (1:待审批, 2:已通过, 3:已拒绝, 4:已退回, 5:原路退款处理中, 6:原路退款失败, 7:审批提交失败)"` OrderID *uint `json:"order_id" query:"order_id" validate:"omitempty" description:"关联订单ID"` ShopID *uint `json:"shop_id" query:"shop_id" validate:"omitempty" description:"店铺ID"` AssetIdentifier string `json:"asset_identifier" query:"asset_identifier" validate:"omitempty,max=100" maxLength:"100" description:"资产标识精确检索(ICCID 或 设备虚拟号,非空时精确匹配)"` @@ -84,7 +84,7 @@ type RefundResponse struct { ApprovedRefundAmount *int64 `json:"approved_refund_amount,omitempty" description:"审批实际退款金额(分)"` RefundVoucherKey []string `json:"refund_voucher_key" description:"退款凭证对象存储file_key列表(最多5个)"` RefundReason string `json:"refund_reason" description:"退款原因"` - Status int `json:"status" description:"状态 (1:待审批, 2:已通过, 3:已拒绝, 4:已退回, 5:原路退款处理中, 6:原路退款失败)"` + Status int `json:"status" description:"状态 (1:待审批, 2:已通过, 3:已拒绝, 4:已退回, 5:原路退款处理中, 6:原路退款失败, 7:审批提交失败)"` StatusName string `json:"status_name" description:"状态名称(中文)"` Method string `json:"method" description:"退款方式 (original_route:原路退款, customer_account:客户收款信息退款, asset_wallet:退回资产钱包, agent_wallet:退回代理主钱包),空表示未接入方式的存量申请"` MethodName string `json:"method_name" description:"退款方式中文名称"` diff --git a/internal/model/financial.go b/internal/model/financial.go index ebbc7d18..beb5506a 100644 --- a/internal/model/financial.go +++ b/internal/model/financial.go @@ -23,7 +23,7 @@ type CommissionWithdrawalRequest struct { WithdrawalMethod string `gorm:"column:withdrawal_method;type:varchar(20);comment:提现方式 alipay-支付宝 wechat-微信 bank-银行卡" json:"withdrawal_method"` PaymentType string `gorm:"column:payment_type;type:varchar(20);default:'manual';comment:放款类型(manual=人工打款)" json:"payment_type"` AccountInfo datatypes.JSON `gorm:"column:account_info;type:jsonb;comment:收款账户信息(姓名、账号等)" json:"account_info"` - Status int `gorm:"column:status;type:int;default:1;comment:状态 1-待审核 2-已通过 3-已拒绝 4-已到账" json:"status"` + Status int `gorm:"column:status;type:int;default:1;comment:状态 1-待审核 2-已通过 3-已拒绝 4-已到账 5-审批提交失败" json:"status"` ProcessorID uint `gorm:"column:processor_id;index;comment:处理人ID" json:"processor_id"` ProcessedAt *time.Time `gorm:"column:processed_at;comment:处理时间" json:"processed_at"` PaidAt *time.Time `gorm:"column:paid_at;comment:到账时间" json:"paid_at"` diff --git a/internal/model/refund.go b/internal/model/refund.go index 134a9a2e..b21e0e96 100644 --- a/internal/model/refund.go +++ b/internal/model/refund.go @@ -122,6 +122,8 @@ const ( RefundStatusChannelProcessing = 5 // RefundStatusChannelFailed 表示原路渠道明确失败或超时可恢复失败。 RefundStatusChannelFailed = 6 + // RefundStatusSubmissionFailed 表示提交企业微信审批被明确拒绝,退款单终止且钱未动;可对原单重新发起或新建退款单。 + RefundStatusSubmissionFailed = 7 ) // RefundActiveStatuses 返回会阻止同一订单创建新退款申请的状态集合。 @@ -132,5 +134,5 @@ func RefundActiveStatuses() []int { // RefundResubmittableStatuses 返回允许修改材料并重提的状态集合。 func RefundResubmittableStatuses() []int { - return []int{RefundStatusRejected, RefundStatusReturned, RefundStatusChannelFailed} + return []int{RefundStatusRejected, RefundStatusReturned, RefundStatusChannelFailed, RefundStatusSubmissionFailed} } diff --git a/internal/query/distributionwithdrawal/query.go b/internal/query/distributionwithdrawal/query.go index 1fd847e7..14be620f 100644 --- a/internal/query/distributionwithdrawal/query.go +++ b/internal/query/distributionwithdrawal/query.go @@ -433,6 +433,6 @@ func projectRegistration(row registrationProjection) dto.AgentDistributionRegist if item.SubmissionStatusName == "" { item.SubmissionStatusName = "未知" } - item.Recoverable = row.Status == constants.AgentDistributionRegistrationStatusPending && row.ParentShopStatus == 1 && row.ApprovalInstanceID > 0 && (row.ApprovalStatus == constants.ApprovalStatusSubmitting || row.ApprovalStatus == constants.ApprovalStatusPending || row.ApprovalStatus == constants.ApprovalStatusSubmissionFailed || row.ApprovalStatus == constants.ApprovalStatusSubmissionUnknown || row.SubmissionStatus == constants.WeComSubmissionStatusFailed || row.SubmissionStatus == constants.WeComSubmissionStatusUnknown) + item.Recoverable = row.Status == constants.AgentDistributionRegistrationStatusPending && row.ParentShopStatus == 1 && row.ApprovalInstanceID > 0 && (row.ApprovalStatus == constants.ApprovalStatusSubmitting || row.ApprovalStatus == constants.ApprovalStatusPending || row.ApprovalStatus == constants.ApprovalStatusSubmissionUnknown || row.SubmissionStatus == constants.WeComSubmissionStatusUnknown) return item } diff --git a/internal/query/employeecollection/application.go b/internal/query/employeecollection/application.go index 751ed0cc..58cc627d 100644 --- a/internal/query/employeecollection/application.go +++ b/internal/query/employeecollection/application.go @@ -47,6 +47,9 @@ func (q *ApplicationQuery) List( for index := range applications { list = append(list, ProjectApplication(&applications[index])) } + if err := q.fillApplicationSubmission(ctx, list); err != nil { + return nil, err + } return &dto.EmployeeCollectionApplicationListResponse{ List: list, Total: total, Page: page, PageSize: pageSize, }, nil @@ -77,8 +80,12 @@ func (q *ApplicationQuery) Detail(ctx context.Context, id uint) (*dto.EmployeeCo if err != nil { return nil, err } + projected := ProjectApplication(&application) + if err := q.fillApplicationSubmission(ctx, []*dto.EmployeeCollectionApplicationResponse{projected}); err != nil { + return nil, err + } return &dto.EmployeeCollectionApplicationDetailResponse{ - Application: ProjectApplication(&application), Allocations: allocations, Attempts: attempts, + Application: projected, Allocations: allocations, Attempts: attempts, }, nil } @@ -105,7 +112,8 @@ func (q *ApplicationQuery) applyApplicationScope( case constants.EmployeeCollectionApplicationStatusPending, constants.EmployeeCollectionApplicationStatusApproved, constants.EmployeeCollectionApplicationStatusRejected, - constants.EmployeeCollectionApplicationStatusRevoked: + constants.EmployeeCollectionApplicationStatusRevoked, + constants.EmployeeCollectionApplicationStatusSubmissionFailed: query = query.Where("status = ?", *request.Status) default: return nil, errors.New(errors.CodeInvalidParam, "不支持的核销申请状态") diff --git a/internal/query/employeecollection/application_submission.go b/internal/query/employeecollection/application_submission.go new file mode 100644 index 00000000..aa635591 --- /dev/null +++ b/internal/query/employeecollection/application_submission.go @@ -0,0 +1,71 @@ +package employeecollection + +import ( + "context" + "time" + + approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval" + "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" +) + +// applicationSubmissionRow 是核销申请最新审批实例的提交事实。 +type applicationSubmissionRow struct { + InstanceID uint `gorm:"column:instance_id"` + InstanceStatus int `gorm:"column:instance_status"` + SubmissionStatus int `gorm:"column:submission_status"` + LastError string `gorm:"column:last_error"` + LastRecoveryAt *time.Time `gorm:"column:last_recovery_at"` +} + +// fillApplicationSubmission 按最新审批实例一次批量读取企微审批上下文,填充提交状态、脱敏摘要、可恢复与最近恢复时间。 +// 无审批实例或无企微上下文的申请保持零值。 +func (q *ApplicationQuery) fillApplicationSubmission(ctx context.Context, responses []*dto.EmployeeCollectionApplicationResponse) error { + instanceIDs := make([]uint, 0, len(responses)) + for _, response := range responses { + if response != nil && response.LatestApprovalInstanceID != 0 { + instanceIDs = append(instanceIDs, response.LatestApprovalInstanceID) + } + } + if len(instanceIDs) == 0 { + return nil + } + var rows []applicationSubmissionRow + if err := q.db.WithContext(ctx).Raw( + `SELECT ai.id AS instance_id, ai.status AS instance_status, + COALESCE(wc.submission_status, 0) AS submission_status, + COALESCE(wc.last_error, '') AS last_error, wc.last_recovery_at + FROM tb_approval_instance ai + LEFT JOIN tb_wecom_approval_context wc ON wc.approval_instance_id = ai.id + WHERE ai.id IN ?`, instanceIDs).Scan(&rows).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "批量查询核销申请审批提交状态失败") + } + byInstance := make(map[uint]applicationSubmissionRow, len(rows)) + for _, row := range rows { + byInstance[row.InstanceID] = row + } + for _, response := range responses { + if response == nil { + continue + } + row, ok := byInstance[response.LatestApprovalInstanceID] + if !ok { + continue + } + response.ApprovalSubmissionStatus = row.SubmissionStatus + response.ApprovalSubmissionName = constants.GetWeComSubmissionStatusName(row.SubmissionStatus) + if row.SubmissionStatus == constants.WeComSubmissionStatusFailed { + response.ApprovalFailureSummary = approvalapp.SubmissionFailureReason(row.LastError) + } + // 提交失败为终态不可恢复;只有审批中的申请且实例仍在提交中、审批中或结果未知时可恢复。 + response.Recoverable = response.Status == constants.EmployeeCollectionApplicationStatusPending && + (row.InstanceStatus == constants.ApprovalStatusSubmitting || + row.InstanceStatus == constants.ApprovalStatusPending || + row.InstanceStatus == constants.ApprovalStatusSubmissionUnknown) + if row.LastRecoveryAt != nil { + response.LastRecoveryAt = row.LastRecoveryAt.UTC().Format(time.RFC3339) + } + } + return nil +} diff --git a/internal/service/refund/approval_decision.go b/internal/service/refund/approval_decision.go index 5fb4867b..9dbd486d 100644 --- a/internal/service/refund/approval_decision.go +++ b/internal/service/refund/approval_decision.go @@ -28,7 +28,8 @@ func (s *Service) Handle(ctx context.Context, event approvalapp.TerminalDecision switch event.Decision { case constants.ApprovalDecisionApproved: return s.applyApprovedDecision(ctx, event) - case constants.ApprovalDecisionRejected, constants.ApprovalDecisionCancelled, constants.ApprovalDecisionDeleted: + case constants.ApprovalDecisionRejected, constants.ApprovalDecisionCancelled, constants.ApprovalDecisionDeleted, + constants.ApprovalDecisionSubmissionFailed: return s.applyClosedDecision(ctx, event) case constants.ApprovalDecisionRevokedAfterApproved: return s.applyRevokedAfterApproved(ctx, event) @@ -214,6 +215,8 @@ func (s *Service) prepareChannelRefundInTx(ctx context.Context, tx *gorm.DB, ref return s.channelRefund.PrepareInTx(ctx, tx, refund, attempt) } +// applyClosedDecision 处理企微拒绝、撤销、删除(退款单→已拒绝)与审批提交失败(退款单→审批提交失败),均为钱未动的终态。 +// 业务标识按审批尝试优先解析(BusinessID 在尝试模式下是尝试 ID),再按退款单 ID 加行锁。 func (s *Service) applyClosedDecision(ctx context.Context, event approvalapp.TerminalDecisionEvent) error { ctx = auditcontext.With(ctx, auditcontext.Context{CorrelationID: event.CorrelationID, ParentEventID: event.EventID}) reason := map[string]string{ @@ -221,18 +224,35 @@ func (s *Service) applyClosedDecision(ctx context.Context, event approvalapp.Ter constants.ApprovalDecisionCancelled: "企业微信审批已撤销", constants.ApprovalDecisionDeleted: "企业微信审批已删除", }[event.Decision] + submissionFailed := event.Decision == constants.ApprovalDecisionSubmissionFailed + targetStatus, actionSummary, auditSuffix, auditResult := model.RefundStatusRejected, "拒绝退款审批", ":rejected", "退款已拒绝" + if submissionFailed { + targetStatus, actionSummary, auditSuffix, auditResult = model.RefundStatusSubmissionFailed, "退款审批提交失败", ":submission_failed", "退款审批提交失败" + } var refund model.RefundRequest var order model.Order err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", event.BusinessID).First(&refund).Error; err != nil { + target, _, err := refundapproval.ResolveRefundInTx(ctx, tx, event.BusinessID, event.InstanceID) + if err != nil { + return err + } + if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).First(&refund, target.ID).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "锁定退款申请失败") } - if refund.ApprovalInstanceID == nil || *refund.ApprovalInstanceID != event.InstanceID { - return errors.New(errors.CodeConflict, "退款申请关联的审批实例不一致") - } - if refund.Status == model.RefundStatusRejected { + // 已被重新发起取代的历史审批不再改变退款单事实。 + if refund.LatestApprovalInstanceID != 0 && refund.LatestApprovalInstanceID != event.InstanceID { return nil } + if refund.Status == targetStatus { + return nil + } + if submissionFailed { + failureReason, failed, err := approvalapp.ConfirmSubmissionFailed(ctx, tx, event.InstanceID) + if err != nil || !failed { + return err + } + reason = failureReason + } if refund.Status != model.RefundStatusPending { return errors.New(errors.CodeInvalidStatus, "退款申请状态不允许结束审批") } @@ -243,7 +263,7 @@ func (s *Service) applyClosedDecision(ctx context.Context, event approvalapp.Ter result := tx.WithContext(ctx).Model(&model.RefundRequest{}). Where("id = ? AND status = ?", refund.ID, model.RefundStatusPending). Updates(map[string]any{ - "status": model.RefundStatusRejected, "processed_at": event.OccurredAt, + "status": targetStatus, "processed_at": event.OccurredAt, "reject_reason": strings.TrimSpace(reason), "updated_at": event.OccurredAt, }) if result.Error != nil { @@ -252,8 +272,9 @@ func (s *Service) applyClosedDecision(ctx context.Context, event approvalapp.Ter if result.RowsAffected != 1 { return errors.New(errors.CodeConflict, "退款申请状态已变化") } - return s.appendRefundAudit(ctx, tx, refund.ID, constants.AuditActionRefundRejected, "拒绝退款审批", - "refund:"+strconv.FormatUint(uint64(refund.ID), 10)+":rejected", beforeRefund, nil, "退款已拒绝") + return s.appendRefundAudit(ctx, tx, refund.ID, constants.AuditActionRefundRejected, actionSummary, + "refund:"+strconv.FormatUint(uint64(refund.ID), 10)+auditSuffix+":"+strconv.FormatUint(uint64(event.InstanceID), 10), + beforeRefund, nil, auditResult) }) if err != nil { s.recordRefundFailure(ctx, constants.AuditActionRefundRejected, "拒绝退款审批失败", &refund, &order, err) diff --git a/internal/service/refund/service.go b/internal/service/refund/service.go index bf3fc3e1..b98970a4 100644 --- a/internal/service/refund/service.go +++ b/internal/service/refund/service.go @@ -1464,7 +1464,8 @@ func (s *Service) loadApprovalSummaries(ctx context.Context, refunds []*model.Re } for _, row := range rows { summary := approvalSummary{Provider: row.Provider, Status: row.Status, SubmissionStatus: row.SubmissionStatus, FailureSummary: sanitizeApprovalFailure(row.LastError), LastRecoveryAt: row.LastRecoveryAt, SPNo: row.SPNo, EventStatus: row.EventStatus} - summary.Recoverable = row.Status == constants.ApprovalStatusSubmissionFailed && row.SubmissionStatus == constants.WeComSubmissionStatusFailed && row.SPNo == "" && (row.EventStatus == constants.OutboxStatusFailed || row.EventStatus == constants.OutboxStatusDelivered) + // 提交失败为终态不可恢复;只有提交中、审批中与结果未知仍可由恢复入口确认。 + summary.Recoverable = row.Status == constants.ApprovalStatusSubmitting || row.Status == constants.ApprovalStatusPending || row.Status == constants.ApprovalStatusSubmissionUnknown result[row.ID] = summary } return result, nil diff --git a/migrations/000239_allow_approval_submission_failed_status.down.sql b/migrations/000239_allow_approval_submission_failed_status.down.sql new file mode 100644 index 00000000..9aeb46cd --- /dev/null +++ b/migrations/000239_allow_approval_submission_failed_status.down.sql @@ -0,0 +1,33 @@ +-- 回滚 000239:恢复五张业务表原状态约束与列注释。 +-- 执行前必须确认已无“审批提交失败”状态数据,否则重建约束会失败。 +ALTER TABLE tb_employee_collection_application DROP CONSTRAINT IF EXISTS chk_employee_collection_application_status; +ALTER TABLE tb_employee_collection_application + ADD CONSTRAINT chk_employee_collection_application_status CHECK (status IN (0, 1, 2, 3)); +COMMENT ON COLUMN tb_employee_collection_application.status IS '状态:0 审批中、1 已通过、2 已驳回、3 已撤销或已关闭'; + +ALTER TABLE tb_refund_request DROP CONSTRAINT IF EXISTS chk_refund_request_status; +ALTER TABLE tb_refund_request + ADD CONSTRAINT chk_refund_request_status CHECK (status IN (1, 2, 3, 4, 5, 6)); +COMMENT ON COLUMN tb_refund_request.status IS '状态 1-待审批 2-已通过 3-已拒绝 4-已退回 5-原路退款处理中 6-原路退款失败'; + +ALTER TABLE tb_agent_distribution_registration DROP CONSTRAINT IF EXISTS chk_agent_distribution_registration_status; +ALTER TABLE tb_agent_distribution_registration + ADD CONSTRAINT chk_agent_distribution_registration_status CHECK (status IN (0, 1, 2)); +COMMENT ON COLUMN tb_agent_distribution_registration.status IS '状态:0 待审批、1 已通过、2 已驳回'; + +ALTER TABLE tb_withdrawal_qualification DROP CONSTRAINT IF EXISTS chk_withdrawal_qualification_status; +ALTER TABLE tb_withdrawal_qualification + ADD CONSTRAINT chk_withdrawal_qualification_status CHECK (status IN (0, 1, 2, 3)); +COMMENT ON COLUMN tb_withdrawal_qualification.status IS '状态:0 待审批、1 已通过有效、2 已驳回、3 已失效或已作废'; + +ALTER TABLE tb_agent_recharge_record DROP CONSTRAINT IF EXISTS chk_agent_recharge_status; +ALTER TABLE tb_agent_recharge_record + ADD CONSTRAINT chk_agent_recharge_status CHECK (status IN (1, 2, 3, 4, 5, 6)); +COMMENT ON COLUMN tb_agent_recharge_record.status IS '充值状态:1-待支付 2-已支付 3-已完成 4-已关闭 5-已退款'; + +COMMENT ON COLUMN tb_commission_withdrawal_request.status IS '状态 1-待审核 2-已通过 3-已拒绝 4-已到账'; + +ALTER TABLE tb_approval_decision_delivery DROP CONSTRAINT IF EXISTS ck_approval_decision_delivery_decision; +ALTER TABLE tb_approval_decision_delivery + ADD CONSTRAINT ck_approval_decision_delivery_decision + CHECK (decision IN ('approved', 'rejected', 'cancelled', 'deleted', 'revoked_after_approved')); diff --git a/migrations/000239_allow_approval_submission_failed_status.up.sql b/migrations/000239_allow_approval_submission_failed_status.up.sql new file mode 100644 index 00000000..58529a68 --- /dev/null +++ b/migrations/000239_allow_approval_submission_failed_status.up.sql @@ -0,0 +1,35 @@ +-- 审批提交失败终态:放宽五张业务表的状态约束,新增“审批提交失败”状态值(只放宽,不改既有数据)。 +-- 核销申请 4、退款 7、扫码注册代理 3、提现资料资格 4、线下代理充值 7;佣金提现表无状态约束,只更新列注释(新增 5); +-- 另放宽审批决策投递表的决策取值,允许 submission_failed。 +ALTER TABLE tb_employee_collection_application DROP CONSTRAINT IF EXISTS chk_employee_collection_application_status; +ALTER TABLE tb_employee_collection_application + ADD CONSTRAINT chk_employee_collection_application_status CHECK (status IN (0, 1, 2, 3, 4)); +COMMENT ON COLUMN tb_employee_collection_application.status IS '状态:0 审批中、1 已通过、2 已驳回、3 已撤销或已关闭、4 审批提交失败'; + +ALTER TABLE tb_refund_request DROP CONSTRAINT IF EXISTS chk_refund_request_status; +ALTER TABLE tb_refund_request + ADD CONSTRAINT chk_refund_request_status CHECK (status IN (1, 2, 3, 4, 5, 6, 7)); +COMMENT ON COLUMN tb_refund_request.status IS '状态 1-待审批 2-已通过 3-已拒绝 4-已退回 5-原路退款处理中 6-原路退款失败 7-审批提交失败'; + +ALTER TABLE tb_agent_distribution_registration DROP CONSTRAINT IF EXISTS chk_agent_distribution_registration_status; +ALTER TABLE tb_agent_distribution_registration + ADD CONSTRAINT chk_agent_distribution_registration_status CHECK (status IN (0, 1, 2, 3)); +COMMENT ON COLUMN tb_agent_distribution_registration.status IS '状态:0 待审批、1 已通过、2 已驳回、3 审批提交失败'; + +ALTER TABLE tb_withdrawal_qualification DROP CONSTRAINT IF EXISTS chk_withdrawal_qualification_status; +ALTER TABLE tb_withdrawal_qualification + ADD CONSTRAINT chk_withdrawal_qualification_status CHECK (status IN (0, 1, 2, 3, 4)); +COMMENT ON COLUMN tb_withdrawal_qualification.status IS '状态:0 待审批、1 已通过有效、2 已驳回、3 已失效或已作废、4 审批提交失败'; + +ALTER TABLE tb_agent_recharge_record DROP CONSTRAINT IF EXISTS chk_agent_recharge_status; +ALTER TABLE tb_agent_recharge_record + ADD CONSTRAINT chk_agent_recharge_status CHECK (status IN (1, 2, 3, 4, 5, 6, 7)); +COMMENT ON COLUMN tb_agent_recharge_record.status IS '充值状态:1-待支付 2-已支付 3-已完成 4-已关闭 5-已退款 6-已驳回 7-审批提交失败'; + +COMMENT ON COLUMN tb_commission_withdrawal_request.status IS '状态 1-待审核 2-已通过 3-已拒绝 4-已到账 5-审批提交失败'; + +-- 审批核心在实例进入提交失败时写入 submission_failed 决策投递事实,放宽决策取值约束。 +ALTER TABLE tb_approval_decision_delivery DROP CONSTRAINT IF EXISTS ck_approval_decision_delivery_decision; +ALTER TABLE tb_approval_decision_delivery + ADD CONSTRAINT ck_approval_decision_delivery_decision + CHECK (decision IN ('approved', 'rejected', 'cancelled', 'deleted', 'revoked_after_approved', 'submission_failed')); diff --git a/openspec/changes/fix-approval-submission-failure-terminal/.openspec.yaml b/openspec/changes/fix-approval-submission-failure-terminal/.openspec.yaml new file mode 100644 index 00000000..a3692196 --- /dev/null +++ b/openspec/changes/fix-approval-submission-failure-terminal/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-11 diff --git a/openspec/changes/fix-approval-submission-failure-terminal/design.md b/openspec/changes/fix-approval-submission-failure-terminal/design.md new file mode 100644 index 00000000..9c11f70e --- /dev/null +++ b/openspec/changes/fix-approval-submission-failure-terminal/design.md @@ -0,0 +1,53 @@ +## Context + +承接《第1批-8-9月迭代缺陷修复技术方案》(下称“方案”)第 3、7、8 节。 + +- 提交失败:`approval_submission_consumer.go` 在表单构造失败和企微明确拒绝两处调用 `ApprovalContextRepository.MarkFailed`,其内部 `markSubmissionState` 单事务更新企微上下文与审批实例(7),但不通知业务模块。测试库退款、线下充值、核销、扫码注册均有提交失败实例停在待审批。 +- 决策分发:六个业务类型已通过 `DecisionDispatcher` 注册决策消费者,终态经 `TerminalEventWriter`(`internal/infrastructure/approval/terminal_event.go`)写入公共 Outbox;各消费者 `switch` 的 `default` 分支返回错误。 +- M13:`internal/service/refund/approval_decision.go:227` 关闭分支用 `event.BusinessID` 直接查退款单,而新流程中 `BusinessID` 是审批尝试 ID;通过分支(:48、:92)已用 `refundapproval.ResolveRefundInTx`。 +- 恢复:核销、退款、扫码注册三处恢复入口在“实例为 7 且上下文为提交失败”时走 `RecoverSubmissionEvent` 重放。 +- 状态约束:五张业务表有 `CHECK (status IN ...)`,佣金提现表无约束。 +- 核销列表投影 `ProjectApplication`(`internal/query/employeecollection/application.go:207`)未给审批提交状态字段赋值。 + +## Goals / Non-Goals + +**Goals:** +- 提交失败的业务单进入明确终态并释放占用,可在卡管重新发起。 +- 退款关闭类终态正确结束退款单。 +- 核销列表与详情显示真实提交状态。 + +**Non-Goals:** +- 不改通过、驳回、撤销、通过后撤销的既有处理(M13 只修定位)。 +- 不处理提交结果未知(8),仍由恢复任务确认。 +- 不支持企微侧重新提交挂接回原单。 +- 不新增佣金提现表的状态约束。 + +## Decisions + +1. **复用既有决策通道,新增决策值而不是新事件类型。** `pkg/constants/approval.go` 增加 `ApprovalDecisionSubmissionFailed = "submission_failed"`;`markSubmissionState` 在状态 7 分支、实例更新成功后,同一事务写入决策投递记录与 `TerminalEventWriter.Append` 事件(见决策 9),事件号为 `approval:{实例ID}:submission_failed`。不新建订阅链路,幂等、重试、死信全部沿用现有机制。备选是业务模块轮询“实例 7 且业务单待审批”,延迟高且要六处各写一套扫描,放弃。 +2. **六个消费者同一次交付接入。** `default` 分支返回错误,未接入的业务类型会进入分发失败重试,因此 M8、M14 同批,消费者显式处理新值,不能落入驳回分支。处理前锁审批实例并确认仍为 7。 +3. **提交失败为终态,去掉重放。** 决策异步消费,若业务单变终态前有人点“恢复审批”,实例被重放回提交中,会出现“业务单已终态、企微又生成审批”的分裂。三处恢复入口的 replay 分支一律拒绝;“结果未知”“已有单号同步”分支不变。 +4. **状态值。** 核销 4、退款 7、扫码注册 3、资料资格 4、线下充值 7、佣金提现 5。 +5. **退款状态 7 的门禁与已拒绝(3)一致。** 加入 `RefundResubmittableStatuses()`,不加入 `RefundActiveStatuses()`:既可对原单重新发起,也可为同一订单新建退款单,两张同时生效由 `uk_refund_request_active_order` 兜底。状态 7 与 3、4 同属“钱未动的终态”,且不可恢复,无重复退款风险;状态 6 禁止另建新单是因为渠道退款可能已发起,与 7 不同。2026-10-11 已确认按 3、4 处理。换货校验 `HasUnfinishedByAsset` 不改,新状态自然不阻断换货。 +6. **M13 照通过分支写法。** `ResolveRefundInTx` 本身不加锁,解析后再按 `target.ID` 对退款单 `SELECT ... FOR UPDATE`;其余逻辑不变。重新发起沿用 `CreationService.Resubmit`,已允许状态 3。 +7. **核销投影一次批量读取。** 按 `latest_approval_instance_id` 批量读 `tb_wecom_approval_context`,状态名称映射与脱敏复用退款已有实现(`internal/service/refund/service.go:1467-1520`)。 +8. **迁移只放宽约束。** 一组新的成对迁移:五张表逐表 `DROP CONSTRAINT` 后按新值集合重建、更新列注释;佣金提现只更新列注释。down 恢复原约束(执行前须确认没有新状态数据)。资料资格的 `chk_withdrawal_qualification_invalidated` 与 `WHERE status = 1` 唯一索引不受影响。同一迁移放宽 `tb_approval_decision_delivery.ck_approval_decision_delivery_decision`,允许 `submission_failed`,否则决策 9 写投递记录会违反约束(2026-10-11 实施时发现)。 +9. **写决策时同时建投递记录。** 现有终态决策(`internal/application/approval/sync_decision.go`)在同一事务里调用 `DecisionDeliveryWriter.Create` 与 `TerminalEventWriter.Append`;分发器处理前 `Claim` 投递记录(`internal/infrastructure/approval/decision_delivery.go`,要求更新到 1 行),没有记录时返回未认领,分发器直接结束,业务单永远不变且不报错。因此 `markSubmissionState` 必须两者都写,事件号沿用 `terminalDecisionEventID` 的格式 `approval:{实例ID}:submission_failed`。`ApprovalContextRepository`(`internal/infrastructure/wecom`)需注入这两个写入器,同步修改启动装配。 +10. **只在消费入口放行新决策值。** `TerminalDecisionConsumer.Consume`(`internal/infrastructure/approval/terminal_event.go:69`)用 `approvaldomain.StatusForDecision` 校验决策值,目前不认 `submission_failed`。该函数也被渠道状态推进 `ApplyDecision` 使用,直接加值会让企微回调可以把实例推到提交失败;因此只在 `Consume` 中对 `submission_failed` 单独放行,`StatusForDecision` 不改。 +11. **失败原因由业务模块读取。** `TerminalDecisionEvent` 没有原因字段,不扩展事件结构;各业务消费者处理 `submission_failed` 时按实例读取 `tb_wecom_approval_context.last_error`,脱敏后写入各自的终态原因字段与审计。 + +## Risks / Trade-offs + +- [某个消费者漏接新决策] → 分发失败重试堆积;任务中逐个消费者核对,测试环境对六个业务类型各构造一次提交失败。 +- [漏写投递记录或消费入口未放行] → 事件静默结束或被拒;验证时逐个确认投递记录状态为成功、业务单状态已变化。 +- [测试库已有“实例 7、业务单待审批”存量] → 代码不自动修正;在测试环境只读统计,需要时写一次性补数据脚本,经确认后执行。 +- [佣金提现冻结释放出错影响余额] → 释放与状态更新同事务、条件更新检查 `RowsAffected`,验证时核对钱包余额与流水。 +- [退款状态 7 可同时走重发与新建两条路] → 与已拒绝一致,唯一索引保证至多一张活动申请。 + +## Migration Plan + +测试环境:先执行新的成对迁移(只放宽约束,旧代码照常运行),再部署 API 与 Worker。六个消费者与核心决策同一次部署。 + +## Open Questions + +无。 diff --git a/openspec/changes/fix-approval-submission-failure-terminal/proposal.md b/openspec/changes/fix-approval-submission-failure-terminal/proposal.md new file mode 100644 index 00000000..c10ad1ad --- /dev/null +++ b/openspec/changes/fix-approval-submission-failure-terminal/proposal.md @@ -0,0 +1,33 @@ +## Why + +企微明确拒绝提交审批时,审批核心只把审批实例改为“提交失败”,不通知业务模块,业务单永远停在待审批:核销申请的账单预占、佣金提现的冻结金额一直不释放。另外退款审批在企微被拒绝、撤销、删除时,关闭分支把审批尝试 ID 当成退款单 ID,退款单停在待审批(测试库退款单 511 已复现)。核销申请列表与详情的审批提交状态字段也从未赋值。依据为《第1批-8-9月迭代缺陷修复技术方案》第 3、7、8 节(M8、M13、M14)。 + +## What Changes + +- 审批核心在实例变为“提交失败”的同一事务内,发出新的终态决策 `submission_failed`,带脱敏失败摘要;提交结果未知不发。六个业务决策消费者必须同时接入该决策。 +- 核销申请新增状态 4“审批提交失败”(终态),收到决策后释放全部预占;列表与详情一次批量返回审批提交状态(M8)。 +- 退款新增状态 7“审批提交失败”(终态);扫码注册代理新增 3、提现资料资格新增 4、线下充值新增 7、佣金提现新增 5,各自为终态并释放占用;佣金提现释放冻结金额(M14)。 +- 以上状态均不再提供“恢复审批”;核销、退款、扫码注册代理三处恢复入口对“提交失败”的重放分支改为拒绝。要再办理须在卡管重新发起,生成新的审批尝试与新的企微审批。 +- 退款审批在企微被拒绝、撤销、删除后,按审批尝试正确定位退款单并置为“已拒绝”(终态);企微侧重新提交产生的新单号不关联(M13)。 +- **BREAKING** 上述业务列表、筛选、导出出现新状态值;新增一组成对迁移,放宽五张表的状态 `CHECK` 约束。 + +## Capabilities + +### New Capabilities + +无。 + +### Modified Capabilities + +- `external-integration`:审批提交失败作为终态决策通知业务模块。 +- `employee-collection-bill`:核销申请新增“审批提交失败”终态;列表与详情返回审批提交状态。 +- `order-refund-exchange`:退款新增“审批提交失败”终态;企微关闭类终态按审批尝试定位退款单;企微侧重新提交不关联;恢复入口不再重放提交失败。 +- `agent-distribution-withdrawal`:扫码注册代理、佣金提现、提现资料资格新增“审批提交失败”终态;注册恢复入口不再重放提交失败。 +- `agent-funds-commission`:线下代理充值新增“审批提交失败”终态。 + +## Impact + +- 代码:`pkg/constants/approval.go`、`internal/infrastructure/wecom/approval_context_repository.go`、`internal/infrastructure/approval/terminal_event.go`;六个决策消费者(`internal/application/employeecollection/approval_decision.go`、`internal/service/refund/approval_decision.go`、`internal/application/agentrecharge/approval_decision.go`、`internal/application/distributionwithdrawal/{registration,withdrawal,qualification}_approval.go`);三处恢复入口;各业务状态常量、列表筛选、状态名称、导出格式化;`internal/query/employeecollection/application.go`。 +- 数据库:新增一组成对迁移(编号排在 238 之后),放宽 `tb_employee_collection_application`、`tb_refund_request`、`tb_agent_distribution_registration`、`tb_withdrawal_qualification`、`tb_agent_recharge_record` 的状态约束,更新列注释(含无约束的 `tb_commission_withdrawal_request`);放宽 `tb_approval_decision_delivery` 的决策取值约束,允许 `submission_failed`。 +- 接口:列表筛选与状态说明新增值;重新生成接口文档。 +- 运营:这些单据提交企微失败后需在卡管重新发起,不再“恢复审批”。 diff --git a/openspec/changes/fix-approval-submission-failure-terminal/specs/agent-distribution-withdrawal/spec.md b/openspec/changes/fix-approval-submission-failure-terminal/specs/agent-distribution-withdrawal/spec.md new file mode 100644 index 00000000..b43bf485 --- /dev/null +++ b/openspec/changes/fix-approval-submission-failure-terminal/specs/agent-distribution-withdrawal/spec.md @@ -0,0 +1,50 @@ +## MODIFIED Requirements + +### Requirement: 代理注册原审批可受控恢复 + +系统 SHALL 提供 `POST /api/admin/agent-distribution-registrations/{id}/recover-approval`,允许有权平台账号及申请直接上级代理恢复仍待审批的注册申请。恢复 MUST 校验原审批实例及业务关联、上级店铺启用状态和调用方范围,复用原实例与稳定提交事实;不得新建注册记录、审批实例、企业微信审批单、店铺、账号或钱包。结果未知时 MUST 优先确认原提交,已有审批单号时 MUST 同步原审批;审批实例为提交失败时 MUST 返回“审批提交失败为终态,请重新发起”,MUST NOT 重放原提交事件。终态申请(含审批提交失败)或停用上级 MUST 返回稳定冲突;恢复 MUST 写入脱敏审计。 + +#### Scenario: 提交失败不可恢复 + +- **WHEN** 注册申请的原审批实例已提交失败时请求恢复 +- **THEN** 系统返回“审批提交失败为终态,请重新发起”,不重放提交事件、不新建注册记录或业务实体 + +#### Scenario: 结果未知优先确认 + +- **WHEN** 待审批申请的原提交结果未知 +- **THEN** 系统进入原实例确认流程,不直接创建新的企业微信审批 + +#### Scenario: 终态或停用上级拒绝恢复 + +- **WHEN** 注册申请已终态或其直接上级店铺已停用时请求恢复 +- **THEN** 系统返回稳定冲突且不改变注册、审批或实体事实 + +## ADDED Requirements + +### Requirement: 扫码注册代理审批提交失败 + +审批核心对注册申请的审批实例发出 `submission_failed` 决策时,系统 SHALL 确认审批实例仍为提交失败,将注册记录从待审批(0)条件更新为审批提交失败(3),记录脱敏失败原因并写审计,按事件幂等;MUST NOT 创建店铺、账号、钱包或层级。审批提交失败为终态,MUST NOT 阻止同一手机号、用户名或店铺编号再次提交;客户重新扫码提交形成新的注册记录与新的审批实例。 + +#### Scenario: 注册审批提交失败 +- **WHEN** 待审批注册申请的审批实例提交企微失败 +- **THEN** 注册记录变为审批提交失败,不创建任何业务实体,同一手机号可重新扫码提交 + +### Requirement: 佣金提现审批提交失败 + +审批核心对提现申请最新审批尝试发出 `submission_failed` 决策时,系统 SHALL 确认审批实例仍为提交失败,在同一事务内仅一次释放该次尝试的冻结余额并记录释放时间,将提现申请从待审核条件更新为审批提交失败(5),记录脱敏失败原因并写审计,按事件幂等。审批提交失败的申请 MUST 允许代理修改金额、收款信息和本次发票后重新发起,重新发起时按新金额重新冻结、新增审批尝试与审批实例,资料资格保持有效。 + +#### Scenario: 提现审批提交失败释放冻结 +- **WHEN** 待审核提现申请的审批实例提交企微失败 +- **THEN** 申请变为审批提交失败,冻结金额仅一次释放,钱包余额与流水一致 + +#### Scenario: 提交失败后重新发起 +- **WHEN** 代理对审批提交失败的提现申请修改后重新发起 +- **THEN** 系统按新金额重新冻结并创建新的审批尝试与审批实例,历史尝试不被覆盖 + +### Requirement: 提现资料资格审批提交失败 + +审批核心对资料版本审批实例发出 `submission_failed` 决策时,系统 SHALL 确认审批实例仍为提交失败,将资料版本从等待终审(0)条件更新为审批提交失败(4),记录脱敏失败原因并写审计,按事件幂等。审批提交失败为终态,MUST NOT 阻止代理提交新的资料版本,MUST NOT 影响已通过的有效版本。 + +#### Scenario: 资料资格审批提交失败 +- **WHEN** 等待终审的资料版本审批实例提交企微失败 +- **THEN** 该版本变为审批提交失败,代理可提交新的资料版本,已通过的有效版本不受影响 diff --git a/openspec/changes/fix-approval-submission-failure-terminal/specs/agent-funds-commission/spec.md b/openspec/changes/fix-approval-submission-failure-terminal/specs/agent-funds-commission/spec.md new file mode 100644 index 00000000..57488816 --- /dev/null +++ b/openspec/changes/fix-approval-submission-failure-terminal/specs/agent-funds-commission/spec.md @@ -0,0 +1,13 @@ +## ADDED Requirements + +### Requirement: 线下代理充值审批提交失败 + +审批核心对线下代理充值申请的审批实例发出 `submission_failed` 决策时,系统 SHALL 确认审批实例仍为提交失败,将充值记录从待审批条件更新为审批提交失败(7),记录脱敏失败原因并写审计,按事件幂等;MUST NOT 入账或改变代理钱包余额。审批提交失败为终态,代理充值列表、筛选、状态名称与导出 MUST 显示该状态;要再充值由申请人重新发起新的充值申请。 + +#### Scenario: 线下充值审批提交失败 +- **WHEN** 待审批线下代理充值申请的审批实例提交企微失败 +- **THEN** 充值记录变为审批提交失败,钱包余额不变,列表与筛选可见该状态 + +#### Scenario: 重复投递 +- **WHEN** 同一 `submission_failed` 决策被重复投递 +- **THEN** 充值记录状态与审计只生效一次 diff --git a/openspec/changes/fix-approval-submission-failure-terminal/specs/employee-collection-bill/spec.md b/openspec/changes/fix-approval-submission-failure-terminal/specs/employee-collection-bill/spec.md new file mode 100644 index 00000000..36e94b6b --- /dev/null +++ b/openspec/changes/fix-approval-submission-failure-terminal/specs/employee-collection-bill/spec.md @@ -0,0 +1,46 @@ +## MODIFIED Requirements + +### Requirement: 核销申请审批、重提与幂等 +核销申请状态 SHALL 为 `审批中`、`已通过`、`已驳回`、`已撤销/已关闭`、`审批提交失败`,且不得以申请状态覆盖账单核销状态。提交或重提时系统 MUST 新增一条不可变审批尝试记录(冻结当次收款方式、外部付款、附件、备注、账单分摊及审批材料快照)并为其创建新的企业微信审批实例;申请只保存最新审批实例 ID 用于展示。企业微信业务类型 MUST 为 `employee_collection_approval`,其业务标识 MUST 取审批尝试记录主键,使同一申请的多次提交各自持有独立审批实例,且 MUST NOT 修改既有审批实例的共享唯一约束或既有 `refund_approval`、`offline_recharge_approval` 场景语义。 + +企业微信最终通过时,系统 MUST 幂等地将申请标记为已通过、将各分摊写入账单已核销金额并释放其预占;最终驳回时 MUST 标记申请已驳回、释放全部预占且保留审批意见。已驳回或审批提交失败的申请可修改全部申请内容后重提,重提生成新的审批尝试与新的企业微信审批,历史审批实例、材料和结果不得覆盖;已通过分摊不可修改。 + +审批核心对最新审批尝试发出 `submission_failed` 决策时,系统 MUST 确认审批实例仍为提交失败,并在同一事务内释放该次尝试的全部预占分摊与账单预占、将申请从审批中条件更新为审批提交失败、以脱敏失败摘要写入终态原因并写审计;非最新尝试或申请已不是审批中时幂等结束。审批提交失败为终态,MUST NOT 再通过“恢复审批”重放原提交,恢复入口对该情形 MUST 返回“审批提交失败为终态,请重新发起”,`recoverable` MUST 返回 false。回调延迟或提交结果未知时申请保持在途,系统 MUST 使用既有查询/恢复机制确认渠道结果,且不得由本地人工通过或拒绝绕过企业微信。企业微信对已通过申请撤销时,系统 MUST NOT 回滚已核销金额,MUST 将申请转为异常终态、保留审计与账单详情提示,并禁止自动重提。同一撤销结果在本地申请仍为审批中时(回调乱序或通过结果未被消费),系统 MUST 释放该次审批尝试的全部审批中预占、将申请转为异常终态并记录终态原因、保留审计,MUST NOT 自动重提。 + +#### Scenario: 企业微信通过核销申请 +- **WHEN** 企业微信对含多笔分摊的核销申请返回最终通过,且该结果首次被消费 +- **THEN** 系统仅一次更新申请、各账单已核销金额和状态,并保留审批实例及冻结快照 + +#### Scenario: 企业微信驳回后重提 +- **WHEN** 企业微信最终驳回核销申请 +- **THEN** 系统释放预占、保留驳回实例和意见;员工或有代办权限的超级管理员修改申请后重提时新增审批尝试记录并创建新的审批实例,历史实例与材料不被覆盖 + +#### Scenario: 企业微信通过后撤销 +- **WHEN** 已通过的核销申请收到企业微信撤销结果 +- **THEN** 系统不回滚已核销金额,将申请标记为异常终态、保留审计并在账单详情提示,且不允许自动重提 + +#### Scenario: 审批中收到通过后撤销 +- **WHEN** 企业微信对仍处于审批中的核销申请返回“通过后撤销”(回调乱序或本地未消费到通过结果) +- **THEN** 系统释放该申请全部审批中预占、将申请转为异常终态并记录终态原因、保留审计,且不回滚任何已核销金额;该申请禁止自动重提,账单可重新发起新的核销申请 + +#### Scenario: 审批提交失败释放预占 +- **WHEN** 核销申请的最新审批实例因缺必填控件映射或企微明确拒绝而提交失败 +- **THEN** 申请变为审批提交失败、全部预占释放、账单可再次核销、审计写入,且对该申请“恢复审批”被拒 + +#### Scenario: 审批提交失败后重提 +- **WHEN** 员工修改审批提交失败的申请后重提 +- **THEN** 系统新增审批尝试记录并创建新的企业微信审批,申请回到审批中 + +#### Scenario: 提交结果未知保持在途 +- **WHEN** 核销申请的审批实例提交结果未知 +- **THEN** 申请保持审批中,预占不释放 + +## ADDED Requirements + +### Requirement: 核销申请返回审批提交状态 + +核销申请列表与详情 SHALL 返回审批提交状态、状态名称、脱敏失败摘要、可恢复标记、最近一次恢复结果与时间,按申请的最新审批实例一次批量读取企微审批上下文,MUST NOT 逐行查询;状态名称映射与失败摘要脱敏 MUST 与退款审批投影一致。无审批上下文的申请 MUST 返回默认值,MUST NOT 报错或丢行。 + +#### Scenario: 列表返回提交状态 +- **WHEN** 查询一页核销申请,其中部分有审批上下文、部分没有 +- **THEN** 有上下文的申请返回与测试库企微审批上下文一致的提交状态,没有的返回默认值,整页无逐行查询 diff --git a/openspec/changes/fix-approval-submission-failure-terminal/specs/external-integration/spec.md b/openspec/changes/fix-approval-submission-failure-terminal/specs/external-integration/spec.md new file mode 100644 index 00000000..3c040cd8 --- /dev/null +++ b/openspec/changes/fix-approval-submission-failure-terminal/specs/external-integration/spec.md @@ -0,0 +1,29 @@ +## ADDED Requirements + +### Requirement: 审批提交失败通知业务模块 + +审批实例因企微明确拒绝提交或审批表单无法构造(如缺必填控件映射)而变为“提交失败”(7)时,系统 SHALL 在同一事务内经既有终态决策通道发出决策 `submission_failed`:MUST 同时写入标准决策 Outbox 事件与决策投递记录(与其他终态决策相同,缺投递记录时业务消费者不会处理);事件标识 MUST 沿用既有格式 `approval:{审批实例标识}:submission_failed`,重复写入不产生第二个事件。决策事件结构不新增字段,失败原因由业务模块从该实例的企微审批上下文读取并脱敏后保存。决策消费入口 MUST 接受 `submission_failed`;渠道回调与轮询的审批状态推进 MUST NOT 因此接受该决策值。提交结果未知(8)MUST NOT 发出该决策,仍由既有恢复机制确认。所有已注册的审批业务类型(核销、退款、扫码注册代理、线下充值、佣金提现、提现资料资格)MUST 显式处理该决策,MUST NOT 将其按驳回处理;各业务处理前 MUST 确认审批实例仍为提交失败,否则幂等结束。 + +#### Scenario: 企微明确拒绝提交 +- **WHEN** 某业务单的审批实例提交企微被明确拒绝 +- **THEN** 审批实例变为提交失败,同一事务写入一条 `submission_failed` 决策事件,对应业务模块随后将业务单置为“审批提交失败” + +#### Scenario: 审批表单构造失败 +- **WHEN** 某审批实例因缺少必填控件映射无法构造表单 +- **THEN** 系统按提交失败处理并发出 `submission_failed` 决策 + +#### Scenario: 提交结果未知不发决策 +- **WHEN** 审批实例提交结果未知 +- **THEN** 系统不发出 `submission_failed` 决策,业务单保持审批中 + +#### Scenario: 业务模块确实收到决策 +- **WHEN** 审批实例变为提交失败并写入决策事件 +- **THEN** 该事件存在对应的决策投递记录,业务消费者能认领并处理,业务单状态发生变化,而不是被当作已处理直接结束 + +#### Scenario: 渠道回调不能推进到提交失败 +- **WHEN** 企微回调或轮询带来的决策值为 `submission_failed` +- **THEN** 审批状态推进按不受支持的决策拒绝,审批实例状态不变 + +#### Scenario: 决策重复投递 +- **WHEN** 同一 `submission_failed` 决策被重复投递 +- **THEN** 业务单状态、释放的占用与审计均只生效一次 diff --git a/openspec/changes/fix-approval-submission-failure-terminal/specs/order-refund-exchange/spec.md b/openspec/changes/fix-approval-submission-failure-terminal/specs/order-refund-exchange/spec.md new file mode 100644 index 00000000..5d8d3153 --- /dev/null +++ b/openspec/changes/fix-approval-submission-failure-terminal/specs/order-refund-exchange/spec.md @@ -0,0 +1,97 @@ +## MODIFIED Requirements + +### Requirement: 订单、退款与换货状态门禁 + +系统 SHALL 仅允许待支付订单取消;退款申请按待审批、已通过、已拒绝、已退回、原路退款处理中、原路退款失败、审批提交失败流转,仅已拒绝、已退回、原路退款失败和审批提交失败申请可修改并重新提交;换货按待填写信息、待发货、已发货、已完成或已取消流转,并拒绝与当前状态或流程类型不匹配的操作。处于待审批、原路退款处理中或原路退款失败的退款申请 MUST 阻止同一订单创建新的退款申请,且 MUST NOT 被再次推进为新的审批实例或第二次渠道退款。已拒绝与审批提交失败的退款申请为终态,MUST NOT 阻止同一订单创建新的退款申请,也 MUST NOT 阻止该资产换货。 + +#### Scenario: 重复推进终态 + +- **GIVEN** 退款或换货已进入不允许当前操作的状态 +- **WHEN** 再次审批、发货、完成、取消或重新提交 +- **THEN** 系统返回状态冲突且不重复改变资产、余额或业务状态 + +#### Scenario: 原路退款失败后重提 + +- **WHEN** 退款申请处于原路退款失败状态且调用方在其数据范围内修改材料后重新提交 +- **THEN** 系统新增一条审批尝试记录与一个新的企业微信审批实例并使申请回到待审批,历史尝试与已提交的渠道退款不被覆盖 + +#### Scenario: 企业微信通过后撤销的申请被再次推进 + +- **GIVEN** 退款申请已记录企业微信通过后撤销的审批异常 +- **WHEN** 调用者再次提交或重提该申请 +- **THEN** 系统返回状态冲突,不恢复套餐权益、不取消已提交的渠道退款且不自动重提 + +#### Scenario: 审批提交失败的退款不阻断换货 + +- **GIVEN** 资产的退款申请处于审批提交失败 +- **WHEN** 调用方为该资产发起换货 +- **THEN** 系统不因该退款申请拒绝换货 + +### Requirement: 企业微信唯一终审与审批尝试重提 + +退款申请 SHALL 保存退款原因、冻结实收金额、唯一关联套餐及其使用情况、方式、金额和当次材料快照。超级管理员、平台用户和代理可在各自订单数据范围内创建、修改并重提未成功申请;企业微信是唯一终审,本地 MUST NOT 人工通过、拒绝或退回已关联审批实例的申请。每次创建或重提 MUST 新增一条不可变的审批尝试记录,并以该尝试记录作为通用审批业务标识;同一申请每次提交各自持有独立审批实例,历史尝试材料与审批结果不被覆盖。退款单 SHALL 仅保存最新尝试与最新审批实例引用用于展示。 + +同一订单同时至多存在一张待审批、原路退款处理中或原路退款失败的退款申请。企业微信驳回、撤销或删除待审批申请后,系统 MUST 将退款申请置为已拒绝(终态),拒绝原因分别为“企业微信审批已拒绝”“企业微信审批已撤销”“企业微信审批已删除”,并写审计;要再退款须在卡管对该申请重新发起,生成新的审批尝试与新的企业微信审批。企业微信侧对已驳回或已撤销审批重新提交产生的新审批单号,系统 MUST NOT 关联到任何退款申请,只按既有“未关联本系统业务,已忽略”留痕。原路退款明确失败后可修改未成功申请的金额、原因、方式、收款信息和附件并重提;每次重提必须新建审批实例及快照。 + +审批核心对最新审批实例发出 `submission_failed` 决策时,系统 MUST 确认审批实例仍为提交失败,将退款申请从待审批条件更新为审批提交失败(终态),记录脱敏失败原因并写审计,按事件幂等;该申请 MUST 可在卡管重新发起,MUST NOT 再通过恢复入口重放原提交。审批结果未知时申请保持在途,使用既有查询与恢复闭环,不得另建或重提。企业微信通过后撤销时不回滚套餐失效或已启动退款,标记审批异常并禁止自动重提。 + +审批终态(含通过、驳回、撤销、删除、通过后撤销与提交失败)MUST 以审批实例标识判别业务归属:先按审批尝试记录标识与审批实例匹配,未命中时按退款申请标识与审批实例匹配以兼容尚未切换到尝试模式的存量申请,两者均不匹配时返回稳定冲突错误,不得回落到任一候选业务单。定位后 MUST 对退款申请加行锁再推进状态。 + +#### Scenario: 审批通过前结果未知 + +- **WHEN** 企业微信提交成功性或最终结果暂时未知 +- **THEN** 申请保持在途且订单不得创建第二张活动申请,系统通过既有恢复机制确认结果 + +#### Scenario: 未成功申请重提新建实例 + +- **GIVEN** 退款申请处于已拒绝、已退回、原路退款失败或审批提交失败状态且无审批异常 +- **WHEN** 调用者修改材料后重新提交 +- **THEN** 系统在同一事务新增审批尝试记录并创建新的企业微信审批实例,原尝试记录与历史审批结果保持不变 + +#### Scenario: 尝试模式申请被企业微信撤销 + +- **GIVEN** 退款申请以审批尝试记录作为审批业务标识,且处于待审批 +- **WHEN** 企业微信对该审批返回撤销、拒绝或删除 +- **THEN** 系统按审批尝试定位到该退款申请,将其置为已拒绝并记录对应原因与审计,该资产随后可换货 + +#### Scenario: 企业微信侧重新提交不关联 + +- **WHEN** 企业微信回调一个卡管未创建的审批单号(企微侧对已驳回或已撤销审批的重新提交) +- **THEN** 系统不关联任何退款申请,只留痕“未关联本系统业务,已忽略” + +#### Scenario: 审批提交失败 + +- **WHEN** 待审批退款申请的最新审批实例提交企微失败 +- **THEN** 退款申请变为审批提交失败并写审计,列表、筛选、导出显示该状态;重复投递结果不变 + +#### Scenario: 存量申请审批终态仍被消费 + +- **GIVEN** 退款申请由本能力上线前创建,其审批实例的业务标识指向退款申请本身 +- **WHEN** 该审批实例到达通过或关闭终态 +- **THEN** 系统按审批实例标识匹配到该退款申请并幂等推进,不按尝试模式误判为不存在 + +#### Scenario: 已关联实例的本地人工终审 + +- **GIVEN** 退款申请已关联审批实例 +- **WHEN** 后台账号调用本地通过、拒绝或退回入口 +- **THEN** 系统拒绝该操作且不改变退款申请与订单状态 + +#### Scenario: 存量无实例待审批申请仍可终结 + +- **GIVEN** 退款申请未关联任何审批实例且处于待审批状态 +- **WHEN** 后台账号在既有开关开放时调用本地通过、拒绝或退回入口 +- **THEN** 系统按既有语义终结该申请,不要求该申请先接入企业微信审批 + +### Requirement: 退款原审批实例可受控恢复 + +系统 SHALL 提供 `POST /api/admin/refunds/{id}/recover-approval`,允许既有退款管理范围内的超级管理员、平台用户和代理恢复仍处于待审批的退款最新审批实例;企业账号拒绝,越权与不存在不可区分。恢复 MUST 复用原审批实例及 `approval:{instance_id}:submission` 稳定提交事实,不得新增审批尝试/实例、修改冻结退款材料或触发退款、订单、套餐与资金动作。已有 `sp_no` 只查询同步,结果未知只确认;审批实例为提交失败时 MUST 返回“审批提交失败为终态,请重新发起”,MUST NOT 重放原提交事件。审批终态、审批提交失败、原路退款处理中或渠道退款失败 MUST 返回状态冲突。审批提交失败的退款申请 `recoverable` MUST 为 false。历史快照缺失新增键时,只能从关联不可变审批尝试补齐缺失值并写脱敏审计。 + +#### Scenario: 恢复入口只处理原审批事实 + +- **WHEN** 授权账号请求恢复待审批退款 +- **THEN** 系统按最新尝试与实例关联选择恢复分支,列表/详情返回提交状态、可恢复标记、安全失败摘要和最近恢复时间,重复或并发请求至多一次改变提交事实。 + +#### Scenario: 提交失败不可恢复 + +- **WHEN** 授权账号对审批实例已提交失败的退款申请请求恢复 +- **THEN** 系统返回“审批提交失败为终态,请重新发起”,不重放提交事件、不改变审批实例 diff --git a/openspec/changes/fix-approval-submission-failure-terminal/tasks.md b/openspec/changes/fix-approval-submission-failure-terminal/tasks.md new file mode 100644 index 00000000..f790b651 --- /dev/null +++ b/openspec/changes/fix-approval-submission-failure-terminal/tasks.md @@ -0,0 +1,43 @@ +## 1. 迁移与常量 + +- [x] 1.1 新增成对迁移(编号排在 238 之后):放宽核销 `IN (0..4)`、退款 `IN (1..7)`、扫码注册 `IN (0..3)`、资料资格 `IN (0..4)`、线下充值 `IN (1..7)` 的状态约束并更新列注释;佣金提现只更新列注释;同时放宽 `tb_approval_decision_delivery` 决策取值约束允许 `submission_failed`;down 恢复原约束。 +- [x] 1.2 新增状态常量与中文名“审批提交失败”:核销 4、退款 7、扫码注册 3、资料资格 4、线下充值 7、佣金提现 5,同步模型注释与状态名称函数。 +- [x] 1.3 `pkg/constants/approval.go` 新增 `ApprovalDecisionSubmissionFailed = "submission_failed"`。 + +## 2. 审批核心发出提交失败决策 + +- [x] 2.1 `markSubmissionState` 在状态 7 分支、实例更新成功后,同一事务内依次调用 `DecisionDeliveryWriter.Create` 与 `TerminalEventWriter.Append` 写 `submission_failed`(事件号 `approval:{实例ID}:submission_failed`,事件结构不加字段);状态 8 不发。`ApprovalContextRepository` 注入两个写入器并修改启动装配。 +- [x] 2.2 核对 `approval_submission_consumer.go` 表单构造失败与企微明确拒绝两处均经该分支发出决策。 +- [x] 2.3 `TerminalDecisionConsumer.Consume` 对 `submission_failed` 单独放行,`StatusForDecision` 与 `ApplyDecision` 不改,渠道回调仍拒绝该值。 +- [x] 2.4 六个消费者处理 `submission_failed` 时按实例读取企微审批上下文 `last_error`,脱敏后写入终态原因与审计。 + +## 3. M13 退款关闭类终态定位 + +- [x] 3.1 `internal/service/refund/approval_decision.go` 的 `applyClosedDecision` 改为 `ResolveRefundInTx` 定位,再按 `target.ID` 对退款单加行锁;其余逻辑不变。 + +## 4. 六个业务消费者接入(同一次交付) + +- [x] 4.1 核销:照 `applyClosed` 实现 `applySubmitFailed`(确认实例为 7、只处理最新尝试、释放全部预占、0→4 条件更新、终态原因、审计);`ValidateApplicationResubmit` 允许状态 4 重提。 +- [x] 4.2 退款:新增提交失败分支,用 `ResolveRefundInTx` 定位,待审批→7 条件更新、记录原因、审计;状态 7 加入 `RefundResubmittableStatuses()`,不加入 `RefundActiveStatuses()`。 +- [x] 4.3 扫码注册代理:0→3,不创建实体,审计。 +- [x] 4.4 线下充值:待审批→7,不入账,审计。 +- [x] 4.5 佣金提现:同事务释放冻结并记录释放时间,待审核→5,审计;`Resubmit` 扩展为允许状态 5,按新金额重新冻结。 +- [x] 4.6 提现资料资格:0→4,审计;确认可提交新资料版本、不影响已通过版本。 + +## 5. 恢复入口与投影 + +- [x] 5.1 核销、退款、扫码注册三处恢复入口的提交失败 replay 分支改为拒绝“审批提交失败为终态,请重新发起”;`recoverable` 对新状态返回 false。 +- [x] 5.2 核销列表与详情按最新审批实例批量读取企微上下文,填充提交状态、名称、脱敏摘要、可恢复、最近恢复结果与时间。 +- [x] 5.3 六个业务的列表筛选、接口字段说明、导出状态格式化(含 `internal/exporter/refund_scene.go`)加入新状态;实现时 `grep` 各状态常量逐处核对。 + +## 6. 文档与测试环境验证 + +- [x] 6.1 `gofmt`、`go build ./cmd/api ./cmd/worker`、`go run cmd/gendocs/main.go`。 +- [x] 6.2 在 `junhong_cmp_test` 执行新迁移(up、down、再 up)并确认旧数据不受影响。 +- [ ] 6.3 按 ENG-TEST-001 用缺必填控件映射对六个业务类型各构造一次提交失败:业务单变为“审批提交失败”、占用释放(核销预占、提现冻结与钱包流水)、审计写入、列表筛选导出可见;恢复被拒;重新发起生成新尝试与新审批;同一事件重复投递结果不变。 +- [ ] 6.4 构造提交结果未知的实例,业务单保持审批中。 +- [ ] 6.5 核对每个提交失败事件都有决策投递记录且最终为成功;模拟渠道回调带 `submission_failed` 被拒、实例状态不变。 +- [ ] 6.6 M13:对尝试模式退款分别用模拟企微撤销、拒绝、删除终态事件触发,退款单变为已拒绝、原因与审计正确、资产可换货;对其重新发起生成新审批;模拟卡管未知的企微单号回调只留痕;“通过”“通过后撤销”与早期 `business_id = 退款单 ID` 的数据回归不变。 +- [ ] 6.7 核销列表与详情提交状态与测试库一致,无上下文申请正常返回,无逐行查询。 +- [ ] 6.8 只读统计测试库“实例 7 或已终态、业务单仍审批中”的存量,需要时写一次性补数据脚本,经确认后执行。 +- [ ] 6.9 运行 `openspec validate --all` 与 `./scripts/context-health.sh`。 diff --git a/pkg/constants/agent_distribution.go b/pkg/constants/agent_distribution.go index 6e2e0f23..cadc899e 100644 --- a/pkg/constants/agent_distribution.go +++ b/pkg/constants/agent_distribution.go @@ -7,6 +7,8 @@ const ( AgentDistributionRegistrationStatusApproved = 1 // AgentDistributionRegistrationStatusRejected 表示注册记录已驳回,未创建任何实体。 AgentDistributionRegistrationStatusRejected = 2 + // AgentDistributionRegistrationStatusSubmissionFailed 表示提交企业微信审批被明确拒绝,注册终止且未创建任何实体。 + AgentDistributionRegistrationStatusSubmissionFailed = 3 ) const ( @@ -23,6 +25,8 @@ func GetAgentDistributionRegistrationStatusName(status int) string { return "已通过" case AgentDistributionRegistrationStatusRejected: return "已驳回" + case AgentDistributionRegistrationStatusSubmissionFailed: + return ApprovalSubmissionFailedStatusName default: return "未知" } diff --git a/pkg/constants/approval.go b/pkg/constants/approval.go index 13dd32bc..bc7d722b 100644 --- a/pkg/constants/approval.go +++ b/pkg/constants/approval.go @@ -228,6 +228,15 @@ const ( ApprovalDecisionDeleted = "deleted" // ApprovalDecisionRevokedAfterApproved 表示标准审批通过后撤销决策。 ApprovalDecisionRevokedAfterApproved = "revoked_after_approved" + // ApprovalDecisionSubmissionFailed 表示审批提交被企业微信明确拒绝(实例进入提交失败),由审批核心在同一事务内发出。 + // 该值只在终态决策消费入口放行,渠道回调状态推进不接受此值。 + ApprovalDecisionSubmissionFailed = "submission_failed" + // ApprovalSubmissionFailedStatusName 各业务“审批提交失败”终态的统一中文名称。 + ApprovalSubmissionFailedStatusName = "审批提交失败" + // ApprovalSubmissionFailedReason 提交失败对外展示的脱敏原因,不透传企微原文。 + ApprovalSubmissionFailedReason = "审批提交失败,请检查审批模板与渠道状态" + // ApprovalSubmissionFailedNotRecoverableMessage 恢复入口拒绝重放提交失败审批时的提示。 + ApprovalSubmissionFailedNotRecoverableMessage = "审批提交失败为终态,请重新发起" ) const ( @@ -285,6 +294,8 @@ const ( ApprovalSyncSourcePolling = "polling" // ApprovalSyncSourceManual 表示由受控人工动作触发权威同步。 ApprovalSyncSourceManual = "manual" + // ApprovalSyncSourceSubmission 表示由审批提交 Worker 在提交被明确拒绝时发出决策。 + ApprovalSyncSourceSubmission = "submission" ) // GetApprovalStatusName 返回通用审批状态的中文名称。 diff --git a/pkg/constants/employee_collection.go b/pkg/constants/employee_collection.go index 31fd322b..4679d072 100644 --- a/pkg/constants/employee_collection.go +++ b/pkg/constants/employee_collection.go @@ -31,6 +31,8 @@ const ( EmployeeCollectionApplicationStatusRejected = 2 // EmployeeCollectionApplicationStatusRevoked 表示核销申请通过后撤销等异常终态或已关闭。 EmployeeCollectionApplicationStatusRevoked = 3 + // EmployeeCollectionApplicationStatusSubmissionFailed 表示提交企业微信审批被明确拒绝,申请终止并释放预占。 + EmployeeCollectionApplicationStatusSubmissionFailed = 4 ) // 核销分摊状态,取值与 tb_employee_collection_application_allocation.status 检查约束一致。 @@ -118,6 +120,8 @@ func GetEmployeeCollectionApplicationStatusName(status int) string { return "已驳回" case EmployeeCollectionApplicationStatusRevoked: return "已撤销或已关闭" + case EmployeeCollectionApplicationStatusSubmissionFailed: + return ApprovalSubmissionFailedStatusName default: return "未知状态" } diff --git a/pkg/constants/iot.go b/pkg/constants/iot.go index 862b1576..37d14f8e 100644 --- a/pkg/constants/iot.go +++ b/pkg/constants/iot.go @@ -248,6 +248,8 @@ const ( WithdrawalStatusApproved = 2 // 已通过 WithdrawalStatusRejected = 3 // 已拒绝 WithdrawalStatusPaid = 4 // 已到账 + // WithdrawalStatusSubmissionFailed 提交企业微信审批被明确拒绝,提现终止并释放冻结金额。 + WithdrawalStatusSubmissionFailed = 5 ) // 提现方式 @@ -416,7 +418,7 @@ func GetOrderCommissionResultName(result int) string { } // GetRefundStatusName 获取退款申请状态名称 -// 1=待审批, 2=已通过, 3=已拒绝, 4=已退回, 5=原路退款处理中, 6=原路退款失败 +// 1=待审批, 2=已通过, 3=已拒绝, 4=已退回, 5=原路退款处理中, 6=原路退款失败, 7=审批提交失败 func GetRefundStatusName(status int) string { switch status { case 1: @@ -431,13 +433,15 @@ func GetRefundStatusName(status int) string { return "原路退款处理中" case 6: return "原路退款失败" + case 7: + return ApprovalSubmissionFailedStatusName default: return "未知" } } // GetWithdrawalStatusName 获取提现申请状态名称 -// 对应 WithdrawalStatus* 常量:1=待审核, 2=已通过, 3=已拒绝, 4=已到账 +// 对应 WithdrawalStatus* 常量:1=待审核, 2=已通过, 3=已拒绝, 4=已到账, 5=审批提交失败 func GetWithdrawalStatusName(status int) string { switch status { case WithdrawalStatusPending: @@ -448,6 +452,8 @@ func GetWithdrawalStatusName(status int) string { return "已拒绝" case WithdrawalStatusPaid: return "已到账" + case WithdrawalStatusSubmissionFailed: + return ApprovalSubmissionFailedStatusName default: return "未知" } diff --git a/pkg/constants/wallet.go b/pkg/constants/wallet.go index 92ff0900..c6110f3a 100644 --- a/pkg/constants/wallet.go +++ b/pkg/constants/wallet.go @@ -136,6 +136,8 @@ const ( RechargeStatusClosed = 4 // 已关闭 RechargeStatusRefunded = 5 // 已退款 RechargeStatusRejected = 6 // 已驳回 + // RechargeStatusSubmissionFailed 线下代理充值提交企业微信审批被明确拒绝,充值终止且不入账。 + RechargeStatusSubmissionFailed = 7 ) // 充值支付方式 @@ -161,7 +163,7 @@ const ( // ========== Redis Key 生成函数 ========== // GetRechargeStatusName 获取充值状态名称 -// 对应 RechargeStatus* 常量:1=待支付, 2=已支付, 3=已完成, 4=已关闭, 5=已退款, 6=已驳回 +// 对应 RechargeStatus* 常量:1=待支付, 2=已支付, 3=已完成, 4=已关闭, 5=已退款, 6=已驳回, 7=审批提交失败 func GetRechargeStatusName(status int) string { switch status { case RechargeStatusPending: @@ -176,6 +178,8 @@ func GetRechargeStatusName(status int) string { return "已退款" case RechargeStatusRejected: return "已驳回" + case RechargeStatusSubmissionFailed: + return ApprovalSubmissionFailedStatusName default: return "未知" } diff --git a/pkg/constants/withdrawal_qualification.go b/pkg/constants/withdrawal_qualification.go index 5d4a9900..dc211d31 100644 --- a/pkg/constants/withdrawal_qualification.go +++ b/pkg/constants/withdrawal_qualification.go @@ -16,6 +16,8 @@ const ( WithdrawalQualificationStatusRejected = 2 // WithdrawalQualificationStatusInvalidated 表示资料版本已失效或已被超级管理员作废。 WithdrawalQualificationStatusInvalidated = 3 + // WithdrawalQualificationStatusSubmissionFailed 表示资料版本提交企业微信审批被明确拒绝,该版本终止。 + WithdrawalQualificationStatusSubmissionFailed = 4 ) // WithdrawalQualificationCheckPassed 表示本次提现资料资格校验通过。 @@ -66,6 +68,8 @@ func GetWithdrawalQualificationStatusName(status int) string { return "已驳回" case WithdrawalQualificationStatusInvalidated: return "已失效" + case WithdrawalQualificationStatusSubmissionFailed: + return ApprovalSubmissionFailedStatusName default: return "未知" }