Files
junhong_cmp_fiber/internal/application/packagesilence/extension_submit.go
邹进 909f4d0ec2
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 8m39s
feat: 实名即生效套餐沉默期、延期办理与资产出库时间
- 迁移 000259~000265:套餐记录沉默期字段与扫描索引、套餐默认沉默期、延期政策/流水/订单/批次/明细 5 张表、默认政策与系统配置(总开关默认关闭)、企微审批白名单、卡与设备出库时间及出库记录
- 沉默期设置(订单发放、自动购包、钱包续费)、后台购买时调整(超默认值按政策免审或企微审批)、统一判定与激活闸门、排队与孤儿扫描跳过过期套餐、过期套餐禁止退款、每 10 分钟扫描
- 总开关专用接口与打开前 4 项检查;延期政策;延期资格与计费;H5 自助与后台二维码在线支付(商户池路由)、支付回调与查单恢复、线下登记、特殊/批量延期、异常重试与原路退款;沉默期审批场景与终态处理
- 换货沉默期流水与出库时间;卡/设备列表、详情、导出与出库记录接口;延期订单与即将过期套餐列表及导出;财务流水纳入延期支付
- 沉默期与出库时间回填工具 cmd/silence-backfill;前端接口变更说明

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:32:58 +09:00

549 lines
25 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package packagesilence
import (
"context"
"fmt"
"slices"
"strings"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
packagedomain "github.com/break/junhong_cmp_fiber/internal/domain/package"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// RegisterOffline 登记线下付费延期(技术方案 §8.4a):实收金额必须等于计算金额,同一收款方式下交易单号唯一;
// 政策快照未勾选 offline_paid 时提交企微审批,勾选时登记即执行。
func (s *ExtensionService) RegisterOffline(ctx context.Context, req dto.SilenceOfflineOrderRequest) (*dto.SilenceExtensionOrderResponse, error) {
if err := s.requireEnabled(ctx); err != nil {
return nil, err
}
if err := s.requirePermission(ctx, constants.PermissionPackageSilenceOffline); err != nil {
return nil, err
}
operatorID := middleware.GetUserIDFromContext(ctx)
requestKey := scopedRequestKey(constants.SilenceOrderSourceOffline, 0, &operatorID, req.RequestID)
if existing, err := postgres.NewPackageSilenceOrderStore(s.db).FindByRequestID(ctx, requestKey); err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询延期订单幂等记录失败")
} else if existing != nil {
return s.orderResponse(ctx, existing, false)
}
tradeNo := strings.TrimSpace(req.OfflineTradeNo)
if tradeNo == "" {
return nil, errors.New(errors.CodeInvalidParam, "线下交易单号不能为空")
}
if len(req.PaymentVoucherKeys) == 0 || len(req.PaymentVoucherKeys) > constants.SilenceExtensionVoucherMaxCount {
return nil, errors.New(errors.CodeInvalidParam, "线下付费延期必须上传 1 至 5 个收款凭证")
}
usage, err := s.loadUsage(ctx, s.db, req.PackageUsageID, Scope{})
if err != nil {
return nil, err
}
if _, err := s.resolver.Resolve(ctx, usage.ID, ResolveOptions{Trigger: constants.SilenceTriggerEligibilityRead, AllowExpire: true}); err != nil {
return nil, err
}
if replaced, err := s.settleOpenOrder(ctx, usage.ID); err != nil {
return nil, err
} else if replaced != nil {
return nil, errors.New(errors.CodeSilenceExtensionOrderPending, "该套餐已有已支付的在线延期订单,不能再登记线下付款")
}
if usage, err = s.loadUsage(ctx, s.db, req.PackageUsageID, Scope{}); err != nil {
return nil, err
}
now := s.now()
facts, err := s.computeEligibility(ctx, s.db, usage, now)
if err != nil {
return nil, err
}
if !facts.canPay() {
return nil, facts.paidError()
}
quote := packagedomain.QuoteSilenceExtension(packagedomain.SilenceQuoteInput{
DeadlineAt: *usage.SilenceDeadlineAt, Now: now, InputDays: req.Days, Policy: facts.match.Snapshot,
PaidExtendedDays: usage.SilencePaidExtendedDays, Kind: packagedomain.SilenceQuotePaid,
})
if !quote.InputValid {
return nil, errors.NewWithData(errors.CodeSilenceExtensionMonthsInvalid, map[string]any{"min_days": quote.MinDays, "max_days": quote.MaxDays})
}
if req.PaidAmount != quote.Amount {
return nil, errors.NewWithData(errors.CodeSilenceOfflineAmountMismatch, map[string]any{"expected_amount": quote.Amount})
}
method, err := loadOfflinePaymentMethod(ctx, s.db, req.OfflinePaymentMethodID)
if err != nil {
return nil, err
}
if err := ensureTradeNoUnused(ctx, s.db, method.ID, tradeNo); err != nil {
return nil, err
}
snapshot := facts.match.Snapshot
approvalRequired := !packagedomain.IsSilenceApprovalExempt(snapshot.ApprovalExemptTypes, constants.SilenceExtensionTypeOfflinePaid)
var preparation approvalapp.Preparation
var submitter approvalSubmitter
if approvalRequired {
if submitter, err = loadSubmitter(ctx, s.db, operatorID); err != nil {
return nil, err
}
if preparation, err = prepareApproval(ctx, s.approval, operatorID, "silence-offline-"+requestKey[:16]); err != nil {
return nil, err
}
}
identifier, err := assetIdentifier(ctx, s.db, usage)
if err != nil {
return nil, err
}
shopID := assetShopID(ctx, s.db, usage)
var order *model.PackageSilenceExtensionOrder
executed := false
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := lockCarrier(ctx, tx, usage); err != nil {
return err
}
batchStatus, orderStatus, detailStatus := constants.SilenceBatchStatusExecuting, constants.SilenceOrderStatusPaid, constants.SilenceExtensionStatusExecuting
if approvalRequired {
batchStatus, orderStatus, detailStatus = constants.SilenceBatchStatusPendingApproval, constants.SilenceOrderStatusPendingApproval, constants.SilenceExtensionStatusPendingApproval
}
paidAt, paidAmount := req.PaidAt, req.PaidAmount
batch := &model.PackageSilenceExtensionBatch{
BatchNo: generateNo(constants.SilenceExtensionBatchNoPrefix, now), Path: constants.SilencePathOffline,
ExtensionType: constants.SilenceExtensionTypeOfflinePaid, ApprovalRequired: approvalRequired,
EvidenceRefs: model.StringJSONBArray(nonNil(req.EvidenceRefs)), PolicyID: &snapshot.PolicyID,
PolicyNameSnapshot: snapshot.Name, UnitPriceSnapshot: snapshot.UnitPrice,
ApprovalExemptTypesSnapshot: model.StringJSONBArray(nonNil(snapshot.ApprovalExemptTypes)),
FreeExtensionLimitSnapshot: snapshot.FreeExtensionLimit, PaidMaxMonthsSnapshot: snapshot.PaidMaxMonths,
SingleMaxMonthsSnapshot: snapshot.SingleMaxMonths, Amount: quote.Amount,
PaymentVoucherKeys: model.StringJSONBArray(req.PaymentVoucherKeys), RequestedBy: operatorID,
RequestedByType: constants.SilenceOperatorAccount, RequestedAt: now, Status: batchStatus, RequestedCount: 1, ShopIDTag: shopID,
}
if err := postgres.NewPackageSilenceBatchStore(tx).Create(ctx, batch); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建延期批次失败")
}
order = &model.PackageSilenceExtensionOrder{
OrderNo: generateNo(constants.SilenceExtensionOrderNoPrefix, now), BatchID: batch.ID,
PackageUsageID: usage.ID, PackageID: usage.PackageID, PackageNameSnapshot: usage.PackageName,
AssetIdentifierSnapshot: identifier, ShopID: shopID, Source: constants.SilenceOrderSourceOffline,
CreatedBy: &operatorID, EvidenceRefs: model.StringJSONBArray(nonNil(req.EvidenceRefs)), InputDays: req.Days,
Months: quote.Months, ExtensionDays: quote.BilledDays, PolicyID: &snapshot.PolicyID,
PolicyNameSnapshot: snapshot.Name, UnitPrice: snapshot.UnitPrice,
ApprovalExemptTypesSnapshot: model.StringJSONBArray(nonNil(snapshot.ApprovalExemptTypes)),
FreeExtensionLimitSnapshot: snapshot.FreeExtensionLimit, PaidMaxMonthsSnapshot: snapshot.PaidMaxMonths,
SingleMaxMonthsSnapshot: snapshot.SingleMaxMonths, Amount: quote.Amount,
BeforeDeadlineAt: *usage.SilenceDeadlineAt, ExpectedDeadlineAt: quote.NewDeadline, Status: orderStatus,
PaymentMethod: model.PaymentByOffline, OfflinePaymentMethodID: &method.ID, OfflinePaymentMethodCode: &method.Code,
OfflinePaymentMethodName: &method.Name, OfflineTradeNo: &tradeNo,
PaymentVoucherKeys: model.StringJSONBArray(req.PaymentVoucherKeys), ApprovalRequired: approvalRequired,
PaidAmount: &paidAmount, PaidAt: &paidAt, RequestID: &requestKey, ShopIDTag: shopID,
}
order.AssetType, order.AssetID = usageAsset(usage)
if err := postgres.NewPackageSilenceOrderStore(tx).Create(ctx, order); err != nil {
if isUniqueViolation(err) {
return errors.New(errors.CodeSilenceOfflineTradeNoDuplicate, "线下交易单号已登记或该套餐已有未结束的延期订单")
}
return errors.Wrap(errors.CodeDatabaseError, err, "创建线下延期订单失败")
}
extension := &model.PackageSilenceExtension{
BatchID: batch.ID, PackageUsageID: usage.ID, PackageID: usage.PackageID, PackageNameSnapshot: usage.PackageName,
AssetType: order.AssetType, AssetID: order.AssetID, AssetIdentifierSnapshot: identifier,
ExtensionType: constants.SilenceExtensionTypeOfflinePaid, InputDays: req.Days, Months: quote.Months,
ExtensionDays: quote.BilledDays, UnitPriceSnapshot: snapshot.UnitPrice, Amount: quote.Amount,
ExtensionOrderID: &order.ID, BeforeDeadlineAt: usage.SilenceDeadlineAt, Status: detailStatus,
}
if err := postgres.NewPackageSilenceExtensionStore(tx).Create(ctx, extension); err != nil {
if isUniqueViolation(err) {
return errors.New(errors.CodeSilenceExtensionOrderPending)
}
return errors.Wrap(errors.CodeDatabaseError, err, "创建延期明细失败")
}
if approvalRequired {
instanceID, err := createApprovalInTx(ctx, tx, s.approval, preparation, batch, submitter, ApplyTypeOfflinePaid, map[string]any{
ApprovalFieldAssetIdentifier: identifier, ApprovalFieldPackageName: usage.PackageName,
ApprovalFieldOrderNo: order.OrderNo, ApprovalFieldOriginalDeadline: formatDate(usage.SilenceDeadlineAt),
ApprovalFieldInputDays: req.Days, ApprovalFieldBilledDays: quote.BilledDays,
ApprovalFieldNewDeadline: formatDate(&quote.NewDeadline), ApprovalFieldAmount: formatAmount(quote.Amount),
ApprovalFieldAmountCent: quote.Amount, ApprovalFieldPaymentMethod: method.Name, ApprovalFieldTradeNo: tradeNo,
ApprovalFieldVoucherKeys: req.PaymentVoucherKeys, ApprovalFieldEvidenceKeys: nonNil(req.EvidenceRefs),
})
if err != nil {
return err
}
if err := tx.Model(&model.PackageSilenceExtensionOrder{}).Where("id = ?", order.ID).Update("approval_instance_id", instanceID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "关联线下延期审批实例失败")
}
order.ApprovalInstanceID = &instanceID
if err := s.writeRequestAudit(ctx, tx, usage, batch, "提交线下付费延期审批"); err != nil {
return err
}
return s.writeOrderAudit(ctx, tx, order, "", "登记线下付费延期,待审批")
}
if err := s.writeOrderAudit(ctx, tx, order, "", "登记线下付费延期(免审批)"); err != nil {
return err
}
ok, err := s.executeOrderInTx(ctx, tx, order)
executed = ok
return err
})
if err != nil {
return nil, err
}
if executed {
s.afterExecuted(ctx, []uint{usage.ID})
}
current, err := postgres.NewPackageSilenceOrderStore(s.db).GetByID(ctx, order.ID)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询延期订单失败")
}
return s.orderResponse(ctx, current, false)
}
// SubmitSpecial 提交特殊延期(技术方案 §8.5):单个或批量;免费次数受政策约束;不受付费上限与"是否支持延期"约束。
// 政策快照未勾选对应免审批场景(批量看 batch,否则看延期类型)时提交企微审批,通过后自动执行;勾选时提交即逐条执行。
func (s *ExtensionService) SubmitSpecial(ctx context.Context, req dto.SilenceSpecialRequest) (*dto.SilenceBatchResponse, error) {
if err := s.requireEnabled(ctx); err != nil {
return nil, err
}
if !slices.Contains(constants.SilenceSpecialExtensionTypes, req.ExtensionType) {
return nil, errors.New(errors.CodeInvalidParam, "特殊延期类型非法")
}
isBatch := len(req.Items) > 1 || strings.TrimSpace(req.ImportFileKey) != ""
if err := s.requirePermission(ctx, constants.PermissionPackageSilenceExtend); err != nil {
return nil, err
}
if isBatch {
if err := s.requirePermission(ctx, constants.PermissionPackageSilenceExtendBatch); err != nil {
return nil, err
}
}
if len(req.Items) == 0 || len(req.Items) > constants.SilenceBatchImportMaxRows {
return nil, errors.New(errors.CodeInvalidParam, "特殊延期明细数量需在 1~1000 之间")
}
reason := strings.TrimSpace(req.Reason)
if reason == "" {
return nil, errors.New(errors.CodeInvalidParam, "特殊延期必须填写原因")
}
free := req.ExtensionType == constants.SilenceExtensionTypeFree
if free && req.Amount != 0 {
return nil, errors.New(errors.CodeInvalidParam, "免费延期费用必须为 0")
}
operatorID := middleware.GetUserIDFromContext(ctx)
now := s.now()
rows, firstMatch, exempt, err := s.validateSpecialItems(ctx, req, free, isBatch, now)
if err != nil {
return nil, err
}
var method *model.EmployeeCollectionPaymentMethod
tradeNo := strings.TrimSpace(req.OfflineTradeNo)
if tradeNo != "" {
if req.OfflinePaymentMethodID == nil || len(req.PaymentVoucherKeys) == 0 {
return nil, errors.New(errors.CodeInvalidParam, "优惠价有收款时需填写收款方式与收款凭证")
}
if method, err = loadOfflinePaymentMethod(ctx, s.db, *req.OfflinePaymentMethodID); err != nil {
return nil, err
}
if err := ensureTradeNoUnused(ctx, s.db, method.ID, tradeNo); err != nil {
return nil, err
}
}
approvalRequired := !exempt
var preparation approvalapp.Preparation
var submitter approvalSubmitter
if approvalRequired {
if submitter, err = loadSubmitter(ctx, s.db, operatorID); err != nil {
return nil, err
}
if preparation, err = prepareApproval(ctx, s.approval, operatorID, fmt.Sprintf("silence-special-%d-%d", operatorID, now.UnixNano())); err != nil {
return nil, err
}
}
snapshot := firstMatch.Snapshot
var batch *model.PackageSilenceExtensionBatch
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
batchStatus, detailStatus := constants.SilenceBatchStatusExecuting, constants.SilenceExtensionStatusApproved
if approvalRequired {
batchStatus, detailStatus = constants.SilenceBatchStatusPendingApproval, constants.SilenceExtensionStatusPendingApproval
}
extensionType := req.ExtensionType
batch = &model.PackageSilenceExtensionBatch{
BatchNo: generateNo(constants.SilenceExtensionBatchNoPrefix, now), Path: constants.SilencePathSpecial,
ExtensionType: extensionType, ApprovalRequired: approvalRequired, Reason: truncate(reason, 1000),
EvidenceRefs: model.StringJSONBArray(nonNil(req.EvidenceRefs)), Basis: truncate(strings.TrimSpace(req.Basis), 1000),
ImportFileKey: strings.TrimSpace(req.ImportFileKey), FreeExtensionLimitSnapshot: firstMatch.FreeExtensionLimit(),
ApprovalExemptTypesSnapshot: model.StringJSONBArray(nonNil(firstMatch.ExemptTypes())), Amount: req.Amount,
PaymentVoucherKeys: model.StringJSONBArray(nonNil(req.PaymentVoucherKeys)), RequestedBy: operatorID,
RequestedByType: constants.SilenceOperatorAccount, RequestedAt: now, Status: batchStatus, RequestedCount: len(rows),
}
if snapshot != nil {
batch.PolicyID, batch.PolicyNameSnapshot, batch.UnitPriceSnapshot = &snapshot.PolicyID, snapshot.Name, snapshot.UnitPrice
batch.PaidMaxMonthsSnapshot, batch.SingleMaxMonthsSnapshot = snapshot.PaidMaxMonths, snapshot.SingleMaxMonths
}
if method != nil {
paidAmount := req.Amount
batch.OfflinePaymentMethodID, batch.OfflinePaymentMethodCode, batch.OfflinePaymentMethodName = &method.ID, &method.Code, &method.Name
batch.OfflineTradeNo, batch.OfflinePaidAmount, batch.OfflinePaidAt = &tradeNo, &paidAmount, req.OfflinePaidAt
}
if len(rows) == 1 {
batch.ShopIDTag = rows[0].shopID
}
if err := postgres.NewPackageSilenceBatchStore(tx).Create(ctx, batch); err != nil {
if isUniqueViolation(err) {
return errors.New(errors.CodeSilenceOfflineTradeNoDuplicate)
}
return errors.Wrap(errors.CodeDatabaseError, err, "创建特殊延期批次失败")
}
perRowAmount := int64(0)
if !free && len(rows) > 0 {
perRowAmount = req.Amount / int64(len(rows))
}
for index, row := range rows {
extension := &model.PackageSilenceExtension{
BatchID: batch.ID, RowNo: index + 1, PackageUsageID: row.usage.ID, PackageID: row.usage.PackageID,
PackageNameSnapshot: row.usage.PackageName, AssetType: row.assetType, AssetID: row.assetID,
AssetIdentifierSnapshot: row.identifier, ExtensionType: extensionType, InputDays: row.days,
Months: row.months, ExtensionDays: row.months * constants.SilenceDaysPerMonth, Amount: perRowAmount,
BeforeDeadlineAt: row.usage.SilenceDeadlineAt, Status: detailStatus,
}
if err := postgres.NewPackageSilenceExtensionStore(tx).Create(ctx, extension); err != nil {
if isUniqueViolation(err) {
return errors.NewWithData(errors.CodeSilenceExtensionOrderPending, map[string]any{"row_no": index + 1, "package_usage_id": row.usage.ID})
}
return errors.Wrap(errors.CodeDatabaseError, err, "创建特殊延期明细失败")
}
if err := s.writeRequestAudit(ctx, tx, row.usage, batch, "提交特殊延期"); err != nil {
return err
}
}
if !approvalRequired {
return nil
}
first := rows[0]
_, err := createApprovalInTx(ctx, tx, s.approval, preparation, batch, submitter, ApplyTypeSpecial, map[string]any{
ApprovalFieldAssetIdentifier: first.identifier, ApprovalFieldPackageName: first.usage.PackageName,
ApprovalFieldOrderNo: first.usage.OrderNo, ApprovalFieldOriginalDeadline: formatDate(first.usage.SilenceDeadlineAt),
ApprovalFieldInputDays: first.days, ApprovalFieldBilledDays: first.months * constants.SilenceDaysPerMonth,
ApprovalFieldExtensionType: specialTypeName(extensionType), ApprovalFieldItemCount: len(rows),
ApprovalFieldAmount: formatAmount(req.Amount), ApprovalFieldAmountCent: req.Amount,
ApprovalFieldReason: reason, ApprovalFieldBasis: strings.TrimSpace(req.Basis),
ApprovalFieldEvidenceKeys: nonNil(req.EvidenceRefs), ApprovalFieldTradeNo: tradeNo,
ApprovalFieldVoucherKeys: nonNil(req.PaymentVoucherKeys),
})
return err
})
if err != nil {
return nil, err
}
if !approvalRequired {
if err := s.executeBatch(ctx, batch.ID); err != nil {
return nil, err
}
}
return s.BatchDetail(ctx, batch.ID)
}
// specialRow 是一条已校验的特殊延期明细。
type specialRow struct {
usage *model.PackageUsage
days int
months int
identifier string
assetType string
assetID uint
shopID uint
}
// validateSpecialItems 逐行校验特殊延期明细,任一行不满足时拒绝并返回行号与原因。
// 返回首行匹配的政策(批次快照)以及是否全部明细的政策都勾选了对应免审批场景。
func (s *ExtensionService) validateSpecialItems(ctx context.Context, req dto.SilenceSpecialRequest, free, isBatch bool, now time.Time) ([]specialRow, PolicyMatch, bool, error) {
scene := packagedomain.SilenceSpecialApprovalScene(req.ExtensionType, isBatch)
rows := make([]specialRow, 0, len(req.Items))
seen := map[uint]bool{}
var first PolicyMatch
exempt := true
rowErrors := make([]map[string]any, 0)
for index, item := range req.Items {
rowNo := index + 1
fail := func(reason string) {
rowErrors = append(rowErrors, map[string]any{"row_no": rowNo, "package_usage_id": item.PackageUsageID, "reason": reason})
}
if seen[item.PackageUsageID] {
fail("同一套餐记录重复")
continue
}
seen[item.PackageUsageID] = true
if _, err := s.resolver.Resolve(ctx, item.PackageUsageID, ResolveOptions{Trigger: constants.SilenceTriggerEligibilityRead, AllowExpire: true}); err != nil {
return nil, first, false, err
}
usage, err := s.loadUsage(ctx, s.db, item.PackageUsageID, Scope{})
if err != nil {
fail("套餐记录不存在或无权限")
continue
}
facts, err := s.computeEligibility(ctx, s.db, usage, now)
if err != nil {
return nil, first, false, err
}
if !facts.expired {
fail("套餐不是沉默期已过期状态")
continue
}
if !facts.specialAvailable {
fail("该套餐已有未结束的延期订单或申请")
continue
}
months, _ := packagedomain.SilenceMonthsForDays(item.Days)
if months < facts.special.MinMonths {
fail(fmt.Sprintf("延期天数不足,至少 %d 天", facts.special.MinDays))
continue
}
if free && !facts.freeAvailable {
if !isBatch && len(req.Items) == 1 {
return nil, first, false, errors.New(errors.CodeSilenceFreeExtensionUsedUp)
}
fail("免费延期次数已用完")
continue
}
if index == 0 || first.Policy == nil {
first = facts.match
}
if !packagedomain.IsSilenceApprovalExempt(facts.match.ExemptTypes(), scene) {
exempt = false
}
identifier, err := assetIdentifier(ctx, s.db, usage)
if err != nil {
return nil, first, false, err
}
assetType, assetID := usageAsset(usage)
rows = append(rows, specialRow{usage: usage, days: item.Days, months: months, identifier: identifier,
assetType: assetType, assetID: assetID, shopID: assetShopID(ctx, s.db, usage)})
}
if len(rowErrors) > 0 {
if len(req.Items) == 1 {
return nil, first, false, errors.NewWithData(errors.CodeSilenceExtensionNotExpired, map[string]any{"rows": rowErrors}, fmt.Sprint(rowErrors[0]["reason"]))
}
return nil, first, false, errors.NewWithData(errors.CodeInvalidParam, map[string]any{"rows": rowErrors}, "批量特殊延期明细校验未通过")
}
return rows, first, exempt, nil
}
// executeBatch 逐条执行已批准的特殊延期明细:每条独立事务,部分失败不影响其他行。
func (s *ExtensionService) executeBatch(ctx context.Context, batchID uint) error {
batch, err := postgres.NewPackageSilenceBatchStore(s.db).GetByID(ctx, batchID)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询延期批次失败")
}
extensions, err := postgres.NewPackageSilenceExtensionStore(s.db).ListByBatch(ctx, batchID)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询延期明细失败")
}
free := batch.ExtensionType == constants.SilenceExtensionTypeFree
succeeded := make([]uint, 0, len(extensions))
for index := range extensions {
extension := &extensions[index]
if extension.Status != constants.SilenceExtensionStatusApproved {
continue
}
executed := false
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
from := []string{constants.SilenceExtensionStatusApproved}
outcome, err := s.executeExtensionInTx(ctx, tx, extension, executeKind{free: free}, paidSnapshot{freeLimit: batch.FreeExtensionLimitSnapshot}, from)
if err != nil {
if isAlreadyExecuted(err) {
return nil
}
return err
}
if !outcome.executed {
return s.markExtensionFailedInTx(ctx, tx, extension, constants.SilenceExtensionStatusFailed, from, outcome.failureCode, outcome.failureText)
}
executed = true
return nil
})
if err != nil {
return err
}
if executed {
succeeded = append(succeeded, extension.PackageUsageID)
}
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return refreshBatchCountsInTx(ctx, tx, batchID, s.now())
}); err != nil {
return err
}
s.afterExecuted(ctx, succeeded)
return nil
}
func (s *ExtensionService) writeRequestAudit(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage, batch *model.PackageSilenceExtensionBatch, summary string) error {
if s.audit == nil {
return nil
}
return s.audit.AppendSilenceUsage(ctx, tx, extensionAuditInput(usage, batch, constants.AuditActionUsageSilenceExtensionRequested, summary))
}
func loadOfflinePaymentMethod(ctx context.Context, db *gorm.DB, id uint) (*model.EmployeeCollectionPaymentMethod, error) {
var method model.EmployeeCollectionPaymentMethod
if err := db.WithContext(ctx).Where("id = ?", id).First(&method).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeEmployeeCollectionPaymentMethodNotFound)
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询线下收款方式失败")
}
if method.Status != constants.StatusEnabled {
return nil, errors.New(errors.CodeEmployeeCollectionPaymentMethodDisabled)
}
return &method, nil
}
// ensureTradeNoUnused 同一收款方式下交易单号只能登记一次(延期订单与特殊延期的优惠收款共同校验)。
func ensureTradeNoUnused(ctx context.Context, db *gorm.DB, methodID uint, tradeNo string) error {
var orders, batches int64
if err := db.WithContext(ctx).Model(&model.PackageSilenceExtensionOrder{}).
Where("offline_payment_method_id = ? AND offline_trade_no = ?", methodID, tradeNo).Count(&orders).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "校验线下交易单号失败")
}
if err := db.WithContext(ctx).Model(&model.PackageSilenceExtensionBatch{}).
Where("offline_payment_method_id = ? AND offline_trade_no = ? AND status NOT IN ?", methodID, tradeNo,
[]string{constants.SilenceBatchStatusRejected, constants.SilenceBatchStatusClosed}).Count(&batches).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "校验线下交易单号失败")
}
if orders+batches > 0 {
return errors.New(errors.CodeSilenceOfflineTradeNoDuplicate)
}
return nil
}
func specialTypeName(extensionType string) string {
switch extensionType {
case constants.SilenceExtensionTypeFree:
return "免费延期"
case constants.SilenceExtensionTypeDiscount:
return "优惠价延期"
case constants.SilenceExtensionTypeComplaintCompensation:
return "客诉补偿"
case constants.SilenceExtensionTypeSystemCompensation:
return "系统异常补偿"
case constants.SilenceExtensionTypeSpecialCustomer:
return "特殊客户政策"
default:
return extensionType
}
}
// lockBatchForDecision 锁定审批关联的批次。
func lockBatchForDecision(ctx context.Context, tx *gorm.DB, batchID uint) (*model.PackageSilenceExtensionBatch, error) {
var batch model.PackageSilenceExtensionBatch
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", batchID).First(&batch).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "沉默期审批关联的批次不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定沉默期批次失败")
}
return &batch, nil
}