Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -531,7 +531,7 @@ readonly isStandalone = this.accountingService.isStandalone;
<!-- ===== Capability Gating ===== -->
## Capability Gating (Phase 4)

The system runs on a **per-install capability gate**: 164 named capabilities (e.g., `CAP-MD-CUSTOMERS`, `CAP-INV-LOTS`, `CAP-EXT-AI-ASSISTANT`) are registered in a static catalog. Each install's capability state is stored in the `capabilities` table; controllers and Hangfire-fired commands carry `[RequiresCapability("CAP-...")]` attributes; the `CapabilityGateMiddleware` (controller side) and `CapabilityGateBehavior` (MediatR side) short-circuit with 403 + envelope when a capability is disabled. Bootstrap-exempt endpoints (auth, descriptor, capability admin) carry `[CapabilityBootstrap]` instead so admins are never locked out.
The system runs on a **per-install capability gate**: 165 named capabilities (e.g., `CAP-MD-CUSTOMERS`, `CAP-INV-LOTS`, `CAP-EXT-AI-ASSISTANT`) are registered in a static catalog. Each install's capability state is stored in the `capabilities` table; controllers and Hangfire-fired commands carry `[RequiresCapability("CAP-...")]` attributes; the `CapabilityGateMiddleware` (controller side) and `CapabilityGateBehavior` (MediatR side) short-circuit with 403 + envelope when a capability is disabled. Bootstrap-exempt endpoints (auth, descriptor, capability admin) carry `[CapabilityBootstrap]` instead so admins are never locked out.

**Where things live:**
- **Catalog (source of truth)**: `forge-api/forge.api/Capabilities/CapabilityCatalog.cs` — 157 capabilities with code, name, area, default-state, dependencies/mutexes
Expand Down Expand Up @@ -729,6 +729,28 @@ Real: **Shopify**, **WooCommerce** (both storefronts — you are merchant of rec


<!-- ===== What NOT to Do + Efficiency/Memory ===== -->
## Gated Sequence Engine (Sequences, `CAP-CROSS-SEQUENCES`)

Added 2026-08-18. A general-purpose gated-process primitive — a Petri net with guarded transitions and clocks — for
routing gates, inspection sign-offs, lot expiry, permit/inspection chains. Design + record:
`forge/docs/delivery/in-progress/gated-sequence-engine/`. Rules when touching it:

- **Definition vs instance.** `SequenceDefinition` (`Code`+`Version`, Draft→Published→Retired) is immutable once
Published; instances pin the version they started on. Never mutate a Published definition — `new-version`.
- **The evaluator is pure.** `Forge.Core.Sequences.SequenceEvaluator` takes (net, instance, verdicts, now) and returns
events; it never touches storage, clocks, or gate sources. Storage/DI live in `forge.api/Services/SequenceEvaluationService`.
Keep it that way — it is what makes the engine unit-testable and idempotent.
- **Blocked is derived, never stored.** A step is Blocked when its predecessors are satisfied and a gate is not Go.
Do not add a Blocked status.
- **Every state change is a `SequenceEvent` row** (append-only) plus an `ActivityLog` row against the instance and its
subject (`SequenceQueries.IndexingPoints`). Override / skip / rework / cancel require a reason.
- **Adding a gate kind = registering an `IGateSource`** (`SourceType = Custom`, `CustomKey = "<key>"`) in DI; the gate's
`config_json` names it via `{ "key": "<key>" }`. Unknown keys fail closed (NoGo). Sources must be side-effect free.
- **Anything that can change a verdict must dispatch `ReevaluateSequenceCommand`.** Built-in triggers: step complete,
gate clear/override, rework, `SequenceClockJob` (minutely — the engine's only timer), `ApprovalCompletedEvent`.
- Domain events published: `SequenceStepReadyEvent`, `SequenceInstanceCompletedEvent`, `SequenceClockExpiredEvent`.
No default reactions — consumers subscribe.

## What NOT to Do

- Never use `FormsModule` / `ngModel` in features — always `ReactiveFormsModule`
Expand Down
1 change: 1 addition & 0 deletions forge.api/Capabilities/CapabilityCatalog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ public static class CapabilityCatalog
new("CAP-CROSS-WEBHOOKS", "CROSS", @"Outbound webhooks", @"Configurable webhook subscriptions on domain events, with delivery retry and failure tracking.", IsDefaultOn: false, RequiresRoles: null),
new("CAP-CROSS-BI-EXPORT", "CROSS", @"BI tool data export", @"Read-only data export endpoints (Sankey reports, dynamic report builder, scheduled exports) consumable by external BI tools via API key.", IsDefaultOn: false, RequiresRoles: null),
new("CAP-CROSS-CONCURRENCY", "CROSS", @"Optimistic locking + conflict resolution", @"RowVersion-based optimistic locking on transactional entities (Job, Invoice, PO, SO, Quote, Payment, Shipment); 409 Conflict + UI conflict-resolution dialog.", IsDefaultOn: true, RequiresRoles: null),
new("CAP-CROSS-SEQUENCES", "CROSS", @"Gated sequence engine", @"General-purpose gated process primitive (a Petri net with guarded transitions and clocks): versioned step/edge/gate definitions, runs against any entity, go/no-go gates (manual clearance, time window, resource clock, approval, module-custom), dwell and resource clocks with block/flag/escalate, override and rework with mandatory reasons, append-only event log. The substrate for routing gates, inspection sign-offs, lot expiry and permit/inspection chains. Off by default until a module needs it.", IsDefaultOn: false, RequiresRoles: null),
new("CAP-ADMIN-I18N", "CROSS", @"UI label customization (i18n overrides)", @"Admin screen to customize UI label text per language (e.g. rename ""Customer"" to match business vocabulary). Overrides are stored per install and merged over the shipped i18n catalogs at load time; edits in one language fan out as machine translations to the other configured languages via the self-hosted AI module, flagged and individually editable.", IsDefaultOn: true, RequiresRoles: "Admin"),
new("CAP-EXT-KANBAN", "EXT", @"Kanban-style job board", @"Visual kanban board for jobs/WOs with custom track types (Production, R&D, Maintenance, Other), multi-select bulk actions, real-time SignalR updates.", IsDefaultOn: true, RequiresRoles: null),
new("CAP-EXT-KANBAN-REPLENISHMENT", "EXT", @"Replenishment kanban", @"Two-bin / kanban-card replenishment triggers tied to inventory consumption.", IsDefaultOn: false, RequiresRoles: null),
Expand Down
5 changes: 5 additions & 0 deletions forge.api/Capabilities/CapabilityCatalogRelations.cs
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,11 @@ public static class CapabilityCatalogRelations
new("CAP-O2C-SO-ACCEPTANCE", "CAP-O2C-SO"),
new("CAP-O2C-RECURRING", "CAP-O2C-SO"),
new("CAP-O2C-PICKPACK", "CAP-O2C-SO"),
// Inventory stores and moves *parts* — the item master is a real prerequisite.
// This edge was implicit while CAP-MD-PARTS sat in Foundations; when Parts moved
// out of Foundations (2026-08-17, services/construction installs) the dependency
// had to become explicit so an inventory-module install still closes over it.
new("CAP-INV-CORE", "CAP-MD-PARTS"),
new("CAP-O2C-PICKPACK", "CAP-INV-CORE"),
new("CAP-O2C-SHIP", "CAP-O2C-PICKPACK"),
// Note: CAP-O2C-INVOICE depends on (CAP-ACCT-BUILTIN OR CAP-ACCT-EXTERNAL) per 4A —
Expand Down
10 changes: 8 additions & 2 deletions forge.api/Capabilities/ModuleCatalog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,14 @@ public static class ModuleCatalog
"CAP-CROSS-PERMS-MATRIX", "CAP-CROSS-ACTIVITY-LOG", "CAP-CROSS-LIST-UX",
"CAP-CROSS-BULK-OPS", "CAP-CROSS-DOCS", "CAP-CROSS-ATTACHMENTS",
"CAP-CROSS-NOTIFICATIONS", "CAP-CROSS-INTEG-FILE", "CAP-CROSS-CONCURRENCY",
// Core master data every flow leans on
"CAP-MD-PARTS", "CAP-MD-UOM", "CAP-MD-LOCATIONS", "CAP-MD-CURRENCIES", "CAP-MD-TAXCODES",
// Core master data every flow leans on. CAP-MD-PARTS is deliberately NOT
// here (moved out 2026-08-17): services shops and non-manufacturing verticals
// (construction) run without a Part catalog, and Foundations-membership made
// the module picker unable to express that while PRESET-08 (Pro Services)
// removes the capability — the two mechanisms contradicted each other.
// Modules that genuinely need the item master (inventory, production via
// BOM/routing) pull it back in through dependency closure instead.
"CAP-MD-UOM", "CAP-MD-LOCATIONS", "CAP-MD-CURRENCIES", "CAP-MD-TAXCODES",
// Baseline dashboards + mobile shell. Operational reports (CAP-RPT-OPERATIONAL)
// are deliberately NOT here: they require customer + vendor master data, so a
// foundations slot would force Customers/Vendors on for every install (e.g.
Expand Down
23 changes: 23 additions & 0 deletions forge.api/Controllers/BarcodesController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,29 @@ public async Task<IActionResult> Regenerate(
cancellationToken);
return Ok(result);
}

/// <summary>Add a manual alternate barcode (manufacturer UPC, vendor SKU, legacy label) on top of the
/// entity's auto-assigned code. The value must be globally unique.</summary>
[HttpPost]
public async Task<IActionResult> AddManual(
[FromBody] AddManualBarcodeRequestModel request,
CancellationToken cancellationToken)
{
var result = await mediator.Send(
new AddManualBarcodeCommand(request.EntityType, request.EntityId, request.Value),
cancellationToken);
return Ok(result);
}

/// <summary>Remove a manually-added alternate barcode. The auto-assigned code cannot be removed.</summary>
[HttpDelete("{id:int}")]
public async Task<IActionResult> RemoveManual(int id, CancellationToken cancellationToken)
{
await mediator.Send(new RemoveManualBarcodeCommand(id), cancellationToken);
return NoContent();
}
}

public record RegenerateBarcodeRequestModel(BarcodeEntityType EntityType, int EntityId, string NaturalIdentifier);

public record AddManualBarcodeRequestModel(BarcodeEntityType EntityType, int EntityId, string Value);
136 changes: 136 additions & 0 deletions forge.api/Controllers/SequencesController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
using System.Security.Claims;

using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

using Forge.Api.Capabilities;
using Forge.Api.Features.Sequences;
using Forge.Core.Enums;
using Forge.Core.Models;

namespace Forge.Api.Controllers;

/// <summary>Gated Sequence Engine — definitions (versioned templates), instances (runs), and resource clocks.</summary>
[ApiController]
[Route("api/v1/sequences")]
[Authorize]
[RequiresCapability("CAP-CROSS-SEQUENCES")]
public class SequencesController(IMediator mediator) : ControllerBase
{
// ----- definitions -----

[HttpGet("definitions")]
public async Task<IActionResult> GetDefinitions([FromQuery] string? code, [FromQuery] SequenceDefinitionStatus? status, CancellationToken ct) =>
Ok(await mediator.Send(new GetSequenceDefinitionsQuery(code, status), ct));

[HttpGet("definitions/{id:int}")]
public async Task<IActionResult> GetDefinition(int id, CancellationToken ct) =>
Ok(await mediator.Send(new GetSequenceDefinitionQuery(id), ct));

[HttpPost("definitions")]
public async Task<IActionResult> CreateDefinition([FromBody] SequenceDefinitionRequestModel model, CancellationToken ct)
{
var result = await mediator.Send(new CreateSequenceDefinitionCommand(model), ct);
return CreatedAtAction(nameof(GetDefinition), new { id = result.Id }, result);
}

[HttpPut("definitions/{id:int}")]
public async Task<IActionResult> UpdateDefinition(int id, [FromBody] SequenceDefinitionRequestModel model, CancellationToken ct) =>
Ok(await mediator.Send(new UpdateSequenceDefinitionCommand(id, model), ct));

[HttpPost("definitions/{id:int}/publish")]
public async Task<IActionResult> PublishDefinition(int id, CancellationToken ct) =>
Ok(await mediator.Send(new PublishSequenceDefinitionCommand(id, GetUserId()), ct));

[HttpPost("definitions/{id:int}/new-version")]
public async Task<IActionResult> NewVersion(int id, CancellationToken ct)
{
var result = await mediator.Send(new NewSequenceDefinitionVersionCommand(id), ct);
return CreatedAtAction(nameof(GetDefinition), new { id = result.Id }, result);
}

[HttpDelete("definitions/{id:int}")]
public async Task<IActionResult> RetireDefinition(int id, CancellationToken ct)
{
await mediator.Send(new RetireSequenceDefinitionCommand(id), ct);
return NoContent();
}

// ----- instances -----

[HttpGet("instances")]
public async Task<IActionResult> GetInstances([FromQuery] string? subjectEntityType, [FromQuery] int? subjectEntityId,
[FromQuery] SequenceInstanceStatus? status, [FromQuery] int? definitionId, CancellationToken ct) =>
Ok(await mediator.Send(new GetSequenceInstancesQuery(subjectEntityType, subjectEntityId, status, definitionId), ct));

[HttpGet("instances/{id:int}")]
public async Task<IActionResult> GetInstance(int id, CancellationToken ct) =>
Ok(await mediator.Send(new GetSequenceInstanceQuery(id), ct));

[HttpGet("instances/{id:int}/events")]
public async Task<IActionResult> GetEvents(int id, CancellationToken ct) =>
Ok(await mediator.Send(new GetSequenceEventsQuery(id), ct));

[HttpPost("instances")]
public async Task<IActionResult> Start([FromBody] StartSequenceRequestModel model, CancellationToken ct)
{
var result = await mediator.Send(new StartSequenceInstanceCommand(model, GetUserId()), ct);
return CreatedAtAction(nameof(GetInstance), new { id = result.Id }, result);
}

[HttpPost("instances/{id:int}/reevaluate")]
public async Task<IActionResult> Reevaluate(int id, CancellationToken ct) =>
Ok(await mediator.Send(new ReevaluateSequenceCommand(id, GetUserId()), ct));

[HttpPost("instances/{id:int}/cancel")]
public async Task<IActionResult> Cancel(int id, [FromBody] SequenceReasonRequestModel model, CancellationToken ct) =>
Ok(await mediator.Send(new CancelSequenceInstanceCommand(id, model.Reason, GetUserId()), ct));

[HttpPost("instances/{id:int}/rework")]
public async Task<IActionResult> Rework(int id, [FromBody] SequenceReworkRequestModel model, CancellationToken ct) =>
Ok(await mediator.Send(new ReworkSequenceCommand(id, model.TargetStepKey, model.Reason, GetUserId()), ct));

[HttpPost("instances/{id:int}/steps/{stepKey}/start")]
public async Task<IActionResult> StartStep(int id, string stepKey, CancellationToken ct) =>
Ok(await mediator.Send(new StartSequenceStepCommand(id, stepKey, GetUserId()), ct));

[HttpPost("instances/{id:int}/steps/{stepKey}/complete")]
public async Task<IActionResult> CompleteStep(int id, string stepKey, CancellationToken ct) =>
Ok(await mediator.Send(new CompleteSequenceStepCommand(id, stepKey, GetUserId()), ct));

[HttpPost("instances/{id:int}/steps/{stepKey}/skip")]
public async Task<IActionResult> SkipStep(int id, string stepKey, [FromBody] SequenceReasonRequestModel model, CancellationToken ct) =>
Ok(await mediator.Send(new SkipSequenceStepCommand(id, stepKey, model.Reason, GetUserId()), ct));

[HttpPost("instances/{id:int}/gates/{stepKey}/{gateKey}/clear")]
public async Task<IActionResult> ClearGate(int id, string stepKey, string gateKey, CancellationToken ct) =>
Ok(await mediator.Send(new ClearSequenceGateCommand(id, stepKey, gateKey, GetUserId()), ct));

[HttpPost("instances/{id:int}/gates/{stepKey}/{gateKey}/override")]
public async Task<IActionResult> OverrideGate(int id, string stepKey, string gateKey, [FromBody] SequenceReasonRequestModel model, CancellationToken ct) =>
Ok(await mediator.Send(new OverrideSequenceGateCommand(id, stepKey, gateKey, model.Reason, GetUserId()), ct));

// ----- resource clocks -----

[HttpGet("resource-clocks")]
public async Task<IActionResult> GetResourceClocks([FromQuery] string? resourceType, [FromQuery] int? resourceId, [FromQuery] bool includeFired, CancellationToken ct) =>
Ok(await mediator.Send(new GetSequenceResourceClocksQuery(resourceType, resourceId, includeFired), ct));

[HttpPost("resource-clocks")]
public async Task<IActionResult> CreateResourceClock([FromBody] SequenceResourceClockRequestModel model, CancellationToken ct) =>
Ok(await mediator.Send(new CreateSequenceResourceClockCommand(model), ct));

[HttpDelete("resource-clocks/{id:int}")]
public async Task<IActionResult> DeleteResourceClock(int id, CancellationToken ct)
{
await mediator.Send(new DeleteSequenceResourceClockCommand(id), ct);
return NoContent();
}

private int GetUserId()
{
var claim = User.FindFirstValue(ClaimTypes.NameIdentifier);
return int.TryParse(claim, out var id) ? id : throw new UnauthorizedAccessException();
}
}
33 changes: 18 additions & 15 deletions forge.api/Features/Accounting/ReceiptInventoryPostingService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -143,8 +143,8 @@ private async Task PostCoreAsync(
// is the material price variance posted below. No resolver wired, or no resolvable standard, falls
// back to landed actual (no variance) — backward compatible with actual-cost carrying.
var stocked = IsStocked(line.Part);
var stdUnit = stocked && standardCost is not null
? (await standardCost.ResolveAsync(line.PartId, ct)).Total
var stdUnit = stocked && standardCost is not null && line.PartId is int stdPartId
? (await standardCost.ResolveAsync(stdPartId, ct)).Total
: 0m;
var inventoryAmount = stocked && stdUnit > 0m
? Math.Round(stdUnit * rec.QuantityReceived, 2, MidpointRounding.AwayFromZero)
Expand All @@ -154,15 +154,15 @@ private async Task PostCoreAsync(
{
AccountKey = DebitKeyFor(line.Part),
Debit = inventoryAmount,
Description = $"Receipt {receiptNumber} — {(line.Part?.PartNumber ?? $"part {line.PartId}")} x{rec.QuantityReceived}",
Description = $"Receipt {receiptNumber} — {(line.Part?.PartNumber ?? (line.PartId is int dp ? $"part {dp}" : line.Description))} x{rec.QuantityReceived}",
});
totalBase += baseCost;
totalFreight += freight;
totalInventory += inventoryAmount;

// Consumables/tools are expensed (not stocked) — only perpetual-stocked classes feed the store.
if (stocked)
valuationFeeds.Add((line.PartId, rec.QuantityReceived, inventoryAmount));
if (stocked && line.PartId is int feedPartId)
valuationFeeds.Add((feedPartId, rec.QuantityReceived, inventoryAmount));
}

if (totalBase + totalFreight <= 0m)
Expand Down Expand Up @@ -234,16 +234,19 @@ private static bool IsStocked(Part? part) => part?.InventoryClass is
InventoryClass.Raw or InventoryClass.Component or InventoryClass.Subassembly or InventoryClass.FinishedGood;

/// <summary>Maps a received part's <see cref="InventoryClass"/> to the inventory determination key
/// it capitalizes to. Consumables / tools are expensed (not stocked-for-production); a null/unknown
/// class defaults to raw-materials inventory (a purchased input).</summary>
private static string DebitKeyFor(Part? part) => part?.InventoryClass switch
{
InventoryClass.Raw or InventoryClass.Component => KeyInventoryRaw,
InventoryClass.Subassembly => KeyInventorySubassembly,
InventoryClass.FinishedGood => KeyInventoryFg,
InventoryClass.Consumable or InventoryClass.Tool => KeyOperatingExpense,
_ => KeyInventoryRaw,
};
/// it capitalizes to. Consumables / tools are expensed (not stocked-for-production). A line with NO
/// part at all (service / described material) is expensed — nothing enters inventory. A part with an
/// unknown class defaults to raw-materials inventory (a purchased input).</summary>
private static string DebitKeyFor(Part? part) => part is null
? KeyOperatingExpense
: part.InventoryClass switch
{
InventoryClass.Raw or InventoryClass.Component => KeyInventoryRaw,
InventoryClass.Subassembly => KeyInventorySubassembly,
InventoryClass.FinishedGood => KeyInventoryFg,
InventoryClass.Consumable or InventoryClass.Tool => KeyOperatingExpense,
_ => KeyInventoryRaw,
};

private async Task TryAuditAsync(
string receiptNumber, int purchaseOrderId, JournalEntry entry, decimal totalBase, decimal totalFreight,
Expand Down
Loading
Loading