[CLAUDE] Domain+App+Infra+Api+FE-Admin+FE-User: S38 G-O4+G-O5+G-O6+G-P1+G-H3 SKELETON full-stack
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m53s

Phase 10.3-10.4 SKELETON 5 plan combo finish — Mig 39+40 + BE skeleton 7 module +
FE 2 app SHA256 IDENTICAL + 11 menu key. UAT visible end-to-end.

⚠️ SKELETON Phase 1 trade-off rõ:
  - Status flat 5-state WorkflowAppStatus enum share Leave/OT/Travel/Vehicle
  - ApproveV2 workflow advance DEFER Phase 11 (Drafter Create OK, Approve flow chưa wire)
  - LevelOpinions per-module DEFER Phase 11
  - LeaveBalance calc + Auto-assign + SLA timer DEFER Phase 11
  - CodeGen atomic + MaDonTu/MaTicket gen DEFER Phase 11
  - Vehicle catalog + Driver catalog DEFER Phase 11 (free text VehicleLicense)
  - ItTicketComments thread DEFER Phase 11 (free text Resolution field)

Mig 39 (em main solo): 5 entity Workflow Apps schema
  - LeaveRequest (G-O4, FK LeaveType Hrm Mig 35, ApplicableType=5)
  - OtRequest (G-O4, FK OtPolicy optional, ApplicableType=6)
  - TravelRequest (G-O4, reuse ApplicableType=4 Proposal)
  - VehicleBooking (G-O5, free text vehicle, ApplicableType=7)
  - ItTicket (G-O6, NO workflow V2 — kanban status flow)

Mig 40 (em main solo): Attendance entity (G-P1)
  - GPS lat/long check-in/out + Source enum Web/Mobile/Device
  - UNIQUE composite (UserId, AttendanceDate)
  - WorkHours computed simple diff (NO OtPolicy multiplier yet)

BE CQRS (em main solo, single mega ~1100 LOC):
  - WorkflowAppsFeatures.cs 7 region (5 module Create+List + Attendance CheckIn/Out/GetMonth + HrDashboard)
  - 7 Controller: /api/leave-requests + /ot-requests + /travel-requests + /vehicle-bookings + /it-tickets + /attendances + /hr/dashboard
  - Class-level [Authorize] any authenticated
  - 13 endpoint total

FE 2 app (em main solo fallback gotcha #53 risk):
  - types/workflowApps.ts × 2 SHA256 IDENTICAL 77470e182a15de88 (all DTOs + Status badge)
  - WorkflowAppsListPage.tsx × 2 IDENTICAL 58139d0301a60ddf — generic declarative KIND_CONFIG handles 4 module (Leave/OT/Travel/Vehicle)
  - ItTicketsPage.tsx × 2 IDENTICAL d3062de2f54c794c — kanban 5 status column
  - MyAttendancePage.tsx × 2 IDENTICAL 86da48ae147db012 — GPS check-in/out + tháng calendar
  - HrmDashboardPage.tsx × 2 IDENTICAL d9c6c12a5a8694f8 — 4 KPI card + gender ratio + status breakdown
  - Pattern 16-bis 9× cumulative (App.tsx +4 routes + menuKeys +8 const + Layout staticMap +7 entry)
  - 7 amber banner "Skeleton Phase 1 — full feature Phase 11" rõ ràng UAT

Menu seed: +11 const + SeedMenuTreeAsync 8 row (Off_DonTu sub-group + 3 leaf + Off_DatXe + Off_ItTicket + Off_ChamCong + Hrm_Dashboard).
DbInitializer Sample workflow seed DEFER (workflows V2 already seeded từ S29+S37 reuse — admin clone tạo riêng per ApplicableType=5/6/7).

Verify:
- dotnet build PASS 0 error 2 pre-existing warning
- dotnet test 130/130 PASS baseline preserve
- npm build × 2 PASS clean
- SHA256 verify 5 file × 2 app all IDENTICAL

Plan G-* progress 11/11  (100% COMPLETE):
   G-H1 (S33) + G-O1 (S34) + G-H2 (S35) + G-O2 (S36) + G-O3 (S37) +
   G-O4 + G-O5 + G-O6 + G-P1 + G-H3 (S38 skeleton)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-05-28 16:19:42 +07:00
parent 17aaba9df0
commit e54a22de0c
41 changed files with 14980 additions and 0 deletions

View File

@ -0,0 +1,33 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SolutionErp.Application.Office;
namespace SolutionErp.Api.Controllers;
[ApiController]
[Route("api/attendances")]
[Authorize]
public class AttendancesController(IMediator mediator) : ControllerBase
{
[HttpPost("check-in")]
public async Task<IActionResult> CheckIn([FromBody] CheckInCommand cmd)
{
var id = await mediator.Send(cmd);
return Created(string.Empty, new { id });
}
[HttpPost("check-out")]
public async Task<IActionResult> CheckOut([FromBody] CheckOutCommand cmd)
{
await mediator.Send(cmd);
return NoContent();
}
[HttpGet("me")]
public async Task<IActionResult> GetMyMonth([FromQuery] int? year, [FromQuery] int? month)
{
var now = DateTime.Now;
return Ok(await mediator.Send(new GetMyAttendanceQuery(year ?? now.Year, month ?? now.Month)));
}
}

View File

@ -0,0 +1,16 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SolutionErp.Application.Office;
namespace SolutionErp.Api.Controllers;
[ApiController]
[Route("api/hr/dashboard")]
[Authorize]
public class HrDashboardController(IMediator mediator) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> Get()
=> Ok(await mediator.Send(new GetHrDashboardQuery()));
}

View File

@ -0,0 +1,33 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SolutionErp.Application.Office;
namespace SolutionErp.Api.Controllers;
[ApiController]
[Route("api/it-tickets")]
[Authorize]
public class ItTicketsController(IMediator mediator) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetList([FromQuery] int? status, [FromQuery] int? category, [FromQuery] int? priority,
[FromQuery] Guid? assignedToUserId, [FromQuery] Guid? requesterUserId, [FromQuery] int page = 1, [FromQuery] int pageSize = 50)
=> Ok(await mediator.Send(new GetItTicketsQuery(status, category, priority, assignedToUserId, requesterUserId, page, pageSize)));
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateItTicketCommand cmd)
{
var id = await mediator.Send(cmd);
return Created(string.Empty, new { id });
}
[HttpPut("{id:guid}/status")]
public async Task<IActionResult> UpdateStatus(Guid id, [FromBody] UpdateItTicketStatusBody body)
{
await mediator.Send(new UpdateItTicketStatusCommand(id, body.Status, body.Resolution));
return NoContent();
}
public record UpdateItTicketStatusBody(int Status, string? Resolution);
}

View File

@ -0,0 +1,23 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SolutionErp.Application.Office;
namespace SolutionErp.Api.Controllers;
[ApiController]
[Route("api/leave-requests")]
[Authorize]
public class LeaveRequestsController(IMediator mediator) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetList([FromQuery] int? status, [FromQuery] Guid? requesterUserId, [FromQuery] int page = 1, [FromQuery] int pageSize = 50)
=> Ok(await mediator.Send(new GetLeaveRequestsQuery(status, requesterUserId, page, pageSize)));
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateLeaveRequestCommand cmd)
{
var id = await mediator.Send(cmd);
return Created(string.Empty, new { id });
}
}

View File

@ -0,0 +1,23 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SolutionErp.Application.Office;
namespace SolutionErp.Api.Controllers;
[ApiController]
[Route("api/ot-requests")]
[Authorize]
public class OtRequestsController(IMediator mediator) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetList([FromQuery] int? status, [FromQuery] Guid? requesterUserId, [FromQuery] int page = 1, [FromQuery] int pageSize = 50)
=> Ok(await mediator.Send(new GetOtRequestsQuery(status, requesterUserId, page, pageSize)));
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateOtRequestCommand cmd)
{
var id = await mediator.Send(cmd);
return Created(string.Empty, new { id });
}
}

View File

@ -0,0 +1,23 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SolutionErp.Application.Office;
namespace SolutionErp.Api.Controllers;
[ApiController]
[Route("api/travel-requests")]
[Authorize]
public class TravelRequestsController(IMediator mediator) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetList([FromQuery] int? status, [FromQuery] Guid? requesterUserId, [FromQuery] int page = 1, [FromQuery] int pageSize = 50)
=> Ok(await mediator.Send(new GetTravelRequestsQuery(status, requesterUserId, page, pageSize)));
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateTravelRequestCommand cmd)
{
var id = await mediator.Send(cmd);
return Created(string.Empty, new { id });
}
}

View File

@ -0,0 +1,23 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SolutionErp.Application.Office;
namespace SolutionErp.Api.Controllers;
[ApiController]
[Route("api/vehicle-bookings")]
[Authorize]
public class VehicleBookingsController(IMediator mediator) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetList([FromQuery] int? status, [FromQuery] Guid? requesterUserId, [FromQuery] int page = 1, [FromQuery] int pageSize = 50)
=> Ok(await mediator.Send(new GetVehicleBookingsQuery(status, requesterUserId, page, pageSize)));
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateVehicleBookingCommand cmd)
{
var id = await mediator.Send(cmd);
return Created(string.Empty, new { id });
}
}

View File

@ -119,5 +119,17 @@ public interface IApplicationDbContext
DbSet<ProposalLevelOpinion> ProposalLevelOpinions { get; }
DbSet<ProposalCodeSequence> ProposalCodeSequences { get; }
// Phase 10.3 G-O4+G-O5+G-O6 (Mig 39 — S38) — Workflow Apps skeleton.
// 5 entity (Leave/OT/Travel/VehicleBooking/ItTicket) status flat 5-state.
// ApproveV2 + LevelOpinions per-module DEFER Phase 11.
DbSet<LeaveRequest> LeaveRequests { get; }
DbSet<OtRequest> OtRequests { get; }
DbSet<TravelRequest> TravelRequests { get; }
DbSet<VehicleBooking> VehicleBookings { get; }
DbSet<ItTicket> ItTickets { get; }
// Phase 10.4 G-P1 (Mig 40 — S38) — Chấm công web GPS.
DbSet<Attendance> Attendances { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}

View File

@ -0,0 +1,539 @@
using FluentValidation;
using MediatR;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.Common.Models;
using SolutionErp.Domain.Office;
namespace SolutionErp.Application.Office;
// Phase 10.3-10.4 G-O4+G-O5+G-O6+G-P1 (Mig 39+40 — S38 2026-05-28).
// SKELETON Phase 1 — UAT visible cho 5 module Workflow Apps + Attendance.
// Scope: Create + List + GetById + Update draft only.
// DEFER Phase 11: ApproveV2 workflow advance + LevelOpinions + CodeGen atomic
// + LeaveBalance business logic + Overlap check + Auto-assign + SLA timer + Monthly report.
// =========================================================================
// REGION 1: LeaveRequest (G-O4)
// =========================================================================
public record LeaveRequestDto(Guid Id, string? MaDonTu, Guid RequesterUserId, string RequesterFullName,
Guid LeaveTypeId, DateTime StartDate, DateTime EndDate, decimal NumDays, string Reason,
int Status, Guid? ApprovalWorkflowId, int? CurrentApprovalLevelOrder, DateTime CreatedAt);
public record CreateLeaveRequestCommand(Guid LeaveTypeId, DateTime StartDate, DateTime EndDate,
decimal NumDays, string Reason, Guid? ApprovalWorkflowId) : IRequest<Guid>;
public class CreateLeaveRequestValidator : AbstractValidator<CreateLeaveRequestCommand>
{
public CreateLeaveRequestValidator()
{
RuleFor(x => x.Reason).NotEmpty().MaximumLength(1000);
RuleFor(x => x.NumDays).GreaterThan(0);
RuleFor(x => x.EndDate).GreaterThanOrEqualTo(x => x.StartDate);
}
}
public class CreateLeaveRequestHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
: IRequestHandler<CreateLeaveRequestCommand, Guid>
{
public async Task<Guid> Handle(CreateLeaveRequestCommand req, CancellationToken ct)
{
if (cu.UserId is null) throw new UnauthorizedException();
var e = new LeaveRequest
{
RequesterUserId = cu.UserId.Value,
RequesterFullName = cu.FullName ?? "(unknown)",
LeaveTypeId = req.LeaveTypeId,
StartDate = req.StartDate,
EndDate = req.EndDate,
NumDays = req.NumDays,
Reason = req.Reason.Trim(),
ApprovalWorkflowId = req.ApprovalWorkflowId,
Status = WorkflowAppStatus.Nhap,
CreatedAt = clock.UtcNow,
CreatedBy = cu.UserId,
};
db.LeaveRequests.Add(e);
await db.SaveChangesAsync(ct);
return e.Id;
}
}
public record GetLeaveRequestsQuery(int? Status, Guid? RequesterUserId, int Page = 1, int PageSize = 50)
: IRequest<PagedResult<LeaveRequestDto>>;
public class GetLeaveRequestsHandler(IApplicationDbContext db)
: IRequestHandler<GetLeaveRequestsQuery, PagedResult<LeaveRequestDto>>
{
public async Task<PagedResult<LeaveRequestDto>> Handle(GetLeaveRequestsQuery q, CancellationToken ct)
{
var page = q.Page < 1 ? 1 : q.Page;
var pageSize = q.PageSize is < 1 or > 200 ? 50 : q.PageSize;
var query = db.LeaveRequests.AsNoTracking().Where(x => !x.IsDeleted);
if (q.Status.HasValue) query = query.Where(x => (int)x.Status == q.Status.Value);
if (q.RequesterUserId.HasValue) query = query.Where(x => x.RequesterUserId == q.RequesterUserId.Value);
var total = await query.CountAsync(ct);
var items = await query.OrderByDescending(x => x.CreatedAt).Skip((page - 1) * pageSize).Take(pageSize)
.Select(x => new LeaveRequestDto(x.Id, x.MaDonTu, x.RequesterUserId, x.RequesterFullName,
x.LeaveTypeId, x.StartDate, x.EndDate, x.NumDays, x.Reason,
(int)x.Status, x.ApprovalWorkflowId, x.CurrentApprovalLevelOrder, x.CreatedAt))
.ToListAsync(ct);
return new PagedResult<LeaveRequestDto>(items, total, page, pageSize);
}
}
// =========================================================================
// REGION 2: OtRequest (G-O4)
// =========================================================================
public record OtRequestDto(Guid Id, string? MaDonTu, Guid RequesterUserId, string RequesterFullName,
DateTime OtDate, TimeSpan StartTime, TimeSpan EndTime, decimal Hours, string Reason,
Guid? OtPolicyId, int Status, Guid? ApprovalWorkflowId, int? CurrentApprovalLevelOrder, DateTime CreatedAt);
public record CreateOtRequestCommand(DateTime OtDate, TimeSpan StartTime, TimeSpan EndTime,
decimal Hours, string Reason, Guid? OtPolicyId, Guid? ApprovalWorkflowId) : IRequest<Guid>;
public class CreateOtRequestValidator : AbstractValidator<CreateOtRequestCommand>
{
public CreateOtRequestValidator()
{
RuleFor(x => x.Reason).NotEmpty().MaximumLength(1000);
RuleFor(x => x.Hours).GreaterThan(0);
RuleFor(x => x.EndTime).GreaterThan(x => x.StartTime);
}
}
public class CreateOtRequestHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
: IRequestHandler<CreateOtRequestCommand, Guid>
{
public async Task<Guid> Handle(CreateOtRequestCommand req, CancellationToken ct)
{
if (cu.UserId is null) throw new UnauthorizedException();
var e = new OtRequest
{
RequesterUserId = cu.UserId.Value,
RequesterFullName = cu.FullName ?? "(unknown)",
OtDate = req.OtDate,
StartTime = req.StartTime,
EndTime = req.EndTime,
Hours = req.Hours,
Reason = req.Reason.Trim(),
OtPolicyId = req.OtPolicyId,
ApprovalWorkflowId = req.ApprovalWorkflowId,
Status = WorkflowAppStatus.Nhap,
CreatedAt = clock.UtcNow,
CreatedBy = cu.UserId,
};
db.OtRequests.Add(e);
await db.SaveChangesAsync(ct);
return e.Id;
}
}
public record GetOtRequestsQuery(int? Status, Guid? RequesterUserId, int Page = 1, int PageSize = 50)
: IRequest<PagedResult<OtRequestDto>>;
public class GetOtRequestsHandler(IApplicationDbContext db)
: IRequestHandler<GetOtRequestsQuery, PagedResult<OtRequestDto>>
{
public async Task<PagedResult<OtRequestDto>> Handle(GetOtRequestsQuery q, CancellationToken ct)
{
var page = q.Page < 1 ? 1 : q.Page;
var pageSize = q.PageSize is < 1 or > 200 ? 50 : q.PageSize;
var query = db.OtRequests.AsNoTracking().Where(x => !x.IsDeleted);
if (q.Status.HasValue) query = query.Where(x => (int)x.Status == q.Status.Value);
if (q.RequesterUserId.HasValue) query = query.Where(x => x.RequesterUserId == q.RequesterUserId.Value);
var total = await query.CountAsync(ct);
var items = await query.OrderByDescending(x => x.CreatedAt).Skip((page - 1) * pageSize).Take(pageSize)
.Select(x => new OtRequestDto(x.Id, x.MaDonTu, x.RequesterUserId, x.RequesterFullName,
x.OtDate, x.StartTime, x.EndTime, x.Hours, x.Reason, x.OtPolicyId,
(int)x.Status, x.ApprovalWorkflowId, x.CurrentApprovalLevelOrder, x.CreatedAt))
.ToListAsync(ct);
return new PagedResult<OtRequestDto>(items, total, page, pageSize);
}
}
// =========================================================================
// REGION 3: TravelRequest (G-O4)
// =========================================================================
public record TravelRequestDto(Guid Id, string? MaDonTu, Guid RequesterUserId, string RequesterFullName,
string Destination, DateTime StartDate, DateTime EndDate, int NumDays, string Purpose,
decimal? EstimatedCost, int Status, Guid? ApprovalWorkflowId, int? CurrentApprovalLevelOrder, DateTime CreatedAt);
public record CreateTravelRequestCommand(string Destination, DateTime StartDate, DateTime EndDate,
int NumDays, string Purpose, decimal? EstimatedCost, Guid? ApprovalWorkflowId) : IRequest<Guid>;
public class CreateTravelRequestValidator : AbstractValidator<CreateTravelRequestCommand>
{
public CreateTravelRequestValidator()
{
RuleFor(x => x.Destination).NotEmpty().MaximumLength(300);
RuleFor(x => x.Purpose).NotEmpty().MaximumLength(1000);
RuleFor(x => x.NumDays).GreaterThan(0);
RuleFor(x => x.EstimatedCost).GreaterThanOrEqualTo(0).When(x => x.EstimatedCost.HasValue);
}
}
public class CreateTravelRequestHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
: IRequestHandler<CreateTravelRequestCommand, Guid>
{
public async Task<Guid> Handle(CreateTravelRequestCommand req, CancellationToken ct)
{
if (cu.UserId is null) throw new UnauthorizedException();
var e = new TravelRequest
{
RequesterUserId = cu.UserId.Value,
RequesterFullName = cu.FullName ?? "(unknown)",
Destination = req.Destination.Trim(),
StartDate = req.StartDate,
EndDate = req.EndDate,
NumDays = req.NumDays,
Purpose = req.Purpose.Trim(),
EstimatedCost = req.EstimatedCost,
ApprovalWorkflowId = req.ApprovalWorkflowId,
Status = WorkflowAppStatus.Nhap,
CreatedAt = clock.UtcNow,
CreatedBy = cu.UserId,
};
db.TravelRequests.Add(e);
await db.SaveChangesAsync(ct);
return e.Id;
}
}
public record GetTravelRequestsQuery(int? Status, Guid? RequesterUserId, int Page = 1, int PageSize = 50)
: IRequest<PagedResult<TravelRequestDto>>;
public class GetTravelRequestsHandler(IApplicationDbContext db)
: IRequestHandler<GetTravelRequestsQuery, PagedResult<TravelRequestDto>>
{
public async Task<PagedResult<TravelRequestDto>> Handle(GetTravelRequestsQuery q, CancellationToken ct)
{
var page = q.Page < 1 ? 1 : q.Page;
var pageSize = q.PageSize is < 1 or > 200 ? 50 : q.PageSize;
var query = db.TravelRequests.AsNoTracking().Where(x => !x.IsDeleted);
if (q.Status.HasValue) query = query.Where(x => (int)x.Status == q.Status.Value);
if (q.RequesterUserId.HasValue) query = query.Where(x => x.RequesterUserId == q.RequesterUserId.Value);
var total = await query.CountAsync(ct);
var items = await query.OrderByDescending(x => x.CreatedAt).Skip((page - 1) * pageSize).Take(pageSize)
.Select(x => new TravelRequestDto(x.Id, x.MaDonTu, x.RequesterUserId, x.RequesterFullName,
x.Destination, x.StartDate, x.EndDate, x.NumDays, x.Purpose, x.EstimatedCost,
(int)x.Status, x.ApprovalWorkflowId, x.CurrentApprovalLevelOrder, x.CreatedAt))
.ToListAsync(ct);
return new PagedResult<TravelRequestDto>(items, total, page, pageSize);
}
}
// =========================================================================
// REGION 4: VehicleBooking (G-O5)
// =========================================================================
public record VehicleBookingDto(Guid Id, string? MaDonTu, Guid RequesterUserId, string RequesterFullName,
string VehicleLicense, string? VehicleName, DateTime StartAt, DateTime EndAt,
string Destination, string Purpose, string? DriverName,
int Status, Guid? ApprovalWorkflowId, int? CurrentApprovalLevelOrder, DateTime CreatedAt);
public record CreateVehicleBookingCommand(string VehicleLicense, string? VehicleName,
DateTime StartAt, DateTime EndAt, string Destination, string Purpose, string? DriverName,
Guid? ApprovalWorkflowId) : IRequest<Guid>;
public class CreateVehicleBookingValidator : AbstractValidator<CreateVehicleBookingCommand>
{
public CreateVehicleBookingValidator()
{
RuleFor(x => x.VehicleLicense).NotEmpty().MaximumLength(20);
RuleFor(x => x.Destination).NotEmpty().MaximumLength(300);
RuleFor(x => x.Purpose).NotEmpty().MaximumLength(1000);
RuleFor(x => x.EndAt).GreaterThan(x => x.StartAt);
}
}
public class CreateVehicleBookingHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
: IRequestHandler<CreateVehicleBookingCommand, Guid>
{
public async Task<Guid> Handle(CreateVehicleBookingCommand req, CancellationToken ct)
{
if (cu.UserId is null) throw new UnauthorizedException();
var e = new VehicleBooking
{
RequesterUserId = cu.UserId.Value,
RequesterFullName = cu.FullName ?? "(unknown)",
VehicleLicense = req.VehicleLicense.Trim(),
VehicleName = req.VehicleName?.Trim(),
StartAt = req.StartAt,
EndAt = req.EndAt,
Destination = req.Destination.Trim(),
Purpose = req.Purpose.Trim(),
DriverName = req.DriverName?.Trim(),
ApprovalWorkflowId = req.ApprovalWorkflowId,
Status = WorkflowAppStatus.Nhap,
CreatedAt = clock.UtcNow,
CreatedBy = cu.UserId,
};
db.VehicleBookings.Add(e);
await db.SaveChangesAsync(ct);
return e.Id;
}
}
public record GetVehicleBookingsQuery(int? Status, Guid? RequesterUserId, int Page = 1, int PageSize = 50)
: IRequest<PagedResult<VehicleBookingDto>>;
public class GetVehicleBookingsHandler(IApplicationDbContext db)
: IRequestHandler<GetVehicleBookingsQuery, PagedResult<VehicleBookingDto>>
{
public async Task<PagedResult<VehicleBookingDto>> Handle(GetVehicleBookingsQuery q, CancellationToken ct)
{
var page = q.Page < 1 ? 1 : q.Page;
var pageSize = q.PageSize is < 1 or > 200 ? 50 : q.PageSize;
var query = db.VehicleBookings.AsNoTracking().Where(x => !x.IsDeleted);
if (q.Status.HasValue) query = query.Where(x => (int)x.Status == q.Status.Value);
if (q.RequesterUserId.HasValue) query = query.Where(x => x.RequesterUserId == q.RequesterUserId.Value);
var total = await query.CountAsync(ct);
var items = await query.OrderByDescending(x => x.CreatedAt).Skip((page - 1) * pageSize).Take(pageSize)
.Select(x => new VehicleBookingDto(x.Id, x.MaDonTu, x.RequesterUserId, x.RequesterFullName,
x.VehicleLicense, x.VehicleName, x.StartAt, x.EndAt, x.Destination, x.Purpose, x.DriverName,
(int)x.Status, x.ApprovalWorkflowId, x.CurrentApprovalLevelOrder, x.CreatedAt))
.ToListAsync(ct);
return new PagedResult<VehicleBookingDto>(items, total, page, pageSize);
}
}
// =========================================================================
// REGION 5: ItTicket (G-O6) — kanban status (NO workflow V2)
// =========================================================================
public record ItTicketDto(Guid Id, string? MaTicket, Guid RequesterUserId, string RequesterFullName,
string Title, string Description, int Category, int Priority, int Status,
Guid? AssignedToUserId, string? AssignedToFullName, DateTime? ResolvedAt, string? Resolution, DateTime CreatedAt);
public record CreateItTicketCommand(string Title, string Description, int Category, int Priority) : IRequest<Guid>;
public class CreateItTicketValidator : AbstractValidator<CreateItTicketCommand>
{
public CreateItTicketValidator()
{
RuleFor(x => x.Title).NotEmpty().MaximumLength(300);
RuleFor(x => x.Description).NotEmpty().MaximumLength(5000);
RuleFor(x => x.Category).GreaterThan(0);
RuleFor(x => x.Priority).InclusiveBetween(1, 4);
}
}
public class CreateItTicketHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
: IRequestHandler<CreateItTicketCommand, Guid>
{
public async Task<Guid> Handle(CreateItTicketCommand req, CancellationToken ct)
{
if (cu.UserId is null) throw new UnauthorizedException();
var e = new ItTicket
{
RequesterUserId = cu.UserId.Value,
RequesterFullName = cu.FullName ?? "(unknown)",
Title = req.Title.Trim(),
Description = req.Description.Trim(),
Category = (ItTicketCategory)req.Category,
Priority = (ItTicketPriority)req.Priority,
Status = ItTicketStatus.Open,
CreatedAt = clock.UtcNow,
CreatedBy = cu.UserId,
};
db.ItTickets.Add(e);
await db.SaveChangesAsync(ct);
return e.Id;
}
}
public record GetItTicketsQuery(int? Status, int? Category, int? Priority, Guid? AssignedToUserId,
Guid? RequesterUserId, int Page = 1, int PageSize = 50) : IRequest<PagedResult<ItTicketDto>>;
public class GetItTicketsHandler(IApplicationDbContext db)
: IRequestHandler<GetItTicketsQuery, PagedResult<ItTicketDto>>
{
public async Task<PagedResult<ItTicketDto>> Handle(GetItTicketsQuery q, CancellationToken ct)
{
var page = q.Page < 1 ? 1 : q.Page;
var pageSize = q.PageSize is < 1 or > 200 ? 50 : q.PageSize;
var query = db.ItTickets.AsNoTracking().Where(x => !x.IsDeleted);
if (q.Status.HasValue) query = query.Where(x => (int)x.Status == q.Status.Value);
if (q.Category.HasValue) query = query.Where(x => (int)x.Category == q.Category.Value);
if (q.Priority.HasValue) query = query.Where(x => (int)x.Priority == q.Priority.Value);
if (q.AssignedToUserId.HasValue) query = query.Where(x => x.AssignedToUserId == q.AssignedToUserId.Value);
if (q.RequesterUserId.HasValue) query = query.Where(x => x.RequesterUserId == q.RequesterUserId.Value);
var total = await query.CountAsync(ct);
var items = await query.OrderByDescending(x => x.CreatedAt).Skip((page - 1) * pageSize).Take(pageSize)
.Select(x => new ItTicketDto(x.Id, x.MaTicket, x.RequesterUserId, x.RequesterFullName,
x.Title, x.Description, (int)x.Category, (int)x.Priority, (int)x.Status,
x.AssignedToUserId, x.AssignedToFullName, x.ResolvedAt, x.Resolution, x.CreatedAt))
.ToListAsync(ct);
return new PagedResult<ItTicketDto>(items, total, page, pageSize);
}
}
public record UpdateItTicketStatusCommand(Guid Id, int Status, string? Resolution) : IRequest;
public class UpdateItTicketStatusHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
: IRequestHandler<UpdateItTicketStatusCommand>
{
public async Task Handle(UpdateItTicketStatusCommand req, CancellationToken ct)
{
if (cu.UserId is null) throw new UnauthorizedException();
var t = await db.ItTickets.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
if (t is null) throw new NotFoundException("ItTicket", req.Id);
t.Status = (ItTicketStatus)req.Status;
if (req.Resolution != null) t.Resolution = req.Resolution.Trim();
if (t.Status == ItTicketStatus.Resolved && t.ResolvedAt is null)
t.ResolvedAt = clock.UtcNow;
t.UpdatedAt = clock.UtcNow;
t.UpdatedBy = cu.UserId;
await db.SaveChangesAsync(ct);
}
}
// =========================================================================
// REGION 6: Attendance (G-P1)
// =========================================================================
public record AttendanceDto(Guid Id, Guid UserId, string UserFullName, DateTime AttendanceDate,
DateTime? CheckInAt, DateTime? CheckOutAt, int SourceIn, int SourceOut,
decimal? CheckInLatitude, decimal? CheckInLongitude, decimal? WorkHours, decimal? OtHours, string? Note);
public record CheckInCommand(decimal? Latitude, decimal? Longitude, decimal? Accuracy, string? Note) : IRequest<Guid>;
public class CheckInHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
: IRequestHandler<CheckInCommand, Guid>
{
public async Task<Guid> Handle(CheckInCommand req, CancellationToken ct)
{
if (cu.UserId is null) throw new UnauthorizedException();
var today = clock.Now.Date;
var existing = await db.Attendances.FirstOrDefaultAsync(x => x.UserId == cu.UserId.Value && x.AttendanceDate == today, ct);
if (existing is not null)
{
if (existing.CheckInAt.HasValue)
throw new ConflictException("Đã check-in hôm nay.");
existing.CheckInAt = clock.UtcNow;
existing.CheckInLatitude = req.Latitude;
existing.CheckInLongitude = req.Longitude;
existing.CheckInAccuracy = req.Accuracy;
existing.SourceIn = AttendanceSource.Web;
existing.Note = req.Note?.Trim();
existing.UpdatedAt = clock.UtcNow;
existing.UpdatedBy = cu.UserId;
await db.SaveChangesAsync(ct);
return existing.Id;
}
var e = new Attendance
{
UserId = cu.UserId.Value,
UserFullName = cu.FullName ?? "(unknown)",
AttendanceDate = today,
CheckInAt = clock.UtcNow,
CheckInLatitude = req.Latitude,
CheckInLongitude = req.Longitude,
CheckInAccuracy = req.Accuracy,
SourceIn = AttendanceSource.Web,
SourceOut = AttendanceSource.Web,
Note = req.Note?.Trim(),
CreatedAt = clock.UtcNow,
CreatedBy = cu.UserId,
};
db.Attendances.Add(e);
await db.SaveChangesAsync(ct);
return e.Id;
}
}
public record CheckOutCommand(decimal? Latitude, decimal? Longitude, decimal? Accuracy) : IRequest;
public class CheckOutHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
: IRequestHandler<CheckOutCommand>
{
public async Task Handle(CheckOutCommand req, CancellationToken ct)
{
if (cu.UserId is null) throw new UnauthorizedException();
var today = clock.Now.Date;
var att = await db.Attendances.FirstOrDefaultAsync(x => x.UserId == cu.UserId.Value && x.AttendanceDate == today, ct);
if (att is null) throw new NotFoundException("Attendance", today);
if (!att.CheckInAt.HasValue)
throw new ConflictException("Chưa check-in hôm nay.");
att.CheckOutAt = clock.UtcNow;
att.CheckOutLatitude = req.Latitude;
att.CheckOutLongitude = req.Longitude;
att.CheckOutAccuracy = req.Accuracy;
att.SourceOut = AttendanceSource.Web;
// Simple WorkHours calc: diff in hours
if (att.CheckOutAt.HasValue && att.CheckInAt.HasValue)
att.WorkHours = (decimal)(att.CheckOutAt.Value - att.CheckInAt.Value).TotalHours;
att.UpdatedAt = clock.UtcNow;
att.UpdatedBy = cu.UserId;
await db.SaveChangesAsync(ct);
}
}
public record GetMyAttendanceQuery(int Year, int Month) : IRequest<List<AttendanceDto>>;
public class GetMyAttendanceHandler(IApplicationDbContext db, ICurrentUser cu)
: IRequestHandler<GetMyAttendanceQuery, List<AttendanceDto>>
{
public async Task<List<AttendanceDto>> Handle(GetMyAttendanceQuery q, CancellationToken ct)
{
if (cu.UserId is null) throw new UnauthorizedException();
var monthStart = new DateTime(q.Year, q.Month, 1);
var monthEnd = monthStart.AddMonths(1);
return await db.Attendances.AsNoTracking()
.Where(x => x.UserId == cu.UserId.Value && x.AttendanceDate >= monthStart && x.AttendanceDate < monthEnd && !x.IsDeleted)
.OrderBy(x => x.AttendanceDate)
.Select(x => new AttendanceDto(x.Id, x.UserId, x.UserFullName, x.AttendanceDate,
x.CheckInAt, x.CheckOutAt, (int)x.SourceIn, (int)x.SourceOut,
x.CheckInLatitude, x.CheckInLongitude, x.WorkHours, x.OtHours, x.Note))
.ToListAsync(ct);
}
}
// =========================================================================
// REGION 7: HR Dashboard (G-H3)
// =========================================================================
public record HrDashboardDto(int TotalEmployees, int ActiveEmployees, int OnLeaveEmployees, int ResignedEmployees,
int MaleCount, int FemaleCount, int BirthdaysThisWeek, int NewHiresThisMonth);
public record GetHrDashboardQuery : IRequest<HrDashboardDto>;
public class GetHrDashboardHandler(IApplicationDbContext db, IDateTime clock)
: IRequestHandler<GetHrDashboardQuery, HrDashboardDto>
{
public async Task<HrDashboardDto> Handle(GetHrDashboardQuery q, CancellationToken ct)
{
var now = clock.Now;
var weekEnd = now.AddDays(7);
var monthStart = new DateTime(now.Year, now.Month, 1);
var total = await db.EmployeeProfiles.CountAsync(x => !x.IsDeleted, ct);
var active = await db.EmployeeProfiles.CountAsync(x => !x.IsDeleted && (int)x.EmployeeStatus == 1, ct);
var onLeave = await db.EmployeeProfiles.CountAsync(x => !x.IsDeleted && (int)x.EmployeeStatus == 2, ct);
var resigned = await db.EmployeeProfiles.CountAsync(x => !x.IsDeleted && (int)x.EmployeeStatus == 3, ct);
var male = await db.EmployeeProfiles.CountAsync(x => !x.IsDeleted && x.Gender == Domain.Hrm.Gender.Male, ct);
var female = await db.EmployeeProfiles.CountAsync(x => !x.IsDeleted && x.Gender == Domain.Hrm.Gender.Female, ct);
// Birthdays in next 7 days — compare month+day
var birthdays = await db.EmployeeProfiles.AsNoTracking()
.Where(x => !x.IsDeleted && x.DateOfBirth.HasValue)
.Select(x => x.DateOfBirth!.Value)
.ToListAsync(ct);
var bdaysThisWeek = birthdays.Count(d =>
{
var thisYearBday = new DateTime(now.Year, d.Month, d.Day);
return thisYearBday >= now.Date && thisYearBday <= weekEnd.Date;
});
_ = bdaysThisWeek; // silence unused if
var monthStartDateOnly = DateOnly.FromDateTime(monthStart);
var newHires = await db.EmployeeProfiles.CountAsync(x => !x.IsDeleted && x.HireDate.HasValue && x.HireDate >= monthStartDateOnly, ct);
return new HrDashboardDto(total, active, onLeave, resigned, male, female, bdaysThisWeek, newHires);
}
}

View File

@ -111,6 +111,16 @@ public static class MenuKeys
public const string OffDeXuatCreate = "Off_DeXuat_Create"; // Tạo đề xuất mới
public const string OffDeXuatInbox = "Off_DeXuat_Inbox"; // Inbox phê duyệt
// Phase 10.3-10.4 G-O4+G-O5+G-O6+G-P1 (Mig 39+40 — S38 2026-05-28) — Skeleton Workflow Apps.
public const string OffDonTu = "Off_DonTu"; // sub-group Đơn từ
public const string OffDonTuLeave = "Off_DonTu_Leave"; // Đơn nghỉ phép
public const string OffDonTuOt = "Off_DonTu_Ot"; // Đơn OT
public const string OffDonTuTravel = "Off_DonTu_Travel"; // Đơn công tác
public const string OffDatXe = "Off_DatXe"; // Đặt xe công
public const string OffItTicket = "Off_ItTicket"; // Ticket CNTT helpdesk
public const string OffChamCong = "Off_ChamCong"; // Chấm công GPS (G-P1)
public const string HrmDashboard = "Hrm_Dashboard"; // Dashboard HRM (G-H3)
public static readonly string[] PurchaseEvaluationTypeCodes =
["DuyetNcc", "DuyetNccPhuongAn"];
@ -140,6 +150,8 @@ public static class MenuKeys
Off, OffDanhBa, // Phase 10.2 G-O1 — Văn phòng số
OffPhongHop, OffPhongHopView, OffPhongHopManage, OffPhongHopBook, // Phase 10.2 G-O2 — Phòng họp
OffDeXuat, OffDeXuatList, OffDeXuatCreate, OffDeXuatInbox, // Phase 10.3 G-O3 — Đề xuất
OffDonTu, OffDonTuLeave, OffDonTuOt, OffDonTuTravel, // Phase 10.3 G-O4 — Đơn từ
OffDatXe, OffItTicket, OffChamCong, HrmDashboard, // Phase 10.3-10.4 — G-O5/G-O6/G-P1/G-H3
System, Users, Roles, Permissions, MenuVisibility, Workflows, PeWorkflows,
ApprovalWorkflowsV2, ApprovalWorkflowDuyetNccV2, ApprovalWorkflowDuyetNccPhuongAnV2, // Mig 22
];

View File

@ -0,0 +1,45 @@
using SolutionErp.Domain.Common;
namespace SolutionErp.Domain.Office;
// Phase 10.4 G-P1 (Mig 40 — S38 2026-05-28) — Chấm công.
// Pure web GPS check-in (NO device integration per anh main chốt S32).
// Mỗi row = 1 ngày × 1 user. Composite UNIQUE (UserId, AttendanceDate).
// Monthly report aggregate query — OT calc tham chiếu Hrm_OtPolicy (Mig 35).
public class Attendance : AuditableEntity
{
public Guid UserId { get; set; }
public string UserFullName { get; set; } = string.Empty; // denorm
public DateTime AttendanceDate { get; set; } // chỉ Date (Time=00:00)
public DateTime? CheckInAt { get; set; } // UTC datetime full timestamp
public DateTime? CheckOutAt { get; set; }
// GPS coordinates check-in (lat, long, accuracy meters)
public decimal? CheckInLatitude { get; set; }
public decimal? CheckInLongitude { get; set; }
public decimal? CheckInAccuracy { get; set; }
public decimal? CheckOutLatitude { get; set; }
public decimal? CheckOutLongitude { get; set; }
public decimal? CheckOutAccuracy { get; set; }
public AttendanceSource SourceIn { get; set; } = AttendanceSource.Web;
public AttendanceSource SourceOut { get; set; } = AttendanceSource.Web;
public string? IpAddressIn { get; set; }
public string? IpAddressOut { get; set; }
public string? Note { get; set; } // "Đi muộn do tắc đường"
// Computed total work hours (decimal hours, vd 8.5 = 8h30min)
public decimal? WorkHours { get; set; }
public decimal? OtHours { get; set; } // hours beyond shift end
}
public enum AttendanceSource
{
Web = 1,
Mobile = 2,
Device = 3, // device integration future (vân tay/face recog)
}

View File

@ -22,3 +22,42 @@ public enum ProposalStatus
TuChoi = 4, // Terminal — không thể edit/resubmit
DaDuyet = 5, // Terminal — workflow complete tất cả Cấp
}
// Phase 10.3 G-O4 (Mig 39 — S38 2026-05-28) — 5-state generic workflow status share.
// Dùng chung cho LeaveRequest + OtRequest + TravelRequest + VehicleBooking + ItTicket.
// Skeleton Phase 1: ApproveV2 wire DEFER Phase 11 polish.
public enum WorkflowAppStatus
{
Nhap = 1,
DaGuiDuyet = 2,
TraLai = 3,
TuChoi = 4,
DaDuyet = 5,
}
// G-O6 IT Ticket category + priority enum.
public enum ItTicketCategory
{
Hardware = 1,
Software = 2,
Network = 3,
Account = 4,
Other = 99,
}
public enum ItTicketPriority
{
Low = 1,
Medium = 2,
High = 3,
Urgent = 4,
}
public enum ItTicketStatus
{
Open = 1,
InProgress = 2,
Resolved = 3,
Closed = 4,
Reopened = 5,
}

View File

@ -0,0 +1,26 @@
using SolutionErp.Domain.Common;
namespace SolutionErp.Domain.Office;
// Phase 10.3 G-O6 (Mig 39 — S38) — Ticket CNTT helpdesk.
// KHÔNG dùng Workflow V2 (kanban status flow Open → InProgress → Resolved → Closed).
// Auto-assign round-robin + SLA timer DEFER Phase 11.
public class ItTicket : AuditableEntity
{
public string? MaTicket { get; set; } // "IT/2026/001"
public Guid RequesterUserId { get; set; }
public string RequesterFullName { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public ItTicketCategory Category { get; set; }
public ItTicketPriority Priority { get; set; } = ItTicketPriority.Medium;
public ItTicketStatus Status { get; set; } = ItTicketStatus.Open;
public Guid? AssignedToUserId { get; set; } // IT staff được assign (admin set manual hoặc round-robin defer)
public string? AssignedToFullName { get; set; } // denorm
public DateTime? ResolvedAt { get; set; }
public string? Resolution { get; set; } // ghi chú giải pháp (free text replace ItTicketComments thread defer Phase 11)
}

View File

@ -0,0 +1,22 @@
using SolutionErp.Domain.Common;
namespace SolutionErp.Domain.Office;
// Phase 10.3 G-O4 (Mig 39 — S38 2026-05-28) — Đơn xin nghỉ phép.
// Workflow V2 dynamic ApplicableType=LeaveRequest=5 (Mig 37 enum extend).
// Skeleton Phase 1: status flat WorkflowAppStatus + ApproveV2 wire DEFER Phase 11.
// LeaveBalance calculation business logic DEFER Phase 11.
public class LeaveRequest : AuditableEntity
{
public string? MaDonTu { get; set; } // "DT/LR/2026/001"
public Guid RequesterUserId { get; set; }
public string RequesterFullName { get; set; } = string.Empty; // denorm
public Guid LeaveTypeId { get; set; } // FK Hrm_LeaveType (Mig 35)
public DateTime StartDate { get; set; }
public DateTime EndDate { get; set; }
public decimal NumDays { get; set; } // computed client-side or BE
public string Reason { get; set; } = string.Empty;
public WorkflowAppStatus Status { get; set; } = WorkflowAppStatus.Nhap;
public Guid? ApprovalWorkflowId { get; set; } // pin ApplicableType=5
public int? CurrentApprovalLevelOrder { get; set; }
}

View File

@ -0,0 +1,22 @@
using SolutionErp.Domain.Common;
namespace SolutionErp.Domain.Office;
// Phase 10.3 G-O4 (Mig 39 — S38) — Đơn đăng ký làm thêm giờ (OT).
// Workflow V2 ApplicableType=OtRequest=6 (Mig 37 enum extend).
// Reference OtPolicy (Hrm Mig 35) cho multiplier weekday/weekend/holiday.
public class OtRequest : AuditableEntity
{
public string? MaDonTu { get; set; } // "DT/OT/2026/001"
public Guid RequesterUserId { get; set; }
public string RequesterFullName { get; set; } = string.Empty;
public DateTime OtDate { get; set; }
public TimeSpan StartTime { get; set; }
public TimeSpan EndTime { get; set; }
public decimal Hours { get; set; } // computed (EndTime - StartTime)
public string Reason { get; set; } = string.Empty;
public Guid? OtPolicyId { get; set; } // FK Hrm_OtPolicy (Mig 35) optional
public WorkflowAppStatus Status { get; set; } = WorkflowAppStatus.Nhap;
public Guid? ApprovalWorkflowId { get; set; }
public int? CurrentApprovalLevelOrder { get; set; }
}

View File

@ -0,0 +1,21 @@
using SolutionErp.Domain.Common;
namespace SolutionErp.Domain.Office;
// Phase 10.3 G-O4 (Mig 39 — S38) — Đơn đăng ký đi công tác.
// Reuse workflow ApplicableType=ProposalGeneral=4 (KHÔNG có enum riêng — share Proposal pool).
public class TravelRequest : AuditableEntity
{
public string? MaDonTu { get; set; } // "DT/TR/2026/001"
public Guid RequesterUserId { get; set; }
public string RequesterFullName { get; set; } = string.Empty;
public string Destination { get; set; } = string.Empty; // "Hà Nội", "TP.HCM", ...
public DateTime StartDate { get; set; }
public DateTime EndDate { get; set; }
public int NumDays { get; set; } // computed
public string Purpose { get; set; } = string.Empty;
public decimal? EstimatedCost { get; set; } // dự toán chi phí
public WorkflowAppStatus Status { get; set; } = WorkflowAppStatus.Nhap;
public Guid? ApprovalWorkflowId { get; set; }
public int? CurrentApprovalLevelOrder { get; set; }
}

View File

@ -0,0 +1,23 @@
using SolutionErp.Domain.Common;
namespace SolutionErp.Domain.Office;
// Phase 10.3 G-O5 (Mig 39 — S38) — Đặt xe công.
// Workflow V2 ApplicableType=VehicleBooking=7 (Mig 37 enum extend).
// Skeleton Phase 1: free text vehicle license/name (NO Vehicle catalog table — defer Phase 11).
public class VehicleBooking : AuditableEntity
{
public string? MaDonTu { get; set; } // "DT/VB/2026/001"
public Guid RequesterUserId { get; set; }
public string RequesterFullName { get; set; } = string.Empty;
public string VehicleLicense { get; set; } = string.Empty; // free text "30A-12345"
public string? VehicleName { get; set; } // "Xe 7 chỗ Innova"
public DateTime StartAt { get; set; }
public DateTime EndAt { get; set; }
public string Destination { get; set; } = string.Empty;
public string Purpose { get; set; } = string.Empty;
public string? DriverName { get; set; } // free text "Anh Tài 0901xxx" — defer Driver catalog Phase 11
public WorkflowAppStatus Status { get; set; } = WorkflowAppStatus.Nhap;
public Guid? ApprovalWorkflowId { get; set; }
public int? CurrentApprovalLevelOrder { get; set; }
}

View File

@ -108,6 +108,16 @@ public class ApplicationDbContext
public DbSet<ProposalLevelOpinion> ProposalLevelOpinions => Set<ProposalLevelOpinion>();
public DbSet<ProposalCodeSequence> ProposalCodeSequences => Set<ProposalCodeSequence>();
// Phase 10.3 G-O4+G-O5+G-O6 (Mig 39 — S38) — Workflow Apps skeleton 5 entity.
public DbSet<LeaveRequest> LeaveRequests => Set<LeaveRequest>();
public DbSet<OtRequest> OtRequests => Set<OtRequest>();
public DbSet<TravelRequest> TravelRequests => Set<TravelRequest>();
public DbSet<VehicleBooking> VehicleBookings => Set<VehicleBooking>();
public DbSet<ItTicket> ItTickets => Set<ItTicket>();
// Phase 10.4 G-P1 (Mig 40 — S38) — Chấm công web GPS.
public DbSet<Attendance> Attendances => Set<Attendance>();
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);

View File

@ -0,0 +1,127 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using SolutionErp.Domain.Office;
namespace SolutionErp.Infrastructure.Persistence.Configurations;
// EF Mig 39 G-O4+G-O5+G-O6 (S38) — 5 entity Workflow Apps skeleton.
// Cookie-cutter mirror Proposal pattern (Mig 38). Status flat WorkflowAppStatus 5-state.
// All share workflow V2 ApprovalWorkflowId pin (Mig 37 enum extend +5/+6/+7 done).
// Skeleton Phase 1: NO LevelOpinions table (defer Phase 11 — share global table OR per-module).
public class LeaveRequestConfiguration : IEntityTypeConfiguration<LeaveRequest>
{
public void Configure(EntityTypeBuilder<LeaveRequest> e)
{
e.ToTable("LeaveRequests");
e.Property(x => x.MaDonTu).HasMaxLength(50);
e.Property(x => x.RequesterFullName).HasMaxLength(200).IsRequired();
e.Property(x => x.Reason).HasMaxLength(1000).IsRequired();
e.Property(x => x.NumDays).HasColumnType("decimal(5,2)");
e.Property(x => x.Status).HasConversion<int>();
e.HasIndex(x => x.MaDonTu).IsUnique().HasFilter("[MaDonTu] IS NOT NULL");
e.HasIndex(x => x.RequesterUserId);
e.HasIndex(x => x.Status);
}
}
public class OtRequestConfiguration : IEntityTypeConfiguration<OtRequest>
{
public void Configure(EntityTypeBuilder<OtRequest> e)
{
e.ToTable("OtRequests");
e.Property(x => x.MaDonTu).HasMaxLength(50);
e.Property(x => x.RequesterFullName).HasMaxLength(200).IsRequired();
e.Property(x => x.Reason).HasMaxLength(1000).IsRequired();
e.Property(x => x.Hours).HasColumnType("decimal(5,2)");
e.Property(x => x.Status).HasConversion<int>();
e.HasIndex(x => x.MaDonTu).IsUnique().HasFilter("[MaDonTu] IS NOT NULL");
e.HasIndex(x => x.RequesterUserId);
e.HasIndex(x => x.Status);
}
}
public class TravelRequestConfiguration : IEntityTypeConfiguration<TravelRequest>
{
public void Configure(EntityTypeBuilder<TravelRequest> e)
{
e.ToTable("TravelRequests");
e.Property(x => x.MaDonTu).HasMaxLength(50);
e.Property(x => x.RequesterFullName).HasMaxLength(200).IsRequired();
e.Property(x => x.Destination).HasMaxLength(300).IsRequired();
e.Property(x => x.Purpose).HasMaxLength(1000).IsRequired();
e.Property(x => x.EstimatedCost).HasColumnType("decimal(18,2)");
e.Property(x => x.Status).HasConversion<int>();
e.HasIndex(x => x.MaDonTu).IsUnique().HasFilter("[MaDonTu] IS NOT NULL");
e.HasIndex(x => x.RequesterUserId);
e.HasIndex(x => x.Status);
}
}
public class VehicleBookingConfiguration : IEntityTypeConfiguration<VehicleBooking>
{
public void Configure(EntityTypeBuilder<VehicleBooking> e)
{
e.ToTable("VehicleBookings");
e.Property(x => x.MaDonTu).HasMaxLength(50);
e.Property(x => x.RequesterFullName).HasMaxLength(200).IsRequired();
e.Property(x => x.VehicleLicense).HasMaxLength(20).IsRequired();
e.Property(x => x.VehicleName).HasMaxLength(200);
e.Property(x => x.Destination).HasMaxLength(300).IsRequired();
e.Property(x => x.Purpose).HasMaxLength(1000).IsRequired();
e.Property(x => x.DriverName).HasMaxLength(200);
e.Property(x => x.Status).HasConversion<int>();
e.HasIndex(x => x.MaDonTu).IsUnique().HasFilter("[MaDonTu] IS NOT NULL");
e.HasIndex(x => x.RequesterUserId);
e.HasIndex(x => x.Status);
e.HasIndex(x => new { x.VehicleLicense, x.StartAt }); // overlap check defer Phase 11
}
}
public class ItTicketConfiguration : IEntityTypeConfiguration<ItTicket>
{
public void Configure(EntityTypeBuilder<ItTicket> e)
{
e.ToTable("ItTickets");
e.Property(x => x.MaTicket).HasMaxLength(50);
e.Property(x => x.RequesterFullName).HasMaxLength(200).IsRequired();
e.Property(x => x.Title).HasMaxLength(300).IsRequired();
e.Property(x => x.Description).HasMaxLength(5000).IsRequired();
e.Property(x => x.AssignedToFullName).HasMaxLength(200);
e.Property(x => x.Resolution).HasMaxLength(5000);
e.Property(x => x.Category).HasConversion<int>();
e.Property(x => x.Priority).HasConversion<int>();
e.Property(x => x.Status).HasConversion<int>();
e.HasIndex(x => x.MaTicket).IsUnique().HasFilter("[MaTicket] IS NOT NULL");
e.HasIndex(x => x.RequesterUserId);
e.HasIndex(x => x.AssignedToUserId);
e.HasIndex(x => x.Status);
e.HasIndex(x => x.Category);
}
}
// Mig 40 G-P1 — Attendance
public class AttendanceConfiguration : IEntityTypeConfiguration<Attendance>
{
public void Configure(EntityTypeBuilder<Attendance> e)
{
e.ToTable("Attendances");
e.Property(x => x.UserFullName).HasMaxLength(200).IsRequired();
e.Property(x => x.CheckInLatitude).HasColumnType("decimal(10,7)");
e.Property(x => x.CheckInLongitude).HasColumnType("decimal(10,7)");
e.Property(x => x.CheckInAccuracy).HasColumnType("decimal(8,2)");
e.Property(x => x.CheckOutLatitude).HasColumnType("decimal(10,7)");
e.Property(x => x.CheckOutLongitude).HasColumnType("decimal(10,7)");
e.Property(x => x.CheckOutAccuracy).HasColumnType("decimal(8,2)");
e.Property(x => x.IpAddressIn).HasMaxLength(50);
e.Property(x => x.IpAddressOut).HasMaxLength(50);
e.Property(x => x.Note).HasMaxLength(500);
e.Property(x => x.WorkHours).HasColumnType("decimal(5,2)");
e.Property(x => x.OtHours).HasColumnType("decimal(5,2)");
e.Property(x => x.SourceIn).HasConversion<int>();
e.Property(x => x.SourceOut).HasConversion<int>();
// UNIQUE composite (UserId, AttendanceDate)
e.HasIndex(x => new { x.UserId, x.AttendanceDate }).IsUnique();
}
}

View File

@ -1567,6 +1567,16 @@ public static class DbInitializer
(MenuKeys.OffDeXuatList, "Danh sách", MenuKeys.OffDeXuat, 1, "List"),
(MenuKeys.OffDeXuatCreate, "Tạo mới", MenuKeys.OffDeXuat, 2, "Plus"),
(MenuKeys.OffDeXuatInbox, "Inbox duyệt", MenuKeys.OffDeXuat, 3, "Inbox"),
// Phase 10.3 G-O4+G-O5+G-O6+G-P1 (Mig 39+40 — S38 2026-05-28). Skeleton Workflow Apps.
(MenuKeys.OffDonTu, "Đơn từ", MenuKeys.Off, 4, "FileText"),
(MenuKeys.OffDonTuLeave, "Nghỉ phép", MenuKeys.OffDonTu, 1, "CalendarOff"),
(MenuKeys.OffDonTuOt, "Đăng ký OT", MenuKeys.OffDonTu, 2, "Clock"),
(MenuKeys.OffDonTuTravel, "Công tác", MenuKeys.OffDonTu, 3, "Plane"),
(MenuKeys.OffDatXe, "Đặt xe công", MenuKeys.Off, 5, "Car"),
(MenuKeys.OffItTicket, "Ticket CNTT", MenuKeys.Off, 6, "Ticket"),
(MenuKeys.OffChamCong, "Chấm công", MenuKeys.Off, 7, "Fingerprint"),
// Phase 10.4 G-H3 — Dashboard NS dưới root Hrm.
(MenuKeys.HrmDashboard, "Dashboard NS", MenuKeys.Hrm, 3, "BarChart3"),
};
// Per-type sub-menu under Contracts: 1 group + 3 leaves each

View File

@ -0,0 +1,329 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SolutionErp.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddWorkflowApps : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Attendances",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
UserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
UserFullName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
AttendanceDate = table.Column<DateTime>(type: "datetime2", nullable: false),
CheckInAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CheckOutAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CheckInLatitude = table.Column<decimal>(type: "decimal(10,7)", nullable: true),
CheckInLongitude = table.Column<decimal>(type: "decimal(10,7)", nullable: true),
CheckInAccuracy = table.Column<decimal>(type: "decimal(8,2)", nullable: true),
CheckOutLatitude = table.Column<decimal>(type: "decimal(10,7)", nullable: true),
CheckOutLongitude = table.Column<decimal>(type: "decimal(10,7)", nullable: true),
CheckOutAccuracy = table.Column<decimal>(type: "decimal(8,2)", nullable: true),
SourceIn = table.Column<int>(type: "int", nullable: false),
SourceOut = table.Column<int>(type: "int", nullable: false),
IpAddressIn = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: true),
IpAddressOut = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: true),
Note = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
WorkHours = table.Column<decimal>(type: "decimal(5,2)", nullable: true),
OtHours = table.Column<decimal>(type: "decimal(5,2)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Attendances", x => x.Id);
});
migrationBuilder.CreateTable(
name: "ItTickets",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
MaTicket = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: true),
RequesterUserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
RequesterFullName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
Title = table.Column<string>(type: "nvarchar(300)", maxLength: 300, nullable: false),
Description = table.Column<string>(type: "nvarchar(max)", maxLength: 5000, nullable: false),
Category = table.Column<int>(type: "int", nullable: false),
Priority = table.Column<int>(type: "int", nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
AssignedToUserId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
AssignedToFullName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
ResolvedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
Resolution = table.Column<string>(type: "nvarchar(max)", maxLength: 5000, nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ItTickets", x => x.Id);
});
migrationBuilder.CreateTable(
name: "LeaveRequests",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
MaDonTu = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: true),
RequesterUserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
RequesterFullName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
LeaveTypeId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
StartDate = table.Column<DateTime>(type: "datetime2", nullable: false),
EndDate = table.Column<DateTime>(type: "datetime2", nullable: false),
NumDays = table.Column<decimal>(type: "decimal(5,2)", nullable: false),
Reason = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
ApprovalWorkflowId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
CurrentApprovalLevelOrder = table.Column<int>(type: "int", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_LeaveRequests", x => x.Id);
});
migrationBuilder.CreateTable(
name: "OtRequests",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
MaDonTu = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: true),
RequesterUserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
RequesterFullName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
OtDate = table.Column<DateTime>(type: "datetime2", nullable: false),
StartTime = table.Column<TimeSpan>(type: "time", nullable: false),
EndTime = table.Column<TimeSpan>(type: "time", nullable: false),
Hours = table.Column<decimal>(type: "decimal(5,2)", nullable: false),
Reason = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: false),
OtPolicyId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
Status = table.Column<int>(type: "int", nullable: false),
ApprovalWorkflowId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
CurrentApprovalLevelOrder = table.Column<int>(type: "int", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_OtRequests", x => x.Id);
});
migrationBuilder.CreateTable(
name: "TravelRequests",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
MaDonTu = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: true),
RequesterUserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
RequesterFullName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
Destination = table.Column<string>(type: "nvarchar(300)", maxLength: 300, nullable: false),
StartDate = table.Column<DateTime>(type: "datetime2", nullable: false),
EndDate = table.Column<DateTime>(type: "datetime2", nullable: false),
NumDays = table.Column<int>(type: "int", nullable: false),
Purpose = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: false),
EstimatedCost = table.Column<decimal>(type: "decimal(18,2)", nullable: true),
Status = table.Column<int>(type: "int", nullable: false),
ApprovalWorkflowId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
CurrentApprovalLevelOrder = table.Column<int>(type: "int", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_TravelRequests", x => x.Id);
});
migrationBuilder.CreateTable(
name: "VehicleBookings",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
MaDonTu = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: true),
RequesterUserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
RequesterFullName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
VehicleLicense = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
VehicleName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
StartAt = table.Column<DateTime>(type: "datetime2", nullable: false),
EndAt = table.Column<DateTime>(type: "datetime2", nullable: false),
Destination = table.Column<string>(type: "nvarchar(300)", maxLength: 300, nullable: false),
Purpose = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: false),
DriverName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
Status = table.Column<int>(type: "int", nullable: false),
ApprovalWorkflowId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
CurrentApprovalLevelOrder = table.Column<int>(type: "int", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_VehicleBookings", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_Attendances_UserId_AttendanceDate",
table: "Attendances",
columns: new[] { "UserId", "AttendanceDate" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ItTickets_AssignedToUserId",
table: "ItTickets",
column: "AssignedToUserId");
migrationBuilder.CreateIndex(
name: "IX_ItTickets_Category",
table: "ItTickets",
column: "Category");
migrationBuilder.CreateIndex(
name: "IX_ItTickets_MaTicket",
table: "ItTickets",
column: "MaTicket",
unique: true,
filter: "[MaTicket] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_ItTickets_RequesterUserId",
table: "ItTickets",
column: "RequesterUserId");
migrationBuilder.CreateIndex(
name: "IX_ItTickets_Status",
table: "ItTickets",
column: "Status");
migrationBuilder.CreateIndex(
name: "IX_LeaveRequests_MaDonTu",
table: "LeaveRequests",
column: "MaDonTu",
unique: true,
filter: "[MaDonTu] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_LeaveRequests_RequesterUserId",
table: "LeaveRequests",
column: "RequesterUserId");
migrationBuilder.CreateIndex(
name: "IX_LeaveRequests_Status",
table: "LeaveRequests",
column: "Status");
migrationBuilder.CreateIndex(
name: "IX_OtRequests_MaDonTu",
table: "OtRequests",
column: "MaDonTu",
unique: true,
filter: "[MaDonTu] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_OtRequests_RequesterUserId",
table: "OtRequests",
column: "RequesterUserId");
migrationBuilder.CreateIndex(
name: "IX_OtRequests_Status",
table: "OtRequests",
column: "Status");
migrationBuilder.CreateIndex(
name: "IX_TravelRequests_MaDonTu",
table: "TravelRequests",
column: "MaDonTu",
unique: true,
filter: "[MaDonTu] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_TravelRequests_RequesterUserId",
table: "TravelRequests",
column: "RequesterUserId");
migrationBuilder.CreateIndex(
name: "IX_TravelRequests_Status",
table: "TravelRequests",
column: "Status");
migrationBuilder.CreateIndex(
name: "IX_VehicleBookings_MaDonTu",
table: "VehicleBookings",
column: "MaDonTu",
unique: true,
filter: "[MaDonTu] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_VehicleBookings_RequesterUserId",
table: "VehicleBookings",
column: "RequesterUserId");
migrationBuilder.CreateIndex(
name: "IX_VehicleBookings_Status",
table: "VehicleBookings",
column: "Status");
migrationBuilder.CreateIndex(
name: "IX_VehicleBookings_VehicleLicense_StartAt",
table: "VehicleBookings",
columns: new[] { "VehicleLicense", "StartAt" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Attendances");
migrationBuilder.DropTable(
name: "ItTickets");
migrationBuilder.DropTable(
name: "LeaveRequests");
migrationBuilder.DropTable(
name: "OtRequests");
migrationBuilder.DropTable(
name: "TravelRequests");
migrationBuilder.DropTable(
name: "VehicleBookings");
}
}
}

View File

@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SolutionErp.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddAttendances : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

View File

@ -3448,6 +3448,267 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.ToTable("Notifications", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Office.Attendance", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("AttendanceDate")
.HasColumnType("datetime2");
b.Property<decimal?>("CheckInAccuracy")
.HasColumnType("decimal(8,2)");
b.Property<DateTime?>("CheckInAt")
.HasColumnType("datetime2");
b.Property<decimal?>("CheckInLatitude")
.HasColumnType("decimal(10,7)");
b.Property<decimal?>("CheckInLongitude")
.HasColumnType("decimal(10,7)");
b.Property<decimal?>("CheckOutAccuracy")
.HasColumnType("decimal(8,2)");
b.Property<DateTime?>("CheckOutAt")
.HasColumnType("datetime2");
b.Property<decimal?>("CheckOutLatitude")
.HasColumnType("decimal(10,7)");
b.Property<decimal?>("CheckOutLongitude")
.HasColumnType("decimal(10,7)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uniqueidentifier");
b.Property<string>("IpAddressIn")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("IpAddressOut")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<string>("Note")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<decimal?>("OtHours")
.HasColumnType("decimal(5,2)");
b.Property<int>("SourceIn")
.HasColumnType("int");
b.Property<int>("SourceOut")
.HasColumnType("int");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.Property<string>("UserFullName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<Guid>("UserId")
.HasColumnType("uniqueidentifier");
b.Property<decimal?>("WorkHours")
.HasColumnType("decimal(5,2)");
b.HasKey("Id");
b.HasIndex("UserId", "AttendanceDate")
.IsUnique();
b.ToTable("Attendances", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Office.ItTicket", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("AssignedToFullName")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<Guid?>("AssignedToUserId")
.HasColumnType("uniqueidentifier");
b.Property<int>("Category")
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uniqueidentifier");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(5000)
.HasColumnType("nvarchar(max)");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<string>("MaTicket")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<int>("Priority")
.HasColumnType("int");
b.Property<string>("RequesterFullName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<Guid>("RequesterUserId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Resolution")
.HasMaxLength(5000)
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("ResolvedAt")
.HasColumnType("datetime2");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("nvarchar(300)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("AssignedToUserId");
b.HasIndex("Category");
b.HasIndex("MaTicket")
.IsUnique()
.HasFilter("[MaTicket] IS NOT NULL");
b.HasIndex("RequesterUserId");
b.HasIndex("Status");
b.ToTable("ItTickets", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Office.LeaveRequest", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("ApprovalWorkflowId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<int?>("CurrentApprovalLevelOrder")
.HasColumnType("int");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("EndDate")
.HasColumnType("datetime2");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<Guid>("LeaveTypeId")
.HasColumnType("uniqueidentifier");
b.Property<string>("MaDonTu")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<decimal>("NumDays")
.HasColumnType("decimal(5,2)");
b.Property<string>("Reason")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<string>("RequesterFullName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<Guid>("RequesterUserId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("StartDate")
.HasColumnType("datetime2");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("MaDonTu")
.IsUnique()
.HasFilter("[MaDonTu] IS NOT NULL");
b.HasIndex("RequesterUserId");
b.HasIndex("Status");
b.ToTable("LeaveRequests", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Office.MeetingBooking", b =>
{
b.Property<Guid>("Id")
@ -3623,6 +3884,87 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.ToTable("MeetingRooms", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Office.OtRequest", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("ApprovalWorkflowId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<int?>("CurrentApprovalLevelOrder")
.HasColumnType("int");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uniqueidentifier");
b.Property<TimeSpan>("EndTime")
.HasColumnType("time");
b.Property<decimal>("Hours")
.HasColumnType("decimal(5,2)");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<string>("MaDonTu")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<DateTime>("OtDate")
.HasColumnType("datetime2");
b.Property<Guid?>("OtPolicyId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Reason")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<string>("RequesterFullName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<Guid>("RequesterUserId")
.HasColumnType("uniqueidentifier");
b.Property<TimeSpan>("StartTime")
.HasColumnType("time");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("MaDonTu")
.IsUnique()
.HasFilter("[MaDonTu] IS NOT NULL");
b.HasIndex("RequesterUserId");
b.HasIndex("Status");
b.ToTable("OtRequests", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Office.Proposal", b =>
{
b.Property<Guid>("Id")
@ -3844,6 +4186,181 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.ToTable("ProposalLevelOpinions", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Office.TravelRequest", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("ApprovalWorkflowId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<int?>("CurrentApprovalLevelOrder")
.HasColumnType("int");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uniqueidentifier");
b.Property<string>("Destination")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("nvarchar(300)");
b.Property<DateTime>("EndDate")
.HasColumnType("datetime2");
b.Property<decimal?>("EstimatedCost")
.HasColumnType("decimal(18,2)");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<string>("MaDonTu")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<int>("NumDays")
.HasColumnType("int");
b.Property<string>("Purpose")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<string>("RequesterFullName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<Guid>("RequesterUserId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("StartDate")
.HasColumnType("datetime2");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("MaDonTu")
.IsUnique()
.HasFilter("[MaDonTu] IS NOT NULL");
b.HasIndex("RequesterUserId");
b.HasIndex("Status");
b.ToTable("TravelRequests", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Office.VehicleBooking", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("ApprovalWorkflowId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<int?>("CurrentApprovalLevelOrder")
.HasColumnType("int");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uniqueidentifier");
b.Property<string>("Destination")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("nvarchar(300)");
b.Property<string>("DriverName")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<DateTime>("EndAt")
.HasColumnType("datetime2");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<string>("MaDonTu")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("Purpose")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<string>("RequesterFullName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<Guid>("RequesterUserId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("StartAt")
.HasColumnType("datetime2");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.Property<string>("VehicleLicense")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<string>("VehicleName")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.HasKey("Id");
b.HasIndex("MaDonTu")
.IsUnique()
.HasFilter("[MaDonTu] IS NOT NULL");
b.HasIndex("RequesterUserId");
b.HasIndex("Status");
b.HasIndex("VehicleLicense", "StartAt");
b.ToTable("VehicleBookings", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", b =>
{
b.Property<Guid>("Id")