[CLAUDE] Contract: K7 SP-002 bridge KHKK->HD hoi-2-khoa + Line.ContractId + grant seeder (gate PASS-WITH-FLAGS 10, F-8 fixed)
Some checks failed
Deploy SOLUTION_ERP / build-deploy (push) Has been cancelled
Some checks failed
Deploy SOLUTION_ERP / build-deploy (push) Has been cancelled
BE: POST {id}/create-contract (Contracts.Create AND KeHoachKyKet.Read) + guard 4-ve IsActive/IsUserSelectable
+ Line.ContractId write-site DAU TIEN + list-DTO ContractIds + SeedContractCreateAccessAsync (Drafter/
Procurement/Admin upgrade-only, cau III default cho ratify). FE x2 app SHA-pair 3/3: nut bridge gate 2-khoa
+ dialog khuon-san + cay GD3/GD4 doc TAP contractIds (union pe.contractId legacy). Test +17 (12 bridge T1-T12
+ 5 authz) suite 639/0; ngoai-le CreateContract trong AuthorizePolicyRegressionTests (#85).
Gate F-8 fixed truoc commit: neo so-dong :2516 chet-luc-land -> neo ten ham (3 cho) + 13/13->12/13 theo do
prod + khai chu-dich CanRead + sua tro-nham-ham. No mang theo (0-blocker): MAJOR-race-2-request-dong-thoi
(chong-trung in-memory, phuong an unique/RowVersion = nhip sau) + DraftData-khong-carry + a11y/O(n.m) FE.
UAT-NOI-TRUOC: nut sang sau restart + user RE-LOGIN (menu cache); acceptance seeder log = 0 added + 2 upgraded.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@ -5,6 +5,7 @@ using SolutionErp.Application.Common.Models;
|
||||
using SolutionErp.Application.ContractSigningPlans;
|
||||
using SolutionErp.Application.ContractSigningPlans.Services;
|
||||
using SolutionErp.Domain.ContractSigningPlans;
|
||||
using SolutionErp.Domain.Contracts; // ContractType (cầu GĐ2 → GĐ3, K7)
|
||||
|
||||
namespace SolutionErp.Api.Controllers;
|
||||
|
||||
@ -213,6 +214,30 @@ public class ContractSigningPlansController(
|
||||
=> Ok(await workflow.TransitionAsync(id, body.Action, body.Comment,
|
||||
body.ApplyLevelFinalize ?? true, ct));
|
||||
|
||||
// ===================== Cầu GĐ2 → GĐ3 (K7 — S167) =====================
|
||||
// 🔴 [vá-1 spec-cụm-3 §②-1] AUTHZ = HỘI 2 KHOÁ, cả 2 vế đều phải chặn được:
|
||||
// vế 1 (class, dòng :28) `KeHoachKyKet.Read` — không được xem kế hoạch thì không được
|
||||
// lấy dòng của nó đi tạo HĐ;
|
||||
// vế 2 (action, ngay dưới) `Contracts.Create` — tài nguyên SINH RA là Contract ⇒ khoá phải
|
||||
// là khoá của thứ được tạo (gotcha #85:
|
||||
// gate bằng đúng key policy của endpoint).
|
||||
// ASP.NET Core cộng dồn [Authorize] class + action theo AND ⇒ thiếu 1 trong 2 = 403.
|
||||
// ⚠️ `Contracts.Create` trước S167 KHÔNG role nào có (seeder `SeedAllRolesReviewReadPermissionsAsync`
|
||||
// đặt `CanCreate = isPe` ⇒ false cho key `Contracts` — grep tên hàm, ĐỪNG neo số dòng: file này
|
||||
// dài ra là số thối [gate-K7 F-8 bắt neo cũ sai ngay lúc land]); `SeedContractCreateAccessAsync` cấp cho
|
||||
// Drafter + Procurement + Admin — grant đó ăn @restart, KHÔNG phải deploy FE.
|
||||
// ⚠️ KHÔNG động tới `POST /api/contracts` (`ContractsController` `[Authorize]` trần, #82 treo
|
||||
// chủ đích): siết ở đây là siết CỬA MỚI, không phải sửa cửa cũ.
|
||||
[HttpPost("{id:guid}/create-contract")]
|
||||
[Authorize(Policy = "Contracts.Create")]
|
||||
public async Task<ActionResult<CreateContractFromSigningPlanResult>> CreateContract(
|
||||
Guid id, [FromBody] CreateContractFromSigningPlanBody body, CancellationToken ct)
|
||||
{
|
||||
var result = await mediator.Send(new CreateContractFromSigningPlanCommand(
|
||||
id, body.LineIds ?? [], body.ContractType, body.ApprovalWorkflowId, body.TenHopDong), ct);
|
||||
return Created($"/api/contracts/{result.ContractId}", result);
|
||||
}
|
||||
|
||||
// ========================= Body records =========================
|
||||
// PUT header: KHÔNG mang `Id` trong body (id lấy từ route) — tránh 2 nguồn sự thật.
|
||||
// Mọi field nullable = null-safe: client không gửi ⇒ giữ giá trị cũ (#73).
|
||||
@ -231,4 +256,13 @@ public class ContractSigningPlansController(
|
||||
string Action,
|
||||
string? Comment = null,
|
||||
bool? ApplyLevelFinalize = null);
|
||||
|
||||
// [K7 S167] Cầu → HĐ. `PlanId` lấy từ route (không nhận trong body — 1 nguồn sự thật).
|
||||
// `LineIds` nullable ở BODY để client gửi thiếu ra 400 của validator ("chọn ít nhất 1 dòng")
|
||||
// thay vì 500 NullReference ở tầng dưới.
|
||||
public record CreateContractFromSigningPlanBody(
|
||||
List<Guid>? LineIds,
|
||||
ContractType ContractType,
|
||||
Guid ApprovalWorkflowId,
|
||||
string? TenHopDong = null);
|
||||
}
|
||||
|
||||
@ -61,7 +61,18 @@ public record ContractSigningPlanListItemDto(
|
||||
// đề 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);
|
||||
decimal TotalApprovedAmount,
|
||||
// [K7 S167 — vá-4a] ID các HĐ đã sinh ra TỪ CÁC DÒNG của phiếu này (`Line.ContractId`,
|
||||
// write-site `CreateContractFromSigningPlanFeatures.cs`). Rỗng = phiếu chưa đưa dòng nào
|
||||
// vào HĐ.
|
||||
// 🔴 VÌ SAO Ở LIST (không chỉ ở detail — detail đã có `lines[].contractId` từ W2 `:74`):
|
||||
// cây 4-folder (`usePipelineStages.ts:287-291`) dựng GĐ3 từ payload LIST và hôm nay chỉ
|
||||
// có MỘT nguồn là ô đơn `pe.contractId` ⇒ gói có N HĐ thì HĐ thứ 2+ VÔ HÌNH trên cây
|
||||
// (K5 FLAG-1 "chỗ cắm 1 dòng" — lane K5 đã dựng sẵn union, thiếu đúng nguồn thứ hai này).
|
||||
// Trả mảng PHẲNG thay vì `lines[]` lồng: consumer là một `Set<string>`, không cần hạt dòng.
|
||||
// ⚠️ KHÔNG `Distinct()` trong projection: N dòng gộp 1 HĐ ra ID trùng — vô hại (consumer là
|
||||
// Set) và tránh một tầng dịch SQL nữa trên projection dùng chung 3 endpoint.
|
||||
List<Guid> ContractIds);
|
||||
|
||||
public record ContractSigningPlanLineDto(
|
||||
Guid Id,
|
||||
@ -821,7 +832,15 @@ public class ListContractSigningPlansQueryHandler(IApplicationDbContext db, ICur
|
||||
// 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))
|
||||
.Sum(l => l.ApprovedAmount) ?? 0m,
|
||||
// [K7 S167 — vá-4a] Nguồn THẬT của đường GĐ2 → GĐ3 cho cây 4-folder (xem chú
|
||||
// thích tại DTO). Cùng khuôn collection-subquery với `WinnerSupplierNames`
|
||||
// (`CreateContractFromEvaluationFeatures.cs:185-189`) — đã chạy thật ở list PE.
|
||||
db.ContractSigningPlanLines
|
||||
.Where(l => l.ContractSigningPlanId == p.Id && l.ContractId != null)
|
||||
.OrderBy(l => l.CreatedAt)
|
||||
.Select(l => l.ContractId!.Value)
|
||||
.ToList()))
|
||||
.ToListAsync(ct);
|
||||
|
||||
return new PagedResult<ContractSigningPlanListItemDto>(items, total, page, pageSize);
|
||||
|
||||
@ -0,0 +1,251 @@
|
||||
using FluentValidation;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SolutionErp.Application.Common.Exceptions;
|
||||
using SolutionErp.Application.Common.Interfaces;
|
||||
using SolutionErp.Application.Contracts.Services;
|
||||
using SolutionErp.Domain.ApprovalWorkflowsV2;
|
||||
using SolutionErp.Domain.ContractSigningPlans;
|
||||
using SolutionErp.Domain.Contracts;
|
||||
|
||||
namespace SolutionErp.Application.ContractSigningPlans;
|
||||
|
||||
// =====================================================================================
|
||||
// [K7 — S167 2026-08-01] CẦU GĐ2 → GĐ3: Kế hoạch ký kết (KHKK) đã duyệt → Hợp đồng draft.
|
||||
//
|
||||
// Khuôn = bridge PE→HĐ cũ `CreateContractFromEvaluationFeatures.cs` + guard workflow V2 của
|
||||
// `ContractFeatures.cs:74-87`. Luật spec-4gd §①: GIỐNG NHAU — CHỈ KHÁC FORM — ĐỪNG CHẾ THÊM.
|
||||
//
|
||||
// 🔴 KHÁC bridge PE cũ ở 3 điểm CÓ CHỦ ĐÍCH:
|
||||
// 1. Hạt là DÒNG (`ContractSigningPlanLine`), không phải phiếu: 1 call = 1 HĐ cho ĐÚNG tập
|
||||
// `LineIds` gửi lên. Đây là write-site ĐẦU TIÊN toàn codebase của `Line.ContractId`
|
||||
// (đo @S164: 0 assignment) ⇒ cây 4-folder có nguồn THẬT thay vì suy từ `pe.ContractId` đơn.
|
||||
// 2. Pin V2 `ApprovalWorkflowId` (bridge cũ chỉ pin V1) — `ApprovalWorkflow.ApplicableType`
|
||||
// = Contract(3) là "HĐ general (any ContractType)" ⇒ KHÔNG suy được từ ContractType,
|
||||
// người bấm phải chọn trong dialog.
|
||||
// 3. Trả 1 HĐ (`Guid` + mã) thay vì `List<Guid>` — liên-danh N NCC = N lần bấm (OG-1 UI-1-1).
|
||||
//
|
||||
// 🔴 4 VÁ của spec-cụm-3 §② nằm TRONG file này (đừng gỡ khi refactor):
|
||||
// vá-1 authz HỘI 2 khoá — ở controller (`KeHoachKyKet.Read` class ∧ `Contracts.Create` action).
|
||||
// vá-3 guard workflow pin phải `IsActive` ∧ `IsUserSelectable` — KHÔNG chỉ tồn-tại+đúng-type
|
||||
// (khuôn `ContractFeatures.cs:79-87` thiếu 2 vế này ⇒ forge POST pin được quy trình đã
|
||||
// retired; dropdown FE lọc 2 cờ `ContractCreatePage.tsx:334-341` nhưng FE không phải rào).
|
||||
// vá-4a `line.ContractId` ghi LUÔN (nguồn thật) + `pe.ContractId ??=` giữ vai FALLBACK hiển thị.
|
||||
// vá-6 nạp `Project.Code` + `Supplier.Code` (2 guard NotFound) cho codegen RG-001 · field-map
|
||||
// += `SlaDeadline` · pin `WorkflowDefinitionId` như 2 tiền lệ (`ContractFeatures.cs:105-109`
|
||||
// + bridge cũ `:108`) — để null là rơi về policy hardcode `ContractFeatures.cs:647-662`.
|
||||
// =====================================================================================
|
||||
|
||||
/// Kết quả bridge — trả CẢ mã HĐ vì mã gen NGAY lúc tạo (FE toast + link không phải gọi lại).
|
||||
public record CreateContractFromSigningPlanResult(Guid ContractId, string? MaHopDong);
|
||||
|
||||
/// <param name="LineIds">
|
||||
/// Tập dòng đưa vào HĐ này. Máy nhận N (schema GỘP: N dòng cùng NCC → 1 HĐ); UI hướng-1
|
||||
/// truyền đúng 1 phần tử. `GiaTri` = Σ `ApprovedAmount` của ĐÚNG tập này — không hơn không kém.
|
||||
/// </param>
|
||||
/// <param name="ApprovalWorkflowId">
|
||||
/// Quy trình duyệt HĐ (V2, `ApplicableType = Contract`). BẮT BUỘC — xem chú thích đầu file
|
||||
/// điểm 2: không suy được từ `ContractType`.
|
||||
/// </param>
|
||||
public record CreateContractFromSigningPlanCommand(
|
||||
Guid PlanId,
|
||||
List<Guid> LineIds,
|
||||
ContractType ContractType,
|
||||
Guid ApprovalWorkflowId,
|
||||
string? TenHopDong = null) : IRequest<CreateContractFromSigningPlanResult>;
|
||||
|
||||
public class CreateContractFromSigningPlanCommandValidator
|
||||
: AbstractValidator<CreateContractFromSigningPlanCommand>
|
||||
{
|
||||
public CreateContractFromSigningPlanCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.PlanId).NotEmpty();
|
||||
RuleFor(x => x.ApprovalWorkflowId).NotEmpty();
|
||||
RuleFor(x => x.ContractType).IsInEnum();
|
||||
RuleFor(x => x.LineIds)
|
||||
.NotEmpty().WithMessage("Chọn ít nhất 1 dòng của kế hoạch để đưa vào Hợp đồng.");
|
||||
RuleFor(x => x.LineIds)
|
||||
.Must(ids => ids.Distinct().Count() == ids.Count)
|
||||
.When(x => x.LineIds is { Count: > 0 })
|
||||
.WithMessage("Danh sách dòng bị trùng — mỗi dòng chỉ được chọn 1 lần.");
|
||||
// MaxLength MATCH EF config (`ContractConfiguration` TenHopDong = 500) — EF là source
|
||||
// of truth, KHÔNG lấy số từ spec (bài S35).
|
||||
RuleFor(x => x.TenHopDong).MaximumLength(500);
|
||||
}
|
||||
}
|
||||
|
||||
public class CreateContractFromSigningPlanCommandHandler(
|
||||
IApplicationDbContext db,
|
||||
ICurrentUser currentUser,
|
||||
IContractWorkflowService workflow,
|
||||
IContractCodeGenerator codeGenerator)
|
||||
: IRequestHandler<CreateContractFromSigningPlanCommand, CreateContractFromSigningPlanResult>
|
||||
{
|
||||
public async Task<CreateContractFromSigningPlanResult> Handle(
|
||||
CreateContractFromSigningPlanCommand request, CancellationToken ct)
|
||||
{
|
||||
if (!currentUser.IsAuthenticated || currentUser.UserId is null)
|
||||
throw new UnauthorizedException();
|
||||
var actorId = currentUser.UserId.Value;
|
||||
|
||||
// ---- Rào (1) PHIẾU + PHASE — mirror rào (i) `ContractSigningPlanFeatures.cs:381-382`.
|
||||
var plan = await db.ContractSigningPlans
|
||||
.Include(p => p.Lines)
|
||||
.FirstOrDefaultAsync(p => p.Id == request.PlanId, ct)
|
||||
?? throw new NotFoundException("ContractSigningPlan", request.PlanId);
|
||||
|
||||
if (plan.Phase != ContractSigningPlanPhase.DaDuyet)
|
||||
throw new ConflictException(
|
||||
"Chỉ tạo Hợp đồng từ Kế hoạch ký kết đã duyệt xong (Đã duyệt).");
|
||||
|
||||
// ---- Rào (2) DÒNG ∈ PHIẾU. Đếm sau `Distinct` — id lạ/của phiếu khác đều rơi vào đây.
|
||||
var lineIds = request.LineIds.Distinct().ToList();
|
||||
var lines = plan.Lines
|
||||
.Where(l => lineIds.Contains(l.Id))
|
||||
.OrderBy(l => l.CreatedAt).ThenBy(l => l.Id)
|
||||
.ToList();
|
||||
if (lines.Count != lineIds.Count)
|
||||
throw new ConflictException(
|
||||
"Có dòng không thuộc kế hoạch này (đã bị xoá hoặc chọn nhầm phiếu) — tải lại trang rồi chọn lại.");
|
||||
|
||||
// ---- Rào (3) 1 HĐ = 1 NCC (`Contract.SupplierId` đơn). Liên-danh N NCC = N lần bấm.
|
||||
var supplierIds = lines.Select(l => l.SupplierId).Distinct().ToList();
|
||||
if (supplierIds.Count > 1)
|
||||
throw new ConflictException(
|
||||
"Các dòng chọn thuộc nhiều NCC khác nhau — mỗi Hợp đồng chỉ 1 NCC, tạo lần lượt.");
|
||||
|
||||
// ---- Rào (4)+(5) per-dòng: có giá CHỐT + chưa bridge. Message NÊU TÊN hạng mục để người
|
||||
// bấm biết dòng nào — danh sách dòng trong dialog không có mã, chỉ có tên.
|
||||
foreach (var l in lines)
|
||||
{
|
||||
var ten = l.TenHangMuc ?? "(chưa gán hạng mục)";
|
||||
// KHÔNG fallback `ProposedAmount`: đó là số ĐỀ XUẤT chưa qua choke-point finalize
|
||||
// (`ContractSigningPlanWorkflowService.cs:348` `ApprovedAmount ??=`). Lấy nhầm =
|
||||
// HĐ mang số chưa ai chốt.
|
||||
if (l.ApprovedAmount is null)
|
||||
throw new ConflictException(
|
||||
$"Dòng '{ten}' chưa có giá duyệt chốt — phiếu phải đi qua bước duyệt kết thúc trước khi đưa vào Hợp đồng.");
|
||||
if (l.ContractId is not null)
|
||||
throw new ConflictException($"Dòng '{ten}' đã có Hợp đồng.");
|
||||
}
|
||||
|
||||
// ---- Rào (6) WORKFLOW PIN — 4 vế. 2 vế đầu = khuôn `ContractFeatures.cs:79-87`;
|
||||
// 2 vế sau = [vá-3 spec-cụm-3 §②-3] đóng cửa forge-POST pin quy trình đã nghỉ.
|
||||
var aw = await db.ApprovalWorkflows.AsNoTracking()
|
||||
.FirstOrDefaultAsync(w => w.Id == request.ApprovalWorkflowId, ct)
|
||||
?? throw new NotFoundException("ApprovalWorkflow", request.ApprovalWorkflowId);
|
||||
if (aw.ApplicableType != ApprovalWorkflowApplicableType.Contract)
|
||||
throw new ConflictException(
|
||||
$"Quy trình '{aw.Code}' áp dụng cho {aw.ApplicableType}, không khớp với Hợp đồng " +
|
||||
"(cần quy trình loại Hợp đồng).");
|
||||
// 2 cờ TÁCH RIÊNG (không gộp `!IsActive || !IsUserSelectable`): admin cần đọc được
|
||||
// NGUYÊN NHÂN — "đã nghỉ" và "chưa ghim" xử lý ở 2 nút khác nhau trong Designer.
|
||||
if (!aw.IsActive)
|
||||
throw new ConflictException(
|
||||
$"Quy trình '{aw.Code}' đã ngừng hoạt động — chọn quy trình đang hoạt động.");
|
||||
if (!aw.IsUserSelectable)
|
||||
throw new ConflictException(
|
||||
$"Quy trình '{aw.Code}' chưa được ghim cho người dùng chọn — nhờ Admin ghim, hoặc chọn quy trình khác.");
|
||||
|
||||
// ---- [vá-6] Nạp Project + Supplier ĐỦ (cần `Code` cho codegen RG-001) — 2 guard NotFound
|
||||
// theo khuôn bridge cũ `CreateContractFromEvaluationFeatures.cs:63-66/:85-86`.
|
||||
// loose-Guid ⇒ không có FK vật lý đỡ lưng, thiếu guard = NRE lúc gen mã.
|
||||
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == plan.ProjectId, ct)
|
||||
?? throw new NotFoundException("Project", plan.ProjectId);
|
||||
var supplierId = supplierIds[0];
|
||||
var supplier = await db.Suppliers.FirstOrDefaultAsync(s => s.Id == supplierId, ct)
|
||||
?? throw new NotFoundException("Supplier", supplierId);
|
||||
|
||||
// ---- [vá-6] Pin V1 `WorkflowDefinitionId` NGANG bridge cũ `:108` + `ContractFeatures.cs:105`.
|
||||
// Cả 2 cùng set là ĐÚNG ("Cả 2 set ok" `:107-108`): `ContractWorkflowService` dispatch
|
||||
// V2 trước. Để null thì HĐ rơi về policy hardcode `ContractFeatures.cs:647-662` khi
|
||||
// ai đó gỡ pin V2 — mất luôn đường về quy trình đã cấu hình cho loại HĐ đó.
|
||||
var activeWfId = await db.WorkflowDefinitions.AsNoTracking()
|
||||
.Where(w => w.ContractType == request.ContractType && w.IsActive)
|
||||
.Select(w => (Guid?)w.Id)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
// PE nguồn: TRACKED (cần ghi `ContractId` fallback bên dưới) + lấy tên gói cho tên HĐ mặc
|
||||
// định. loose-Guid ⇒ có thể null (PE bị xoá cứng) — mọi chỗ dùng đều null-safe.
|
||||
var pe = await db.PurchaseEvaluations
|
||||
.FirstOrDefaultAsync(e => e.Id == plan.PurchaseEvaluationId, ct);
|
||||
|
||||
var giaTri = lines.Sum(l => l.ApprovedAmount!.Value);
|
||||
|
||||
var tenHangMuc = string.Join(", ", lines
|
||||
.Select(l => l.TenHangMuc)
|
||||
.Where(t => !string.IsNullOrWhiteSpace(t))
|
||||
.Distinct());
|
||||
var tenMacDinh = string.IsNullOrWhiteSpace(tenHangMuc)
|
||||
? (pe?.TenGoiThau ?? plan.MaKeHoach ?? "Hợp đồng")
|
||||
: $"{pe?.TenGoiThau ?? plan.MaKeHoach} — {tenHangMuc}";
|
||||
var tenHopDong = request.TenHopDong ?? tenMacDinh;
|
||||
// Clamp theo EF max length (500) — tên tự ghép từ N hạng mục có thể vượt.
|
||||
if (tenHopDong.Length > 500) tenHopDong = tenHopDong[..500];
|
||||
|
||||
var contract = new Contract
|
||||
{
|
||||
Type = request.ContractType,
|
||||
Phase = ContractPhase.DangSoanThao,
|
||||
SupplierId = supplierId,
|
||||
ProjectId = plan.ProjectId,
|
||||
DepartmentId = plan.DepartmentId,
|
||||
DrafterUserId = actorId,
|
||||
GiaTri = giaTri,
|
||||
TenHopDong = tenHopDong,
|
||||
NoiDung = pe?.MoTa,
|
||||
// NS tham chiếu: Σ số PE (snapshot lúc lập kế hoạch), KHÔNG phải số đã chốt — cột
|
||||
// "ngân sách nhập tay" của HĐ là mốc so sánh, giá trị HĐ nằm ở `GiaTri`.
|
||||
BudgetManualName = plan.MaKeHoach is null ? null : $"NS tham chiếu KHKK {plan.MaKeHoach}",
|
||||
BudgetManualAmount = lines.Sum(l => l.PeReferenceAmount),
|
||||
WorkflowDefinitionId = activeWfId, // [vá-6]
|
||||
ApprovalWorkflowId = request.ApprovalWorkflowId, // pin V2
|
||||
// [vá-6] SlaDeadline — thiếu thì HĐ sinh từ cầu này là HĐ DUY NHẤT trong hệ không có
|
||||
// hạn phase (cả 2 tiền lệ `ContractFeatures.cs:110` + bridge cũ `:109-110` đều set).
|
||||
SlaDeadline = DateTime.UtcNow.Add(
|
||||
workflow.GetPhaseSla(ContractPhase.DangSoanThao) ?? TimeSpan.FromDays(7)),
|
||||
};
|
||||
|
||||
// Codegen TỰ-COMMIT (SERIALIZABLE + `SaveChangesAsync` nội bộ — `ContractCodeGenerator`):
|
||||
// gọi TRƯỚC `Add` để lượt save nội bộ của nó không flush HĐ dở (bài S88, đúng thứ tự
|
||||
// bridge cũ `:112-113` và `CreateContractSigningPlanCommandHandler:426-429`).
|
||||
contract.MaHopDong = await codeGenerator.GenerateAsync(contract, project.Code, supplier.Code, ct);
|
||||
|
||||
db.Contracts.Add(contract);
|
||||
|
||||
db.ContractChangelogs.Add(new ContractChangelog
|
||||
{
|
||||
ContractId = contract.Id,
|
||||
EntityType = ChangelogEntityType.Contract,
|
||||
Action = ChangelogAction.Insert,
|
||||
PhaseAtChange = contract.Phase,
|
||||
UserId = actorId,
|
||||
Summary = $"Tạo HĐ {contract.MaHopDong} từ Kế hoạch ký kết {plan.MaKeHoach ?? plan.Id.ToString()}",
|
||||
ContextNote = $"ContractSigningPlan {plan.Id} — {lines.Count} dòng",
|
||||
});
|
||||
db.ContractSigningPlanChangelogs.Add(new ContractSigningPlanChangelog
|
||||
{
|
||||
ContractSigningPlanId = plan.Id,
|
||||
EntityType = ContractSigningPlanEntityType.Line,
|
||||
Action = ChangelogAction.Update,
|
||||
PhaseAtChange = plan.Phase,
|
||||
UserId = actorId,
|
||||
Summary = $"Đưa {lines.Count} dòng vào HĐ {contract.MaHopDong}",
|
||||
ContextNote = $"Contract {contract.Id}",
|
||||
});
|
||||
|
||||
// ---- [vá-4a] WRITE-SITE `Line.ContractId` — NGUỒN THẬT của đường KHKK → HĐ.
|
||||
foreach (var l in lines) l.ContractId = contract.Id;
|
||||
|
||||
// ---- [vá-4a] `pe.ContractId` giữ vai FALLBACK HIỂN THỊ (cây 4-folder đọc ô đơn này).
|
||||
// `??=` CÓ CHIỀU: chỉ điền khi còn trống ⇒ HĐ đầu tiên của gói thắng, các HĐ sau
|
||||
// KHÔNG ghi đè (chúng vẫn nhìn thấy được qua `Line.ContractId` — đó là lý do vá-4a
|
||||
// tồn tại). Bridge PE cũ gán thẳng `= contracts[0].Id` sau khi đã chặn ở `:59-60`,
|
||||
// nên ở ĐÂY phải là `??=`: 2 cầu cùng chạy trên 1 PE thì cầu chạy sau không được
|
||||
// cướp ô của cầu chạy trước.
|
||||
if (pe is not null) pe.ContractId ??= contract.Id;
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
return new CreateContractFromSigningPlanResult(contract.Id, contract.MaHopDong);
|
||||
}
|
||||
}
|
||||
@ -2353,6 +2353,14 @@ public static class DbInitializer
|
||||
// @S159 ⇒ nhánh insert skip-existing = NO-OP im lặng ⇒ 403 giết dry-run (gotcha #44).
|
||||
await SeedKeHoachKyKetAccessAsync(db, roleManager, logger);
|
||||
|
||||
// [K7 — S167 2026-08-01] Cầu GĐ2 → GĐ3 golive: cấp `Contracts.Create` cho Drafter +
|
||||
// Procurement + Admin (câu III default — chờ anh ratify, đổi bộ vai TRONG method).
|
||||
// CHẠY SAU revoke cùng lý do 2 seeder trên. Không có nó thì nút "Đưa vào Hợp đồng"
|
||||
// 403 với 12/13 role (`SeedAllRolesReviewReadPermissionsAsync` để CanCreate=false cho key
|
||||
// `Contracts`; Admin qua nhờ bypass + prod sẵn row CanCreate=True — đo @gate-K7) —
|
||||
// nghiệm thu = restart THẬT rồi đếm row, không đọc code (#75/#76).
|
||||
await SeedContractCreateAccessAsync(db, roleManager, logger);
|
||||
|
||||
// [S65 2026-06-16] CHẠY SAU revoke để THẮNG: mở lại quyền XEM "Hồ sơ Nhân sự"
|
||||
// (+ root nhóm "Nhân sự") cho MỌI role — anh chốt public module Nhân sự cho
|
||||
// user thường tra cứu hồ sơ. Dashboard NS (Hrm_Dashboard) + 6 catalog
|
||||
@ -2728,6 +2736,88 @@ public static class DbInitializer
|
||||
}
|
||||
}
|
||||
|
||||
// [K7 — S167 2026-08-01] Cấp `Contracts.Create` cho các vai được bấm "Đưa vào Hợp đồng"
|
||||
// (cầu GĐ2 → GĐ3, `ContractSigningPlansController` `POST {id}/create-contract`).
|
||||
//
|
||||
// 🔴 LÝ DO PHẢI CÓ HÀM NÀY: trước S167 **KHÔNG role nào** mang `Contracts.Create` —
|
||||
// `SeedAllRolesReviewReadPermissionsAsync` (grep tên hàm, đừng neo số dòng) tạo row key
|
||||
// `Contracts` với `CanCreate = isPe` mà `isPe` = false cho key này ⇒ nút bridge 403 với
|
||||
// 12/13 role (kể cả Drafter); Admin qua nhờ bypass và prod ĐÃ sẵn row CanCreate=True cho
|
||||
// Admin (đo prod @gate-K7). Đây là lỗ ĐO ĐƯỢC, không phải suy đoán.
|
||||
// CanRead nâng KÈM là CHỦ ĐÍCH, không phải tràn-nhãn: vai được tạo HĐ phải THẤY menu
|
||||
// "Hợp đồng" để mở HĐ vừa tạo từ cầu KHKK — CanRead là cờ hiển thị menu (2-tầng #82).
|
||||
//
|
||||
// ⏳ [K7 — câu III default, chờ anh ratify] Bộ vai = Drafter + Procurement + Admin
|
||||
// ("người có quyền tạo HĐ"): Drafter = người soạn HĐ, Procurement = Mua hàng chủ trì cầu
|
||||
// NCC, Admin = quản trị/UAT. Anh chốt khác thì SỬA ĐÚNG mảng `roleNames` dưới đây (1 chỗ),
|
||||
// KHÔNG đụng controller — máy BE không đổi theo câu trả lời, chỉ đổi ai thấy nút.
|
||||
//
|
||||
// Khuôn = `SeedKeHoachKyKetAccessAsync` (W1 S161): UPGRADE-ONLY (row có sẵn → NÂNG cờ
|
||||
// desired, KHÔNG hạ cờ nào, KHÔNG đụng Update/Delete), row chưa có (fresh DB) → tạo
|
||||
// Read+Create. CẤM SQL tay (#84): grant tay bị seeder/revoker lật ở restart kế (bài S159
|
||||
// Run #423 — 447/494 row rơi). Chạy SAU `RevokeTemporarilyHiddenModulesAsync` để thắng
|
||||
// revoke (revoker hiện KHÔNG còn nhánh `Contracts` từ S159, nhưng giữ thứ tự cho an toàn).
|
||||
// Thu hồi khi cần: xoá call trong `SeedAdminPermissionsAsync` (gate-K7 bắt bản đầu trỏ nhầm SeedAsync).
|
||||
private static async Task SeedContractCreateAccessAsync(
|
||||
ApplicationDbContext db, RoleManager<Role> roleManager, ILogger logger)
|
||||
{
|
||||
// CHỈ key root `Contracts` — đó là key mà policy `Contracts.Create` của endpoint đọc
|
||||
// (`MenuPermissionHandler` so khớp CHÍNH XÁC 1 key, KHÔNG kế thừa xuống `Ct_*`).
|
||||
// Cấp thêm cho `Ct_*` là cấp thừa: không endpoint nào gate bằng key con đó.
|
||||
const string key = MenuKeys.Contracts;
|
||||
var roleNames = new[] { AppRoles.Drafter, AppRoles.Procurement, AppRoles.Admin };
|
||||
|
||||
var roles = await roleManager.Roles
|
||||
.Where(r => r.Name != null && roleNames.Contains(r.Name))
|
||||
.ToListAsync();
|
||||
if (roles.Count == 0)
|
||||
{
|
||||
logger.LogWarning("SeedContractCreateAccessAsync: skip — chưa seed role nào trong {Roles}.",
|
||||
string.Join("/", roleNames));
|
||||
return;
|
||||
}
|
||||
|
||||
var roleIds = roles.Select(r => r.Id).ToList();
|
||||
var existing = (await db.Permissions
|
||||
.Where(p => p.MenuKey == key && roleIds.Contains(p.RoleId))
|
||||
.ToListAsync())
|
||||
.ToDictionary(p => p.RoleId);
|
||||
|
||||
var added = 0;
|
||||
var upgraded = 0;
|
||||
foreach (var role in roles)
|
||||
{
|
||||
if (existing.TryGetValue(role.Id, out var row))
|
||||
{
|
||||
var changed = false;
|
||||
if (!row.CanRead) { row.CanRead = true; changed = true; }
|
||||
if (!row.CanCreate) { row.CanCreate = true; changed = true; }
|
||||
if (changed) upgraded++;
|
||||
continue;
|
||||
}
|
||||
|
||||
db.Permissions.Add(new Permission
|
||||
{
|
||||
RoleId = role.Id,
|
||||
MenuKey = key,
|
||||
CanRead = true,
|
||||
CanCreate = true,
|
||||
CanUpdate = false,
|
||||
CanDelete = false,
|
||||
});
|
||||
added++;
|
||||
}
|
||||
|
||||
if (added > 0 || upgraded > 0)
|
||||
{
|
||||
await db.SaveChangesAsync();
|
||||
logger.LogInformation(
|
||||
"Seeded Contracts.Create access: {Added} added + {Upgraded} upgraded " +
|
||||
"(K7 cầu KHKK→HĐ — {Roles}; câu III default chờ ratify)",
|
||||
added, upgraded, string.Join("/", roleNames));
|
||||
}
|
||||
}
|
||||
|
||||
// [S65 2026-06-16] Mở quyền XEM (Read-only) "Hồ sơ Nhân sự" cho MỌI role: anh chốt
|
||||
// public module Nhân sự (trọng tâm Hồ sơ NS) để user thường tra cứu hồ sơ nhân sự.
|
||||
// CHẠY SAU RevokeTemporarilyHiddenModulesAsync (SeedAsync) để THẮNG revoke: revoke
|
||||
|
||||
Reference in New Issue
Block a user