[CLAUDE] PurchaseEvaluation: freeze ngân sách phiếu DaDuyet — Mig 67 snapshot 11 cột + display-gate budgetFrozen + 2 terminal-guard (UAT anh Kiệt 07-16)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m29s

- Bug UAT: PeWorkItemBudgets (record NS dùng chung per Dự án × Hạng mục) sửa được sau duyệt → phiếu ĐÃ DUYỆT đổi số retroactive (detail-bundle load pair LIVE :911 + FE fallback ?? bs.*).
- Fix: Mig 67 AddPeApprovedBudgetSnapshot — 11 cột ApprovedBudget* trên PurchaseEvaluations; helper ApplyBudgetSnapshotOnFinalizeAsync gọi ở 5 site set DaDuyet (4 finalize + admin-override catch-all — reviewer catch #81-class, assignment qua biến lọt literal-grep); display-gate: DaDuyet+SnapshotAt → serve snapshot + BudgetFrozen=true + CanEdit=false, else LIVE (R2: NS live vẫn là "bản cập nhật gần nhất" cho phiếu chưa duyệt — UpdatePeBudgetPro/Ccm giữ nguyên không phase-guard).
- 2 terminal-guard block CẢ Admin: SetPeCcmBudgetPeriod + AdjustBudget → Conflict khi DaDuyet/TuChoi (đường chính thống: un-terminal → duyệt lại → re-snapshot overwrite).
- Backfill idempotent phiếu DaDuyet cũ = live hiện tại (số tại-thời-điểm-duyệt không khôi phục được — Changelogs attribution-gap); OUTER APPLY dịch đúng predicate PeBudgetAccumulator (3 bản khớp: SQL ↔ accumulator ↔ code gốc); demo-seeder DbInitializer cũng snapshot (fresh-DB gap — reviewer catch).
- PeBudgetAccumulator extract lũy kế behavior-preserving (dùng chung GetDetail live + helper snapshot); FE 2 app: types +budgetFrozen + PeDetailTabs badge khoá NS (SHA-mirror giữ, hash 2 app bằng nhau).
- Tests 509→520 (T0 reproduce RED→GREEN + T1-T9 + AdminOverride site-5); build slnx 0/0; npm build ×2 PASS; Mig applied 2 LocalDB (Dev catch-up cả Mig 66 lag).
- Pipeline S133: /fable-real invest (wf_348f6155) → spec → HMW Opus MAX (wf_d9639faa / wf_4bef08b8 / wf_75c6bfd3) → reviewer PASS_WITH_FIXES (wf_f9c0b939) → 2 SHOULD fixed. #53 return-fail ×3/5 lane — disk-truth recover 3/3, 0 mất việc.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-07-17 11:14:25 +07:00
parent 38b6d196d6
commit d4fbced02f
16 changed files with 7829 additions and 61 deletions

View File

@ -42,6 +42,16 @@ public class PurchaseEvaluationConfiguration : IEntityTypeConfiguration<Purchase
b.Property(x => x.CcmSuggestedPriceNote).HasMaxLength(1000);
// [S97 2026-07-01] Runtime finalize-fact — bit NOT NULL default false (mirror IsUrgent* bool).
b.Property(x => x.EndedByLevelFinalize).HasDefaultValue(false);
// [S133 Mig 67] Snapshot ngân sách phiếu DaDuyet — 6 decimal (18,2) + 2 note (1000)
// mirror precision cột ApprovedPrice*/PeWorkItemBudget. Tất cả nullable (backfill Mig 67).
b.Property(x => x.ApprovedBudgetProInitialAmount).HasPrecision(18, 2);
b.Property(x => x.ApprovedBudgetProAdjustmentAmount).HasPrecision(18, 2);
b.Property(x => x.ApprovedBudgetCcmInitialAmount).HasPrecision(18, 2);
b.Property(x => x.ApprovedBudgetCcmAdjustmentAmount).HasPrecision(18, 2);
b.Property(x => x.ApprovedBudgetPrevSubmittedTotal).HasPrecision(18, 2);
b.Property(x => x.ApprovedBudgetPrevSelectedTotal).HasPrecision(18, 2);
b.Property(x => x.ApprovedBudgetProNote).HasMaxLength(1000);
b.Property(x => x.ApprovedBudgetCcmNote).HasMaxLength(1000);
b.HasIndex(x => x.MaPhieu).IsUnique().HasFilter("[MaPhieu] IS NOT NULL");
b.HasIndex(x => new { x.Phase, x.IsDeleted });

View File

@ -1322,6 +1322,24 @@ public static class DbInitializer
}
pe.Phase = current;
// [S133] Demo phiếu DaDuyet cũng phải có snapshot NS: fresh-DB migrate chạy TRƯỚC seed
// → backfill Mig 67 no-op trên bảng rỗng; thiếu block này demo DaDuyet hiển thị live-drift
// (reviewer S133 catch — mirror helper ApplyBudgetSnapshotOnFinalizeAsync, null-safe).
if (current == PurchaseEvaluationPhase.DaDuyet)
{
var pairDemo = pe.WorkItemId is Guid wiDemo
? await db.PeWorkItemBudgets
.FirstOrDefaultAsync(b => b.ProjectId == pe.ProjectId && b.WorkItemId == wiDemo)
: null;
pe.ApprovedBudgetProInitialAmount = pairDemo?.ProInitialAmount;
pe.ApprovedBudgetProAdjustmentAmount = pairDemo?.ProAdjustmentAmount;
pe.ApprovedBudgetProNote = pairDemo?.ProNote;
pe.ApprovedBudgetCcmInitialAmount = pairDemo?.InitialAmount;
pe.ApprovedBudgetCcmAdjustmentAmount = pairDemo?.AdjustmentAmount;
pe.ApprovedBudgetCcmNote = pairDemo?.CcmNote;
pe.ApprovedBudgetSnapshotAt = nowUtc;
}
// Set winner nếu DaDuyet
if (current == PurchaseEvaluationPhase.DaDuyet
&& winnerSupplierCode is not null

View File

@ -0,0 +1,201 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SolutionErp.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddPeApprovedBudgetSnapshot : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<decimal>(
name: "ApprovedBudgetCcmAdjustmentAmount",
table: "PurchaseEvaluations",
type: "decimal(18,2)",
precision: 18,
scale: 2,
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "ApprovedBudgetCcmInitialAmount",
table: "PurchaseEvaluations",
type: "decimal(18,2)",
precision: 18,
scale: 2,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ApprovedBudgetCcmNote",
table: "PurchaseEvaluations",
type: "nvarchar(1000)",
maxLength: 1000,
nullable: true);
migrationBuilder.AddColumn<int>(
name: "ApprovedBudgetPrevSelectedCount",
table: "PurchaseEvaluations",
type: "int",
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "ApprovedBudgetPrevSelectedTotal",
table: "PurchaseEvaluations",
type: "decimal(18,2)",
precision: 18,
scale: 2,
nullable: true);
migrationBuilder.AddColumn<int>(
name: "ApprovedBudgetPrevSubmittedCount",
table: "PurchaseEvaluations",
type: "int",
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "ApprovedBudgetPrevSubmittedTotal",
table: "PurchaseEvaluations",
type: "decimal(18,2)",
precision: 18,
scale: 2,
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "ApprovedBudgetProAdjustmentAmount",
table: "PurchaseEvaluations",
type: "decimal(18,2)",
precision: 18,
scale: 2,
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "ApprovedBudgetProInitialAmount",
table: "PurchaseEvaluations",
type: "decimal(18,2)",
precision: 18,
scale: 2,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ApprovedBudgetProNote",
table: "PurchaseEvaluations",
type: "nvarchar(1000)",
maxLength: 1000,
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "ApprovedBudgetSnapshotAt",
table: "PurchaseEvaluations",
type: "datetime2",
nullable: true);
// [S133 2026-07-17 — anh Kiệt FDC] Backfill snapshot ngân sách cho phiếu ĐÃ DUYỆT
// hiện hữu = giá trị LIVE hôm nay (số tại-thời-điểm-duyệt KHÔNG khôi phục được —
// Changelogs có log budget-edit nhưng gắn phiếu-đang-mở-lúc-sửa, attribution gap).
// Idempotent: WHERE ApprovedBudgetSnapshotAt IS NULL → re-run no-op. SQL-Server-only
// (tests SQLite EnsureCreated KHÔNG replay migration — mirror Mig 61/65).
// Predicate lũy kế = BẢN DỊCH SQL của PeBudgetAccumulator.ComputeAsync (3 bản phải khớp:
// SQL này ↔ accumulator ↔ GetPurchaseEvaluationQuery cũ): peers cùng (ProjectId, WorkItemId),
// Id <> pe, CreatedAt < pe, IsDeleted = 0; submitted = Phase IN (10 ChoDuyet, 7 DaDuyet);
// selected = Phase 7 + EXISTS supplier IsWinner. Suppliers/Quotes = BaseEntity KHÔNG
// soft-delete filter → không điều kiện IsDeleted trên s/q. Phiếu WorkItemId NULL →
// 6 cột pair NULL (LEFT JOIN không match) + 4 lũy kế NULL (CASE — mirror helper runtime),
// SnapshotAt VẪN set (marker "đã chốt").
migrationBuilder.Sql(@"
UPDATE pe SET
ApprovedBudgetProInitialAmount = b.ProInitialAmount,
ApprovedBudgetProAdjustmentAmount = b.ProAdjustmentAmount,
ApprovedBudgetProNote = b.ProNote,
ApprovedBudgetCcmInitialAmount = b.InitialAmount,
ApprovedBudgetCcmAdjustmentAmount = b.AdjustmentAmount,
ApprovedBudgetCcmNote = b.CcmNote,
ApprovedBudgetPrevSubmittedTotal = CASE WHEN pe.WorkItemId IS NULL THEN NULL ELSE ISNULL(sub.SubmittedTotal, 0) END,
ApprovedBudgetPrevSubmittedCount = CASE WHEN pe.WorkItemId IS NULL THEN NULL ELSE ISNULL(sub.SubmittedCount, 0) END,
ApprovedBudgetPrevSelectedTotal = CASE WHEN pe.WorkItemId IS NULL THEN NULL ELSE ISNULL(selt.SelectedTotal, 0) END,
ApprovedBudgetPrevSelectedCount = CASE WHEN pe.WorkItemId IS NULL THEN NULL ELSE ISNULL(sel.SelectedCount, 0) END,
ApprovedBudgetSnapshotAt = SYSUTCDATETIME()
FROM PurchaseEvaluations pe
LEFT JOIN PeWorkItemBudgets b
ON b.ProjectId = pe.ProjectId AND b.WorkItemId = pe.WorkItemId AND b.IsDeleted = 0
OUTER APPLY (
SELECT COUNT(*) AS SubmittedCount,
SUM(ISNULL(p.BudgetPeriodAmount, 0)) AS SubmittedTotal
FROM PurchaseEvaluations p
WHERE p.ProjectId = pe.ProjectId AND p.WorkItemId = pe.WorkItemId
AND p.Id <> pe.Id AND p.CreatedAt < pe.CreatedAt AND p.IsDeleted = 0
AND p.Phase IN (10, 7)
) sub
OUTER APPLY (
SELECT COUNT(*) AS SelectedCount
FROM PurchaseEvaluations p
WHERE p.ProjectId = pe.ProjectId AND p.WorkItemId = pe.WorkItemId
AND p.Id <> pe.Id AND p.CreatedAt < pe.CreatedAt AND p.IsDeleted = 0
AND p.Phase = 7
AND EXISTS (SELECT 1 FROM PurchaseEvaluationSuppliers s
WHERE s.PurchaseEvaluationId = p.Id AND s.IsWinner = 1)
) sel
OUTER APPLY (
SELECT SUM(q.ThanhTien) AS SelectedTotal
FROM PurchaseEvaluations p
INNER JOIN PurchaseEvaluationSuppliers s ON s.PurchaseEvaluationId = p.Id
INNER JOIN PurchaseEvaluationQuotes q ON q.PurchaseEvaluationSupplierId = s.Id
WHERE p.ProjectId = pe.ProjectId AND p.WorkItemId = pe.WorkItemId
AND p.Id <> pe.Id AND p.CreatedAt < pe.CreatedAt AND p.IsDeleted = 0
AND p.Phase = 7 AND q.IsSelected = 1
AND EXISTS (SELECT 1 FROM PurchaseEvaluationSuppliers s2
WHERE s2.PurchaseEvaluationId = p.Id AND s2.IsWinner = 1)
) selt
WHERE pe.Phase = 7 AND pe.ApprovedBudgetSnapshotAt IS NULL AND pe.IsDeleted = 0;");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ApprovedBudgetCcmAdjustmentAmount",
table: "PurchaseEvaluations");
migrationBuilder.DropColumn(
name: "ApprovedBudgetCcmInitialAmount",
table: "PurchaseEvaluations");
migrationBuilder.DropColumn(
name: "ApprovedBudgetCcmNote",
table: "PurchaseEvaluations");
migrationBuilder.DropColumn(
name: "ApprovedBudgetPrevSelectedCount",
table: "PurchaseEvaluations");
migrationBuilder.DropColumn(
name: "ApprovedBudgetPrevSelectedTotal",
table: "PurchaseEvaluations");
migrationBuilder.DropColumn(
name: "ApprovedBudgetPrevSubmittedCount",
table: "PurchaseEvaluations");
migrationBuilder.DropColumn(
name: "ApprovedBudgetPrevSubmittedTotal",
table: "PurchaseEvaluations");
migrationBuilder.DropColumn(
name: "ApprovedBudgetProAdjustmentAmount",
table: "PurchaseEvaluations");
migrationBuilder.DropColumn(
name: "ApprovedBudgetProInitialAmount",
table: "PurchaseEvaluations");
migrationBuilder.DropColumn(
name: "ApprovedBudgetProNote",
table: "PurchaseEvaluations");
migrationBuilder.DropColumn(
name: "ApprovedBudgetSnapshotAt",
table: "PurchaseEvaluations");
}
}
}

View File

@ -4682,6 +4682,47 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Property<Guid?>("ApprovalWorkflowId")
.HasColumnType("uniqueidentifier");
b.Property<decimal?>("ApprovedBudgetCcmAdjustmentAmount")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<decimal?>("ApprovedBudgetCcmInitialAmount")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<string>("ApprovedBudgetCcmNote")
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<int?>("ApprovedBudgetPrevSelectedCount")
.HasColumnType("int");
b.Property<decimal?>("ApprovedBudgetPrevSelectedTotal")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<int?>("ApprovedBudgetPrevSubmittedCount")
.HasColumnType("int");
b.Property<decimal?>("ApprovedBudgetPrevSubmittedTotal")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<decimal?>("ApprovedBudgetProAdjustmentAmount")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<decimal?>("ApprovedBudgetProInitialAmount")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<string>("ApprovedBudgetProNote")
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<DateTime?>("ApprovedBudgetSnapshotAt")
.HasColumnType("datetime2");
b.Property<decimal?>("ApprovedPriceAmount")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");