diff --git a/src/PowderCoating.Application/Constants/ColumbiaIntegrationConstants.cs b/src/PowderCoating.Application/Constants/ColumbiaIntegrationConstants.cs
new file mode 100644
index 0000000..a3445b7
--- /dev/null
+++ b/src/PowderCoating.Application/Constants/ColumbiaIntegrationConstants.cs
@@ -0,0 +1,46 @@
+namespace PowderCoating.Application.Constants;
+
+///
+/// Central constants for the Columbia Coatings catalog integration — config keys, platform-setting
+/// keys, and the derived provenance/manufacturer/category values the mapper assigns. Kept in one
+/// place so the API client, sync mapper, scheduled job, and purge logic all agree on the strings.
+///
+public static class ColumbiaIntegrationConstants
+{
+ // ── Configuration keys (appsettings.json / environment) ───────────────
+ /// API key — secret, lives in config not the platform-settings DB.
+ public const string ConfigApiKey = "Columbia:ApiKey";
+ public const string ConfigBaseUrl = "Columbia:BaseUrl";
+
+ /// Configurable API namespace/base path, so an API version bump is a config change.
+ public const string ConfigApiBasePath = "Columbia:ApiBasePath";
+
+ public const string DefaultBaseUrl = "https://columbiacoatings.com";
+ public const string DefaultApiBasePath = "/wp-json/cca/v1";
+
+ /// Resource segment appended to the API base path for product endpoints.
+ public const string ProductsResource = "/products";
+
+ /// API caps per_page at 100.
+ public const int MaxPerPage = 100;
+
+ // ── Platform setting keys (SuperAdmin-managed, non-secret) ────────────
+ public const string SettingEnabled = "ColumbiaSyncEnabled";
+ public const string SettingIntervalDays = "ColumbiaSyncIntervalDays";
+ public const string SettingLastSyncedAt = "ColumbiaLastSyncedAt";
+ public const string SettingLastResult = "ColumbiaLastSyncResult";
+
+ public const int DefaultSyncIntervalDays = 7;
+
+ // ── Provenance ────────────────────────────────────────────────────────
+ /// Stored in PowderCatalogItem.Source — the purge key for right-to-delete.
+ public const string SourceName = "Columbia Coatings API";
+
+ // ── Derived manufacturers (PowderCatalogItem.VendorName) ──────────────
+ public const string ManufacturerColumbia = "Columbia Coatings";
+ public const string ManufacturerPpg = "PPG";
+ public const string ManufacturerKp = "KP Pigments";
+
+ // ── Derived category (PowderCatalogItem.Category) ─────────────────────
+ public const string CategoryPowderAdditives = "Powder Additives";
+}
diff --git a/src/PowderCoating.Application/DTOs/Columbia/ColumbiaImageJsonConverter.cs b/src/PowderCoating.Application/DTOs/Columbia/ColumbiaImageJsonConverter.cs
new file mode 100644
index 0000000..1ee746d
--- /dev/null
+++ b/src/PowderCoating.Application/DTOs/Columbia/ColumbiaImageJsonConverter.cs
@@ -0,0 +1,49 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace PowderCoating.Application.DTOs.Columbia;
+
+///
+/// Tolerant converter for Columbia image fields. WordPress/WooCommerce returns an object
+/// ({id,src,name,alt}) when an image is present, but an empty array ([]) — or
+/// sometimes false/empty string — when it is not. A single
+/// can't bind to those non-object forms, so this converter reads the object when present and
+/// yields null for anything else (consuming the token so deserialization continues).
+///
+public class ColumbiaImageJsonConverter : JsonConverter
+{
+ public override ColumbiaImage? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ switch (reader.TokenType)
+ {
+ case JsonTokenType.StartObject:
+ using (var doc = JsonDocument.ParseValue(ref reader))
+ {
+ var el = doc.RootElement;
+ return new ColumbiaImage
+ {
+ Id = el.TryGetProperty("id", out var id) && id.TryGetInt32(out var i) ? i : 0,
+ Src = GetString(el, "src"),
+ Name = GetString(el, "name"),
+ Alt = GetString(el, "alt"),
+ };
+ }
+
+ case JsonTokenType.StartArray:
+ reader.Skip(); // empty/non-empty array form means "no image"
+ return null;
+
+ default:
+ // Primitive (false / "" / null / number): nothing to consume further.
+ return null;
+ }
+ }
+
+ public override void Write(Utf8JsonWriter writer, ColumbiaImage? value, JsonSerializerOptions options)
+ => throw new NotSupportedException("Columbia image fields are read-only.");
+
+ private static string GetString(JsonElement el, string property) =>
+ el.TryGetProperty(property, out var v) && v.ValueKind == JsonValueKind.String
+ ? v.GetString() ?? string.Empty
+ : string.Empty;
+}
diff --git a/src/PowderCoating.Application/DTOs/Columbia/ColumbiaProductDtos.cs b/src/PowderCoating.Application/DTOs/Columbia/ColumbiaProductDtos.cs
new file mode 100644
index 0000000..7612e72
--- /dev/null
+++ b/src/PowderCoating.Application/DTOs/Columbia/ColumbiaProductDtos.cs
@@ -0,0 +1,142 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace PowderCoating.Application.DTOs.Columbia;
+
+///
+/// One page of the Columbia Coatings GET /products response: a list of products plus
+/// pagination metadata. Property names are snake_case in the API and bound via the snake-case
+/// naming policy configured on the client's .
+///
+public class ColumbiaProductsResponse
+{
+ public List Items { get; set; } = new();
+ public ColumbiaPagination? Pagination { get; set; }
+}
+
+/// Pagination block returned alongside a product page.
+public class ColumbiaPagination
+{
+ public int Page { get; set; }
+ public int PerPage { get; set; }
+ public int Total { get; set; }
+ public int TotalPages { get; set; }
+}
+
+///
+/// A single Columbia Coatings product as returned by the API. This mirrors the wire shape, not our
+/// catalog model — mapping into PowderCatalogItem happens in the sync mapper. Prices arrive
+/// as strings; cure/spec fields are free text; documents are direct URLs.
+///
+public class ColumbiaProduct
+{
+ public int Id { get; set; }
+
+ /// "simple" or "variable". Variable products carry packaging/size variants in
+ /// and leave as an empty array.
+ public string ProductType { get; set; } = string.Empty;
+
+ public string Name { get; set; } = string.Empty;
+ public string Slug { get; set; } = string.Empty;
+ public string Sku { get; set; } = string.Empty;
+ public string Permalink { get; set; } = string.Empty;
+ public string Status { get; set; } = string.Empty;
+
+ /// Columbia-specific values seen include "In Stock"/"instock", "formulated",
+ /// "drop_shipped", "multiple_variations", "outofstock", "onbackorder" — mixed casing.
+ public string StockStatus { get; set; } = string.Empty;
+
+ // ── Pricing (all strings on the wire) ─────────────────────────────────
+ public string Price { get; set; } = string.Empty;
+ public string RegularPrice { get; set; } = string.Empty;
+ public string SalePrice { get; set; } = string.Empty;
+
+ ///
+ /// Quantity-break pricing. POLYMORPHIC on the wire: an object
+ /// ({type, minimum_quantity, tiers:[...]}) on simple products, but an empty ARRAY
+ /// ([]) on variable products. Captured as a nullable raw so
+ /// deserialization never throws on the type mismatch and an absent value is null (not an
+ /// invalid Undefined element); the mapper interprets it.
+ ///
+ public JsonElement? TieredPricing { get; set; }
+
+ /// Per-variant pricing for variable products (e.g. Bulk vs 1 lb Bags, or gram sizes).
+ /// Each variant has its own SKU and price. Empty for simple products.
+ public List? VariationPricing { get; set; }
+
+ // ── Documents (direct URLs) ───────────────────────────────────────────
+ public string SafetyDataSheet { get; set; } = string.Empty;
+ public string TechnicalDataSheet { get; set; } = string.Empty;
+ public string ProductFlyer { get; set; } = string.Empty;
+ public string ProductBrochure { get; set; } = string.Empty;
+
+ // ── Coating spec free-text ────────────────────────────────────────────
+ public string CureSchedule { get; set; } = string.Empty;
+ public string MilThickness { get; set; } = string.Empty;
+
+ /// Resin chemistry (e.g. "Polyester/TGIC", "TGIC", "Epoxy"). NOT finish/gloss.
+ public string Type { get; set; } = string.Empty;
+
+ public string ReleaseDate { get; set; } = string.Empty;
+ public string FormulationDate { get; set; } = string.Empty;
+
+ /// Free-text reformulation log, e.g. "Formulation Change: 05/22/26".
+ public string FormulationDateChanges { get; set; } = string.Empty;
+
+ // ── Content ───────────────────────────────────────────────────────────
+ /// HTML product description (WordPress markup).
+ public string Description { get; set; } = string.Empty;
+ public string ShortDescription { get; set; } = string.Empty;
+
+ public ColumbiaImage? FeaturedImage { get; set; }
+
+ // ── Taxonomy (arrays of {name}) — used at import to derive manufacturer/category, not stored raw ──
+ public List Categories { get; set; } = new();
+ public List Tags { get; set; } = new();
+ public List PaColorGroup { get; set; } = new();
+ public List Attributes { get; set; } = new();
+}
+
+/// A pricing variant of a variable product (own SKU, own price/tiers).
+public class ColumbiaVariationPricing
+{
+ public int Id { get; set; }
+ public string Sku { get; set; } = string.Empty;
+ public List Attributes { get; set; } = new();
+ public string StockStatus { get; set; } = string.Empty;
+ public string Price { get; set; } = string.Empty;
+ public string RegularPrice { get; set; } = string.Empty;
+ public string SalePrice { get; set; } = string.Empty;
+
+ /// Same polymorphic object-or-array shape as the parent; captured raw (nullable).
+ public JsonElement? TieredPricing { get; set; }
+}
+
+/// An image object — featured or gallery.
+public class ColumbiaImage
+{
+ public int Id { get; set; }
+ public string Src { get; set; } = string.Empty;
+ public string Name { get; set; } = string.Empty;
+ public string Alt { get; set; } = string.Empty;
+}
+
+/// A simple {name} taxonomy entry (category, tag, or color group).
+public class ColumbiaNamed
+{
+ public string Name { get; set; } = string.Empty;
+}
+
+/// A product attribute with its option list, e.g. Color Group → [Blue], Packaging → [Bulk, 1 lb Bags].
+public class ColumbiaAttribute
+{
+ public string Name { get; set; } = string.Empty;
+ public List Options { get; set; } = new();
+}
+
+/// A resolved attribute value on a specific variation, e.g. Packaging → "Bulk".
+public class ColumbiaAttributeValue
+{
+ public string Name { get; set; } = string.Empty;
+ public string Value { get; set; } = string.Empty;
+}
diff --git a/src/PowderCoating.Application/DTOs/Inventory/InventoryDtos.cs b/src/PowderCoating.Application/DTOs/Inventory/InventoryDtos.cs
index 61edd7d..9804d24 100644
--- a/src/PowderCoating.Application/DTOs/Inventory/InventoryDtos.cs
+++ b/src/PowderCoating.Application/DTOs/Inventory/InventoryDtos.cs
@@ -37,6 +37,8 @@ public class InventoryItemDto
public decimal AverageCost { get; set; }
public decimal LastPurchasePrice { get; set; }
public DateTime? LastPurchaseDate { get; set; }
+ public decimal? CatalogReferencePrice { get; set; }
+ public DateTime? CatalogPriceUpdatedAt { get; set; }
public int? PrimaryVendorId { get; set; }
public string? PrimaryVendorName { get; set; }
public string? VendorPartNumber { get; set; }
diff --git a/src/PowderCoating.Application/Interfaces/IColumbiaCatalogSyncService.cs b/src/PowderCoating.Application/Interfaces/IColumbiaCatalogSyncService.cs
new file mode 100644
index 0000000..1e319bd
--- /dev/null
+++ b/src/PowderCoating.Application/Interfaces/IColumbiaCatalogSyncService.cs
@@ -0,0 +1,41 @@
+namespace PowderCoating.Application.Interfaces;
+
+///
+/// Orchestrates a full Columbia Coatings catalog sync: pull every product, map and upsert it, then
+/// (only on a complete pull) reconcile discontinuations. Used by both the scheduled background job
+/// and the manual "Sync now" admin action.
+///
+public interface IColumbiaCatalogSyncService
+{
+ ///
+ /// Runs one full sync. Assumes the caller has already decided it should run (enabled / due).
+ /// Returns a result describing the outcome; never throws for an expected failure (not
+ /// configured, partial pull, HTTP error) — those are reported on the result instead.
+ ///
+ Task RunSyncAsync(CancellationToken cancellationToken = default);
+}
+
+/// Outcome of a Columbia catalog sync run.
+public class ColumbiaSyncResult
+{
+ public bool Success { get; set; }
+ public string? ErrorMessage { get; set; }
+
+ public int TotalFetched { get; set; }
+ public int Inserted { get; set; }
+ public int Updated { get; set; }
+ public int Unchanged { get; set; }
+ public int Skipped { get; set; }
+ public int Discontinued { get; set; }
+ public int Reactivated { get; set; }
+
+ public DateTime StartedAt { get; set; }
+ public TimeSpan Duration { get; set; }
+
+ /// One-line summary suitable for storing in the last-result platform setting / UI.
+ public string Summary =>
+ Success
+ ? $"{TotalFetched} fetched: {Inserted} new, {Updated} updated, {Unchanged} unchanged, " +
+ $"{Discontinued} discontinued, {Reactivated} reactivated ({Duration.TotalSeconds:F0}s)"
+ : $"Failed: {ErrorMessage}";
+}
diff --git a/src/PowderCoating.Application/Interfaces/IColumbiaCoatingsApiClient.cs b/src/PowderCoating.Application/Interfaces/IColumbiaCoatingsApiClient.cs
new file mode 100644
index 0000000..1e6a852
--- /dev/null
+++ b/src/PowderCoating.Application/Interfaces/IColumbiaCoatingsApiClient.cs
@@ -0,0 +1,47 @@
+using PowderCoating.Application.DTOs.Columbia;
+
+namespace PowderCoating.Application.Interfaces;
+
+///
+/// Typed client for the Columbia Coatings product catalog API (/wp-json/cca/v1).
+/// Read-only: lists products via the paged GET /products endpoint.
+///
+/// We deliberately page /products rather than using the bulk export.json download:
+/// the export returns a temporary download_url to a static file under /wp-content/uploads,
+/// which sits behind Cloudflare bot protection and 403s for non-browser clients. The
+/// /wp-json API routes are allowlisted via the API key, so paging is the only path that
+/// works reliably from a server.
+///
+///
+public interface IColumbiaCoatingsApiClient
+{
+ ///
+ /// True when an API key is configured (Columbia:ApiKey). When false, callers should
+ /// skip the sync entirely rather than issue unauthenticated requests.
+ ///
+ bool IsConfigured { get; }
+
+ ///
+ /// Retrieves a single page of products. is capped at 100 by the API.
+ ///
+ Task GetProductsPageAsync(int page, int perPage, CancellationToken cancellationToken = default);
+
+ ///
+ /// Pages through the entire catalog and returns every product. Honors rate limiting
+ /// (429 / Retry-After). THROWS if any page fails after retries — callers must treat an
+ /// exception as "incomplete pull" and NOT run discontinuation logic against a partial set.
+ ///
+ Task> GetAllProductsAsync(CancellationToken cancellationToken = default);
+
+ ///
+ /// Fetches a single product by exact SKU (GET /products?sku=...), or null if not found.
+ /// For ad-hoc refresh of one record without pulling the whole catalog.
+ ///
+ Task GetProductBySkuAsync(string sku, CancellationToken cancellationToken = default);
+
+ ///
+ /// Fetches a single product by WooCommerce product ID (GET /products/{id}), or null if
+ /// not found. Useful when we already store the catalog product's ID and want to refresh it.
+ ///
+ Task GetProductByIdAsync(int id, CancellationToken cancellationToken = default);
+}
diff --git a/src/PowderCoating.Application/Interfaces/IInventoryAiLookupService.cs b/src/PowderCoating.Application/Interfaces/IInventoryAiLookupService.cs
index 6e5752a..6017d65 100644
--- a/src/PowderCoating.Application/Interfaces/IInventoryAiLookupService.cs
+++ b/src/PowderCoating.Application/Interfaces/IInventoryAiLookupService.cs
@@ -59,9 +59,17 @@ public interface IInventoryAiLookupService
Task ScanLabelAsync(string base64Image, string mediaType);
///
- /// Fetches a Technical Data Sheet URL and extracts cure temperature and cure time.
- /// Called when the main lookup found a TDS URL but cure specs are still missing.
+ /// Fetches a Technical Data Sheet URL and extracts cure temperature, cure time, and specific
+ /// gravity. Called when the main lookup found a TDS URL but specs are still missing.
/// Returns Success=false silently (no UI error) when the TDS is a PDF or unreachable.
///
Task FetchTdsCureSpecsAsync(string tdsUrl, string? colorName);
+
+ ///
+ /// Lazily fills a powder catalog item's specific gravity (and any missing cure specs) from its
+ /// TDS the first time it's needed, then derives theoretical coverage. No-op when specific
+ /// gravity is already known or no TDS URL is present. Persists the enrichment to the catalog so
+ /// it's done once and benefits every future use. Returns true if anything was filled.
+ ///
+ Task EnsureCatalogTdsSpecsAsync(PowderCoating.Core.Entities.PowderCatalogItem catalog);
}
diff --git a/src/PowderCoating.Application/Interfaces/IPowderCatalogUpsertService.cs b/src/PowderCoating.Application/Interfaces/IPowderCatalogUpsertService.cs
new file mode 100644
index 0000000..709f1d0
--- /dev/null
+++ b/src/PowderCoating.Application/Interfaces/IPowderCatalogUpsertService.cs
@@ -0,0 +1,32 @@
+using PowderCoating.Core.Entities;
+
+namespace PowderCoating.Application.Interfaces;
+
+///
+/// Shared upsert for the platform powder catalog: matches incoming records to existing rows by
+/// (VendorName, SKU), inserts new ones, and updates changed ones in place. Used by BOTH the manual
+/// JSON file import and the Columbia API sync so there is a single upsert path, only the mapping
+/// differs. Does NOT handle discontinuation — that is a sync-specific concern.
+///
+public interface IPowderCatalogUpsertService
+{
+ ///
+ /// Applies mapped catalog items. Only fields sourced from the feed
+ /// are copied on update; enrichment fields (specific gravity, coverage, transfer efficiency,
+ /// finish) are preserved so they are not wiped by a feed that never carries them. Changed and
+ /// inserted rows get stamped on LastSyncedAt/UpdatedAt.
+ ///
+ Task UpsertAsync(
+ IReadOnlyList incoming,
+ DateTime runTimestamp,
+ CancellationToken cancellationToken = default);
+}
+
+/// Counts from an upsert run.
+public class PowderCatalogUpsertResult
+{
+ public int Inserted { get; set; }
+ public int Updated { get; set; }
+ public int Unchanged { get; set; }
+ public int Skipped { get; set; }
+}
diff --git a/src/PowderCoating.Application/Services/PricingCalculationService.cs b/src/PowderCoating.Application/Services/PricingCalculationService.cs
index 8756c2a..45aebb5 100644
--- a/src/PowderCoating.Application/Services/PricingCalculationService.cs
+++ b/src/PowderCoating.Application/Services/PricingCalculationService.cs
@@ -149,9 +149,12 @@ public class PricingCalculationService : IPricingCalculationService
try
{
var inventoryItem = await _unitOfWork.InventoryItems.GetByIdAsync(coat.InventoryItemId.Value);
- if (inventoryItem != null && inventoryItem.UnitCost > 0)
+ // Prefer the current catalog price (replacement cost) so quotes reflect the latest
+ // price; fall back to the item's own cost when it isn't catalog-linked.
+ var effectiveCostPerLb = inventoryItem?.CatalogReferencePrice ?? inventoryItem?.UnitCost ?? 0m;
+ if (inventoryItem != null && effectiveCostPerLb > 0)
{
- costPerLb = inventoryItem.UnitCost;
+ costPerLb = effectiveCostPerLb;
isIncomingPowder = inventoryItem.IsIncoming;
var coverage = coat.CoverageSqFtPerLb;
var transferEfficiency = coat.TransferEfficiency;
@@ -160,8 +163,8 @@ public class PricingCalculationService : IPricingCalculationService
var actualPoundsPerSqFt = poundsPerSqFt / (transferEfficiency / 100m);
powderCostPerSqFt = actualPoundsPerSqFt * costPerLb;
- _logger.LogInformation("Coat {CoatName}: Using inventory item: {InventoryItem} (IsIncoming={IsIncoming}), UnitCost={UnitCost}/lb, Coverage={Coverage}sqft/lb, Efficiency={Efficiency}%, Calculated={CalcCost}/sqft",
- coat.CoatName, inventoryItem.Name, isIncomingPowder, inventoryItem.UnitCost, coverage, transferEfficiency, powderCostPerSqFt);
+ _logger.LogInformation("Coat {CoatName}: Using inventory item: {InventoryItem} (IsIncoming={IsIncoming}), CostPerLb={CostPerLb}/lb (catalog ref={CatalogRef}), Coverage={Coverage}sqft/lb, Efficiency={Efficiency}%, Calculated={CalcCost}/sqft",
+ coat.CoatName, inventoryItem.Name, isIncomingPowder, costPerLb, inventoryItem.CatalogReferencePrice, coverage, transferEfficiency, powderCostPerSqFt);
}
}
catch (Exception ex)
@@ -691,7 +694,8 @@ public class PricingCalculationService : IPricingCalculationService
var invItem = await _unitOfWork.InventoryItems.GetByIdAsync(c.InventoryItemId.Value);
if (invItem?.IsIncoming == true)
{
- customPowderOrderAmount += c.PowderToOrder.Value * invItem.UnitCost;
+ // Bill the powder-to-order at the current catalog price when linked.
+ customPowderOrderAmount += c.PowderToOrder.Value * (invItem.CatalogReferencePrice ?? invItem.UnitCost);
var colorName = !string.IsNullOrWhiteSpace(c.ColorName) ? c.ColorName : invItem.Name;
if (!string.IsNullOrWhiteSpace(colorName))
customPowderOrderColors.Add(colorName);
diff --git a/src/PowderCoating.Core/Entities/InventoryItem.cs b/src/PowderCoating.Core/Entities/InventoryItem.cs
index 8423b6f..459f5e4 100644
--- a/src/PowderCoating.Core/Entities/InventoryItem.cs
+++ b/src/PowderCoating.Core/Entities/InventoryItem.cs
@@ -31,6 +31,27 @@ public class InventoryItem : BaseEntity
public string? SdsUrl { get; set; } // Safety Data Sheet URL (from powder catalog or manual entry)
public string? TdsUrl { get; set; } // Technical Data Sheet URL (from powder catalog or manual entry)
+ ///
+ /// Optional link to the platform powder catalog record this item was created from.
+ /// Populated when an item is added via the catalog lookup, or back-filled by Manufacturer+SKU.
+ /// Lets the inventory detail screen surface manufacturer-level status (e.g. "discontinued by
+ /// manufacturer — cannot reorder") and future price/reformulation change flags. Nulled — not
+ /// cascaded — if the source catalog data is purged (the shop's own stock record must survive).
+ ///
+ public int? PowderCatalogItemId { get; set; }
+
+ ///
+ /// Latest list price from the linked powder catalog, refreshed by the catalog sync. This is the
+ /// QUOTING price (current replacement cost) and is kept deliberately SEPARATE from
+ /// / (the actual paid cost basis that drives
+ /// inventory valuation and COGS). Quoting prefers this when present so quotes reflect the
+ /// current price; accounting never reads it. Null for manual/non-catalog powders.
+ ///
+ public decimal? CatalogReferencePrice { get; set; }
+
+ /// Timestamp (UTC) when was last refreshed by the sync.
+ public DateTime? CatalogPriceUpdatedAt { get; set; }
+
// Sample Panel Tracking (coating category items only)
public bool HasSamplePanel { get; set; } = false;
diff --git a/src/PowderCoating.Core/Entities/PowderCatalogItem.cs b/src/PowderCoating.Core/Entities/PowderCatalogItem.cs
index 22dab0c..35cc24d 100644
--- a/src/PowderCoating.Core/Entities/PowderCatalogItem.cs
+++ b/src/PowderCoating.Core/Entities/PowderCatalogItem.cs
@@ -40,9 +40,30 @@ public class PowderCatalogItem
/// Cure hold time at cure temperature, in minutes.
public int? CureTimeMinutes { get; set; }
+ ///
+ /// Raw cure schedule text exactly as supplied by the vendor — e.g. "10 minutes @ 400°F".
+ /// Preserved verbatim because vendor formats vary wildly and some carry application notes
+ /// that don't reduce to a single temp/time pair (partial cures, clear-coat steps).
+ ///
+ public string? CureScheduleText { get; set; }
+
+ ///
+ /// All parsed cure curves as JSON — e.g. [{"tempF":400,"minutes":10},{"tempF":350,"minutes":20}].
+ /// Many powders list alternate lower-temperature curves; these matter for heat-sensitive
+ /// substrates that cannot take the standard 400°F cure, so we keep every curve, not just the
+ /// primary one in /.
+ ///
+ public string? CureCurvesJson { get; set; }
+
/// Finish type — e.g. Gloss, Matte, Satin, Metallic, Texture.
public string? Finish { get; set; }
+ /// Resin chemistry — e.g. "Polyester", "TGIC", "Epoxy", "Hybrid". Distinct from .
+ public string? ChemistryType { get; set; }
+
+ /// Recommended film build (mil thickness) as free text from the vendor — e.g. "2.0-3.0 Mils".
+ public string? MilThickness { get; set; }
+
/// Comma-separated color family tags — e.g. "Blue,Purple".
public string? ColorFamilies { get; set; }
@@ -60,6 +81,29 @@ public class PowderCatalogItem
// ── Catalog management ────────────────────────────────────────────────
+ ///
+ /// Our internal product category — e.g. "Powder Additives" for pigments/additives that are
+ /// sold by weight in grams and mixed into clear rather than sprayed as a standalone powder.
+ /// Null/empty for standard powders. Derived at import from the vendor's taxonomy, NOT stored
+ /// from their raw category list.
+ ///
+ public string? Category { get; set; }
+
+ ///
+ /// Provenance of this record — e.g. "Columbia Coatings API". Kept SEPARATE from
+ /// (which holds the derived manufacturer) so we can honor a
+ /// distributor's right-to-delete by purging every record that came from their feed,
+ /// regardless of which manufacturer made the product.
+ ///
+ public string? Source { get; set; }
+
+ ///
+ /// Reformulation history as supplied by the vendor — e.g. "Formulation Change: 05/22/26".
+ /// Not a reliable modified-date (free text, reformulations only) but a useful signal that a
+ /// product's formula — and therefore its cure specs — may have changed.
+ ///
+ public string? FormulationChanges { get; set; }
+
/// True when the vendor has discontinued this product. Kept for historical lookups.
public bool IsDiscontinued { get; set; } = false;
diff --git a/src/PowderCoating.Infrastructure/Data/ApplicationDbContext.cs b/src/PowderCoating.Infrastructure/Data/ApplicationDbContext.cs
index 538227c..b7ecd72 100644
--- a/src/PowderCoating.Infrastructure/Data/ApplicationDbContext.cs
+++ b/src/PowderCoating.Infrastructure/Data/ApplicationDbContext.cs
@@ -1511,6 +1511,9 @@ modelBuilder.Entity()
modelBuilder.Entity()
.HasIndex(i => new { i.CompanyId, i.SKU })
.IsUnique()
+ // Filter on IsDeleted so soft-deleted items don't reserve their SKU and block a new
+ // (or re-created) item from reusing it — matching the app's soft-delete semantics.
+ .HasFilter("[IsDeleted] = 0")
.HasDatabaseName("IX_InventoryItems_CompanyId_SKU");
modelBuilder.Entity()
diff --git a/src/PowderCoating.Infrastructure/Migrations/20260617142130_AddColumbiaCatalogIntegrationFields.Designer.cs b/src/PowderCoating.Infrastructure/Migrations/20260617142130_AddColumbiaCatalogIntegrationFields.Designer.cs
new file mode 100644
index 0000000..14a926e
--- /dev/null
+++ b/src/PowderCoating.Infrastructure/Migrations/20260617142130_AddColumbiaCatalogIntegrationFields.Designer.cs
@@ -0,0 +1,11375 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Metadata;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using PowderCoating.Infrastructure.Data;
+
+#nullable disable
+
+namespace PowderCoating.Infrastructure.Migrations
+{
+ [DbContext(typeof(ApplicationDbContext))]
+ [Migration("20260617142130_AddColumbiaCatalogIntegrationFields")]
+ partial class AddColumbiaCatalogIntegrationFields
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "8.0.11")
+ .HasAnnotation("Relational:MaxIdentifierLength", 128);
+
+ SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
+
+ modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("FriendlyName")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Xml")
+ .HasColumnType("nvarchar(max)");
+
+ b.HasKey("Id");
+
+ b.ToTable("DataProtectionKeys");
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Name")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.Property("NormalizedName")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedName")
+ .IsUnique()
+ .HasDatabaseName("RoleNameIndex")
+ .HasFilter("[NormalizedName] IS NOT NULL");
+
+ b.ToTable("AspNetRoles", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("ClaimType")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("ClaimValue")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("RoleId")
+ .IsRequired()
+ .HasColumnType("nvarchar(450)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("AspNetRoleClaims", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("ClaimType")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("ClaimValue")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("UserId")
+ .IsRequired()
+ .HasColumnType("nvarchar(450)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AspNetUserClaims", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b =>
+ {
+ b.Property("LoginProvider")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("ProviderKey")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("ProviderDisplayName")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("UserId")
+ .IsRequired()
+ .HasColumnType("nvarchar(450)");
+
+ b.HasKey("LoginProvider", "ProviderKey");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AspNetUserLogins", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("RoleId")
+ .HasColumnType("nvarchar(450)");
+
+ b.HasKey("UserId", "RoleId");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("AspNetUserRoles", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("LoginProvider")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("Name")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("Value")
+ .HasColumnType("nvarchar(max)");
+
+ b.HasKey("UserId", "LoginProvider", "Name");
+
+ b.ToTable("AspNetUserTokens", (string)null);
+ });
+
+ modelBuilder.Entity("PowderCoating.Core.Entities.Account", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("AccountNumber")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("AccountSubType")
+ .HasColumnType("int");
+
+ b.Property("AccountType")
+ .HasColumnType("int");
+
+ b.Property("CompanyId")
+ .HasColumnType("int");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("CreatedBy")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("CurrentBalance")
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("DeletedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("DeletedBy")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Description")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("IsActive")
+ .HasColumnType("bit");
+
+ b.Property("IsDeleted")
+ .HasColumnType("bit");
+
+ b.Property("IsSystem")
+ .HasColumnType("bit");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("OpeningBalance")
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("OpeningBalanceDate")
+ .HasColumnType("datetime2");
+
+ b.Property("ParentAccountId")
+ .HasColumnType("int");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("nvarchar(max)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ParentAccountId");
+
+ b.ToTable("Accounts");
+ });
+
+ modelBuilder.Entity("PowderCoating.Core.Entities.AiItemPrediction", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("AiTags")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("CompanyId")
+ .HasColumnType("int");
+
+ b.Property("Confidence")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("ConversationRounds")
+ .HasColumnType("int");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("CreatedBy")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("DeletedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("DeletedBy")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("IsDeleted")
+ .HasColumnType("bit");
+
+ b.Property("PredictedComplexity")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("PredictedMinutes")
+ .HasColumnType("int");
+
+ b.Property("PredictedSurfaceAreaSqFt")
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("PredictedUnitPrice")
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("Reasoning")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("UserOverrodeEstimate")
+ .HasColumnType("bit");
+
+ b.HasKey("Id");
+
+ b.ToTable("AiItemPredictions");
+ });
+
+ modelBuilder.Entity("PowderCoating.Core.Entities.AiUsageLog", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("CalledAt")
+ .HasColumnType("datetime2");
+
+ b.Property("CompanyId")
+ .HasColumnType("int");
+
+ b.Property("Feature")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("InputLength")
+ .HasColumnType("int");
+
+ b.Property("Success")
+ .HasColumnType("bit");
+
+ b.Property("UserId")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CompanyId", "CalledAt")
+ .HasDatabaseName("IX_AiUsageLogs_CompanyId_CalledAt");
+
+ b.ToTable("AiUsageLogs");
+ });
+
+ modelBuilder.Entity("PowderCoating.Core.Entities.Announcement", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("CreatedByUserId")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("CreatedByUserName")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("datetime2");
+
+ b.Property("IsActive")
+ .HasColumnType("bit");
+
+ b.Property("IsDismissible")
+ .HasColumnType("bit");
+
+ b.Property("Message")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("StartsAt")
+ .HasColumnType("datetime2");
+
+ b.Property("Target")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("TargetCompanyId")
+ .HasColumnType("int");
+
+ b.Property("TargetPlan")
+ .HasColumnType("int");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.HasKey("Id");
+
+ b.ToTable("Announcements");
+ });
+
+ modelBuilder.Entity("PowderCoating.Core.Entities.AnnouncementDismissal", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("AnnouncementId")
+ .HasColumnType("int");
+
+ b.Property("DismissedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("UserId")
+ .IsRequired()
+ .HasColumnType("nvarchar(450)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AnnouncementId", "UserId")
+ .IsUnique();
+
+ b.ToTable("AnnouncementDismissals");
+ });
+
+ modelBuilder.Entity("PowderCoating.Core.Entities.ApplicationUser", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("AccessFailedCount")
+ .HasColumnType("int");
+
+ b.Property("Address")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("BanReason")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("BannedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("BannedByUserId")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("CanApproveQuotes")
+ .HasColumnType("bit");
+
+ b.Property("CanCreateQuotes")
+ .HasColumnType("bit");
+
+ b.Property("CanManageAccounting")
+ .HasColumnType("bit");
+
+ b.Property("CanManageBills")
+ .HasColumnType("bit");
+
+ b.Property("CanManageCalendar")
+ .HasColumnType("bit");
+
+ b.Property("CanManageCustomers")
+ .HasColumnType("bit");
+
+ b.Property("CanManageEquipment")
+ .HasColumnType("bit");
+
+ b.Property("CanManageInventory")
+ .HasColumnType("bit");
+
+ b.Property("CanManageInvoices")
+ .HasColumnType("bit");
+
+ b.Property("CanManageJobs")
+ .HasColumnType("bit");
+
+ b.Property("CanManageMaintenance")
+ .HasColumnType("bit");
+
+ b.Property("CanManageProducts")
+ .HasColumnType("bit");
+
+ b.Property("CanManageVendors")
+ .HasColumnType("bit");
+
+ b.Property("CanViewCalendar")
+ .HasColumnType("bit");
+
+ b.Property("CanViewProducts")
+ .HasColumnType("bit");
+
+ b.Property("CanViewReports")
+ .HasColumnType("bit");
+
+ b.Property("CanViewShopFloor")
+ .HasColumnType("bit");
+
+ b.Property("City")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("CompanyId")
+ .HasColumnType("int");
+
+ b.Property("CompanyRole")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("DashboardLayout")
+ .HasColumnType("int");
+
+ b.Property("DateFormat")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Department")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Email")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.Property("EmailConfirmed")
+ .HasColumnType("bit");
+
+ b.Property("EmployeeNumber")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("FirstName")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("HireDate")
+ .HasColumnType("datetime2");
+
+ b.Property("IsActive")
+ .HasColumnType("bit");
+
+ b.Property("IsBanned")
+ .HasColumnType("bit");
+
+ b.Property("KioskPin")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("LaborCostPerHour")
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("LastLoginDate")
+ .HasColumnType("datetime2");
+
+ b.Property("LastName")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("LockoutEnabled")
+ .HasColumnType("bit");
+
+ b.Property("LockoutEnd")
+ .HasColumnType("datetimeoffset");
+
+ b.Property("NormalizedEmail")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.Property("NormalizedUserName")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.Property("Notes")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("PasskeyPromptDismissed")
+ .HasColumnType("bit");
+
+ b.Property("PasswordHash")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("PhoneNumber")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("PhoneNumberConfirmed")
+ .HasColumnType("bit");
+
+ b.Property("Position")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("ProfilePictureFilePath")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("SecurityStamp")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("SidebarColor")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("State")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("TerminationDate")
+ .HasColumnType("datetime2");
+
+ b.Property("Theme")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("TimeZone")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("TwoFactorEnabled")
+ .HasColumnType("bit");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("UserName")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.Property("ZipCode")
+ .HasColumnType("nvarchar(max)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CompanyId");
+
+ b.HasIndex("NormalizedEmail")
+ .HasDatabaseName("EmailIndex");
+
+ b.HasIndex("NormalizedUserName")
+ .IsUnique()
+ .HasDatabaseName("UserNameIndex")
+ .HasFilter("[NormalizedUserName] IS NOT NULL");
+
+ b.ToTable("AspNetUsers", (string)null);
+ });
+
+ modelBuilder.Entity("PowderCoating.Core.Entities.Appointment", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("ActualEndTime")
+ .HasColumnType("datetime2");
+
+ b.Property("ActualStartTime")
+ .HasColumnType("datetime2");
+
+ b.Property("AppointmentNumber")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("AppointmentStatusId")
+ .HasColumnType("int");
+
+ b.Property("AppointmentTypeId")
+ .HasColumnType("int");
+
+ b.Property("AssignedUserId")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("CompanyId")
+ .HasColumnType("int");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("CreatedBy")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("CustomerId")
+ .HasColumnType("int");
+
+ b.Property("DeletedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("DeletedBy")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Description")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("IsAllDay")
+ .HasColumnType("bit");
+
+ b.Property("IsDeleted")
+ .HasColumnType("bit");
+
+ b.Property("IsReminderEnabled")
+ .HasColumnType("bit");
+
+ b.Property("JobId")
+ .HasColumnType("int");
+
+ b.Property("Location")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Notes")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("ReminderMinutesBefore")
+ .HasColumnType("int");
+
+ b.Property("ReminderSentAt")
+ .HasColumnType("datetime2");
+
+ b.Property("ScheduledEndTime")
+ .HasColumnType("datetime2");
+
+ b.Property("ScheduledStartTime")
+ .HasColumnType("datetime2");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("nvarchar(max)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppointmentStatusId");
+
+ b.HasIndex("AppointmentTypeId");
+
+ b.HasIndex("AssignedUserId");
+
+ b.HasIndex("CustomerId");
+
+ b.HasIndex("JobId");
+
+ b.HasIndex("ScheduledStartTime");
+
+ b.HasIndex("CompanyId", "AppointmentStatusId")
+ .HasDatabaseName("IX_Appointments_CompanyId_AppointmentStatusId");
+
+ b.HasIndex("CompanyId", "ScheduledStartTime")
+ .HasDatabaseName("IX_Appointments_CompanyId_ScheduledStartTime");
+
+ b.ToTable("Appointments");
+ });
+
+ modelBuilder.Entity("PowderCoating.Core.Entities.AppointmentStatusLookup", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("ColorClass")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("CompanyId")
+ .HasColumnType("int");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("CreatedBy")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("DeletedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("DeletedBy")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Description")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("DisplayName")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("DisplayOrder")
+ .HasColumnType("int");
+
+ b.Property("IconClass")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("IsActive")
+ .HasColumnType("bit");
+
+ b.Property("IsDeleted")
+ .HasColumnType("bit");
+
+ b.Property("IsSystemDefined")
+ .HasColumnType("bit");
+
+ b.Property("IsTerminalStatus")
+ .HasColumnType("bit");
+
+ b.Property("StatusCode")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("nvarchar(max)");
+
+ b.HasKey("Id");
+
+ b.ToTable("AppointmentStatusLookups");
+ });
+
+ modelBuilder.Entity("PowderCoating.Core.Entities.AppointmentTypeLookup", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("ColorClass")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("CompanyId")
+ .HasColumnType("int");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("CreatedBy")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("DeletedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("DeletedBy")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Description")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("DisplayName")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("DisplayOrder")
+ .HasColumnType("int");
+
+ b.Property("IconClass")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("IsActive")
+ .HasColumnType("bit");
+
+ b.Property("IsDeleted")
+ .HasColumnType("bit");
+
+ b.Property("IsSystemDefined")
+ .HasColumnType("bit");
+
+ b.Property("RequiresJobLink")
+ .HasColumnType("bit");
+
+ b.Property("TypeCode")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("nvarchar(max)");
+
+ b.HasKey("Id");
+
+ b.ToTable("AppointmentTypeLookups");
+ });
+
+ modelBuilder.Entity("PowderCoating.Core.Entities.AuditLog", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property