From 51449d6b9dbbec0e329a1d7c28e4edb7a85dbd0f Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Thu, 23 Apr 2026 11:36:59 +0700 Subject: [PATCH] =?UTF-8?q?[CLAUDE]=20App+Infra:=20M=C3=A3=20H=C4=90=20gen?= =?UTF-8?q?=20ngay=20t=E1=BA=A1i=20CreateContract=20+=20backfill=20H=C4=90?= =?UTF-8?q?=20legacy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User feedback: HĐ phải có mã ngay khi tạo (không đợi đến DangDongDau như cũ). HĐ đã tạo trước đây nhưng chưa có mã → backfill tự động. ## Thay đổi ### CreateContractCommandHandler (App) - Inject IContractCodeGenerator - Load supplier + project FULL (cần Code, không chỉ check tồn tại như trước) - Call codeGenerator.GenerateAsync TRƯỚC khi db.Contracts.Add — entity chưa tracked nên GenerateAsync internal SaveChangesAsync chỉ save SEQ (không kèm contract chưa tracked) - Set entity.MaHopDong = result trước khi Add → INSERT contract đã có mã - Changelog summary include mã: "Tạo HĐ {mã} — {tên}" ### Trade-off documented - Mã gen sớm → HĐ TuChoi sẽ "wasted" 1 mã (gap trong sequence) - Acceptable vì user cần mã reference vào tài liệu/giấy tờ ngay từ đầu ### ContractWorkflowService.TransitionAsync (Infra) - Giữ logic cũ `if MaHopDong is null → gen` ở DangDongDau - Update comment: nominal flow skip vì mã đã có; defensive cho HĐ legacy hoặc HĐ tạo bằng path khác (seed/import) ### DbInitializer.BackfillContractCodesAsync (Infra) - Chạy 1 lần trước WarnDefaultAdminPasswordAsync - Idempotent: count Contracts WHERE MaHopDong IS NULL → skip nếu 0 - Loop từng HĐ: load supplier+project → GenerateAsync → SaveChangesAsync - Skip + log warning nếu missing supplier/project (legacy data corruption) - Try-catch per HĐ, log success/failed count cuối cùng ## Build dotnet build BE pass (0 error, 2 pre-existing DocxRenderer warning) ## Note Khi deploy lên prod, DbInitializer chạy startup → backfill HĐ cũ tự động. Log line "Backfill mã HĐ: X HĐ thiếu mã, đang gen..." sẽ xuất hiện ở Logs/log-{date}.txt để verify. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Contracts/ContractFeatures.cs | 20 ++++++-- .../Persistence/DbInitializer.cs | 51 +++++++++++++++++++ .../Services/ContractWorkflowService.cs | 5 +- 3 files changed, 70 insertions(+), 6 deletions(-) diff --git a/src/Backend/SolutionErp.Application/Contracts/ContractFeatures.cs b/src/Backend/SolutionErp.Application/Contracts/ContractFeatures.cs index 3198af7..30d47b7 100644 --- a/src/Backend/SolutionErp.Application/Contracts/ContractFeatures.cs +++ b/src/Backend/SolutionErp.Application/Contracts/ContractFeatures.cs @@ -43,14 +43,16 @@ public class CreateContractCommandHandler( IApplicationDbContext db, ICurrentUser currentUser, IContractWorkflowService workflow, + IContractCodeGenerator codeGenerator, IChangelogService changelog) : IRequestHandler { public async Task Handle(CreateContractCommand request, CancellationToken ct) { - if (!await db.Suppliers.AnyAsync(s => s.Id == request.SupplierId, ct)) - throw new NotFoundException("Supplier", request.SupplierId); - if (!await db.Projects.AnyAsync(p => p.Id == request.ProjectId, ct)) - throw new NotFoundException("Project", request.ProjectId); + // Load supplier + project full (cần Code cho gen mã RG-001) + var supplier = await db.Suppliers.FirstOrDefaultAsync(s => s.Id == request.SupplierId, ct) + ?? throw new NotFoundException("Supplier", request.SupplierId); + var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == request.ProjectId, ct) + ?? throw new NotFoundException("Project", request.ProjectId); var activeWfId = await db.WorkflowDefinitions.AsNoTracking() .Where(w => w.ContractType == request.Type && w.IsActive) @@ -74,12 +76,20 @@ public class CreateContractCommandHandler( WorkflowDefinitionId = activeWfId, SlaDeadline = DateTime.UtcNow.Add(workflow.GetPhaseSla(ContractPhase.DangSoanThao) ?? TimeSpan.FromDays(7)), }; + + // Gen mã HĐ NGAY tại create (user request) — không đợi DangDongDau như cũ. + // Trade-off: nếu HĐ bị TuChoi → mã bị "wasted" (gap trong sequence). Acceptable + // vì user muốn có mã ngay từ đầu để reference vào tài liệu/giấy tờ. + // GenerateAsync chạy SERIALIZABLE transaction riêng — entity chưa tracked nên + // không bị save kèm. + entity.MaHopDong = await codeGenerator.GenerateAsync(entity, project.Code, supplier.Code, ct); + db.Contracts.Add(entity); await changelog.LogContractChangeAsync( entity.Id, ChangelogAction.Insert, - summary: $"Tạo HĐ {entity.TenHopDong ?? "(chưa tên)"}", + summary: $"Tạo HĐ {entity.MaHopDong} — {entity.TenHopDong ?? "(chưa tên)"}", phaseAtChange: entity.Phase, ct: ct); diff --git a/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs b/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs index 04905a9..cd61c10 100644 --- a/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs +++ b/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using SolutionErp.Application.Contracts.Services; using SolutionErp.Domain.Contracts; using SolutionErp.Domain.Forms; using SolutionErp.Domain.Identity; @@ -34,9 +35,59 @@ public static class DbInitializer await SeedDemoMasterDataAsync(db, logger); await SeedContractTemplatesAsync(db, logger); await SeedWorkflowDefinitionsAsync(db, logger); + + // Backfill mã HĐ cho HĐ legacy chưa có (sau khi đổi policy gen-tại-create). + // Idempotent: chỉ HĐ MaHopDong IS NULL được gen. + var codeGen = sp.GetRequiredService(); + await BackfillContractCodesAsync(db, codeGen, logger); + await WarnDefaultAdminPasswordAsync(userManager, logger); } + private static async Task BackfillContractCodesAsync( + ApplicationDbContext db, IContractCodeGenerator codeGen, ILogger logger) + { + var orphanCount = await db.Contracts.CountAsync(c => c.MaHopDong == null); + if (orphanCount == 0) + { + logger.LogInformation("Backfill mã HĐ: không có HĐ nào thiếu mã."); + return; + } + + logger.LogInformation("Backfill mã HĐ: {Count} HĐ thiếu mã, đang gen...", orphanCount); + var orphans = await db.Contracts.Where(c => c.MaHopDong == null).ToListAsync(); + int success = 0, failed = 0; + + foreach (var contract in orphans) + { + var supplier = await db.Suppliers.FirstOrDefaultAsync(s => s.Id == contract.SupplierId); + var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == contract.ProjectId); + if (supplier is null || project is null) + { + logger.LogWarning( + "Backfill HĐ {Id}: skip vì supplier/project missing.", contract.Id); + failed++; + continue; + } + + try + { + contract.MaHopDong = await codeGen.GenerateAsync(contract, project.Code, supplier.Code); + await db.SaveChangesAsync(); + logger.LogInformation("Backfill HĐ {Id} → {Code}", contract.Id, contract.MaHopDong); + success++; + } + catch (Exception ex) + { + logger.LogError(ex, "Backfill HĐ {Id} thất bại.", contract.Id); + failed++; + } + } + + logger.LogInformation( + "Backfill mã HĐ xong: {Success} thành công, {Failed} thất bại.", success, failed); + } + // Seed v01 per ContractType from hardcoded WorkflowPolicies. Idempotent: // skip if any active definition already exists for that type (admin may // have already created custom versions — don't clobber). diff --git a/src/Backend/SolutionErp.Infrastructure/Services/ContractWorkflowService.cs b/src/Backend/SolutionErp.Infrastructure/Services/ContractWorkflowService.cs index 8d8aac8..ded9ea7 100644 --- a/src/Backend/SolutionErp.Infrastructure/Services/ContractWorkflowService.cs +++ b/src/Backend/SolutionErp.Infrastructure/Services/ContractWorkflowService.cs @@ -75,7 +75,10 @@ public class ContractWorkflowService( var fromPhase = contract.Phase; - // Gen mã HĐ khi chuyển sang DangDongDau (BOD ký xong) + // Defensive — gen mã HĐ nếu chưa có khi chuyển sang DangDongDau. + // Nominal flow (sau user feedback): mã đã gen sẵn từ CreateContract → skip. + // Fallback chỉ trigger cho HĐ legacy chưa qua backfill, hoặc HĐ tạo bằng + // path khác (vd seed/import) chưa set MaHopDong. if (targetPhase == ContractPhase.DangDongDau && string.IsNullOrEmpty(contract.MaHopDong)) { var supplier = await db.Suppliers.FirstOrDefaultAsync(s => s.Id == contract.SupplierId, ct)