Files
solution-erp/.claude/workflows/runs/2026-07-29-S161-khkk-w1-schema/sub-database-agent-1.md
2026-07-29 19:44:56 +07:00

21 KiB
Raw Blame History

sub-database-agent-1 — Design-review Mig 69 AddContractSigningPlans (S161 W1, READ-ONLY advisory)

Ghi TỪNG MỤC trong lúc làm (chống #53). Không sửa file production. Không commit. Nguồn đo: file migration + Designer + ModelSnapshot + 7 EF Configuration + sqlcmd LocalDB Dev/Design.

VERDICT TỔNG: PASS 8/8 mục (a)(h) + sqlcmd + DB11 — ĐỦ ĐIỀU KIỆN DEPLOY, 0 blocker.

4 advisory cho wave sau (A-1 chặn 2 kế hoạch/1 PE · A-2 dọn Attachments khi xoá DossierItem · A-3 IX_SlaDeadline chỉ khi wire job · A-4 backup trước khi chạy Down trên DB có data). Chi tiết §END.


0. Vật liệu đã đọc (evidence anchors)

Vật Đường dẫn Ghi chú
Migration src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260729122015_AddContractSigningPlans.cs 373 dòng
Designer ...20260729122015_AddContractSigningPlans.Designer.cs 264.515 B
Snapshot ...Migrations/ApplicationDbContextModelSnapshot.cs 264.399 B, git diff +550/-0
Config ×7 src/Backend/SolutionErp.Infrastructure/Persistence/Configurations/ContractSigningPlan*.cs 7 file
Spec W1 .claude/workflows/runs/2026-07-29-S160-khkk-dryrun-plan/spec-wave-w1-schema-designer-29-07-2026.md:16-20 §②-2/4/6
Spec gốc maxLength .claude/workflows/runs/2026-07-28-S157-ke-hoach-ky-ket-hd/spec-ke-hoach-ky-ket-hd-28-07-2026.md:134-137 danh sách 11 cột chuỗi

Đếm operation trong migration (grep + uniq):

  • CreateTable ×7 · CreateIndex ×20 · DropTable ×7 (Down)
  • AddColumn / AlterColumn / DropColumn / RenameColumn / Sql( = 0 (không match nào)

3. Đối chứng sqlcmd LocalDB (DB1/DB10 — schema THẬT, không trí nhớ)

sqlcmd -S "(localdb)\MSSQLLocalDB" -d SolutionErp_Dev -E :

TABLECOUNT|96
CSPTABLE|ContractSigningPlanApprovals
CSPTABLE|ContractSigningPlanAttachments
CSPTABLE|ContractSigningPlanChangelogs
CSPTABLE|ContractSigningPlanDossierItems
CSPTABLE|ContractSigningPlanLevelOpinions
CSPTABLE|ContractSigningPlanLines
CSPTABLE|ContractSigningPlans
MIGTOP|20260729122015_AddContractSigningPlans
MIGCOUNT|69

-d SolutionErp_Design :

DESIGN_TABLECOUNT|96 · DESIGN_CSP|7 · DESIGN_MIGTOP|20260729122015_AddContractSigningPlans · DESIGN_IXCOUNT|27

2 DB parity TUYỆT ĐỐI (96/96 bảng · 7/7 CSP · cùng mig-top · 27 index = 20 IX + 7 PK). → KHÔNG có committed-but-unapplied-local drift (khác bẫy S53). → Acceptance §③-B "sys.tables 89 → 96" = ĐẠT (đo được 96; delta +7 khớp 7 CreateTable).

Index thật trong DB (27 dòng, khớp 1:1 file mig)

Nhóm filtered (soi gotcha #57):

ContractSigningPlanLines | IX_..._ContractSigningPlanId_SupplierId | unique=1 | ([IsDeleted]=(0)) | ContractSigningPlanId,SupplierId
ContractSigningPlans     | IX_ContractSigningPlans_MaKeHoach        | unique=1 | ([MaKeHoach] IS NOT NULL) | MaKeHoach

Các IX còn lại filter = - (không filter), unique=0 trừ IX_ContractSigningPlanLevelOpinions_ContractSigningPlanId_ApprovalWorkflowLevelId (unique=1, không filter — bàn ở §2e/§4).

FK thật (8 FK, cột delete_referential_action_desc)

Approvals    → ContractSigningPlans  CASCADE
Attachments  → ContractSigningPlans  CASCADE
Changelogs   → ContractSigningPlans  CASCADE
DossierItems → ContractSigningPlans  CASCADE
LevelOpinions→ ContractSigningPlans  CASCADE
Lines        → ContractSigningPlans  CASCADE
LevelOpinions→ ApprovalWorkflowLevels NO_ACTION   (= Restrict)
ContractSigningPlans → ApprovalWorkflows NO_ACTION (= Restrict)

→ 6 con Cascade 1-hop + 2 Restrict, đúng y spec §②-2/4. Zero FK vật lý sang PE/Project/Supplier/User/Contract (loose-Guid, convention Mig 49) — xác nhận bằng chính danh sách 8 FK trên (không có dòng nào trỏ PurchaseEvaluations/Suppliers/Projects).


2. Chấm điểm từng mục (a)(h)

(a) Down reversible sạch — PASS

:348-370 — 7 DropTable, thứ tự: Approvals · Attachments · Changelogs · DossierItems · LevelOpinions · Lines · ContractSigningPlans (cuối).

  • 6 bảng con (mọi bảng có FK Cascade trỏ Plans) drop TRƯỚC cha ⇒ 0 vi phạm FK-order.
  • Attachments → DossierItems không có FK vật lý (loose, chỉ HasIndex :252-254) ⇒ không tồn tại ràng buộc thứ tự giữa 2 bảng này; thứ tự alphabet hiện tại vẫn hợp lệ.
  • LevelOpinions → ApprovalWorkflowLevels (Restrict) và Plans → ApprovalWorkflows (Restrict): bảng cha nằm NGOÀI mig ⇒ drop bảng con không đụng cha.
  • Up chỉ CreateTable/CreateIndex ⇒ Down = đảo hoàn toàn, không có state nào sót lại. Không cần DropIndex riêng (DropTable cuốn index theo).
  • ⚠️ DB2: Down là destructive-by-nature (mất data 7 bảng). Với dry-run GĐ2 chưa có data prod thì rủi ro = 0; nếu chạy Down sau khi prod đã có phiếu ⇒ bắt buộc backup trước.

(b) 0 ALTER bảng cũ — PASS

Đếm operation trên toàn file: AddColumn/AlterColumn/DropColumn/RenameColumn/RenameTable/Sql( = 0 match. Chỉ 7 CreateTable + 20 CreateIndex (Up) + 7 DropTable (Down). → Không đụng 89 bảng cũ ⇒ deploy prod = additive thuần, không khoá bảng đang chạy, rollback rẻ. → ApprovalWorkflowApplicableType += ContractSigningPlan = 10 là đổi ENUM C# trên cột int sẵn có ⇒ đúng là không sinh migration (xác nhận: mig không có AlterColumn nào trên ApprovalWorkflows).

(c) FK strategy — PASS

Khớp 100% ý đồ spec (đo từ sys.foreign_keys, không đọc code):

  • 6 con → Plan: Cascade 1-hop
  • Plan → ApprovalWorkflows: Restrict (:42-47, config ContractSigningPlanConfiguration.cs:37-40) ✓ — không cho xoá quy trình khi còn phiếu pin.
  • LevelOpinions → ApprovalWorkflowLevels: Restrict (:196-201) ✓ — bảo vệ chữ ký.
  • 0 multiple-cascade-path: điểm nguy hiểm duy nhất là Attachments (vừa con của Plan vừa trỏ DossierItem). Đã né đúng cách bằng loose-Guid + HasIndex (ContractSigningPlanAttachmentConfiguration.cs:11-15,31). Nếu đặt FK Cascade ở đó thì Plan→DossierItem→Attachment + Plan→Attachment = 2 đường cascade ⇒ SQL Server từ chối CREATE TABLE ngay (lỗi 1785). Chứng cứ ngược: bảng tạo được trên cả 2 DB ⇒ không có multiple path.
  • ⚠️ Hệ quả cần W2/W3 biết (advisory, không phải lỗi schema): xoá 1 DossierItem KHÔNG tự dọn Attachments trỏ nó → ContractSigningPlanDossierItemId thành con trỏ mồ côi. Đây đúng khuôn PE, nhưng PE có handler dọn tay. W2 phải tự set null / xoá mềm attachment con khi xoá DossierItem, nếu không màn xem hồ sơ sẽ hiện file gắn vào mục đã biến mất.

(f) maxLength khớp spec :134-137PASS (11/11, +5 cột ngoài danh sách cũng đã khai)

Đo từ sys.columns (⚠️ max_length của nvarchar là BYTE = 2× số ký tự):

Cột (bảng) spec yêu cầu DB thật (byte) = ký tự verdict
MaKeHoach (Plans) 50 100 50
GhiChu (Plans) 2000 4000 2000
Name (DossierItems) 500 1000 500 ✓ NOT NULL
Note (DossierItems/Lines/Attachments) 1000 2000 1000 ×3
TvgsName (DossierItems) 200 400 200
Summary (Changelogs) 1000 2000 1000
ContextNote (Changelogs) 2000 4000 2000
SignedByFullName (LevelOpinions) 200 IsRequired 400, is_nullable=0 200
FileName (Attachments) 500 1000 500 ✓ NOT NULL
StoragePath (Attachments) 1000 2000 1000 ✓ NOT NULL
ContentType (Attachments) 200 400 200 ✓ NOT NULL

+5 cột chuỗi ngoài danh sách spec, đều đã khai độ dài (không rơi vào bẫy nvarchar(max)): HoSoLink 1000 (mirror PE Mig 52) · Comment (Approvals) 1000 · Comment (LevelOpinions) 2000 · UserName (Changelogs) 200 · FieldChangesJson = nvarchar(-1)=MAX, CỐ Ý (JSON tự do, không index — ContractSigningPlanChangelogConfiguration.cs:30).

Chỉ đúng 1 cột nvarchar(max) trong cả 7 bảng, và nó là cột được chỉ định. Không có cột chuỗi nào "quên khai" (rủi ro lens-schema C3 = đã chặn).

Tiền tệ: PeReferenceAmount/ProposedAmount/ApprovedAmount = decimal(18,2) đo từ sys.columns.precision/scale = 18/2 — khớp khuôn PurchaseEvaluationQuotes.BgVat/ChuaVat/ThanhTien (18/2). Không có cột tiền nào rơi về decimal(18,0) (bẫy mất phần lẻ).

(d) Index đủ cho query list/inbox tương lai — PASS

Trục 1 — mọi bảng con seek được theo PlanId (chống scan lúc mở chi tiết phiếu): 6/6 con có index dẫn đầu bằng ContractSigningPlanId: Lines(PlanId,SupplierId) unique-filtered · DossierItems(PlanId) · Attachments(PlanId) · Changelogs(PlanId,CreatedAt) + (PlanId,EntityType) · Approvals(PlanId,ApprovedAt) · LevelOpinions(PlanId,LevelId) unique. → Load 1 phiếu = 6 seek, 0 table scan. Đây là cái quan trọng nhất và nó đủ.

Trục 2 — list (W2): IX_ContractSigningPlans_Phase_IsDeleted (2 equality) + IX_ProjectId + IX_DrafterUserId + IX_PurchaseEvaluationId. Query "phiếu của tôi" / "theo dự án" / "PE này đã có kế hoạch chưa" (spec cũ :157 — tra ngược bằng Plans.Where(PurchaseEvaluationId==x), KHÔNG thêm cột lên PE) đều có index đích. ✓

Trục 3 — inbox (W3): WHERE Phase=ChoDuyet AND IsDeleted=0 → seek IX_Phase_IsDeleted; rồi lọc theo ApprovalWorkflowId (có IX) + 2 con-trỏ StepIndex/LevelOrder nằm sẵn trong row. ✓

Trục 4 — 3 site admin xoá Level: cả 3 đều lọc levelIds.Contains(o.ApprovalWorkflowLevelId)IX_ContractSigningPlanLevelOpinions_ApprovalWorkflowLevelId phục vụ đúng (ApprovalWorkflowV2AdminFeatures.cs:945 count · :1017 retained · :1083 purge). ✓ Đã wire đủ 3 site (grep xác nhận 3 vị trí, khớp yêu cầu spec §②-10).

Sai khác duy nhất so twin — IX_SlaDeadline KHÔNG có (cả Contracts lẫn PurchaseEvaluations đều có). Không phải thiếu sót: SlaExpiryJob chỉ quét db.Contracts (SlaExpiryJob.cs:76 + :138, 0 tham chiếu tới bảng khác), và entity khai tường minh ContractSigningPlan.cs:34-36 "SLA = FE hiển thị tham khảo, KHÔNG đăng ký SlaExpiryJob". Index không job nào quét = index chết. Bỏ là ĐÚNG. 🔸 Điều kiện lật: wave sau nếu wire KHKK vào SlaExpiryJob ⇒ phải thêm IX_ContractSigningPlans_SlaDeadline (1 mig nhỏ) trước khi bật job, không thì job quét full-table mỗi chu kỳ.

(e) UNIQUE filtered gotcha #57 — PASS

Đo từ sys.indexes.filter_definition (không đọc code):

  • IX_ContractSigningPlanLines_ContractSigningPlanId_SupplierIdis_unique=1, filter ([IsDeleted]=(0))ĐÚNG gotcha #57. Xoá mềm 1 NCC rồi thêm lại NCC đó = được (không đụng unique).
  • IX_ContractSigningPlans_MaKeHoachis_unique=1, filter ([MaKeHoach] IS NOT NULL) ✓ khuôn Contracts.MaHopDong / PurchaseEvaluations.MaPhieu (đo được cả 2 đều cùng dạng filter). Nháp chưa gen mã (MaKeHoach=null) không đụng nhau.
  • IX_ContractSigningPlanLevelOpinions_(PlanId,LevelId)is_unique=1, KHÔNG filter. 👉 ĐÚNG, không phải sót: đối chứng 2 twin đo trực tiếp trong DB — IX_PurchaseEvaluationLevelOpinions_(PeId,LevelId) unique KHÔNG filter · IX_ContractLevelOpinions_(ContractId,LevelId) unique KHÔNG filter. Họ *LevelOpinionUPSERT 1-row-per-level, không có luồng xoá-mềm-rồi-ký-lại ⇒ thêm filter sẽ lệch khuôn 7 twin và mở cửa cho 2 row cùng (Plan,Level).

(g) loose-Guid nào cần IX mà thiếu — PASS (superset của khuôn PE)

loose-Guid IX? đối chứng khuôn PE
Plans.PurchaseEvaluationId (PE không có twin — mới)
Plans.ProjectId PE có IX_ProjectId
Plans.DepartmentId PE KHÔNG có → CSP chặt hơn
Plans.DrafterUserId PE không có twin
Lines.SupplierId / Lines.ContractId ✓ / ✓ PE có IX_ContractId
DossierItems.SupplierId PE Attachments.PeSupplierId
Attachments.DossierItemId (loose cố ý, né multiple-cascade)
Approvals.ApprovalWorkflowLevelId PE Approvals KHÔNG có → CSP chặt hơn
Approvals.ApprovedByUserId PE Approvals cũng ✗ — parity, cột hiển thị không phải predicate
Changelogs.UserId / EntityId ✗ / ✗ PE Changelogs cũng ✗ — parity
LevelOpinions.SignedByUserId PE/Contract LevelOpinions cũng ✗ — parity

0 loose-Guid thiếu index mà có người truy vấn. 4 cột không index đều trùng khớp twin PE (chỉ đọc ra để hiển thị, không nằm trong WHERE của bất kỳ site nào đã wire).

(h) Snapshot drift — PASS (0 drift)

  1. Designer vs ModelSnapshot (diff sau khi bỏ dòng trống/comment): 15 dòng khác, TẤT CẢ là headerusing ...Migrations · [Migration("20260729122015_...")] · partial class AddContractSigningPlans vs ApplicationDbContextModelSnapshot : ModelSnapshot · BuildTargetModel vs BuildModel. 0 dòng khác biệt về model. (Designer 4.973 dòng / Snapshot 4.971 dòng — lệch đúng 2 dòng header.)
  2. git diff ModelSnapshot = 1 file changed, 550 insertions(+), 0 deletions — 3 hunk đều dạng -0 (thuần chèn): @@ -314,0 +315,453 @@ (7 entity) · @@ -5685,0 +6139,82 @@ (quan hệ) · @@ -6277,0 +6813,15 @@ (navigation). 👉 Không dòng nào của 89 bảng cũ bị sửa ⇒ chứng minh ở tầng MODEL rằng mig này additive thuần (khớp mục (b) ở tầng SQL).
  3. 3-file rule đủ: 20260729122015_AddContractSigningPlans.cs (22.059 B) + .Designer.cs (264.515 B) + ApplicationDbContextModelSnapshot.cs (264.399 B) — cả 3 đều xuất hiện trong git status (2 file đầu untracked-mới, snapshot modified). ✓ skill ef-core-migration.
  4. Tên file dùng timestamp EF chuẩn 20260729122015_, không có tiền tố 69_ ⇒ khớp acceptance §③-B dòng 1. __EFMigrationsHistory COUNT = 69 ⇒ đây đúng là mig thứ 69.

4. Kiểm tra DB11 (concurrency) — floor bắt buộc của vai này

ContractSigningPlanCodeGenerator.cs — PASS.

  • BeginTransactionAsync(IsolationLevel.Serializable, ct) (:23-24) bọc read-modify-write của sequence ✓ (đúng pattern tham chiếu WorkflowAppCodeGen).
  • (DbContext)db cast (:22) để với tới Database — đúng cách vì IApplicationDbContext chỉ expose DbSet + SaveChangesAsync.
  • Commit/Rollback đủ nhánh (:43, :48).
  • Hàng rào thứ 2 đo được trong DB: PK_WorkflowAppCodeSequences đặt trên chính cột Prefix (is_unique=1) ⇒ nếu 2 giao dịch cùng lần-đầu-tiên-của-năm chèn KHKK/2026, một bên vỡ PK và rollback — không thể sinh 2 row cùng prefix.
  • Hàng rào thứ 3: IX_ContractSigningPlans_MaKeHoach unique-filtered ⇒ dù codegen có lọt mã trùng thì INSERT phiếu vẫn bị chặn ở DB.
  • 🔸 Rủi ro còn lại (đã có tiền lệ, KHÔNG phải lỗi mới): SERIALIZABLE + đọc-rồi-ghi cùng row ⇒ 2 giao dịch đồng thời có thể deadlock (1205) thay vì xếp hàng. Fail-safe (một bên abort, mã KHÔNG trùng, không mất data), và đây là hành vi chung của 4 module Office đang chạy prod. Nếu sau này thấy 1205 trong log Submit ⇒ đổi câu đọc sang UPDLOCK, HOLDLOCK để biến deadlock thành chờ. Không đề xuất sửa trong wave này.
  • 🔸 Format {LastSeq:D3}: seq ≥ 1000 sẽ tự nới thành 4 chữ số (KHKK/2026/1000) — không lỗi, nhưng regex acceptance ^KHKK/\d{4}/\d{3}$ sẽ không còn khớp. Với nhịp phiếu KHKK thì còn rất xa; chỉ ghi để test-specialist biết ràng buộc của phép đo.

Không có write-path nào khác trong W1 (W1 chỉ đặt schema; handler CQRS thuộc W2/W3) ⇒ chưa có chỗ nào cần RowVersion. 🔸 Cảnh báo trước cho W3: Plans.Phase + CurrentWorkflowStepIndex/LevelOrder là bộ đếm bị 2 người duyệt đua — đây đúng lớp lỗi S43/S56 (LeaveBalance lost-update). W3 phải đi qua guard "đọc lại Phase trong transaction" hoặc ExecuteUpdate có điều kiện WHERE Phase = @expected, KHÔNG dùng bare-SaveChanges trên entity đã tracked.


5. Đề xuất (ADVISORY — không sửa gì, lead quyết)

A-1 (nên làm ở W2, KHÔNG cần migration) — chặn 2 kế hoạch DaDuyet cho cùng 1 PE. Plans.PurchaseEvaluationIdNOT NULL + IX không unique ⇒ DB cho phép N kế hoạch trên 1 PE. W5 lại tra bằng Plans.Where(PurchaseEvaluationId==peId && Phase==DaDuyet) (spec-wave-w5:13) — nếu có 2 phiếu DaDuyet cùng PE thì câu này mơ hồ, lấy nhầm giá ApprovedAmount là sai tiền. → Đề xuất: guard trong handler Create/Approve của W2/W3 ("PE này đã có kế hoạch đang mở/đã duyệt"). KHÔNG đề xuất thêm filtered-unique index vì (i) plan cha chốt "Mig 69 = migration DUY NHẤT", (ii) chưa rõ nghiệp vụ có cho làm lại kế hoạch sau khi 1 cái bị huỷ hay không — khoá cứng ở DB lúc này là quyết định thay owner.

A-2 (W2) — xoá DossierItem phải dọn Attachments con. Không có FK vật lý giữa 2 bảng (cố ý, để né multiple-cascade-path) ⇒ DB không tự dọn. Thiếu bước này thì màn hồ sơ hiện file gắn vào mục đã biến mất.

A-3 (chỉ khi wire SLA job) — thêm IX_ContractSigningPlans_SlaDeadline. Hiện KHÔNG cần (job chỉ quét Contracts).

A-4 (vận hành, DB2) — Down xoá 7 bảng + toàn bộ data trong đó. Script Down đã sinh và đọc sạch, nhưng nếu chạy sau khi prod đã có phiếu thì phải backup trước. Với dry-run GĐ2 (0 row) thì rollback an toàn.

KHÔNG có đề xuất nào bắt buộc trước deploy.


6. Bằng chứng Down chạy được (DB2-safe: sinh SCRIPT, KHÔNG apply)

dotnet ef migrations script 20260729122015_AddContractSigningPlans 20260727033522_AddPeAllowApproverDelete --no-build

BEGIN TRANSACTION;
DROP TABLE [ContractSigningPlanApprovals];
DROP TABLE [ContractSigningPlanAttachments];
DROP TABLE [ContractSigningPlanChangelogs];
DROP TABLE [ContractSigningPlanDossierItems];
DROP TABLE [ContractSigningPlanLevelOpinions];
DROP TABLE [ContractSigningPlanLines];
DROP TABLE [ContractSigningPlans];
DELETE FROM [__EFMigrationsHistory] WHERE [MigrationId] = N'20260729122015_AddContractSigningPlans';
COMMIT;

→ Sinh không lỗi, 7 DROP + xoá history nằm trong MỘT transaction (atomic, không kẹt nửa chừng). Chiều Up cũng sinh sạch (đuôi script: 20 CREATE INDEX + INSERT history ProductVersion = 10.0.6 = EF Core 10, đúng pin). ⚠️ Tôi CỐ Ý KHÔNG chạy database update <prev> trên LocalDB Dev — đó là lệnh destructive (DB2), và acceptance "Down chạy sạch trên DB copy" thuộc quyền lead/implementer trên bản copy, không phải trên DB Dev đang dùng.


§END — VERDICT

# Mục Verdict
a Down reversible sạch (7 DropTable đúng thứ tự FK) PASS
b 0 ALTER bảng cũ PASS (0 AddColumn/AlterColumn/DropColumn/Sql)
c FK strategy 6 Cascade + 2 Restrict, 0 multiple-cascade-path PASS
d Index đủ cho list/inbox PASS (6/6 con seek theo PlanId; thiếu IX_SlaDeadline là CỐ Ý ĐÚNG)
e UNIQUE filtered gotcha #57 PASS (Lines [IsDeleted]=(0) ✓; LevelOpinions không filter = đúng khuôn 2 twin)
f maxLength khớp spec :134-137 PASS 11/11 + 5 cột ngoài spec; đúng 1 nvarchar(max) cố ý
g loose-Guid thiếu IX PASS — superset khuôn PE, 4 cột không IX đều parity
h Snapshot drift PASS — Designer ≡ Snapshot (chỉ khác header), diff 550 insert / 0 delete
sqlcmd đối chứng Dev + Design PASS — 96/96 bảng, 7/7 CSP, 27/27 index, mig-top khớp, 2 DB parity
DB11 concurrency (codegen) PASS — SERIALIZABLE + PK(Prefix) + unique-filtered MaKeHoach = 3 lớp

KẾT LUẬN: PASS TOÀN BỘ — Mig 69 ĐỦ ĐIỀU KIỆN DEPLOY. 0 blocker, 0 finding phải sửa trước deploy. 4 advisory (A-1..A-4) đều thuộc wave sau / vận hành.

TOTAL: 10 dòng verdict · 0 FAIL · 4 advisory.