[CLAUDE] Domain+App+Infra+Api+FE-Admin+FE-User: S36 Plan G-O2 Phòng họp Mig 36 + BE CRUD + FE 2 app
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m55s

Phase 10.2 G-O2 Phòng họp BookingCalendar — Mig 36 schema + BE CQRS + FE 2 app
mirror cookie-cutter G-H2 HrmConfig pattern. Standalone không depend workflow.

BE schema (Mig 36 — em main solo Step 4):
- 4 Domain new: MeetingRoom catalog + MeetingBooking header + MeetingBookingAttendee
  join table N-to-N (NOT JSON per Investigator verdict) + Enums (MeetingBookingStatus
  3-state: Confirmed/Cancelled/Completed)
- 3 EF Config: UNIQUE Code + composite index (RoomId, StartAt) range query + UNIQUE
  composite (BookingId, UserId) join
- FK strategy: Room→Restrict (preserve history) + Booking→Cascade attendees +
  User→Restrict (denorm FullName+Email tránh cascade wipe)
- Mig 36 3-file rule + ApplicationDbContextModelSnapshot updated + apply Dev+Design DB

BE CQRS (~584 LOC — Implementer Case 2):
- MeetingFeatures.cs 479 LOC 9 handler: 4 Room CRUD + 5 Booking (List + GetById +
  Create + Update + Cancel)
- SERIALIZABLE transaction overlap check via EXISTS query — throw 409 Conflict
  "Phòng đã được đặt trong khoảng thời gian này"
- MeetingRoomsController 49 LOC + MeetingBookingsController 56 LOC — class-level
  [Authorize] + Roles="Admin" for write
- Application.csproj +Microsoft.EntityFrameworkCore.Relational package (em main fix
  IsolationLevel overload — Implementer gotcha #53 4th truncation diagnose mid-task)
- MenuKeys.cs +4 const (Off_PhongHop sub-group + View/Manage/Book leaf)
- DbInitializer +SeedMeetingRoomsAsync 4 sample (PH-A Phòng họp lớn cap=20 + PH-B
  cap=8 + PHG-501 Giám đốc cap=6 + ONL-1 Online Zoom cap=50) — NOT gated DemoSeed
  per gotcha #51 INFRASTRUCTURE seed

FE 2 app (~1770 LOC × 2 — Implementer Case 2):
- types/meeting.ts × 2 SHA256 IDENTICAL (ce0ad9c6d017cde2) — DTO interface mirror
- MeetingCalendarPage.tsx × 2 SHA256 IDENTICAL (d6d160ae1e4f2285) ~530 LOC — custom
  HTML 7-day grid 8h-20h slot, NO FullCalendar dep (~80 KB bundle saved per
  Investigator verdict alternative)
- MeetingRoomsPage.tsx × 2 SHA256 IDENTICAL (ba35a7ef379a5e9c) ~270 LOC — admin
  catalog CRUD table + Dialog
- 4-place mirror Pattern 16-bis 7× cumulative: types + page + App.tsx route +
  menuKeys + Layout staticMap 3 entry (gotcha #50 silent sidebar drop prevention)

Verify:
- dotnet build SolutionErp.slnx PASS 0 error 2 pre-existing DocxRenderer warning
- dotnet test 130/130 PASS baseline preserve (58 Domain + 72 Infra)
- npm build × 2 app PASS 0 TS error (fe-admin 16.91s bundle 1490 KB / fe-user 8.56s
  bundle 1404 KB, +23 KB gzip both)

Pattern reinforced cumulative S36:
- Pattern 12-bis cross-module mirror 10× (PE → Contract V2 → Hrm → Office)
- Pattern 16-bis 4-place mirror cross-app 7×
- Smart Friend Implementer truncation gotcha #53 4th — mitigation tight brief WORK
  (FE 2 app no truncation, BE truncate diagnose mid only)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-05-28 15:06:12 +07:00
parent 8afdc1e826
commit f45090b654
30 changed files with 8573 additions and 0 deletions

View File

@ -0,0 +1,56 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SolutionErp.Application.Office;
namespace SolutionErp.Api.Controllers;
// Phase 10.2 G-O2 (S36) — Phòng họp Booking API.
// Class-level [Authorize]: mọi user đăng nhập được book (multi-tenant office use case).
// Authorization owner-or-admin enforced TRONG handler (UpdateMeetingBookingHandler +
// CancelMeetingBookingHandler) — controller không restrict role.
// Pattern 12-bis cross-module mirror HrmConfigsController.cs (S35 cumulative 10×).
[ApiController]
[Route("api/meeting-bookings")]
[Authorize]
public class MeetingBookingsController(IMediator mediator) : ControllerBase
{
[HttpGet]
public async Task<List<MeetingBookingDto>> List(
[FromQuery] Guid? roomId,
[FromQuery] DateTime? startDate,
[FromQuery] DateTime? endDate,
[FromQuery] bool myOnly,
CancellationToken ct)
=> await mediator.Send(new GetMeetingBookingsQuery(roomId, startDate, endDate, myOnly), ct);
[HttpGet("{id:guid}")]
public async Task<ActionResult<MeetingBookingDto>> GetById(Guid id, CancellationToken ct)
{
var dto = await mediator.Send(new GetMeetingBookingByIdQuery(id), ct);
return dto is null ? NotFound() : Ok(dto);
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateMeetingBookingCommand body, CancellationToken ct)
{
var id = await mediator.Send(body, ct);
return CreatedAtAction(nameof(GetById), new { id }, new { id });
}
[HttpPut("{id:guid}")]
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateMeetingBookingCommand body, CancellationToken ct)
{
if (id != body.Id) return BadRequest("Id mismatch");
await mediator.Send(body, ct);
return NoContent();
}
[HttpDelete("{id:guid}")]
public async Task<IActionResult> Cancel(Guid id, CancellationToken ct)
{
await mediator.Send(new CancelMeetingBookingCommand(id), ct);
return NoContent();
}
}

View File

@ -0,0 +1,49 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SolutionErp.Application.Office;
namespace SolutionErp.Api.Controllers;
// Phase 10.2 G-O2 (S36) — Phòng họp catalog API.
// Class-level [Authorize]: mọi user đăng nhập được List (FE Booking page cần load room dropdown).
// Per-action [Authorize(Roles = "Admin")] cho Create/Update/Delete: chỉ admin quản lý catalog.
// Pattern 12-bis cross-module mirror HrmConfigsController.cs (S35 cumulative 10×).
[ApiController]
[Route("api/meeting-rooms")]
[Authorize]
public class MeetingRoomsController(IMediator mediator) : ControllerBase
{
[HttpGet]
public async Task<List<MeetingRoomDto>> List(
[FromQuery] string? search,
[FromQuery] bool? isActiveOnly,
CancellationToken ct)
=> await mediator.Send(new GetMeetingRoomsQuery(search, isActiveOnly), ct);
[HttpPost]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> Create([FromBody] CreateMeetingRoomCommand body, CancellationToken ct)
{
var id = await mediator.Send(body, ct);
return CreatedAtAction(nameof(List), new { id }, new { id });
}
[HttpPut("{id:guid}")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateMeetingRoomCommand body, CancellationToken ct)
{
if (id != body.Id) return BadRequest("Id mismatch");
await mediator.Send(body, ct);
return NoContent();
}
[HttpDelete("{id:guid}")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
await mediator.Send(new DeleteMeetingRoomCommand(id), ct);
return NoContent();
}
}

View File

@ -9,6 +9,7 @@ using SolutionErp.Domain.Identity;
using SolutionErp.Domain.Master;
using SolutionErp.Domain.Master.Catalogs;
using SolutionErp.Domain.Notifications;
using SolutionErp.Domain.Office;
using SolutionErp.Domain.PurchaseEvaluations;
namespace SolutionErp.Application.Common.Interfaces;
@ -103,5 +104,12 @@ public interface IApplicationDbContext
DbSet<ShiftPattern> ShiftPatterns { get; }
DbSet<OtPolicy> OtPolicies { get; }
// Phase 10.2 G-O2 (Mig 36 — S36) — Phòng họp + Booking + Attendee join.
// Overlap check qua SERIALIZABLE tx + EXISTS handler (clean-room SOL,
// mirror NamGroup TblBookingResource S50 pattern). FullCalendar v6 MIT FE.
DbSet<MeetingRoom> MeetingRooms { get; }
DbSet<MeetingBooking> MeetingBookings { get; }
DbSet<MeetingBookingAttendee> MeetingBookingAttendees { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}

View File

@ -0,0 +1,479 @@
using FluentValidation;
using MediatR;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Domain.Office;
namespace SolutionErp.Application.Office;
// Phase 10.2 G-O2 (Mig 36 — S36 2026-05-28) — Phòng họp + Booking CQRS.
// Pattern 12-bis cross-module mirror Hrm/HrmConfigFeatures.cs (S35 cumulative 10×).
// Pattern 16-bis foundation 4-place modification: entity (done) + config (done)
// + controller (separate file) + menu/DbInit (DbInitializer SeedMenuTreeAsync extend).
//
// Endpoints expose qua MeetingRoomsController + MeetingBookingsController:
// GET /api/meeting-rooms — list (filter search + active)
// POST /api/meeting-rooms — create [Admin]
// PUT /api/meeting-rooms/{id} — update [Admin]
// DELETE /api/meeting-rooms/{id} — soft disable IsActive=false [Admin]
// (NOT IsDeleted vì FK Restrict Booking)
// GET /api/meeting-bookings — list (room + date range + my filter)
// GET /api/meeting-bookings/{id} — detail Include Attendees
// POST /api/meeting-bookings — create (SERIALIZABLE tx overlap check)
// PUT /api/meeting-bookings/{id} — update (SERIALIZABLE tx exclude self)
// DELETE /api/meeting-bookings/{id} — cancel Status=Cancelled (NOT IsDeleted — preserve history)
//
// Investigator verdict S36 pre-flight chốt:
// - Overlap check qua System.Data.IsolationLevel.Serializable tx + EXISTS AnyAsync
// - Attendees join table N-to-N (MeetingBookingAttendee), NOT JSON array
// - 3-state Status enum (Confirmed=1 / Cancelled=2 / Completed=3)
// ===== DTOs =====
public record MeetingRoomDto(
Guid Id,
string Code,
string Name,
int Capacity,
string? Location,
string? Equipment,
bool IsActive,
DateTime CreatedAt);
public record MeetingBookingAttendeeDto(
Guid UserId,
string FullName,
string? Email,
string? Notes);
public record MeetingBookingDto(
Guid Id,
Guid RoomId,
string RoomCode,
string RoomName,
Guid BookedByUserId,
string BookedByFullName,
DateTime StartAt,
DateTime EndAt,
string Title,
string? Description,
int Status,
string? Note,
DateTime CreatedAt,
List<MeetingBookingAttendeeDto> Attendees);
public record AttendeeInput(Guid UserId, string? Notes);
// ===== Region 1: MeetingRoom CRUD =====
public record GetMeetingRoomsQuery(string? Search = null, bool? IsActiveOnly = null) : IRequest<List<MeetingRoomDto>>;
public class GetMeetingRoomsHandler(IApplicationDbContext db) : IRequestHandler<GetMeetingRoomsQuery, List<MeetingRoomDto>>
{
public async Task<List<MeetingRoomDto>> Handle(GetMeetingRoomsQuery req, CancellationToken ct)
{
var q = db.MeetingRooms.AsNoTracking().Where(x => !x.IsDeleted);
if (req.IsActiveOnly == true) q = q.Where(x => x.IsActive);
if (!string.IsNullOrWhiteSpace(req.Search))
{
var s = req.Search.ToLower();
q = q.Where(x =>
x.Code.ToLower().Contains(s) ||
x.Name.ToLower().Contains(s) ||
(x.Location != null && x.Location.ToLower().Contains(s)));
}
return await q.OrderBy(x => x.Code)
.Select(x => new MeetingRoomDto(
x.Id, x.Code, x.Name, x.Capacity, x.Location, x.Equipment, x.IsActive, x.CreatedAt))
.ToListAsync(ct);
}
}
public record CreateMeetingRoomCommand(
string Code,
string Name,
int Capacity,
string? Location,
string? Equipment) : IRequest<Guid>;
public class CreateMeetingRoomValidator : AbstractValidator<CreateMeetingRoomCommand>
{
public CreateMeetingRoomValidator()
{
RuleFor(x => x.Code).NotEmpty().MaximumLength(20);
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
RuleFor(x => x.Capacity).GreaterThanOrEqualTo(0);
RuleFor(x => x.Location).MaximumLength(200);
RuleFor(x => x.Equipment).MaximumLength(500);
}
}
public class CreateMeetingRoomHandler(IApplicationDbContext db) : IRequestHandler<CreateMeetingRoomCommand, Guid>
{
public async Task<Guid> Handle(CreateMeetingRoomCommand req, CancellationToken ct)
{
if (await db.MeetingRooms.AnyAsync(x => x.Code == req.Code && !x.IsDeleted, ct))
throw new ConflictException($"Mã phòng họp '{req.Code}' đã tồn tại.");
var entity = new MeetingRoom
{
Code = req.Code,
Name = req.Name,
Capacity = req.Capacity,
Location = req.Location,
Equipment = req.Equipment,
IsActive = true,
};
db.MeetingRooms.Add(entity);
await db.SaveChangesAsync(ct);
return entity.Id;
}
}
public record UpdateMeetingRoomCommand(
Guid Id,
string Code,
string Name,
int Capacity,
string? Location,
string? Equipment,
bool IsActive) : IRequest;
public class UpdateMeetingRoomValidator : AbstractValidator<UpdateMeetingRoomCommand>
{
public UpdateMeetingRoomValidator()
{
RuleFor(x => x.Code).NotEmpty().MaximumLength(20);
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
RuleFor(x => x.Capacity).GreaterThanOrEqualTo(0);
RuleFor(x => x.Location).MaximumLength(200);
RuleFor(x => x.Equipment).MaximumLength(500);
}
}
public class UpdateMeetingRoomHandler(IApplicationDbContext db) : IRequestHandler<UpdateMeetingRoomCommand>
{
public async Task Handle(UpdateMeetingRoomCommand req, CancellationToken ct)
{
var entity = await db.MeetingRooms.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct)
?? throw new NotFoundException("MeetingRoom", req.Id);
if (entity.Code != req.Code
&& await db.MeetingRooms.AnyAsync(x => x.Code == req.Code && x.Id != req.Id && !x.IsDeleted, ct))
throw new ConflictException($"Mã phòng họp '{req.Code}' đã tồn tại.");
entity.Code = req.Code;
entity.Name = req.Name;
entity.Capacity = req.Capacity;
entity.Location = req.Location;
entity.Equipment = req.Equipment;
entity.IsActive = req.IsActive;
await db.SaveChangesAsync(ct);
}
}
public record DeleteMeetingRoomCommand(Guid Id) : IRequest;
public class DeleteMeetingRoomHandler(IApplicationDbContext db) : IRequestHandler<DeleteMeetingRoomCommand>
{
public async Task Handle(DeleteMeetingRoomCommand req, CancellationToken ct)
{
var entity = await db.MeetingRooms.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct)
?? throw new NotFoundException("MeetingRoom", req.Id);
// FK Restrict Booking → NOT soft delete (IsDeleted=true), chỉ set IsActive=false.
// Preserve booking history + future query filter chỉ load room đang active.
// Admin có thể toggle IsActive=true lại nếu muốn re-enable.
entity.IsActive = false;
await db.SaveChangesAsync(ct);
}
}
// ===== Region 2: MeetingBooking CRUD =====
// Overlap check pattern SERIALIZABLE tx + EXISTS:
// - BeginTransactionAsync(IsolationLevel.Serializable, ct)
// - AnyAsync(b.RoomId == cmd.RoomId && b.Status == Confirmed && !b.IsDeleted
// && b.StartAt < cmd.EndAt && b.EndAt > cmd.StartAt)
// - Update: exclude self (b.Id != cmd.Id)
// SERIALIZABLE đảm bảo 2 concurrent insert overlap không cùng commit (range lock).
public record GetMeetingBookingsQuery(
Guid? RoomId = null,
DateTime? StartDate = null,
DateTime? EndDate = null,
bool MyOnly = false) : IRequest<List<MeetingBookingDto>>;
public class GetMeetingBookingsHandler(IApplicationDbContext db, ICurrentUser currentUser)
: IRequestHandler<GetMeetingBookingsQuery, List<MeetingBookingDto>>
{
public async Task<List<MeetingBookingDto>> Handle(GetMeetingBookingsQuery req, CancellationToken ct)
{
var q = db.MeetingBookings.AsNoTracking()
.Include(x => x.Room)
.Include(x => x.Attendees)
.Where(x => !x.IsDeleted);
if (req.RoomId.HasValue) q = q.Where(x => x.RoomId == req.RoomId.Value);
if (req.StartDate.HasValue) q = q.Where(x => x.EndAt >= req.StartDate.Value);
if (req.EndDate.HasValue) q = q.Where(x => x.StartAt <= req.EndDate.Value);
if (req.MyOnly && currentUser.UserId is Guid uid)
{
// "My bookings" = đã đặt OR được mời (Attendee).
q = q.Where(x => x.BookedByUserId == uid || x.Attendees.Any(a => a.UserId == uid));
}
return await q.OrderBy(x => x.StartAt)
.Select(x => new MeetingBookingDto(
x.Id,
x.RoomId,
x.Room.Code,
x.Room.Name,
x.BookedByUserId,
x.BookedByFullName,
x.StartAt,
x.EndAt,
x.Title,
x.Description,
(int)x.Status,
x.Note,
x.CreatedAt,
x.Attendees
.Select(a => new MeetingBookingAttendeeDto(a.UserId, a.FullName, a.Email, a.Notes))
.ToList()))
.ToListAsync(ct);
}
}
public record GetMeetingBookingByIdQuery(Guid Id) : IRequest<MeetingBookingDto?>;
public class GetMeetingBookingByIdHandler(IApplicationDbContext db)
: IRequestHandler<GetMeetingBookingByIdQuery, MeetingBookingDto?>
{
public async Task<MeetingBookingDto?> Handle(GetMeetingBookingByIdQuery req, CancellationToken ct)
{
return await db.MeetingBookings.AsNoTracking()
.Include(x => x.Room)
.Include(x => x.Attendees)
.Where(x => x.Id == req.Id && !x.IsDeleted)
.Select(x => new MeetingBookingDto(
x.Id,
x.RoomId,
x.Room.Code,
x.Room.Name,
x.BookedByUserId,
x.BookedByFullName,
x.StartAt,
x.EndAt,
x.Title,
x.Description,
(int)x.Status,
x.Note,
x.CreatedAt,
x.Attendees
.Select(a => new MeetingBookingAttendeeDto(a.UserId, a.FullName, a.Email, a.Notes))
.ToList()))
.FirstOrDefaultAsync(ct);
}
}
public record CreateMeetingBookingCommand(
Guid RoomId,
DateTime StartAt,
DateTime EndAt,
string Title,
string? Description,
string? Note,
List<AttendeeInput> Attendees) : IRequest<Guid>;
public class CreateMeetingBookingValidator : AbstractValidator<CreateMeetingBookingCommand>
{
public CreateMeetingBookingValidator()
{
RuleFor(x => x.RoomId).NotEmpty();
RuleFor(x => x.Title).NotEmpty().MaximumLength(200);
RuleFor(x => x.Description).MaximumLength(1000);
RuleFor(x => x.Note).MaximumLength(500);
RuleFor(x => x.Attendees).NotEmpty().WithMessage("Cần ít nhất 1 người tham gia.");
RuleFor(x => x).Must(c => c.EndAt > c.StartAt)
.WithMessage("Giờ kết thúc phải sau giờ bắt đầu.");
}
}
public class CreateMeetingBookingHandler(IApplicationDbContext db, ICurrentUser currentUser)
: IRequestHandler<CreateMeetingBookingCommand, Guid>
{
public async Task<Guid> Handle(CreateMeetingBookingCommand req, CancellationToken ct)
{
if (currentUser.UserId is not Guid userId)
throw new UnauthorizedException();
// Verify Room exists + IsActive (NotFoundException nếu fail)
var roomActive = await db.MeetingRooms
.AnyAsync(r => r.Id == req.RoomId && r.IsActive && !r.IsDeleted, ct);
if (!roomActive)
throw new NotFoundException("MeetingRoom", req.RoomId);
// SERIALIZABLE transaction — range lock prevent 2 concurrent insert overlap commit.
// EXISTS query overlap detect: tồn tại booking Confirmed cùng Room khoảng giao thời.
await using var tx = await ((Microsoft.EntityFrameworkCore.DbContext)db)
.Database.BeginTransactionAsync(System.Data.IsolationLevel.Serializable, ct);
var overlap = await db.MeetingBookings.AnyAsync(b =>
b.RoomId == req.RoomId &&
b.Status == MeetingBookingStatus.Confirmed &&
!b.IsDeleted &&
b.StartAt < req.EndAt && b.EndAt > req.StartAt,
ct);
if (overlap)
throw new ConflictException("Phòng đã được đặt trong khoảng thời gian này.");
// Resolve attendee FullName + Email từ Users JOIN (denorm).
var attendeeIds = req.Attendees.Select(a => a.UserId).Distinct().ToList();
var attendeeUsers = await db.Users.AsNoTracking()
.Where(u => attendeeIds.Contains(u.Id))
.Select(u => new { u.Id, u.FullName, u.Email })
.ToListAsync(ct);
var attendeeLookup = attendeeUsers.ToDictionary(u => u.Id);
var entity = new MeetingBooking
{
RoomId = req.RoomId,
BookedByUserId = userId,
BookedByFullName = currentUser.FullName ?? string.Empty,
StartAt = req.StartAt,
EndAt = req.EndAt,
Title = req.Title,
Description = req.Description,
Note = req.Note,
Status = MeetingBookingStatus.Confirmed,
Attendees = req.Attendees
.Where(a => attendeeLookup.ContainsKey(a.UserId)) // skip user không tồn tại
.Select(a => new MeetingBookingAttendee
{
UserId = a.UserId,
FullName = attendeeLookup[a.UserId].FullName,
Email = attendeeLookup[a.UserId].Email,
Notes = a.Notes,
})
.ToList(),
};
db.MeetingBookings.Add(entity);
await db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
return entity.Id;
}
}
public record UpdateMeetingBookingCommand(
Guid Id,
Guid RoomId,
DateTime StartAt,
DateTime EndAt,
string Title,
string? Description,
string? Note,
List<AttendeeInput> Attendees) : IRequest;
public class UpdateMeetingBookingValidator : AbstractValidator<UpdateMeetingBookingCommand>
{
public UpdateMeetingBookingValidator()
{
RuleFor(x => x.RoomId).NotEmpty();
RuleFor(x => x.Title).NotEmpty().MaximumLength(200);
RuleFor(x => x.Description).MaximumLength(1000);
RuleFor(x => x.Note).MaximumLength(500);
RuleFor(x => x.Attendees).NotEmpty().WithMessage("Cần ít nhất 1 người tham gia.");
RuleFor(x => x).Must(c => c.EndAt > c.StartAt)
.WithMessage("Giờ kết thúc phải sau giờ bắt đầu.");
}
}
public class UpdateMeetingBookingHandler(IApplicationDbContext db, ICurrentUser currentUser)
: IRequestHandler<UpdateMeetingBookingCommand>
{
public async Task Handle(UpdateMeetingBookingCommand req, CancellationToken ct)
{
if (currentUser.UserId is not Guid userId)
throw new UnauthorizedException();
var entity = await db.MeetingBookings
.Include(x => x.Attendees)
.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct)
?? throw new NotFoundException("MeetingBooking", req.Id);
// Authorization: owner OR Admin role
var isOwner = entity.BookedByUserId == userId;
var isAdmin = currentUser.Roles.Contains("Admin");
if (!isOwner && !isAdmin)
throw new ForbiddenException("Chỉ người đặt phòng hoặc Admin được phép sửa booking.");
// Verify Room exists + IsActive
var roomActive = await db.MeetingRooms
.AnyAsync(r => r.Id == req.RoomId && r.IsActive && !r.IsDeleted, ct);
if (!roomActive)
throw new NotFoundException("MeetingRoom", req.RoomId);
// SERIALIZABLE transaction — overlap check exclude SELF (b.Id != req.Id).
await using var tx = await ((Microsoft.EntityFrameworkCore.DbContext)db)
.Database.BeginTransactionAsync(System.Data.IsolationLevel.Serializable, ct);
var overlap = await db.MeetingBookings.AnyAsync(b =>
b.Id != req.Id &&
b.RoomId == req.RoomId &&
b.Status == MeetingBookingStatus.Confirmed &&
!b.IsDeleted &&
b.StartAt < req.EndAt && b.EndAt > req.StartAt,
ct);
if (overlap)
throw new ConflictException("Phòng đã được đặt trong khoảng thời gian này.");
// Update header fields
entity.RoomId = req.RoomId;
entity.StartAt = req.StartAt;
entity.EndAt = req.EndAt;
entity.Title = req.Title;
entity.Description = req.Description;
entity.Note = req.Note;
// Status preserve (Cancel via separate command).
// Recreate Attendees: delete existing + re-add (idempotent simpler than diff).
var attendeeIds = req.Attendees.Select(a => a.UserId).Distinct().ToList();
var attendeeUsers = await db.Users.AsNoTracking()
.Where(u => attendeeIds.Contains(u.Id))
.Select(u => new { u.Id, u.FullName, u.Email })
.ToListAsync(ct);
var attendeeLookup = attendeeUsers.ToDictionary(u => u.Id);
// Remove old (Cascade FK on Booking.Attendees handles cleanup via collection)
db.MeetingBookingAttendees.RemoveRange(entity.Attendees);
entity.Attendees = req.Attendees
.Where(a => attendeeLookup.ContainsKey(a.UserId))
.Select(a => new MeetingBookingAttendee
{
BookingId = entity.Id,
UserId = a.UserId,
FullName = attendeeLookup[a.UserId].FullName,
Email = attendeeLookup[a.UserId].Email,
Notes = a.Notes,
})
.ToList();
await db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
}
}
public record CancelMeetingBookingCommand(Guid Id) : IRequest;
public class CancelMeetingBookingHandler(IApplicationDbContext db, ICurrentUser currentUser)
: IRequestHandler<CancelMeetingBookingCommand>
{
public async Task Handle(CancelMeetingBookingCommand req, CancellationToken ct)
{
if (currentUser.UserId is not Guid userId)
throw new UnauthorizedException();
var entity = await db.MeetingBookings
.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct)
?? throw new NotFoundException("MeetingBooking", req.Id);
var isOwner = entity.BookedByUserId == userId;
var isAdmin = currentUser.Roles.Contains("Admin");
if (!isOwner && !isAdmin)
throw new ForbiddenException("Chỉ người đặt phòng hoặc Admin được phép huỷ booking.");
// Status=Cancelled (NOT IsDeleted=true) — preserve history + audit trail.
entity.Status = MeetingBookingStatus.Cancelled;
await db.SaveChangesAsync(ct);
}
}

View File

@ -9,6 +9,8 @@
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
<PackageReference Include="MediatR" Version="12.4.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.6" />
<!-- Relational cần cho IsolationLevel overload BeginTransactionAsync (S36 G-O2 SERIALIZABLE overlap) -->
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.6" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.6" />
</ItemGroup>

View File

@ -99,6 +99,11 @@ public static class MenuKeys
// ============================================================
public const string Off = "Off"; // root group văn phòng số
public const string OffDanhBa = "Off_DanhBa"; // Danh bạ nội bộ (card grid)
// Phase 10.2 G-O2 (Mig 36 — S36 2026-05-28) — Phòng họp + Booking calendar.
public const string OffPhongHop = "Off_PhongHop"; // sub-group phòng họp
public const string OffPhongHopView = "Off_PhongHop_View"; // Xem lịch (FullCalendar)
public const string OffPhongHopManage = "Off_PhongHop_Manage"; // Quản lý phòng (Admin CRUD MeetingRoom)
public const string OffPhongHopBook = "Off_PhongHop_Book"; // Đặt phòng (Create/Update/Cancel Booking)
public static readonly string[] PurchaseEvaluationTypeCodes =
["DuyetNcc", "DuyetNccPhuongAn"];
@ -127,6 +132,7 @@ public static class MenuKeys
Hrm, HrmHoSo, // Mig 34 — Phase 10.1
HrmConfig, HrmConfigLeaveTypes, HrmConfigHolidays, HrmConfigShifts, HrmConfigOtPolicies, // Mig 35 — Phase 10.2 G-H2
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
System, Users, Roles, Permissions, MenuVisibility, Workflows, PeWorkflows,
ApprovalWorkflowsV2, ApprovalWorkflowDuyetNccV2, ApprovalWorkflowDuyetNccPhuongAnV2, // Mig 22
];

View File

@ -0,0 +1,12 @@
namespace SolutionErp.Domain.Office;
// Phase 10.2 G-O2 — Phòng họp BookingCalendar enum.
// Reference NamGroup TblBookingResource TrangThai 4-state (NamGroup S50)
// — SOL adapt 3-state, drop "Rejected" (use Cancelled instead).
public enum MeetingBookingStatus
{
Confirmed = 1, // Đã xác nhận (default sau Create)
Cancelled = 2, // Đã huỷ
Completed = 3, // Đã kết thúc (auto-set khi EndAt < Now via job/manual)
}

View File

@ -0,0 +1,29 @@
using SolutionErp.Domain.Common;
namespace SolutionErp.Domain.Office;
// Phase 10.2 G-O2 (Mig 36 — S36) — Booking phòng họp.
// Overlap check qua SERIALIZABLE tx + EXISTS query trong handler
// (NOT DB constraint vì datetime range not native SQL Server).
// Reference NamGroup TblBookingResource S50 — SOL adapt 3-state status.
public class MeetingBooking : AuditableEntity
{
public Guid RoomId { get; set; }
public MeetingRoom Room { get; set; } = null!; // FK Restrict — không xoá Room còn booking active
public Guid BookedByUserId { get; set; } // User đặt phòng (Creator semantics)
public string BookedByFullName { get; set; } = string.Empty; // denorm tránh cascade
public DateTime StartAt { get; set; } // UTC datetime2
public DateTime EndAt { get; set; } // UTC datetime2. EndAt > StartAt (validator)
public string Title { get; set; } = string.Empty; // "Họp giao ban tuần", "Phỏng vấn ứng viên"
public string? Description { get; set; } // Nội dung chi tiết / agenda
public MeetingBookingStatus Status { get; set; } = MeetingBookingStatus.Confirmed;
public string? Note { get; set; } // Ghi chú thêm (vd "Cần chuẩn bị nước uống")
// Attendees nav — collection join entity (NOT JSON, theo Investigator verdict S36)
public List<MeetingBookingAttendee> Attendees { get; set; } = new();
}

View File

@ -0,0 +1,21 @@
using SolutionErp.Domain.Common;
namespace SolutionErp.Domain.Office;
// Phase 10.2 G-O2 (Mig 36 — S36) — Attendee join table N-to-N.
// Composite PK (BookingId, UserId) — không support add user 2 lần cùng booking.
// FK Cascade Booking (xoá Booking → wipe Attendees) + Restrict User
// (admin không xoá user còn invite — denorm name preserve).
// Reference Investigator verdict: prefer join table over JSON array
// để index query Notification push + reuse Users API multi-select FE.
public class MeetingBookingAttendee : BaseEntity // NO IsDeleted — composite PK delete entire row
{
public Guid BookingId { get; set; }
public MeetingBooking Booking { get; set; } = null!;
public Guid UserId { get; set; }
public string FullName { get; set; } = string.Empty; // denorm tránh JOIN cho Notification push
public string? Email { get; set; } // denorm cho email outbox future
public string? Notes { get; set; } // "Vắng buổi sáng", "Tham gia online"
}

View File

@ -0,0 +1,23 @@
using SolutionErp.Domain.Common;
namespace SolutionErp.Domain.Office;
// Phase 10.2 G-O2 (Mig 36 — S36 2026-05-28) — Phòng họp catalog.
// Reference NamGroup TblResource 11 cols (S50 design) — SOL adapt clean-room
// 3 entity: MeetingRoom (catalog) + MeetingBooking (header) + MeetingBookingAttendee (join).
// AuditableEntity inherit chuẩn SOL Clean Arch.
public class MeetingRoom : AuditableEntity
{
public string Code { get; set; } = string.Empty; // UNIQUE — "PH-A", "PH-B", "PHG-501"
public string Name { get; set; } = string.Empty; // "Phòng họp tầng 5", "Phòng họp lớn"
// Sức chứa tối đa (số người). 0 = không giới hạn (mở rộng tự do).
public int Capacity { get; set; }
public string? Location { get; set; } // "Tầng 5, Toà A" / "Trực tuyến Zoom"
// Trang thiết bị có sẵn (free text): "Máy chiếu, Bảng trắng, Wifi" — info-only.
public string? Equipment { get; set; }
public bool IsActive { get; set; } = true; // Soft-disable không xoá (preserve booking history)
}

View File

@ -11,6 +11,7 @@ using SolutionErp.Domain.Identity;
using SolutionErp.Domain.Master;
using SolutionErp.Domain.Master.Catalogs;
using SolutionErp.Domain.Notifications;
using SolutionErp.Domain.Office;
using SolutionErp.Domain.PurchaseEvaluations;
namespace SolutionErp.Infrastructure.Persistence;
@ -96,6 +97,11 @@ public class ApplicationDbContext
public DbSet<ShiftPattern> ShiftPatterns => Set<ShiftPattern>();
public DbSet<OtPolicy> OtPolicies => Set<OtPolicy>();
// Phase 10.2 G-O2 (Mig 36 — S36) — Phòng họp + Booking + Attendee join.
public DbSet<MeetingRoom> MeetingRooms => Set<MeetingRoom>();
public DbSet<MeetingBooking> MeetingBookings => Set<MeetingBooking>();
public DbSet<MeetingBookingAttendee> MeetingBookingAttendees => Set<MeetingBookingAttendee>();
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);

View File

@ -0,0 +1,25 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using SolutionErp.Domain.Office;
namespace SolutionErp.Infrastructure.Persistence.Configurations;
// EF Mig 36 G-O2 (S36) — Attendee join table N-to-N.
// Composite UNIQUE (BookingId, UserId) — không add user 2 lần cùng booking.
// Inherit BaseEntity (Guid Id auto) + composite UNIQUE constraint via index.
public class MeetingBookingAttendeeConfiguration : IEntityTypeConfiguration<MeetingBookingAttendee>
{
public void Configure(EntityTypeBuilder<MeetingBookingAttendee> e)
{
e.ToTable("MeetingBookingAttendees");
e.Property(x => x.FullName).HasMaxLength(200).IsRequired();
e.Property(x => x.Email).HasMaxLength(200);
e.Property(x => x.Notes).HasMaxLength(200);
// UNIQUE composite — 1 user / 1 booking
e.HasIndex(x => new { x.BookingId, x.UserId }).IsUnique();
// UserId lookup "my invites"
e.HasIndex(x => x.UserId);
}
}

View File

@ -0,0 +1,37 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using SolutionErp.Domain.Office;
namespace SolutionErp.Infrastructure.Persistence.Configurations;
// EF Mig 36 G-O2 (S36) — Booking phòng họp.
// FK Restrict cho Room (preserve history) + Restrict User Drafter (denorm name).
// Index composite (RoomId, StartAt) cho range query overlap check + calendar fetch.
public class MeetingBookingConfiguration : IEntityTypeConfiguration<MeetingBooking>
{
public void Configure(EntityTypeBuilder<MeetingBooking> e)
{
e.ToTable("MeetingBookings");
e.Property(x => x.BookedByFullName).HasMaxLength(200).IsRequired();
e.Property(x => x.Title).HasMaxLength(200).IsRequired();
e.Property(x => x.Description).HasMaxLength(1000);
e.Property(x => x.Note).HasMaxLength(500);
e.Property(x => x.Status).HasConversion<int>();
e.HasOne(x => x.Room)
.WithMany()
.HasForeignKey(x => x.RoomId)
.OnDelete(DeleteBehavior.Restrict);
e.HasMany(x => x.Attendees)
.WithOne(a => a.Booking)
.HasForeignKey(a => a.BookingId)
.OnDelete(DeleteBehavior.Cascade);
// Range query + overlap check (RoomId + StartAt)
e.HasIndex(x => new { x.RoomId, x.StartAt });
// BookedByUserId lookup "my bookings"
e.HasIndex(x => x.BookedByUserId);
}
}

View File

@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using SolutionErp.Domain.Office;
namespace SolutionErp.Infrastructure.Persistence.Configurations;
// EF Mig 36 G-O2 (S36) — Phòng họp catalog. Standalone no FK.
// Mirror LeaveType pattern (no HasQueryFilter — handler explicit Where(!IsDeleted)).
public class MeetingRoomConfiguration : IEntityTypeConfiguration<MeetingRoom>
{
public void Configure(EntityTypeBuilder<MeetingRoom> e)
{
e.ToTable("MeetingRooms");
e.Property(x => x.Code).HasMaxLength(20).IsRequired();
e.Property(x => x.Name).HasMaxLength(200).IsRequired();
e.Property(x => x.Location).HasMaxLength(200);
e.Property(x => x.Equipment).HasMaxLength(500);
e.HasIndex(x => x.Code).IsUnique();
}
}

View File

@ -11,6 +11,7 @@ using SolutionErp.Domain.Hrm;
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.Master;
using SolutionErp.Domain.Master.Catalogs;
using SolutionErp.Domain.Office;
using SolutionErp.Domain.PurchaseEvaluations;
namespace SolutionErp.Infrastructure.Persistence;
@ -95,6 +96,9 @@ public static class DbInitializer
// Plan G-H2 (Mig 35 S34 2026-05-27) — Cấu hình HRM 4 catalog. Infrastructure
// data (NOT gated DemoSeed flag) — Workflow Apps reference cần data ngày 1.
await SeedHrmConfigsAsync(db, logger);
// Plan G-O2 (Mig 36 S36 2026-05-28) — Phòng họp 4 sample room. Infrastructure
// data (NOT gated DemoSeed flag, gotcha #51) — admin có thể edit/delete sau.
await SeedMeetingRoomsAsync(db, logger);
await SeedMenuTreeAsync(db, logger);
await SeedAdminPermissionsAsync(db, roleManager, logger);
await SeedDemoMasterDataAsync(db, logger);
@ -1498,6 +1502,11 @@ public static class DbInitializer
// Future leaf: Off_PhongHop (G-O2) + workflow apps Off_DeXuat/DonTu/DatXe/ItTicket.
(MenuKeys.Off, "Văn phòng số", null, 29, "Briefcase"),
(MenuKeys.OffDanhBa, "Danh bạ nội bộ", MenuKeys.Off, 1, "BookUser"),
// Phase 10.2 G-O2 (Mig 36 — S36 2026-05-28). Sub-group "Phòng họp" + 3 leaf.
(MenuKeys.OffPhongHop, "Phòng họp", MenuKeys.Off, 2, "CalendarRange"),
(MenuKeys.OffPhongHopView, "Xem lịch", MenuKeys.OffPhongHop, 1, "CalendarDays"),
(MenuKeys.OffPhongHopManage, "Quản lý phòng", MenuKeys.OffPhongHop, 2, "Building2"),
(MenuKeys.OffPhongHopBook, "Đặt phòng", MenuKeys.OffPhongHop, 3, "CalendarPlus"),
};
// Per-type sub-menu under Contracts: 1 group + 3 leaves each
@ -2156,4 +2165,49 @@ public static class DbInitializer
await db.SaveChangesAsync();
logger.LogInformation("SeedHrmConfigsAsync: seeded 5 LeaveTypes + 10 Holidays 2026 + 3 ShiftPatterns + 1 OtPolicy default.");
}
// Plan G-O2 (Mig 36 — S36 2026-05-28). 4 sample MeetingRoom seed.
// Infrastructure data (NOT gated DemoSeed flag, gotcha #51) — Booking calendar
// cần dropdown phòng ngày 1. Admin có thể edit/delete/disable qua FE Manage page.
// Idempotent: skip nếu đã có row.
private static async Task SeedMeetingRoomsAsync(ApplicationDbContext db, ILogger logger)
{
if (await db.MeetingRooms.AnyAsync())
{
logger.LogInformation("SeedMeetingRoomsAsync: skip — đã có MeetingRoom.");
return;
}
db.MeetingRooms.AddRange(
new MeetingRoom
{
Code = "PH-A", Name = "Phòng họp lớn",
Capacity = 20, Location = "Tầng 5, Toà A",
Equipment = "Máy chiếu, Bảng trắng, Wifi, Webcam 4K",
IsActive = true,
},
new MeetingRoom
{
Code = "PH-B", Name = "Phòng họp nhỏ",
Capacity = 8, Location = "Tầng 3, Toà A",
Equipment = "TV 55 inch, Bảng trắng",
IsActive = true,
},
new MeetingRoom
{
Code = "PHG-501", Name = "Phòng Giám đốc",
Capacity = 6, Location = "Tầng 5, Toà A",
Equipment = "TV 65 inch, Bàn họp dài, Tủ tài liệu",
IsActive = true,
},
new MeetingRoom
{
Code = "ONL-1", Name = "Trực tuyến Zoom",
Capacity = 50, Location = "Online",
Equipment = "License Zoom Pro, link cố định",
IsActive = true,
});
await db.SaveChangesAsync();
logger.LogInformation("SeedMeetingRoomsAsync: seeded 4 sample meeting rooms (PH-A/PH-B/PHG-501/ONL-1).");
}
}

View File

@ -0,0 +1,138 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SolutionErp.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddMeetingRooms : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "MeetingRooms",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Code = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
Capacity = table.Column<int>(type: "int", nullable: false),
Location = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
Equipment = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
IsActive = table.Column<bool>(type: "bit", nullable: false),
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_MeetingRooms", x => x.Id);
});
migrationBuilder.CreateTable(
name: "MeetingBookings",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
RoomId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
BookedByUserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
BookedByFullName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
StartAt = table.Column<DateTime>(type: "datetime2", nullable: false),
EndAt = table.Column<DateTime>(type: "datetime2", nullable: false),
Title = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
Description = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: true),
Status = table.Column<int>(type: "int", nullable: false),
Note = table.Column<string>(type: "nvarchar(500)", maxLength: 500, 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_MeetingBookings", x => x.Id);
table.ForeignKey(
name: "FK_MeetingBookings_MeetingRooms_RoomId",
column: x => x.RoomId,
principalTable: "MeetingRooms",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "MeetingBookingAttendees",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
BookingId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
UserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
FullName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
Email = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
Notes = table.Column<string>(type: "nvarchar(200)", maxLength: 200, 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)
},
constraints: table =>
{
table.PrimaryKey("PK_MeetingBookingAttendees", x => x.Id);
table.ForeignKey(
name: "FK_MeetingBookingAttendees_MeetingBookings_BookingId",
column: x => x.BookingId,
principalTable: "MeetingBookings",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_MeetingBookingAttendees_BookingId_UserId",
table: "MeetingBookingAttendees",
columns: new[] { "BookingId", "UserId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_MeetingBookingAttendees_UserId",
table: "MeetingBookingAttendees",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_MeetingBookings_BookedByUserId",
table: "MeetingBookings",
column: "BookedByUserId");
migrationBuilder.CreateIndex(
name: "IX_MeetingBookings_RoomId_StartAt",
table: "MeetingBookings",
columns: new[] { "RoomId", "StartAt" });
migrationBuilder.CreateIndex(
name: "IX_MeetingRooms_Code",
table: "MeetingRooms",
column: "Code",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "MeetingBookingAttendees");
migrationBuilder.DropTable(
name: "MeetingBookings");
migrationBuilder.DropTable(
name: "MeetingRooms");
}
}
}

View File

@ -3448,6 +3448,181 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.ToTable("Notifications", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Office.MeetingBooking", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("BookedByFullName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<Guid>("BookedByUserId")
.HasColumnType("uniqueidentifier");
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")
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<DateTime>("EndAt")
.HasColumnType("datetime2");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<string>("Note")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<Guid>("RoomId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("StartAt")
.HasColumnType("datetime2");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("BookedByUserId");
b.HasIndex("RoomId", "StartAt");
b.ToTable("MeetingBookings", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Office.MeetingBookingAttendee", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<Guid>("BookingId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<string>("Email")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("FullName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("Notes")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("UserId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("UserId");
b.HasIndex("BookingId", "UserId")
.IsUnique();
b.ToTable("MeetingBookingAttendees", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Office.MeetingRoom", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<int>("Capacity")
.HasColumnType("int");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
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>("Equipment")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<string>("Location")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("Code")
.IsUnique();
b.ToTable("MeetingRooms", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", b =>
{
b.Property<Guid>("Id")
@ -4690,6 +4865,28 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
.OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity("SolutionErp.Domain.Office.MeetingBooking", b =>
{
b.HasOne("SolutionErp.Domain.Office.MeetingRoom", "Room")
.WithMany()
.HasForeignKey("RoomId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Room");
});
modelBuilder.Entity("SolutionErp.Domain.Office.MeetingBookingAttendee", b =>
{
b.HasOne("SolutionErp.Domain.Office.MeetingBooking", "Booking")
.WithMany("Attendees")
.HasForeignKey("BookingId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Booking");
});
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", b =>
{
b.HasOne("SolutionErp.Domain.ApprovalWorkflowsV2.ApprovalWorkflow", null)
@ -4924,6 +5121,11 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Navigation("Permissions");
});
modelBuilder.Entity("SolutionErp.Domain.Office.MeetingBooking", b =>
{
b.Navigation("Attendees");
});
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", b =>
{
b.Navigation("Approvals");