Skip to main content

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

ModuleResponsibility
Cost AccountingBOM cost calculation, standard costs, costing versions, variance analysis
InventoryItem master data (ItemId, ItemNumber, UOM, physical characteristics)
ProductionBOM structures, route operations, manufacturing processes
General LedgerNot involved in cost calculation (receives variance postings later)

Key Design Decisions

  1. Async Cross-Module Communication: Uses MassTransit integration events
  2. In-Memory Orchestration: Pending calculations tracked in-memory (fast, calculation completes quickly once data arrives)
  3. CorrelationId-Based Tracking: Uses MassTransit's built-in CorrelationId for request/response correlation
  4. No Persistent State Table: Unlike D365 F&O's BOMCalcItemTask, we don't persist orchestration state to database
  5. Placeholder Cost Retrieval: Currently uses 0m placeholder; will implement ItemPrice lookup based on CostPriceModel

Workflow Phases

Phase 1: Initiation (Synchronous)

What happens:

  1. User calls POST /api/cost-accounting/bom-calculations
  2. System creates empty BOMCalcTable record (stores ItemId, Quantity, CalcType)
  3. System generates two CorrelationIds (one for item data, one for BOM data)
  4. System registers calculation with BOMCalculationOrchestrator
  5. System publishes two integration events in parallel:
    • ItemDataRequestedIntegrationEvent → Inventory module
    • BOMDataRequestedIntegrationEvent → Production/Inventory module
  6. User receives response with CalculationId and 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:

CostPriceModelCost Source
CostingVersionItemPrice.Price from active costing version
LastPurchasePriceMost recent purchase price from Inventory
CurrentAverageCurrent inventory average cost from Inventory
ItemSalesPriceSales 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:

TypeBehaviorExample
ConstantFixed quantity per batchSetup materials (always 1 sheet of sandpaper)
VariableScales with quantityRaw materials (2kg per unit × quantity)

Formula:

Constant: Quantity = BOMLine.Quantity (fixed)
Variable: Quantity = BOMLine.Quantity × ProductionQuantity / BOMQuantity

Storage:

  • Captured in BOMCalcTrans.ConsumptionType field
  • 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

  1. Item Not Found

    • Inventory module returns Found = false
    • Orchestrator calls FailCalculationAsync
    • BOMCalcTable marked as failed with error message
    • Cleanup occurs immediately
  2. BOM Not Found

    • Production module returns Found = false
    • Similar failure handling
  3. Missing Calculation Group

    • Detected during ProcessBOMCalculation
    • BOMCalcTable marked as failed
    • Error message stored
  4. 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 &gt; 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

  1. Batch Cost Retrieval: When calculating multi-level BOMs, retrieve all component costs in single query
  2. Caching: Cache frequently accessed costing versions and calc groups
  3. 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