[CLAUDE] Contract: K2 SP-002 phieu KHKK mang hang-muc + nhom duyet N1-8 (Mig 71) + FE x2 app + 11 test
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m12s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m12s
Mig 71 AddKhkkApprovalGroupCatalogAndFinalizeRuntime: +ApprovalGroup (backfill=1)
+EndedByLevelFinalize (K2 de, K3 tieu - ghost-window khai) + Line +CatalogEntryId/
TenHangMuc + unique (Plan,Supplier,CatalogEntry) filtered IsDeleted=0. Rao
1-PE-1-phieu doi (PeId,ApprovalGroup) 2 site + picker OG-7 ExistingGroups + nut
'Tao them cho nhom khac'. Submit-guard va-5 (CHAN null, thang nen cho-qua-legacy;
ghost-window K2->K4b khai) + dem-so-khop (gate F-1: bit duong hang-muc xoa-mem).
List ?group= end-to-end + Tong-Approved list (va-8). FE: select nhom default 1 +
badge N{g} + cot Hang-muc + cot Tong-duyet, SHA-pair 5/5. Gate K2 PASS-WITH-FLAGS
10f: F-1 guard fix + 2 test bit (xoa-mem chan + reflection ?group=); F-2/F-5/F-7/
F-8/F-9 comment-honest; F-3/F-4 docs-delta. Suite 609/0 (45D+564I). Wave co #53 x5,
artifact + relaunch-cat-got cuu 100% (0 mat cong).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@ -39,13 +39,14 @@ public class ContractSigningPlansController(
|
||||
[FromQuery] Guid? projectId = null,
|
||||
[FromQuery] Guid? purchaseEvaluationId = null,
|
||||
[FromQuery] bool pendingMe = false,
|
||||
[FromQuery(Name = "group")] int? group = null,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
[FromQuery] string? search = null,
|
||||
[FromQuery] bool sortDesc = true,
|
||||
CancellationToken ct = default)
|
||||
=> Ok(await mediator.Send(
|
||||
new ListContractSigningPlansQuery(phase, projectId, purchaseEvaluationId, pendingMe)
|
||||
new ListContractSigningPlansQuery(phase, projectId, purchaseEvaluationId, pendingMe, group)
|
||||
{ Page = page, PageSize = pageSize, Search = search, SortDesc = sortDesc }, ct));
|
||||
|
||||
/// Inbox "chờ tôi duyệt" — wire sẵn cho W3 (W2 trả rỗng vì chưa có đường trình duyệt).
|
||||
|
||||
@ -54,7 +54,14 @@ public record ContractSigningPlanListItemDto(
|
||||
decimal TotalProposedAmount,
|
||||
DateTime? SlaDeadline,
|
||||
DateTime CreatedAt,
|
||||
DateTime? UpdatedAt);
|
||||
DateTime? UpdatedAt,
|
||||
// [K2 S164] append-CUỐI. `ApprovalGroup` = nhóm duyệt 1..8 (cây 8 folder + badge).
|
||||
// `TotalApprovedAmount` (vá-8): list ĐANG có 2 Σ (PeReference `:711` + Proposed `:713`)
|
||||
// nhưng KHÔNG có Σ của số ĐÃ CHỐT ⇒ sau khi phiếu DaDuyet, màn danh sách vẫn khoe số
|
||||
// đề xuất trong khi màn chi tiết đã khoá theo `ApprovedAmount` = hai màn lệch số trên
|
||||
// cùng một phiếu. Σ này đóng đúng khe đó. Phiếu chưa duyệt → mọi dòng null → 0.
|
||||
int ApprovalGroup,
|
||||
decimal TotalApprovedAmount);
|
||||
|
||||
public record ContractSigningPlanLineDto(
|
||||
Guid Id,
|
||||
@ -65,7 +72,11 @@ public record ContractSigningPlanLineDto(
|
||||
decimal ProposedAmount,
|
||||
decimal? ApprovedAmount,
|
||||
Guid? ContractId,
|
||||
string? Note);
|
||||
string? Note,
|
||||
// [K2 S164] append-CUỐI — hạng mục SP-002 của dòng. `TenHangMuc` là DENORM lúc gán
|
||||
// (đừng join ngược lấy tên mới: danh mục đổi tên không được viết lại lịch sử phiếu).
|
||||
Guid? CatalogEntryId,
|
||||
string? TenHangMuc);
|
||||
|
||||
public record ContractSigningPlanDossierItemDto(
|
||||
Guid Id,
|
||||
@ -144,7 +155,15 @@ public record ContractSigningPlanDetailDto(
|
||||
List<ContractSigningPlanDossierItemDto> DossierItems,
|
||||
List<ContractSigningPlanAttachmentDto> Attachments,
|
||||
List<ContractSigningPlanLevelOpinionDto> LevelOpinions,
|
||||
List<ContractSigningPlanWorkflowStepDto> WorkflowSteps);
|
||||
List<ContractSigningPlanWorkflowStepDto> WorkflowSteps,
|
||||
// [K2 S164] append-CUỐI (sau cả các List — convention của file là append cuối CÙNG,
|
||||
// không chèn giữa; JSON bind theo TÊN nên FE không quan tâm vị trí).
|
||||
int ApprovalGroup,
|
||||
// Display-gate (khuôn tên `budgetFrozen` PE S133): phiếu DaDuyet ⇒ FE đọc `ApprovedAmount`
|
||||
// + badge khoá; trước đó đọc `ProposedAmount`. Đây là CỜ HIỂN THỊ dẫn xuất từ Phase,
|
||||
// KHÔNG phải cột DB — số đã được đóng băng ở tầng dữ liệu bởi `ApprovedAmount ??=` tại
|
||||
// choke-point finalize (`ContractSigningPlanWorkflowService.cs:301-305`).
|
||||
bool BudgetFrozen);
|
||||
|
||||
/// Dòng picker "PE đã duyệt — chờ lập kế hoạch ký kết".
|
||||
public record ApprovedPeAwaitingPlanDto(
|
||||
@ -162,7 +181,14 @@ public record ApprovedPeAwaitingPlanDto(
|
||||
decimal WinnerQuoteTotal,
|
||||
DateTime CreatedAt,
|
||||
DateTime? UpdatedAt,
|
||||
List<string> WinnerSupplierNames); // [W2 S161 — F-6] FE render chip tên NCC trúng thầu (append cuối)
|
||||
List<string> WinnerSupplierNames, // [W2 S161 — F-6] FE render chip tên NCC trúng thầu (append cuối)
|
||||
// [K2 S164 — OG-7 UI-hướng-1] Nhóm duyệt của các kế hoạch ĐANG SỐNG trên PE này
|
||||
// (`Phase != TuChoi`, xoá mềm đã bị global filter loại). Rỗng = PE chưa có kế hoạch nào.
|
||||
// 🔴 Đổi HÌNH DẠNG picker: trước K2, PE có kế hoạch bị LOẠI khỏi danh sách (biến mất);
|
||||
// giờ vẫn hiện KÈM danh sách nhóm đã dùng để FE hiện nút "Tạo thêm cho nhóm khác" và
|
||||
// disable đúng những nhóm đã có. Ẩn phiếu đi thì user không phân biệt được
|
||||
// "PE không đủ điều kiện" với "PE đã dùng hết nhóm" — hai thứ khác nhau.
|
||||
List<int> ExistingGroups);
|
||||
|
||||
public record CreateContractSigningPlanResult(Guid Id, string MaKeHoach, int LineCount);
|
||||
|
||||
@ -170,6 +196,12 @@ public record CreateContractSigningPlanResult(Guid Id, string MaKeHoach, int Lin
|
||||
|
||||
internal static class ContractSigningPlanScope
|
||||
{
|
||||
// [K2 S164] Biên nhóm duyệt N1..N8 (SP-002) + default hướng-1 (OG-1/OG-7 UI-1-1:
|
||||
// FE không gửi nhóm ⇒ phiếu vào nhóm 1 — khớp backfill Mig 71 ApprovalGroup=1).
|
||||
internal const int MinApprovalGroup = 1;
|
||||
internal const int MaxApprovalGroup = 8;
|
||||
internal const int DefaultApprovalGroup = 1;
|
||||
|
||||
/// Phase cho phép SỬA nội dung nháp (header + căn cứ b.8-9).
|
||||
internal static bool IsDraftEditable(ContractSigningPlanPhase phase)
|
||||
=> phase is ContractSigningPlanPhase.DangSoanThao or ContractSigningPlanPhase.TraLai;
|
||||
@ -291,10 +323,18 @@ internal static class ContractSigningPlanScope
|
||||
/// W2 §②-2 viết đúng chữ đó và lane FE dựng body JSON từ CÙNG bản spec ⇒ đổi tên ở đây
|
||||
/// là gãy tích hợp giữa 2 lane. DTO trả về vẫn dùng tên đầy đủ.
|
||||
/// </remarks>
|
||||
/// <param name="ApprovalGroup">
|
||||
/// [K2 S164] Nhóm duyệt N1..N8 của kế hoạch. **NULL = nhóm 1** (mặc định) — CỐ Ý nullable
|
||||
/// thay vì `int` bắt buộc: client hiện tại (`KhkkCreatePage.tsx:73-75` ×2 app) CHƯA gửi
|
||||
/// trường này, mà giá trị bỏ trống của `int` là 0 → rơi thẳng vào 400 "ngoài 1..8" ⇒ đổi
|
||||
/// sang bắt buộc là làm gãy màn tạo phiếu đang chạy. `null → 1` khớp đúng ngữ nghĩa
|
||||
/// backfill của Mig 71 (mọi phiếu cũ = nhóm 1). Client gửi số thì validator 1..8 vẫn có răng.
|
||||
/// </param>
|
||||
public record CreateContractSigningPlanCommand(
|
||||
Guid PeId,
|
||||
Guid ApprovalWorkflowId,
|
||||
string? GhiChu = null) : IRequest<CreateContractSigningPlanResult>;
|
||||
string? GhiChu = null,
|
||||
int? ApprovalGroup = null) : IRequest<CreateContractSigningPlanResult>;
|
||||
|
||||
public class CreateContractSigningPlanCommandValidator : AbstractValidator<CreateContractSigningPlanCommand>
|
||||
{
|
||||
@ -305,6 +345,12 @@ public class CreateContractSigningPlanCommandValidator : AbstractValidator<Creat
|
||||
// MaxLength MATCH EF config `ContractSigningPlanConfiguration.cs:22` (GhiChu = 2000).
|
||||
// EF là source of truth — KHÔNG lấy số từ spec (bài S35).
|
||||
RuleFor(x => x.GhiChu).MaximumLength(2000);
|
||||
// Chỉ soi khi client CÓ gửi (`.When`): null là đường hợp lệ (= nhóm 1), 0 hoặc 9 thì không.
|
||||
RuleFor(x => x.ApprovalGroup)
|
||||
.InclusiveBetween(ContractSigningPlanScope.MinApprovalGroup,
|
||||
ContractSigningPlanScope.MaxApprovalGroup)
|
||||
.When(x => x.ApprovalGroup.HasValue)
|
||||
.WithMessage("Nhóm duyệt phải trong khoảng N1..N8.");
|
||||
}
|
||||
}
|
||||
|
||||
@ -336,15 +382,26 @@ public class CreateContractSigningPlanCommandHandler(
|
||||
if (winners.Count == 0)
|
||||
throw new ConflictException("Phiếu chưa chọn NCC/TP trúng thầu — chưa có dòng giá để lập kế hoạch.");
|
||||
|
||||
// ---- Rào (iii) 1 PE = 1 kế hoạch SỐNG. `TuChoi` KHÔNG tính là sống ⇒ phiếu bị từ
|
||||
// chối vẫn lập lại được. Phiếu xoá mềm cũng không tính (global filter tự loại) ⇒
|
||||
// xoá xong tạo lại được (acceptance §③-B "DELETE → tạo lại → 201").
|
||||
// ---- Rào (iii) [K2 S164 — OG-7] 1 PE = 1 kế hoạch SỐNG **MỖI NHÓM DUYỆT** (trước K2 là
|
||||
// 1 kế hoạch/PE tuyệt đối). Một gói thầu có thể phải ký NHIỀU loại hợp đồng thuộc các
|
||||
// nhóm duyệt khác nhau ⇒ khoá theo PE là khoá nhầm chiều. `TuChoi` KHÔNG tính là sống ⇒
|
||||
// phiếu bị từ chối vẫn lập lại được. Phiếu xoá mềm cũng không tính (global filter tự
|
||||
// loại) ⇒ xoá xong tạo lại được (acceptance §③-B "DELETE → tạo lại → 201").
|
||||
// 🔴 CỐ Ý KHÔNG dựng UNIQUE (PeId, ApprovalGroup) dưới DB — lý do THẬT (gate-K2 F-7 sửa;
|
||||
// bản cũ nói "unique không diễn tả được Phase != TuChoi" là SAI kỹ thuật: filtered index
|
||||
// `WHERE [Phase] <> 3 AND [IsDeleted] = 0` diễn tả được). Đợt này chấp nhận rào handler
|
||||
// (check-then-insert, còn cửa race 2-request-đồng-thời nhỏ) vì Phase là cột mutable +
|
||||
// UI-hướng-1; cần chống race tuyệt đối thì dựng filtered unique ở wave sau.
|
||||
var approvalGroup = request.ApprovalGroup ?? ContractSigningPlanScope.DefaultApprovalGroup;
|
||||
|
||||
var hasLivePlan = await db.ContractSigningPlans
|
||||
.AnyAsync(p => p.PurchaseEvaluationId == pe.Id
|
||||
&& p.ApprovalGroup == approvalGroup
|
||||
&& p.Phase != ContractSigningPlanPhase.TuChoi, ct);
|
||||
if (hasLivePlan)
|
||||
throw new ConflictException(
|
||||
"Phiếu Duyệt NCC này đã có kế hoạch ký kết. Xoá hoặc để bị từ chối kế hoạch cũ trước khi lập lại.");
|
||||
$"Phiếu Duyệt NCC này đã có kế hoạch ký kết cho nhóm duyệt N{approvalGroup}. " +
|
||||
"Chọn nhóm duyệt khác, hoặc xoá/để bị từ chối kế hoạch cũ của nhóm này trước khi lập lại.");
|
||||
|
||||
// ---- Rào (iv) WORKFLOW TYPE — copy trọn 2 nhánh Proposal `:258-267`.
|
||||
await ContractSigningPlanScope.EnsureWorkflowTypeAsync(db, request.ApprovalWorkflowId, ct);
|
||||
@ -371,6 +428,7 @@ public class CreateContractSigningPlanCommandHandler(
|
||||
// `Guid.Empty` cho phiếu PE cũ thiếu drafter = hỏng cả quyền sửa lẫn scope list.
|
||||
DrafterUserId = actorId,
|
||||
Phase = ContractSigningPlanPhase.DangSoanThao,
|
||||
ApprovalGroup = approvalGroup, // [K2 S164] nhóm duyệt của phiếu — chốt lúc TẠO
|
||||
ApprovalWorkflowId = request.ApprovalWorkflowId,
|
||||
GhiChu = string.IsNullOrWhiteSpace(request.GhiChu) ? null : request.GhiChu.Trim(),
|
||||
};
|
||||
@ -390,6 +448,13 @@ public class CreateContractSigningPlanCommandHandler(
|
||||
{
|
||||
ContractSigningPlanId = plan.Id,
|
||||
SupplierId = w.SupplierId,
|
||||
// [K2 S164] Mồi hạng mục = NULL CÓ CHỦ ĐÍCH. Lúc tạo, hệ thống biết NCC nào
|
||||
// trúng thầu nhưng KHÔNG biết dòng đó sẽ ký theo hạng mục SP-002 nào — đoán bừa
|
||||
// một hạng mục là bịa dữ liệu pháp lý. Người soạn gán sau qua
|
||||
// `UpdateContractSigningPlanLineCommand`; cửa ra là SUBMIT-GUARD
|
||||
// (`ContractSigningPlanWorkflowService.SubmitAsync`) chặn trình khi còn dòng NULL.
|
||||
CatalogEntryId = null,
|
||||
TenHangMuc = null,
|
||||
PeReferenceAmount = peReference,
|
||||
// Mồi ProposedAmount = số PE. W2 CHƯA có đường sửa dòng giá (spec §②-2 không
|
||||
// liệt kê command nào cho Line) ⇒ để 0 thì cả phiếu là 0 và không ai sửa được.
|
||||
@ -571,7 +636,11 @@ public class GetContractSigningPlanQueryHandler(IApplicationDbContext db, ICurre
|
||||
l.Id, l.SupplierId,
|
||||
suppliers.TryGetValue(l.SupplierId, out var sup) ? sup.Name : null,
|
||||
sup?.Code,
|
||||
l.PeReferenceAmount, l.ProposedAmount, l.ApprovedAmount, l.ContractId, l.Note))
|
||||
l.PeReferenceAmount, l.ProposedAmount, l.ApprovedAmount, l.ContractId, l.Note,
|
||||
// [K2 S164] Tên hạng mục lấy từ CỘT DENORM của chính dòng, KHÔNG join ngược sang
|
||||
// `ContractCatalogEntries`: danh mục sửa tên/xoá mềm sau ngày ký không được phép
|
||||
// viết lại nội dung phiếu đã lập (khuôn `SignedByFullName` Mig 26).
|
||||
l.CatalogEntryId, l.TenHangMuc))
|
||||
.ToList();
|
||||
|
||||
var dossierItems = plan.DossierItems
|
||||
@ -613,17 +682,29 @@ public class GetContractSigningPlanQueryHandler(IApplicationDbContext db, ICurre
|
||||
plan.CurrentWorkflowStepIndex, plan.CurrentApprovalLevelOrder,
|
||||
plan.SlaDeadline, plan.HoSoLink, plan.GhiChu,
|
||||
plan.CreatedAt, plan.UpdatedAt,
|
||||
lines, dossierItems, attachments, opinions, stepDtos);
|
||||
lines, dossierItems, attachments, opinions, stepDtos,
|
||||
plan.ApprovalGroup,
|
||||
// [K2 S164] `BudgetFrozen` = CỜ HIỂN THỊ dẫn xuất từ Phase, KHÔNG phải cột DB. Số đã
|
||||
// được đóng băng ở tầng dữ liệu bởi `ApprovedAmount ??=` tại choke-point finalize
|
||||
// (`ContractSigningPlanWorkflowService.ApplyApprovedValuesOnFinalize`); cờ này chỉ
|
||||
// bảo FE "đọc `ApprovedAmount` + khoá ô" thay vì đọc `ProposedAmount`.
|
||||
plan.Phase == ContractSigningPlanPhase.DaDuyet);
|
||||
}
|
||||
}
|
||||
|
||||
// ======================= Region 4: LIST / INBOX / ĐÃ XOÁ ============================
|
||||
|
||||
/// <param name="ApprovalGroup">
|
||||
/// [K2 S164] Lọc theo nhóm duyệt N1..N8. K2 là CHỦ tham số này; cây 8 folder (K5) và các
|
||||
/// màn con (K4b) chỉ TIÊU THỤ — thêm chỗ lọc thứ hai ở tầng trên là đẻ hai định nghĩa
|
||||
/// "thuộc nhóm N" rồi lệch nhau. `null` = mọi nhóm (giữ nguyên hành vi trước K2).
|
||||
/// </param>
|
||||
public record ListContractSigningPlansQuery(
|
||||
ContractSigningPlanPhase? Phase = null,
|
||||
Guid? ProjectId = null,
|
||||
Guid? PurchaseEvaluationId = null,
|
||||
bool PendingMe = false) : PagedRequest, IRequest<PagedResult<ContractSigningPlanListItemDto>>;
|
||||
bool PendingMe = false,
|
||||
int? ApprovalGroup = null) : PagedRequest, IRequest<PagedResult<ContractSigningPlanListItemDto>>;
|
||||
|
||||
public class ListContractSigningPlansQueryHandler(IApplicationDbContext db, ICurrentUser currentUser)
|
||||
: IRequestHandler<ListContractSigningPlansQuery, PagedResult<ContractSigningPlanListItemDto>>
|
||||
@ -653,6 +734,10 @@ public class ListContractSigningPlansQueryHandler(IApplicationDbContext db, ICur
|
||||
if (request.ProjectId is not null) q = q.Where(p => p.ProjectId == request.ProjectId);
|
||||
if (request.PurchaseEvaluationId is not null)
|
||||
q = q.Where(p => p.PurchaseEvaluationId == request.PurchaseEvaluationId);
|
||||
// [K2 S164] Lọc nhóm duyệt — đặt CÙNG chỗ với các vế lọc khác, SAU vế IDOR ở trên:
|
||||
// lọc không bao giờ được là thứ NỚI phạm vi thấy được, chỉ được thu hẹp.
|
||||
if (request.ApprovalGroup is not null)
|
||||
q = q.Where(p => p.ApprovalGroup == request.ApprovalGroup);
|
||||
|
||||
if (request.PendingMe)
|
||||
{
|
||||
@ -711,7 +796,14 @@ public class ListContractSigningPlansQueryHandler(IApplicationDbContext db, ICur
|
||||
.Sum(l => (decimal?)l.PeReferenceAmount) ?? 0m,
|
||||
db.ContractSigningPlanLines.Where(l => l.ContractSigningPlanId == p.Id)
|
||||
.Sum(l => (decimal?)l.ProposedAmount) ?? 0m,
|
||||
p.SlaDeadline, p.CreatedAt, p.UpdatedAt))
|
||||
p.SlaDeadline, p.CreatedAt, p.UpdatedAt,
|
||||
p.ApprovalGroup,
|
||||
// [K2 S164 — vá-8] Σ số ĐÃ CHỐT. Thiếu Σ này thì phiếu `DaDuyet` khoe số ĐỀ XUẤT
|
||||
// ở màn danh sách trong khi màn chi tiết đã khoá theo `ApprovedAmount` ⇒ hai màn
|
||||
// nói hai con số trên CÙNG một phiếu. `ApprovedAmount` vốn nullable ⇒ phiếu chưa
|
||||
// duyệt cho ra `null` → `0`, đúng nghĩa "chưa chốt đồng nào".
|
||||
db.ContractSigningPlanLines.Where(l => l.ContractSigningPlanId == p.Id)
|
||||
.Sum(l => l.ApprovedAmount) ?? 0m))
|
||||
.ToListAsync(ct);
|
||||
|
||||
return new PagedResult<ContractSigningPlanListItemDto>(items, total, page, pageSize);
|
||||
@ -1198,8 +1290,11 @@ public class ListApprovedPeAwaitingPlanQueryHandler(IApplicationDbContext db)
|
||||
from wi in wij.DefaultIfEmpty()
|
||||
where e.Phase == PurchaseEvaluationPhase.DaDuyet
|
||||
&& db.PurchaseEvaluationSuppliers.Any(sw => sw.PurchaseEvaluationId == e.Id && sw.IsWinner)
|
||||
&& !db.ContractSigningPlans.Any(pl => pl.PurchaseEvaluationId == e.Id
|
||||
&& pl.Phase != ContractSigningPlanPhase.TuChoi)
|
||||
// 🔴 [K2 S164 — OG-7] ĐÃ GỠ vế `&& !db.ContractSigningPlans.Any(...)`: rào không còn
|
||||
// là "1 PE = 1 kế hoạch" mà là "1 PE = 1 kế hoạch MỖI NHÓM" ⇒ loại PE-đã-có-phiếu
|
||||
// khỏi picker sẽ chặn luôn đường lập cho nhóm khác (chính là năng lực K2 mở ra).
|
||||
// Vế đó chuyển thành DỮ LIỆU trả về (`ExistingGroups` dưới) để FE khoá đúng nhóm
|
||||
// đã dùng — rào cứng 409 vẫn nằm ở Create, picker chỉ có nhiệm vụ không mời nhầm.
|
||||
orderby e.UpdatedAt ?? e.CreatedAt descending
|
||||
select new ApprovedPeAwaitingPlanDto(
|
||||
e.Id, e.MaPhieu, e.TenGoiThau,
|
||||
@ -1223,6 +1318,14 @@ public class ListApprovedPeAwaitingPlanQueryHandler(IApplicationDbContext db)
|
||||
where sw.PurchaseEvaluationId == e.Id && sw.IsWinner
|
||||
join sup in db.Suppliers on sw.SupplierId equals sup.Id
|
||||
orderby sup.Name
|
||||
select sup.Name).ToList())).ToListAsync(ct);
|
||||
select sup.Name).ToList(),
|
||||
// [K2 S164] Nhóm đã dùng — CÙNG HÌNH DẠNG subquery-ToList với vế trên (khuôn đã
|
||||
// chạy thật), KHÔNG `Distinct()`: rào Create bảo đảm mỗi (PE, nhóm) tối đa 1 phiếu
|
||||
// sống ⇒ danh sách vốn đã không trùng; thêm Distinct chỉ để SQL sinh phức tạp hơn.
|
||||
(from pl in db.ContractSigningPlans
|
||||
where pl.PurchaseEvaluationId == e.Id
|
||||
&& pl.Phase != ContractSigningPlanPhase.TuChoi
|
||||
orderby pl.ApprovalGroup
|
||||
select pl.ApprovalGroup).ToList())).ToListAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
@ -30,6 +30,26 @@ public class ContractSigningPlan : AuditableEntity
|
||||
|
||||
public ContractSigningPlanPhase Phase { get; set; } = ContractSigningPlanPhase.DangSoanThao;
|
||||
|
||||
// [K2 Mig 71 — S164] NHÓM DUYỆT 1..8 (N1..N8 theo SOL-CCM-SP-002). 1 phiếu = 1 nhóm,
|
||||
// N hạng mục CÙNG nhóm. Phiếu cũ backfill = 1 (Mig 71 `Sql UPDATE`) ⇒ COUNT(phiếu) ==
|
||||
// Σ COUNT 8 folder, không có phiếu nào rơi ngoài cây.
|
||||
// 🔴 K2 là CHỦ DUY NHẤT của cột này (QĐ-3/spec §②-3): K3 chỉ ĐỌC (map Code `KHKK-N{n}`
|
||||
// ↔ group + validator phiếu-pin-đúng-nhóm). Range 1..8 gác bằng validator, KHÔNG CHECK
|
||||
// constraint (khuôn repo: `ContractCatalogEntry.ApprovalGroup` cũng vậy).
|
||||
// 🔴 [gate-K2 F-9] Mig 71 để DEFAULT constraint 0 (AddColumn) — app LUÔN set giá trị;
|
||||
// CẤM INSERT SQL thô (họ #84): SQL thô sẽ ra nhóm 0 ngoài dải 1..8, rơi khỏi cây 8 folder câm lặng.
|
||||
public int ApprovalGroup { get; set; } = 1;
|
||||
|
||||
// [K2 Mig 71 — S164, forward-provision cho K3] Cờ RUNTIME "phiếu kết thúc SỚM bằng
|
||||
// level-finalize" (approver ở Cấp có `AllowApproverFinalize` tick kết-thúc-tại-đây).
|
||||
// 🔴 GHOST-WINDOW KHAI TRƯỚC: K2 ĐẺ cột, K3 TIÊU THỤ. Từ lúc K2 land tới lúc K3 land,
|
||||
// cột này NẰM IM = luôn false, KHÔNG có site nào ghi (service KHKK hiện chỉ có đúng một
|
||||
// nhánh set DaDuyet — `ContractSigningPlanWorkflowService.cs:277-278` — và nhánh đó là
|
||||
// đường THƯỜNG, không phải finalize sớm). Cố ý gộp vào Mig 71 để K3 = 0-mig
|
||||
// (spec §②-9 + vá-7 item-6). Đọc RUNTIME chứ không suy từ CONFIG = né tái diễn #78
|
||||
// (PE S97: phiếu opt-out vẫn lên CEO mà display suy từ cờ config nên báo sai).
|
||||
public bool EndedByLevelFinalize { get; set; }
|
||||
|
||||
// Workflow V2 pin (ApplicableType = ContractSigningPlan = 10). Con-trỏ ĐÔI mirror PE:
|
||||
// StepIndex 0-based (Bước) + LevelOrder 1-based (Cấp trong Bước). Null khi terminal.
|
||||
public Guid? ApprovalWorkflowId { get; set; }
|
||||
|
||||
@ -14,6 +14,18 @@ public class ContractSigningPlanLine : AuditableEntity
|
||||
public Guid ContractSigningPlanId { get; set; }
|
||||
public Guid SupplierId { get; set; } // loose-Guid (KHÔNG FK vật lý sang Suppliers)
|
||||
|
||||
// [K2 Mig 71 — S164] Hạng mục danh mục SP-002 của dòng này (→ `ContractCatalogEntries.Id`).
|
||||
// 🔴 loose-Guid KHÔNG FK VẬT LÝ — mirror `PurchaseEvaluation.WorkItemId` (Mig 49) và
|
||||
// chính `SupplierId` ngay trên: convention module là mọi con trỏ ra ngoài module đều
|
||||
// loose. Thêm FK thật ở đây = phá khuôn + chặn soft-delete dòng danh mục.
|
||||
// NULL = dòng legacy (phiếu lập trước K2) hoặc dòng vừa auto-sinh chưa gán hạng mục.
|
||||
// Trình duyệt thì BẮT BUỘC có (submit-guard `ContractSigningPlanWorkflowService`).
|
||||
public Guid? CatalogEntryId { get; set; }
|
||||
|
||||
// Denorm tên hạng mục TẠI THỜI ĐIỂM GÁN — danh mục đổi tên sau KHÔNG viết lại lịch sử
|
||||
// phiếu (khuôn `SignedByFullName` Mig 26). Đọc để hiển thị; đừng join ngược lấy tên mới.
|
||||
public string? TenHangMuc { get; set; }
|
||||
|
||||
// SNAPSHOT tổng báo giá của NCC này trên phiếu PE (SUM Quote.IsSelected) tại thời
|
||||
// điểm TẠO kế hoạch — chụp 1 lần, KHÔNG đọc live (PE sửa sau không được làm trôi
|
||||
// số của kế hoạch đã lập; cùng triết lý freeze ngân sách Mig 67).
|
||||
|
||||
@ -28,6 +28,17 @@ public class ContractSigningPlanConfiguration : IEntityTypeConfiguration<Contrac
|
||||
b.HasIndex(x => x.MaKeHoach).IsUnique().HasFilter("[MaKeHoach] IS NOT NULL");
|
||||
|
||||
b.HasIndex(x => x.PurchaseEvaluationId);
|
||||
|
||||
// [K2 Mig 71 — S164] Rào "1 PE = 1 phiếu" đổi thành "(PE × NHÓM DUYỆT)" [OG-7] ⇒ 1 PE
|
||||
// được N phiếu miễn khác nhóm. 🔴 CỐ Ý **KHÔNG** dựng unique (PeId, ApprovalGroup) —
|
||||
// lý do THẬT (gate-K2 F-7 sửa; bản cũ "unique không diễn tả được Phase != TuChoi" SAI:
|
||||
// filtered index `WHERE [Phase] <> 3 AND [IsDeleted] = 0` diễn tả được). Đợt này chấp
|
||||
// nhận rào handler (check-then-insert, còn cửa race nhỏ) vì Phase mutable + UI-hướng-1;
|
||||
// chống race tuyệt đối = dựng filtered unique ở wave sau. Rào sống ở handler
|
||||
// (`ContractSigningPlanFeatures.cs` Region 1); index dưới đây chỉ để lọc/đếm theo nhóm
|
||||
// (list `?group=` + cây 8 folder) cho nhanh.
|
||||
b.HasIndex(x => new { x.ApprovalGroup, x.Phase });
|
||||
|
||||
b.HasIndex(x => x.ProjectId);
|
||||
b.HasIndex(x => x.DepartmentId);
|
||||
b.HasIndex(x => x.DrafterUserId);
|
||||
|
||||
@ -18,11 +18,27 @@ public class ContractSigningPlanLineConfiguration : IEntityTypeConfiguration<Con
|
||||
b.Property(x => x.ProposedAmount).HasPrecision(18, 2);
|
||||
b.Property(x => x.ApprovedAmount).HasPrecision(18, 2);
|
||||
b.Property(x => x.Note).HasMaxLength(1000);
|
||||
// MaxLength khớp `ContractCatalogEntry.TenVi` (500, `ContractCatalogEntryConfiguration.cs`)
|
||||
// — denorm mà hẹp hơn nguồn thì tên dài nhất (A3-09 ~150 ký tự) vẫn lọt, nhưng để
|
||||
// BẰNG nguồn thì không bao giờ phải cắt. Validator lấy số từ ĐÂY (EF = source of truth).
|
||||
b.Property(x => x.TenHangMuc).HasMaxLength(500);
|
||||
|
||||
b.HasIndex(x => new { x.ContractSigningPlanId, x.SupplierId })
|
||||
// [K2 Mig 71 — S164] UNIQUE đổi (Plan, Supplier) → (Plan, Supplier, CatalogEntry):
|
||||
// 1 NCC được nhiều dòng trên cùng phiếu, mỗi dòng 1 hạng mục khác nhau.
|
||||
// 🔴 BẪY NULL-UNIQUE (vá-9): trong UNIQUE index SQL Server, NULL == NULL ⇒ HAI dòng
|
||||
// cùng (Plan, Supplier) mà đều `CatalogEntryId NULL` sẽ VI PHẠM index này. Dữ liệu cũ
|
||||
// an toàn (unique cũ bảo đảm ≤ 1 dòng/cặp ⇒ tối đa 1 NULL/cặp). Với dòng phát sinh
|
||||
// MỚI, mắt xích chặn KHÔNG PHẢI index này (index cũ đã bị DROP trong chính Mig 71)
|
||||
// mà là (gate-K2 F-2 — nói đúng THÌ): (1) SUBMIT-GUARD chặn null trước khi trình
|
||||
// (`ContractSigningPlanWorkflowService`) — lớp DUY NHẤT ĐANG TỒN TẠI; +
|
||||
// (2) [K4b — THÌ TƯƠNG LAI] validator đường gán hạng mục khi Lines-editor ra đời.
|
||||
// 🔴 CỐ Ý KHÔNG thêm `AND [CatalogEntryId] IS NOT NULL` vào filter: làm vậy là thả
|
||||
// trôi vô hạn dòng trùng (Plan, Supplier, NULL).
|
||||
b.HasIndex(x => new { x.ContractSigningPlanId, x.SupplierId, x.CatalogEntryId })
|
||||
.IsUnique()
|
||||
.HasFilter("[IsDeleted] = 0");
|
||||
b.HasIndex(x => x.SupplierId);
|
||||
b.HasIndex(x => x.CatalogEntryId);
|
||||
b.HasIndex(x => x.ContractId);
|
||||
|
||||
b.HasOne(x => x.ContractSigningPlan)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,144 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
// [K2 Mig 71 — S164 2026-07-31] KHKK mang HẠNG MỤC + NHÓM DUYỆT (SOL-CCM-SP-002).
|
||||
// 6 item, KHÔNG bảng mới (96 giữ nguyên), 0 RenameColumn:
|
||||
// 1. ContractSigningPlans +ApprovalGroup int — nhóm duyệt 1..8, BACKFILL = 1
|
||||
// 2. ContractSigningPlans +EndedByLevelFinalize bit default 0 — cờ RUNTIME (xem ghi chú 🔴)
|
||||
// 3. ContractSigningPlanLines +CatalogEntryId uniqueidentifier NULL — loose-Guid, KHÔNG FK
|
||||
// 4. ContractSigningPlanLines +TenHangMuc nvarchar(500) NULL — denorm tên lúc gán
|
||||
// 5. UNIQUE (Plan, Supplier) → (Plan, Supplier, CatalogEntry) filtered [IsDeleted]=0 (#57)
|
||||
// 6. +2 index tra cứu: (ApprovalGroup, Phase) trên header · CatalogEntryId trên dòng
|
||||
//
|
||||
// 🔴 GHOST-WINDOW KHAI TRƯỚC (item 2): `EndedByLevelFinalize` do K2 ĐẺ, K3 TIÊU THỤ.
|
||||
// Từ Mig 71 tới khi K3 land, cột này KHÔNG có site nào ghi ⇒ luôn = 0. Cố ý gộp vào
|
||||
// đây để K3 = 0-mig (spec §②-9 + vá-7 item-6). Ai đọc cột này TRƯỚC K3 sẽ thấy "mọi
|
||||
// phiếu đều không kết thúc sớm" — ĐÚNG với hiện trạng (service KHKK hiện chỉ có đúng
|
||||
// MỘT nhánh set DaDuyet, là đường thường), không phải dữ liệu hỏng.
|
||||
//
|
||||
// 🔴 BẤT-BIẾN TIỀN: migration này có **0 câu lệnh** chạm `PeReferenceAmount` /
|
||||
// `ProposedAmount` / `ApprovedAmount` ⇒ Σ trước == Σ sau là đúng theo CẤU TRÚC, không
|
||||
// chỉ theo phép đo. (Đo local: cả Dev lẫn Design đều 0 dòng KHKK ⇒ Σ = tập RỖNG hai
|
||||
// phía — khai thẳng vì "trích rỗng đọc thành sạch" là bẫy, phép đo prod mới có răng.)
|
||||
public partial class AddKhkkApprovalGroupCatalogAndFinalizeRuntime : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// 🔴 UNIQUE CŨ CHẾT TỪ ĐÂY (item 5). Sau lệnh này, mắt xích chặn "2 dòng cùng
|
||||
// (Plan, Supplier) đều CatalogEntryId NULL" KHÔNG còn là index nữa — trong UNIQUE
|
||||
// index SQL Server NULL == NULL nên index MỚI vẫn chặn cặp NULL-trùng, nhưng nó
|
||||
// chặn bằng lỗi 2601 lúc SaveChanges (500 khó hiểu), không phải bằng nghiệp vụ.
|
||||
// Mắt xích THẬT do tầng ứng dụng giữ (vá-9) — hiện 1 lớp, K4b thêm lớp 2 (gate-K2 F-2):
|
||||
// (a) SUBMIT-GUARD `ContractSigningPlanWorkflowService.SubmitAsync` — mọi dòng
|
||||
// phải có hạng mục trước khi trình duyệt (ĐANG TỒN TẠI);
|
||||
// (b) [K4b — THÌ TƯƠNG LAI] validator đường gán hạng mục của Lines-editor:
|
||||
// `CatalogEntryId` BẮT BUỘC trên dòng mới qua API.
|
||||
// Dữ liệu cũ AN TOÀN không cần backfill dòng: unique cũ đã bảo đảm ≤ 1 dòng cho
|
||||
// mỗi cặp (Plan, Supplier) ⇒ tối đa 1 dòng NULL mỗi cặp, không thể đụng nhau.
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_ContractSigningPlanLines_ContractSigningPlanId_SupplierId",
|
||||
table: "ContractSigningPlanLines");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "ApprovalGroup",
|
||||
table: "ContractSigningPlans",
|
||||
type: "int",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
// BACKFILL = 1 (item 1). Mọi phiếu tồn tại trước K2 thuộc NHÓM 1 — acceptance
|
||||
// `COUNT(phiếu) == Σ COUNT 8 folder` (không phiếu nào rơi ngoài cây 8 nhóm).
|
||||
// Khuôn Mig 64 `Sql("UPDATE Suppliers SET IsPublic=1")`: `Sql()` trần, KHÔNG
|
||||
// provider-guard (khuôn Mig 61/65/67) — SQLite trong test dùng EnsureCreated nên
|
||||
// không chạy migration, không có nhánh nào cần rẽ. Vô điều kiện (KHÔNG
|
||||
// `WHERE ApprovalGroup = 0`) để phép đo có răng: rowcount phải == số phiếu cũ;
|
||||
// nếu viết WHERE thì backfill hụt sẽ trông y hệt backfill thành công.
|
||||
migrationBuilder.Sql("UPDATE [ContractSigningPlans] SET [ApprovalGroup] = 1;");
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "EndedByLevelFinalize",
|
||||
table: "ContractSigningPlans",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "CatalogEntryId",
|
||||
table: "ContractSigningPlanLines",
|
||||
type: "uniqueidentifier",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "TenHangMuc",
|
||||
table: "ContractSigningPlanLines",
|
||||
type: "nvarchar(500)",
|
||||
maxLength: 500,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ContractSigningPlans_ApprovalGroup_Phase",
|
||||
table: "ContractSigningPlans",
|
||||
columns: new[] { "ApprovalGroup", "Phase" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ContractSigningPlanLines_CatalogEntryId",
|
||||
table: "ContractSigningPlanLines",
|
||||
column: "CatalogEntryId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ContractSigningPlanLines_ContractSigningPlanId_SupplierId_CatalogEntryId",
|
||||
table: "ContractSigningPlanLines",
|
||||
columns: new[] { "ContractSigningPlanId", "SupplierId", "CatalogEntryId" },
|
||||
unique: true,
|
||||
filter: "[IsDeleted] = 0");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_ContractSigningPlans_ApprovalGroup_Phase",
|
||||
table: "ContractSigningPlans");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_ContractSigningPlanLines_CatalogEntryId",
|
||||
table: "ContractSigningPlanLines");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_ContractSigningPlanLines_ContractSigningPlanId_SupplierId_CatalogEntryId",
|
||||
table: "ContractSigningPlanLines");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ApprovalGroup",
|
||||
table: "ContractSigningPlans");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EndedByLevelFinalize",
|
||||
table: "ContractSigningPlans");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CatalogEntryId",
|
||||
table: "ContractSigningPlanLines");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TenHangMuc",
|
||||
table: "ContractSigningPlanLines");
|
||||
|
||||
// [gate-K2 F-8] Down chỉ AN TOÀN khi CHƯA có dữ liệu tách-dòng (pre-K4b): một khi 1 NCC
|
||||
// có nhiều dòng/phiếu (khác hạng mục), DropColumn ở trên đã xoá chiều phân biệt và
|
||||
// CreateIndex 2-cột dưới đây FAIL vì trùng (Plan, Supplier). Rollback lúc đó = quyết định
|
||||
// owner + dọn/gộp dữ liệu trước, không chạy Down mù.
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ContractSigningPlanLines_ContractSigningPlanId_SupplierId",
|
||||
table: "ContractSigningPlanLines",
|
||||
columns: new[] { "ContractSigningPlanId", "SupplierId" },
|
||||
unique: true,
|
||||
filter: "[IsDeleted] = 0");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -318,6 +318,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<int>("ApprovalGroup")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<Guid?>("ApprovalWorkflowId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
@ -345,6 +348,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||
b.Property<Guid>("DrafterUserId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<bool>("EndedByLevelFinalize")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("GhiChu")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("nvarchar(2000)");
|
||||
@ -394,6 +400,8 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||
|
||||
b.HasIndex("PurchaseEvaluationId");
|
||||
|
||||
b.HasIndex("ApprovalGroup", "Phase");
|
||||
|
||||
b.HasIndex("Phase", "IsDeleted");
|
||||
|
||||
b.ToTable("ContractSigningPlans", (string)null);
|
||||
@ -710,6 +718,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||
.HasPrecision(18, 2)
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<Guid?>("CatalogEntryId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<Guid?>("ContractId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
@ -746,6 +757,10 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||
b.Property<Guid>("SupplierId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("TenHangMuc")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
@ -754,11 +769,13 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CatalogEntryId");
|
||||
|
||||
b.HasIndex("ContractId");
|
||||
|
||||
b.HasIndex("SupplierId");
|
||||
|
||||
b.HasIndex("ContractSigningPlanId", "SupplierId")
|
||||
b.HasIndex("ContractSigningPlanId", "SupplierId", "CatalogEntryId")
|
||||
.IsUnique()
|
||||
.HasFilter("[IsDeleted] = 0");
|
||||
|
||||
|
||||
@ -164,6 +164,26 @@ public class ContractSigningPlanWorkflowService(
|
||||
|
||||
await EnsureCanSubmitAsync(plan, actorId, isAdmin, ct);
|
||||
|
||||
// ── [K2 S164 — vá-5 spec-cụm1, THẮNG nền K2.d vế null] Rào HẠNG MỤC trước khi trình:
|
||||
// (i) mọi dòng phải đã gán hạng mục — auto-gen mồi CatalogEntryId=null chỉ hợp lệ trong
|
||||
// nháp; nền `:126` định CHO-QUA null-legacy nhưng vá-5 chốt CHẶN cho NHẤT QUÁN.
|
||||
// Ghost-window K2→K4b khai chủ-đích: phiếu cũ bị Trả-lại sẽ kẹt trình tới khi
|
||||
// Lines-editor (K4b) land — message chỉ rõ việc cần làm.
|
||||
// (ii) hạng mục phải CÙNG nhóm duyệt với phiếu (code mẫu nền K2.d) — chốt CUỐI chống
|
||||
// data cũ/lách API; validator đường-gán K4b là fail-fast tầng trước.
|
||||
if (plan.Lines.Any(l => l.CatalogEntryId == null))
|
||||
throw new ConflictException(
|
||||
"Còn dòng chưa gán hạng mục công việc (danh mục SP-002) — gán hạng mục cho mọi dòng trước khi trình.");
|
||||
// [gate-K2 F-1] Hỏi câu ĐÚNG: "MỌI id resolve tới hạng mục CÒN SỐNG + ĐÚNG nhóm" — không
|
||||
// phải "có id nào SAI nhóm". Query filter !IsDeleted nuốt hạng mục xoá mềm ⇒ Any(sai nhóm)
|
||||
// = false cho cả Guid rác lẫn hạng mục đã xoá (vắng-mặt đọc thành sạch). Đếm-so-khớp bịt cả hai.
|
||||
var catalogIds = plan.Lines.Select(l => l.CatalogEntryId!.Value).Distinct().ToList();
|
||||
var okCount = await db.ContractCatalogEntries
|
||||
.CountAsync(c => catalogIds.Contains(c.Id) && c.ApprovalGroup == plan.ApprovalGroup, ct);
|
||||
if (okCount != catalogIds.Count)
|
||||
throw new ConflictException(
|
||||
$"Có hạng mục không hợp lệ (đã xoá / không tồn tại) hoặc không thuộc nhóm duyệt của phiếu — mọi dòng phải cùng nhóm N{plan.ApprovalGroup}.");
|
||||
|
||||
var steps = await LoadStepsAsync(awId, ct);
|
||||
var firstLevelGroup = steps[0].Levels.Where(l => l.Order == 1).ToList();
|
||||
if (firstLevelGroup.Count == 0)
|
||||
|
||||
Reference in New Issue
Block a user