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