Production
Bounded Context
Thiết kế DDD đầy đủ cho Production Module — Aggregate Root, Value Objects, Business Rules, và implementation C# .NET 9 theo pattern CQRS + Event Sourcing.
Production Bounded Context là gì
Production BC chịu trách nhiệm quản lý toàn bộ vòng đời của Lệnh Sản Xuất (Production Order) — từ khi được tạo ra đến khi hoàn thành hoặc bị hủy. Đây là aggregate phức tạp nhất và trung tâm nhất của hệ thống MES.
- Aggregate Root:
ProductionOrder - Kafka Topic:
production.events - Database: Event Store (write) +
production_ordersprojection (read) - Các module phụ thuộc: Inventory (lock NVL), Quality (trigger inspection), Finance (tính chi phí)
Tất cả thay đổi trong production order đi qua một điểm duy nhất — Aggregate Root — đảm bảo tính nhất quán của business rules và invariants. Không có code nào được phép thay đổi trạng thái production order mà không đi qua aggregate này.
State Machine — ProductionOrder
Aggregate properties
| Property | Type | Mô tả | Mutability |
|---|---|---|---|
Id | OrderId | Identity duy nhất | Immutable |
OrderNumber | OrderNumber | Số lệnh (PO-2026-001234) | Immutable |
Product | ProductSpec | Sản phẩm sản xuất | Immutable |
PlannedQty | Quantity | Số lượng kế hoạch | Immutable |
ActualQty | Quantity | Số lượng thực tế (tích lũy) | Mutable |
Status | ProductionStatus | Trạng thái hiện tại | Mutable |
MachineId | MachineId | Máy được giao | Immutable |
OperatorId | OperatorId? | Operator đang chạy | Mutable |
Shift | Shift | Ca sản xuất | Immutable |
TenantId | string | Multi-tenancy | Immutable |
Version | int | Optimistic locking version | Mutable |
Value Objects
Value Objects là immutable, không có identity, so sánh bằng value. Mỗi VO tự validate khi khởi tạo — không bao giờ tạo được VO ở trạng thái invalid.
OrderId.New() → gen UUIDPO-YYYY-NNNNNNQuantity.Zero(unit) allowedBusiness Rules — Invariants
Những rule này được enforce bởi Aggregate, không thể bị bypass. Nếu vi phạm → throw ProductionDomainException.
| Rule | Điều kiện | Lý do |
|---|---|---|
| R01 — Start từ đúng trạng thái | Status phải là Planned hoặc Released | Tránh restart lệnh đang chạy |
| R02 — Complete khi đang chạy | Status phải là InProgress | Logic nhất quán |
| R03 — Số lượng dương | PlannedQty.Value > 0 | Lệnh phải có số lượng |
| R04 — Không vượt quá kế hoạch | ActualQty ≤ PlannedQty × 1.1 | Buffer 10% cho biến động |
| R05 — Ghi sản lượng khi chạy | Status phải là InProgress | Không ghi khi đang pause |
| R06 — Hủy chỉ khi chưa hoàn thành | Status không phải Completed | Lệnh đã hoàn thành không được hủy |
| R07 — Lý do hủy bắt buộc | CancellationReason không blank khi hủy từ InProgress | Audit trail |
| R08 — OrderNumber unique per tenant | Enforce ở Application layer + DB unique constraint | Không trùng số lệnh |
| R09 — Công đoạn chạy tuần tự | Bước trước phải Completed mới mở bước sau (PREVIOUS_OPERATION_OPEN) | Bán thành phẩm của bước trước là đầu vào của bước sau |
| R10 — Không làm vượt số nhận vào | GoodQty + ScrapQty ≤ InputQty (OPERATION_QTY_EXCEEDED) | Không thể làm ra nhiều hơn số nhận từ bước trước |
| R11 — Lệnh có công đoạn thì cấm ghi sản lượng cấp lệnh | USE_OPERATION_OUTPUT | Hai đường ghi sản lượng song song sẽ cho hai con số khác nhau; ActualQty lấy ở bước CUỐI, không cộng dồn |
| R12 — Chấm công phải chỉ rõ công đoạn | Lệnh có Routing → sequence bắt buộc (OPERATION_REQUIRED) | Không có công đoạn thì không tính được hiệu suất từng khâu — mất phần lớn giá trị của việc chấm công |
| R13 — Đơn giá giờ chốt vào bản ghi | Lưu HourlyRate tại thời điểm chấm, không tham chiếu md_employees | Đơn giá đổi theo thời gian; tra lại sau sẽ ra số khác với lúc thực làm |
Công đoạn và chấm công trong lệnh sản xuất
Tuỳ chọn, không bắt buộc. Sản phẩm chưa khai Routing thì lệnh chạy y hệt như cũ — đây là ràng buộc thiết kế quan trọng nhất của phần này, vì thêm công đoạn mà làm hỏng lệnh của những sản phẩm đơn giản là đánh đổi sai.
Chụp quy trình khi Release
Hook IOrderRoutingPlanner chạy sau khi phát hành lệnh: lấy Routing đang
Active của sản phẩm và chụp lại thành công đoạn của lệnh — chụp chứ không tham chiếu
sống. Đổi quy trình chuẩn ngày mai không được làm thay đổi lệnh đang chạy dở hôm nay.
| Trường của công đoạn | Ý nghĩa |
|---|---|
Sequence · OperationCode · WorkCenterCode | Chụp từ bước Routing tương ứng |
InputQty | Bước đầu nhận toàn bộ số kế hoạch; các bước sau nhận đầu ra của bước trước |
GoodQty · ScrapQty | Số đạt và số hỏng ghi tại bước này |
SetupMinutes · MinutesPerUnit | Giờ chuẩn chụp từ Routing — nền tính giờ máy cho phân bổ chi phí chung |
Giờ máy lấy từ giờ chuẩn, không lấy từ mốc thời gian
EarnedMachineMinutes = SetupMinutes + MinutesPerUnit × GoodQty.
Không dùng hiệu số CompletedAt − StartedAt: mốc thời gian là thời gian trôi
qua, một công đoạn bắt đầu chiều thứ Sáu xong sáng thứ Hai sẽ thành 72 giờ máy. Công
thức này trùng với công thức CRP và xếp lịch hữu hạn dùng — một định nghĩa giờ máy duy nhất
trong cả hệ thống.
Chấm công
BookLabor(sequence, employeeCode, minutes, hourlyRate) ghi thời gian làm
thực tế vào từng công đoạn. Chi phí nhân công tính ngay lúc chấm
(minutes / 60 × hourlyRate) và chốt vào bản ghi.
public void BookLabor(int? sequence, string employeeCode, decimal minutes, decimal hourlyRate)
{
EnsureStatus(ProductionStatus.InProgress);
// Lệnh có công đoạn thì BẮT BUỘC chỉ rõ công đoạn: không có thì không tính được
// hiệu suất chuẩn/thực tế từng khâu, mất phần lớn giá trị của việc chấm công.
if (_operations.Count > 0 && sequence is null)
Throw("Lệnh có công đoạn — phải chỉ rõ công đoạn khi chấm công.", "OPERATION_REQUIRED");
if (_operations.Count == 0 && sequence is not null)
Throw("Lệnh không có công đoạn nào.", "NO_OPERATIONS");
var cost = decimal.Round(minutes / 60m * hourlyRate, 2);
Raise(new ProductionLaborBooked(
Id, sequence, employeeCode, minutes, hourlyRate, cost, DateTimeOffset.UtcNow));
}
Đường đi tới giá thành
Ba thành phần của giá thành đều lấy từ số thật, không còn tham số nhập tay:
| Thành phần | Nguồn |
|---|---|
| Chi phí vật tư | SUM(cogs_amount) của các bút toán kho tham chiếu MO {số lệnh} — giá vốn vật tư ĐÃ XUẤT thật |
| Chi phí nhân công | SUM(labor_cost) của các bản ghi chấm công thuộc lệnh |
| Chi phí sản xuất chung | Phân bổ theo giờ công hoặc giờ máy qua OverheadAllocator; trạm có giờ mà chưa khai đơn giá thì ném lỗi chứ không lặng lẽ tính 0 |
Cấu trúc thư mục .NET 9
├── Core/ ← Domain layer (không phụ thuộc gì)
│ ├── ERP.MES.Core.csproj
│ ├── Shared/
│ │ ├── AggregateRoot.cs ← Base aggregate root
│ │ ├── DomainEvent.cs ← Base domain event
│ │ └── IRepository.cs ← Repository interface
│ └── Production/
│ ├── ProductionOrder.cs ← Aggregate Root
│ ├── Events/ProductionOrderEvents.cs ← Domain Events
│ ├── ValueObjects/ProductionOrderVOs.cs ← Value Objects
│ └── Exceptions/ProductionDomainException.cs
├── Application/ ← Use cases, CQRS
│ ├── ERP.MES.Application.csproj
│ ├── Shared/
│ │ └── ICommandHandler.cs
│ └── Production/
│ ├── Commands/
│ │ ├── CreateProductionOrder.cs
│ │ ├── StartProductionOrder.cs
│ │ └── CompleteProductionOrder.cs
│ └── Queries/
│ └── GetProductionOrderById.cs
└── Infrastructure/ ← DB, Kafka (Phase 2)
Implementation — C# .NET 9
Value Objects
namespace ERP.MES.Core.Production.ValueObjects;
public record OrderId(Guid Value)
{
public static OrderId New() => new(Guid.NewGuid());
public static OrderId From(Guid id) => new(id);
public static OrderId From(string s) => new(Guid.Parse(s));
public override string ToString() => Value.ToString();
}
public record OrderNumber
{
public string Value { get; }
public OrderNumber(string value)
{
if (string.IsNullOrWhiteSpace(value))
throw new ArgumentException("Order number không được để trống.");
var v = value.Trim().ToUpper();
if (v.Length > 50)
throw new ArgumentException("Order number tối đa 50 ký tự.");
Value = v;
}
public static OrderNumber Generate(int sequence, int year = 0)
{
var y = year == 0 ? DateTime.UtcNow.Year : year;
return new OrderNumber($"PO-{y}-{sequence:D6}");
}
public override string ToString() => Value;
}
public record ProductSpec(Guid ProductId, string Code, string Name)
{
public ProductSpec(Guid productId, string code, string name)
: this(productId, code, name)
{
if (productId == Guid.Empty) throw new ArgumentException("ProductId không hợp lệ.");
if (string.IsNullOrWhiteSpace(code)) throw new ArgumentException("Product code trống.");
if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("Product name trống.");
}
}
public record Quantity
{
public decimal Value { get; }
public string Unit { get; }
public Quantity(decimal value, string unit)
{
if (string.IsNullOrWhiteSpace(unit)) throw new ArgumentException("Unit trống.");
if (value < 0) throw new ArgumentException("Quantity không được âm.");
Value = value;
Unit = unit.Trim().ToUpper();
}
public static Quantity Zero(string unit) => new(0, unit);
public Quantity Add(Quantity other)
{
if (Unit != other.Unit)
throw new InvalidOperationException($"Không thể cộng {Unit} với {other.Unit}");
return new Quantity(Value + other.Value, Unit);
}
public bool IsGreaterThan(Quantity other) => Unit == other.Unit && Value > other.Value;
public bool IsGreaterOrEqual(Quantity other) => Unit == other.Unit && Value >= other.Value;
public override string ToString() => $"{Value:G} {Unit}";
}
public record MachineId(Guid Value)
{
public static MachineId From(Guid id) => new(id);
public override string ToString() => Value.ToString();
}
public record OperatorId(Guid Value)
{
public static OperatorId From(Guid id) => new(id);
public override string ToString() => Value.ToString();
}
public enum ProductionStatus
{
Planned = 1,
Released = 2,
InProgress = 3,
OnHold = 4,
Completed = 5,
Cancelled = 6
}
public enum Shift { Morning, Afternoon, Night }
Domain Events
namespace ERP.MES.Core.Production.Events;
using ERP.MES.Core.Production.ValueObjects;
using ERP.MES.Core.Shared;
public sealed record ProductionOrderCreated(
OrderId OrderId,
OrderNumber OrderNumber,
ProductSpec Product,
Quantity PlannedQty,
MachineId MachineId,
Shift Shift,
string TenantId,
DateTimeOffset OccurredAt
) : DomainEvent(OrderId.Value, OccurredAt);
public sealed record ProductionOrderReleased(
OrderId OrderId,
string ReleasedBy,
DateTimeOffset OccurredAt
) : DomainEvent(OrderId.Value, OccurredAt);
public sealed record ProductionStarted(
OrderId OrderId,
OperatorId OperatorId,
DateTimeOffset OccurredAt
) : DomainEvent(OrderId.Value, OccurredAt);
public sealed record ProductionOutputRecorded(
OrderId OrderId,
Quantity Quantity,
DateTimeOffset OccurredAt
) : DomainEvent(OrderId.Value, OccurredAt);
public sealed record ProductionOrderPaused(
OrderId OrderId,
string Reason,
string ReasonCode,
DateTimeOffset OccurredAt
) : DomainEvent(OrderId.Value, OccurredAt);
public sealed record ProductionOrderResumed(
OrderId OrderId,
DateTimeOffset OccurredAt
) : DomainEvent(OrderId.Value, OccurredAt);
public sealed record ProductionOrderCompleted(
OrderId OrderId,
Quantity ActualQty,
DateTimeOffset OccurredAt
) : DomainEvent(OrderId.Value, OccurredAt);
public sealed record ProductionOrderCancelled(
OrderId OrderId,
string Reason,
DateTimeOffset OccurredAt
) : DomainEvent(OrderId.Value, OccurredAt);
ProductionOrder Aggregate Root
namespace ERP.MES.Core.Production;
using ERP.MES.Core.Production.Events;
using ERP.MES.Core.Production.Exceptions;
using ERP.MES.Core.Production.ValueObjects;
using ERP.MES.Core.Shared;
public sealed class ProductionOrder : AggregateRoot<OrderId>
{
public OrderNumber OrderNumber { get; private set; } = null!;
public ProductSpec Product { get; private set; } = null!;
public Quantity PlannedQty { get; private set; } = null!;
public Quantity ActualQty { get; private set; } = null!;
public ProductionStatus Status { get; private set; }
public MachineId MachineId { get; private set; } = null!;
public OperatorId? OperatorId { get; private set; }
public Shift Shift { get; private set; }
public string TenantId { get; private set; } = null!;
private const decimal MaxOutputBuffer = 1.1m; // 10% buffer
private ProductionOrder() { } // EF / reconstitution
// ── Factory ──────────────────────────────────────────────────
public static ProductionOrder Create(
OrderNumber number,
ProductSpec product,
Quantity plannedQty,
MachineId machineId,
Shift shift,
string tenantId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
var order = new ProductionOrder();
order.Raise(new ProductionOrderCreated(
OrderId.New(), number, product, plannedQty,
machineId, shift, tenantId,
DateTimeOffset.UtcNow));
return order;
}
// ── Reconstitution từ Event Store ───────────────────────────
public static ProductionOrder Reconstitute(IEnumerable<DomainEvent> events)
{
var order = new ProductionOrder();
foreach (var e in events)
order.Apply(e);
return order;
}
// ── Commands ─────────────────────────────────────────────────
public void Release(string releasedBy)
{
EnsureStatus(ProductionStatus.Planned);
Raise(new ProductionOrderReleased(Id, releasedBy, DateTimeOffset.UtcNow));
}
public void Start(OperatorId operatorId)
{
if (Status is not (ProductionStatus.Planned or ProductionStatus.Released))
throw new ProductionDomainException(
"Chỉ bắt đầu sản xuất từ trạng thái Planned hoặc Released.",
"INVALID_STATUS_FOR_START");
Raise(new ProductionStarted(Id, operatorId, DateTimeOffset.UtcNow));
}
public void RecordOutput(Quantity qty)
{
EnsureStatus(ProductionStatus.InProgress);
var newTotal = ActualQty.Add(qty);
var maxAllowed = new Quantity(PlannedQty.Value * MaxOutputBuffer, PlannedQty.Unit);
if (newTotal.IsGreaterThan(maxAllowed))
throw new ProductionDomainException(
$"Tổng sản lượng ({newTotal}) vượt quá giới hạn cho phép ({maxAllowed}).",
"OUTPUT_EXCEEDS_LIMIT");
Raise(new ProductionOutputRecorded(Id, qty, DateTimeOffset.UtcNow));
}
public void Pause(string reason, string reasonCode)
{
EnsureStatus(ProductionStatus.InProgress);
ArgumentException.ThrowIfNullOrWhiteSpace(reason);
Raise(new ProductionOrderPaused(Id, reason, reasonCode, DateTimeOffset.UtcNow));
}
public void Resume()
{
EnsureStatus(ProductionStatus.OnHold);
Raise(new ProductionOrderResumed(Id, DateTimeOffset.UtcNow));
}
public void Complete()
{
EnsureStatus(ProductionStatus.InProgress);
Raise(new ProductionOrderCompleted(Id, ActualQty, DateTimeOffset.UtcNow));
}
public void Cancel(string reason)
{
if (Status == ProductionStatus.Completed)
throw new ProductionDomainException(
"Không thể hủy lệnh đã hoàn thành.",
"CANNOT_CANCEL_COMPLETED");
if (Status == ProductionStatus.InProgress)
ArgumentException.ThrowIfNullOrWhiteSpace(reason);
Raise(new ProductionOrderCancelled(Id, reason, DateTimeOffset.UtcNow));
}
// ── Apply (Event Sourcing) ────────────────────────────────────
protected override void Apply(DomainEvent @event)
{
switch (@event)
{
case ProductionOrderCreated e:
Id = e.OrderId;
OrderNumber = e.OrderNumber;
Product = e.Product;
PlannedQty = e.PlannedQty;
ActualQty = Quantity.Zero(e.PlannedQty.Unit);
MachineId = e.MachineId;
Shift = e.Shift;
TenantId = e.TenantId;
Status = ProductionStatus.Planned;
break;
case ProductionOrderReleased:
Status = ProductionStatus.Released;
break;
case ProductionStarted e:
Status = ProductionStatus.InProgress;
OperatorId = e.OperatorId;
break;
case ProductionOutputRecorded e:
ActualQty = ActualQty.Add(e.Quantity);
break;
case ProductionOrderPaused:
Status = ProductionStatus.OnHold;
break;
case ProductionOrderResumed:
Status = ProductionStatus.InProgress;
break;
case ProductionOrderCompleted:
Status = ProductionStatus.Completed;
break;
case ProductionOrderCancelled:
Status = ProductionStatus.Cancelled;
break;
}
}
// ── Guard ─────────────────────────────────────────────────────
private void EnsureStatus(ProductionStatus expected)
{
if (Status != expected)
throw new ProductionDomainException(
$"Lệnh đang ở trạng thái {Status}, không thể thực hiện khi không phải {expected}.",
"INVALID_STATUS");
}
}
Base Classes (Shared)
// ── AggregateRoot.cs ─────────────────────────────────────────────
namespace ERP.MES.Core.Shared;
public abstract class AggregateRoot<TId>
{
private readonly List<DomainEvent> _events = [];
public TId Id { get; protected set; } = default!;
public int Version { get; private set; }
public IReadOnlyList<DomainEvent> DomainEvents => _events.AsReadOnly();
protected void Raise(DomainEvent @event)
{
Apply(@event);
_events.Add(@event);
Version++;
}
protected abstract void Apply(DomainEvent @event);
public void ClearDomainEvents() => _events.Clear();
}
// ── DomainEvent.cs ───────────────────────────────────────────────
namespace ERP.MES.Core.Shared;
public abstract record DomainEvent(
Guid AggregateId,
DateTimeOffset OccurredAt)
{
public Guid EventId { get; } = Guid.NewGuid();
public string EventType { get; } = nameof(DomainEvent);
}
// ── IRepository.cs ───────────────────────────────────────────────
namespace ERP.MES.Core.Shared;
public interface IRepository<TAggregate, TId>
where TAggregate : AggregateRoot<TId>
{
Task<TAggregate?> GetByIdAsync(TId id, CancellationToken ct = default);
Task SaveAsync(TAggregate aggregate, CancellationToken ct = default);
}
CQRS Handlers — Commands & Queries
Command + Handler: StartProductionOrder
namespace ERP.MES.Application.Production.Commands;
using ERP.MES.Core.Production;
using ERP.MES.Core.Production.ValueObjects;
using ERP.MES.Core.Shared;
// ── Command (immutable record) ───────────────────────────────────
public sealed record StartProductionOrderCommand(
Guid OrderId,
Guid OperatorId,
string TenantId) : ICommand<StartProductionOrderResult>;
// ── Result ───────────────────────────────────────────────────────
public sealed record StartProductionOrderResult(
Guid OrderId,
string Status,
string OperatorId);
// ── Handler ──────────────────────────────────────────────────────
public sealed class StartProductionOrderHandler(
IProductionOrderRepository repository)
: ICommandHandler<StartProductionOrderCommand, StartProductionOrderResult>
{
public async Task<StartProductionOrderResult> HandleAsync(
StartProductionOrderCommand command,
CancellationToken ct)
{
var orderId = OrderId.From(command.OrderId);
var operatorId = OperatorId.From(command.OperatorId);
var order = await repository.GetByIdAsync(orderId, ct)
?? throw new NotFoundException($"Production Order {command.OrderId} không tìm thấy.");
// Business rule enforcement xảy ra bên trong aggregate
order.Start(operatorId);
await repository.SaveAsync(order, ct);
return new StartProductionOrderResult(
command.OrderId,
order.Status.ToString(),
command.OperatorId.ToString());
}
}
// ── Repository interface (domain layer) ─────────────────────────
public interface IProductionOrderRepository
: IRepository<ProductionOrder, OrderId>
{
Task<ProductionOrder?> GetByOrderNumberAsync(
OrderNumber number,
string tenantId,
CancellationToken ct = default);
}
Handler chỉ orchestrate: load → call domain method → save. Không có if/else business logic trong handler. Tất cả validation và business rules nằm trong ProductionOrder.Start(). Đây là điểm then chốt của DDD đúng nghĩa.