[CLAUDE] PurchaseEvaluation: demo seed 4 phieu + MaPhieu atomic sequence + Pe_* perm defaults

Polish session tiep cua PE module skeleton (commit 2c6f0ca..3990066):
3 task A (MISSING in MVP) khac STATUS.md In Progress:

1. Demo PE data seed (SeedDemoPurchaseEvaluationsAsync)
   - 4 phieu varied A/B x phase: A-001 DangSoanThao (mo), A-002
     ChoCEODuyetNCC (winner+9 quotes), A-003 DaDuyet (chua tao HD,
     PaymentTerms JSON), B-001 ChoDuAn (5-step giua chung).
   - Idempotent: skip-if-[DEMO]-exists.
   - Approval history dung policy A (3-step) hoac B (5-step).

2. MaPhieu atomic sequence — Migration 13
   - Format PE/{YYYY}/{TypeLetter}/{Seq:D3} (vd PE/2026/A/001).
   - PurchaseEvaluationCodeSequence entity (Prefix PK).
   - IPurchaseEvaluationCodeGenerator + impl SERIALIZABLE
     transaction (mirror ContractCodeGenerator 1:1).
   - Replace Random.Shared trong CreatePurchaseEvaluationCommandHandler.
   - Migration AddPurchaseEvaluationCodeSequences (1 bang).

3. Pe_* permission defaults
   - SeedPurchaseEvaluationPermissionDefaultsAsync — 7 role business x 9 menu key.
   - Drafter/DeptManager/Procurement: R+C+U; CostControl/PM/Director/AuthorizedSigner: R+U.
   - DeptManager them Delete (xoa nhap).
   - Idempotent per-(roleId x menuKey).

Build: 0 error, 2 warning (pre-existing DocxRenderer).

Files: 4 new + 8 modified (1 migration + entity + generator + DI + 2 ctx + 2 features).

Resolves: STATUS.md In Progress §A — 3 item PE MISSING.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-04-24 10:41:17 +07:00
parent 0048a2e83a
commit c48ac2116d
16 changed files with 3621 additions and 24 deletions

View File

@ -34,6 +34,7 @@ public static class DependencyInjection
services.AddScoped<IContractCodeGenerator, ContractCodeGenerator>();
services.AddScoped<IContractWorkflowService, ContractWorkflowService>();
services.AddScoped<IPurchaseEvaluationWorkflowService, PurchaseEvaluationWorkflowService>();
services.AddScoped<IPurchaseEvaluationCodeGenerator, PurchaseEvaluationCodeGenerator>();
services.AddScoped<IContractExcelExporter, ContractExcelExporter>();
services.AddScoped<INotificationService, NotificationService>();
services.AddScoped<IChangelogService, ChangelogService>();

View File

@ -57,6 +57,7 @@ public class ApplicationDbContext
public DbSet<PurchaseEvaluationWorkflowDefinition> PurchaseEvaluationWorkflowDefinitions => Set<PurchaseEvaluationWorkflowDefinition>();
public DbSet<PurchaseEvaluationWorkflowStep> PurchaseEvaluationWorkflowSteps => Set<PurchaseEvaluationWorkflowStep>();
public DbSet<PurchaseEvaluationWorkflowStepApprover> PurchaseEvaluationWorkflowStepApprovers => Set<PurchaseEvaluationWorkflowStepApprover>();
public DbSet<PurchaseEvaluationCodeSequence> PurchaseEvaluationCodeSequences => Set<PurchaseEvaluationCodeSequence>();
protected override void OnModelCreating(ModelBuilder builder)
{

View File

@ -203,3 +203,16 @@ public class PurchaseEvaluationWorkflowStepApproverConfiguration : IEntityTypeCo
.OnDelete(DeleteBehavior.Cascade);
}
}
// Mirror ContractCodeSequenceConfiguration — Prefix là PK, atomic UPDATE qua
// SERIALIZABLE transaction trong PurchaseEvaluationCodeGenerator.
public class PurchaseEvaluationCodeSequenceConfiguration
: IEntityTypeConfiguration<PurchaseEvaluationCodeSequence>
{
public void Configure(EntityTypeBuilder<PurchaseEvaluationCodeSequence> b)
{
b.ToTable("PurchaseEvaluationCodeSequences");
b.HasKey(x => x.Prefix);
b.Property(x => x.Prefix).HasMaxLength(100);
}
}

View File

@ -671,6 +671,323 @@ public static class DbInitializer
c1.MaHopDong, c2.MaHopDong, c3.MaHopDong, c4.MaHopDong, c5.MaHopDong, c6.MaHopDong, c7.MaHopDong);
}
// Seed 4 demo PE phiếu — varied Type (A/B) × Phase (DangSoanThao / mid /
// ChoCEODuyetNCC / DaDuyet). Idempotent: skip nếu MaPhieu prefix `[DEMO]`
// đã tồn tại (KHÔNG backfill — vì Quotes/Approvals nested khó merge).
//
// Mỗi phiếu có:
// - 2-3 NCC tham gia thầu (Suppliers)
// - 2-4 hạng mục (Details — group A.I/A.II/A.III)
// - N×M Quotes (báo giá per supplier × hạng mục)
// - Approval history theo phase đã đi qua
// - Phiếu DaDuyet có SelectedSupplierId (winner)
//
// MaPhieu format tạm hardcode `[DEMO]-A-001` etc. — sau này MaPhieu atomic
// sequence (migration 13) sẽ replace.
private static async Task SeedDemoPurchaseEvaluationsAsync(
ApplicationDbContext db, UserManager<User> userManager, ILogger logger)
{
// Skip nếu đã có demo PE
if (await db.PurchaseEvaluations.AnyAsync(p => p.MaPhieu != null && p.MaPhieu!.StartsWith("[DEMO]")))
{
logger.LogInformation("SeedDemoPurchaseEvaluationsAsync: skip — đã có [DEMO] PE.");
return;
}
// Lookup masters
var suppliersByCode = await db.Suppliers.ToDictionaryAsync(s => s.Code, s => s);
var projectsByCode = await db.Projects.ToDictionaryAsync(p => p.Code, p => p);
if (suppliersByCode.Count < 3 || projectsByCode.Count == 0)
{
logger.LogWarning("SeedDemoPurchaseEvaluationsAsync: skip — thiếu Supplier/Project master.");
return;
}
// Lookup actor users (per role)
var qsHoang = await userManager.FindByEmailAsync("qs.hoang@solutionerp.local");
var proPham = await userManager.FindByEmailAsync("pro.pham@solutionerp.local");
var ccmTran = await userManager.FindByEmailAsync("ccm.tran@solutionerp.local");
var pmNguyen = await userManager.FindByEmailAsync("pm.nguyen@solutionerp.local");
var bodHuynh = await userManager.FindByEmailAsync("bod.huynh@solutionerp.local");
var qsDeptId = (await db.Departments.FirstOrDefaultAsync(d => d.Code == "QS"))?.Id;
var nowUtc = DateTime.UtcNow;
// Helper: build PE phiếu với Suppliers + Details + Quotes + Approvals
async Task<PurchaseEvaluation> createDemoPeAsync(
string maPhieu,
PurchaseEvaluationType type,
PurchaseEvaluationPhase finalPhase,
string tenGoiThau,
string projectCode,
string? diaDiem,
string? moTa,
string[] supplierCodes, // 2-3 supplier codes
(string GroupCode, string GroupName, string ItemCode, string NoiDung, string Dvt, decimal KlNs, decimal KlTc, decimal DgNs)[] detailRows,
// Quotes: index by [detailIdx][supplierIdx] → (BgVat, ChuaVat, IsSelected)
(decimal BgVat, decimal ChuaVat, bool IsSelected)[][] quotesGrid,
string? winnerSupplierCode = null,
string? paymentTermsJson = null)
{
var activeWfId = await db.PurchaseEvaluationWorkflowDefinitions.AsNoTracking()
.Where(w => w.EvaluationType == type && w.IsActive)
.Select(w => (Guid?)w.Id)
.FirstOrDefaultAsync();
var project = projectsByCode.TryGetValue(projectCode, out var p) ? p : projectsByCode.Values.First();
var pe = new PurchaseEvaluation
{
MaPhieu = $"[DEMO]-{maPhieu}",
Type = type,
Phase = PurchaseEvaluationPhase.DangSoanThao, // tạo ở phase 1, transition sau
TenGoiThau = $"[DEMO] {tenGoiThau}",
ProjectId = project.Id,
DepartmentId = qsDeptId,
DrafterUserId = qsHoang?.Id,
DiaDiem = diaDiem,
MoTa = moTa,
WorkflowDefinitionId = activeWfId,
SlaDeadline = nowUtc.AddDays(7),
PaymentTerms = paymentTermsJson,
};
db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync();
// Suppliers
var supplierEntities = new List<PurchaseEvaluationSupplier>();
for (int i = 0; i < supplierCodes.Length; i++)
{
var sCode = supplierCodes[i];
if (!suppliersByCode.TryGetValue(sCode, out var supplier)) continue;
var pes = new PurchaseEvaluationSupplier
{
PurchaseEvaluationId = pe.Id,
SupplierId = supplier.Id,
DisplayName = supplier.Name,
ContactName = supplier.ContactPerson,
ContactEmail = supplier.Email,
ContactPhone = supplier.Phone,
PaymentTermText = i == 0 ? "TGN-30 ngày" : i == 1 ? "TGN-45 ngày" : "Tiến độ",
Note = i == 0 ? "ĐÃ CHỐT SO SÁNH LẦN 1" : null,
Order = i + 1,
};
db.PurchaseEvaluationSuppliers.Add(pes);
supplierEntities.Add(pes);
}
await db.SaveChangesAsync();
// Details
var detailEntities = new List<PurchaseEvaluationDetail>();
for (int i = 0; i < detailRows.Length; i++)
{
var (gc, gn, ic, nd, dvt, klNs, klTc, dgNs) = detailRows[i];
var det = new PurchaseEvaluationDetail
{
PurchaseEvaluationId = pe.Id,
GroupCode = gc, GroupName = gn,
ItemCode = ic, NoiDung = nd, DonViTinh = dvt,
KhoiLuongNganSach = klNs,
KhoiLuongThiCong = klTc,
DonGiaNganSach = dgNs,
ThanhTienNganSach = klNs * dgNs,
Order = i + 1,
};
db.PurchaseEvaluationDetails.Add(det);
detailEntities.Add(det);
}
await db.SaveChangesAsync();
// Quotes: detailIdx × supplierIdx
for (int di = 0; di < detailEntities.Count; di++)
{
if (di >= quotesGrid.Length) break;
var row = quotesGrid[di];
for (int si = 0; si < supplierEntities.Count; si++)
{
if (si >= row.Length) break;
var (bgVat, chuaVat, isSelected) = row[si];
db.PurchaseEvaluationQuotes.Add(new PurchaseEvaluationQuote
{
PurchaseEvaluationDetailId = detailEntities[di].Id,
PurchaseEvaluationSupplierId = supplierEntities[si].Id,
BgVat = bgVat,
ChuaVat = chuaVat,
ThanhTien = detailEntities[di].KhoiLuongNganSach * chuaVat,
IsSelected = isSelected,
});
}
}
await db.SaveChangesAsync();
// Approval history theo flow của type
PurchaseEvaluationPhase[] flow = type == PurchaseEvaluationType.DuyetNcc
? [PurchaseEvaluationPhase.ChoPurchasing, PurchaseEvaluationPhase.ChoCCM,
PurchaseEvaluationPhase.ChoCEODuyetNCC, PurchaseEvaluationPhase.DaDuyet]
: [PurchaseEvaluationPhase.ChoPurchasing, PurchaseEvaluationPhase.ChoDuAn,
PurchaseEvaluationPhase.ChoCCM, PurchaseEvaluationPhase.ChoCEODuyetPA,
PurchaseEvaluationPhase.ChoCEODuyetNCC, PurchaseEvaluationPhase.DaDuyet];
var current = PurchaseEvaluationPhase.DangSoanThao;
foreach (var next in flow)
{
if (next > finalPhase && finalPhase != PurchaseEvaluationPhase.DaDuyet) break;
if (next > finalPhase) break;
Guid? actorId = next switch
{
PurchaseEvaluationPhase.ChoPurchasing => qsHoang?.Id, // Drafter submit
PurchaseEvaluationPhase.ChoDuAn => proPham?.Id, // Procurement chuyển sang
PurchaseEvaluationPhase.ChoCCM => type == PurchaseEvaluationType.DuyetNcc
? proPham?.Id // A: Procurement
: pmNguyen?.Id, // B: PM
PurchaseEvaluationPhase.ChoCEODuyetPA => ccmTran?.Id, // CCM
PurchaseEvaluationPhase.ChoCEODuyetNCC => type == PurchaseEvaluationType.DuyetNcc
? ccmTran?.Id // A: CCM
: bodHuynh?.Id, // B: CEO duyệt PA xong
PurchaseEvaluationPhase.DaDuyet => bodHuynh?.Id, // CEO chốt
_ => qsHoang?.Id,
};
db.PurchaseEvaluationApprovals.Add(new PurchaseEvaluationApproval
{
PurchaseEvaluationId = pe.Id,
FromPhase = current,
ToPhase = next,
ApproverUserId = actorId,
Decision = ApprovalDecision.Approve,
Comment = next switch
{
PurchaseEvaluationPhase.ChoPurchasing => "Demo seed — Drafter submit phiếu",
PurchaseEvaluationPhase.ChoCCM => "Demo seed — chuyển CCM kiểm tra ngân sách",
PurchaseEvaluationPhase.DaDuyet => "Demo seed — CEO duyệt chọn NCC",
_ => null,
},
ApprovedAt = nowUtc.AddMinutes(-(flow.Length - Array.IndexOf(flow, next)) * 30),
});
current = next;
}
pe.Phase = current;
// Set winner nếu DaDuyet
if (current == PurchaseEvaluationPhase.DaDuyet
&& winnerSupplierCode is not null
&& suppliersByCode.TryGetValue(winnerSupplierCode, out var winner))
{
pe.SelectedSupplierId = winner.Id;
}
return pe;
}
// ===========================================================
// 1. Phiếu A — DangSoanThao (Drafter còn đang soạn)
// ===========================================================
var pe1 = await createDemoPeAsync(
maPhieu: "A-001",
type: PurchaseEvaluationType.DuyetNcc,
finalPhase: PurchaseEvaluationPhase.DangSoanThao,
tenGoiThau: "Cung cấp xi măng + sắt thép Q3/2026 — FLOCK 01",
projectCode: "FLOCK01",
diaDiem: "Lô K, KCN Lộc An",
moTa: "Đợt 1: 200 tấn xi măng + 30 tấn thép cho phần thân.",
supplierCodes: ["NCC-XIMANG", "NCC-THEP", "NCC-DIEN"],
detailRows:
[
("A.I", "Xi măng", "XM-001", "Xi măng PCB40 50kg", "tan", 200, 200, 1_800_000),
("A.I", "Xi măng", "XM-002", "Xi măng PCB30 50kg", "tan", 50, 50, 1_650_000),
("A.II", "Sắt thép", "TH-014", "Thép cây phi 14", "kg", 15000, 15000, 18_500),
("A.II", "Sắt thép", "TH-018", "Thép cây phi 18", "kg", 15000, 15000, 18_800),
],
quotesGrid:
[
// Hạng 1 (xi măng PCB40) — chưa nhập (Drafter mới mở phiếu)
[(0, 0, false), (0, 0, false), (0, 0, false)],
[(0, 0, false), (0, 0, false), (0, 0, false)],
[(0, 0, false), (0, 0, false), (0, 0, false)],
[(0, 0, false), (0, 0, false), (0, 0, false)],
]);
// ===========================================================
// 2. Phiếu A — ChoCEODuyetNCC (sắp duyệt — đã có quotes + winner đề xuất)
// ===========================================================
var pe2 = await createDemoPeAsync(
maPhieu: "A-002",
type: PurchaseEvaluationType.DuyetNcc,
finalPhase: PurchaseEvaluationPhase.ChoCEODuyetNCC,
tenGoiThau: "Cung cấp gạch xây + cát đá Q2/2026 — Vinhomes Ocean Park",
projectCode: "VHOMES-OP",
diaDiem: "Vinhomes Ocean Park, Gia Lâm, HN",
moTa: "Vật tư xây dựng đợt 2 cho block A1-A5.",
supplierCodes: ["NCC-XIMANG", "NCC-THEP", "NCC-DIEN"],
detailRows:
[
("A.I", "Gạch", "GACH-001", "Gạch xây 4 lỗ 8x8x18", "vien", 50000, 50000, 1_500),
("A.II", "Cát đá", "CAT-001", "Cát vàng đổ bê tông", "m3", 200, 200, 350_000),
("A.II", "Cát đá", "DA-001", "Đá 1x2", "m3", 150, 150, 280_000),
],
quotesGrid:
[
// Hạng 1: Gạch — supplier 1 thắng (báo giá thấp + cam kết)
[(1_700, 1_550, true), (1_750, 1_590, false), (1_780, 1_618, false)],
// Hạng 2: Cát vàng — supplier 1 thắng
[(385_000, 350_000, true), (390_000, 354_000, false), (395_000, 359_000, false)],
// Hạng 3: Đá 1x2 — supplier 1 thắng
[(308_000, 280_000, true), (315_000, 286_000, false), (320_000, 290_000, false)],
]);
// ===========================================================
// 3. Phiếu A — DaDuyet (đã duyệt — chưa tạo HĐ — demo "Tạo HĐ từ phiếu")
// ===========================================================
var pe3 = await createDemoPeAsync(
maPhieu: "A-003",
type: PurchaseEvaluationType.DuyetNcc,
finalPhase: PurchaseEvaluationPhase.DaDuyet,
tenGoiThau: "Cung cấp ống PVC + phụ kiện cấp thoát nước — Resort Phú Quốc",
projectCode: "RESORT-PQ",
diaDiem: "Bãi Trường, Phú Quốc, Kiên Giang",
moTa: "Hệ thống cấp thoát nước villa + biệt thự ven biển.",
supplierCodes: ["NCC-XIMANG", "NCC-THEP"],
detailRows:
[
("A.I", "Ống nhựa", "ONG-090", "Ống PVC phi 90", "m", 2000, 2000, 65_000),
("A.I", "Ống nhựa", "ONG-110", "Ống PVC phi 110", "m", 1500, 1500, 95_000),
],
quotesGrid:
[
[(71_500, 65_000, true), (72_600, 66_000, false)],
[(104_500, 95_000, true), (106_700, 97_000, false)],
],
winnerSupplierCode: "NCC-XIMANG",
paymentTermsJson: """{"tamUng":20,"thanhToanTam":70,"quyetToan":10,"baoHanh":12,"hanMucCongNo":500000000,"danhGia":"Đi tác chiến lược 3 năm uy tín cao."}""");
// ===========================================================
// 4. Phiếu B — ChoDuAn (5-step giữa chừng — Procurement đã chuyển)
// ===========================================================
var pe4 = await createDemoPeAsync(
maPhieu: "B-001",
type: PurchaseEvaluationType.DuyetNccPhuongAn,
finalPhase: PurchaseEvaluationPhase.ChoDuAn,
tenGoiThau: "Cung cấp + lắp đặt cẩu tháp 6 tháng — FLOCK 01",
projectCode: "FLOCK01",
diaDiem: "Lô K, KCN Lộc An",
moTa: "Phương án A: thuê cẩu Liebherr 320 EC-H. Phương án B: thuê cẩu Potain MD 365.",
supplierCodes: ["DV-VANCHUYEN", "DV-CLEAN", "NTP-XD"],
detailRows:
[
("A.I", "Cẩu tháp", "CT-001", "Cẩu tháp Liebherr 320 EC-H — phương án A", "thang", 6, 6, 90_000_000),
("A.II", "Vận chuyển", "VC-001", "Vận chuyển + lắp đặt cẩu tháp", "lan", 1, 1, 80_000_000),
("A.III", "Bảo trì", "BT-001", "Bảo trì cẩu tháp định kỳ", "lan", 6, 6, 5_000_000),
],
quotesGrid:
[
[(99_000_000, 90_000_000, false), (104_500_000, 95_000_000, false), (96_800_000, 88_000_000, false)],
[(88_000_000, 80_000_000, false), (93_500_000, 85_000_000, false), (82_500_000, 75_000_000, false)],
[(5_500_000, 5_000_000, false), (5_775_000, 5_250_000, false), (5_280_000, 4_800_000, false)],
]);
await db.SaveChangesAsync();
logger.LogInformation("Seed 4 demo PE phiếu: {P1} {P2} {P3} {P4}",
pe1.MaPhieu, pe2.MaPhieu, pe3.MaPhieu, pe4.MaPhieu);
}
// Phase 5.1 security: log warning nếu admin vẫn dùng password mặc định sau deploy production.
private static async Task WarnDefaultAdminPasswordAsync(UserManager<User> userManager, ILogger logger)
{
@ -1024,6 +1341,79 @@ public static class DbInitializer
await db.SaveChangesAsync();
logger.LogInformation("Seeded {Count} admin permissions", added);
}
// Defaults cho Pe_* permissions per role — mỗi role business liên quan
// PE module được grant CRUD level phù hợp lên 2 type (DuyetNcc /
// DuyetNccPhuongAn).
// Idempotent: skip per-(role,menuKey) đã có, chỉ add row mới.
await SeedPurchaseEvaluationPermissionDefaultsAsync(db, roleManager, logger);
}
// Permission defaults cho module Duyệt NCC (Pe_*). Strategy:
// - Drafter / DeptManager / Procurement / CostControl / Director / AuthorizedSigner / ProjectManager
// đều có Read + Create + Update trên CẢ 2 type (xem được, soạn / chỉnh).
// - Workflow admin (PeWf_*) chỉ Admin (đã grant qua MenuKeys.All) — không cấp non-admin.
// - Idempotent: skip per-(roleId × menuKey) đã có row.
private static async Task SeedPurchaseEvaluationPermissionDefaultsAsync(
ApplicationDbContext db, RoleManager<Role> roleManager, ILogger logger)
{
// Roles cần access PE module
var roleNames = new[]
{
AppRoles.Drafter, AppRoles.DeptManager,
AppRoles.Procurement, AppRoles.CostControl,
AppRoles.ProjectManager,
AppRoles.Director, AppRoles.AuthorizedSigner,
};
// Menu keys cần grant: PurchaseEvaluations root + per-type group + 3 leaf
var menuKeys = new List<string> { MenuKeys.PurchaseEvaluations };
foreach (var typeCode in MenuKeys.PurchaseEvaluationTypeCodes)
{
menuKeys.Add(MenuKeys.PurchaseEvaluationGroup(typeCode));
menuKeys.Add(MenuKeys.PurchaseEvaluationList(typeCode));
menuKeys.Add(MenuKeys.PurchaseEvaluationCreate(typeCode));
menuKeys.Add(MenuKeys.PurchaseEvaluationPending(typeCode));
}
int added = 0;
foreach (var roleName in roleNames)
{
var role = await roleManager.FindByNameAsync(roleName);
if (role is null) continue;
var existing = await db.Permissions
.Where(p => p.RoleId == role.Id && menuKeys.Contains(p.MenuKey))
.Select(p => p.MenuKey)
.ToListAsync();
// Drafter có quyền Create + Update (soạn phiếu); Director chỉ Read +
// Update (duyệt — xem + nhấn nút). Map default rõ ràng:
bool canCreate = roleName is AppRoles.Drafter or AppRoles.DeptManager
or AppRoles.Procurement;
bool canUpdate = true; // mọi role được transition (workflow check tách)
bool canDelete = roleName == AppRoles.DeptManager; // chỉ TPB xóa nháp
foreach (var menuKey in menuKeys)
{
if (existing.Contains(menuKey)) continue;
db.Permissions.Add(new Permission
{
RoleId = role.Id,
MenuKey = menuKey,
CanRead = true,
CanCreate = canCreate,
CanUpdate = canUpdate,
CanDelete = canDelete,
});
added++;
}
}
if (added > 0)
{
await db.SaveChangesAsync();
logger.LogInformation("Seeded {Count} PE permission defaults across {Roles} roles", added, roleNames.Length);
}
}
// 9 departments từ QT-TP-NCC.docx — reference data, không phải demo.

View File

@ -0,0 +1,35 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SolutionErp.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddPurchaseEvaluationCodeSequences : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "PurchaseEvaluationCodeSequences",
columns: table => new
{
Prefix = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
LastSeq = table.Column<int>(type: "int", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PurchaseEvaluationCodeSequences", x => x.Prefix);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "PurchaseEvaluationCodeSequences");
}
}
}

View File

@ -2165,6 +2165,23 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.ToTable("PurchaseEvaluationChangelogs", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationCodeSequence", b =>
{
b.Property<string>("Prefix")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<int>("LastSeq")
.HasColumnType("int");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime2");
b.HasKey("Prefix");
b.ToTable("PurchaseEvaluationCodeSequences", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluationDetail", b =>
{
b.Property<Guid>("Id")

View File

@ -0,0 +1,55 @@
using System.Data;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.PurchaseEvaluations.Services;
using SolutionErp.Domain.PurchaseEvaluations;
namespace SolutionErp.Infrastructure.Services;
// Mirror ContractCodeGenerator pattern — atomic sequence per (year × type).
// Prefix = "PE/{YYYY}/{TypeLetter}".
public class PurchaseEvaluationCodeGenerator(IApplicationDbContext db, IDateTime dateTime)
: IPurchaseEvaluationCodeGenerator
{
public async Task<string> GenerateAsync(PurchaseEvaluation evaluation, CancellationToken ct = default)
{
var typeLetter = evaluation.Type switch
{
PurchaseEvaluationType.DuyetNcc => "A",
PurchaseEvaluationType.DuyetNccPhuongAn => "B",
_ => "X",
};
var year = dateTime.UtcNow.Year;
var prefix = $"PE/{year}/{typeLetter}";
var context = (DbContext)db;
await using var tx = await context.Database
.BeginTransactionAsync(IsolationLevel.Serializable, ct);
try
{
var seq = await db.PurchaseEvaluationCodeSequences
.FirstOrDefaultAsync(s => s.Prefix == prefix, ct);
if (seq is null)
{
seq = new PurchaseEvaluationCodeSequence
{
Prefix = prefix, LastSeq = 1, UpdatedAt = dateTime.UtcNow,
};
db.PurchaseEvaluationCodeSequences.Add(seq);
}
else
{
seq.LastSeq += 1;
seq.UpdatedAt = dateTime.UtcNow;
}
await db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
return $"{prefix}/{seq.LastSeq:D3}";
}
catch
{
await tx.RollbackAsync(ct);
throw;
}
}
}