Created
February 16, 2026 21:14
-
-
Save achal7/45a6639ec61b7664dd78bfb3d6cd6515 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| module Medhavi.Domain.Validation | |
| open System | |
| open Medhavi.Domain | |
| /// Reusable validation helpers for command/input checks | |
| let required (field: string) (value: string) = | |
| if String.IsNullOrWhiteSpace value then | |
| Error(DomainError.Validation(DomainErrorCodes.ValidationFailed, $"{field} is required", Map.empty)) | |
| else | |
| Ok(value.Trim()) | |
| let nonNegativeDecimal (field: string) (value: decimal) = | |
| if value < 0m then | |
| Error(DomainError.Validation(DomainErrorCodes.ValidationFailed, $"{field} must be >= 0", Map.empty)) | |
| else | |
| Ok value | |
| let positiveDecimal (field: string) (value: decimal) = | |
| if value <= 0m then | |
| Error(DomainError.Validation(DomainErrorCodes.ValidationFailed, $"{field} must be > 0", Map.empty)) | |
| else | |
| Ok value | |
| let inRange (field: string) (minValue: decimal) (maxValue: decimal) (value: decimal) = | |
| if value < minValue || value > maxValue then | |
| Error( | |
| DomainError.Validation( | |
| DomainErrorCodes.ValidationFailed, | |
| $"{field} must be between {minValue} and {maxValue}", | |
| Map.empty | |
| ) | |
| ) | |
| else | |
| Ok value | |
| let dateNotPast (field: string) (now: DateTimeOffset) (value: DateTimeOffset) = | |
| if value < now then | |
| Error(DomainError.Validation(DomainErrorCodes.ValidationFailed, $"{field} must not be in the past", Map.empty)) | |
| else | |
| Ok value | |
| let dateRange (startField: string) (endField: string) (startDate: DateTimeOffset) (endDate: DateTimeOffset) = | |
| if endDate < startDate then | |
| Error( | |
| DomainError.Validation( | |
| DomainErrorCodes.ValidationFailed, | |
| $"{endField} must be greater than or equal to {startField}", | |
| Map.empty | |
| ) | |
| ) | |
| else | |
| Ok(startDate, endDate) | |
| let nonNegativeInt (field: string) (value: int) = | |
| if value < 0 then | |
| Error(DomainError.Validation(DomainErrorCodes.ValidationFailed, $"{field} must be >= 0", Map.empty)) | |
| else | |
| Ok value | |
| let positiveInt (field: string) (value: int) = | |
| if value <= 0 then | |
| Error(DomainError.Validation(DomainErrorCodes.ValidationFailed, $"{field} must be > 0", Map.empty)) | |
| else | |
| Ok value | |
| namespace Medhavi.Domain.Constraints | |
| open System.Text.Json.Serialization | |
| open System | |
| open Medhavi.Domain.Ids | |
| open Medhavi.Domain | |
| [<JsonFSharpConverter>] | |
| type ConstraintScope = | |
| | Material | |
| | Capacity | |
| | Routing | |
| | Campaign | |
| | DataQuality | |
| | Scheduling | |
| | Promise | |
| [<JsonFSharpConverter>] | |
| type ConstraintSeverity = | |
| | Problem | |
| | Warning | |
| | Info | |
| [<JsonFSharpConverter>] | |
| type ConstraintCode = | |
| | MissingTransportLane | |
| | OverCapacity | |
| | PlanningLate | |
| | CampaignViolation | |
| | ResourceEligibilityOverride | |
| | RoutingMismatch | |
| | DataQualityIssue | |
| | ToleranceBreach | |
| | MissingCalendar | |
| | Other of string | |
| [<JsonFSharpConverter>] | |
| type PolicyDecision = | |
| | Allow | |
| | AllowWithViolation of ConstraintCode * ConstraintSeverity | |
| | Reject of ConstraintCode * ConstraintSeverity | |
| [<JsonFSharpConverter>] | |
| type ConstraintActor = | |
| | User of string | |
| | Optimizer | |
| | Integration of string | |
| | System | |
| type ConstraintContext = | |
| { | |
| Actor: ConstraintActor | |
| SourceAggregateType: string | |
| SourceAggregateId: string | |
| Code: ConstraintCode option | |
| Scope: ConstraintScope option | |
| Data: Map<string, string> | |
| } | |
| /// Generic policy contract to be applied by all actors (UI, optimizer, integration, system). | |
| type ConstraintPolicy = ConstraintContext -> PolicyDecision | |
| type ConstraintStatus = | |
| | Active | |
| | Resolved | |
| type ConstraintViolation = | |
| { | |
| Id: ConstraintViolationId | |
| SourceAggregateType: string | |
| SourceAggregateId: string | |
| Code: ConstraintCode | |
| Severity: ConstraintSeverity | |
| Scope: ConstraintScope | |
| Message: string | |
| ObservedAt: DateTimeOffset | |
| ResolvedAt: DateTimeOffset option | |
| ResolvedBy: string option | |
| Status: ConstraintStatus | |
| Data: Map<string, string> | |
| } | |
| // Commands | |
| type RecordConstraintViolationCmd = | |
| { | |
| Id: string | |
| SourceAggregateType: string | |
| SourceAggregateId: string | |
| Code: ConstraintCode | |
| Severity: ConstraintSeverity | |
| Scope: ConstraintScope | |
| Message: string | |
| ObservedAt: DateTimeOffset | |
| Data: Map<string, string> | |
| } | |
| type ResolveConstraintViolationCmd = | |
| { | |
| Id: ConstraintViolationId | |
| ResolvedAt: DateTimeOffset | |
| ResolvedBy: string option | |
| } | |
| // Events | |
| type ConstraintViolationRecordedEvt = | |
| { | |
| Id: ConstraintViolationId | |
| SourceAggregateType: string | |
| SourceAggregateId: string | |
| Code: ConstraintCode | |
| Severity: ConstraintSeverity | |
| Scope: ConstraintScope | |
| Message: string | |
| ObservedAt: DateTimeOffset | |
| Data: Map<string, string> | |
| } | |
| type ConstraintViolationResolvedEvt = | |
| { | |
| Id: ConstraintViolationId | |
| ResolvedAt: DateTimeOffset | |
| ResolvedBy: string option | |
| } | |
| type ConstraintViolationEvent = | |
| | ConstraintViolationRecorded of ConstraintViolationRecordedEvt | |
| | ConstraintViolationResolved of ConstraintViolationResolvedEvt | |
| type ConstraintViolationCommand = | |
| | RecordConstraintViolation of RecordConstraintViolationCmd | |
| | ResolveConstraintViolation of ResolveConstraintViolationCmd | |
| // Signatures | |
| type DecideConstraintViolation = | |
| ConstraintViolation option -> ConstraintViolationCommand -> Result<ConstraintViolationEvent list, DomainError> | |
| type EvolveConstraintViolation = ConstraintViolation option -> ConstraintViolationEvent -> ConstraintViolation | |
| module Medhavi.Domain.CostFx | |
| open System | |
| /// Cost breakdown for promise/plan decisions. | |
| type CostBreakdown = | |
| { | |
| Material: decimal option | |
| Production: decimal option | |
| Transport: decimal option | |
| Holding: decimal option | |
| Other: decimal option | |
| } | |
| /// FX rate result with as-of timestamp. | |
| type FxRate = | |
| { | |
| From: string | |
| To: string | |
| Rate: decimal | |
| AsOf: DateTimeOffset | |
| } | |
| let normalizeCost (fx: FxRate option) (amount: decimal option) = | |
| match fx, amount with | |
| | None, a -> a | |
| | Some r, Some a -> Some(a * r.Rate) | |
| | _, None -> None | |
| /// Guard against stale rates (returns None if stale beyond maxAge). | |
| let guardStale (maxAge: TimeSpan) (rate: FxRate option) = | |
| match rate with | |
| | None -> None | |
| | Some r when DateTimeOffset.UtcNow - r.AsOf > maxAge -> None | |
| | Some r -> Some r | |
| /// Apply FX normalization with stale-rate guard; returns None if no valid rate. | |
| let applyFx (maxAge: TimeSpan) (fxRate: FxRate option) (amount: decimal option) = | |
| amount |> normalizeCost (guardStale maxAge fxRate) | |
| namespace Medhavi.Domain | |
| open System | |
| open System.Text.Json.Serialization | |
| [<JsonFSharpConverter>] | |
| type Money = { Amount: decimal; Currency: string } | |
| [<JsonFSharpConverter>] | |
| type Quantity = | |
| private | |
| | Quantity of decimal | |
| // Required for Seq.sum, List.sum, etc. | |
| static member Zero = Quantity 0m | |
| static member (+)(Quantity a, Quantity b) = Quantity(a + b) | |
| static member (-)(Quantity a, Quantity b) = Quantity(a - b) | |
| static member (*)(Quantity v, scalar: decimal) = Quantity(v * scalar) | |
| static member (*)(scalar: decimal, Quantity v) = Quantity(scalar * v) | |
| static member (/)(Quantity v, scalar: decimal) = Quantity(v / scalar) | |
| // Comparison operators | |
| static member op_LessThan(Quantity a, Quantity b) = a < b | |
| static member op_LessThanOrEqual(Quantity a, Quantity b) = a <= b | |
| static member op_GreaterThan(Quantity a, Quantity b) = a > b | |
| static member op_GreaterThanOrEqual(Quantity a, Quantity b) = a >= b | |
| module Quantity = | |
| let create (value: decimal) : Result<Quantity, DomainError> = | |
| if value < 0m then | |
| Error(DomainError.Validation(DomainErrorCodes.ValidationFailed, "Value must be non-negative", Map.empty)) | |
| else | |
| Ok(Quantity value) | |
| let value (Quantity v) = v | |
| let zero = Quantity 0m | |
| // Utility functions | |
| let isZero (Quantity v) = v = 0m | |
| let isPositive (Quantity v) = v > 0m | |
| let minOf (Quantity a) (Quantity b) = Quantity(min a b) | |
| let maxOf (Quantity a) (Quantity b) = Quantity(max a b) | |
| /// Safe subtraction - clamps to zero if result would be negative | |
| let subtract (Quantity a) (Quantity b) = Quantity(max 0m (a - b)) | |
| /// Try subtract - returns Error if result would be negative | |
| let trySubtract (Quantity a) (Quantity b) : Result<Quantity, DomainError> = | |
| if a >= b then | |
| Ok(Quantity(a - b)) | |
| else | |
| Error( | |
| DomainError.Validation( | |
| DomainErrorCodes.ValidationFailed, | |
| "Subtraction would result in negative quantity", | |
| Map.empty | |
| ) | |
| ) | |
| /// Ratio between two quantities (a / b) | |
| let ratio (Quantity a) (Quantity b) : decimal = a / b | |
| /// Scale by a factor | |
| let scale (factor: decimal) (Quantity v) = Quantity(v * factor) | |
| /// Sum a sequence of quantities | |
| let sum (quantities: Quantity seq) = quantities |> Seq.fold (+) zero | |
| /// Positive decimal (>= 0m) | |
| [<Struct>] | |
| type PositiveDecimal = | |
| private | |
| | PositiveDecimal of decimal | |
| static member create(value: decimal) = | |
| if value < 0m then | |
| Error(DomainError.Validation(DomainErrorCodes.ValidationFailed, "Value must be non-negative", Map.empty)) | |
| else | |
| Ok(PositiveDecimal value) | |
| static member value(PositiveDecimal v) = v | |
| /// Required by SRTP (List.sum / sumBy) | |
| static member Zero = PositiveDecimal 0m | |
| /// Arithmetic | |
| static member (+)(PositiveDecimal a, PositiveDecimal b) = PositiveDecimal(a + b) | |
| static member (-)(PositiveDecimal a, PositiveDecimal b) = | |
| if a - b < 0m then | |
| failwith "PositiveDecimal subtraction underflow" | |
| else | |
| PositiveDecimal(a - b) | |
| static member (*)(PositiveDecimal v, scalar: decimal) = PositiveDecimal(v * scalar) | |
| static member (*)(scalar: decimal, PositiveDecimal v) = PositiveDecimal(scalar * v) | |
| static member (/)(PositiveDecimal v, scalar: decimal) = PositiveDecimal(v / scalar) | |
| // Comparison operators | |
| static member op_LessThan(PositiveDecimal a, PositiveDecimal b) = a < b | |
| static member op_LessThanOrEqual(PositiveDecimal a, PositiveDecimal b) = a <= b | |
| static member op_GreaterThan(PositiveDecimal a, PositiveDecimal b) = a > b | |
| static member op_GreaterThanOrEqual(PositiveDecimal a, PositiveDecimal b) = a >= b | |
| static member op_Equality(PositiveDecimal a, PositiveDecimal b) = a = b | |
| static member op_Inequality(PositiveDecimal a, PositiveDecimal b) = a <> b | |
| static member op_Multiply(PositiveDecimal a, PositiveDecimal b) = PositiveDecimal(a * b) | |
| /// Percent in the range [0.0, 1.0] | |
| type Percent = private | Percent of decimal | |
| module Percent = | |
| let create (value: decimal) = | |
| if value < 0m || value > 1m then | |
| Error( | |
| DomainError.Validation( | |
| DomainErrorCodes.ValidationFailed, | |
| "Percent must be between 0.0 and 1.0", | |
| Map.empty | |
| ) | |
| ) | |
| else | |
| Ok(Percent value) | |
| let value (Percent v) = v | |
| type Window = | |
| { | |
| Start: DateTimeOffset | |
| End: DateTimeOffset | |
| } | |
| module Window = | |
| let overlaps (a: Window) (b: Window) = a.Start < b.End && b.Start < a.End | |
| let contains (outer: Window) (inner: Window) = | |
| outer.Start <= inner.Start | |
| && outer.End >= inner.End | |
| let applySlack (slack: TimeSpan) (w: Window) = { Start = w.Start; End = w.End + slack } | |
| let isAfter (t: DateTimeOffset) (w: Window) = t <= w.Start | |
| /// Validate cutoff: departure must be >= earliest and before cutoff end. | |
| let meetsCutoff (earliest: DateTimeOffset) (cutoffEnd: DateTimeOffset) (departure: DateTimeOffset) = | |
| departure >= earliest && departure <= cutoffEnd | |
| let start (window: Window) = window.Start | |
| let endTime (window: Window) = window.End | |
| let duration (window: Window) = window.End - window.Start | |
| /// Shared decide/evolve function signatures for event-sourced aggregates. | |
| type Decide<'state, 'cmd, 'evt, 'err> = 'state option -> 'cmd -> Result<'evt list, 'err> | |
| type Evolve<'state, 'evt> = 'state option -> 'evt -> 'state option | |
| namespace Medhavi.Domain | |
| open System | |
| open System.Text.Json.Serialization | |
| /// Domain error codes for programmatic error handling | |
| module DomainErrorCodes = | |
| let ValidationFailed = "DOMAIN_VALIDATION_FAILED" | |
| let NotFound = "DOMAIN_NOT_FOUND" | |
| let Conflict = "DOMAIN_CONFLICT" | |
| let InvariantViolation = "DOMAIN_INVARIANT_VIOLATION" | |
| [<JsonFSharpConverter>] | |
| type DomainError = | |
| | Validation of code: string * message: string * data: Map<string, obj> | |
| | NotFound of code: string * message: string * data: Map<string, obj> | |
| | Conflict of code: string * message: string * data: Map<string, obj> // e.g., code uniqueness, base unit already exists | |
| | Invariant of code: string * message: string * data: Map<string, obj> // e.g., invariants violated | |
| /// Human-readable error message | |
| member this.Message = | |
| match this with | |
| | Validation(_, msg, _) | |
| | NotFound(_, msg, _) | |
| | Conflict(_, msg, _) | |
| | Invariant(_, msg, _) -> msg | |
| /// Machine-readable error code | |
| member this.Code = | |
| match this with | |
| | Validation(code, _, _) | |
| | NotFound(code, _, _) | |
| | Conflict(code, _, _) | |
| | Invariant(code, _, _) -> code | |
| /// Optional contextual data for debugging and logging | |
| member this.Data = | |
| match this with | |
| | Validation(_, _, data) | |
| | NotFound(_, _, data) | |
| | Conflict(_, _, data) | |
| | Invariant(_, _, data) -> data | |
| /// Create a validation error | |
| static member validation message = Validation(DomainErrorCodes.ValidationFailed, message, Map.empty) | |
| /// Create a validation error with contextual data | |
| static member validationWith message (data: Map<string, obj>) = | |
| Validation(DomainErrorCodes.ValidationFailed, message, data) | |
| /// Create a not found error | |
| static member notFound message = NotFound(DomainErrorCodes.NotFound, message, Map.empty) | |
| /// Create a not found error with contextual data | |
| static member notFoundWith message (data: Map<string, obj>) = NotFound(DomainErrorCodes.NotFound, message, data) | |
| /// Create a conflict error | |
| static member conflict message = Conflict(DomainErrorCodes.Conflict, message, Map.empty) | |
| /// Create a conflict error with contextual data | |
| static member conflictWith message (data: Map<string, obj>) = Conflict(DomainErrorCodes.Conflict, message, data) | |
| /// Create an invariant violation error | |
| static member invariant message = Invariant(DomainErrorCodes.InvariantViolation, message, Map.empty) | |
| /// Create an invariant violation error with contextual data | |
| static member invariantWith message (data: Map<string, obj>) = | |
| Invariant(DomainErrorCodes.InvariantViolation, message, data) | |
| /// Active patterns and utilities for DomainError | |
| [<RequireQualifiedAccess>] | |
| module DomainError = | |
| /// Pattern match on validation errors | |
| let (|Validation|_|) = | |
| function | |
| | Validation(_, msg, _) -> Some msg | |
| | _ -> None | |
| /// Pattern match on not found errors | |
| let (|NotFound|_|) = | |
| function | |
| | NotFound(_, msg, _) -> Some msg | |
| | _ -> None | |
| /// Pattern match on conflict errors | |
| let (|Conflict|_|) = | |
| function | |
| | Conflict(_, msg, _) -> Some msg | |
| | _ -> None | |
| /// Pattern match on invariant errors | |
| let (|Invariant|_|) = | |
| function | |
| | Invariant(_, msg, _) -> Some msg | |
| | _ -> None | |
| (* | |
| Examples of using DomainError: | |
| // 1. Creating errors using static factory methods | |
| let validationError = DomainError.validation "ProductId cannot be empty" | |
| let notFoundError = DomainError.notFound "Product not found" | |
| let conflictError = DomainError.conflict "Product code already exists" | |
| let invariantError = DomainError.invariant "Cannot cancel a completed order" | |
| // 2. Creating errors with contextual data for debugging | |
| let validationWithData = | |
| DomainError.validationWith | |
| "Quantity must be positive" | |
| (Map.ofList [ ("Field", box "Quantity"); ("Value", box -5) ]) | |
| let notFoundWithData = | |
| DomainError.notFoundWith | |
| "Product not found" | |
| (Map.ofList [ ("ProductId", box "PROD-123"); ("StockingPointId", box "SP-456") ]) | |
| // 3. Pattern matching using active patterns | |
| let handleError (error: DomainError) = | |
| match error with | |
| | DomainError.Validation msg -> printfn "Validation error: %s" msg | |
| | DomainError.NotFound msg -> printfn "Not found: %s" msg | |
| | DomainError.Conflict msg -> printfn "Conflict: %s" msg | |
| | DomainError.Invariant msg -> printfn "Invariant violation: %s" msg | |
| // 4. Pattern matching with error code and data extraction | |
| let handleErrorWithDetails (error: DomainError) = | |
| match error with | |
| | DomainError.Validation msg -> | |
| printfn "Validation error [%s]: %s" error.Code msg | |
| printfn "Context: %A" error.Data | |
| | DomainError.NotFound msg -> | |
| printfn "Not found [%s]: %s" error.Code msg | |
| printfn "Context: %A" error.Data | |
| | DomainError.Conflict msg -> | |
| printfn "Conflict [%s]: %s" error.Code msg | |
| printfn "Context: %A" error.Data | |
| | DomainError.Invariant msg -> | |
| printfn "Invariant violation [%s]: %s" error.Code msg | |
| printfn "Context: %A" error.Data | |
| // 5. Using in Result types | |
| let validateProductId (productId: string) : Result<string, DomainError> = | |
| if System.String.IsNullOrWhiteSpace(productId) then | |
| Error(DomainError.validation "ProductId cannot be empty") | |
| else | |
| Ok productId | |
| let findProduct (productId: string) : Result<Product, DomainError> = | |
| match tryFindProduct productId with | |
| | Some product -> Ok product | |
| | None -> | |
| Error( | |
| DomainError.notFoundWith | |
| $"Product {productId} not found" | |
| (Map.ofList [ ("ProductId", box productId) ]) | |
| ) | |
| // 6. Pattern matching in Result handling | |
| let processResult (result: Result<Product, DomainError>) = | |
| match result with | |
| | Ok product -> printfn "Success: %A" product | |
| | Error(DomainError.Validation msg) -> printfn "Validation failed: %s" msg | |
| | Error(DomainError.NotFound msg) -> printfn "Not found: %s" msg | |
| | Error(DomainError.Conflict msg) -> printfn "Conflict: %s" msg | |
| | Error(DomainError.Invariant msg) -> printfn "Invariant violation: %s" msg | |
| // 7. Error code-based handling (programmatic) | |
| let handleByCode (error: DomainError) = | |
| match error.Code with | |
| | DomainErrorCodes.ValidationFailed -> "Handle validation error" | |
| | DomainErrorCodes.NotFound -> "Handle not found error" | |
| | DomainErrorCodes.Conflict -> "Handle conflict error" | |
| | DomainErrorCodes.InvariantViolation -> "Handle invariant violation" | |
| | _ -> "Unknown error" | |
| // 8. Extracting error information | |
| let errorInfo (error: DomainError) = | |
| { | |
| Code = error.Code | |
| Message = error.Message | |
| Data = error.Data | |
| } | |
| // 9. Combining multiple validation errors (using Result.mapError) | |
| let validateOrder (order: Order) : Result<Order, DomainError> = | |
| validateProductId order.ProductId | |
| |> Result.bind (fun _ -> validateQuantity order.Quantity) | |
| |> Result.mapError (fun err -> | |
| match err with | |
| | DomainError.Validation msg -> DomainError.validationWith msg (Map.ofList [ ("OrderId", box order.Id) ]) | |
| | _ -> err | |
| ) | |
| // 10. Pattern matching in async/asyncResult workflows | |
| let asyncProcessOrder (orderId: string) = | |
| async { | |
| match! findOrder orderId with | |
| | Ok order -> return Ok order | |
| | Error(DomainError.NotFound msg) -> | |
| return Error(DomainError.notFoundWith $"Order {orderId} not found" (Map.ofList [ ("OrderId", box orderId) ])) | |
| | Error err -> return Error err | |
| } | |
| *) | |
| module Medhavi.Domain.IdsFactory | |
| open System | |
| open System.Security.Cryptography | |
| open System.Text | |
| open Medhavi.Domain | |
| open Medhavi.Domain.Ids | |
| /// External system identifier (e.g., from ERP, MES, etc.) | |
| type ExternalSystemId = | |
| { | |
| SystemName: string // e.g., "ERP", "MES", "SAP" | |
| ExternalId: string // The ID from the external system | |
| } | |
| /// ID generation strategy | |
| type IdGenerationStrategy = | |
| | Deterministic of ExternalSystemId // Use external system ID to generate deterministic internal ID | |
| | Random // Generate random ID (for internal-only entities) | |
| | Explicit of string // Use explicitly provided ID | |
| /// Common ID factory for creating deterministic IDs from external system identifiers | |
| /// This ensures idempotent ingestion: same external ID always produces same internal ID | |
| /// Normalize external system name (lowercase, trimmed) | |
| let private normalizeSystemName (name: string) : string = name.Trim().ToLowerInvariant().Replace(" ", "_") | |
| /// Normalize external ID (trimmed, but preserve case for uniqueness) | |
| let private normalizeExternalId (id: string) : string = id.Trim() | |
| /// Create a deterministic internal ID from external system identifier | |
| /// Uses SHA256 hash to ensure same external ID always produces same internal ID | |
| /// Format: "{systemName}:{hash}" where hash is first 16 chars of SHA256 | |
| let createDeterministicId (externalSystemId: ExternalSystemId) (aggregateType: string) : string = | |
| let normalizedSystem = normalizeSystemName externalSystemId.SystemName | |
| let normalizedExternalId = normalizeExternalId externalSystemId.ExternalId | |
| let combined = $"{normalizedSystem}:{aggregateType}:{normalizedExternalId}" | |
| // Use SHA256 to create deterministic hash | |
| use sha256 = SHA256.Create() | |
| let hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(combined)) | |
| let hashString = | |
| BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant() | |
| // Use first 16 characters of hash + last 4 of original ID for readability | |
| let shortHash = hashString.Substring(0, 16) | |
| let idSuffix = | |
| if normalizedExternalId.Length >= 4 then | |
| normalizedExternalId.Substring(Math.Max(0, normalizedExternalId.Length - 4)) | |
| else | |
| normalizedExternalId | |
| $"{normalizedSystem}-{aggregateType}-{shortHash}-{idSuffix}" | |
| /// Create deterministic ProductId from external system identifier | |
| let createProductId (strategy: IdGenerationStrategy) : Result<ProductId, DomainError> = | |
| match strategy with | |
| | Deterministic extId -> | |
| let deterministicId = createDeterministicId extId "product" | |
| ProductId.create deterministicId | |
| | Random -> | |
| let randomId = Guid.NewGuid().ToString("N") | |
| ProductId.create randomId | |
| | Explicit id -> ProductId.create id | |
| /// Create deterministic SupplyOrderId from external system identifier | |
| let createSupplyOrderId (strategy: IdGenerationStrategy) : Result<SupplyOrderId, DomainError> = | |
| match strategy with | |
| | Deterministic extId -> | |
| let deterministicId = createDeterministicId extId "supplyorder" | |
| SupplyOrderId.create deterministicId | |
| | Random -> | |
| let randomId = Guid.NewGuid().ToString("N") | |
| SupplyOrderId.create randomId | |
| | Explicit id -> SupplyOrderId.create id | |
| /// Create deterministic CustomerOrderId from external system identifier | |
| let createCustomerOrderId (strategy: IdGenerationStrategy) : Result<CustomerOrderId, DomainError> = | |
| match strategy with | |
| | Deterministic extId -> | |
| let deterministicId = createDeterministicId extId "customerorder" | |
| CustomerOrderId.create deterministicId | |
| | Random -> | |
| let randomId = Guid.NewGuid().ToString("N") | |
| CustomerOrderId.create randomId | |
| | Explicit id -> CustomerOrderId.create id | |
| /// Create deterministic StockingPointId from external system identifier | |
| let createStockingPointId (strategy: IdGenerationStrategy) : Result<StockingPointId, DomainError> = | |
| match strategy with | |
| | Deterministic extId -> | |
| let deterministicId = createDeterministicId extId "stockingpoint" | |
| StockingPointId.create deterministicId | |
| | Random -> | |
| let randomId = Guid.NewGuid().ToString("N") | |
| StockingPointId.create randomId | |
| | Explicit id -> StockingPointId.create id | |
| /// Create deterministic RoutingId from external system identifier | |
| let createRoutingId (strategy: IdGenerationStrategy) : Result<RoutingId, DomainError> = | |
| match strategy with | |
| | Deterministic extId -> | |
| let deterministicId = createDeterministicId extId "routing" | |
| RoutingId.create deterministicId | |
| | Random -> | |
| let randomId = Guid.NewGuid().ToString("N") | |
| RoutingId.create randomId | |
| | Explicit id -> RoutingId.create id | |
| namespace Medhavi.Domain.Ids | |
| open System | |
| open System.Text.Json.Serialization | |
| open System.Security.Cryptography | |
| open System.Text | |
| open Medhavi.Domain | |
| /// Generic helper to validate non-empty, trimmed identifiers. | |
| module private IdHelpers = | |
| let createId (ctor: string -> 'id) (name: string) (value: string) = | |
| if String.IsNullOrWhiteSpace value then | |
| Error(DomainError.Validation(DomainErrorCodes.ValidationFailed, $"{name} must not be empty", Map.empty)) | |
| else | |
| Ok(ctor value) | |
| /// Deterministic ID helpers for idempotency (pure, side-effect free). | |
| module DeterministicIds = | |
| let private hashParts (parts: string list) = | |
| use sha = SHA256.Create() | |
| let payload = String.concat "|" parts |> Encoding.UTF8.GetBytes | |
| let bytes = sha.ComputeHash payload | |
| bytes | |
| |> Array.take 12 | |
| |> Array.fold (fun acc b -> acc + b.ToString("x2")) "" | |
| /// Build a deterministic ID for reservations (material/capacity/transport) given a natural key. | |
| let reservationId (scope: string) (reference: string) (windowStart: DateTimeOffset) (windowEnd: DateTimeOffset) = | |
| hashParts [ "resv"; scope; reference; windowStart.ToString("O"); windowEnd.ToString("O") ] | |
| /// Deterministic itinerary/transport option ID (origin/destination + legs signature). | |
| let itineraryId (origin: string) (destination: string) (legsSignature: string) = | |
| hashParts [ "itin"; origin; destination; legsSignature ] | |
| /// Deterministic capacity allocation/assignment ID. | |
| let allocationId (resourceRef: string) (windowStart: DateTimeOffset) (windowEnd: DateTimeOffset) = | |
| hashParts [ "alloc"; resourceRef; windowStart.ToString("O"); windowEnd.ToString("O") ] | |
| /// Deterministic peg (demand↔supply) ID. | |
| let pegId (demandId: string) (supplyId: string) = hashParts [ "peg"; demandId; supplyId ] | |
| /// Deterministic proposal/recommendation ID (e.g., supply order proposals). | |
| let proposalId (proposalType: string) (anchorId: string) (timestamp: DateTimeOffset) = | |
| hashParts [ "proposal"; proposalType; anchorId; timestamp.ToString("O") ] | |
| /// Plant identifier | |
| [<JsonFSharpConverter>] | |
| type PlantId = private | PlantId of string | |
| module PlantId = | |
| let create = IdHelpers.createId PlantId "PlantId" | |
| let value (PlantId v) = v | |
| /// Stocking point identifier | |
| [<JsonFSharpConverter>] | |
| type StockingPointId = private | StockingPointId of string | |
| module StockingPointId = | |
| let create = IdHelpers.createId StockingPointId "StockingPointId" | |
| let value (StockingPointId v) = v | |
| /// Product identifier | |
| [<JsonFSharpConverter>] | |
| type ProductId = private | ProductId of string | |
| module ProductId = | |
| let create = IdHelpers.createId ProductId "ProductId" | |
| let value (ProductId v) = v | |
| /// Product substitution rule identifier | |
| [<JsonFSharpConverter>] | |
| type ProductSubstitutionRuleId = private | ProductSubstitutionRuleId of string | |
| module ProductSubstitutionRuleId = | |
| let create = | |
| IdHelpers.createId ProductSubstitutionRuleId "ProductSubstitutionRuleId" | |
| let value (ProductSubstitutionRuleId v) = v | |
| /// Routing identifiers | |
| [<JsonFSharpConverter>] | |
| type RoutingId = private | RoutingId of string | |
| module RoutingId = | |
| let create = IdHelpers.createId RoutingId "RoutingId" | |
| let value (RoutingId v) = v | |
| [<JsonFSharpConverter>] | |
| type RoutingStepId = private | RoutingStepId of string | |
| module RoutingStepId = | |
| let create = IdHelpers.createId RoutingStepId "RoutingStepId" | |
| let value (RoutingStepId v) = v | |
| [<JsonFSharpConverter>] | |
| type TransportLaneId = private | TransportLaneId of string | |
| module TransportLaneId = | |
| let create = IdHelpers.createId TransportLaneId "TransportLaneId" | |
| let value (TransportLaneId v) = v | |
| [<JsonFSharpConverter>] | |
| type TransportLegId = private | TransportLegId of string | |
| module TransportLegId = | |
| let create = IdHelpers.createId TransportLegId "TransportLegId" | |
| let value (TransportLegId v) = v | |
| /// Resource identifiers | |
| [<JsonFSharpConverter>] | |
| type ResourceGroupId = private | ResourceGroupId of string | |
| module ResourceGroupId = | |
| let create = IdHelpers.createId ResourceGroupId "ResourceGroupId" | |
| let value (ResourceGroupId v) = v | |
| [<JsonFSharpConverter>] | |
| type StandardResourceId = private | StandardResourceId of string | |
| module StandardResourceId = | |
| let create = IdHelpers.createId StandardResourceId "StandardResourceId" | |
| let value (StandardResourceId v) = v | |
| [<JsonFSharpConverter>] | |
| type PhysicalResourceId = private | PhysicalResourceId of string | |
| module PhysicalResourceId = | |
| let create = IdHelpers.createId PhysicalResourceId "PhysicalResourceId" | |
| let value (PhysicalResourceId v) = v | |
| [<JsonFSharpConverter>] | |
| type CombinedResourceId = private | CombinedResourceId of string | |
| module CombinedResourceId = | |
| let create = IdHelpers.createId CombinedResourceId "CombinedResourceId" | |
| let value (CombinedResourceId v) = v | |
| /// Calendar identifier | |
| [<JsonFSharpConverter>] | |
| type CalendarId = private | CalendarId of string | |
| module CalendarId = | |
| let create = IdHelpers.createId CalendarId "CalendarId" | |
| let value (CalendarId v) = v | |
| /// Resource period identifiers | |
| [<JsonFSharpConverter>] | |
| type ResourceGroupCapacityId = private | ResourceGroupCapacityId of string | |
| module ResourceGroupCapacityId = | |
| let create = IdHelpers.createId ResourceGroupCapacityId "ResourceGroupCapacityId" | |
| let value (ResourceGroupCapacityId v) = v | |
| [<JsonFSharpConverter>] | |
| type StandardResourcePeriodId = private | StandardResourcePeriodId of string | |
| module StandardResourcePeriodId = | |
| let create = IdHelpers.createId StandardResourcePeriodId "StandardResourcePeriodId" | |
| let value (StandardResourcePeriodId v) = v | |
| [<JsonFSharpConverter>] | |
| type PhysicalResourcePeriodId = private | PhysicalResourcePeriodId of string | |
| module PhysicalResourcePeriodId = | |
| let create = IdHelpers.createId PhysicalResourcePeriodId "PhysicalResourcePeriodId" | |
| let value (PhysicalResourcePeriodId v) = v | |
| [<JsonFSharpConverter>] | |
| type CombinedResourcePeriodId = private | CombinedResourcePeriodId of string | |
| module CombinedResourcePeriodId = | |
| let create = IdHelpers.createId CombinedResourcePeriodId "CombinedResourcePeriodId" | |
| let value (CombinedResourcePeriodId v) = v | |
| /// Campaign identifiers | |
| [<JsonFSharpConverter>] | |
| type CampaignTypeId = private | CampaignTypeId of string | |
| module CampaignTypeId = | |
| let create = IdHelpers.createId CampaignTypeId "CampaignTypeId" | |
| let value (CampaignTypeId v) = v | |
| [<JsonFSharpConverter>] | |
| type CampaignId = private | CampaignId of string | |
| module CampaignId = | |
| let create = IdHelpers.createId CampaignId "CampaignId" | |
| let value (CampaignId v) = v | |
| [<JsonFSharpConverter>] | |
| type CampaignTypePeriodId = private | CampaignTypePeriodId of string | |
| module CampaignTypePeriodId = | |
| let create = IdHelpers.createId CampaignTypePeriodId "CampaignTypePeriodId" | |
| let value (CampaignTypePeriodId v) = v | |
| [<JsonFSharpConverter>] | |
| type CampaignTypeAssignmentId = private | CampaignTypeAssignmentId of string | |
| module CampaignTypeAssignmentId = | |
| let create = IdHelpers.createId CampaignTypeAssignmentId "CampaignTypeAssignmentId" | |
| let value (CampaignTypeAssignmentId v) = v | |
| /// Assignment identifiers | |
| [<JsonFSharpConverter>] | |
| type PeriodAssignmentId = private | PeriodAssignmentId of string | |
| module PeriodAssignmentId = | |
| let create = IdHelpers.createId PeriodAssignmentId "PeriodAssignmentId" | |
| let value (PeriodAssignmentId v) = v | |
| [<JsonFSharpConverter>] | |
| type ResourceAssignmentId = private | ResourceAssignmentId of string | |
| module ResourceAssignmentId = | |
| let create = IdHelpers.createId ResourceAssignmentId "ResourceAssignmentId" | |
| let value (ResourceAssignmentId v) = v | |
| /// Capacity hold identifiers | |
| [<JsonFSharpConverter>] | |
| type CapacityAllocationId = private | CapacityAllocationId of string | |
| module CapacityAllocationId = | |
| let create = IdHelpers.createId CapacityAllocationId "CapacityAllocationId" | |
| let value (CapacityAllocationId v) = v | |
| [<JsonFSharpConverter>] | |
| type CapacityReservationId = private | CapacityReservationId of string | |
| module CapacityReservationId = | |
| let create = IdHelpers.createId CapacityReservationId "CapacityReservationId" | |
| let value (CapacityReservationId v) = v | |
| /// Order identifiers | |
| [<JsonFSharpConverter>] | |
| type CustomerOrderId = private | CustomerOrderId of string | |
| module CustomerOrderId = | |
| let create = IdHelpers.createId CustomerOrderId "CustomerOrderId" | |
| let value (CustomerOrderId v) = v | |
| /// Order line identifiers | |
| [<JsonFSharpConverter>] | |
| type OrderLineId = private | OrderLineId of string | |
| module OrderLineId = | |
| let create = IdHelpers.createId OrderLineId "OrderLineId" | |
| let value (OrderLineId v) = v | |
| /// Customer identifier | |
| [<JsonFSharpConverter>] | |
| type CustomerId = private | CustomerId of string | |
| module CustomerId = | |
| let create = IdHelpers.createId CustomerId "CustomerId" | |
| let value (CustomerId v) = v | |
| [<JsonFSharpConverter>] | |
| type SupplyOrderId = private | SupplyOrderId of string | |
| module SupplyOrderId = | |
| let create = IdHelpers.createId SupplyOrderId "SupplyOrderId" | |
| let value (SupplyOrderId v) = v | |
| /// Inventory identifier (used for projections/links) | |
| [<JsonFSharpConverter>] | |
| type InventoryId = private | InventoryId of string | |
| module InventoryId = | |
| let create = IdHelpers.createId InventoryId "InventoryId" | |
| let value (InventoryId v) = v | |
| [<JsonFSharpConverter>] | |
| type InventoryTargetId = private | InventoryTargetId of string | |
| module InventoryTargetId = | |
| let create = IdHelpers.createId InventoryTargetId "InventoryTargetId" | |
| let value (InventoryTargetId v) = v | |
| [<JsonFSharpConverter>] | |
| type PlanUnitId = private | PlanUnitId of string | |
| module PlanUnitId = | |
| let create = IdHelpers.createId PlanUnitId "PlanUnitId" | |
| let value (PlanUnitId v) = v | |
| [<JsonFSharpConverter>] | |
| type OperationId = private | OperationId of string | |
| module OperationId = | |
| let create = IdHelpers.createId OperationId "OperationId" | |
| let value (OperationId v) = v | |
| /// BOM / material identifiers | |
| [<JsonFSharpConverter>] | |
| type BomId = private | BomId of string | |
| module BomId = | |
| let create = IdHelpers.createId BomId "BomId" | |
| let value (BomId v) = v | |
| [<JsonFSharpConverter>] | |
| type MaterialRequirementId = private | MaterialRequirementId of string | |
| module MaterialRequirementId = | |
| let create = IdHelpers.createId MaterialRequirementId "MaterialRequirementId" | |
| let value (MaterialRequirementId v) = v | |
| [<JsonFSharpConverter>] | |
| type MaterialReservationId = private | MaterialReservationId of string | |
| module MaterialReservationId = | |
| let create = IdHelpers.createId MaterialReservationId "MaterialReservationId" | |
| let value (MaterialReservationId v) = v | |
| [<JsonFSharpConverter>] | |
| type PurchaseOrderRecommendationId = private | PurchaseOrderRecommendationId of string | |
| module PurchaseOrderRecommendationId = | |
| let create = | |
| IdHelpers.createId PurchaseOrderRecommendationId "PurchaseOrderRecommendationId" | |
| let value (PurchaseOrderRecommendationId v) = v | |
| //. Unit of measure identifier | |
| [<JsonFSharpConverter>] | |
| type UnitOfMeasureId = private | UnitOfMeasureId of string | |
| module UnitOfMeasureId = | |
| let create (value: string) : Result<UnitOfMeasureId, DomainError> = | |
| if String.IsNullOrWhiteSpace value then | |
| Error( | |
| DomainError.Validation(DomainErrorCodes.ValidationFailed, "UnitOfMeasureId cannot be empty", Map.empty) | |
| ) | |
| else | |
| Ok(UnitOfMeasureId value) | |
| let value (UnitOfMeasureId v) = v | |
| /// Supplier identifier | |
| [<JsonFSharpConverter>] | |
| type SupplierId = private | SupplierId of string | |
| module SupplierId = | |
| let create (value: string) : Result<SupplierId, DomainError> = | |
| if String.IsNullOrWhiteSpace value then | |
| Error(DomainError.Validation(DomainErrorCodes.ValidationFailed, "SupplierId cannot be empty", Map.empty)) | |
| else | |
| Ok(SupplierId value) | |
| let value (SupplierId v) = v | |
| [<JsonFSharpConverter>] | |
| type SupplierOfferId = private | SupplierOfferId of string | |
| module SupplierOfferId = | |
| let create = IdHelpers.createId SupplierOfferId "SupplierOfferId" | |
| let value (SupplierOfferId v) = v | |
| /// Unit conversion identifier | |
| [<JsonFSharpConverter>] | |
| type UnitConversionId = private | UnitConversionId of string | |
| module UnitConversionId = | |
| let create = IdHelpers.createId UnitConversionId "UnitConversionId" | |
| let value (UnitConversionId v) = v | |
| /// Optimization identifiers | |
| [<JsonFSharpConverter>] | |
| type CombiId = private | CombiId of string | |
| module CombiId = | |
| let create = IdHelpers.createId CombiId "CombiId" | |
| let value (CombiId v) = v | |
| [<JsonFSharpConverter>] | |
| type LeadTimeCalculationId = private | LeadTimeCalculationId of string | |
| module LeadTimeCalculationId = | |
| let create = IdHelpers.createId LeadTimeCalculationId "LeadTimeCalculationId" | |
| let value (LeadTimeCalculationId v) = v | |
| /// Constraint violation id | |
| [<JsonFSharpConverter>] | |
| type ConstraintViolationId = private | ConstraintViolationId of string | |
| module ConstraintViolationId = | |
| let create = IdHelpers.createId ConstraintViolationId "ConstraintViolationId" | |
| let value (ConstraintViolationId v) = v | |
| namespace Medhavi.Domain.TelemetryContracts | |
| open System | |
| /// Standard telemetry contracts for providers/orchestrator. | |
| type KpiEvent = | |
| | PromiseEvaluated of promiseId: string * decision: string * limiter: string option * latencyMs: int | |
| | ProviderLatency of name: string * latencyMs: int | |
| | CacheHitMiss of cache: string * hit: bool | |
| | ProviderError of name: string * error: string | |
| | AtRiskDetected of promiseId: string * reason: string | |
| | CapacityUtilization of resource: string * usedMs: int * availableMs: int | |
| | CapacityAllocationLatency of step: string * latencyMs: int | |
| | CapacityBottleneckDetected of resource: string * step: string | |
| | CapacityChurn of cycleId: string * allocationsChanged: int | |
| | CapacityLockAdherence of resource: string * reservedMs: int * usedMs: int | |
| type TelemetryProvider = | |
| { | |
| RecordKpi: KpiEvent -> unit | |
| RecordError: exn * string -> unit | |
| RecordLatency: string * TimeSpan -> unit | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment