BOM Cost Calculation Workflow
Overview
The BOM (Bill of Materials) cost calculation system determines the standard manufacturing cost of finished goods by rolling up costs from components, materials, and route operations. This is a core Cost Accounting function that enables:
- Standard cost maintenance for manufactured items
- Cost simulation and "what-if" scenarios
- Variance analysis (actual vs. standard costs)
- Pricing decisions based on manufacturing costs
Key Principle: This calculates what items should cost to manufacture (standard/planned costs), NOT actual inventory values. Inventory valuation (FIFO, LIFO, etc.) is handled by the Inventory Module.
Architecture Overview
Module Responsibilities
| Module | Responsibility |
|---|---|
| Cost Accounting | BOM cost calculation, standard costs, costing versions, variance analysis |
| Inventory | Item master data (ItemId, ItemNumber, UOM, physical characteristics) |
| Production | BOM structures, route operations, manufacturing processes |
| General Ledger | Not involved in cost calculation (receives variance postings later) |
Key Design Decisions
- Async Cross-Module Communication: Uses MassTransit integration events
- In-Memory Orchestration: Pending calculations tracked in-memory (fast, calculation completes quickly once data arrives)
- CorrelationId-Based Tracking: Uses MassTransit's built-in CorrelationId for request/response correlation
- No Persistent State Table: Unlike D365 F&O's BOMCalcItemTask, we don't persist orchestration state to database
- Placeholder Cost Retrieval: Currently uses 0m placeholder; will implement ItemPrice lookup based on CostPriceModel
Workflow Phases
Phase 1: Initiation (Synchronous)
What happens:
- User calls
POST /api/cost-accounting/bom-calculations - System creates empty
BOMCalcTablerecord (stores ItemId, Quantity, CalcType) - System generates two CorrelationIds (one for item data, one for BOM data)
- System registers calculation with
BOMCalculationOrchestrator - System publishes two integration events in parallel:
ItemDataRequestedIntegrationEvent→ Inventory moduleBOMDataRequestedIntegrationEvent→ Production/Inventory module
- User receives response with
CalculationIdand message about async processing
Phase 2: Data Gathering (Asynchronous)
Orchestrator Logic:
// In-memory tracking (ConcurrentDictionary)
_pendingCalculations[calculationId] = {
ItemDataReceived: false,
BOMDataReceived: false,
ItemData: null,
BOMData: null
}
// When ItemDataResponse arrives
_pendingCalculations[calculationId].ItemDataReceived = true;
_pendingCalculations[calculationId].ItemData = data;
CheckIfReady();
// When BOMDataResponse arrives
_pendingCalculations[calculationId].BOMDataReceived = true;
_pendingCalculations[calculationId].BOMData = data;
CheckIfReady();
// When both received
if (ItemDataReceived && BOMDataReceived) {
await _mediator.Send(new ProcessBOMCalculationCommand { ... });
CleanupCalculation(calculationId); // Remove from in-memory tracking
}
Phase 3: Calculation (Synchronous via MediatR)
ProcessBOMCalculationCommand
↓
Load BOMCalcTable from database
↓
Update with item master data (UOM)
↓
Get BOMCalcGroup (cost/sales price models)
↓
For each BOM line:
├─ Calculate effective quantity (+ scrap %)
├─ Retrieve component cost (TODO: from ItemPrice)
├─ Determine consumption type (Constant/Variable)
└─ Create BOMCalcTrans record
↓
For each route operation (future):
├─ Calculate operation time
├─ Calculate operation cost
└─ Create BOMCalcTrans record
↓
Calculate totals:
├─ Total material cost
├─ Total route cost
└─ Unit cost = Total / Quantity
↓
Calculate sales price (based on SalesPriceModel)
↓
Mark calculation as completed
↓
Save to database
Request/Response Correlation
Using MassTransit CorrelationId
// Step 1: Generate CorrelationIds
var itemDataCorrelationId = Guid.NewGuid();
var bomDataCorrelationId = Guid.NewGuid();
// Step 2: Register with Orchestrator
_orchestrator.RegisterCalculation(
calculationId: bomCalcTable.Id,
itemDataCorrelationId: itemDataCorrelationId,
bomDataCorrelationId: bomDataCorrelationId,
calcGroupId: request.CalcGroupId);
// Step 3: Publish with CorrelationId
await _publishEndpoint.Publish(
new ItemDataRequestedIntegrationEvent { ItemId = request.ItemId, ... },
ctx => ctx.CorrelationId = itemDataCorrelationId,
cancellationToken);
// Step 4: Receive response with matching CorrelationId
public async Task Consume(ConsumeContext<ItemDataResponseIntegrationEvent> context)
{
var correlationId = context.CorrelationId; // MassTransit sets this automatically
// Orchestrator looks up calculationId using correlationId
await _orchestrator.HandleItemDataResponseAsync(correlationId.Value, ...);
}
Benefits:
- Industry-standard pattern
- Built-in MassTransit support for sagas and distributed tracing
- No custom RequestId properties needed
- Better observability in production
State Management
In-Memory vs. Database
Design Choice: In-memory orchestration state (NOT persisted to database)
Rationale:
- BOM calculation is fast once data arrives (milliseconds)
- Slow part is waiting for async responses from other modules
- If service restarts during wait period:
- User sees "async processing" message
- User can query calculation status or retry
- BOMCalcTable record exists in database (shows status)
- Simpler than D365 F&O's BOMCalcItemTask approach
D365 F&O Comparison:
D365 F&O:
BOMCalcTable (calculation header)
├─ BOMCalcTrans (calculation lines)
└─ BOMCalcItemTask (orchestration state) ← We don't use this
Our System:
BOMCalcTable (calculation header)
├─ BOMCalcTrans (calculation lines)
└─ BOMCalculationOrchestrator (in-memory) ← Transient state
Pending Calculation Structure
internal class PendingCalculation
{
public Guid CalculationId { get; set; }
public Guid ItemDataCorrelationId { get; set; }
public Guid BOMDataCorrelationId { get; set; }
public string? CalcGroupId { get; set; }
public DateTime RegisteredAt { get; set; }
public bool ItemDataReceived { get; set; }
public bool BOMDataReceived { get; set; }
public ItemDataResponse? ItemData { get; set; }
public BOMDataResponse? BOMData { get; set; }
}
Lifetime: Exists only while waiting for responses, then cleaned up immediately.
Integration Events
1. ItemDataRequestedIntegrationEvent
Purpose: Get item master data from Inventory module
Payload:
public record ItemDataRequestedIntegrationEvent : INotification
{
public Guid ItemId { get; init; }
public DateTime AsOfDate { get; init; }
public Guid? SiteId { get; init; }
public bool IncludeDetails { get; init; }
}
Response: ItemDataResponseIntegrationEvent
public record ItemDataResponseIntegrationEvent : INotification
{
public Guid ItemId { get; init; }
public string ItemNumber { get; init; }
public string Name { get; init; }
public string ItemType { get; init; }
public Guid PrimaryUnitId { get; init; }
public string? BOMCalcGroupId { get; init; }
public Guid? CostGroupId { get; init; }
public decimal? NetWeight { get; init; }
public bool Found { get; init; }
public string? ErrorMessage { get; init; }
}
Important: Does NOT include StandardCost (Cost Accounting owns all cost data).
2. BOMDataRequestedIntegrationEvent
Purpose: Get BOM structure and route operations from Production module
Payload:
public record BOMDataRequestedIntegrationEvent : INotification
{
public Guid ItemId { get; init; }
public string? BOMId { get; init; }
public DateTime AsOfDate { get; init; }
public Guid? SiteId { get; init; }
public bool IncludeRouteData { get; init; }
public bool IncludeMultiLevel { get; init; } = true;
}
Response: BOMDataResponseIntegrationEvent
public record BOMDataResponseIntegrationEvent : INotification
{
public string? BOMId { get; init; }
public decimal BOMQuantity { get; init; }
public List<BOMLineDto> Lines { get; init; }
public List<RouteOperationDto>? RouteOperations { get; init; }
public bool Found { get; init; }
public string? ErrorMessage { get; init; }
}
Cost Retrieval Strategy
Current Implementation (Placeholder)
// TODO: Implement proper cost retrieval for component items
// Should query ItemPrice table based on calcGroup.CostPriceModel for bomLine.ItemId
// For now, using placeholder cost of 0m until cost retrieval service is implemented
var componentUnitCost = 0m;
Future Implementation
Based on CostPriceModel in BOMCalcGroup:
| CostPriceModel | Cost Source |
|---|---|
| CostingVersion | ItemPrice.Price from active costing version |
| LastPurchasePrice | Most recent purchase price from Inventory |
| CurrentAverage | Current inventory average cost from Inventory |
| ItemSalesPrice | Sales price minus margin |
Example Query:
// Get component cost based on CostPriceModel
if (calcGroup.CostPriceModel.Name == "CostingVersion")
{
// Query ItemPrice table
var itemPrice = await _costingVersionRepository
.GetActiveCostAsync(bomLine.ItemId, calcDate, siteId);
componentUnitCost = itemPrice?.Price ?? 0m;
}
Consumption Types
Constant vs. Variable
Consumption type affects how component quantities scale with production quantity:
| Type | Behavior | Example |
|---|---|---|
| Constant | Fixed quantity per batch | Setup materials (always 1 sheet of sandpaper) |
| Variable | Scales with quantity | Raw materials (2kg per unit × quantity) |
Formula:
Constant: Quantity = BOMLine.Quantity (fixed)
Variable: Quantity = BOMLine.Quantity × ProductionQuantity / BOMQuantity
Storage:
- Captured in
BOMCalcTrans.ConsumptionTypefield - Used in future multi-level BOM explosions
- Affects cost scaling calculations
Route Operations (Future Enhancement)
Currently the system has placeholder support for route operations:
// Step 5: Process route operations if available
decimal totalRouteCost = 0m;
if (request.BOMData.RouteOperations != null && request.BOMData.RouteOperations.Any())
{
foreach (var operation in request.BOMData.RouteOperations)
{
// Calculate total time for this operation
var totalTime = operation.SetupTime +
(operation.ProcessTime * bomCalcTable.Quantity / operation.ProcessPerQty);
// Convert minutes to hours and calculate cost
var operationCost = (totalTime / 60m) * operation.CostPerHour;
bomCalcTable.AddRouteOperation(...);
totalRouteCost += operationCost;
}
}
When Routes are implemented:
- Production module will provide route operations in BOMDataResponse
- System will calculate operation costs based on:
- Setup time (fixed per operation)
- Process time per unit
- Resource cost per hour
- Operation sequence and dependencies
Error Handling
Failure Scenarios
-
Item Not Found
- Inventory module returns
Found = false - Orchestrator calls
FailCalculationAsync - BOMCalcTable marked as failed with error message
- Cleanup occurs immediately
- Inventory module returns
-
BOM Not Found
- Production module returns
Found = false - Similar failure handling
- Production module returns
-
Missing Calculation Group
- Detected during ProcessBOMCalculation
- BOMCalcTable marked as failed
- Error message stored
-
Component Cost Not Found
- Warning added to BOMCalcTrans
- Calculation continues with 0m cost
- Warning visible in calculation results
Timeout Handling
Current: No explicit timeout (relies on MassTransit message expiration)
Future Enhancement: Could add timeout tracking in orchestrator
// Check for stale calculations
if ((DateTime.UtcNow - pending.RegisteredAt).TotalMinutes > 5)
{
await FailCalculationAsync(calculationId, "Timeout waiting for responses");
}
Sequence Diagrams
Complete Flow Diagram
User CommandHandler Orchestrator Inventory Production ProcessHandler
│ │ │ │ │ │
│ POST /bom-calc │ │ │ │ │
├──────────────────────→│ │ │ │ │
│ │ Create BOMCalcTable│ │ │ │
│ ├───────────────────→│ │ │ │
│ │ │ │ │ │
│ │ RegisterCalculation│ │ │ │
│ ├───────────────────→│ │ │ │
│ │ │ │ │ │
│ │ Publish(ItemDataReq) with CorrelationId │ │
│ ├─────────────────────────────────→│ │ │
│ │ │ │ │ │
│ │ Publish(BOMDataReq) with CorrelationId │ │
│ ├────────────────────────────────────────────────→│ │
│ │ │ │ │ │
│ 202 Accepted │ │ │ │ │
│←───────────────────────┤ │ │ │ │
│ {CalculationId} │ │ │ │ │
│ │ │ │ │ │
│ │ ItemDataResponse(CorrelationId)│ │ │
│ │ │←──────────────┤ │ │
│ │ │ │ │ │
│ │ │ HandleItemDataResponse │ │
│ │ ├──────────────→│ │ │
│ │ │ Store data │ │ │
│ │ │ Check ready │ │ │
│ │ │ │ │ │
│ │ BOMDataResponse(CorrelationId) │ │ │
│ │ │←───────────────────────────→│ │
│ │ │ │ │ │
│ │ │ HandleBOMDataResponse │ │
│ │ ├──────────────→│ │ │
│ │ │ Store data │ │ │
│ │ │ Check ready │ │ │
│ │ │ BOTH RECEIVED!│ │ │
│ │ │ │ │ │
│ │ │ Send(ProcessBOMCalculationCommand) │
│ │ ├────────────────────────────────────────────→│
│ │ │ │ │ │
│ │ │ Cleanup │ │ Load BOMCalcTable
│ │ │ │ │ Calculate costs
│ │ │ │ │ Create BOMCalcTrans
│ │ │ │ │ Mark completed
│ │ │ │ │ Save to DB
│ │ │ │ │ │
│ │
│ Later: GET /bom-calculations/{id} │
├──────────────────────────────────────────────────────────────────────────────────────────→│
│ │
│ 200 OK {Status: Completed, CostPrice: 150.00, Lines: [...]} │
│←────────────────────────────────────────────────────────────────────────────────────────────┤
CorrelationId Tracking Detail
InitiateBOMCalculationHandler
│
├─ Generate itemDataCorrelationId = Guid.NewGuid()
├─ Generate bomDataCorrelationId = Guid.NewGuid()
│
├─ Register in Orchestrator:
│ _correlationIdToCalculationId[itemDataCorrelationId] = calculationId
│ _correlationIdToCalculationId[bomDataCorrelationId] = calculationId
│
├─ Publish ItemDataRequest
│ MassTransit sets: context.CorrelationId = itemDataCorrelationId
│
└─ Publish BOMDataRequest
MassTransit sets: context.CorrelationId = bomDataCorrelationId
─────────────────────────────────────────────────────────────
ItemDataResponseHandler
│
├─ Receive response: context.CorrelationId = itemDataCorrelationId
│
├─ Orchestrator lookup:
│ calculationId = _correlationIdToCalculationId[context.CorrelationId]
│
└─ Store response and check if ready
Performance Considerations
Scalability
Current Design:
- Orchestrator is Scoped (one instance per request)
- In-memory dictionaries per instance
- No shared state between requests
Implications:
- Multiple concurrent calculations work independently
- No contention on shared resources
- Memory cleaned up when requests complete
Optimization Opportunities
- Batch Cost Retrieval: When calculating multi-level BOMs, retrieve all component costs in single query
- Caching: Cache frequently accessed costing versions and calc groups
- Parallel Processing: Process independent BOM branches in parallel (future multi-level support)
Testing Strategy
Unit Tests (Domain Layer)
[Fact]
public void AddBOMLine_WithValidData_CreatesBOMCalcTrans()
{
// Arrange
var bomCalcTable = BOMCalcTable.Create(...);
// Act
bomCalcTable.AddBOMLine(
itemId: Guid.NewGuid(),
quantity: 2.5m,
unitId: Guid.NewGuid(),
costPrice: 10m,
level: 0,
consumptionType: ConsumptionType.Variable);
// Assert
bomCalcTable.Transactions.Should().HaveCount(1);
bomCalcTable.Transactions.First().CostPriceQty.Should().Be(25m);
}
Integration Tests (API Layer)
[Fact]
public async Task InitiateBOMCalculation_ReturnsAccepted_AndCreatesRecord()
{
// Arrange
var command = new InitiateBOMCalculationCommand { ... };
// Act
var result = await _mediator.Send(command);
// Assert
result.Success.Should().BeTrue();
result.CalculationId.Should().NotBeEmpty();
// Verify database record created
var record = await _repository.GetByIdAsync(result.CalculationId);
record.Should().NotBeNull();
record.ItemId.Should().Be(command.ItemId);
}
Orchestration Tests
[Fact]
public async Task Orchestrator_WhenBothResponsesReceived_TriggersCalculation()
{
// Arrange
var orchestrator = new BOMCalculationOrchestrator(...);
var calculationId = Guid.NewGuid();
orchestrator.RegisterCalculation(calculationId, itemCorrelationId, bomCorrelationId);
// Act
await orchestrator.HandleItemDataResponseAsync(itemCorrelationId, itemData);
await orchestrator.HandleBOMDataResponseAsync(bomCorrelationId, bomData);
// Assert
// Verify ProcessBOMCalculationCommand was sent
_mediatorMock.Verify(x => x.Send(
It.Is<ProcessBOMCalculationCommand>(c => c.CalculationId == calculationId),
It.IsAny<CancellationToken>()));
}
Future Enhancements
1. Multi-Level BOM Explosion
Currently processes single-level BOMs. Future:
- Recursively explode sub-BOMs
- Track BOM levels in BOMCalcTrans
- Rollup costs from bottom to top
2. Cost Simulation
- Create "what-if" scenarios
- Compare different costing versions
- Analyze impact of cost changes
3. Approval Workflow
- Submit calculations for approval
- Version control for approved calculations
- Update ItemPrice after approval
4. Route Operations
- Full route costing support
- Operation dependencies
- Resource capacity planning
5. Batch Processing
- Calculate costs for multiple items
- Schedule periodic recalculations
- Mass update ItemPrice records
References
- D365 F&O BOM Calculation: https://learn.microsoft.com/en-us/dynamics365/supply-chain/cost-management/bom-calculations
- MassTransit CorrelationId: https://masstransit.io/documentation/concepts/messages#correlation-id
- Domain Aggregate:
docs/domain/cost-accounting/aggregates/bom-calc-table.aggregate.md - API Documentation:
docs/api/cost-accounting/bom-calculations.api.md