Recompute inventory-consumption COGS and fix written-off AR (audit O6, O8)

O6: inventory consumed on jobs posts DR COGS / CR Inventory, but neither recompute
engine reflected it — so reports understated COGS / overstated inventory and a
"Recalculate Balances" wiped the effect. The COGS posting fires only for JobUsage
and Waste transaction types, which are created only at the two COGS-posting sites,
so the consumption is exactly identifiable from InventoryTransaction:
  - both posting sites now record consumption at the effective (weighted-average)
    unit cost so TotalCost equals the COGS posted (the recompute reads TotalCost)
  - LedgerService: new section (dated rows + prior balance) crediting Inventory /
    debiting COGS from JobUsage/Waste rows on items with both accounts mapped
  - FinancialReportService: Trial Balance + accrual P&L include consumption COGS
This reads existing transactions, so historical data is covered with no backfill.
The Balance Sheet inventory line is intentionally left alone — it does not track
inventory purchases either (periodic), so relieving it for consumption alone would
unbalance it; tracked as O9 (inventory capitalization policy).

O8: the write-off already creates a balanced posted JournalEntry (both engines read
it via their JE-line sections). The real defect was 4 "Status != WrittenOff" filters
in FinancialReportService that excluded pre-write-off payments from AR credits and
bank debits — leaving the paid portion dangling as open AR and understating the bank.
Removed those filters; AR now nets to zero for written-off invoices and the trial
balance balances. No backfill needed.

Adds a LedgerService regression test for inventory consumption. Build clean; 293
unit tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-19 21:27:20 -04:00
parent 91ed19c2b1
commit 7834d67432
6 changed files with 191 additions and 44 deletions
@@ -1831,13 +1831,16 @@ public class InventoryController : Controller
item.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.InventoryItems.UpdateAsync(item);
// Record at the effective (weighted-average) unit cost so TotalCost equals the COGS actually
// posted — the GL recompute reads TotalCost to reproduce the DR COGS / CR Inventory entry.
var effectiveUnitCost = item.AverageCost > 0 ? item.AverageCost : item.UnitCost;
var txn = new InventoryTransaction
{
InventoryItemId = item.Id,
TransactionType = transactionType,
Quantity = -quantityUsed,
UnitCost = item.UnitCost,
TotalCost = quantityUsed * item.UnitCost,
UnitCost = effectiveUnitCost,
TotalCost = quantityUsed * effectiveUnitCost,
TransactionDate = DateTime.UtcNow,
BalanceAfter = item.QuantityOnHand,
JobId = jobId,
@@ -1851,7 +1854,7 @@ public class InventoryController : Controller
if (item.CogsAccountId.HasValue && item.InventoryAccountId.HasValue)
{
var cost = quantityUsed * (item.AverageCost > 0 ? item.AverageCost : item.UnitCost);
var cost = txn.TotalCost;
await _accountBalanceService.DebitAsync(item.CogsAccountId, cost);
await _accountBalanceService.CreditAsync(item.InventoryAccountId, cost);
}
@@ -2997,13 +2997,17 @@ public class JobsController : Controller
inventoryItem.QuantityOnHand -= deductNow;
await _unitOfWork.InventoryItems.UpdateAsync(inventoryItem);
// Record the consumption at the effective (weighted-average) unit cost so the
// transaction's TotalCost equals the COGS actually posted — the GL recompute
// reads TotalCost to reproduce the DR COGS / CR Inventory entry.
var effectiveUnitCost = inventoryItem.AverageCost > 0 ? inventoryItem.AverageCost : inventoryItem.UnitCost;
var transaction = new InventoryTransaction
{
InventoryItemId = inventoryItem.Id,
TransactionType = InventoryTransactionType.JobUsage,
Quantity = -deductNow,
UnitCost = inventoryItem.UnitCost,
TotalCost = inventoryItem.UnitCost * deductNow,
UnitCost = effectiveUnitCost,
TotalCost = effectiveUnitCost * deductNow,
TransactionDate = DateTime.UtcNow,
JobId = job.Id,
Reference = job.JobNumber,
@@ -3015,7 +3019,7 @@ public class JobsController : Controller
if (inventoryItem.CogsAccountId.HasValue && inventoryItem.InventoryAccountId.HasValue)
{
var cost = deductNow * (inventoryItem.AverageCost > 0 ? inventoryItem.AverageCost : inventoryItem.UnitCost);
var cost = transaction.TotalCost;
await _accountBalanceService.DebitAsync(inventoryItem.CogsAccountId, cost);
await _accountBalanceService.CreditAsync(inventoryItem.InventoryAccountId, cost);
}