修复合同到期时间与终止恢复规则

- 合同动作中文化并将到期时间设为必填\n- 隐藏修订记录无业务意义的删除时间\n- 禁止已终止合同直接启用,统一三端状态规则\n- 补充回归测试、模拟数据回填和中文文档
This commit is contained in:
czl231
2026-08-13 00:54:27 +08:00
parent 9937e3a508
commit a7ac692110
14 changed files with 151 additions and 19 deletions

View File

@@ -144,11 +144,11 @@ func CreateGasorderContract(ctx *gin.Context) {
DefaultDeliveryFee int64 `json:"default_delivery_fee"`
SignedAt time.Time `json:"signed_at" binding:"required"`
EffectiveAt time.Time `json:"effective_at" binding:"required"`
ExpiredAt *time.Time `json:"expired_at"`
ExpiredAt *time.Time `json:"expired_at" binding:"required"`
}
if err := ctx.ShouldBindJSON(&request); err != nil || request.DefaultDeliveryFee < 0 ||
request.SignedAt.IsZero() || request.EffectiveAt.IsZero() ||
(request.ExpiredAt != nil && !request.ExpiredAt.After(request.EffectiveAt)) {
request.ExpiredAt == nil || !request.ExpiredAt.After(request.EffectiveAt) {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
@@ -213,10 +213,10 @@ func UpdateGasorderContract(ctx *gin.Context) {
DefaultDeliveryFee int64 `json:"default_delivery_fee"`
SignedAt time.Time `json:"signed_at" binding:"required"`
EffectiveAt time.Time `json:"effective_at" binding:"required"`
ExpiredAt *time.Time `json:"expired_at"`
ExpiredAt *time.Time `json:"expired_at" binding:"required"`
}
if err := ctx.ShouldBindJSON(&request); err != nil || request.DefaultDeliveryFee < 0 ||
(request.ExpiredAt != nil && !request.ExpiredAt.After(request.EffectiveAt)) {
request.ExpiredAt == nil || !request.ExpiredAt.After(request.EffectiveAt) {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
@@ -307,11 +307,11 @@ func TerminateGasorderContract(ctx *gin.Context) {
func RenewGasorderContract(ctx *gin.Context) {
var request struct {
EffectiveAt time.Time `json:"effective_at" binding:"required"`
ExpiredAt *time.Time `json:"expired_at"`
ExpiredAt *time.Time `json:"expired_at" binding:"required"`
Reason string `json:"reason" binding:"required"`
}
if err := ctx.ShouldBindJSON(&request); err != nil ||
(request.ExpiredAt != nil && !request.ExpiredAt.After(request.EffectiveAt)) {
request.ExpiredAt == nil || !request.ExpiredAt.After(request.EffectiveAt) {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
@@ -351,7 +351,7 @@ func changeGasorderContract(ctx *gin.Context, action string, target int) {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("identity = ?", ctx.Param("identity")).First(&contract).Error; err != nil {
return err
}
if action == "activate" && contract.ContractStatus != common.StatusDraft && contract.ContractStatus != common.StatusTerminated {
if action == "activate" && !contractCanActivate(contract.ContractStatus) {
return errContractCannotActivate
}
if action == "terminate" && contract.ContractStatus != common.StatusActive {
@@ -395,6 +395,11 @@ func changeGasorderContract(ctx *gin.Context, action string, target int) {
infra.Response.Success(ctx, gin.H{"updated": true, "contract_status": target})
}
// contractCanActivate 限制启用动作仅用于首次启用草稿合同,终止合同必须通过续签恢复。
func contractCanActivate(status int) bool {
return status == common.StatusDraft
}
// isContractChangeBusinessError 仅允许预定义业务原因透传,避免泄露数据库内部错误。
func isContractChangeBusinessError(err error) bool {
return errors.Is(err, errContractCannotActivate) ||

View File

@@ -106,6 +106,18 @@ func TestContractRevisionKeepsSingleContractHistory(t *testing.T) {
}
}
// TestTerminatedContractCannotActivate 验证终止合同不能绕过续签流程直接恢复生效。
func TestTerminatedContractCannotActivate(t *testing.T) {
if !contractCanActivate(common.StatusDraft) {
t.Fatal("草稿合同应允许首次启用")
}
for _, status := range []int{common.StatusActive, common.StatusExpired, common.StatusTerminated} {
if contractCanActivate(status) {
t.Fatalf("合同状态 %d 不应允许直接启用", status)
}
}
}
func TestContractDeliveryRequiresDirectGasOwnership(t *testing.T) {
if !contractDeliveryMatchesGas(10, 10) {
t.Fatal("合同气站的直属配送点被错误拒绝")

View File

@@ -544,6 +544,8 @@ func MockData(database *gorm.DB) error {
// seedAdditionalCoreScenarios 补齐十组可独立检索的核心主数据。
// 每组记录都使用本组父表的数据库 ID 建立关联,避免仅有展示字段而无真实关系。
func seedAdditionalCoreScenarios(database *gorm.DB, passwordHash string, now time.Time) error {
// 联调合同统一设置一年有效期,保证合同详情具有明确到期时间。
nextYear := now.AddDate(1, 0, 0)
for scenario := 2; scenario <= 10; scenario++ {
sequence := 1000 + scenario*100
suffix := fmt.Sprintf("%03d", scenario)
@@ -635,11 +637,17 @@ func seedAdditionalCoreScenarios(database *gorm.DB, passwordHash string, now tim
ContractNo: "MOCK-CONTRACT-" + suffix, UserAccountID: user.ID, GasBasicID: gas.ID,
DeliveryBasicID: delivery.ID, Title: fmt.Sprintf("示例供气合同 %d", scenario),
Terms: "仅用于开发联调。", DefaultDeliveryFee: 500,
SignedAt: now, EffectiveAt: now,
SignedAt: now, EffectiveAt: now, ExpiredAt: &nextYear,
}
if err := put(database, &contract); err != nil {
return err
}
// 仅回填本函数生成的联调合同,修复旧种子执行后已存在的空到期时间。
if err := database.Model(&models.GasorderContract{}).
Where("identity = ? AND contract_no = ? AND expired_at IS NULL", contract.Identity, contract.ContractNo).
Update("expired_at", nextYear).Error; err != nil {
return fmt.Errorf("backfill mock contract expiry: %w", err)
}
contractProduct := models.GasorderContractProduct{
Entity: entity(sequence+11, common.StatusEnable), GasorderContractID: contract.ID,