Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| da2bb46d5a | |||
| 843d1c3c51 | |||
| c59d55529f | |||
| f752abad86 | |||
| 148a3f465e | |||
| a6538d9638 | |||
| 059d94d4fe | |||
| 8401bd77e8 | |||
| 0f6eef5370 | |||
| c22537b68f | |||
| 115ccf7d5e | |||
| 99b22d2ad2 | |||
| 6db055dcf8 | |||
| eed61a298b | |||
| 2286b5431d | |||
| d2d9f44358 | |||
| 4506c1f641 | |||
| a07f6aa1a8 | |||
| 9aa3a99488 | |||
| 2b420d4623 | |||
| a4a3dde7e4 | |||
| 39f61b9718 | |||
| c98f9faf63 | |||
| 0498decfb0 | |||
| 2fae9aefad | |||
| 2c179bc892 | |||
| deb248b2a6 | |||
| eb8fc8b6d0 | |||
| 4f039b8281 |
@@ -0,0 +1,8 @@
|
||||
# Build output
|
||||
bin/
|
||||
obj/
|
||||
|
||||
# Transient scrape artifacts
|
||||
*.tmp
|
||||
*.invalid-*.bak
|
||||
prismatic-sync.log
|
||||
@@ -0,0 +1,43 @@
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace PrismaticSync.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// A headless Chromium session with a realistic desktop fingerprint (UA, viewport, locale,
|
||||
/// timezone) — matching the original scraper's settings to look like a normal browser.
|
||||
/// </summary>
|
||||
public sealed class BrowserSession : IAsyncDisposable
|
||||
{
|
||||
private IPlaywright? _pw;
|
||||
private IBrowser? _browser;
|
||||
private IBrowserContext? _context;
|
||||
|
||||
public IPage Page { get; private set; } = null!;
|
||||
|
||||
public static async Task<BrowserSession> CreateAsync(bool headed)
|
||||
{
|
||||
var session = new BrowserSession();
|
||||
session._pw = await Playwright.CreateAsync();
|
||||
session._browser = await session._pw.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
|
||||
{
|
||||
Headless = !headed
|
||||
});
|
||||
session._context = await session._browser.NewContextAsync(new BrowserNewContextOptions
|
||||
{
|
||||
UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
|
||||
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
||||
ViewportSize = new ViewportSize { Width = 1365, Height = 900 },
|
||||
Locale = "en-US",
|
||||
TimezoneId = "America/New_York"
|
||||
});
|
||||
session.Page = await session._context.NewPageAsync();
|
||||
return session;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_context is not null) await _context.CloseAsync();
|
||||
if (_browser is not null) await _browser.CloseAsync();
|
||||
_pw?.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System.Text.Json;
|
||||
using PrismaticSync.Models;
|
||||
|
||||
namespace PrismaticSync.Infrastructure;
|
||||
|
||||
/// <summary>Loads/saves the scrape output and the URL list, with atomic writes so a crash mid-save can't corrupt them.</summary>
|
||||
public static class JsonStore
|
||||
{
|
||||
private static readonly JsonSerializerOptions WriteOptions = new() { WriteIndented = true };
|
||||
private static readonly JsonSerializerOptions ReadOptions = new() { PropertyNameCaseInsensitive = true };
|
||||
|
||||
public static ScrapeOutput LoadOutput(string path)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
return new ScrapeOutput();
|
||||
|
||||
var json = File.ReadAllText(path);
|
||||
try
|
||||
{
|
||||
// Tolerate a bare array (older output format) as well as { results, errors }.
|
||||
if (json.TrimStart().StartsWith("["))
|
||||
{
|
||||
var results = JsonSerializer.Deserialize<List<ProductRecord>>(json, ReadOptions) ?? new();
|
||||
return new ScrapeOutput { Results = results };
|
||||
}
|
||||
return JsonSerializer.Deserialize<ScrapeOutput>(json, ReadOptions) ?? new ScrapeOutput();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var backup = $"{path}.invalid-{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}.bak";
|
||||
File.Copy(path, backup, overwrite: true);
|
||||
throw new InvalidOperationException($"Could not parse {path}. Backed it up to {backup}. {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public static void SaveOutput(string path, ScrapeOutput data)
|
||||
{
|
||||
var tmp = path + ".tmp";
|
||||
File.WriteAllText(tmp, JsonSerializer.Serialize(data, WriteOptions));
|
||||
File.Move(tmp, path, overwrite: true);
|
||||
}
|
||||
|
||||
public static List<string> LoadUrls(string path)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
return new List<string>();
|
||||
|
||||
return File.ReadAllLines(path)
|
||||
.Select(CleanUrl)
|
||||
.Where(u => u.Length > 0 && !u.StartsWith("#"))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public static void SaveUrls(string path, IEnumerable<string> urls)
|
||||
{
|
||||
var sorted = urls.Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(u => u, StringComparer.OrdinalIgnoreCase);
|
||||
var tmp = path + ".tmp";
|
||||
File.WriteAllText(tmp, string.Join(Environment.NewLine, sorted) + Environment.NewLine);
|
||||
File.Move(tmp, path, overwrite: true);
|
||||
}
|
||||
|
||||
public static string CleanUrl(string? url) =>
|
||||
(url ?? string.Empty).Split('?')[0].Split('#')[0].Trim();
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
namespace PrismaticSync.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal timestamped logger — writes to the console and appends to a rolling log file so an
|
||||
/// unattended (Task Scheduler) run leaves an audit trail. Intentionally dependency-free.
|
||||
/// </summary>
|
||||
public static class Log
|
||||
{
|
||||
private static string _logFile = "prismatic-sync.log";
|
||||
private static readonly object Gate = new();
|
||||
|
||||
public static void Configure(string logFile) => _logFile = logFile;
|
||||
|
||||
public static void Info(string message) => Write("INFO", message);
|
||||
public static void Warn(string message) => Write("WARN", message);
|
||||
public static void Error(string message) => Write("ERROR", message);
|
||||
|
||||
private static void Write(string level, string message)
|
||||
{
|
||||
var line = $"[{DateTime.UtcNow:yyyy-MM-ddTHH:mm:ssZ}] {level,-5} {message}";
|
||||
|
||||
// Live console stream (visible on a manual run); color-code so warnings/errors stand out.
|
||||
lock (Gate)
|
||||
{
|
||||
var color = level switch
|
||||
{
|
||||
"WARN" => ConsoleColor.Yellow,
|
||||
"ERROR" => ConsoleColor.Red,
|
||||
_ => (ConsoleColor?)null
|
||||
};
|
||||
|
||||
if (color is { } c)
|
||||
{
|
||||
var previous = Console.ForegroundColor;
|
||||
Console.ForegroundColor = c;
|
||||
Console.WriteLine(line);
|
||||
Console.ForegroundColor = previous;
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine(line);
|
||||
}
|
||||
|
||||
// File trail — never let logging break a run.
|
||||
try { File.AppendAllText(_logFile, line + Environment.NewLine); }
|
||||
catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
namespace PrismaticSync.Infrastructure;
|
||||
|
||||
/// <summary>Strongly-typed config bound from the "Sync" section of appsettings.json.</summary>
|
||||
public class SyncConfig
|
||||
{
|
||||
public string BaseUrl { get; set; } = "https://www.prismaticpowders.com";
|
||||
public string ColorsPath { get; set; } = "/shop/powder-coating-colors";
|
||||
|
||||
public string ProductUrlsFile { get; set; } = "product-urls.txt";
|
||||
public string OutputJsonFile { get; set; } = "prismatic_powders.json";
|
||||
public string LogFile { get; set; } = "prismatic-sync.log";
|
||||
|
||||
/// <summary>Politeness delay between product scrapes (randomized within the range).</summary>
|
||||
public int MinDelaySeconds { get; set; } = 6;
|
||||
public int MaxDelaySeconds { get; set; } = 14;
|
||||
|
||||
/// <summary>On a 403/block, cool down this many seconds × the consecutive-block count, then retry.</summary>
|
||||
public int BlockedCooldownSeconds { get; set; } = 120;
|
||||
|
||||
/// <summary>Upper bound on a single cooldown so escalation can't run away.</summary>
|
||||
public int BlockedCooldownMaxSeconds { get; set; } = 600;
|
||||
|
||||
/// <summary>How many times to cool-down-and-retry a blocked product before recording it as an error.</summary>
|
||||
public int BlockedMaxRetries { get; set; } = 3;
|
||||
|
||||
/// <summary>Take a longer rest after this many products (0 disables). Eases load and looks less robotic.</summary>
|
||||
public int LongRestEveryProducts { get; set; } = 150;
|
||||
|
||||
/// <summary>Length of the periodic long rest, in seconds.</summary>
|
||||
public int LongRestSeconds { get; set; } = 45;
|
||||
|
||||
/// <summary>Extra settle time after a product page loads before reading it.</summary>
|
||||
public int PageSettleSeconds { get; set; } = 4;
|
||||
|
||||
/// <summary>Pause after each scroll while a listing lazy-loads more items.</summary>
|
||||
public int ScrollWaitMs { get; set; } = 1500;
|
||||
|
||||
/// <summary>Hard cap on scrolls per listing, as a safety stop.</summary>
|
||||
public int MaxScrolls { get; set; } = 400;
|
||||
|
||||
/// <summary>Full discovery: stop a listing after this many scrolls add no new links.</summary>
|
||||
public int StopAfterNoNewScrolls { get; set; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Incremental discovery: stop the newest-first listing after this many consecutive scrolls
|
||||
/// that surfaced only already-known URLs — i.e. we've scrolled past the new products.
|
||||
/// </summary>
|
||||
public int StopAfterKnownScrolls { get; set; } = 8;
|
||||
|
||||
/// <summary>Color filter params used by full discovery.</summary>
|
||||
public string[] ColorParams { get; set; } = Array.Empty<string>();
|
||||
|
||||
public ImportConfig Import { get; set; } = new();
|
||||
|
||||
public string ColorsUrl => $"{BaseUrl.TrimEnd('/')}{ColorsPath}";
|
||||
}
|
||||
|
||||
/// <summary>Where and how to push the scraped catalog into the app.</summary>
|
||||
public class ImportConfig
|
||||
{
|
||||
/// <summary>Full URL of the app's token-authenticated catalog import endpoint.</summary>
|
||||
public string EndpointUrl { get; set; } = "";
|
||||
|
||||
/// <summary>Shared secret sent in the X-Import-Token header. Must match the app's config.</summary>
|
||||
public string Token { get; set; } = "";
|
||||
|
||||
/// <summary>Vendor name applied to every record on import.</summary>
|
||||
public string VendorName { get; set; } = "Prismatic Powders";
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace PrismaticSync.Models;
|
||||
|
||||
/// <summary>
|
||||
/// On-disk scrape output. Shape matches the app's catalog import (a top-level "results" array of
|
||||
/// snake_case product records), so the JSON drops straight into the import endpoint. "errors" tracks
|
||||
/// failed URLs for resumable re-runs.
|
||||
/// </summary>
|
||||
public class ScrapeOutput
|
||||
{
|
||||
[JsonPropertyName("results")] public List<ProductRecord> Results { get; set; } = new();
|
||||
[JsonPropertyName("errors")] public List<ScrapeError> Errors { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>One scraped product, in the import's expected field shape.</summary>
|
||||
public class ProductRecord
|
||||
{
|
||||
[JsonPropertyName("sku")] public string Sku { get; set; } = "";
|
||||
[JsonPropertyName("color_name")] public string ColorName { get; set; } = "";
|
||||
[JsonPropertyName("description")] public string Description { get; set; } = "";
|
||||
[JsonPropertyName("price_tiers")] public List<PriceTier> PriceTiers { get; set; } = new();
|
||||
[JsonPropertyName("safety_data_sheet_url")] public string SafetyDataSheetUrl { get; set; } = "";
|
||||
[JsonPropertyName("technical_data_sheet_url")] public string TechnicalDataSheetUrl { get; set; } = "";
|
||||
[JsonPropertyName("application_guide_url")] public string ApplicationGuideUrl { get; set; } = "";
|
||||
[JsonPropertyName("sample_image_url")] public string SampleImageUrl { get; set; } = "";
|
||||
[JsonPropertyName("product_url")] public string ProductUrl { get; set; } = "";
|
||||
[JsonPropertyName("scraped_at")] public DateTime ScrapedAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>A quantity-break price tier — {min, max, price}. max is null for an open-ended top tier.</summary>
|
||||
public class PriceTier
|
||||
{
|
||||
[JsonPropertyName("min")] public int? Min { get; set; }
|
||||
[JsonPropertyName("max")] public int? Max { get; set; }
|
||||
[JsonPropertyName("price")] public decimal Price { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>A URL that failed to scrape, kept so resumable runs can skip or retry it.</summary>
|
||||
public class ScrapeError
|
||||
{
|
||||
[JsonPropertyName("product_url")] public string ProductUrl { get; set; } = "";
|
||||
[JsonPropertyName("error")] public string Error { get; set; } = "";
|
||||
[JsonPropertyName("scraped_at")] public DateTime ScrapedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
Standalone workstation tool — deliberately NOT part of PowderCoating.sln.
|
||||
Build/publish independently and run on a machine you control (Task Scheduler),
|
||||
never on the deployed app server. Scrapes Prismatic Powders and pushes the
|
||||
result into the app's catalog import endpoint.
|
||||
|
||||
First-time setup on a workstation:
|
||||
dotnet build
|
||||
pwsh bin/Debug/net8.0/playwright.ps1 install chromium
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<AssemblyName>PrismaticSync</AssemblyName>
|
||||
<RootNamespace>PrismaticSync</RootNamespace>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Playwright" Version="1.49.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="appsettings.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,106 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using PrismaticSync.Infrastructure;
|
||||
using PrismaticSync.Services;
|
||||
|
||||
// ── Load config ───────────────────────────────────────────────────────────────
|
||||
var configRoot = new ConfigurationBuilder()
|
||||
.SetBasePath(AppContext.BaseDirectory)
|
||||
.AddJsonFile("appsettings.json", optional: false)
|
||||
.Build();
|
||||
|
||||
var config = configRoot.GetSection("Sync").Get<SyncConfig>() ?? new SyncConfig();
|
||||
Log.Configure(config.LogFile);
|
||||
|
||||
// ── Parse args ────────────────────────────────────────────────────────────────
|
||||
var command = args.Length > 0 && !args[0].StartsWith("--") ? args[0].ToLowerInvariant() : "run";
|
||||
var headed = args.Contains("--headed");
|
||||
var retryErrors = args.Contains("--retry-errors");
|
||||
var maxProducts = GetIntArg("--max-products", 0);
|
||||
// "run" refreshes products older than 30 days by default; explicit commands default to new-only.
|
||||
var refreshOlderThanDays = GetIntArg("--refresh-older-than", command == "run" ? 30 : 0);
|
||||
|
||||
Log.Info($"PrismaticSync — command '{command}' (headed={headed}, refreshOlderThan={refreshOlderThanDays}d, maxProducts={maxProducts})");
|
||||
|
||||
try
|
||||
{
|
||||
switch (command)
|
||||
{
|
||||
case "discover-new":
|
||||
await WithBrowser(d => new PrismaticDiscoverer(d, config).DiscoverNewAsync());
|
||||
break;
|
||||
|
||||
case "discover-full":
|
||||
await WithBrowser(d => new PrismaticDiscoverer(d, config).DiscoverFullAsync());
|
||||
break;
|
||||
|
||||
case "scrape":
|
||||
await WithBrowser(d => new PrismaticScraper(d, config).ScrapeAsync(refreshOlderThanDays, maxProducts, retryErrors));
|
||||
break;
|
||||
|
||||
case "push":
|
||||
await new CatalogPusher(config).PushAsync();
|
||||
break;
|
||||
|
||||
case "run":
|
||||
// The scheduled default: find new colors, scrape new + stale, then push.
|
||||
await WithBrowser(async d =>
|
||||
{
|
||||
await new PrismaticDiscoverer(d, config).DiscoverNewAsync();
|
||||
await new PrismaticScraper(d, config).ScrapeAsync(refreshOlderThanDays, maxProducts, retryErrors);
|
||||
});
|
||||
await new CatalogPusher(config).PushAsync();
|
||||
break;
|
||||
|
||||
default:
|
||||
PrintUsage();
|
||||
return 1;
|
||||
}
|
||||
|
||||
Log.Info("Done.");
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error($"Fatal: {ex}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
async Task WithBrowser(Func<BrowserSession, Task> action)
|
||||
{
|
||||
await using var session = await BrowserSession.CreateAsync(headed);
|
||||
await action(session);
|
||||
}
|
||||
|
||||
int GetIntArg(string name, int fallback)
|
||||
{
|
||||
var prefix = name + "=";
|
||||
var found = args.FirstOrDefault(a => a.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
|
||||
return found is not null && int.TryParse(found[prefix.Length..], out var value) ? value : fallback;
|
||||
}
|
||||
|
||||
void PrintUsage()
|
||||
{
|
||||
Console.WriteLine(
|
||||
"""
|
||||
PrismaticSync — scrape Prismatic Powders and push to the app catalog.
|
||||
|
||||
Usage: PrismaticSync [command] [options]
|
||||
|
||||
Commands:
|
||||
run (default) discover-new + scrape (new + stale) + push
|
||||
discover-new Incremental discovery via newest-first sort (cheap; finds new colors)
|
||||
discover-full Full discovery across all color filters (heavy; reconciles the whole set)
|
||||
scrape Scrape product pages from the URL list (resumable)
|
||||
push Push the scraped JSON to the import endpoint
|
||||
|
||||
Options:
|
||||
--refresh-older-than=N Re-scrape products whose data is older than N days (default 30 for 'run')
|
||||
--max-products=N Cap products scraped this run (0 = no cap)
|
||||
--retry-errors Retry URLs previously recorded as errors
|
||||
--headed Show the browser window (debugging)
|
||||
|
||||
Config: appsettings.json (delays, file paths, import endpoint + token).
|
||||
First run on a new machine: dotnet build, then `pwsh bin/Debug/net8.0/playwright.ps1 install chromium`.
|
||||
""");
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
# PrismaticSync
|
||||
|
||||
A standalone .NET console tool that scrapes the Prismatic Powders catalog and pushes it into the
|
||||
Powder Coating Logix catalog import endpoint. It exists because Prismatic has **no API** (unlike
|
||||
Columbia Coatings) — so the data has to be scraped via browser automation.
|
||||
|
||||
> **Runs on a workstation you control — never on the deployed app server.** Scraping from the cloud
|
||||
> app's IP would get blocked and isn't appropriate. This tool is deliberately *not* part of
|
||||
> `PowderCoating.sln`; build and run it independently.
|
||||
|
||||
## First-time setup (per machine)
|
||||
|
||||
```powershell
|
||||
cd "scripts/Prismatic Data Scraper"
|
||||
dotnet build
|
||||
pwsh bin/Debug/net8.0/playwright.ps1 install chromium # one-time browser download
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
```powershell
|
||||
dotnet run -- run # default: discover-new + scrape (new + stale >30d) + push
|
||||
dotnet run -- discover-new # cheap: find newly-added colors (newest-first, stops at known)
|
||||
dotnet run -- discover-full # heavy: crawl all color filters (reconcile whole set / removals)
|
||||
dotnet run -- scrape # scrape product pages from product-urls.txt (resumable)
|
||||
dotnet run -- scrape --refresh-older-than=30 # also re-scrape products older than 30 days (price changes)
|
||||
dotnet run -- push # push prismatic_powders.json to the import endpoint
|
||||
```
|
||||
|
||||
Options: `--max-products=N`, `--retry-errors`, `--headed` (show the browser for debugging).
|
||||
|
||||
Everything streams to the console live (warnings/errors in color) **and** to `prismatic-sync.log`.
|
||||
|
||||
## Operating model (suggested cadence)
|
||||
|
||||
| Run | Command | Cadence | Why |
|
||||
|-----|---------|---------|-----|
|
||||
| Find new colors | `run` (does discover-new + scrape-new) | Weekly | Cheap; Prismatic adds colors often |
|
||||
| Price refresh | `scrape --refresh-older-than=30` then `push` | Monthly | Re-scrapes stale products to catch price changes (slow, ~hours) |
|
||||
| Full reconcile | `discover-full` then `scrape` | Quarterly | Catches removed/discontinued colors |
|
||||
|
||||
A full scrape of ~5,000 products takes hours (polite delays). It saves after every product and is
|
||||
fully resumable, so stop/restart any time.
|
||||
|
||||
## Politeness / anti-block
|
||||
|
||||
Configurable in `appsettings.json`: randomized 6–14s base delay, an escalating **cooldown + retry on
|
||||
403** (so a temporary block doesn't get you hard-banned mid-run), and a periodic long rest. Leave
|
||||
these conservative — getting blocked is worse than being slow, and Prismatic is a partner.
|
||||
|
||||
## Pushing into the app
|
||||
|
||||
Set in `appsettings.json`:
|
||||
- `Sync.Import.EndpointUrl` → `https://<your-app>/PowderCatalog/ImportApi`
|
||||
- `Sync.Import.Token` → the same secret as the app's `CatalogImport:Token` config
|
||||
|
||||
The tool POSTs the JSON with an `X-Import-Token` header (and `X-Vendor-Name: Prismatic Powders`) to
|
||||
that endpoint, which authenticates the token and runs the records through the same upsert as the
|
||||
Columbia sync. If the endpoint/token isn't configured here, `push` is skipped and you upload
|
||||
`prismatic_powders.json` manually via the Powder Catalog admin page instead.
|
||||
|
||||
> **App side:** set `CatalogImport:Token` in the web app's config (Azure App Setting in prod). The
|
||||
> endpoint returns 401 until a token is set, so it's inert by default.
|
||||
|
||||
## Scheduling (Windows Task Scheduler)
|
||||
|
||||
Point a scheduled task at the published exe (or `dotnet run`). Example weekly task command:
|
||||
|
||||
```
|
||||
Program/script: C:\Tools\PrismaticSync\PrismaticSync.exe
|
||||
Arguments: run
|
||||
Start in: C:\Tools\PrismaticSync
|
||||
```
|
||||
|
||||
Publish a self-contained build to drop on the workstation:
|
||||
|
||||
```powershell
|
||||
dotnet publish -c Release -r win-x64 --self-contained false -o C:\Tools\PrismaticSync
|
||||
pwsh C:\Tools\PrismaticSync\playwright.ps1 install chromium
|
||||
```
|
||||
|
||||
## The long game
|
||||
|
||||
This is the interim path. The durable endgame is a real Prismatic **API** (the partnership), at which
|
||||
point this tool is replaced by a clean in-app sync like Columbia's — reusing the same upsert,
|
||||
propagation, and discontinued handling.
|
||||
@@ -0,0 +1,63 @@
|
||||
using System.Text;
|
||||
using PrismaticSync.Infrastructure;
|
||||
|
||||
namespace PrismaticSync.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Pushes the scraped JSON to the app's token-authenticated catalog import endpoint. When no
|
||||
/// endpoint is configured it no-ops (the JSON is still on disk for a manual upload), so the tool is
|
||||
/// useful before the endpoint exists.
|
||||
/// </summary>
|
||||
public class CatalogPusher
|
||||
{
|
||||
private readonly SyncConfig _config;
|
||||
|
||||
public CatalogPusher(SyncConfig config) => _config = config;
|
||||
|
||||
public async Task<bool> PushAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_config.Import.EndpointUrl))
|
||||
{
|
||||
Log.Warn($"No import endpoint configured (Sync.Import.EndpointUrl) — skipping push. " +
|
||||
$"Upload {_config.OutputJsonFile} manually via the Powder Catalog admin instead.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!File.Exists(_config.OutputJsonFile))
|
||||
{
|
||||
Log.Warn($"Output file {_config.OutputJsonFile} not found — nothing to push.");
|
||||
return false;
|
||||
}
|
||||
|
||||
var json = await File.ReadAllTextAsync(_config.OutputJsonFile);
|
||||
Log.Info($"Pushing {_config.OutputJsonFile} to {_config.Import.EndpointUrl} (vendor: {_config.Import.VendorName})...");
|
||||
|
||||
using var http = new HttpClient { Timeout = TimeSpan.FromMinutes(5) };
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, _config.Import.EndpointUrl);
|
||||
request.Headers.Add("X-Import-Token", _config.Import.Token);
|
||||
request.Headers.Add("X-Vendor-Name", _config.Import.VendorName);
|
||||
request.Content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
|
||||
try
|
||||
{
|
||||
using var response = await http.SendAsync(request);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
Log.Info($"Push succeeded ({(int)response.StatusCode}): {Trim(body)}");
|
||||
return true;
|
||||
}
|
||||
|
||||
Log.Error($"Push failed ({(int)response.StatusCode}): {Trim(body)}");
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error($"Push error: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string Trim(string s) => s.Length > 500 ? s[..500] + "…" : s;
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Playwright;
|
||||
using PrismaticSync.Infrastructure;
|
||||
|
||||
namespace PrismaticSync.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Discovers product URLs from the Prismatic color listing (infinite-scroll). Two modes:
|
||||
/// incremental (newest-first via <c>?category=created_at</c>, stop once we reach already-known
|
||||
/// URLs) for cheap frequent runs, and full (every color filter to the bottom) for occasional
|
||||
/// reconciliation. Both append to the URL list file.
|
||||
/// </summary>
|
||||
public class PrismaticDiscoverer
|
||||
{
|
||||
private static readonly Regex ProductUrlRegex =
|
||||
new(@"/shop/powder-coating-colors/[A-Z0-9-]+/", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
private readonly BrowserSession _session;
|
||||
private readonly SyncConfig _config;
|
||||
|
||||
public PrismaticDiscoverer(BrowserSession session, SyncConfig config)
|
||||
{
|
||||
_session = session;
|
||||
_config = config;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Incremental discovery: crawl the newest-first listing and stop once a run of consecutive
|
||||
/// scrolls surfaces only already-known URLs — meaning we've scrolled past the new products.
|
||||
/// Returns the count of newly found URLs.
|
||||
/// </summary>
|
||||
public async Task<int> DiscoverNewAsync()
|
||||
{
|
||||
var known = new HashSet<string>(JsonStore.LoadUrls(_config.ProductUrlsFile), StringComparer.OrdinalIgnoreCase);
|
||||
var startCount = known.Count;
|
||||
Log.Info($"Incremental discovery (newest first). Known URLs: {startCount}");
|
||||
|
||||
await GotoAsync($"{_config.ColorsUrl}?category=created_at");
|
||||
|
||||
var knownStreak = 0;
|
||||
for (var i = 0; i < _config.MaxScrolls; i++)
|
||||
{
|
||||
var addedNew = 0;
|
||||
foreach (var link in await CollectProductLinksAsync())
|
||||
if (known.Add(link)) addedNew++;
|
||||
|
||||
JsonStore.SaveUrls(_config.ProductUrlsFile, known);
|
||||
knownStreak = addedNew == 0 ? knownStreak + 1 : 0;
|
||||
Log.Info($"Scroll {i + 1}: +{addedNew} new, total {known.Count}, known-streak {knownStreak}");
|
||||
|
||||
if (knownStreak >= _config.StopAfterKnownScrolls)
|
||||
{
|
||||
Log.Info("Reached known territory — stopping incremental discovery.");
|
||||
break;
|
||||
}
|
||||
|
||||
await ScrollAsync();
|
||||
}
|
||||
|
||||
var newCount = known.Count - startCount;
|
||||
Log.Info($"Incremental discovery done. New URLs: {newCount}; total {known.Count}");
|
||||
return newCount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Full discovery: crawl every color filter to the bottom. Heavier — use occasionally to
|
||||
/// reconcile the whole set (e.g. to notice colors that have been removed). Returns new URL count.
|
||||
/// </summary>
|
||||
public async Task<int> DiscoverFullAsync()
|
||||
{
|
||||
var known = new HashSet<string>(JsonStore.LoadUrls(_config.ProductUrlsFile), StringComparer.OrdinalIgnoreCase);
|
||||
var startCount = known.Count;
|
||||
Log.Info($"Full discovery across {_config.ColorParams.Length} color filters. Known URLs: {startCount}");
|
||||
|
||||
foreach (var color in _config.ColorParams)
|
||||
{
|
||||
Log.Info($"Color filter: {color}");
|
||||
try
|
||||
{
|
||||
await GotoAsync($"{_config.ColorsUrl}?color={Uri.EscapeDataString(color)}");
|
||||
|
||||
var noNew = 0;
|
||||
for (var i = 0; i < _config.MaxScrolls; i++)
|
||||
{
|
||||
var added = 0;
|
||||
foreach (var link in await CollectProductLinksAsync())
|
||||
if (known.Add(link)) added++;
|
||||
|
||||
JsonStore.SaveUrls(_config.ProductUrlsFile, known);
|
||||
noNew = added == 0 ? noNew + 1 : 0;
|
||||
if (noNew >= _config.StopAfterNoNewScrolls)
|
||||
break;
|
||||
|
||||
await ScrollAsync();
|
||||
}
|
||||
|
||||
Log.Info($"Color {color} done. Total {known.Count}");
|
||||
await _session.Page.WaitForTimeoutAsync(3000);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warn($"Color {color} failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
var newCount = known.Count - startCount;
|
||||
Log.Info($"Full discovery done. New this run: {newCount}; total {known.Count}");
|
||||
return newCount;
|
||||
}
|
||||
|
||||
private async Task GotoAsync(string url)
|
||||
{
|
||||
await _session.Page.GotoAsync(url, new PageGotoOptions
|
||||
{
|
||||
WaitUntil = WaitUntilState.DOMContentLoaded,
|
||||
Timeout = 60000
|
||||
});
|
||||
await _session.Page.WaitForTimeoutAsync(_config.PageSettleSeconds * 1000);
|
||||
}
|
||||
|
||||
private async Task ScrollAsync()
|
||||
{
|
||||
await _session.Page.Mouse.WheelAsync(0, 2500);
|
||||
await _session.Page.WaitForTimeoutAsync(_config.ScrollWaitMs);
|
||||
}
|
||||
|
||||
private async Task<List<string>> CollectProductLinksAsync()
|
||||
{
|
||||
var hrefs = await _session.Page.EvalOnSelectorAllAsync<string[]>(
|
||||
"a", "els => els.map(a => a.href).filter(Boolean)");
|
||||
|
||||
return hrefs
|
||||
.Where(h => ProductUrlRegex.IsMatch(h))
|
||||
.Select(JsonStore.CleanUrl)
|
||||
.Where(u => u.Length > 0)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Playwright;
|
||||
using PrismaticSync.Infrastructure;
|
||||
using PrismaticSync.Models;
|
||||
|
||||
namespace PrismaticSync.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Scrapes individual Prismatic product pages into <see cref="ProductRecord"/>s. Resumable (skips
|
||||
/// already-scraped URLs, optionally retries past errors) and supports a refresh window so stale
|
||||
/// records get re-scraped to catch price changes. Saves after every product so a long run can be
|
||||
/// stopped and resumed safely, and logs continuously — including the delay between products — so a
|
||||
/// manual run always shows it's alive.
|
||||
/// </summary>
|
||||
public class PrismaticScraper
|
||||
{
|
||||
private static readonly Regex ProductUrlRegex =
|
||||
new(@"/shop/powder-coating-colors/[A-Z0-9-]+/", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
private static readonly Regex SkuRegex =
|
||||
new(@"Item:\s*([A-Z0-9-]+)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
private static readonly Regex DescRegex =
|
||||
new(@"Description:\s*(.*?)(WARNING:|What does this match\?|PRODUCT SUPPORT|PRODUCT COLLECTIONS|CUSTOMER SERVICE|$)",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled);
|
||||
private static readonly Regex PriceTierRegex =
|
||||
new(@"(\d+\s*-\s*\d+\s*lbs|\d+\s*\+\s*lbs)\s*\$([\d.]+)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
private static readonly Regex RangeRegex = new(@"(\d+)\s*-\s*(\d+)", RegexOptions.Compiled);
|
||||
private static readonly Regex PlusRegex = new(@"(\d+)\s*\+", RegexOptions.Compiled);
|
||||
private static readonly Regex WhitespaceRegex = new(@"\s+", RegexOptions.Compiled);
|
||||
|
||||
private readonly BrowserSession _session;
|
||||
private readonly SyncConfig _config;
|
||||
private readonly Random _random = new();
|
||||
|
||||
public PrismaticScraper(BrowserSession session, SyncConfig config)
|
||||
{
|
||||
_session = session;
|
||||
_config = config;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scrapes products needing work: those not yet scraped, plus (when <paramref name="refreshOlderThanDays"/>
|
||||
/// > 0) any whose data is older than that window. Returns (scraped, errors).
|
||||
/// </summary>
|
||||
public async Task<(int Scraped, int Errors)> ScrapeAsync(int refreshOlderThanDays, int maxProducts, bool retryErrors)
|
||||
{
|
||||
var allUrls = JsonStore.LoadUrls(_config.ProductUrlsFile)
|
||||
.Where(u => ProductUrlRegex.IsMatch(u))
|
||||
.ToList();
|
||||
|
||||
var data = JsonStore.LoadOutput(_config.OutputJsonFile);
|
||||
|
||||
// Index existing results by URL (keep the most recent if the file has dupes).
|
||||
var resultByUrl = data.Results
|
||||
.GroupBy(r => JsonStore.CleanUrl(r.ProductUrl), StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(g => g.Key, g => g.OrderByDescending(r => r.ScrapedAt).First(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var errorUrls = new HashSet<string>(
|
||||
data.Errors.Select(e => JsonStore.CleanUrl(e.ProductUrl)), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var staleCutoff = DateTime.UtcNow.AddDays(-Math.Max(0, refreshOlderThanDays));
|
||||
|
||||
var toScrape = new List<string>();
|
||||
foreach (var url in allUrls)
|
||||
{
|
||||
if (resultByUrl.TryGetValue(url, out var existing))
|
||||
{
|
||||
if (refreshOlderThanDays > 0 && existing.ScrapedAt < staleCutoff)
|
||||
toScrape.Add(url); // stale → refresh for price changes
|
||||
}
|
||||
else
|
||||
{
|
||||
if (retryErrors || !errorUrls.Contains(url))
|
||||
toScrape.Add(url); // never scraped (skip known errors unless retrying)
|
||||
}
|
||||
}
|
||||
|
||||
if (maxProducts > 0)
|
||||
toScrape = toScrape.Take(maxProducts).ToList();
|
||||
|
||||
var total = toScrape.Count;
|
||||
Log.Info($"URLs: {allUrls.Count}; already scraped: {resultByUrl.Count}; errors on file: {errorUrls.Count}");
|
||||
Log.Info($"To scrape this run: {total} (refresh older than {refreshOlderThanDays}d, retry errors: {retryErrors})");
|
||||
|
||||
if (total == 0)
|
||||
{
|
||||
Log.Info("Nothing to scrape. Done.");
|
||||
return (0, 0);
|
||||
}
|
||||
|
||||
var avgDelaySec = (_config.MinDelaySeconds + _config.MaxDelaySeconds) / 2.0;
|
||||
var etaMinutes = total * (avgDelaySec + _config.PageSettleSeconds + 2) / 60.0;
|
||||
Log.Info($"Estimated run time: ~{FormatDuration(TimeSpan.FromMinutes(etaMinutes))} " +
|
||||
$"(grab a coffee if that's a while — it saves after every product and is resumable).");
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
int scraped = 0, errors = 0, index = 0, consecutiveBlocks = 0;
|
||||
|
||||
foreach (var url in toScrape)
|
||||
{
|
||||
index++;
|
||||
|
||||
for (var attempt = 1; ; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
var row = await ParseProductAsync(url, index, total);
|
||||
|
||||
if (resultByUrl.TryGetValue(url, out var existing))
|
||||
data.Results[data.Results.IndexOf(existing)] = row;
|
||||
else
|
||||
data.Results.Add(row);
|
||||
|
||||
resultByUrl[url] = row;
|
||||
data.Errors.RemoveAll(e => JsonStore.CleanUrl(e.ProductUrl).Equals(url, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
scraped++;
|
||||
consecutiveBlocks = 0;
|
||||
JsonStore.SaveOutput(_config.OutputJsonFile, data);
|
||||
|
||||
var basePrice = row.PriceTiers.Count > 0 ? row.PriceTiers.Min(t => t.Price) : 0m;
|
||||
Log.Info($"[{index}/{total}] Saved {row.Sku} \"{row.ColorName}\" " +
|
||||
$"({row.PriceTiers.Count} tier(s), base ${basePrice:0.00}) | elapsed {FormatDuration(stopwatch.Elapsed)}");
|
||||
break;
|
||||
}
|
||||
catch (Exception ex) when (IsBlocked(ex) && attempt <= _config.BlockedMaxRetries)
|
||||
{
|
||||
// Site pushed back — back off (escalating) and retry the SAME product rather
|
||||
// than barreling on, which is how an unattended run gets hard-banned.
|
||||
consecutiveBlocks++;
|
||||
var cooldown = Math.Min(_config.BlockedCooldownSeconds * consecutiveBlocks, _config.BlockedCooldownMaxSeconds);
|
||||
Log.Warn($"[{index}/{total}] Blocked (403), attempt {attempt}. Cooling down {cooldown}s, then retrying this product...");
|
||||
await Task.Delay(cooldown * 1000);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
data.Errors.Add(new ScrapeError { ProductUrl = url, Error = ex.Message, ScrapedAt = DateTime.UtcNow });
|
||||
JsonStore.SaveOutput(_config.OutputJsonFile, data);
|
||||
errors++;
|
||||
Log.Error($"[{index}/{total}] {url} -> {ex.Message}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Periodic longer rest — eases server load and avoids a robotic, evenly-spaced cadence.
|
||||
if (_config.LongRestEveryProducts > 0 && index % _config.LongRestEveryProducts == 0 && index < total)
|
||||
{
|
||||
Log.Info($"Resting {_config.LongRestSeconds}s after {index} products...");
|
||||
await Task.Delay(_config.LongRestSeconds * 1000);
|
||||
}
|
||||
|
||||
if (index < total)
|
||||
{
|
||||
var delayMs = RandomDelayMs();
|
||||
Log.Info($"[{index}/{total}] Waiting {delayMs / 1000.0:0.0}s before next product...");
|
||||
await Task.Delay(delayMs);
|
||||
}
|
||||
}
|
||||
|
||||
Log.Info($"Scrape complete. Scraped {scraped}, errors {errors}. Total results on file: {data.Results.Count}. " +
|
||||
$"Took {FormatDuration(stopwatch.Elapsed)}.");
|
||||
return (scraped, errors);
|
||||
}
|
||||
|
||||
private async Task<ProductRecord> ParseProductAsync(string url, int index, int total)
|
||||
{
|
||||
Log.Info($"[{index}/{total}] Scraping {url}");
|
||||
|
||||
var response = await _session.Page.GotoAsync(url, new PageGotoOptions
|
||||
{
|
||||
WaitUntil = WaitUntilState.DOMContentLoaded,
|
||||
Timeout = 60000
|
||||
});
|
||||
await _session.Page.WaitForTimeoutAsync(_config.PageSettleSeconds * 1000);
|
||||
|
||||
var status = response?.Status ?? 0;
|
||||
var title = Clean(await SafeTextAsync(() => _session.Page.TitleAsync()));
|
||||
var plainText = Clean(await SafeTextAsync(() => _session.Page.Locator("body").InnerTextAsync()));
|
||||
|
||||
if (status == 403 || Regex.IsMatch(title, @"^403 Forbidden$", RegexOptions.IgnoreCase))
|
||||
throw new Exception("403 Forbidden returned by site.");
|
||||
if (status == 404 || Regex.IsMatch(title, @"404|Page Not Found", RegexOptions.IgnoreCase))
|
||||
throw new Exception("404 Not Found returned by site.");
|
||||
|
||||
var colorName = Clean(await SafeTextAsync(() => _session.Page.Locator("h1").First.InnerTextAsync()));
|
||||
|
||||
var skuMatch = SkuRegex.Match(plainText);
|
||||
var sku = skuMatch.Success ? skuMatch.Groups[1].Value : "";
|
||||
if (string.IsNullOrEmpty(sku) && string.IsNullOrEmpty(colorName))
|
||||
throw new Exception("Could not find SKU or title on product page.");
|
||||
|
||||
var descMatch = DescRegex.Match(plainText);
|
||||
var description = descMatch.Success ? Clean(descMatch.Groups[1].Value) : "";
|
||||
|
||||
return new ProductRecord
|
||||
{
|
||||
Sku = sku,
|
||||
ColorName = colorName,
|
||||
Description = description,
|
||||
PriceTiers = ParsePriceTiers(plainText),
|
||||
SafetyDataSheetUrl = await GetLinkByTextAsync(new[] { "Safety Data Sheet", @"\bSDS\b" }),
|
||||
TechnicalDataSheetUrl = await GetLinkByTextAsync(new[] { "Tech Data Sheet", "Technical Data Sheet", @"\bTDS\b" }),
|
||||
ApplicationGuideUrl = await GetLinkByTextAsync(new[] { "Application Guide" }),
|
||||
SampleImageUrl = await GetSampleImageUrlAsync(),
|
||||
ProductUrl = url,
|
||||
ScrapedAt = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
private static List<PriceTier> ParsePriceTiers(string text)
|
||||
{
|
||||
var tiers = new List<PriceTier>();
|
||||
foreach (Match m in PriceTierRegex.Matches(text))
|
||||
{
|
||||
if (!decimal.TryParse(m.Groups[2].Value, NumberStyles.Any, CultureInfo.InvariantCulture, out var price))
|
||||
continue;
|
||||
|
||||
var rangeText = Clean(m.Groups[1].Value);
|
||||
int? min = null, max = null;
|
||||
|
||||
var range = RangeRegex.Match(rangeText);
|
||||
if (range.Success)
|
||||
{
|
||||
min = int.Parse(range.Groups[1].Value);
|
||||
max = int.Parse(range.Groups[2].Value);
|
||||
}
|
||||
|
||||
var plus = PlusRegex.Match(rangeText);
|
||||
if (plus.Success)
|
||||
{
|
||||
min = int.Parse(plus.Groups[1].Value);
|
||||
max = null;
|
||||
}
|
||||
|
||||
tiers.Add(new PriceTier { Min = min, Max = max, Price = price });
|
||||
}
|
||||
return tiers;
|
||||
}
|
||||
|
||||
/// <summary>Returns the href of the first link whose text matches any pattern. Uses a single eval
|
||||
/// returning "texthref" pairs to avoid object deserialization quirks.</summary>
|
||||
private async Task<string> GetLinkByTextAsync(string[] patterns)
|
||||
{
|
||||
var combined = await _session.Page.EvalOnSelectorAllAsync<string[]>(
|
||||
"a",
|
||||
"els => els.map(a => ((a.innerText || a.textContent || '').replace(/\\s+/g, ' ').trim()) " +
|
||||
"+ String.fromCharCode(1) + (a.href || ''))");
|
||||
|
||||
foreach (var entry in combined)
|
||||
{
|
||||
var parts = entry.Split('');
|
||||
var text = parts.Length > 0 ? parts[0] : "";
|
||||
var href = parts.Length > 1 ? parts[1] : "";
|
||||
// Require the link to point at an actual document, not a generic /documents nav page.
|
||||
if (href.Length > 0
|
||||
&& IsDocumentUrl(href)
|
||||
&& patterns.Any(p => Regex.IsMatch(text, p, RegexOptions.IgnoreCase)))
|
||||
return href;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/// <summary>True when an href looks like a real document (hosted on the NIC CDN or a direct PDF).</summary>
|
||||
private static bool IsDocumentUrl(string href)
|
||||
{
|
||||
var path = href.Split('?')[0];
|
||||
return href.Contains("nicindustries.com", StringComparison.OrdinalIgnoreCase)
|
||||
|| path.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private async Task<string> GetSampleImageUrlAsync()
|
||||
{
|
||||
var srcs = await _session.Page.EvalOnSelectorAllAsync<string[]>(
|
||||
"img",
|
||||
"els => els.map(i => i.currentSrc || i.src || i.getAttribute('src') || i.getAttribute('data-src') || '')" +
|
||||
".filter(Boolean)");
|
||||
|
||||
// Only accept real product images on the NIC CDN (prefer full-size over thumbnail). Do NOT
|
||||
// fall back to any "prismatic"-ish URL — that catches the site logo on products with no image.
|
||||
return srcs.FirstOrDefault(s => Regex.IsMatch(s, @"images\.nicindustries\.com/prismatic/products", RegexOptions.IgnoreCase)
|
||||
&& !Regex.IsMatch(s, "thumbnail", RegexOptions.IgnoreCase))
|
||||
?? srcs.FirstOrDefault(s => Regex.IsMatch(s, @"images\.nicindustries\.com/prismatic/products", RegexOptions.IgnoreCase))
|
||||
?? "";
|
||||
}
|
||||
|
||||
private static bool IsBlocked(Exception ex) =>
|
||||
ex.Message.Contains("403", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static async Task<string> SafeTextAsync(Func<Task<string>> fn)
|
||||
{
|
||||
try { return await fn(); } catch { return ""; }
|
||||
}
|
||||
|
||||
private static string Clean(string? text) => WhitespaceRegex.Replace(text ?? "", " ").Trim();
|
||||
|
||||
private int RandomDelayMs()
|
||||
{
|
||||
var min = Math.Max(0, _config.MinDelaySeconds * 1000);
|
||||
var max = Math.Max(min, _config.MaxDelaySeconds * 1000);
|
||||
return _random.Next(min, max + 1);
|
||||
}
|
||||
|
||||
private static string FormatDuration(TimeSpan t) =>
|
||||
t.TotalHours >= 1 ? $"{(int)t.TotalHours}h {t.Minutes}m" :
|
||||
t.TotalMinutes >= 1 ? $"{(int)t.TotalMinutes}m {t.Seconds}s" :
|
||||
$"{t.Seconds}s";
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"Sync": {
|
||||
"BaseUrl": "https://www.prismaticpowders.com",
|
||||
"ColorsPath": "/shop/powder-coating-colors",
|
||||
|
||||
"ProductUrlsFile": "product-urls.txt",
|
||||
"OutputJsonFile": "prismatic_powders.json",
|
||||
"LogFile": "prismatic-sync.log",
|
||||
|
||||
"MinDelaySeconds": 6,
|
||||
"MaxDelaySeconds": 14,
|
||||
"PageSettleSeconds": 4,
|
||||
|
||||
"BlockedCooldownSeconds": 120,
|
||||
"BlockedCooldownMaxSeconds": 600,
|
||||
"BlockedMaxRetries": 3,
|
||||
"LongRestEveryProducts": 150,
|
||||
"LongRestSeconds": 45,
|
||||
|
||||
"ScrollWaitMs": 1500,
|
||||
"MaxScrolls": 400,
|
||||
"StopAfterNoNewScrolls": 10,
|
||||
"StopAfterKnownScrolls": 8,
|
||||
|
||||
"ColorParams": [
|
||||
"pris_black", "pris_blue", "pris_bronze", "pris_brown", "pris_clear",
|
||||
"pris_copper", "pris_gold", "pris_gray", "pris_green", "pris_orange",
|
||||
"pris_pink", "pris_purple", "pris_red", "pris_silver", "pris_tan",
|
||||
"pris_white", "pris_yellow"
|
||||
],
|
||||
|
||||
"Import": {
|
||||
"EndpointUrl": "",
|
||||
"Token": "",
|
||||
"VendorName": "Prismatic Powders"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
namespace PowderCoating.Application.Constants;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static class ColumbiaIntegrationConstants
|
||||
{
|
||||
// ── Configuration keys (appsettings.json / environment) ───────────────
|
||||
/// <summary>API key — secret, lives in config not the platform-settings DB.</summary>
|
||||
public const string ConfigApiKey = "Columbia:ApiKey";
|
||||
public const string ConfigBaseUrl = "Columbia:BaseUrl";
|
||||
|
||||
/// <summary>Configurable API namespace/base path, so an API version bump is a config change.</summary>
|
||||
public const string ConfigApiBasePath = "Columbia:ApiBasePath";
|
||||
|
||||
public const string DefaultBaseUrl = "https://columbiacoatings.com";
|
||||
public const string DefaultApiBasePath = "/wp-json/cca/v1";
|
||||
|
||||
/// <summary>Resource segment appended to the API base path for product endpoints.</summary>
|
||||
public const string ProductsResource = "/products";
|
||||
|
||||
/// <summary>API caps per_page at 100.</summary>
|
||||
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 ────────────────────────────────────────────────────────
|
||||
/// <summary>Stored in <c>PowderCatalogItem.Source</c> — the purge key for right-to-delete.</summary>
|
||||
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";
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace PowderCoating.Application.DTOs.Columbia;
|
||||
|
||||
/// <summary>
|
||||
/// Tolerant converter for Columbia image fields. WordPress/WooCommerce returns an object
|
||||
/// (<c>{id,src,name,alt}</c>) when an image is present, but an empty array (<c>[]</c>) — or
|
||||
/// sometimes <c>false</c>/empty string — when it is not. A single <see cref="ColumbiaImage"/>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
public class ColumbiaImageJsonConverter : JsonConverter<ColumbiaImage?>
|
||||
{
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace PowderCoating.Application.DTOs.Columbia;
|
||||
|
||||
/// <summary>
|
||||
/// One page of the Columbia Coatings <c>GET /products</c> 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 <see cref="JsonSerializerOptions"/>.
|
||||
/// </summary>
|
||||
public class ColumbiaProductsResponse
|
||||
{
|
||||
public List<ColumbiaProduct> Items { get; set; } = new();
|
||||
public ColumbiaPagination? Pagination { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Pagination block returned alongside a product page.</summary>
|
||||
public class ColumbiaPagination
|
||||
{
|
||||
public int Page { get; set; }
|
||||
public int PerPage { get; set; }
|
||||
public int Total { get; set; }
|
||||
public int TotalPages { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single Columbia Coatings product as returned by the API. This mirrors the wire shape, not our
|
||||
/// catalog model — mapping into <c>PowderCatalogItem</c> happens in the sync mapper. Prices arrive
|
||||
/// as strings; cure/spec fields are free text; documents are direct URLs.
|
||||
/// </summary>
|
||||
public class ColumbiaProduct
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
/// <summary>"simple" or "variable". Variable products carry packaging/size variants in
|
||||
/// <see cref="VariationPricing"/> and leave <see cref="TieredPricing"/> as an empty array.</summary>
|
||||
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;
|
||||
|
||||
/// <summary>Columbia-specific values seen include "In Stock"/"instock", "formulated",
|
||||
/// "drop_shipped", "multiple_variations", "outofstock", "onbackorder" — mixed casing.</summary>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Quantity-break pricing. POLYMORPHIC on the wire: an object
|
||||
/// (<c>{type, minimum_quantity, tiers:[...]}</c>) on simple products, but an empty ARRAY
|
||||
/// (<c>[]</c>) on variable products. Captured as a nullable raw <see cref="JsonElement"/> so
|
||||
/// deserialization never throws on the type mismatch and an absent value is null (not an
|
||||
/// invalid <c>Undefined</c> element); the mapper interprets it.
|
||||
/// </summary>
|
||||
public JsonElement? TieredPricing { get; set; }
|
||||
|
||||
/// <summary>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.</summary>
|
||||
public List<ColumbiaVariationPricing>? 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;
|
||||
|
||||
/// <summary>Resin chemistry (e.g. "Polyester/TGIC", "TGIC", "Epoxy"). NOT finish/gloss.</summary>
|
||||
public string Type { get; set; } = string.Empty;
|
||||
|
||||
public string ReleaseDate { get; set; } = string.Empty;
|
||||
public string FormulationDate { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Free-text reformulation log, e.g. "Formulation Change: 05/22/26".</summary>
|
||||
public string FormulationDateChanges { get; set; } = string.Empty;
|
||||
|
||||
// ── Content ───────────────────────────────────────────────────────────
|
||||
/// <summary>HTML product description (WordPress markup).</summary>
|
||||
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<ColumbiaNamed> Categories { get; set; } = new();
|
||||
public List<ColumbiaNamed> Tags { get; set; } = new();
|
||||
public List<ColumbiaNamed> PaColorGroup { get; set; } = new();
|
||||
public List<ColumbiaAttribute> Attributes { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>A pricing variant of a variable product (own SKU, own price/tiers).</summary>
|
||||
public class ColumbiaVariationPricing
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Sku { get; set; } = string.Empty;
|
||||
public List<ColumbiaAttributeValue> 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;
|
||||
|
||||
/// <summary>Same polymorphic object-or-array shape as the parent; captured raw (nullable).</summary>
|
||||
public JsonElement? TieredPricing { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>An image object — featured or gallery.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>A simple <c>{name}</c> taxonomy entry (category, tag, or color group).</summary>
|
||||
public class ColumbiaNamed
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>A product attribute with its option list, e.g. Color Group → [Blue], Packaging → [Bulk, 1 lb Bags].</summary>
|
||||
public class ColumbiaAttribute
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public List<ColumbiaNamed> Options { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>A resolved attribute value on a specific variation, e.g. Packaging → "Bulk".</summary>
|
||||
public class ColumbiaAttributeValue
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Value { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -37,6 +37,7 @@ public class PaymentDtos
|
||||
public string? Reference { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public int? DepositAccountId { get; set; }
|
||||
public bool SuppressNotification { get; set; }
|
||||
}
|
||||
|
||||
public class EditPaymentDto
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
namespace PowderCoating.Application.DTOs.Terminal
|
||||
{
|
||||
/// <summary>
|
||||
/// Minimal postal address used to create a Stripe Terminal Location. Kept in the Application
|
||||
/// layer so <c>IStripeConnectService</c> doesn't leak Stripe SDK types to controllers.
|
||||
/// </summary>
|
||||
public class TerminalAddressDto
|
||||
{
|
||||
public string Line1 { get; set; } = string.Empty;
|
||||
public string City { get; set; } = string.Empty;
|
||||
public string State { get; set; } = string.Empty;
|
||||
public string PostalCode { get; set; } = string.Empty;
|
||||
public string Country { get; set; } = "US";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A Stripe Terminal reader as returned by the Stripe API, projected to a plain DTO for the
|
||||
/// settings page and reconciliation. Stripe remains the source of truth for live network status.
|
||||
/// </summary>
|
||||
public class TerminalReaderDto
|
||||
{
|
||||
public string StripeReaderId { get; set; } = string.Empty;
|
||||
public string Label { get; set; } = string.Empty;
|
||||
public string DeviceType { get; set; } = string.Empty;
|
||||
public string? SerialNumber { get; set; }
|
||||
public string? NetworkStatus { get; set; } // "online" / "offline"
|
||||
public DateTime? LastSeenAt { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
namespace PowderCoating.Application.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public interface IColumbiaCatalogSyncService
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
Task<ColumbiaSyncResult> RunSyncAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>Outcome of a Columbia catalog sync run.</summary>
|
||||
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; }
|
||||
|
||||
/// <summary>One-line summary suitable for storing in the last-result platform setting / UI.</summary>
|
||||
public string Summary =>
|
||||
Success
|
||||
? $"{TotalFetched} fetched: {Inserted} new, {Updated} updated, {Unchanged} unchanged, " +
|
||||
$"{Discontinued} discontinued, {Reactivated} reactivated ({Duration.TotalSeconds:F0}s)"
|
||||
: $"Failed: {ErrorMessage}";
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using PowderCoating.Application.DTOs.Columbia;
|
||||
|
||||
namespace PowderCoating.Application.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Typed client for the Columbia Coatings product catalog API (<c>/wp-json/cca/v1</c>).
|
||||
/// Read-only: lists products via the paged <c>GET /products</c> endpoint.
|
||||
/// <para>
|
||||
/// We deliberately page <c>/products</c> rather than using the bulk <c>export.json</c> download:
|
||||
/// the export returns a temporary <c>download_url</c> to a static file under <c>/wp-content/uploads</c>,
|
||||
/// which sits behind Cloudflare bot protection and 403s for non-browser clients. The
|
||||
/// <c>/wp-json</c> API routes are allowlisted via the API key, so paging is the only path that
|
||||
/// works reliably from a server.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public interface IColumbiaCoatingsApiClient
|
||||
{
|
||||
/// <summary>
|
||||
/// True when an API key is configured (<c>Columbia:ApiKey</c>). When false, callers should
|
||||
/// skip the sync entirely rather than issue unauthenticated requests.
|
||||
/// </summary>
|
||||
bool IsConfigured { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a single page of products. <paramref name="perPage"/> is capped at 100 by the API.
|
||||
/// </summary>
|
||||
Task<ColumbiaProductsResponse> GetProductsPageAsync(int page, int perPage, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ColumbiaProduct>> GetAllProductsAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Fetches a single product by exact SKU (<c>GET /products?sku=...</c>), or null if not found.
|
||||
/// For ad-hoc refresh of one record without pulling the whole catalog.
|
||||
/// </summary>
|
||||
Task<ColumbiaProduct?> GetProductBySkuAsync(string sku, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Fetches a single product by WooCommerce product ID (<c>GET /products/{id}</c>), or null if
|
||||
/// not found. Useful when we already store the catalog product's ID and want to refresh it.
|
||||
/// </summary>
|
||||
Task<ColumbiaProduct?> GetProductByIdAsync(int id, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -59,9 +59,17 @@ public interface IInventoryAiLookupService
|
||||
Task<InventoryAiLookupResult> ScanLabelAsync(string base64Image, string mediaType);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
Task<InventoryAiLookupResult> FetchTdsCureSpecsAsync(string tdsUrl, string? colorName);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
Task<bool> EnsureCatalogTdsSpecsAsync(PowderCoating.Core.Entities.PowderCatalogItem catalog);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
using PowderCoating.Core.Entities;
|
||||
|
||||
namespace PowderCoating.Application.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public interface IPowderCatalogUpsertService
|
||||
{
|
||||
/// <summary>
|
||||
/// Applies <paramref name="incoming"/> 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 <paramref name="runTimestamp"/> stamped on LastSyncedAt/UpdatedAt.
|
||||
/// </summary>
|
||||
Task<PowderCatalogUpsertResult> UpsertAsync(
|
||||
IReadOnlyList<PowderCatalogItem> incoming,
|
||||
DateTime runTimestamp,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>Counts from an upsert run.</summary>
|
||||
public class PowderCatalogUpsertResult
|
||||
{
|
||||
public int Inserted { get; set; }
|
||||
public int Updated { get; set; }
|
||||
public int Unchanged { get; set; }
|
||||
public int Skipped { get; set; }
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
using PowderCoating.Application.DTOs.Terminal;
|
||||
|
||||
namespace PowderCoating.Application.Interfaces;
|
||||
|
||||
public interface IStripeConnectService
|
||||
@@ -36,75 +34,4 @@ public interface IStripeConnectService
|
||||
string currency,
|
||||
string quoteNumber,
|
||||
int quoteId);
|
||||
|
||||
// ----- Stripe Terminal (in-person card payments, WisePOS E) -----
|
||||
// All methods route to the connected account via RequestOptions.StripeAccount, mirroring the
|
||||
// online payment methods above. They return structured tuples instead of throwing.
|
||||
|
||||
/// <summary>
|
||||
/// Creates the shop's single Stripe Terminal Location (one per company) from its address.
|
||||
/// Readers must be attached to a Location. Returns the new Location id (tml_xxx).
|
||||
/// </summary>
|
||||
Task<(bool Success, string? LocationId, string? ErrorMessage)> CreateTerminalLocationAsync(
|
||||
string connectedAccountId,
|
||||
string displayName,
|
||||
TerminalAddressDto address);
|
||||
|
||||
/// <summary>
|
||||
/// Registers a physical (or simulated) reader to the shop's Location using the registration
|
||||
/// code shown on the device. Returns the reader id (tmr_xxx), device type and serial number.
|
||||
/// </summary>
|
||||
Task<(bool Success, string? ReaderId, string? DeviceType, string? SerialNumber, string? ErrorMessage)> RegisterReaderAsync(
|
||||
string connectedAccountId,
|
||||
string locationId,
|
||||
string registrationCode,
|
||||
string label);
|
||||
|
||||
/// <summary>Lists the readers attached to the shop's Location (for status refresh/reconciliation).</summary>
|
||||
Task<(bool Success, IReadOnlyList<TerminalReaderDto> Readers, string? ErrorMessage)> ListReadersAsync(
|
||||
string connectedAccountId,
|
||||
string locationId);
|
||||
|
||||
/// <summary>Unregisters (deletes) a reader from Stripe.</summary>
|
||||
Task<(bool Success, string? ErrorMessage)> DeleteReaderAsync(
|
||||
string connectedAccountId,
|
||||
string readerId);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a card_present PaymentIntent for an invoice and pushes it to the physical reader,
|
||||
/// which then prompts the customer to tap/insert/swipe. Metadata carries <c>source=terminal</c>
|
||||
/// so the existing <c>payment_intent.succeeded</c> webhook records it as a card-reader payment.
|
||||
/// Returns the PaymentIntent id so the caller can store it on the invoice for idempotency.
|
||||
/// </summary>
|
||||
Task<(bool Success, string? PaymentIntentId, string? ErrorMessage)> ProcessInvoicePaymentOnReaderAsync(
|
||||
string connectedAccountId,
|
||||
string readerId,
|
||||
decimal amount,
|
||||
decimal surchargeAmount,
|
||||
string currency,
|
||||
string invoiceNumber,
|
||||
int invoiceId);
|
||||
|
||||
/// <summary>
|
||||
/// Reads the reader's current action status for live UI feedback. The authoritative payment
|
||||
/// record is still created by the webhook — this is only for showing progress to the clerk.
|
||||
/// </summary>
|
||||
Task<(bool Success, string? ActionStatus, string? ActionType, string? PaymentIntentId,
|
||||
string? FailureCode, string? FailureMessage, string? NetworkStatus, string? ErrorMessage)> GetReaderStatusAsync(
|
||||
string connectedAccountId,
|
||||
string readerId);
|
||||
|
||||
/// <summary>Cancels the reader's in-progress action (clerk cancelled or wants to retry).</summary>
|
||||
Task<(bool Success, string? ErrorMessage)> CancelReaderActionAsync(
|
||||
string connectedAccountId,
|
||||
string readerId);
|
||||
|
||||
/// <summary>
|
||||
/// TEST MODE ONLY: simulates a card tap on a simulated reader so the payment can complete
|
||||
/// without physical hardware. Uses the Stripe TestHelpers Terminal API. Callers must guard
|
||||
/// this to test mode; it is a no-op against real readers.
|
||||
/// </summary>
|
||||
Task<(bool Success, string? ErrorMessage)> SimulatePresentPaymentMethodAsync(
|
||||
string connectedAccountId,
|
||||
string readerId);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -45,11 +45,6 @@ public class Company : BaseEntity
|
||||
public decimal OnlinePaymentSurchargeValue { get; set; } = 0; // % or flat $ depending on type
|
||||
public bool OnlineSurchargeAcknowledged { get; set; } = false; // shop accepted compliance disclaimer
|
||||
|
||||
// Stripe Terminal — in-person card payments (WisePOS E). Runs on the same connected account
|
||||
// as online payments; a single Terminal Location is created once per shop from its address.
|
||||
public string? StripeTerminalLocationId { get; set; } // tml_xxx
|
||||
public bool TerminalSurchargeEnabled { get; set; } = false; // default OFF — in-person surcharge rules vary by state
|
||||
|
||||
/// <summary>Internal notes about manual subscription changes (not shown to the company).</summary>
|
||||
public string? SubscriptionNotes { get; set; }
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
public int? PowderCatalogItemId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <see cref="UnitCost"/>/<see cref="AverageCost"/> (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.
|
||||
/// </summary>
|
||||
public decimal? CatalogReferencePrice { get; set; }
|
||||
|
||||
/// <summary>Timestamp (UTC) when <see cref="CatalogReferencePrice"/> was last refreshed by the sync.</summary>
|
||||
public DateTime? CatalogPriceUpdatedAt { get; set; }
|
||||
|
||||
// Sample Panel Tracking (coating category items only)
|
||||
public bool HasSamplePanel { get; set; } = false;
|
||||
|
||||
|
||||
@@ -40,9 +40,30 @@ public class PowderCatalogItem
|
||||
/// <summary>Cure hold time at cure temperature, in minutes.</summary>
|
||||
public int? CureTimeMinutes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
public string? CureScheduleText { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="CureTemperatureF"/>/<see cref="CureTimeMinutes"/>.
|
||||
/// </summary>
|
||||
public string? CureCurvesJson { get; set; }
|
||||
|
||||
/// <summary>Finish type — e.g. Gloss, Matte, Satin, Metallic, Texture.</summary>
|
||||
public string? Finish { get; set; }
|
||||
|
||||
/// <summary>Resin chemistry — e.g. "Polyester", "TGIC", "Epoxy", "Hybrid". Distinct from <see cref="Finish"/>.</summary>
|
||||
public string? ChemistryType { get; set; }
|
||||
|
||||
/// <summary>Recommended film build (mil thickness) as free text from the vendor — e.g. "2.0-3.0 Mils".</summary>
|
||||
public string? MilThickness { get; set; }
|
||||
|
||||
/// <summary>Comma-separated color family tags — e.g. "Blue,Purple".</summary>
|
||||
public string? ColorFamilies { get; set; }
|
||||
|
||||
@@ -60,6 +81,29 @@ public class PowderCatalogItem
|
||||
|
||||
// ── Catalog management ────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public string? Category { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Provenance of this record — e.g. "Columbia Coatings API". Kept SEPARATE from
|
||||
/// <see cref="VendorName"/> (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.
|
||||
/// </summary>
|
||||
public string? Source { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public string? FormulationChanges { get; set; }
|
||||
|
||||
/// <summary>True when the vendor has discontinued this product. Kept for historical lookups.</summary>
|
||||
public bool IsDiscontinued { get; set; } = false;
|
||||
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
using PowderCoating.Core.Enums;
|
||||
|
||||
namespace PowderCoating.Core.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// A Stripe Terminal card reader (e.g. a BBPOS WisePOS E) registered to a company for in-person
|
||||
/// card payments. The reader lives on the company's Stripe Connect connected account and is attached
|
||||
/// to the company's single Terminal Location (<see cref="Company.StripeTerminalLocationId"/>).
|
||||
/// <para>
|
||||
/// We mirror only the identifiers and a friendly label locally; Stripe remains the source of truth
|
||||
/// for live network status. <see cref="Status"/> is a local lifecycle flag (Active/Deactivated),
|
||||
/// separate from Stripe's transient online/offline network state captured in
|
||||
/// <see cref="LastKnownNetworkStatus"/>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class TerminalReader : BaseEntity
|
||||
{
|
||||
/// <summary>Stripe reader id (tmr_xxx) returned when the reader is registered.</summary>
|
||||
public string StripeReaderId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Stripe Terminal Location id (tml_xxx) the reader is attached to — denormalized copy of <see cref="Company.StripeTerminalLocationId"/>.</summary>
|
||||
public string StripeLocationId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Shop-friendly name, e.g. "Front Counter".</summary>
|
||||
public string Label { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Stripe device type, e.g. "bbpos_wisepos_e" or "simulated_wisepos_e" (test mode).</summary>
|
||||
public string DeviceType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Hardware serial number reported by Stripe, when available.</summary>
|
||||
public string? SerialNumber { get; set; }
|
||||
|
||||
/// <summary>Local lifecycle state — whether the shop still uses this reader.</summary>
|
||||
public TerminalReaderStatus Status { get; set; } = TerminalReaderStatus.Active;
|
||||
|
||||
/// <summary>Last network status snapshot from Stripe ("online"/"offline"), refreshed on poll. Advisory only.</summary>
|
||||
public string? LastKnownNetworkStatus { get; set; }
|
||||
|
||||
/// <summary>When Stripe last saw the reader online, from the last status refresh.</summary>
|
||||
public DateTime? LastSeenAt { get; set; }
|
||||
}
|
||||
@@ -33,18 +33,7 @@ public enum PaymentMethod
|
||||
CreditDebitCard = 2,
|
||||
BankTransferACH = 3,
|
||||
DigitalPayment = 4,
|
||||
StoreCredit = 5, // Refund issued as store credit (creates a CreditMemo)
|
||||
CardReader = 6 // In-person card payment via a Stripe Terminal reader (WisePOS E)
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Local lifecycle state for a registered Stripe Terminal card reader. Distinct from Stripe's
|
||||
/// network status (online/offline) — this tracks whether the shop still uses the reader.
|
||||
/// </summary>
|
||||
public enum TerminalReaderStatus
|
||||
{
|
||||
Active = 0,
|
||||
Deactivated = 1 // Unregistered from Stripe and hidden from the shop's reader list
|
||||
StoreCredit = 5 // Refund issued as store credit (creates a CreditMemo)
|
||||
}
|
||||
|
||||
public enum GiftCertificateStatus
|
||||
|
||||
@@ -79,7 +79,6 @@ IRepository<ReworkRecord> ReworkRecords { get; }
|
||||
IRepository<InvoiceItem> InvoiceItems { get; }
|
||||
IRepository<Payment> Payments { get; }
|
||||
IRepository<Deposit> Deposits { get; }
|
||||
IRepository<TerminalReader> TerminalReaders { get; }
|
||||
|
||||
// Purchase Orders — typed repository for paged/filtered list and detail load
|
||||
IPurchaseOrderRepository PurchaseOrders { get; }
|
||||
|
||||
@@ -321,8 +321,6 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
||||
/// Auto-applied to the invoice when it is created (unapplied deposits are swept into Payment records).
|
||||
/// </summary>
|
||||
public DbSet<Deposit> Deposits { get; set; }
|
||||
/// <summary>Registered Stripe Terminal card readers (WisePOS E) for in-person payments; tenant-filtered with soft delete.</summary>
|
||||
public DbSet<TerminalReader> TerminalReaders { get; set; }
|
||||
|
||||
// Purchase Orders
|
||||
/// <summary>Purchase orders issued to vendors; tenant-filtered with soft delete.</summary>
|
||||
@@ -658,8 +656,6 @@ modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<Deposit>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<TerminalReader>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
|
||||
// Deposit → Invoice (nullable, no cascade)
|
||||
modelBuilder.Entity<Deposit>()
|
||||
@@ -1515,6 +1511,9 @@ modelBuilder.Entity<Job>()
|
||||
modelBuilder.Entity<InventoryItem>()
|
||||
.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<Company>()
|
||||
@@ -1916,15 +1915,6 @@ modelBuilder.Entity<Job>()
|
||||
.HasIndex(p => new { p.CompanyId, p.PaymentDate })
|
||||
.HasDatabaseName("IX_Payments_CompanyId_PaymentDate");
|
||||
|
||||
// Terminal readers — looked up by Stripe id (webhook/registration) and by company (settings list)
|
||||
modelBuilder.Entity<TerminalReader>()
|
||||
.HasIndex(r => r.StripeReaderId)
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_TerminalReaders_StripeReaderId");
|
||||
modelBuilder.Entity<TerminalReader>()
|
||||
.HasIndex(r => r.CompanyId)
|
||||
.HasDatabaseName("IX_TerminalReaders_CompanyId");
|
||||
|
||||
modelBuilder.Entity<NotificationLog>()
|
||||
.HasOne<Company>()
|
||||
.WithMany()
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddTerminalReaders : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "StripeTerminalLocationId",
|
||||
table: "Companies",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "TerminalSurchargeEnabled",
|
||||
table: "Companies",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "TerminalReaders",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
StripeReaderId = table.Column<string>(type: "nvarchar(450)", nullable: false),
|
||||
StripeLocationId = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
Label = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
DeviceType = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
SerialNumber = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
Status = table.Column<int>(type: "int", nullable: false),
|
||||
LastKnownNetworkStatus = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
LastSeenAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CompanyId = table.Column<int>(type: "int", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_TerminalReaders", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 15, 22, 29, 10, 622, DateTimeKind.Utc).AddTicks(5996));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 15, 22, 29, 10, 622, DateTimeKind.Utc).AddTicks(6003));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 15, 22, 29, 10, 622, DateTimeKind.Utc).AddTicks(6004));
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TerminalReaders_CompanyId",
|
||||
table: "TerminalReaders",
|
||||
column: "CompanyId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TerminalReaders_StripeReaderId",
|
||||
table: "TerminalReaders",
|
||||
column: "StripeReaderId",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "TerminalReaders");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "StripeTerminalLocationId",
|
||||
table: "Companies");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TerminalSurchargeEnabled",
|
||||
table: "Companies");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 14, 1, 21, 46, 131, DateTimeKind.Utc).AddTicks(4191));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 14, 1, 21, 46, 131, DateTimeKind.Utc).AddTicks(4196));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 14, 1, 21, 46, 131, DateTimeKind.Utc).AddTicks(4197));
|
||||
}
|
||||
}
|
||||
}
|
||||
+11375
File diff suppressed because it is too large
Load Diff
+141
@@ -0,0 +1,141 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddColumbiaCatalogIntegrationFields : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Category",
|
||||
table: "PowderCatalogItems",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ChemistryType",
|
||||
table: "PowderCatalogItems",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "CureCurvesJson",
|
||||
table: "PowderCatalogItems",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "CureScheduleText",
|
||||
table: "PowderCatalogItems",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "FormulationChanges",
|
||||
table: "PowderCatalogItems",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "MilThickness",
|
||||
table: "PowderCatalogItems",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Source",
|
||||
table: "PowderCatalogItems",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "PowderCatalogItemId",
|
||||
table: "InventoryItems",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 17, 14, 21, 27, 126, DateTimeKind.Utc).AddTicks(6644));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 17, 14, 21, 27, 126, DateTimeKind.Utc).AddTicks(6651));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 17, 14, 21, 27, 126, DateTimeKind.Utc).AddTicks(6652));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Category",
|
||||
table: "PowderCatalogItems");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ChemistryType",
|
||||
table: "PowderCatalogItems");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CureCurvesJson",
|
||||
table: "PowderCatalogItems");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CureScheduleText",
|
||||
table: "PowderCatalogItems");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "FormulationChanges",
|
||||
table: "PowderCatalogItems");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "MilThickness",
|
||||
table: "PowderCatalogItems");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Source",
|
||||
table: "PowderCatalogItems");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PowderCatalogItemId",
|
||||
table: "InventoryItems");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 14, 1, 21, 46, 131, DateTimeKind.Utc).AddTicks(4191));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 14, 1, 21, 46, 131, DateTimeKind.Utc).AddTicks(4196));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 14, 1, 21, 46, 131, DateTimeKind.Utc).AddTicks(4197));
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+11375
File diff suppressed because it is too large
Load Diff
+88
@@ -0,0 +1,88 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class SeedColumbiaSyncSettings : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// Seed the SuperAdmin-managed platform settings for the Columbia Coatings catalog sync.
|
||||
// Idempotent so it is safe against a DB where keys were added manually. The API key
|
||||
// itself is NOT here — secrets live in configuration (Columbia:ApiKey), not this table.
|
||||
migrationBuilder.Sql(@"
|
||||
IF NOT EXISTS (SELECT 1 FROM PlatformSettings WHERE [Key] = 'ColumbiaSyncEnabled')
|
||||
INSERT INTO PlatformSettings ([Key],[Value],[Label],[Description],[GroupName])
|
||||
VALUES ('ColumbiaSyncEnabled','false','Columbia Coatings Sync Enabled','Master switch for the scheduled Columbia Coatings catalog sync. When off, no automatic or manual sync runs regardless of the configured API key.','Integrations');
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM PlatformSettings WHERE [Key] = 'ColumbiaSyncIntervalDays')
|
||||
INSERT INTO PlatformSettings ([Key],[Value],[Label],[Description],[GroupName])
|
||||
VALUES ('ColumbiaSyncIntervalDays','7','Columbia Sync Interval (days)','How many days between automatic Columbia catalog syncs. A full sync is cheap (~25 API calls), so daily (1) or weekly (7) keeps pricing fresh.','Integrations');
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM PlatformSettings WHERE [Key] = 'ColumbiaLastSyncedAt')
|
||||
INSERT INTO PlatformSettings ([Key],[Value],[Label],[Description],[GroupName])
|
||||
VALUES ('ColumbiaLastSyncedAt',NULL,'Columbia Last Synced At','Timestamp (UTC) of the last successful Columbia catalog sync. Set automatically by the sync job.','Integrations');
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM PlatformSettings WHERE [Key] = 'ColumbiaLastSyncResult')
|
||||
INSERT INTO PlatformSettings ([Key],[Value],[Label],[Description],[GroupName])
|
||||
VALUES ('ColumbiaLastSyncResult',NULL,'Columbia Last Sync Result','Summary of the last Columbia catalog sync run (inserted/updated/discontinued counts or error). Set automatically by the sync job.','Integrations');
|
||||
");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 17, 14, 44, 40, 147, DateTimeKind.Utc).AddTicks(5997));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 17, 14, 44, 40, 147, DateTimeKind.Utc).AddTicks(6002));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 17, 14, 44, 40, 147, DateTimeKind.Utc).AddTicks(6003));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(@"
|
||||
DELETE FROM PlatformSettings WHERE [Key] IN (
|
||||
'ColumbiaSyncEnabled','ColumbiaSyncIntervalDays','ColumbiaLastSyncedAt','ColumbiaLastSyncResult'
|
||||
);
|
||||
");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 17, 14, 21, 27, 126, DateTimeKind.Utc).AddTicks(6644));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 17, 14, 21, 27, 126, DateTimeKind.Utc).AddTicks(6651));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 17, 14, 21, 27, 126, DateTimeKind.Utc).AddTicks(6652));
|
||||
}
|
||||
}
|
||||
}
|
||||
+11376
File diff suppressed because it is too large
Load Diff
+82
@@ -0,0 +1,82 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class FilterInventorySkuUniqueIndexOnSoftDelete : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_InventoryItems_CompanyId_SKU",
|
||||
table: "InventoryItems");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 17, 16, 33, 6, 662, DateTimeKind.Utc).AddTicks(4251));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 17, 16, 33, 6, 662, DateTimeKind.Utc).AddTicks(4258));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 17, 16, 33, 6, 662, DateTimeKind.Utc).AddTicks(4259));
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_InventoryItems_CompanyId_SKU",
|
||||
table: "InventoryItems",
|
||||
columns: new[] { "CompanyId", "SKU" },
|
||||
unique: true,
|
||||
filter: "[IsDeleted] = 0");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_InventoryItems_CompanyId_SKU",
|
||||
table: "InventoryItems");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 17, 14, 44, 40, 147, DateTimeKind.Utc).AddTicks(5997));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 17, 14, 44, 40, 147, DateTimeKind.Utc).AddTicks(6002));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 17, 14, 44, 40, 147, DateTimeKind.Utc).AddTicks(6003));
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_InventoryItems_CompanyId_SKU",
|
||||
table: "InventoryItems",
|
||||
columns: new[] { "CompanyId", "SKU" },
|
||||
unique: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
+37
-84
@@ -12,8 +12,8 @@ using PowderCoating.Infrastructure.Data;
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
[DbContext(typeof(ApplicationDbContext))]
|
||||
[Migration("20260615222914_AddTerminalReaders")]
|
||||
partial class AddTerminalReaders
|
||||
[Migration("20260617192646_AddInventoryCatalogReferencePrice")]
|
||||
partial class AddInventoryCatalogReferencePrice
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
@@ -1911,9 +1911,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<string>("StripeSubscriptionId")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("StripeTerminalLocationId")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("SubscriptionEndDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
@@ -1929,9 +1926,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<int>("SubscriptionStatus")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<bool>("TerminalSurchargeEnabled")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("TimeZone")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
@@ -4084,6 +4078,12 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<decimal>("AverageCost")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<DateTime?>("CatalogPriceUpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<decimal?>("CatalogReferencePrice")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
@@ -4182,6 +4182,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int?>("PowderCatalogItemId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int?>("PrimaryVendorId")
|
||||
.HasColumnType("int");
|
||||
|
||||
@@ -4250,7 +4253,8 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
|
||||
b.HasIndex("CompanyId", "SKU")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_InventoryItems_CompanyId_SKU");
|
||||
.HasDatabaseName("IX_InventoryItems_CompanyId_SKU")
|
||||
.HasFilter("[IsDeleted] = 0");
|
||||
|
||||
b.HasIndex("CompanyId", "QuantityOnHand", "ReorderPoint")
|
||||
.HasDatabaseName("IX_InventoryItems_CompanyId_Quantity_Reorder");
|
||||
@@ -6945,6 +6949,12 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<string>("ApplicationGuideUrl")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ChemistryType")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ColorFamilies")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
@@ -6958,6 +6968,12 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("CureCurvesJson")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("CureScheduleText")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<decimal?>("CureTemperatureF")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
@@ -6970,6 +6986,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<string>("Finish")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("FormulationChanges")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ImageUrl")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
@@ -6982,6 +7001,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<DateTime?>("LastSyncedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("MilThickness")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("PriceTiersJson")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
@@ -6998,6 +7020,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<string>("Source")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<decimal?>("SpecificGravity")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
@@ -7219,7 +7244,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 1,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 6, 15, 22, 29, 10, 622, DateTimeKind.Utc).AddTicks(5996),
|
||||
CreatedAt = new DateTime(2026, 6, 17, 19, 26, 43, 161, DateTimeKind.Utc).AddTicks(2051),
|
||||
Description = "Standard pricing for regular customers",
|
||||
DiscountPercent = 0m,
|
||||
IsActive = true,
|
||||
@@ -7230,7 +7255,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 2,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 6, 15, 22, 29, 10, 622, DateTimeKind.Utc).AddTicks(6003),
|
||||
CreatedAt = new DateTime(2026, 6, 17, 19, 26, 43, 161, DateTimeKind.Utc).AddTicks(2056),
|
||||
Description = "5% discount for preferred customers",
|
||||
DiscountPercent = 5m,
|
||||
IsActive = true,
|
||||
@@ -7241,7 +7266,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 3,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 6, 15, 22, 29, 10, 622, DateTimeKind.Utc).AddTicks(6004),
|
||||
CreatedAt = new DateTime(2026, 6, 17, 19, 26, 43, 161, DateTimeKind.Utc).AddTicks(2057),
|
||||
Description = "10% discount for premium customers",
|
||||
DiscountPercent = 10m,
|
||||
IsActive = true,
|
||||
@@ -8747,78 +8772,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.ToTable("TaxRates");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.TerminalReader", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("CompanyId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("DeletedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("DeviceType")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("Label")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("LastKnownNetworkStatus")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("LastSeenAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("SerialNumber")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("StripeLocationId")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("StripeReaderId")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("UpdatedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CompanyId")
|
||||
.HasDatabaseName("IX_TerminalReaders_CompanyId");
|
||||
|
||||
b.HasIndex("StripeReaderId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_TerminalReaders_StripeReaderId");
|
||||
|
||||
b.ToTable("TerminalReaders");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.TermsAcceptance", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddInventoryCatalogReferencePrice : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "CatalogPriceUpdatedAt",
|
||||
table: "InventoryItems",
|
||||
type: "datetime2",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "CatalogReferencePrice",
|
||||
table: "InventoryItems",
|
||||
type: "decimal(18,2)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 17, 19, 26, 43, 161, DateTimeKind.Utc).AddTicks(2051));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 17, 19, 26, 43, 161, DateTimeKind.Utc).AddTicks(2056));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 17, 19, 26, 43, 161, DateTimeKind.Utc).AddTicks(2057));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CatalogPriceUpdatedAt",
|
||||
table: "InventoryItems");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CatalogReferencePrice",
|
||||
table: "InventoryItems");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 17, 16, 33, 6, 662, DateTimeKind.Utc).AddTicks(4251));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 17, 16, 33, 6, 662, DateTimeKind.Utc).AddTicks(4258));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 17, 16, 33, 6, 662, DateTimeKind.Utc).AddTicks(4259));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1908,9 +1908,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<string>("StripeSubscriptionId")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("StripeTerminalLocationId")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("SubscriptionEndDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
@@ -1926,9 +1923,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<int>("SubscriptionStatus")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<bool>("TerminalSurchargeEnabled")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("TimeZone")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
@@ -4081,6 +4075,12 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<decimal>("AverageCost")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<DateTime?>("CatalogPriceUpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<decimal?>("CatalogReferencePrice")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
@@ -4179,6 +4179,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int?>("PowderCatalogItemId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int?>("PrimaryVendorId")
|
||||
.HasColumnType("int");
|
||||
|
||||
@@ -4247,7 +4250,8 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
|
||||
b.HasIndex("CompanyId", "SKU")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_InventoryItems_CompanyId_SKU");
|
||||
.HasDatabaseName("IX_InventoryItems_CompanyId_SKU")
|
||||
.HasFilter("[IsDeleted] = 0");
|
||||
|
||||
b.HasIndex("CompanyId", "QuantityOnHand", "ReorderPoint")
|
||||
.HasDatabaseName("IX_InventoryItems_CompanyId_Quantity_Reorder");
|
||||
@@ -6942,6 +6946,12 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<string>("ApplicationGuideUrl")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ChemistryType")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ColorFamilies")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
@@ -6955,6 +6965,12 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("CureCurvesJson")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("CureScheduleText")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<decimal?>("CureTemperatureF")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
@@ -6967,6 +6983,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<string>("Finish")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("FormulationChanges")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ImageUrl")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
@@ -6979,6 +6998,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<DateTime?>("LastSyncedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("MilThickness")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("PriceTiersJson")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
@@ -6995,6 +7017,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<string>("Source")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<decimal?>("SpecificGravity")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
@@ -7216,7 +7241,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 1,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 6, 15, 22, 29, 10, 622, DateTimeKind.Utc).AddTicks(5996),
|
||||
CreatedAt = new DateTime(2026, 6, 17, 19, 26, 43, 161, DateTimeKind.Utc).AddTicks(2051),
|
||||
Description = "Standard pricing for regular customers",
|
||||
DiscountPercent = 0m,
|
||||
IsActive = true,
|
||||
@@ -7227,7 +7252,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 2,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 6, 15, 22, 29, 10, 622, DateTimeKind.Utc).AddTicks(6003),
|
||||
CreatedAt = new DateTime(2026, 6, 17, 19, 26, 43, 161, DateTimeKind.Utc).AddTicks(2056),
|
||||
Description = "5% discount for preferred customers",
|
||||
DiscountPercent = 5m,
|
||||
IsActive = true,
|
||||
@@ -7238,7 +7263,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 3,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 6, 15, 22, 29, 10, 622, DateTimeKind.Utc).AddTicks(6004),
|
||||
CreatedAt = new DateTime(2026, 6, 17, 19, 26, 43, 161, DateTimeKind.Utc).AddTicks(2057),
|
||||
Description = "10% discount for premium customers",
|
||||
DiscountPercent = 10m,
|
||||
IsActive = true,
|
||||
@@ -8744,78 +8769,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.ToTable("TaxRates");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.TerminalReader", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("CompanyId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("DeletedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("DeviceType")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("Label")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("LastKnownNetworkStatus")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("LastSeenAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("SerialNumber")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("StripeLocationId")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("StripeReaderId")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("UpdatedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CompanyId")
|
||||
.HasDatabaseName("IX_TerminalReaders_CompanyId");
|
||||
|
||||
b.HasIndex("StripeReaderId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_TerminalReaders_StripeReaderId");
|
||||
|
||||
b.ToTable("TerminalReaders");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.TermsAcceptance", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
|
||||
@@ -150,7 +150,6 @@ public class UnitOfWork : IUnitOfWork
|
||||
private IRepository<InvoiceItem>? _invoiceItems;
|
||||
private IRepository<Payment>? _payments;
|
||||
private IRepository<Deposit>? _deposits;
|
||||
private IRepository<TerminalReader>? _terminalReaders;
|
||||
|
||||
// Expense Tracking / Accounts Payable
|
||||
private IRepository<Account>? _accounts;
|
||||
@@ -556,10 +555,6 @@ public class UnitOfWork : IUnitOfWork
|
||||
public IRepository<Deposit> Deposits =>
|
||||
_deposits ??= new Repository<Deposit>(_context);
|
||||
|
||||
/// <summary>Repository for <see cref="TerminalReader"/> registered Stripe Terminal card readers.</summary>
|
||||
public IRepository<TerminalReader> TerminalReaders =>
|
||||
_terminalReaders ??= new Repository<TerminalReader>(_context);
|
||||
|
||||
// Expense Tracking / Accounts Payable
|
||||
/// <summary>Repository for <see cref="Account"/> chart-of-accounts entries; supports self-referencing parent/child hierarchy.</summary>
|
||||
public IRepository<Account> Accounts =>
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using PowderCoating.Application.Constants;
|
||||
using PowderCoating.Application.DTOs.Columbia;
|
||||
using PowderCoating.Core.Entities;
|
||||
|
||||
namespace PowderCoating.Infrastructure.Services.Columbia;
|
||||
|
||||
/// <summary>
|
||||
/// Maps a Columbia Coatings API product onto our platform <see cref="PowderCatalogItem"/>.
|
||||
/// Pure, static, side-effect free so the tricky bits (manufacturer derivation, the free-text
|
||||
/// cure-schedule parser, HTML stripping) can be unit tested directly against captured fixtures.
|
||||
/// <para>
|
||||
/// Columbia is a distributor reselling multiple brands, so <see cref="PowderCatalogItem.VendorName"/>
|
||||
/// holds the DERIVED manufacturer (PPG / KP Pigments / Columbia) while
|
||||
/// <see cref="PowderCatalogItem.Source"/> records the feed ("Columbia Coatings API") for
|
||||
/// right-to-delete purges. The vendor's own categories/tags are read here only to derive the
|
||||
/// manufacturer and additive flag — they are never stored raw.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class ColumbiaCatalogMapper
|
||||
{
|
||||
/// <summary>A single parsed cure curve — hold <see cref="Minutes"/> at <see cref="TempF"/>.</summary>
|
||||
public readonly record struct CureCurve(int TempF, int Minutes);
|
||||
|
||||
// Resin chemistries that mean "polyester + TGIC" but arrive formatted three different ways.
|
||||
private static readonly Regex PolyesterTgic =
|
||||
new(@"^\s*(polyester\s*[/ ]\s*tgic|tgic\s*[/ ]\s*polyester)\s*$", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
// "10 minutes @ 400°F", "7 minutes at 375 F", "Metal Temperature: 10 minutes at 400°F (204°C)".
|
||||
// Degree glyph is optional and may be ° (U+00B0), ˚ (U+02DA), or º (U+00BA).
|
||||
private static readonly Regex CureCurveRegex =
|
||||
new(@"(\d+)\s*min(?:ute)?s?\.?\s*(?:@|at)\s*(\d{2,3})\s*[°˚º]?\s*F",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
private static readonly Regex HtmlTag = new("<[^>]+>", RegexOptions.Compiled);
|
||||
private static readonly Regex WhitespaceRun = new(@"\s{2,}", RegexOptions.Compiled);
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOut = new() { WriteIndented = false };
|
||||
|
||||
/// <summary>
|
||||
/// True for products that should not be in the powder catalog as standalone colors:
|
||||
/// physical swatch cards (not powder at all), and tester (4 oz) / sample (5 lb) listings that
|
||||
/// are just smaller SIZES of a parent powder that already exists as its own product. Detected
|
||||
/// by specific SKU suffixes (-SW / -04) and unambiguous name markers ("SWATCH", "Tester",
|
||||
/// "Sample ("). The sample-size "-S" SKU suffix is intentionally NOT used on its own — the
|
||||
/// "Sample (" name marker catches every sample without risking a real SKU that ends in -S.
|
||||
/// </summary>
|
||||
public static bool IsExcludedProduct(ColumbiaProduct p)
|
||||
{
|
||||
var sku = p.Sku ?? string.Empty;
|
||||
if (sku.EndsWith("-SW", StringComparison.OrdinalIgnoreCase)
|
||||
|| sku.EndsWith("-04", StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
|
||||
var name = p.Name ?? string.Empty;
|
||||
return name.Contains("SWATCH", StringComparison.OrdinalIgnoreCase)
|
||||
|| name.Contains("Tester", StringComparison.OrdinalIgnoreCase)
|
||||
|| name.Contains("Sample (", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>Maps a Columbia product into a fully populated (unsaved) catalog item.</summary>
|
||||
public static PowderCatalogItem Map(ColumbiaProduct p)
|
||||
{
|
||||
var curves = ParseCureCurves(p.CureSchedule);
|
||||
var primary = curves.Count > 0 ? curves[0] : (CureCurve?)null;
|
||||
|
||||
return new PowderCatalogItem
|
||||
{
|
||||
VendorName = DeriveManufacturer(p),
|
||||
Sku = p.Sku.Trim(),
|
||||
ColorName = p.Name.Trim(),
|
||||
Source = ColumbiaIntegrationConstants.SourceName,
|
||||
Category = IsAdditive(p) ? ColumbiaIntegrationConstants.CategoryPowderAdditives : null,
|
||||
|
||||
Description = StripHtml(p.Description),
|
||||
UnitPrice = ParseBasePrice(p),
|
||||
PriceTiersJson = BuildPriceTiersJson(p),
|
||||
|
||||
ImageUrl = NullIfBlank(p.FeaturedImage?.Src),
|
||||
SdsUrl = NullIfBlank(p.SafetyDataSheet),
|
||||
TdsUrl = NullIfBlank(p.TechnicalDataSheet),
|
||||
ApplicationGuideUrl = NullIfBlank(FirstNonBlank(p.ProductFlyer, p.ProductBrochure)),
|
||||
ProductUrl = NullIfBlank(p.Permalink),
|
||||
|
||||
ChemistryType = NormalizeChemistry(p.Type),
|
||||
MilThickness = NullIfBlank(p.MilThickness),
|
||||
CureScheduleText = NullIfBlank(p.CureSchedule),
|
||||
CureCurvesJson = curves.Count > 0 ? JsonSerializer.Serialize(curves, JsonOut) : null,
|
||||
CureTemperatureF = primary?.TempF,
|
||||
CureTimeMinutes = primary?.Minutes,
|
||||
RequiresClearCoat = DetectRequiresClearCoat(p),
|
||||
|
||||
ColorFamilies = BuildColorFamilies(p),
|
||||
FormulationChanges = NullIfBlank(p.FormulationDateChanges),
|
||||
|
||||
// Coverage / specific gravity / transfer efficiency are not in the API — left null for
|
||||
// lazy TDS/AI enrichment on first use. IsDiscontinued is handled by the sync sweep.
|
||||
};
|
||||
}
|
||||
|
||||
// ── Manufacturer derivation ───────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Derives the manufacturer from the product's taxonomy/SKU. Columbia resells PPG powders and
|
||||
/// KP Pigments additives through the same feed; everything else is Columbia's own brand.
|
||||
/// </summary>
|
||||
public static string DeriveManufacturer(ColumbiaProduct p)
|
||||
{
|
||||
if (IsKpPigments(p))
|
||||
return ColumbiaIntegrationConstants.ManufacturerKp;
|
||||
if (IsPpg(p))
|
||||
return ColumbiaIntegrationConstants.ManufacturerPpg;
|
||||
return ColumbiaIntegrationConstants.ManufacturerColumbia;
|
||||
}
|
||||
|
||||
private static bool IsKpPigments(ColumbiaProduct p) =>
|
||||
p.Sku.StartsWith("ADD-", StringComparison.OrdinalIgnoreCase)
|
||||
|| CategoryStartsWith(p, "KP");
|
||||
|
||||
private static bool IsPpg(ColumbiaProduct p) =>
|
||||
CategoryStartsWith(p, "PPG");
|
||||
|
||||
/// <summary>
|
||||
/// True for pigments/additives sold by weight (grams) rather than sprayed powders. These get
|
||||
/// forced into the "Powder Additives" category. Keyed off the broad Additives category and the
|
||||
/// ADD- SKU prefix, not just the KP brand (there are ~98 non-KP additives).
|
||||
/// </summary>
|
||||
public static bool IsAdditive(ColumbiaProduct p) =>
|
||||
p.Sku.StartsWith("ADD-", StringComparison.OrdinalIgnoreCase)
|
||||
|| p.Categories.Any(c => c.Name.Equals("Additives", StringComparison.OrdinalIgnoreCase))
|
||||
|| CategoryStartsWith(p, "KP");
|
||||
|
||||
private static bool CategoryStartsWith(ColumbiaProduct p, string prefix) =>
|
||||
p.Categories.Any(c => c.Name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
// ── Pricing ───────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Base unit price = the top-level <c>price</c> (falling back to <c>regular_price</c>). For
|
||||
/// variable products the parent <c>price</c> already carries the lead variant's price, while
|
||||
/// <c>regular_price</c> is often "0", so price is preferred.
|
||||
/// </summary>
|
||||
public static decimal ParseBasePrice(ColumbiaProduct p)
|
||||
{
|
||||
if (TryParseMoney(p.Price, out var price) && price > 0)
|
||||
return price;
|
||||
if (TryParseMoney(p.RegularPrice, out var regular) && regular > 0)
|
||||
return regular;
|
||||
|
||||
// Variable product with a zero parent price: fall back to the lowest variant price.
|
||||
var variantPrices = (p.VariationPricing ?? new List<ColumbiaVariationPricing>())
|
||||
.Select(v => TryParseMoney(v.Price, out var vp) ? vp : 0m)
|
||||
.Where(v => v > 0)
|
||||
.ToList();
|
||||
return variantPrices.Count > 0 ? variantPrices.Min() : 0m;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Captures quantity-break / variant pricing as JSON for later use. For variable products this
|
||||
/// is the per-variant pricing (Bulk vs 1 lb Bags, gram sizes); for simple products it's the
|
||||
/// tiered_pricing object. Null when neither is present.
|
||||
/// </summary>
|
||||
public static string? BuildPriceTiersJson(ColumbiaProduct p)
|
||||
{
|
||||
if (p.VariationPricing is { Count: > 0 })
|
||||
return JsonSerializer.Serialize(p.VariationPricing, JsonOut);
|
||||
|
||||
if (p.TieredPricing is { ValueKind: JsonValueKind.Object } tiered)
|
||||
{
|
||||
// Only keep it if it actually carries tiers (avoid storing empty {type,...} shells).
|
||||
if (tiered.TryGetProperty("tiers", out var tiers)
|
||||
&& tiers.ValueKind == JsonValueKind.Array
|
||||
&& tiers.GetArrayLength() > 0)
|
||||
{
|
||||
return tiered.GetRawText();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool TryParseMoney(string? s, out decimal value) =>
|
||||
decimal.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out value);
|
||||
|
||||
// ── Cure schedule parsing ─────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Extracts every "N minutes at/@ TTT°F" curve from a free-text cure schedule, in document
|
||||
/// order. The first is treated as the primary/standard curve; the rest are alternate (often
|
||||
/// lower-temperature) curves preserved for heat-sensitive substrates. Returns an empty list for
|
||||
/// schedules with no parseable temp/time pair (partial-cure / clear-coat instructions).
|
||||
/// </summary>
|
||||
public static List<CureCurve> ParseCureCurves(string? cureSchedule)
|
||||
{
|
||||
var result = new List<CureCurve>();
|
||||
if (string.IsNullOrWhiteSpace(cureSchedule))
|
||||
return result;
|
||||
|
||||
foreach (Match m in CureCurveRegex.Matches(cureSchedule))
|
||||
{
|
||||
if (int.TryParse(m.Groups[1].Value, out var minutes)
|
||||
&& int.TryParse(m.Groups[2].Value, out var tempF)
|
||||
&& tempF is >= 150 and <= 600 // sanity: real cure temps
|
||||
&& minutes is > 0 and <= 120)
|
||||
{
|
||||
var curve = new CureCurve(tempF, minutes);
|
||||
if (!result.Contains(curve))
|
||||
result.Add(curve);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Powders that genuinely REQUIRE a clear coat say so explicitly. A casual "apply a clear coat
|
||||
// for added durability" must NOT trip this — that over-flagged ~half the catalog and would pad
|
||||
// quotes with unnecessary clear-coat steps.
|
||||
private static readonly string[] RequiresClearPhrases =
|
||||
{
|
||||
"requires a clear", "requires clear", "require a clear",
|
||||
"must be clear coated", "must be cleared", "needs a clear",
|
||||
"clear coat is required", "clear coat required", "requires a clearcoat",
|
||||
"requires a top coat", "clear coat to activate", "clear coat to achieve",
|
||||
"requires a clear coat",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Flags powders that genuinely need a clear coat: multi-step partial-cure (Illusion-style)
|
||||
/// schedules, Columbia's named "Illusion" line, or explicit requirement phrasing. Casual
|
||||
/// "you can clear coat this" mentions are intentionally ignored.
|
||||
/// </summary>
|
||||
public static bool DetectRequiresClearCoat(ColumbiaProduct p)
|
||||
{
|
||||
var cure = p.CureSchedule ?? string.Empty;
|
||||
var name = p.Name ?? string.Empty;
|
||||
|
||||
// Partial-cure / multi-step instructions are the "apply this, then clear" case.
|
||||
if (cure.Contains("partial cure", StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
|
||||
// Columbia's Illusion line needs a clear top coat to develop the effect.
|
||||
if (name.Contains("Illusion", StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
|
||||
var text = $"{name} {cure} {p.Description}";
|
||||
return RequiresClearPhrases.Any(phrase => text.Contains(phrase, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
// ── Misc field helpers ────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Joins the color-group taxonomy ({name} entries) into a comma-separated families string.</summary>
|
||||
public static string? BuildColorFamilies(ColumbiaProduct p)
|
||||
{
|
||||
var groups = p.PaColorGroup.Select(g => g.Name.Trim()).Where(n => n.Length > 0).Distinct().ToList();
|
||||
|
||||
if (groups.Count == 0)
|
||||
{
|
||||
// Fall back to the "Color Group" attribute options when the taxonomy is empty.
|
||||
groups = p.Attributes
|
||||
.Where(a => a.Name.Equals("Color Group", StringComparison.OrdinalIgnoreCase))
|
||||
.SelectMany(a => a.Options.Select(o => o.Name.Trim()))
|
||||
.Where(n => n.Length > 0)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
}
|
||||
|
||||
return groups.Count > 0 ? string.Join(",", groups) : null;
|
||||
}
|
||||
|
||||
/// <summary>Normalizes resin chemistry — trims, and collapses the three Polyester/TGIC spellings.</summary>
|
||||
public static string? NormalizeChemistry(string? type)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(type))
|
||||
return null;
|
||||
var trimmed = type.Trim();
|
||||
return PolyesterTgic.IsMatch(trimmed) ? "Polyester/TGIC" : trimmed;
|
||||
}
|
||||
|
||||
/// <summary>Strips HTML tags/entities from a description and collapses whitespace to plain text.</summary>
|
||||
public static string? StripHtml(string? html)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(html))
|
||||
return null;
|
||||
|
||||
var text = HtmlTag.Replace(html, " ");
|
||||
text = WebUtility.HtmlDecode(text);
|
||||
text = text.Replace("\r", " ").Replace("\n", " ").Replace("\t", " ");
|
||||
text = WhitespaceRun.Replace(text, " ").Trim();
|
||||
return text.Length > 0 ? text : null;
|
||||
}
|
||||
|
||||
private static string? NullIfBlank(string? s) => string.IsNullOrWhiteSpace(s) ? null : s.Trim();
|
||||
|
||||
private static string? FirstNonBlank(params string?[] values) =>
|
||||
values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v));
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PowderCoating.Application.Constants;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
|
||||
namespace PowderCoating.Infrastructure.Services.Columbia;
|
||||
|
||||
/// <summary>
|
||||
/// Full Columbia Coatings catalog sync: pages the API, maps each product, upserts via the shared
|
||||
/// <see cref="IPowderCatalogUpsertService"/>, then reconciles discontinuations against the complete
|
||||
/// pull. The discontinuation sweep runs ONLY after a successful full fetch — a partial pull (any
|
||||
/// page failure throws from the client) aborts before the sweep so a transient error can never mass
|
||||
/// flag the catalog as discontinued.
|
||||
/// </summary>
|
||||
public class ColumbiaCatalogSyncService : IColumbiaCatalogSyncService
|
||||
{
|
||||
private readonly IColumbiaCoatingsApiClient _client;
|
||||
private readonly IPowderCatalogUpsertService _upsert;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IPlatformSettingsService _settings;
|
||||
private readonly ILogger<ColumbiaCatalogSyncService> _logger;
|
||||
|
||||
public ColumbiaCatalogSyncService(
|
||||
IColumbiaCoatingsApiClient client,
|
||||
IPowderCatalogUpsertService upsert,
|
||||
IUnitOfWork unitOfWork,
|
||||
IPlatformSettingsService settings,
|
||||
ILogger<ColumbiaCatalogSyncService> logger)
|
||||
{
|
||||
_client = client;
|
||||
_upsert = upsert;
|
||||
_unitOfWork = unitOfWork;
|
||||
_settings = settings;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ColumbiaSyncResult> RunSyncAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = new ColumbiaSyncResult { StartedAt = DateTime.UtcNow };
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
if (!_client.IsConfigured)
|
||||
{
|
||||
result.ErrorMessage = "Columbia API key is not configured.";
|
||||
await RecordResultAsync(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Full pull — throws on any page failure, which we treat as an incomplete sync.
|
||||
var products = await _client.GetAllProductsAsync(cancellationToken);
|
||||
result.TotalFetched = products.Count;
|
||||
|
||||
// Map and de-duplicate by (VendorName, SKU) in case the feed repeats a SKU.
|
||||
// Exclude swatch cards and tester/sample size-variants — not standalone powder colors.
|
||||
var mapped = products
|
||||
.Where(p => !ColumbiaCatalogMapper.IsExcludedProduct(p))
|
||||
.Select(ColumbiaCatalogMapper.Map)
|
||||
.Where(m => !string.IsNullOrWhiteSpace(m.Sku))
|
||||
.GroupBy(m => $"{m.VendorName}|{m.Sku}", StringComparer.OrdinalIgnoreCase)
|
||||
.Select(g => g.First())
|
||||
.ToList();
|
||||
|
||||
var upsertResult = await _upsert.UpsertAsync(mapped, result.StartedAt, cancellationToken);
|
||||
result.Inserted = upsertResult.Inserted;
|
||||
result.Updated = upsertResult.Updated;
|
||||
result.Unchanged = upsertResult.Unchanged;
|
||||
result.Skipped = upsertResult.Skipped;
|
||||
|
||||
// Remove any excluded records (swatches) that were synced before the exclusion existed,
|
||||
// so they're deleted outright rather than lingering as "discontinued" powders.
|
||||
await RemoveExcludedRecordsAsync();
|
||||
|
||||
// Complete pull succeeded — safe to reconcile discontinuations.
|
||||
var incomingKeys = mapped
|
||||
.Select(m => $"{m.VendorName}|{m.Sku}")
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
(result.Discontinued, result.Reactivated) =
|
||||
await ReconcileDiscontinuationsAsync(incomingKeys, result.StartedAt);
|
||||
|
||||
result.Success = true;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Columbia catalog sync failed; skipping discontinuation sweep.");
|
||||
result.Success = false;
|
||||
result.ErrorMessage = ex.Message;
|
||||
}
|
||||
|
||||
stopwatch.Stop();
|
||||
result.Duration = stopwatch.Elapsed;
|
||||
await RecordResultAsync(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Flags catalog items sourced from Columbia that were NOT in this complete pull as discontinued,
|
||||
/// and reactivates any previously-discontinued item that has reappeared. Returns (discontinued,
|
||||
/// reactivated) counts.
|
||||
/// </summary>
|
||||
private async Task<(int Discontinued, int Reactivated)> ReconcileDiscontinuationsAsync(
|
||||
HashSet<string> incomingKeys, DateTime runTimestamp)
|
||||
{
|
||||
var sourced = await _unitOfWork.PowderCatalog.FindAsync(
|
||||
p => p.Source == ColumbiaIntegrationConstants.SourceName);
|
||||
|
||||
var discontinued = 0;
|
||||
var reactivated = 0;
|
||||
|
||||
foreach (var item in sourced)
|
||||
{
|
||||
var present = incomingKeys.Contains($"{item.VendorName}|{item.Sku}");
|
||||
|
||||
if (!present && !item.IsDiscontinued)
|
||||
{
|
||||
item.IsDiscontinued = true;
|
||||
item.UpdatedAt = runTimestamp;
|
||||
await _unitOfWork.PowderCatalog.UpdateAsync(item);
|
||||
discontinued++;
|
||||
}
|
||||
else if (present && item.IsDiscontinued)
|
||||
{
|
||||
item.IsDiscontinued = false;
|
||||
item.UpdatedAt = runTimestamp;
|
||||
await _unitOfWork.PowderCatalog.UpdateAsync(item);
|
||||
reactivated++;
|
||||
}
|
||||
}
|
||||
|
||||
if (discontinued > 0 || reactivated > 0)
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
return (discontinued, reactivated);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes Columbia-sourced catalog rows that should not be in the catalog (swatch cards and
|
||||
/// tester/sample size-variants). Mirrors <see cref="ColumbiaCatalogMapper.IsExcludedProduct"/>
|
||||
/// on the stored columns. A no-op once the catalog is clean; guards against records synced
|
||||
/// before the exclusion rule and ensures excluded items are removed, not flagged discontinued.
|
||||
/// </summary>
|
||||
private async Task RemoveExcludedRecordsAsync()
|
||||
{
|
||||
var excluded = (await _unitOfWork.PowderCatalog.FindAsync(p =>
|
||||
p.Source == ColumbiaIntegrationConstants.SourceName
|
||||
&& (p.Sku.EndsWith("-SW")
|
||||
|| p.Sku.EndsWith("-04")
|
||||
|| p.ColorName.Contains("SWATCH")
|
||||
|| p.ColorName.Contains("Tester")
|
||||
|| p.ColorName.Contains("Sample (")))).ToList();
|
||||
|
||||
if (excluded.Count == 0)
|
||||
return;
|
||||
|
||||
foreach (var e in excluded)
|
||||
await _unitOfWork.PowderCatalog.DeleteAsync(e);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
_logger.LogInformation("Columbia sync: removed {Count} excluded record(s) (swatch/tester/sample) from the catalog.", excluded.Count);
|
||||
}
|
||||
|
||||
/// <summary>Persists the run outcome to the last-synced / last-result platform settings.</summary>
|
||||
private async Task RecordResultAsync(ColumbiaSyncResult result)
|
||||
{
|
||||
if (result.Success)
|
||||
{
|
||||
await _settings.SetAsync(
|
||||
ColumbiaIntegrationConstants.SettingLastSyncedAt,
|
||||
result.StartedAt.ToString("O", CultureInfo.InvariantCulture),
|
||||
updatedBy: "Columbia Sync");
|
||||
}
|
||||
|
||||
await _settings.SetAsync(
|
||||
ColumbiaIntegrationConstants.SettingLastResult,
|
||||
result.Summary,
|
||||
updatedBy: "Columbia Sync");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PowderCoating.Application.Constants;
|
||||
using PowderCoating.Application.DTOs.Columbia;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
|
||||
namespace PowderCoating.Infrastructure.Services;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP client for the Columbia Coatings product catalog API. Reads the API key and base URL from
|
||||
/// configuration (<c>Columbia:ApiKey</c> / <c>Columbia:BaseUrl</c>), sends the <c>X-API-Key</c>
|
||||
/// header, and pages the catalog via <c>GET /products</c>. Honors the documented rate limit
|
||||
/// (120 requests / 60s) by retrying on HTTP 429 after the <c>Retry-After</c> interval.
|
||||
/// </summary>
|
||||
public class ColumbiaCoatingsApiClient : IColumbiaCoatingsApiClient
|
||||
{
|
||||
private const int MaxRetriesPer429 = 5;
|
||||
private const int DefaultRetryAfterSeconds = 5;
|
||||
private const int MaxPagesSafetyCap = 1000; // guards against a server that never reports last page
|
||||
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly IConfiguration _config;
|
||||
private readonly ILogger<ColumbiaCoatingsApiClient> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Columbia returns snake_case JSON; the snake-case naming policy binds it to our PascalCase DTOs
|
||||
/// without per-property attributes. Case-insensitive as a belt-and-braces fallback.
|
||||
/// </summary>
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
Converters = { new ColumbiaImageJsonConverter() },
|
||||
};
|
||||
|
||||
public ColumbiaCoatingsApiClient(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IConfiguration config,
|
||||
ILogger<ColumbiaCoatingsApiClient> logger)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_config = config;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
private string? ApiKey => _config[ColumbiaIntegrationConstants.ConfigApiKey];
|
||||
|
||||
private string BaseUrl =>
|
||||
(_config[ColumbiaIntegrationConstants.ConfigBaseUrl] ?? ColumbiaIntegrationConstants.DefaultBaseUrl)
|
||||
.TrimEnd('/');
|
||||
|
||||
private string ApiBasePath =>
|
||||
(_config[ColumbiaIntegrationConstants.ConfigApiBasePath] ?? ColumbiaIntegrationConstants.DefaultApiBasePath)
|
||||
.Trim('/');
|
||||
|
||||
/// <summary>Fully-qualified products endpoint: host + configurable API base path + /products.</summary>
|
||||
private string ProductsUrl => $"{BaseUrl}/{ApiBasePath}{ColumbiaIntegrationConstants.ProductsResource}";
|
||||
|
||||
public bool IsConfigured => !string.IsNullOrWhiteSpace(ApiKey);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ColumbiaProductsResponse> GetProductsPageAsync(
|
||||
int page, int perPage, CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureConfigured();
|
||||
perPage = Math.Clamp(perPage, 1, ColumbiaIntegrationConstants.MaxPerPage);
|
||||
var url = $"{ProductsUrl}?page={page}&per_page={perPage}";
|
||||
|
||||
var json = await SendWithRetryAsync(url, $"page {page}", cancellationToken);
|
||||
if (json == null)
|
||||
return new ColumbiaProductsResponse();
|
||||
|
||||
return JsonSerializer.Deserialize<ColumbiaProductsResponse>(json, JsonOptions) ?? new ColumbiaProductsResponse();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ColumbiaProduct?> GetProductBySkuAsync(string sku, CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureConfigured();
|
||||
if (string.IsNullOrWhiteSpace(sku))
|
||||
return null;
|
||||
|
||||
var url = $"{ProductsUrl}?sku={Uri.EscapeDataString(sku)}&per_page=1";
|
||||
var json = await SendWithRetryAsync(url, $"sku {sku}", cancellationToken);
|
||||
if (json == null)
|
||||
return null;
|
||||
|
||||
var response = JsonSerializer.Deserialize<ColumbiaProductsResponse>(json, JsonOptions);
|
||||
return response?.Items.FirstOrDefault();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ColumbiaProduct?> GetProductByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureConfigured();
|
||||
// The by-id endpoint returns a bare product object (not the {items,pagination} envelope).
|
||||
var url = $"{ProductsUrl}/{id}";
|
||||
var json = await SendWithRetryAsync(url, $"id {id}", cancellationToken);
|
||||
if (json == null)
|
||||
return null;
|
||||
|
||||
return JsonSerializer.Deserialize<ColumbiaProduct>(json, JsonOptions);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ColumbiaProduct>> GetAllProductsAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var all = new List<ColumbiaProduct>();
|
||||
|
||||
for (var page = 1; page <= MaxPagesSafetyCap; page++)
|
||||
{
|
||||
var response = await GetProductsPageAsync(page, ColumbiaIntegrationConstants.MaxPerPage, cancellationToken);
|
||||
|
||||
if (response.Items.Count == 0)
|
||||
break;
|
||||
|
||||
all.AddRange(response.Items);
|
||||
|
||||
// Stop when the pagination block says we've reached the last page.
|
||||
if (response.Pagination is { TotalPages: > 0 } p && page >= p.TotalPages)
|
||||
break;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Columbia API: retrieved {Count} products across paged requests.", all.Count);
|
||||
return all;
|
||||
}
|
||||
|
||||
/// <summary>Throws when no API key is configured so callers fail fast rather than 401.</summary>
|
||||
private void EnsureConfigured()
|
||||
{
|
||||
if (!IsConfigured)
|
||||
throw new InvalidOperationException("Columbia Coatings API key is not configured (Columbia:ApiKey).");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Issues a GET with the API key header and returns the response body. Retries on HTTP 429
|
||||
/// (honoring Retry-After) up to <see cref="MaxRetriesPer429"/>. Returns null on 404 so
|
||||
/// single-product lookups surface "not found" without throwing; throws on any other non-success.
|
||||
/// <paramref name="describe"/> is a short label (e.g. "page 3", "sku ABC") for log/error context.
|
||||
/// </summary>
|
||||
private async Task<string?> SendWithRetryAsync(string url, string describe, CancellationToken cancellationToken)
|
||||
{
|
||||
for (var attempt = 1; ; attempt++)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
request.Headers.Add("X-API-Key", ApiKey);
|
||||
|
||||
var client = _httpClientFactory.CreateClient();
|
||||
using var response = await client.SendAsync(request, cancellationToken);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.TooManyRequests)
|
||||
{
|
||||
if (attempt > MaxRetriesPer429)
|
||||
throw new HttpRequestException(
|
||||
$"Columbia API still rate-limiting after {MaxRetriesPer429} retries ({describe}).");
|
||||
|
||||
var delaySeconds = GetRetryAfterSeconds(response) ?? DefaultRetryAfterSeconds;
|
||||
_logger.LogWarning(
|
||||
"Columbia API returned 429 ({Describe}, attempt {Attempt}); waiting {Delay}s before retry.",
|
||||
describe, attempt, delaySeconds);
|
||||
await Task.Delay(TimeSpan.FromSeconds(delaySeconds), cancellationToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
return null;
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
return await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the <c>Retry-After</c> header (delta-seconds or HTTP-date form) into whole seconds,
|
||||
/// or null when absent/unparseable so the caller can fall back to a default.
|
||||
/// </summary>
|
||||
private static int? GetRetryAfterSeconds(HttpResponseMessage response)
|
||||
{
|
||||
var retryAfter = response.Headers.RetryAfter;
|
||||
if (retryAfter == null)
|
||||
return null;
|
||||
|
||||
if (retryAfter.Delta is { } delta)
|
||||
return Math.Max(1, (int)Math.Ceiling(delta.TotalSeconds));
|
||||
|
||||
if (retryAfter.Date is { } date)
|
||||
{
|
||||
var seconds = (int)Math.Ceiling((date - DateTimeOffset.UtcNow).TotalSeconds);
|
||||
return seconds > 0 ? seconds : 1;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ using Anthropic.SDK.Messaging;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
|
||||
namespace PowderCoating.Infrastructure.Services;
|
||||
@@ -541,18 +542,20 @@ Rules:
|
||||
|
||||
// Targeted prompt: we only need cure specs from this document
|
||||
const string curePrompt = @"You are reading a Technical Data Sheet (TDS) for a powder coating product.
|
||||
Extract ONLY the cure schedule. Respond with a valid JSON object — no markdown, no explanation:
|
||||
Extract the cure schedule and the specific gravity. Respond with a valid JSON object — no markdown, no explanation:
|
||||
|
||||
{
|
||||
""cureTemperatureF"": number or null,
|
||||
""cureTimeMinutes"": number or null,
|
||||
""reasoning"": ""one sentence: what cure schedule you found""
|
||||
""specificGravity"": number or null,
|
||||
""reasoning"": ""one sentence: what cure schedule and specific gravity you found""
|
||||
}
|
||||
|
||||
Rules:
|
||||
- cureTemperatureF: the recommended cure temperature in °F. Convert from °C if needed (multiply by 1.8 + 32). If a range is given use the midpoint. Most powders cure 325–400 °F.
|
||||
- cureTimeMinutes: the hold time at cure temperature in minutes (NOT total oven time). Typically 10–20 min.
|
||||
- If neither value can be found in the document, return null for both.";
|
||||
- specificGravity: the specific gravity / density value from the TDS (often labeled ""Specific Gravity"" or ""Density""). Typically 1.2–1.8. Null if not stated.
|
||||
- Return null for any value not found in the document.";
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("Technical Data Sheet content:");
|
||||
@@ -603,11 +606,12 @@ Rules:
|
||||
Success = true,
|
||||
CureTemperatureF = GetDecimal(parsed, "cureTemperatureF"),
|
||||
CureTimeMinutes = GetInt(parsed, "cureTimeMinutes"),
|
||||
SpecificGravity = GetDecimal(parsed, "specificGravity"),
|
||||
Reasoning = GetString(parsed, "reasoning"),
|
||||
};
|
||||
|
||||
_logger.LogInformation("TDS cure lookup for {Url}: temp={Temp}°F, time={Time}min ({Reasoning})",
|
||||
tdsUrl, result.CureTemperatureF, result.CureTimeMinutes, result.Reasoning);
|
||||
_logger.LogInformation("TDS spec lookup for {Url}: temp={Temp}°F, time={Time}min, sg={Sg} ({Reasoning})",
|
||||
tdsUrl, result.CureTemperatureF, result.CureTimeMinutes, result.SpecificGravity, result.Reasoning);
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -617,6 +621,58 @@ Rules:
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> EnsureCatalogTdsSpecsAsync(PowderCatalogItem catalog)
|
||||
{
|
||||
// Already enriched, or nothing to read from. Specific gravity is the trigger: it's never in
|
||||
// the API feed, so its absence means this item hasn't been TDS-enriched yet.
|
||||
if (catalog == null || catalog.SpecificGravity.HasValue || string.IsNullOrWhiteSpace(catalog.TdsUrl))
|
||||
return false;
|
||||
|
||||
var tds = await FetchTdsCureSpecsAsync(catalog.TdsUrl, catalog.ColorName);
|
||||
if (!tds.Success)
|
||||
return false;
|
||||
|
||||
var changed = false;
|
||||
|
||||
if (tds.SpecificGravity is > 0)
|
||||
{
|
||||
catalog.SpecificGravity = tds.SpecificGravity;
|
||||
changed = true;
|
||||
}
|
||||
if (!catalog.CureTemperatureF.HasValue && tds.CureTemperatureF.HasValue)
|
||||
{
|
||||
catalog.CureTemperatureF = tds.CureTemperatureF;
|
||||
changed = true;
|
||||
}
|
||||
if (!catalog.CureTimeMinutes.HasValue && tds.CureTimeMinutes.HasValue)
|
||||
{
|
||||
catalog.CureTimeMinutes = tds.CureTimeMinutes;
|
||||
changed = true;
|
||||
}
|
||||
// Derive theoretical coverage once specific gravity is known.
|
||||
if (!catalog.CoverageSqFtPerLb.HasValue && catalog.SpecificGravity is > 0)
|
||||
{
|
||||
catalog.CoverageSqFtPerLb = Math.Round(
|
||||
TheoreticalCoverageConstant / (catalog.SpecificGravity.Value * DefaultCoverageThicknessMils),
|
||||
2, MidpointRounding.AwayFromZero);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed)
|
||||
{
|
||||
catalog.UpdatedAt = DateTime.UtcNow;
|
||||
await _unitOfWork.PowderCatalog.UpdateAsync(catalog);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
_logger.LogInformation(
|
||||
"Lazily enriched catalog item {Vendor} {Sku} from TDS: sg={Sg}, cure={Temp}F/{Time}min, coverage={Cov}",
|
||||
catalog.VendorName, catalog.Sku, catalog.SpecificGravity, catalog.CureTemperatureF,
|
||||
catalog.CureTimeMinutes, catalog.CoverageSqFtPerLb);
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
// ── Manufacturer URL pattern: build direct product page URL ───────────────
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
|
||||
namespace PowderCoating.Infrastructure.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Single upsert path for the platform <see cref="PowderCatalogItem"/> master list, shared by the
|
||||
/// JSON file import and the Columbia API sync. Match key is (VendorName, SKU), case-insensitive.
|
||||
/// </summary>
|
||||
public class PowderCatalogUpsertService : IPowderCatalogUpsertService
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ILogger<PowderCatalogUpsertService> _logger;
|
||||
|
||||
public PowderCatalogUpsertService(IUnitOfWork unitOfWork, ILogger<PowderCatalogUpsertService> logger)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PowderCatalogUpsertResult> UpsertAsync(
|
||||
IReadOnlyList<PowderCatalogItem> incoming,
|
||||
DateTime runTimestamp,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = new PowderCatalogUpsertResult();
|
||||
|
||||
// Load existing rows for just the vendors we're touching, keyed by (vendor|sku) lower-cased.
|
||||
var vendorNames = incoming
|
||||
.Select(i => i.VendorName)
|
||||
.Where(v => !string.IsNullOrWhiteSpace(v))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
var existing = (await _unitOfWork.PowderCatalog.FindAsync(p => vendorNames.Contains(p.VendorName)))
|
||||
.ToDictionary(KeyOf, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var toAdd = new List<PowderCatalogItem>();
|
||||
|
||||
foreach (var item in incoming)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(item.Sku) || string.IsNullOrWhiteSpace(item.ColorName))
|
||||
{
|
||||
result.Skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (existing.TryGetValue(KeyOf(item), out var record))
|
||||
{
|
||||
if (ApplyFeedFields(record, item))
|
||||
{
|
||||
record.UpdatedAt = runTimestamp;
|
||||
record.LastSyncedAt = runTimestamp;
|
||||
await _unitOfWork.PowderCatalog.UpdateAsync(record);
|
||||
result.Updated++;
|
||||
}
|
||||
else
|
||||
{
|
||||
result.Unchanged++;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
item.CreatedAt = runTimestamp;
|
||||
item.LastSyncedAt = runTimestamp;
|
||||
toAdd.Add(item);
|
||||
result.Inserted++;
|
||||
}
|
||||
}
|
||||
|
||||
if (toAdd.Count > 0)
|
||||
await _unitOfWork.PowderCatalog.AddRangeAsync(toAdd);
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// Push current catalog price + product data down to any tenant inventory linked to these
|
||||
// catalog rows, so quotes reflect the current price.
|
||||
var propagated = await PropagateToLinkedInventoryAsync();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Powder catalog upsert: {Inserted} inserted, {Updated} updated, {Unchanged} unchanged, {Skipped} skipped; {Propagated} linked inventory item(s) refreshed.",
|
||||
result.Inserted, result.Updated, result.Unchanged, result.Skipped, propagated);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Keeps tenant inventory in step with the catalog (across all companies): first self-heals by
|
||||
/// linking any unlinked item to its catalog row by identity, then refreshes every linked item
|
||||
/// with the catalog's current price and product data. Returns the number of items touched.
|
||||
/// </summary>
|
||||
public async Task<int> PropagateToLinkedInventoryAsync()
|
||||
{
|
||||
var linkedCount = await LinkUnlinkedInventoryAsync();
|
||||
var refreshedCount = await RefreshLinkedInventoryAsync();
|
||||
return linkedCount + refreshedCount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Self-heals the catalog link: finds inventory items with no <see cref="InventoryItem.PowderCatalogItemId"/>
|
||||
/// that match a catalog row by Manufacturer + ManufacturerPartNumber (the catalog SKU), sets the
|
||||
/// FK, and applies the catalog price/product data. Only links on a confident match (exact SKU,
|
||||
/// matching vendor, or a single unambiguous candidate) so it never mis-links. Returns the count
|
||||
/// newly linked. This backfills items created before linking existed, on every environment, with
|
||||
/// no manual step.
|
||||
/// </summary>
|
||||
private async Task<int> LinkUnlinkedInventoryAsync()
|
||||
{
|
||||
var unlinked = (await _unitOfWork.InventoryItems.FindAsync(
|
||||
i => i.PowderCatalogItemId == null
|
||||
&& i.Manufacturer != null && i.Manufacturer != ""
|
||||
&& i.ManufacturerPartNumber != null && i.ManufacturerPartNumber != "",
|
||||
ignoreQueryFilters: true)).ToList();
|
||||
if (unlinked.Count == 0)
|
||||
return 0;
|
||||
|
||||
var partNumbers = unlinked.Select(i => i.ManufacturerPartNumber!).Distinct().ToList();
|
||||
var bySku = (await _unitOfWork.PowderCatalog.FindAsync(p => partNumbers.Contains(p.Sku)))
|
||||
.GroupBy(c => c.Sku, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase);
|
||||
if (bySku.Count == 0)
|
||||
return 0;
|
||||
|
||||
var linked = 0;
|
||||
foreach (var inv in unlinked)
|
||||
{
|
||||
if (!bySku.TryGetValue(inv.ManufacturerPartNumber!, out var candidates))
|
||||
continue;
|
||||
|
||||
var mfr = inv.Manufacturer!.Trim().ToLower();
|
||||
var match = candidates.FirstOrDefault(c => c.VendorName.ToLower().Contains(mfr))
|
||||
?? (candidates.Count == 1 ? candidates[0] : null);
|
||||
if (match == null)
|
||||
continue;
|
||||
|
||||
inv.PowderCatalogItemId = match.Id;
|
||||
ApplyCatalogToLinkedInventory(inv, match);
|
||||
await _unitOfWork.InventoryItems.UpdateAsync(inv);
|
||||
linked++;
|
||||
}
|
||||
|
||||
if (linked > 0)
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
return linked;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refreshes every tenant inventory item linked to a powder catalog row (across all companies)
|
||||
/// with the catalog's current list price and product data. Sets
|
||||
/// <see cref="InventoryItem.CatalogReferencePrice"/> (the QUOTING price) and product spec/doc
|
||||
/// fields, but NEVER the cost basis (UnitCost/AverageCost/LastPurchasePrice), quantity, notes,
|
||||
/// image, location, or stock levels — those are tenant-owned. EF persists only items that
|
||||
/// actually changed, so this is a cheap no-op when nothing moved. Returns the number updated.
|
||||
/// </summary>
|
||||
private async Task<int> RefreshLinkedInventoryAsync()
|
||||
{
|
||||
var linked = (await _unitOfWork.InventoryItems.FindAsync(
|
||||
i => i.PowderCatalogItemId != null, ignoreQueryFilters: true)).ToList();
|
||||
if (linked.Count == 0)
|
||||
return 0;
|
||||
|
||||
var catalogIds = linked.Select(i => i.PowderCatalogItemId!.Value).Distinct().ToList();
|
||||
var catalogById = (await _unitOfWork.PowderCatalog.FindAsync(p => catalogIds.Contains(p.Id)))
|
||||
.ToDictionary(p => p.Id);
|
||||
|
||||
var updated = 0;
|
||||
foreach (var inv in linked)
|
||||
{
|
||||
if (!catalogById.TryGetValue(inv.PowderCatalogItemId!.Value, out var cat))
|
||||
continue;
|
||||
|
||||
if (ApplyCatalogToLinkedInventory(inv, cat))
|
||||
{
|
||||
await _unitOfWork.InventoryItems.UpdateAsync(inv);
|
||||
updated++;
|
||||
}
|
||||
}
|
||||
|
||||
if (updated > 0)
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies the catalog's current price and product data onto a linked inventory item, returning
|
||||
/// true if anything changed. Sets the quoting reference price (only when the catalog has a real
|
||||
/// price > 0) and refreshes product/spec fields where the catalog has a value — never erasing
|
||||
/// tenant data with catalog nulls, and never touching cost basis, quantity, notes, image, or
|
||||
/// stock levels.
|
||||
/// </summary>
|
||||
private static bool ApplyCatalogToLinkedInventory(InventoryItem inv, PowderCatalogItem cat)
|
||||
{
|
||||
var changed = false;
|
||||
|
||||
// Quoting price (the point of this): keep the current catalog list price, separate from cost.
|
||||
if (cat.UnitPrice > 0 && inv.CatalogReferencePrice != cat.UnitPrice)
|
||||
{
|
||||
inv.CatalogReferencePrice = cat.UnitPrice;
|
||||
inv.CatalogPriceUpdatedAt = DateTime.UtcNow;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
// Product data — refresh from the catalog where it has a value (catalog is authoritative on
|
||||
// these); do not null out a tenant value the catalog doesn't carry.
|
||||
changed |= SetStrIfCatalogHas(() => inv.Description, v => inv.Description = v, cat.Description);
|
||||
changed |= SetStrIfCatalogHas(() => inv.Finish, v => inv.Finish = v, cat.Finish);
|
||||
changed |= SetStrIfCatalogHas(() => inv.ColorFamilies, v => inv.ColorFamilies = v, cat.ColorFamilies);
|
||||
changed |= SetStrIfCatalogHas(() => inv.SdsUrl, v => inv.SdsUrl = v, cat.SdsUrl);
|
||||
changed |= SetStrIfCatalogHas(() => inv.TdsUrl, v => inv.TdsUrl = v, cat.TdsUrl);
|
||||
changed |= SetStrIfCatalogHas(() => inv.SpecPageUrl, v => inv.SpecPageUrl = v, cat.ProductUrl);
|
||||
|
||||
if (cat.CureTemperatureF.HasValue && inv.CureTemperatureF != cat.CureTemperatureF)
|
||||
{ inv.CureTemperatureF = cat.CureTemperatureF; changed = true; }
|
||||
if (cat.CureTimeMinutes.HasValue && inv.CureTimeMinutes != cat.CureTimeMinutes)
|
||||
{ inv.CureTimeMinutes = cat.CureTimeMinutes; changed = true; }
|
||||
if (cat.CoverageSqFtPerLb.HasValue && inv.CoverageSqFtPerLb != cat.CoverageSqFtPerLb)
|
||||
{ inv.CoverageSqFtPerLb = cat.CoverageSqFtPerLb; changed = true; }
|
||||
if (cat.SpecificGravity.HasValue && inv.SpecificGravity != cat.SpecificGravity)
|
||||
{ inv.SpecificGravity = cat.SpecificGravity; changed = true; }
|
||||
if (cat.TransferEfficiency.HasValue && inv.TransferEfficiency != cat.TransferEfficiency)
|
||||
{ inv.TransferEfficiency = cat.TransferEfficiency; changed = true; }
|
||||
if (cat.RequiresClearCoat == true && !inv.RequiresClearCoat)
|
||||
{ inv.RequiresClearCoat = true; changed = true; }
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
/// <summary>Sets a string property from the catalog only when the catalog value is non-blank and differs.</summary>
|
||||
private static bool SetStrIfCatalogHas(Func<string?> get, Action<string?> set, string? catalogValue)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(catalogValue) && !string.Equals(get(), catalogValue, StringComparison.Ordinal))
|
||||
{
|
||||
set(catalogValue);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string KeyOf(PowderCatalogItem p) => $"{p.VendorName}|{p.Sku}";
|
||||
|
||||
/// <summary>
|
||||
/// Copies feed-sourced fields from <paramref name="src"/> onto <paramref name="dest"/> and
|
||||
/// returns true if anything changed. Deliberately leaves enrichment fields (SpecificGravity,
|
||||
/// CoverageSqFtPerLb, TransferEfficiency, Finish) and lifecycle flags untouched — those are
|
||||
/// owned by lazy TDS/AI enrichment and the discontinuation sweep, not the feed.
|
||||
/// </summary>
|
||||
private static bool ApplyFeedFields(PowderCatalogItem dest, PowderCatalogItem src)
|
||||
{
|
||||
var changed = false;
|
||||
|
||||
changed |= Set(() => dest.ColorName, v => dest.ColorName = v, src.ColorName);
|
||||
changed |= Set(() => dest.Description, v => dest.Description = v, src.Description);
|
||||
changed |= src.UnitPrice > 0 && dest.UnitPrice != src.UnitPrice && Assign(() => dest.UnitPrice = src.UnitPrice);
|
||||
changed |= Set(() => dest.PriceTiersJson, v => dest.PriceTiersJson = v, src.PriceTiersJson);
|
||||
changed |= Set(() => dest.ImageUrl, v => dest.ImageUrl = v, src.ImageUrl);
|
||||
changed |= Set(() => dest.SdsUrl, v => dest.SdsUrl = v, src.SdsUrl);
|
||||
changed |= Set(() => dest.TdsUrl, v => dest.TdsUrl = v, src.TdsUrl);
|
||||
changed |= Set(() => dest.ApplicationGuideUrl, v => dest.ApplicationGuideUrl = v, src.ApplicationGuideUrl);
|
||||
changed |= Set(() => dest.ProductUrl, v => dest.ProductUrl = v, src.ProductUrl);
|
||||
changed |= Set(() => dest.ChemistryType, v => dest.ChemistryType = v, src.ChemistryType);
|
||||
changed |= Set(() => dest.MilThickness, v => dest.MilThickness = v, src.MilThickness);
|
||||
changed |= Set(() => dest.CureScheduleText, v => dest.CureScheduleText = v, src.CureScheduleText);
|
||||
changed |= Set(() => dest.CureCurvesJson, v => dest.CureCurvesJson = v, src.CureCurvesJson);
|
||||
changed |= src.CureTemperatureF.HasValue && dest.CureTemperatureF != src.CureTemperatureF && Assign(() => dest.CureTemperatureF = src.CureTemperatureF);
|
||||
changed |= src.CureTimeMinutes.HasValue && dest.CureTimeMinutes != src.CureTimeMinutes && Assign(() => dest.CureTimeMinutes = src.CureTimeMinutes);
|
||||
changed |= src.RequiresClearCoat.HasValue && dest.RequiresClearCoat != src.RequiresClearCoat && Assign(() => dest.RequiresClearCoat = src.RequiresClearCoat);
|
||||
changed |= Set(() => dest.ColorFamilies, v => dest.ColorFamilies = v, src.ColorFamilies);
|
||||
changed |= Set(() => dest.FormulationChanges, v => dest.FormulationChanges = v, src.FormulationChanges);
|
||||
changed |= Set(() => dest.Category, v => dest.Category = v, src.Category);
|
||||
changed |= Set(() => dest.Source, v => dest.Source = v, src.Source);
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets a nullable-string property when the feed provides a non-blank value that differs.
|
||||
/// Merge semantics: a blank incoming value is ignored, so a partial feed (e.g. the Prismatic
|
||||
/// file import, which omits cure/chemistry) never nulls out existing data.
|
||||
/// </summary>
|
||||
private static bool Set(Func<string?> get, Action<string?> set, string? newValue)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(newValue))
|
||||
return false;
|
||||
if (!string.Equals(get(), newValue, StringComparison.Ordinal))
|
||||
{
|
||||
set(newValue);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Helper so a value assignment can participate in a boolean OR chain.</summary>
|
||||
private static bool Assign(Action assign)
|
||||
{
|
||||
assign();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PowderCoating.Application.DTOs.Terminal;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
@@ -254,258 +253,4 @@ public class StripeConnectService : IStripeConnectService
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Stripe Terminal (in-person card payments, WisePOS E) =====
|
||||
|
||||
/// <summary>True when the Connect secret key is a test-mode key (sk_test_…).</summary>
|
||||
private bool IsTestMode => SecretKey.StartsWith("sk_test_", StringComparison.Ordinal);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<(bool Success, string? LocationId, string? ErrorMessage)> CreateTerminalLocationAsync(
|
||||
string connectedAccountId,
|
||||
string displayName,
|
||||
TerminalAddressDto address)
|
||||
{
|
||||
try
|
||||
{
|
||||
var options = new Stripe.Terminal.LocationCreateOptions
|
||||
{
|
||||
DisplayName = displayName,
|
||||
Address = new AddressOptions
|
||||
{
|
||||
Line1 = address.Line1,
|
||||
City = address.City,
|
||||
State = address.State,
|
||||
PostalCode = address.PostalCode,
|
||||
Country = address.Country
|
||||
}
|
||||
};
|
||||
|
||||
var client = new StripeClient(SecretKey);
|
||||
var requestOptions = new RequestOptions { StripeAccount = connectedAccountId };
|
||||
var service = new Stripe.Terminal.LocationService(client);
|
||||
var location = await service.CreateAsync(options, requestOptions);
|
||||
|
||||
return (true, location.Id, null);
|
||||
}
|
||||
catch (StripeException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to create Terminal Location for account {AccountId}", connectedAccountId);
|
||||
return (false, null, ex.StripeError?.Message ?? ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<(bool Success, string? ReaderId, string? DeviceType, string? SerialNumber, string? ErrorMessage)> RegisterReaderAsync(
|
||||
string connectedAccountId,
|
||||
string locationId,
|
||||
string registrationCode,
|
||||
string label)
|
||||
{
|
||||
try
|
||||
{
|
||||
var options = new Stripe.Terminal.ReaderCreateOptions
|
||||
{
|
||||
RegistrationCode = registrationCode,
|
||||
Label = label,
|
||||
Location = locationId
|
||||
};
|
||||
|
||||
var client = new StripeClient(SecretKey);
|
||||
var requestOptions = new RequestOptions { StripeAccount = connectedAccountId };
|
||||
var service = new Stripe.Terminal.ReaderService(client);
|
||||
var reader = await service.CreateAsync(options, requestOptions);
|
||||
|
||||
return (true, reader.Id, reader.DeviceType, reader.SerialNumber, null);
|
||||
}
|
||||
catch (StripeException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to register Terminal reader for account {AccountId}", connectedAccountId);
|
||||
return (false, null, null, null, ex.StripeError?.Message ?? ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<(bool Success, IReadOnlyList<TerminalReaderDto> Readers, string? ErrorMessage)> ListReadersAsync(
|
||||
string connectedAccountId,
|
||||
string locationId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var options = new Stripe.Terminal.ReaderListOptions { Location = locationId };
|
||||
var client = new StripeClient(SecretKey);
|
||||
var requestOptions = new RequestOptions { StripeAccount = connectedAccountId };
|
||||
var service = new Stripe.Terminal.ReaderService(client);
|
||||
var readers = await service.ListAsync(options, requestOptions);
|
||||
|
||||
var dtos = readers.Data.Select(r => new TerminalReaderDto
|
||||
{
|
||||
StripeReaderId = r.Id,
|
||||
Label = r.Label,
|
||||
DeviceType = r.DeviceType,
|
||||
SerialNumber = r.SerialNumber,
|
||||
NetworkStatus = r.Status
|
||||
}).ToList();
|
||||
|
||||
return (true, dtos, null);
|
||||
}
|
||||
catch (StripeException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to list Terminal readers for account {AccountId}", connectedAccountId);
|
||||
return (false, Array.Empty<TerminalReaderDto>(), ex.StripeError?.Message ?? ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<(bool Success, string? ErrorMessage)> DeleteReaderAsync(
|
||||
string connectedAccountId,
|
||||
string readerId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var client = new StripeClient(SecretKey);
|
||||
var requestOptions = new RequestOptions { StripeAccount = connectedAccountId };
|
||||
var service = new Stripe.Terminal.ReaderService(client);
|
||||
await service.DeleteAsync(readerId, null, requestOptions);
|
||||
return (true, null);
|
||||
}
|
||||
catch (StripeException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to delete Terminal reader {ReaderId}", readerId);
|
||||
return (false, ex.StripeError?.Message ?? ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<(bool Success, string? PaymentIntentId, string? ErrorMessage)> ProcessInvoicePaymentOnReaderAsync(
|
||||
string connectedAccountId,
|
||||
string readerId,
|
||||
decimal amount,
|
||||
decimal surchargeAmount,
|
||||
string currency,
|
||||
string invoiceNumber,
|
||||
int invoiceId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var totalWithSurcharge = amount + surchargeAmount;
|
||||
var amountInCents = (long)Math.Round(totalWithSurcharge * 100, MidpointRounding.AwayFromZero);
|
||||
|
||||
var client = new StripeClient(SecretKey);
|
||||
var requestOptions = new RequestOptions { StripeAccount = connectedAccountId };
|
||||
|
||||
// 1) Create a card_present PaymentIntent. Note: do NOT set AutomaticPaymentMethods here —
|
||||
// it is incompatible with an explicit card_present payment method type.
|
||||
var piOptions = new PaymentIntentCreateOptions
|
||||
{
|
||||
Amount = amountInCents,
|
||||
Currency = currency.ToLower(),
|
||||
Description = $"Invoice {invoiceNumber}",
|
||||
PaymentMethodTypes = new List<string> { "card_present" },
|
||||
CaptureMethod = "automatic",
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
{ "invoice_id", invoiceId.ToString() },
|
||||
{ "invoice_number", invoiceNumber },
|
||||
{ "surcharge_amount", surchargeAmount.ToString("F2") },
|
||||
{ "source", "terminal" }
|
||||
}
|
||||
};
|
||||
var piService = new PaymentIntentService(client);
|
||||
var intent = await piService.CreateAsync(piOptions, requestOptions);
|
||||
|
||||
// 2) Push the PaymentIntent to the physical reader; it prompts the customer to present a card.
|
||||
var processOptions = new Stripe.Terminal.ReaderProcessPaymentIntentOptions
|
||||
{
|
||||
PaymentIntent = intent.Id,
|
||||
ProcessConfig = new Stripe.Terminal.ReaderProcessConfigOptions
|
||||
{
|
||||
EnableCustomerCancellation = true,
|
||||
SkipTipping = true
|
||||
}
|
||||
};
|
||||
var readerService = new Stripe.Terminal.ReaderService(client);
|
||||
await readerService.ProcessPaymentIntentAsync(readerId, processOptions, requestOptions);
|
||||
|
||||
return (true, intent.Id, null);
|
||||
}
|
||||
catch (StripeException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to process Terminal payment for invoice {InvoiceId} on reader {ReaderId}", invoiceId, readerId);
|
||||
return (false, null, ex.StripeError?.Message ?? ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<(bool Success, string? ActionStatus, string? ActionType, string? PaymentIntentId,
|
||||
string? FailureCode, string? FailureMessage, string? NetworkStatus, string? ErrorMessage)> GetReaderStatusAsync(
|
||||
string connectedAccountId,
|
||||
string readerId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var client = new StripeClient(SecretKey);
|
||||
var requestOptions = new RequestOptions { StripeAccount = connectedAccountId };
|
||||
var service = new Stripe.Terminal.ReaderService(client);
|
||||
var reader = await service.GetAsync(readerId, null, requestOptions);
|
||||
|
||||
var action = reader.Action;
|
||||
return (
|
||||
true,
|
||||
action?.Status,
|
||||
action?.Type,
|
||||
action?.ProcessPaymentIntent?.PaymentIntentId,
|
||||
action?.FailureCode,
|
||||
action?.FailureMessage,
|
||||
reader.Status,
|
||||
null);
|
||||
}
|
||||
catch (StripeException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to read Terminal reader status {ReaderId}", readerId);
|
||||
return (false, null, null, null, null, null, null, ex.StripeError?.Message ?? ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<(bool Success, string? ErrorMessage)> CancelReaderActionAsync(
|
||||
string connectedAccountId,
|
||||
string readerId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var client = new StripeClient(SecretKey);
|
||||
var requestOptions = new RequestOptions { StripeAccount = connectedAccountId };
|
||||
var service = new Stripe.Terminal.ReaderService(client);
|
||||
await service.CancelActionAsync(readerId, null, requestOptions);
|
||||
return (true, null);
|
||||
}
|
||||
catch (StripeException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to cancel Terminal reader action {ReaderId}", readerId);
|
||||
return (false, ex.StripeError?.Message ?? ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<(bool Success, string? ErrorMessage)> SimulatePresentPaymentMethodAsync(
|
||||
string connectedAccountId,
|
||||
string readerId)
|
||||
{
|
||||
if (!IsTestMode)
|
||||
return (false, "Simulated card taps are only available in Stripe test mode.");
|
||||
|
||||
try
|
||||
{
|
||||
var client = new StripeClient(SecretKey);
|
||||
var requestOptions = new RequestOptions { StripeAccount = connectedAccountId };
|
||||
var service = new Stripe.TestHelpers.Terminal.ReaderService(client);
|
||||
await service.PresentPaymentMethodAsync(readerId, new Stripe.TestHelpers.Terminal.ReaderPresentPaymentMethodOptions(), requestOptions);
|
||||
return (true, null);
|
||||
}
|
||||
catch (StripeException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to simulate card tap on reader {ReaderId}", readerId);
|
||||
return (false, ex.StripeError?.Message ?? ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
using System.Globalization;
|
||||
using PowderCoating.Application.Constants;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
|
||||
namespace PowderCoating.Web.BackgroundServices;
|
||||
|
||||
/// <summary>
|
||||
/// Runs the Columbia Coatings catalog sync on a schedule. Wakes hourly and triggers a full sync
|
||||
/// only when the master switch (<c>ColumbiaSyncEnabled</c>) is on and the configured interval
|
||||
/// (<c>ColumbiaSyncIntervalDays</c>) has elapsed since the last successful run. A full sync is
|
||||
/// cheap (~25 API calls), so an hourly due-check is negligible; the actual work runs at most once
|
||||
/// per interval. No-ops quietly when disabled or unconfigured.
|
||||
/// </summary>
|
||||
public class ColumbiaCatalogSyncBackgroundService : BackgroundService
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ILogger<ColumbiaCatalogSyncBackgroundService> _logger;
|
||||
|
||||
private static readonly TimeSpan CheckInterval = TimeSpan.FromHours(1);
|
||||
private static readonly TimeSpan StartupDelay = TimeSpan.FromMinutes(2);
|
||||
|
||||
/// <summary>
|
||||
/// Uses <see cref="IServiceScopeFactory"/> because a <see cref="BackgroundService"/> is a
|
||||
/// singleton and the sync service / platform settings are scoped.
|
||||
/// </summary>
|
||||
public ColumbiaCatalogSyncBackgroundService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<ColumbiaCatalogSyncBackgroundService> logger)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("ColumbiaCatalogSyncBackgroundService started.");
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(StartupDelay, stoppingToken);
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
await RunIfDueAsync(stoppingToken);
|
||||
await Task.Delay(CheckInterval, stoppingToken);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Shutting down — expected.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks the enable switch and the elapsed interval, and runs a sync when due. Failures from
|
||||
/// the sync itself are reported on its result (and recorded in platform settings) rather than
|
||||
/// thrown, so a bad run never tears down the loop.
|
||||
/// </summary>
|
||||
private async Task RunIfDueAsync(CancellationToken ct)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var settings = scope.ServiceProvider.GetRequiredService<IPlatformSettingsService>();
|
||||
|
||||
if (!await settings.GetBoolAsync(ColumbiaIntegrationConstants.SettingEnabled))
|
||||
return; // master switch off
|
||||
|
||||
var intervalDays = Math.Max(1, await settings.GetIntAsync(
|
||||
ColumbiaIntegrationConstants.SettingIntervalDays,
|
||||
ColumbiaIntegrationConstants.DefaultSyncIntervalDays));
|
||||
|
||||
if (!IsDue(await settings.GetAsync(ColumbiaIntegrationConstants.SettingLastSyncedAt), intervalDays))
|
||||
return; // synced recently enough
|
||||
|
||||
var sync = scope.ServiceProvider.GetRequiredService<IColumbiaCatalogSyncService>();
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Columbia scheduled sync starting (interval {Days}d).", intervalDays);
|
||||
var result = await sync.RunSyncAsync(ct);
|
||||
|
||||
if (result.Success)
|
||||
_logger.LogInformation("Columbia scheduled sync complete: {Summary}", result.Summary);
|
||||
else
|
||||
_logger.LogWarning("Columbia scheduled sync did not succeed: {Error}", result.ErrorMessage);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Columbia scheduled sync threw unexpectedly.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A sync is due when there is no recorded last-sync timestamp, or the configured number of
|
||||
/// days has elapsed since it. An unparseable timestamp is treated as "due".
|
||||
/// </summary>
|
||||
private static bool IsDue(string? lastSyncedRaw, int intervalDays)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(lastSyncedRaw))
|
||||
return true;
|
||||
|
||||
if (!DateTime.TryParse(lastSyncedRaw, CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.RoundtripKind, out var lastSynced))
|
||||
return true;
|
||||
|
||||
return DateTime.UtcNow - lastSynced.ToUniversalTime() >= TimeSpan.FromDays(intervalDays);
|
||||
}
|
||||
}
|
||||
@@ -158,11 +158,6 @@ public class CompanySettingsController : Controller
|
||||
ViewBag.StripeConnectConfigured = !string.IsNullOrWhiteSpace(connectClientId)
|
||||
&& !connectClientId.Contains("your_connect_client_id_here", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// Stripe Terminal (Card Readers tab) — current in-person surcharge toggle + test-mode flag
|
||||
ViewBag.TerminalSurchargeEnabled = company.TerminalSurchargeEnabled;
|
||||
ViewBag.TerminalTestMode =
|
||||
(_configuration["Stripe:Connect:SecretKey"] ?? string.Empty).StartsWith("sk_test_", StringComparison.Ordinal);
|
||||
|
||||
// Load notification templates for inline tab
|
||||
var existing = await _unitOfWork.NotificationTemplates.FindAsync(t => t.CompanyId == companyId.Value);
|
||||
var seeded = await EnsureNotificationTemplatesSeededAsync(companyId.Value, existing.ToList());
|
||||
|
||||
@@ -25,6 +25,7 @@ public class DashboardController : Controller
|
||||
private readonly ICompanyConfigHealthService _configHealth;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly ISubscriptionService _subscriptionService;
|
||||
private readonly IInventoryAiLookupService _aiLookupService;
|
||||
|
||||
public DashboardController(
|
||||
IUnitOfWork unitOfWork,
|
||||
@@ -33,7 +34,8 @@ public class DashboardController : Controller
|
||||
ITenantContext tenantContext,
|
||||
ICompanyConfigHealthService configHealth,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
ISubscriptionService subscriptionService)
|
||||
ISubscriptionService subscriptionService,
|
||||
IInventoryAiLookupService aiLookupService)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_logger = logger;
|
||||
@@ -42,6 +44,7 @@ public class DashboardController : Controller
|
||||
_configHealth = configHealth;
|
||||
_userManager = userManager;
|
||||
_subscriptionService = subscriptionService;
|
||||
_aiLookupService = aiLookupService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -765,59 +768,15 @@ public class DashboardController : Controller
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
await _unitOfWork.InventoryItems.AddAsync(inventoryItem);
|
||||
await _unitOfWork.CompleteAsync(); // flush to get inventoryItem.Id
|
||||
// Enrich from the platform powder catalog so the new inventory record carries the full
|
||||
// spec/doc set (cure schedule, SDS/TDS, sample image, color families) rather than just
|
||||
// the color code/name carried on the quote. Match by the catalog SKU (stored as the
|
||||
// coat's colorCode), preferring the same manufacturer; fall back to color name.
|
||||
await EnrichInventoryFromCatalogAsync(inventoryItem, colorCode, colorName, manufacturer);
|
||||
|
||||
// Opening stock transaction
|
||||
var transaction = new InventoryTransaction
|
||||
{
|
||||
CompanyId = companyId,
|
||||
InventoryItemId = inventoryItem.Id,
|
||||
TransactionType = InventoryTransactionType.Purchase,
|
||||
Quantity = lbsReceived,
|
||||
UnitCost = unitCost ?? 0,
|
||||
TotalCost = lbsReceived * (unitCost ?? 0),
|
||||
TransactionDate = DateTime.UtcNow,
|
||||
Notes = $"Initial stock — received from powder order for job {jobItem?.Job?.JobNumber}",
|
||||
BalanceAfter = lbsReceived,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
};
|
||||
await _unitOfWork.InventoryTransactions.AddAsync(transaction);
|
||||
|
||||
// Mark coat as received and link to the new inventory item
|
||||
var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||
coat.PowderReceived = true;
|
||||
coat.PowderReceivedAt = DateTime.UtcNow;
|
||||
coat.PowderReceivedByUserId = userId;
|
||||
coat.PowderReceivedLbs = lbsReceived;
|
||||
coat.InventoryItemId = inventoryItem.Id;
|
||||
|
||||
// Scan for sibling coats with the same custom powder and link them to the new item
|
||||
var candidateCoats = await _unitOfWork.JobItemCoats.GetCandidateCoatsForLinkingAsync(coatId, companyId);
|
||||
|
||||
int linkedCount = 0;
|
||||
foreach (var other in candidateCoats)
|
||||
{
|
||||
bool colorMatch = !string.IsNullOrWhiteSpace(colorCode)
|
||||
? string.Equals(other.ColorCode?.Trim(), colorCode.Trim(), StringComparison.OrdinalIgnoreCase)
|
||||
: !string.IsNullOrWhiteSpace(colorName) &&
|
||||
string.Equals(other.ColorName?.Trim(), colorName.Trim(), StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (!colorMatch) continue;
|
||||
|
||||
if (primaryVendorId.HasValue && other.VendorId.HasValue && other.VendorId != primaryVendorId)
|
||||
continue;
|
||||
|
||||
other.InventoryItemId = inventoryItem.Id;
|
||||
linkedCount++;
|
||||
}
|
||||
|
||||
if (linkedCount > 0)
|
||||
_logger.LogInformation("Linked {Count} additional coat(s) to new inventory item {ItemId} ({SKU})",
|
||||
linkedCount, inventoryItem.Id, inventoryItem.SKU);
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
var linkedCount = await FinalizeReceivedPowderAsync(
|
||||
coat, inventoryItem, lbsReceived, companyId, colorCode, colorName, primaryVendorId,
|
||||
jobItem?.Job?.JobNumber);
|
||||
|
||||
return Json(new { success = true, itemName = inventoryItem.Name, sku = inventoryItem.SKU, linkedCount });
|
||||
}
|
||||
@@ -828,6 +787,267 @@ public class DashboardController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the platform powder catalog row for an inventory/coat identity: by catalog SKU
|
||||
/// (stored as the coat's color code), preferring the same manufacturer, then by color name.
|
||||
/// Returns null when no match is found.
|
||||
/// </summary>
|
||||
private async Task<PowderCatalogItem?> FindCatalogByIdentityAsync(
|
||||
string? colorCode, string? colorName, string? manufacturer)
|
||||
{
|
||||
var code = colorCode?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(code))
|
||||
{
|
||||
var codeLower = code.ToLower();
|
||||
var hits = (await _unitOfWork.PowderCatalog.FindAsync(p => p.Sku.ToLower() == codeLower)).ToList();
|
||||
var mfr = manufacturer?.Trim().ToLower();
|
||||
var match = (!string.IsNullOrWhiteSpace(mfr)
|
||||
? hits.FirstOrDefault(p => p.VendorName.ToLower().Contains(mfr))
|
||||
: null)
|
||||
?? hits.FirstOrDefault();
|
||||
if (match != null)
|
||||
return match;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(colorName))
|
||||
{
|
||||
var nameLower = colorName.Trim().ToLower();
|
||||
return (await _unitOfWork.PowderCatalog.FindAsync(p => p.ColorName.ToLower() == nameLower))
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Copies catalog spec/document fields onto an inventory item — cure schedule, coverage,
|
||||
/// specific gravity, transfer efficiency, SDS/TDS links, sample image, color families, product
|
||||
/// page — and links <see cref="InventoryItem.PowderCatalogItemId"/>. Only fills gaps, so any
|
||||
/// value already set (e.g. entered on the receive form) is preserved.
|
||||
/// </summary>
|
||||
private static void ApplyCatalogToInventory(InventoryItem item, PowderCatalogItem catalog)
|
||||
{
|
||||
item.PowderCatalogItemId = catalog.Id;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(item.ManufacturerPartNumber)) item.ManufacturerPartNumber = catalog.Sku;
|
||||
if (string.IsNullOrWhiteSpace(item.Manufacturer)) item.Manufacturer = catalog.VendorName;
|
||||
if (string.IsNullOrWhiteSpace(item.ColorName)) item.ColorName = catalog.ColorName;
|
||||
if (string.IsNullOrWhiteSpace(item.Finish)) item.Finish = catalog.Finish;
|
||||
if (string.IsNullOrWhiteSpace(item.ColorFamilies)) item.ColorFamilies = catalog.ColorFamilies;
|
||||
if (string.IsNullOrWhiteSpace(item.ImageUrl)) item.ImageUrl = catalog.ImageUrl;
|
||||
if (string.IsNullOrWhiteSpace(item.SdsUrl)) item.SdsUrl = catalog.SdsUrl;
|
||||
if (string.IsNullOrWhiteSpace(item.TdsUrl)) item.TdsUrl = catalog.TdsUrl;
|
||||
if (string.IsNullOrWhiteSpace(item.SpecPageUrl)) item.SpecPageUrl = catalog.ProductUrl;
|
||||
|
||||
item.CureTemperatureF ??= catalog.CureTemperatureF;
|
||||
item.CureTimeMinutes ??= catalog.CureTimeMinutes;
|
||||
item.SpecificGravity ??= catalog.SpecificGravity;
|
||||
item.CoverageSqFtPerLb ??= catalog.CoverageSqFtPerLb ?? 30m;
|
||||
item.TransferEfficiency ??= catalog.TransferEfficiency ?? 65m;
|
||||
|
||||
if (!item.RequiresClearCoat && catalog.RequiresClearCoat == true)
|
||||
item.RequiresClearCoat = true;
|
||||
|
||||
if (item.UnitCost <= 0 && catalog.UnitPrice > 0)
|
||||
{
|
||||
item.UnitCost = catalog.UnitPrice;
|
||||
item.LastPurchasePrice = catalog.UnitPrice;
|
||||
}
|
||||
|
||||
// Quoting reference price (current catalog list price) — separate from cost basis above.
|
||||
if (catalog.UnitPrice > 0)
|
||||
{
|
||||
item.CatalogReferencePrice = catalog.UnitPrice;
|
||||
item.CatalogPriceUpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fills blank spec/document fields on a received custom-powder inventory item from the matching
|
||||
/// platform powder catalog row, so the tenant gets a complete record instead of just the color
|
||||
/// code/name carried on the quote. No-op when the powder isn't in the catalog.
|
||||
/// </summary>
|
||||
private async Task EnrichInventoryFromCatalogAsync(
|
||||
InventoryItem item, string? colorCode, string? colorName, string? manufacturer)
|
||||
{
|
||||
var catalog = await FindCatalogByIdentityAsync(colorCode, colorName, manufacturer);
|
||||
if (catalog == null)
|
||||
return;
|
||||
|
||||
// First use — lazily fill specific gravity / cure from the TDS before copying onto the item.
|
||||
await _aiLookupService.EnsureCatalogTdsSpecsAsync(catalog);
|
||||
ApplyCatalogToInventory(item, catalog);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shared finalize for a received powder: saves the inventory item, writes the opening Purchase
|
||||
/// transaction, marks the coat received and links it, then links any sibling coats ordering the
|
||||
/// same color. Returns the number of additional coats linked. Used by both the manual modal
|
||||
/// (<see cref="AddCustomPowderToInventory"/>) and the catalog auto-receive
|
||||
/// (<see cref="ReceivePowderFromCatalog"/>).
|
||||
/// </summary>
|
||||
private async Task<int> FinalizeReceivedPowderAsync(
|
||||
JobItemCoat coat, InventoryItem inventoryItem, decimal lbsReceived, int companyId,
|
||||
string? colorCode, string? colorName, int? primaryVendorId, string? jobNumber)
|
||||
{
|
||||
await _unitOfWork.InventoryItems.AddAsync(inventoryItem);
|
||||
await _unitOfWork.CompleteAsync(); // flush to get inventoryItem.Id
|
||||
|
||||
var transaction = new InventoryTransaction
|
||||
{
|
||||
CompanyId = companyId,
|
||||
InventoryItemId = inventoryItem.Id,
|
||||
TransactionType = InventoryTransactionType.Purchase,
|
||||
Quantity = lbsReceived,
|
||||
UnitCost = inventoryItem.UnitCost,
|
||||
TotalCost = lbsReceived * inventoryItem.UnitCost,
|
||||
TransactionDate = DateTime.UtcNow,
|
||||
Notes = $"Initial stock — received from powder order for job {jobNumber}",
|
||||
BalanceAfter = lbsReceived,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
};
|
||||
await _unitOfWork.InventoryTransactions.AddAsync(transaction);
|
||||
|
||||
var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||
coat.PowderReceived = true;
|
||||
coat.PowderReceivedAt = DateTime.UtcNow;
|
||||
coat.PowderReceivedByUserId = userId;
|
||||
coat.PowderReceivedLbs = lbsReceived;
|
||||
coat.InventoryItemId = inventoryItem.Id;
|
||||
|
||||
var candidateCoats = await _unitOfWork.JobItemCoats.GetCandidateCoatsForLinkingAsync(coat.Id, companyId);
|
||||
|
||||
var linkedCount = 0;
|
||||
foreach (var other in candidateCoats)
|
||||
{
|
||||
var colorMatch = !string.IsNullOrWhiteSpace(colorCode)
|
||||
? string.Equals(other.ColorCode?.Trim(), colorCode.Trim(), StringComparison.OrdinalIgnoreCase)
|
||||
: !string.IsNullOrWhiteSpace(colorName) &&
|
||||
string.Equals(other.ColorName?.Trim(), colorName.Trim(), StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (!colorMatch) continue;
|
||||
if (primaryVendorId.HasValue && other.VendorId.HasValue && other.VendorId != primaryVendorId) continue;
|
||||
|
||||
other.InventoryItemId = inventoryItem.Id;
|
||||
linkedCount++;
|
||||
}
|
||||
|
||||
if (linkedCount > 0)
|
||||
_logger.LogInformation("Linked {Count} additional coat(s) to new inventory item {ItemId} ({SKU})",
|
||||
linkedCount, inventoryItem.Id, inventoryItem.SKU);
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
return linkedCount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a unique powder SKU for a company in the form <c>{CODE}-{YYMM}-{####}</c>, where
|
||||
/// CODE is the (padded) inventory category code. Mirrors the inventory SKU pattern used when
|
||||
/// adding catalog-sourced powders.
|
||||
/// </summary>
|
||||
private async Task<string> GeneratePowderSkuAsync(InventoryCategoryLookup category)
|
||||
{
|
||||
var code = category.CategoryCode.Length >= 4
|
||||
? category.CategoryCode[..4].ToUpperInvariant()
|
||||
: category.CategoryCode.ToUpperInvariant().PadRight(4, 'X');
|
||||
var yearMonth = DateTime.Now.ToString("yyMM");
|
||||
var prefix = $"{code}-{yearMonth}-";
|
||||
|
||||
var allItems = await _unitOfWork.InventoryItems.GetAllAsync(ignoreQueryFilters: true);
|
||||
var maxSeq = allItems
|
||||
.Where(i => i.SKU.StartsWith(prefix))
|
||||
.Select(i => int.TryParse(i.SKU[prefix.Length..], out var n) ? n : 0)
|
||||
.DefaultIfEmpty(0)
|
||||
.Max();
|
||||
|
||||
return $"{prefix}{(maxSeq + 1):D4}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Receives an ordered custom powder straight into inventory WITHOUT the manual modal when the
|
||||
/// powder is already in the master catalog — the new record is fully populated from the catalog
|
||||
/// (specs, SDS/TDS, image, pricing). Returns <c>needsDetails = true</c> (without saving) when
|
||||
/// the powder isn't in the catalog or no coating category is configured, signaling the caller to
|
||||
/// fall back to the manual entry modal.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> ReceivePowderFromCatalog(int coatId, decimal lbsReceived)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (lbsReceived <= 0)
|
||||
return Json(new { success = false, message = "Quantity received must be greater than zero." });
|
||||
|
||||
var coat = await _unitOfWork.JobItemCoats.GetByIdAsync(coatId);
|
||||
if (coat == null)
|
||||
return Json(new { success = false, message = "Coat record not found." });
|
||||
|
||||
var jobItem = await _unitOfWork.JobItems.FirstOrDefaultAsync(
|
||||
i => i.Coats.Any(c => c.Id == coatId), false, i => i.Job);
|
||||
var companyId = jobItem?.Job?.CompanyId ?? _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
|
||||
// Only auto-receive when the powder resolves in the master catalog; otherwise the caller
|
||||
// opens the manual modal.
|
||||
var catalog = await FindCatalogByIdentityAsync(coat.ColorCode, coat.ColorName, null);
|
||||
if (catalog == null)
|
||||
return Json(new { success = false, needsDetails = true });
|
||||
|
||||
// First use — lazily fill specific gravity / cure from the TDS so the new record is complete.
|
||||
await _aiLookupService.EnsureCatalogTdsSpecsAsync(catalog);
|
||||
|
||||
// Resolve the company's POWDER (coating) inventory category.
|
||||
var categories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId);
|
||||
var coatingCategory = categories.FirstOrDefault(c => c.IsActive && c.IsCoating
|
||||
&& c.CategoryCode.Equals("POWDER", StringComparison.OrdinalIgnoreCase))
|
||||
?? categories.Where(c => c.IsActive && c.IsCoating)
|
||||
.OrderBy(c => c.DisplayOrder).FirstOrDefault();
|
||||
if (coatingCategory == null)
|
||||
return Json(new { success = false, needsDetails = true });
|
||||
|
||||
var sku = await GeneratePowderSkuAsync(coatingCategory);
|
||||
|
||||
var inventoryItem = new InventoryItem
|
||||
{
|
||||
CompanyId = companyId,
|
||||
SKU = sku,
|
||||
Name = catalog.ColorName,
|
||||
ColorName = catalog.ColorName,
|
||||
ColorCode = coat.ColorCode,
|
||||
InventoryCategoryId = coatingCategory.Id,
|
||||
Category = coatingCategory.DisplayName,
|
||||
QuantityOnHand = lbsReceived,
|
||||
UnitOfMeasure = "lbs",
|
||||
UnitCost = catalog.UnitPrice,
|
||||
LastPurchasePrice = catalog.UnitPrice,
|
||||
LastPurchaseDate = DateTime.UtcNow,
|
||||
IsActive = true,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
};
|
||||
ApplyCatalogToInventory(inventoryItem, catalog);
|
||||
|
||||
var linkedCount = await FinalizeReceivedPowderAsync(
|
||||
coat, inventoryItem, lbsReceived, companyId, coat.ColorCode, coat.ColorName, null,
|
||||
jobItem?.Job?.JobNumber);
|
||||
|
||||
return Json(new
|
||||
{
|
||||
success = true,
|
||||
fromCatalog = true,
|
||||
itemName = inventoryItem.Name,
|
||||
sku = inventoryItem.SKU,
|
||||
linkedCount
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error auto-receiving powder from catalog for coat {CoatId}", coatId);
|
||||
return Json(new { success = false, message = "An error occurred while saving." });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Platform-level dashboard visible only to SuperAdmins who are not impersonating a tenant.
|
||||
/// Displays a cross-company overview: total/active/inactive company counts, user count,
|
||||
|
||||
@@ -240,6 +240,17 @@ public class InventoryController : Controller
|
||||
var useMetric = await _tenantContext.UseMetricSystemAsync();
|
||||
ViewBag.CoverageUnit = _measurementService.GetCoverageUnitLabel(useMetric);
|
||||
|
||||
// Manufacturer-level catalog status: prefer the linked catalog row, fall back to an
|
||||
// identity match for items added before they were linked. Drives the "discontinued by
|
||||
// manufacturer — cannot reorder" warning. This is distinct from the shop's own
|
||||
// IsActive/DiscontinuedDate (whether the shop still stocks it).
|
||||
var catalogItem = item.PowderCatalogItemId.HasValue
|
||||
? await _unitOfWork.PowderCatalog.GetByIdAsync(item.PowderCatalogItemId.Value)
|
||||
: await FindCatalogMatchAsync(item.Manufacturer, item.ManufacturerPartNumber);
|
||||
ViewBag.CatalogDiscontinued = catalogItem?.IsDiscontinued ?? false;
|
||||
ViewBag.CatalogVendorName = catalogItem?.VendorName;
|
||||
ViewBag.CatalogProductUrl = catalogItem?.ProductUrl;
|
||||
|
||||
return View(itemDto);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -302,6 +313,20 @@ public class InventoryController : Controller
|
||||
item.Category = category.DisplayName;
|
||||
}
|
||||
|
||||
// Link to the platform catalog row when this item's identity matches one, so the detail
|
||||
// screen can show manufacturer-level status (discontinued / cannot reorder) and quotes
|
||||
// can use the current catalog price.
|
||||
var catalogMatch = await FindCatalogMatchAsync(item.Manufacturer, item.ManufacturerPartNumber);
|
||||
if (catalogMatch != null)
|
||||
{
|
||||
item.PowderCatalogItemId = catalogMatch.Id;
|
||||
if (catalogMatch.UnitPrice > 0)
|
||||
{
|
||||
item.CatalogReferencePrice = catalogMatch.UnitPrice;
|
||||
item.CatalogPriceUpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
await _unitOfWork.InventoryItems.AddAsync(item);
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
|
||||
@@ -763,6 +788,24 @@ public class InventoryController : Controller
|
||||
/// Returns (wasInCatalog, addedToCatalog) so callers can surface UI badges.
|
||||
/// Mutates <paramref name="result"/> in place.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// Finds the platform powder catalog row matching an inventory item's identity
|
||||
/// (Manufacturer + ManufacturerPartNumber), or null. Used to set
|
||||
/// <see cref="InventoryItem.PowderCatalogItemId"/> and to surface manufacturer-level status
|
||||
/// (e.g. discontinued / cannot reorder) on the detail screen.
|
||||
/// </summary>
|
||||
private async Task<PowderCatalogItem?> FindCatalogMatchAsync(string? manufacturer, string? sku)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(manufacturer) || string.IsNullOrWhiteSpace(sku))
|
||||
return null;
|
||||
|
||||
var skuLower = sku.Trim().ToLower();
|
||||
var mfrLower = manufacturer.Trim().ToLower();
|
||||
var hits = await _unitOfWork.PowderCatalog.FindAsync(p =>
|
||||
p.Sku.ToLower() == skuLower && p.VendorName.ToLower().Contains(mfrLower));
|
||||
return hits.FirstOrDefault();
|
||||
}
|
||||
|
||||
private async Task<(bool wasInCatalog, bool addedToCatalog)> EnrichFromCatalogAsync(
|
||||
InventoryAiLookupResult result, bool autoContribute)
|
||||
{
|
||||
@@ -1220,6 +1263,10 @@ public class InventoryController : Controller
|
||||
if (catalogItem == null)
|
||||
return Json(new { success = false, error = "Catalog item not found." });
|
||||
|
||||
// First use of this powder — lazily fill specific gravity / cure from its TDS so the new
|
||||
// inventory record (and the catalog) carry complete specs. No-op once already enriched.
|
||||
await _aiLookupService.EnsureCatalogTdsSpecsAsync(catalogItem);
|
||||
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
|
||||
// Find the default coating category to assign.
|
||||
@@ -1257,6 +1304,7 @@ public class InventoryController : Controller
|
||||
ColorName = catalogItem.ColorName,
|
||||
Manufacturer = catalogItem.VendorName,
|
||||
ManufacturerPartNumber= catalogItem.Sku,
|
||||
PowderCatalogItemId = catalogItem.Id,
|
||||
Finish = catalogItem.Finish,
|
||||
ColorFamilies = catalogItem.ColorFamilies,
|
||||
RequiresClearCoat = catalogItem.RequiresClearCoat ?? false,
|
||||
@@ -1272,6 +1320,8 @@ public class InventoryController : Controller
|
||||
UnitCost = catalogItem.UnitPrice,
|
||||
AverageCost = catalogItem.UnitPrice,
|
||||
LastPurchasePrice = catalogItem.UnitPrice,
|
||||
CatalogReferencePrice = catalogItem.UnitPrice > 0 ? catalogItem.UnitPrice : (decimal?)null,
|
||||
CatalogPriceUpdatedAt = catalogItem.UnitPrice > 0 ? DateTime.UtcNow : (DateTime?)null,
|
||||
QuantityOnHand = 0,
|
||||
UnitOfMeasure = "lbs",
|
||||
InventoryCategoryId = coatingCategory.Id,
|
||||
@@ -1297,7 +1347,7 @@ public class InventoryController : Controller
|
||||
efficiency = item.TransferEfficiency ?? 65m,
|
||||
unitOfMeasure= item.UnitOfMeasure,
|
||||
categoryName = coatingCategory.DisplayName,
|
||||
costPerLb = item.UnitCost,
|
||||
costPerLb = item.CatalogReferencePrice ?? item.UnitCost,
|
||||
colorName = item.ColorName ?? item.Name,
|
||||
colorCode = "",
|
||||
isIncoming = true
|
||||
|
||||
@@ -30,7 +30,6 @@ public class InvoicesController : Controller
|
||||
private readonly INotificationService _notificationService;
|
||||
private readonly IAccountBalanceService _accountBalanceService;
|
||||
private readonly ICompanyLogoService _logoService;
|
||||
private readonly IConfiguration _configuration;
|
||||
|
||||
public InvoicesController(
|
||||
IUnitOfWork unitOfWork,
|
||||
@@ -41,8 +40,7 @@ public class InvoicesController : Controller
|
||||
ITenantContext tenantContext,
|
||||
INotificationService notificationService,
|
||||
IAccountBalanceService accountBalanceService,
|
||||
ICompanyLogoService logoService,
|
||||
IConfiguration configuration)
|
||||
ICompanyLogoService logoService)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_mapper = mapper;
|
||||
@@ -53,7 +51,6 @@ public class InvoicesController : Controller
|
||||
_notificationService = notificationService;
|
||||
_accountBalanceService = accountBalanceService;
|
||||
_logoService = logoService;
|
||||
_configuration = configuration;
|
||||
}
|
||||
|
||||
private static readonly string[] StandardPaymentTerms =
|
||||
@@ -85,14 +82,15 @@ public class InvoicesController : Controller
|
||||
// -----------------------------------------------------------------------
|
||||
/// <summary>
|
||||
/// Displays the paginated invoice list with multi-mode filtering. The filter cascade handles
|
||||
/// nine combinations of overdue/outstanding/thisMonth flags with status and search term so the
|
||||
/// database receives a single targeted predicate — no full-table load then in-memory LINQ.
|
||||
/// statusGroup pills (unpaid/partial/paid/all) plus legacy flag combinations (overdue/outstanding/thisMonth)
|
||||
/// so the database receives a single targeted predicate — no full-table load then in-memory LINQ.
|
||||
/// Balance-due sort is computed in the ORDER BY expression rather than a stored column because
|
||||
/// balance = Total − AmountPaid − CreditApplied − GiftCertificateRedeemed changes on every payment.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Index(
|
||||
string? searchTerm,
|
||||
InvoiceStatus? statusFilter,
|
||||
string? statusGroup,
|
||||
string? sortColumn,
|
||||
string sortDirection = "desc",
|
||||
bool outstandingOnly = false,
|
||||
@@ -103,6 +101,11 @@ public class InvoicesController : Controller
|
||||
{
|
||||
try
|
||||
{
|
||||
// Default landing: show unpaid invoices so the list is immediately actionable.
|
||||
if (string.IsNullOrEmpty(statusGroup) && !statusFilter.HasValue &&
|
||||
string.IsNullOrEmpty(searchTerm) && !outstandingOnly && !thisMonthOnly && !overdueOnly)
|
||||
return RedirectToAction("Index", new { statusGroup = "unpaid" });
|
||||
|
||||
var today = DateTime.Today;
|
||||
var startOfMonth = new DateTime(today.Year, today.Month, 1);
|
||||
var endOfMonth = startOfMonth.AddMonths(1);
|
||||
@@ -119,7 +122,18 @@ public class InvoicesController : Controller
|
||||
|
||||
System.Linq.Expressions.Expression<Func<Invoice, bool>>? filter = null;
|
||||
|
||||
if (overdueOnly)
|
||||
// Status-group pills take priority over the dropdown and legacy flags.
|
||||
if (!string.IsNullOrEmpty(statusGroup))
|
||||
{
|
||||
filter = statusGroup switch
|
||||
{
|
||||
"unpaid" => i => i.Status == InvoiceStatus.Draft || i.Status == InvoiceStatus.Sent || i.Status == InvoiceStatus.Overdue,
|
||||
"partial" => i => i.Status == InvoiceStatus.PartiallyPaid,
|
||||
"paid" => i => i.Status == InvoiceStatus.Paid,
|
||||
_ => null // "all" — no predicate
|
||||
};
|
||||
}
|
||||
else if (overdueOnly)
|
||||
{
|
||||
filter = i => (i.Status == InvoiceStatus.Sent || i.Status == InvoiceStatus.PartiallyPaid || i.Status == InvoiceStatus.Overdue)
|
||||
&& i.DueDate.HasValue && i.DueDate.Value < today;
|
||||
@@ -218,12 +232,20 @@ public class InvoicesController : Controller
|
||||
|
||||
ViewBag.SearchTerm = searchTerm;
|
||||
ViewBag.StatusFilter = statusFilter;
|
||||
ViewBag.StatusGroup = statusGroup;
|
||||
ViewBag.OutstandingOnly = outstandingOnly;
|
||||
ViewBag.ThisMonthOnly = thisMonthOnly;
|
||||
ViewBag.OverdueOnly = overdueOnly;
|
||||
ViewBag.SortColumn = gridRequest.SortColumn;
|
||||
ViewBag.SortDirection = gridRequest.SortDirection;
|
||||
|
||||
// Pill badge counts — always global (not scoped to current filter/page)
|
||||
ViewBag.UnpaidCount = await _unitOfWork.Invoices.CountAsync(i =>
|
||||
i.Status == InvoiceStatus.Draft || i.Status == InvoiceStatus.Sent || i.Status == InvoiceStatus.Overdue);
|
||||
ViewBag.PartialCount = await _unitOfWork.Invoices.CountAsync(i => i.Status == InvoiceStatus.PartiallyPaid);
|
||||
ViewBag.PaidCount = await _unitOfWork.Invoices.CountAsync(i => i.Status == InvoiceStatus.Paid);
|
||||
ViewBag.AllCount = await _unitOfWork.Invoices.CountAsync();
|
||||
|
||||
return View(pagedResult);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -281,20 +303,6 @@ public class InvoicesController : Controller
|
||||
ViewBag.OnlinePaymentsEnabled = onlinePaymentsAllowed
|
||||
&& company?.StripeConnectStatus == StripeConnectStatus.Active;
|
||||
|
||||
// In-person card reader (Stripe Terminal) — bundled with the online-payments entitlement.
|
||||
// Surface the active readers + a "Take Card Payment" button only when at least one exists.
|
||||
var terminalReaders = (await _unitOfWork.TerminalReaders.FindAsync(
|
||||
r => r.Status == Core.Enums.TerminalReaderStatus.Active))
|
||||
.OrderBy(r => r.Label)
|
||||
.Select(r => new SelectListItem(r.Label, r.Id.ToString()))
|
||||
.ToList();
|
||||
ViewBag.TerminalReaders = terminalReaders;
|
||||
ViewBag.TerminalPaymentsEnabled = onlinePaymentsAllowed
|
||||
&& company?.StripeConnectStatus == StripeConnectStatus.Active
|
||||
&& terminalReaders.Count > 0;
|
||||
ViewBag.TerminalTestMode =
|
||||
(_configuration["Stripe:Connect:SecretKey"] ?? string.Empty).StartsWith("sk_test_", StringComparison.Ordinal);
|
||||
|
||||
// Expense accounts for the write-off bad-debt modal
|
||||
var expenseAccounts = await _unitOfWork.Accounts.FindAsync(
|
||||
a => a.IsActive && a.AccountType == AccountType.Expense);
|
||||
@@ -1327,14 +1335,17 @@ public class InvoicesController : Controller
|
||||
|
||||
}); // end ExecuteInTransactionAsync
|
||||
|
||||
// Notify (non-blocking)
|
||||
try
|
||||
// Notify (non-blocking) — skipped if user explicitly suppressed it
|
||||
if (!dto.SuppressNotification)
|
||||
{
|
||||
await _notificationService.NotifyPaymentReceivedAsync(invoice, payment);
|
||||
}
|
||||
catch (Exception notifyEx)
|
||||
{
|
||||
_logger.LogWarning(notifyEx, "Payment recorded but notification failed");
|
||||
try
|
||||
{
|
||||
await _notificationService.NotifyPaymentReceivedAsync(invoice, payment);
|
||||
}
|
||||
catch (Exception notifyEx)
|
||||
{
|
||||
_logger.LogWarning(notifyEx, "Payment recorded but notification failed");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -3492,7 +3492,8 @@ public class JobsController : Controller
|
||||
efficiency = i.TransferEfficiency ?? 65m,
|
||||
unitOfMeasure = i.UnitOfMeasure ?? "lbs",
|
||||
categoryName = i.InventoryCategory!.DisplayName,
|
||||
costPerLb = i.UnitCost,
|
||||
// Quote at the current catalog price when linked; fall back to their cost otherwise.
|
||||
costPerLb = i.CatalogReferencePrice ?? i.UnitCost,
|
||||
colorName = i.ColorName ?? i.Name,
|
||||
colorCode = i.ColorCode ?? "",
|
||||
isIncoming = i.IsIncoming
|
||||
|
||||
@@ -381,11 +381,6 @@ public class PaymentController : Controller
|
||||
var dispute = stripeEvent.Data.Object as Dispute;
|
||||
if (dispute != null) await HandleDisputeClosedAsync(dispute);
|
||||
}
|
||||
else if (stripeEvent.Type == "terminal.reader.action_failed")
|
||||
{
|
||||
var reader = stripeEvent.Data.Object as Stripe.Terminal.Reader;
|
||||
if (reader != null) await HandleReaderActionFailedAsync(reader);
|
||||
}
|
||||
|
||||
return Ok();
|
||||
}
|
||||
@@ -464,24 +459,15 @@ public class PaymentController : Controller
|
||||
// Create a Payment record so the payment appears in AR and bank reports, and make the
|
||||
// matching GL entries. Manual payments go through RecordPayment which does the same thing;
|
||||
// this makes Stripe payments consistent with that path.
|
||||
// In-person Terminal payments carry source=terminal so we can record them as a card-reader
|
||||
// payment (vs an online card-not-present payment) for clearer reporting. Everything else —
|
||||
// GL posting, status machine, notifications — is identical.
|
||||
var isTerminal = intent.Metadata.GetValueOrDefault("source") == "terminal";
|
||||
|
||||
var (arAcctId, checkingAcctId) = await GetGlAccountIdsAsync(invoice.CompanyId);
|
||||
var stripePayment = new Core.Entities.Payment
|
||||
{
|
||||
InvoiceId = invoice.Id,
|
||||
Amount = netPayment,
|
||||
PaymentDate = DateTime.UtcNow,
|
||||
PaymentMethod = isTerminal
|
||||
? PowderCoating.Core.Enums.PaymentMethod.CardReader
|
||||
: PowderCoating.Core.Enums.PaymentMethod.CreditDebitCard,
|
||||
PaymentMethod = PowderCoating.Core.Enums.PaymentMethod.CreditDebitCard,
|
||||
Reference = intent.Id,
|
||||
Notes = isTerminal
|
||||
? $"In-person card payment via Stripe Terminal. Surcharge: {surcharge:C}"
|
||||
: $"Online payment via Stripe. Surcharge: {surcharge:C}",
|
||||
Notes = $"Online payment via Stripe. Surcharge: {surcharge:C}",
|
||||
DepositAccountId = checkingAcctId,
|
||||
CompanyId = invoice.CompanyId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
@@ -516,45 +502,6 @@ public class PaymentController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles a <c>terminal.reader.action_failed</c> event (declined card, customer cancellation,
|
||||
/// reader timeout). This is observability only — no payment occurred, so nothing is written to the
|
||||
/// ledger. The clerk's live status poll is the primary feedback channel; this fires an in-app
|
||||
/// notification as a backstop in case they navigated away. Resolves the company via the invoice the
|
||||
/// failed PaymentIntent was created for. Uses <c>IgnoreQueryFilters</c> (no tenant context here).
|
||||
/// </summary>
|
||||
private async Task HandleReaderActionFailedAsync(Stripe.Terminal.Reader reader)
|
||||
{
|
||||
var failureMessage = reader.Action?.FailureMessage ?? "The card reader payment did not complete.";
|
||||
var paymentIntentId = reader.Action?.ProcessPaymentIntent?.PaymentIntentId;
|
||||
|
||||
_logger.LogWarning("Terminal reader {ReaderId} action failed: {Code} {Message} (PI={PI})",
|
||||
reader.Id, reader.Action?.FailureCode, failureMessage, paymentIntentId);
|
||||
|
||||
if (string.IsNullOrEmpty(paymentIntentId)) return;
|
||||
|
||||
var invoice = await _context.Invoices
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(i => i.StripePaymentIntentId == paymentIntentId && !i.IsDeleted);
|
||||
if (invoice == null) return;
|
||||
|
||||
try
|
||||
{
|
||||
await _inApp.CreateAsync(
|
||||
companyId: invoice.CompanyId,
|
||||
title: "Card Reader Payment Failed",
|
||||
message: $"The card reader payment for invoice {invoice.InvoiceNumber} failed: {failureMessage}",
|
||||
notificationType: "PaymentFailed",
|
||||
link: $"/Invoices/Details/{invoice.Id}",
|
||||
invoiceId: invoice.Id,
|
||||
customerId: invoice.CustomerId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "In-app notification failed for terminal failure on invoice {InvoiceId}", invoice.Id);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Processes a successful <c>payment_intent.succeeded</c> event for a quote deposit. Creates a
|
||||
/// <c>Deposit</c> ledger record so the deposit appears in the customer's deposit history and can
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using PowderCoating.Application.Constants;
|
||||
using PowderCoating.Application.DTOs.Common;
|
||||
using PowderCoating.Application.DTOs.Inventory;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
@@ -17,17 +18,31 @@ public class PowderCatalogController : Controller
|
||||
{
|
||||
private const decimal DefaultTransferEfficiency = 65m;
|
||||
|
||||
private const string JsonImportSource = "Manual JSON Import";
|
||||
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IInventoryAiLookupService _aiLookupService;
|
||||
private readonly IColumbiaCatalogSyncService _columbiaSyncService;
|
||||
private readonly IPowderCatalogUpsertService _upsertService;
|
||||
private readonly IPlatformSettingsService _platformSettings;
|
||||
private readonly IConfiguration _config;
|
||||
private readonly ILogger<PowderCatalogController> _logger;
|
||||
|
||||
public PowderCatalogController(
|
||||
IUnitOfWork unitOfWork,
|
||||
IInventoryAiLookupService aiLookupService,
|
||||
IColumbiaCatalogSyncService columbiaSyncService,
|
||||
IPowderCatalogUpsertService upsertService,
|
||||
IPlatformSettingsService platformSettings,
|
||||
IConfiguration config,
|
||||
ILogger<PowderCatalogController> logger)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_aiLookupService = aiLookupService;
|
||||
_columbiaSyncService = columbiaSyncService;
|
||||
_upsertService = upsertService;
|
||||
_platformSettings = platformSettings;
|
||||
_config = config;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -135,6 +150,11 @@ public class PowderCatalogController : Controller
|
||||
}
|
||||
};
|
||||
|
||||
// Columbia sync status for the admin panel (last run + master switch).
|
||||
ViewBag.ColumbiaSyncEnabled = await _platformSettings.GetBoolAsync(ColumbiaIntegrationConstants.SettingEnabled);
|
||||
ViewBag.ColumbiaLastSyncedAt = await _platformSettings.GetAsync(ColumbiaIntegrationConstants.SettingLastSyncedAt);
|
||||
ViewBag.ColumbiaLastResult = await _platformSettings.GetAsync(ColumbiaIntegrationConstants.SettingLastResult);
|
||||
|
||||
return View(vm);
|
||||
}
|
||||
|
||||
@@ -355,7 +375,8 @@ public class PowderCatalogController : Controller
|
||||
PowderCatalogImportResult result;
|
||||
try
|
||||
{
|
||||
result = await ImportJsonAsync(file, vendorName);
|
||||
using var stream = file.OpenReadStream();
|
||||
result = await ImportJsonAsync(stream, vendorName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -376,6 +397,67 @@ public class PowderCatalogController : Controller
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unattended catalog import for the offline scraper tool (e.g. PrismaticSync). Accepts the same
|
||||
/// JSON scrape format in the request body, authenticated by a shared secret in the
|
||||
/// <c>X-Import-Token</c> header (matched against <c>CatalogImport:Token</c>). The vendor name
|
||||
/// comes from the <c>X-Vendor-Name</c> header. Runs through the same upsert as the manual upload.
|
||||
/// Inert (401) until a token is configured.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[AllowAnonymous]
|
||||
[IgnoreAntiforgeryToken]
|
||||
[RequestSizeLimit(50 * 1024 * 1024)] // 50 MB
|
||||
public async Task<IActionResult> ImportApi()
|
||||
{
|
||||
var configuredToken = _config["CatalogImport:Token"];
|
||||
if (string.IsNullOrWhiteSpace(configuredToken))
|
||||
{
|
||||
_logger.LogWarning("ImportApi called but no CatalogImport:Token is configured — rejecting.");
|
||||
return Unauthorized(new { success = false, errorMessage = "Import API is not enabled." });
|
||||
}
|
||||
|
||||
var providedToken = Request.Headers["X-Import-Token"].ToString();
|
||||
if (!FixedTimeEquals(providedToken, configuredToken))
|
||||
return Unauthorized(new { success = false, errorMessage = "Invalid import token." });
|
||||
|
||||
var vendorName = Request.Headers["X-Vendor-Name"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(vendorName))
|
||||
vendorName = "Prismatic Powders";
|
||||
|
||||
try
|
||||
{
|
||||
var result = await ImportJsonAsync(Request.Body, vendorName);
|
||||
_logger.LogInformation(
|
||||
"ImportApi ({Vendor}): {Inserted} inserted, {Updated} updated, {Skipped} skipped, {Errors} errors.",
|
||||
vendorName, result.Inserted, result.Updated, result.Skipped, result.Errors);
|
||||
|
||||
return Json(new
|
||||
{
|
||||
success = result.Success,
|
||||
vendorName,
|
||||
result.Inserted,
|
||||
result.Updated,
|
||||
result.Skipped,
|
||||
result.Errors,
|
||||
result.ErrorMessage
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "ImportApi failed for vendor {Vendor}", vendorName);
|
||||
return StatusCode(500, new { success = false, errorMessage = "Import failed." });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Constant-time string comparison so token checks don't leak length/contents via timing.</summary>
|
||||
private static bool FixedTimeEquals(string a, string b)
|
||||
{
|
||||
var ba = System.Text.Encoding.UTF8.GetBytes(a ?? string.Empty);
|
||||
var bb = System.Text.Encoding.UTF8.GetBytes(b ?? string.Empty);
|
||||
return System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(ba, bb);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// AJAX endpoint used by the inventory form to search the catalog by SKU or color name.
|
||||
/// SKU exact matches are ranked first; color name substring matches follow.
|
||||
@@ -422,6 +504,78 @@ public class PowderCatalogController : Controller
|
||||
return Json(results);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manually triggers a full Columbia Coatings catalog sync (SuperAdmin only). Bypasses the
|
||||
/// scheduled interval. Reports the run outcome via TempData on the catalog index.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> SyncColumbia(CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await _columbiaSyncService.RunSyncAsync(cancellationToken);
|
||||
|
||||
if (result.Success)
|
||||
TempData["Success"] = $"Columbia sync complete - {result.Summary}";
|
||||
else
|
||||
TempData["Error"] = $"Columbia sync failed: {result.ErrorMessage}";
|
||||
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Right-to-delete: removes every catalog record sourced from the Columbia Coatings API
|
||||
/// (regardless of derived manufacturer, since PPG/KP products were served through that feed)
|
||||
/// and nulls any inventory links to them across all tenants. The shops' own inventory stock
|
||||
/// records survive — only the catalog link and discontinued badge are lost. SuperAdmin only.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> PurgeColumbiaData()
|
||||
{
|
||||
var sourced = (await _unitOfWork.PowderCatalog.FindAsync(
|
||||
p => p.Source == ColumbiaIntegrationConstants.SourceName)).ToList();
|
||||
|
||||
if (sourced.Count == 0)
|
||||
{
|
||||
TempData["Error"] = "There is no Columbia Coatings API data to remove.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
var ids = sourced.Select(p => p.Id).ToList();
|
||||
|
||||
// Null the inventory links across ALL tenants (platform-level purge). A tenant's stock
|
||||
// record is their data and must survive — it keeps its add-time snapshot, losing only the
|
||||
// live catalog link.
|
||||
var linked = (await _unitOfWork.InventoryItems.FindAsync(
|
||||
i => i.PowderCatalogItemId.HasValue && ids.Contains(i.PowderCatalogItemId.Value),
|
||||
ignoreQueryFilters: true)).ToList();
|
||||
foreach (var inv in linked)
|
||||
{
|
||||
inv.PowderCatalogItemId = null;
|
||||
await _unitOfWork.InventoryItems.UpdateAsync(inv);
|
||||
}
|
||||
|
||||
foreach (var p in sourced)
|
||||
await _unitOfWork.PowderCatalog.DeleteAsync(p);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// Reset sync tracking so the admin panel reflects the purge.
|
||||
await _platformSettings.SetAsync(ColumbiaIntegrationConstants.SettingLastSyncedAt, null, "Columbia Purge");
|
||||
await _platformSettings.SetAsync(
|
||||
ColumbiaIntegrationConstants.SettingLastResult,
|
||||
$"Purged {sourced.Count:N0} records on {DateTime.UtcNow:yyyy-MM-dd}",
|
||||
"Columbia Purge");
|
||||
|
||||
_logger.LogWarning(
|
||||
"Columbia data purge: deleted {Count} catalog records, unlinked {Linked} inventory items.",
|
||||
sourced.Count, linked.Count);
|
||||
|
||||
TempData["Success"] =
|
||||
$"Removed {sourced.Count:N0} Columbia Coatings catalog record(s) and unlinked " +
|
||||
$"{linked.Count:N0} inventory item(s). Inventory stock was preserved.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
// Private helpers
|
||||
|
||||
private async Task ApplyTdsCureFallbackAsync(InventoryAiLookupResult result, string? colorName)
|
||||
@@ -438,9 +592,8 @@ public class PowderCatalogController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<PowderCatalogImportResult> ImportJsonAsync(IFormFile file, string vendorName)
|
||||
private async Task<PowderCatalogImportResult> ImportJsonAsync(Stream stream, string vendorName)
|
||||
{
|
||||
using var stream = file.OpenReadStream();
|
||||
using var doc = await JsonDocument.ParseAsync(stream);
|
||||
|
||||
if (!doc.RootElement.TryGetProperty("results", out var resultsEl) ||
|
||||
@@ -449,13 +602,10 @@ public class PowderCatalogController : Controller
|
||||
return new PowderCatalogImportResult { Success = false, ErrorMessage = "JSON must have a top-level 'results' array." };
|
||||
}
|
||||
|
||||
// Load existing records for this vendor into a lookup dictionary
|
||||
var existing = (await _unitOfWork.PowderCatalog.FindAsync(p => p.VendorName == vendorName))
|
||||
.ToDictionary(p => p.Sku, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
int inserted = 0, updated = 0, skipped = 0, errors = 0;
|
||||
var toAdd = new List<PowderCatalogItem>();
|
||||
// Map the scrape format to catalog items, then hand off to the shared upsert path (same
|
||||
// one the Columbia API sync uses) so there is a single insert/update/diff implementation.
|
||||
var mapped = new List<PowderCatalogItem>();
|
||||
int skipped = 0, errors = 0;
|
||||
|
||||
foreach (var item in resultsEl.EnumerateArray())
|
||||
{
|
||||
@@ -469,49 +619,21 @@ public class PowderCatalogController : Controller
|
||||
continue;
|
||||
}
|
||||
|
||||
var rawDesc = item.GetStringOrNull("description");
|
||||
var cleanDesc = StripBoilerplate(rawDesc);
|
||||
var unitPrice = ExtractBasePrice(item);
|
||||
var priceTiersJson = item.TryGetProperty("price_tiers", out var tiersEl)
|
||||
? tiersEl.GetRawText()
|
||||
: null;
|
||||
|
||||
if (existing.TryGetValue(sku, out var record))
|
||||
mapped.Add(new PowderCatalogItem
|
||||
{
|
||||
record.ColorName = colorName;
|
||||
record.Description = cleanDesc;
|
||||
record.UnitPrice = unitPrice;
|
||||
record.PriceTiersJson = priceTiersJson;
|
||||
record.ImageUrl = item.GetStringOrNull("sample_image_url");
|
||||
record.SdsUrl = item.GetStringOrNull("safety_data_sheet_url");
|
||||
record.TdsUrl = item.GetStringOrNull("technical_data_sheet_url");
|
||||
record.ApplicationGuideUrl = item.GetStringOrNull("application_guide_url");
|
||||
record.ProductUrl = item.GetStringOrNull("product_url");
|
||||
record.UpdatedAt = now;
|
||||
record.LastSyncedAt = now;
|
||||
await _unitOfWork.PowderCatalog.UpdateAsync(record);
|
||||
updated++;
|
||||
}
|
||||
else
|
||||
{
|
||||
toAdd.Add(new PowderCatalogItem
|
||||
{
|
||||
VendorName = vendorName,
|
||||
Sku = sku,
|
||||
ColorName = colorName,
|
||||
Description = cleanDesc,
|
||||
UnitPrice = unitPrice,
|
||||
PriceTiersJson = priceTiersJson,
|
||||
ImageUrl = item.GetStringOrNull("sample_image_url"),
|
||||
SdsUrl = item.GetStringOrNull("safety_data_sheet_url"),
|
||||
TdsUrl = item.GetStringOrNull("technical_data_sheet_url"),
|
||||
ApplicationGuideUrl = item.GetStringOrNull("application_guide_url"),
|
||||
ProductUrl = item.GetStringOrNull("product_url"),
|
||||
CreatedAt = now,
|
||||
LastSyncedAt = now
|
||||
});
|
||||
inserted++;
|
||||
}
|
||||
VendorName = vendorName,
|
||||
Source = JsonImportSource,
|
||||
Sku = sku,
|
||||
ColorName = colorName,
|
||||
Description = StripBoilerplate(item.GetStringOrNull("description")),
|
||||
UnitPrice = ExtractBasePrice(item),
|
||||
PriceTiersJson = item.TryGetProperty("price_tiers", out var tiersEl) ? tiersEl.GetRawText() : null,
|
||||
ImageUrl = item.GetStringOrNull("sample_image_url"),
|
||||
SdsUrl = item.GetStringOrNull("safety_data_sheet_url"),
|
||||
TdsUrl = item.GetStringOrNull("technical_data_sheet_url"),
|
||||
ApplicationGuideUrl = item.GetStringOrNull("application_guide_url"),
|
||||
ProductUrl = item.GetStringOrNull("product_url"),
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -520,17 +642,14 @@ public class PowderCatalogController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
if (toAdd.Any())
|
||||
await _unitOfWork.PowderCatalog.AddRangeAsync(toAdd);
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
var upsert = await _upsertService.UpsertAsync(mapped, DateTime.UtcNow);
|
||||
|
||||
return new PowderCatalogImportResult
|
||||
{
|
||||
Success = true,
|
||||
Inserted = inserted,
|
||||
Updated = updated,
|
||||
Skipped = skipped,
|
||||
Inserted = upsert.Inserted,
|
||||
Updated = upsert.Updated,
|
||||
Skipped = skipped + upsert.Skipped,
|
||||
Errors = errors
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2545,7 +2545,8 @@ public class QuotesController : Controller
|
||||
efficiency = i.TransferEfficiency ?? 65m,
|
||||
unitOfMeasure = i.UnitOfMeasure ?? "lbs",
|
||||
categoryName = i.InventoryCategory!.DisplayName,
|
||||
costPerLb = i.UnitCost,
|
||||
// Quote at the current catalog price when linked; fall back to their cost otherwise.
|
||||
costPerLb = i.CatalogReferencePrice ?? i.UnitCost,
|
||||
colorName = i.ColorName ?? i.Name,
|
||||
colorCode = i.ColorCode ?? "",
|
||||
isIncoming = i.IsIncoming
|
||||
|
||||
@@ -1,342 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using PowderCoating.Application.DTOs.Terminal;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Shared.Constants;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Authenticated, tenant-scoped controller for Stripe Terminal in-person card payments (WisePOS E).
|
||||
/// Handles reader registration/management (admin) and pushing an invoice payment to a physical reader
|
||||
/// plus live status polling (anyone who can manage invoices).
|
||||
/// <para>
|
||||
/// The authoritative payment record is always created by the existing <c>payment_intent.succeeded</c>
|
||||
/// webhook in <see cref="PaymentController"/> — this controller only kicks off the charge on the reader
|
||||
/// and reports progress. See <c>docs</c>/the plan for the full flow.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageInvoices)]
|
||||
public class TerminalController : Controller
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IStripeConnectService _stripeConnect;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly ILogger<TerminalController> _logger;
|
||||
|
||||
public TerminalController(
|
||||
IUnitOfWork unitOfWork,
|
||||
IStripeConnectService stripeConnect,
|
||||
ITenantContext tenantContext,
|
||||
IConfiguration configuration,
|
||||
ILogger<TerminalController> logger)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_stripeConnect = stripeConnect;
|
||||
_tenantContext = tenantContext;
|
||||
_configuration = configuration;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>Current tenant's company id. The CanManageInvoices policy guarantees a company-scoped user;
|
||||
/// a 0 fallback fails safe (matches no company) for the theoretical claim-less case.</summary>
|
||||
private int CompanyId => _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
|
||||
/// <summary>True when the Connect secret key is a test-mode key — gates the simulated-tap endpoint.</summary>
|
||||
private bool IsTestMode =>
|
||||
(_configuration["Stripe:Connect:SecretKey"] ?? string.Empty).StartsWith("sk_test_", StringComparison.Ordinal);
|
||||
|
||||
// ===== Reader management (admin) =====
|
||||
|
||||
/// <summary>
|
||||
/// Registers a Stripe Terminal reader to the company using the registration code shown on the
|
||||
/// device. Lazily creates the company's single Terminal Location from its address on first use.
|
||||
/// Requires company-admin rights in addition to the controller's invoice policy.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
|
||||
public async Task<IActionResult> RegisterReader(string registrationCode, string label)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(registrationCode) || string.IsNullOrWhiteSpace(label))
|
||||
return Json(new { success = false, error = "A registration code and a label are both required." });
|
||||
|
||||
var companyId = CompanyId;
|
||||
var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
|
||||
if (company == null)
|
||||
return Json(new { success = false, error = "Company not found." });
|
||||
if (company.StripeConnectStatus != StripeConnectStatus.Active || string.IsNullOrEmpty(company.StripeAccountId))
|
||||
return Json(new { success = false, error = "Connect your Stripe account before registering a reader." });
|
||||
|
||||
// Ensure the shop's Terminal Location exists (one per company).
|
||||
var (locOk, locationId, locError) = await EnsureLocationAsync(company);
|
||||
if (!locOk)
|
||||
return Json(new { success = false, error = locError });
|
||||
|
||||
var (ok, readerId, deviceType, serial, error) = await _stripeConnect.RegisterReaderAsync(
|
||||
company.StripeAccountId!, locationId!, registrationCode.Trim(), label.Trim());
|
||||
if (!ok)
|
||||
return Json(new { success = false, error });
|
||||
|
||||
var reader = new TerminalReader
|
||||
{
|
||||
CompanyId = companyId,
|
||||
StripeReaderId = readerId!,
|
||||
StripeLocationId = locationId!,
|
||||
Label = label.Trim(),
|
||||
DeviceType = deviceType ?? string.Empty,
|
||||
SerialNumber = serial,
|
||||
Status = TerminalReaderStatus.Active
|
||||
};
|
||||
await _unitOfWork.TerminalReaders.AddAsync(reader);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
return Json(new { success = true, reader = ToJson(reader) });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves the in-person (Terminal) surcharge toggle. Defaults off; enabling it applies the same
|
||||
/// percent/flat fee configured for online payments to card-reader charges. Admin only.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
|
||||
public async Task<IActionResult> UpdateTerminalSettings(bool surchargeEnabled)
|
||||
{
|
||||
var companyId = CompanyId;
|
||||
var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
|
||||
if (company == null)
|
||||
return Json(new { success = false, error = "Company not found." });
|
||||
|
||||
company.TerminalSurchargeEnabled = surchargeEnabled;
|
||||
company.UpdatedAt = DateTime.UtcNow;
|
||||
await _unitOfWork.Companies.UpdateAsync(company);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
return Json(new { success = true });
|
||||
}
|
||||
|
||||
/// <summary>Returns the company's active registered readers (JSON) for the settings tab.</summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> ListReaders()
|
||||
{
|
||||
var companyId = CompanyId;
|
||||
var readers = await _unitOfWork.TerminalReaders.FindAsync(
|
||||
r => r.CompanyId == companyId && r.Status == TerminalReaderStatus.Active);
|
||||
return Json(new { success = true, readers = readers.OrderBy(r => r.Label).Select(ToJson) });
|
||||
}
|
||||
|
||||
/// <summary>Unregisters a reader from Stripe and soft-deletes the local record.</summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
|
||||
public async Task<IActionResult> DeactivateReader(int id)
|
||||
{
|
||||
var companyId = CompanyId;
|
||||
var reader = await _unitOfWork.TerminalReaders.GetByIdAsync(id);
|
||||
if (reader == null || reader.CompanyId != companyId)
|
||||
return Json(new { success = false, error = "Reader not found." });
|
||||
|
||||
var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
|
||||
if (company?.StripeAccountId != null)
|
||||
{
|
||||
// Best-effort delete on Stripe; proceed with local cleanup even if it has already been removed there.
|
||||
var (ok, error) = await _stripeConnect.DeleteReaderAsync(company.StripeAccountId, reader.StripeReaderId);
|
||||
if (!ok)
|
||||
_logger.LogWarning("Stripe reader delete failed for {ReaderId}: {Error}", reader.StripeReaderId, error);
|
||||
}
|
||||
|
||||
reader.Status = TerminalReaderStatus.Deactivated;
|
||||
await _unitOfWork.TerminalReaders.SoftDeleteAsync(reader);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
return Json(new { success = true });
|
||||
}
|
||||
|
||||
// ===== Taking a payment =====
|
||||
|
||||
/// <summary>
|
||||
/// Creates a card_present PaymentIntent for the invoice and pushes it to the selected reader.
|
||||
/// Stores the returned PaymentIntent id on the invoice so the webhook's idempotency guard works.
|
||||
/// Does NOT record the payment — the <c>payment_intent.succeeded</c> webhook is authoritative.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> ProcessPayment(int invoiceId, int readerId, decimal amount)
|
||||
{
|
||||
var companyId = CompanyId;
|
||||
|
||||
var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
|
||||
if (company == null || company.StripeConnectStatus != StripeConnectStatus.Active || string.IsNullOrEmpty(company.StripeAccountId))
|
||||
return Json(new { success = false, error = "Stripe is not connected for this company." });
|
||||
|
||||
var invoice = await _unitOfWork.Invoices.GetByIdAsync(invoiceId);
|
||||
if (invoice == null || invoice.CompanyId != companyId)
|
||||
return Json(new { success = false, error = "Invoice not found." });
|
||||
if (invoice.Status == InvoiceStatus.Voided)
|
||||
return Json(new { success = false, error = "This invoice has been voided." });
|
||||
if (invoice.BalanceDue <= 0)
|
||||
return Json(new { success = false, error = "This invoice is already paid in full." });
|
||||
if (amount <= 0 || amount > invoice.BalanceDue)
|
||||
return Json(new { success = false, error = "Enter an amount between $0 and the balance due." });
|
||||
|
||||
var reader = await _unitOfWork.TerminalReaders.GetByIdAsync(readerId);
|
||||
if (reader == null || reader.CompanyId != companyId || reader.Status != TerminalReaderStatus.Active)
|
||||
return Json(new { success = false, error = "Card reader not found." });
|
||||
|
||||
// In-person surcharge is OFF unless the shop has explicitly enabled it (compliance varies by state).
|
||||
var surcharge = company.TerminalSurchargeEnabled ? CalculateSurcharge(amount, company) : 0m;
|
||||
|
||||
var (ok, paymentIntentId, error) = await _stripeConnect.ProcessInvoicePaymentOnReaderAsync(
|
||||
company.StripeAccountId!, reader.StripeReaderId, amount, surcharge, "usd", invoice.InvoiceNumber, invoice.Id);
|
||||
if (!ok)
|
||||
return Json(new { success = false, error });
|
||||
|
||||
// Persist the PI id so HandlePaymentSucceededAsync's idempotency guard matches (mirrors PaymentController.CreateIntent).
|
||||
invoice.StripePaymentIntentId = paymentIntentId;
|
||||
if (invoice.OnlinePaymentStatus == OnlinePaymentStatus.NotApplicable)
|
||||
invoice.OnlinePaymentStatus = OnlinePaymentStatus.Pending;
|
||||
invoice.UpdatedAt = DateTime.UtcNow;
|
||||
await _unitOfWork.Invoices.UpdateAsync(invoice);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
return Json(new { success = true, paymentIntentId });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Polls the reader's action status for live UI feedback and reports whether the webhook has
|
||||
/// already recorded the payment (derived from the invoice's online payment status for this PI).
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> PaymentStatus(int readerId, string paymentIntentId)
|
||||
{
|
||||
var companyId = CompanyId;
|
||||
|
||||
var reader = await _unitOfWork.TerminalReaders.GetByIdAsync(readerId);
|
||||
if (reader == null || reader.CompanyId != companyId)
|
||||
return Json(new { success = false, error = "Card reader not found." });
|
||||
|
||||
var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
|
||||
if (company?.StripeAccountId == null)
|
||||
return Json(new { success = false, error = "Stripe is not connected." });
|
||||
|
||||
var status = await _stripeConnect.GetReaderStatusAsync(company.StripeAccountId, reader.StripeReaderId);
|
||||
|
||||
// The webhook is the source of truth — check whether it has landed for this PaymentIntent.
|
||||
var invoice = (await _unitOfWork.Invoices.FindAsync(
|
||||
i => i.CompanyId == companyId && i.StripePaymentIntentId == paymentIntentId)).FirstOrDefault();
|
||||
var webhookRecorded = invoice != null
|
||||
&& invoice.OnlinePaymentStatus is OnlinePaymentStatus.Paid or OnlinePaymentStatus.PartiallyPaid;
|
||||
|
||||
return Json(new
|
||||
{
|
||||
success = status.Success,
|
||||
actionStatus = status.ActionStatus,
|
||||
failureCode = status.FailureCode,
|
||||
failureMessage = status.FailureMessage,
|
||||
webhookRecorded,
|
||||
error = status.ErrorMessage
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Cancels an in-progress reader action (clerk cancelled or wants to retry).</summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> CancelPayment(int readerId)
|
||||
{
|
||||
var companyId = CompanyId;
|
||||
var reader = await _unitOfWork.TerminalReaders.GetByIdAsync(readerId);
|
||||
if (reader == null || reader.CompanyId != companyId)
|
||||
return Json(new { success = false, error = "Card reader not found." });
|
||||
|
||||
var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
|
||||
if (company?.StripeAccountId == null)
|
||||
return Json(new { success = false, error = "Stripe is not connected." });
|
||||
|
||||
var (ok, error) = await _stripeConnect.CancelReaderActionAsync(company.StripeAccountId, reader.StripeReaderId);
|
||||
return Json(new { success = ok, error });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// TEST MODE ONLY: simulates a card tap on a simulated reader so a payment can complete without
|
||||
/// hardware. Returns 404 in production so the endpoint cannot be probed there.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> SimulateTap(int readerId)
|
||||
{
|
||||
if (!IsTestMode)
|
||||
return NotFound();
|
||||
|
||||
var companyId = CompanyId;
|
||||
var reader = await _unitOfWork.TerminalReaders.GetByIdAsync(readerId);
|
||||
if (reader == null || reader.CompanyId != companyId)
|
||||
return Json(new { success = false, error = "Card reader not found." });
|
||||
|
||||
var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
|
||||
if (company?.StripeAccountId == null)
|
||||
return Json(new { success = false, error = "Stripe is not connected." });
|
||||
|
||||
var (ok, error) = await _stripeConnect.SimulatePresentPaymentMethodAsync(company.StripeAccountId, reader.StripeReaderId);
|
||||
return Json(new { success = ok, error });
|
||||
}
|
||||
|
||||
// ===== Helpers =====
|
||||
|
||||
/// <summary>
|
||||
/// Ensures the company has a Stripe Terminal Location, creating one from its address if needed and
|
||||
/// persisting the id. Returns the location id to attach readers to.
|
||||
/// </summary>
|
||||
private async Task<(bool Success, string? LocationId, string? Error)> EnsureLocationAsync(Company company)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(company.StripeTerminalLocationId))
|
||||
return (true, company.StripeTerminalLocationId, null);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(company.Address) || string.IsNullOrWhiteSpace(company.City)
|
||||
|| string.IsNullOrWhiteSpace(company.State) || string.IsNullOrWhiteSpace(company.ZipCode))
|
||||
{
|
||||
return (false, null, "Complete your company address (street, city, state, ZIP) before registering a reader.");
|
||||
}
|
||||
|
||||
var address = new TerminalAddressDto
|
||||
{
|
||||
Line1 = company.Address!,
|
||||
City = company.City!,
|
||||
State = company.State!,
|
||||
PostalCode = company.ZipCode!,
|
||||
Country = "US"
|
||||
};
|
||||
|
||||
var (ok, locationId, error) = await _stripeConnect.CreateTerminalLocationAsync(
|
||||
company.StripeAccountId!, company.CompanyName, address);
|
||||
if (!ok)
|
||||
return (false, null, error);
|
||||
|
||||
company.StripeTerminalLocationId = locationId;
|
||||
company.UpdatedAt = DateTime.UtcNow;
|
||||
await _unitOfWork.Companies.UpdateAsync(company);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
return (true, locationId, null);
|
||||
}
|
||||
|
||||
/// <summary>Mirrors the online surcharge calculation (percent/flat) used by PaymentController.</summary>
|
||||
private static decimal CalculateSurcharge(decimal amount, Company company)
|
||||
{
|
||||
return company.OnlinePaymentSurchargeType switch
|
||||
{
|
||||
OnlinePaymentSurchargeType.Percent => Math.Round(amount * (company.OnlinePaymentSurchargeValue / 100m), 2),
|
||||
OnlinePaymentSurchargeType.Flat => company.OnlinePaymentSurchargeValue,
|
||||
_ => 0m
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Projects a reader to the anonymous shape returned to the settings tab JS.</summary>
|
||||
private static object ToJson(TerminalReader r) => new
|
||||
{
|
||||
id = r.Id,
|
||||
label = r.Label,
|
||||
deviceType = r.DeviceType,
|
||||
serialNumber = r.SerialNumber,
|
||||
networkStatus = r.LastKnownNetworkStatus,
|
||||
lastSeenAt = r.LastSeenAt
|
||||
};
|
||||
}
|
||||
@@ -426,14 +426,6 @@ public static class HelpKnowledgeBase
|
||||
- Payment is recorded automatically on the invoice and the invoice status updates to Paid or Partially Paid
|
||||
- The company receives a bell notification: "Online Payment Received"
|
||||
|
||||
**In-person card payments (Stripe Terminal — WisePOS E):**
|
||||
Take a card payment in person against an invoice using a Stripe Terminal **WisePOS E** card reader. This is included with the same plan entitlement as online payments and runs on the same connected Stripe account.
|
||||
- **Setup (one-time):** Go to **Settings → Card Readers** (the tab appears once Stripe is connected). On the reader, open **Settings → Generate registration code** to get a three-word code, enter it with a label (e.g. "Front Counter") and click **Add Reader**.
|
||||
- **Taking a payment:** On an invoice with a balance due, click **Take Card Payment**, pick the reader, confirm the amount, and click **Send to Reader**. The reader prompts the customer to tap, insert, or swipe. The screen shows live progress and refreshes the invoice when approved.
|
||||
- The payment is recorded automatically (method: **Card Reader**) by the same Stripe webhook used for online payments — it posts to your books and advances the invoice to Paid/Partially Paid. Partial payments are supported.
|
||||
- **In-person surcharge** is OFF by default. It can be enabled on the Card Readers tab, but in-person surcharging is regulated differently than online and is prohibited in some states — only enable it after confirming local rules.
|
||||
- **Declines/cancellations** show an error on the reader and in the app; nothing is charged. Refunds use the normal Issue Refund flow.
|
||||
|
||||
**Voiding an invoice:** Invoice Details → "Void" — marks it voided. Cannot void a paid invoice.
|
||||
|
||||
**Refunds:** Issue a refund from Invoice Details → "Issue Refund."
|
||||
@@ -489,6 +481,8 @@ public static class HelpKnowledgeBase
|
||||
5. Enter opening quantity on hand — the system automatically records an Initial transaction for audit purposes
|
||||
6. Save
|
||||
|
||||
**Manufacturer catalog integration:** The platform is integrated with the Columbia Coatings product catalog. When you add a Columbia powder it auto-fills the color, specs, cure schedule, and SDS/TDS links, and keeps the price current — catalog data refreshes regularly (near real-time). Quotes use the latest catalog price even when your stored cost is older, and a powder's detail page shows the current catalog price (and flags when it has changed since you last bought it). Discontinued powders are flagged "cannot reorder" but stay usable for stock you already have.
|
||||
|
||||
**Stock status:** Three states are shown on every item:
|
||||
- **In Stock** (green) — quantity is above the reorder point
|
||||
- **Low Stock** (red) — quantity is greater than zero but at or below the reorder point; time to reorder
|
||||
|
||||
@@ -11,6 +11,7 @@ using PowderCoating.Core.Interfaces.Services;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Infrastructure.Repositories;
|
||||
using PowderCoating.Infrastructure.Services;
|
||||
using PowderCoating.Infrastructure.Services.Columbia;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Application.Services;
|
||||
using PowderCoating.Application.Configuration;
|
||||
@@ -222,6 +223,9 @@ builder.Services.AddScoped<IInventoryAiLookupService, InventoryAiLookupService>(
|
||||
builder.Services.AddScoped<ICustomFormulaAiService, CustomFormulaAiService>();
|
||||
builder.Services.AddScoped<IFormulaLibraryService, FormulaLibraryService>();
|
||||
builder.Services.AddHttpClient();
|
||||
builder.Services.AddScoped<IColumbiaCoatingsApiClient, ColumbiaCoatingsApiClient>();
|
||||
builder.Services.AddScoped<IPowderCatalogUpsertService, PowderCatalogUpsertService>();
|
||||
builder.Services.AddScoped<IColumbiaCatalogSyncService, ColumbiaCatalogSyncService>();
|
||||
builder.Services.AddScoped<ICompanyLogoService, CompanyLogoService>();
|
||||
builder.Services.AddScoped<IEquipmentManualService, EquipmentManualService>();
|
||||
builder.Services.AddScoped<IPricingCalculationService, PricingCalculationService>();
|
||||
@@ -255,6 +259,7 @@ builder.Services.AddHostedService<StripeWebhookRetentionBackgroundService>();
|
||||
builder.Services.AddHostedService<SetupWizardReminderBackgroundService>();
|
||||
builder.Services.AddHostedService<RecurringTransactionService>();
|
||||
builder.Services.AddHostedService<AppointmentReminderBackgroundService>();
|
||||
builder.Services.AddHostedService<ColumbiaCatalogSyncBackgroundService>();
|
||||
builder.Services.AddScoped<ISubscriptionService, SubscriptionService>();
|
||||
builder.Services.AddScoped<IStripeService, StripeService>();
|
||||
builder.Services.AddScoped<IStripeConnectService, StripeConnectService>();
|
||||
|
||||
@@ -113,7 +113,7 @@
|
||||
const preview = document.getElementById('announcementPreview');
|
||||
preview.className = 'alert mb-0 ' + (typeMap[type] || 'alert-info');
|
||||
document.getElementById('previewTitle').textContent = document.getElementById('Title').value || 'Title';
|
||||
document.getElementById('previewMessage').textContent = ' — ' + (document.getElementById('Message').value || 'Message');
|
||||
document.getElementById('previewMessage').textContent = '\u2014' + (document.getElementById('Message').value || 'Message');
|
||||
}
|
||||
document.getElementById('Type')?.addEventListener('change', updatePreview);
|
||||
document.getElementById('Title')?.addEventListener('input', updatePreview);
|
||||
|
||||
@@ -598,7 +598,7 @@
|
||||
const modal = bootstrap.Modal.getInstance(document.getElementById('scanReceiptModal'));
|
||||
if (modal) modal.hide();
|
||||
|
||||
statusEl.textContent = 'Scan complete — review and adjust as needed.';
|
||||
statusEl.innerHTML = 'Scan complete — review and adjust as needed.';
|
||||
} catch (e) {
|
||||
statusEl.textContent = 'Error connecting to AI service.';
|
||||
} finally {
|
||||
|
||||
@@ -127,6 +127,7 @@
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header fw-semibold">Line Items</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
@@ -169,6 +170,7 @@
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -210,6 +212,7 @@
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
@@ -253,6 +256,7 @@
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -92,94 +92,181 @@
|
||||
{
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width:90px">Type</th>
|
||||
<th>Number</th>
|
||||
<th>Vendor</th>
|
||||
<th>Memo / Account</th>
|
||||
<th>Date</th>
|
||||
<th>Due Date</th>
|
||||
<th>Status</th>
|
||||
<th class="text-end">Amount</th>
|
||||
<th class="text-end">Balance Due</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width:90px">Type</th>
|
||||
<th>Number</th>
|
||||
<th>Vendor</th>
|
||||
<th>Memo / Account</th>
|
||||
<th>Date</th>
|
||||
<th>Due Date</th>
|
||||
<th>Status</th>
|
||||
<th class="text-end">Amount</th>
|
||||
<th class="text-end">Balance Due</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var entry in Model)
|
||||
{
|
||||
<tr class="@(entry.IsOverdue ? "table-warning" : "")">
|
||||
<td>
|
||||
@if (entry.EntryType == "Bill")
|
||||
{
|
||||
<span class="badge bg-primary-subtle text-primary border border-primary-subtle">
|
||||
<i class="bi bi-file-text me-1"></i>Bill
|
||||
</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-secondary-subtle text-secondary border border-secondary-subtle">
|
||||
<i class="bi bi-receipt me-1"></i>Expense
|
||||
</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (entry.EntryType == "Bill")
|
||||
{
|
||||
<a asp-controller="Bills" asp-action="Details" asp-route-id="@entry.Id"
|
||||
class="fw-medium text-decoration-none">@entry.Number</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<a asp-controller="Expenses" asp-action="Details" asp-route-id="@entry.Id"
|
||||
class="fw-medium text-decoration-none">@entry.Number</a>
|
||||
}
|
||||
</td>
|
||||
<td>@entry.VendorName</td>
|
||||
<td class="text-muted small">
|
||||
@(entry.EntryType == "Bill" ? entry.Memo : entry.AccountName)
|
||||
@if (entry.HasReceipt)
|
||||
{
|
||||
<i class="bi bi-paperclip ms-1" title="Has receipt"></i>
|
||||
}
|
||||
</td>
|
||||
<td>@entry.Date.ToString("MMM d, yyyy")</td>
|
||||
<td>
|
||||
@if (entry.DueDate.HasValue)
|
||||
{
|
||||
<span class="@(entry.IsOverdue ? "text-danger fw-medium" : "")">
|
||||
@entry.DueDate.Value.ToString("MMM d, yyyy")
|
||||
@if (entry.IsOverdue) { <i class="bi bi-exclamation-circle ms-1"></i> }
|
||||
</span>
|
||||
}
|
||||
else if (entry.EntryType == "Expense")
|
||||
{
|
||||
<span class="text-muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td><span class="badge bg-@entry.StatusColor">@entry.StatusLabel</span></td>
|
||||
<td class="text-end">@entry.Total.ToString("C")</td>
|
||||
<td class="text-end fw-medium @(entry.BalanceDue > 0 ? "text-danger" : "text-muted")">
|
||||
@Html.Raw(entry.EntryType == "Bill" ? entry.BalanceDue.ToString("C") : "—")
|
||||
</td>
|
||||
<td>
|
||||
@if (entry.EntryType == "Bill")
|
||||
{
|
||||
<a asp-controller="Bills" asp-action="Details" asp-route-id="@entry.Id"
|
||||
class="btn btn-sm btn-outline-primary"><i class="bi bi-eye"></i></a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<a asp-controller="Expenses" asp-action="Details" asp-route-id="@entry.Id"
|
||||
class="btn btn-sm btn-outline-secondary"><i class="bi bi-eye"></i></a>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="mobile-card-view">
|
||||
<div class="mobile-card-list">
|
||||
@foreach (var entry in Model)
|
||||
{
|
||||
<tr class="@(entry.IsOverdue ? "table-warning" : "")">
|
||||
<td>
|
||||
@if (entry.EntryType == "Bill")
|
||||
{
|
||||
<span class="badge bg-primary-subtle text-primary border border-primary-subtle">
|
||||
<i class="bi bi-file-text me-1"></i>Bill
|
||||
</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-secondary-subtle text-secondary border border-secondary-subtle">
|
||||
<i class="bi bi-receipt me-1"></i>Expense
|
||||
</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (entry.EntryType == "Bill")
|
||||
{
|
||||
<a asp-controller="Bills" asp-action="Details" asp-route-id="@entry.Id"
|
||||
class="fw-medium text-decoration-none">@entry.Number</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<a asp-controller="Expenses" asp-action="Details" asp-route-id="@entry.Id"
|
||||
class="fw-medium text-decoration-none">@entry.Number</a>
|
||||
}
|
||||
</td>
|
||||
<td>@entry.VendorName</td>
|
||||
<td class="text-muted small">
|
||||
@(entry.EntryType == "Bill" ? entry.Memo : entry.AccountName)
|
||||
@if (entry.HasReceipt)
|
||||
{
|
||||
<i class="bi bi-paperclip ms-1" title="Has receipt"></i>
|
||||
}
|
||||
</td>
|
||||
<td>@entry.Date.ToString("MMM d, yyyy")</td>
|
||||
<td>
|
||||
var isBill = entry.EntryType == "Bill";
|
||||
var detailUrl = isBill
|
||||
? Url.Action("Details", "Bills", new { id = entry.Id })
|
||||
: Url.Action("Details", "Expenses", new { id = entry.Id });
|
||||
<div class="mobile-data-card" onclick="window.location='@detailUrl'"
|
||||
style="@(entry.IsOverdue ? "border-left: 3px solid #f59e0b;" : "")">
|
||||
<div class="mobile-card-header">
|
||||
<div class="mobile-card-icon" style="background: linear-gradient(135deg, @(isBill ? "#3b82f6 0%, #2563eb" : "#6b7280 0%, #4b5563") 100%);">
|
||||
<i class="bi @(isBill ? "bi-file-text" : "bi-receipt")"></i>
|
||||
</div>
|
||||
<div class="mobile-card-title">
|
||||
<h6>@entry.Number</h6>
|
||||
<small>@entry.VendorName</small>
|
||||
</div>
|
||||
<div class="ms-auto">
|
||||
@if (isBill)
|
||||
{
|
||||
<span class="badge bg-primary-subtle text-primary border border-primary-subtle">Bill</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-secondary-subtle text-secondary border border-secondary-subtle">Expense</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-body">
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Status</span>
|
||||
<span class="mobile-card-value"><span class="badge bg-@entry.StatusColor">@entry.StatusLabel</span></span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Date</span>
|
||||
<span class="mobile-card-value">@entry.Date.ToString("MMM d, yyyy")</span>
|
||||
</div>
|
||||
@if (entry.DueDate.HasValue)
|
||||
{
|
||||
<span class="@(entry.IsOverdue ? "text-danger fw-medium" : "")">
|
||||
@entry.DueDate.Value.ToString("MMM d, yyyy")
|
||||
@if (entry.IsOverdue) { <i class="bi bi-exclamation-circle ms-1"></i> }
|
||||
</span>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Due</span>
|
||||
<span class="mobile-card-value @(entry.IsOverdue ? "text-danger fw-medium" : "")">
|
||||
@entry.DueDate.Value.ToString("MMM d, yyyy")
|
||||
@if (entry.IsOverdue) { <i class="bi bi-exclamation-circle ms-1"></i> }
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
else if (entry.EntryType == "Expense")
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Amount</span>
|
||||
<span class="mobile-card-value fw-semibold">@entry.Total.ToString("C")</span>
|
||||
</div>
|
||||
@if (isBill)
|
||||
{
|
||||
<span class="text-muted">—</span>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Balance Due</span>
|
||||
<span class="mobile-card-value @(entry.BalanceDue > 0 ? "fw-semibold text-danger" : "text-muted")">
|
||||
@entry.BalanceDue.ToString("C")
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
<td><span class="badge bg-@entry.StatusColor">@entry.StatusLabel</span></td>
|
||||
<td class="text-end">@entry.Total.ToString("C")</td>
|
||||
<td class="text-end fw-medium @(entry.BalanceDue > 0 ? "text-danger" : "text-muted")">
|
||||
@Html.Raw(entry.EntryType == "Bill" ? entry.BalanceDue.ToString("C") : "—")
|
||||
</td>
|
||||
<td>
|
||||
@if (entry.EntryType == "Bill")
|
||||
@{
|
||||
var memoText = isBill ? entry.Memo : entry.AccountName;
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(memoText))
|
||||
{
|
||||
<a asp-controller="Bills" asp-action="Details" asp-route-id="@entry.Id"
|
||||
class="btn btn-sm btn-outline-primary"><i class="bi bi-eye"></i></a>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">@(isBill ? "Memo" : "Account")</span>
|
||||
<span class="mobile-card-value text-muted small">
|
||||
@memoText
|
||||
@if (entry.HasReceipt) { <i class="bi bi-paperclip ms-1" title="Has receipt"></i> }
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<a asp-controller="Expenses" asp-action="Details" asp-route-id="@entry.Id"
|
||||
class="btn btn-sm btn-outline-secondary"><i class="bi bi-eye"></i></a>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
</div>
|
||||
<div class="mobile-card-footer">
|
||||
<a href="@detailUrl" class="btn btn-sm @(isBill ? "btn-outline-primary" : "btn-outline-secondary")"
|
||||
onclick="event.stopPropagation()">
|
||||
<i class="bi bi-eye me-1"></i>View
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
@if (Model.AllowOnlinePayments)
|
||||
{
|
||||
<option value="online-payments">Online Payments</option>
|
||||
<option value="card-readers">Card Readers</option>
|
||||
}
|
||||
<option value="kiosk">Kiosk</option>
|
||||
<option value="timeclock">Timeclock</option>
|
||||
@@ -114,11 +113,6 @@
|
||||
<i class="bi bi-credit-card"></i> Online Payments
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="card-readers-tab" data-bs-toggle="tab" data-bs-target="#card-readers" type="button" role="tab">
|
||||
<i class="bi bi-credit-card-2-front"></i> Card Readers
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="kiosk-tab" data-bs-toggle="tab" data-bs-target="#kiosk" type="button" role="tab">
|
||||
@@ -2024,102 +2018,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card Readers (Stripe Terminal) Tab -->
|
||||
<div class="tab-pane fade" id="card-readers" role="tabpanel">
|
||||
<div class="card mt-3">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi bi-credit-card-2-front me-2"></i>Card Readers (In-Person Payments)</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@if (Model.StripeConnectStatus != PowderCoating.Core.Enums.StripeConnectStatus.Active)
|
||||
{
|
||||
<div class="alert alert-warning alert-permanent d-flex align-items-center gap-3 mb-0">
|
||||
<i class="bi bi-exclamation-triangle-fill fs-4"></i>
|
||||
<div>
|
||||
<strong>Connect Stripe first.</strong>
|
||||
<p class="mb-0 small">Card readers run on your connected Stripe account. Connect it on the
|
||||
<strong>Online Payments</strong> tab, then come back to register a WisePOS E reader.</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="text-muted small">
|
||||
Take in-person card payments on a Stripe Terminal <strong>WisePOS E</strong> reader, billed
|
||||
straight to your connected Stripe account. Register a reader below, then use
|
||||
<strong>Take Card Payment</strong> on any invoice.
|
||||
</p>
|
||||
|
||||
@* ── Register a reader ── *@
|
||||
<h6 class="fw-semibold mb-2">Register a reader</h6>
|
||||
<div class="row g-2 align-items-end mb-2">
|
||||
<div class="col-sm-4">
|
||||
<label class="form-label small mb-1">Registration code</label>
|
||||
<input type="text" id="readerRegCode" class="form-control" placeholder="e.g. quick-brown-fox" />
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<label class="form-label small mb-1">Label</label>
|
||||
<input type="text" id="readerLabel" class="form-control" placeholder="e.g. Front Counter" />
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<button type="button" class="btn btn-primary" id="registerReaderBtn">
|
||||
<i class="bi bi-plus-circle me-1"></i>Add Reader
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-muted small">
|
||||
On the reader, open <strong>Settings → Generate registration code</strong> to get the three-word code.
|
||||
@if ((bool)(ViewBag.TerminalTestMode ?? false))
|
||||
{
|
||||
<span>In test mode, use code <code>simulated-wpe</code> to register a simulated reader.</span>
|
||||
}
|
||||
</p>
|
||||
<div id="readerActionResult"></div>
|
||||
|
||||
@* ── Registered readers ── *@
|
||||
<h6 class="fw-semibold mt-4 mb-2">Your readers</h6>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Label</th>
|
||||
<th>Device</th>
|
||||
<th>Serial</th>
|
||||
<th>Status</th>
|
||||
<th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="readersTableBody">
|
||||
<tr><td colspan="5" class="text-muted small">Loading…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@* ── In-person surcharge (default off, compliance) ── *@
|
||||
<fieldset class="mt-4">
|
||||
<h6 class="fw-semibold mb-2">In-Person Surcharge</h6>
|
||||
<div class="alert alert-warning alert-permanent small">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||
In-person card surcharging is regulated <strong>differently</strong> than online and is
|
||||
<strong>prohibited in some states</strong>. Leave this off unless you have confirmed it is allowed
|
||||
where you operate. When enabled, the same fee configured on the Online Payments tab is applied.
|
||||
</div>
|
||||
<div class="form-check form-switch mb-3">
|
||||
<input class="form-check-input" type="checkbox" id="terminalSurchargeEnabled"
|
||||
@((bool)(ViewBag.TerminalSurchargeEnabled ?? false) ? "checked" : "") />
|
||||
<label class="form-check-label" for="terminalSurchargeEnabled">
|
||||
Apply my online surcharge to in-person card payments
|
||||
</label>
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary" id="saveTerminalSettingsBtn">
|
||||
<i class="bi bi-floppy me-1"></i>Save Reader Settings
|
||||
</button>
|
||||
</fieldset>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Kiosk Tab -->
|
||||
@@ -3484,7 +3382,7 @@
|
||||
document.getElementById('ovenCalcToggle').addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
const hidden = _calcPanel.classList.toggle('d-none');
|
||||
if (!hidden) { _calcW.value = ''; _calcD.value = ''; _calcH.value = ''; _calcResult.textContent = '—'; _calcApply.disabled = true; _calcW.focus(); }
|
||||
if (!hidden) { _calcW.value = ''; _calcD.value = ''; _calcH.value = ''; _calcResult.innerHTML = '—'; _calcApply.disabled = true; _calcW.focus(); }
|
||||
});
|
||||
|
||||
function _updateCalc() {
|
||||
@@ -3504,7 +3402,7 @@
|
||||
_calcApply.disabled = false;
|
||||
_calcApply.dataset.val = val;
|
||||
} else {
|
||||
_calcResult.textContent = '—';
|
||||
_calcResult.innerHTML = '—';
|
||||
_calcApply.disabled = true;
|
||||
}
|
||||
}
|
||||
@@ -3522,7 +3420,7 @@
|
||||
document.getElementById('ovenModal').addEventListener('hidden.bs.modal', function () {
|
||||
_calcPanel.classList.add('d-none');
|
||||
_calcW.value = ''; _calcD.value = ''; _calcH.value = '';
|
||||
_calcResult.textContent = '—'; _calcApply.disabled = true;
|
||||
_calcResult.innerHTML = '—'; _calcApply.disabled = true;
|
||||
});
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -3945,10 +3843,6 @@
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
@if (Model.AllowOnlinePayments && Model.StripeConnectStatus == PowderCoating.Core.Enums.StripeConnectStatus.Active)
|
||||
{
|
||||
<script src="~/js/terminal-readers.js" asp-append-version="true"></script>
|
||||
}
|
||||
<script src="~/js/company-settings-custom-formulas.js" asp-append-version="true"></script>
|
||||
<script>
|
||||
// Load formula templates when the tab is first shown; auto-show walkthrough if no templates yet
|
||||
|
||||
@@ -1041,6 +1041,37 @@
|
||||
|
||||
// Custom powder (no inventory item) â†' open modal to add to inventory
|
||||
if (!hasInv) {
|
||||
// If the powder is already in the master catalog, receive it straight to inventory
|
||||
// with all its specs/docs — no modal. Only fall back to the modal when it isn't.
|
||||
const tokenAuto = document.querySelector('input[name="__RequestVerificationToken"]')?.value
|
||||
?? document.querySelector('meta[name="__RequestVerificationToken"]')?.content;
|
||||
this.disabled = true; qtyInput.disabled = true;
|
||||
this.innerHTML = '<span class="spinner-border spinner-border-sm"></span>';
|
||||
try {
|
||||
const autoResp = await fetch('@Url.Action("ReceivePowderFromCatalog", "Dashboard")', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'RequestVerificationToken': tokenAuto },
|
||||
body: `coatId=${coatId}&lbsReceived=${lbs}`
|
||||
});
|
||||
const autoData = await autoResp.json();
|
||||
if (autoData.success) {
|
||||
fadePlacedRow(row);
|
||||
showInventoryToast('Added "' + (autoData.itemName || 'powder') + '" to inventory from the catalog.');
|
||||
return;
|
||||
}
|
||||
if (!autoData.needsDetails) {
|
||||
alert(autoData.message || 'Could not record receipt. Please try again.');
|
||||
this.disabled = false; qtyInput.disabled = false;
|
||||
this.innerHTML = '<i class="bi bi-box-arrow-in-down"></i> Got It';
|
||||
return;
|
||||
}
|
||||
// Not in catalog — fall through to the manual entry modal.
|
||||
} catch {
|
||||
// Network error — fall back to the manual entry modal.
|
||||
}
|
||||
this.disabled = false; qtyInput.disabled = false;
|
||||
this.innerHTML = '<i class="bi bi-box-arrow-in-down"></i> Got It';
|
||||
|
||||
const modal = document.getElementById('addPowderModal');
|
||||
// Pre-fill hidden + text fields
|
||||
modal.querySelector('#apm-coatId').value = coatId;
|
||||
|
||||
@@ -138,6 +138,18 @@
|
||||
<li class="mb-1">If a vendor name is selected in the Vendor field before searching, results are scoped to that vendor first, then broadened automatically if nothing matches.</li>
|
||||
</ul>
|
||||
|
||||
<div class="alert alert-permanent alert-success d-flex gap-2 mb-3" role="alert">
|
||||
<i class="bi bi-cloud-check me-1 flex-shrink-0 mt-1"></i>
|
||||
<div>
|
||||
<strong>Columbia Coatings integration:</strong> the catalog is connected directly to the Columbia Coatings
|
||||
product catalog and refreshes regularly (near real-time). Columbia powders auto-fill their full specs, cure
|
||||
schedule, and SDS/TDS links, and their prices stay current — quotes use the latest catalog price even
|
||||
when your stored cost is older. An item’s detail page shows the current catalog price and flags when it
|
||||
has changed since you last bought it. Discontinued powders are flagged “cannot reorder” but stay
|
||||
usable for stock you already have.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="h6 fw-semibold mt-4 mb-2"><i class="bi bi-camera me-1"></i>Label Scanner (Camera)</h3>
|
||||
<p>
|
||||
Click the <strong>camera icon</strong> next to the Lookup button to open the label scanner.
|
||||
|
||||
@@ -360,33 +360,6 @@
|
||||
The link is unique to each invoice and does not expire as long as the invoice remains unpaid.
|
||||
Voided invoices do not generate payment links.
|
||||
</p>
|
||||
|
||||
<h3 id="card-readers" class="h6 fw-semibold mt-4 mb-2">In-Person Card Payments (WisePOS E)</h3>
|
||||
<p>
|
||||
Take a card payment in person against an invoice using a Stripe Terminal
|
||||
<strong>WisePOS E</strong> card reader. This is included with the same plan that allows
|
||||
online payments and runs on the same connected Stripe account — no separate merchant setup.
|
||||
</p>
|
||||
<ul class="mb-3">
|
||||
<li class="mb-2"><strong>One-time setup:</strong> go to <strong>Settings › Card Readers</strong>
|
||||
(the tab appears once Stripe is connected). On the reader, open
|
||||
<strong>Settings › Generate registration code</strong> to get a three-word code, enter it
|
||||
with a label such as “Front Counter,” and click <strong>Add Reader</strong>.</li>
|
||||
<li class="mb-2"><strong>Taking a payment:</strong> on an invoice with a balance due, click
|
||||
<strong>Take Card Payment</strong>, choose the reader, confirm the amount, and click
|
||||
<strong>Send to Reader</strong>. The reader prompts the customer to tap, insert, or swipe; the
|
||||
screen shows live progress and refreshes the invoice once the payment is approved.</li>
|
||||
<li class="mb-2">The payment is recorded automatically with the method <strong>Card Reader</strong>,
|
||||
posts to your books, and advances the invoice to Paid or Partially Paid. Partial payments are supported.</li>
|
||||
</ul>
|
||||
<div class="alert alert-permanent alert-warning d-flex gap-2 mb-0" role="alert">
|
||||
<i class="bi bi-exclamation-triangle flex-shrink-0 mt-1"></i>
|
||||
<div>
|
||||
<strong>In-person surcharging is off by default.</strong> It can be enabled on the Card Readers tab,
|
||||
but in-person card surcharging is regulated differently than online payments and is prohibited in some
|
||||
states — only turn it on after confirming the rules where you operate.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="payment-reminders" class="mb-5">
|
||||
@@ -442,7 +415,6 @@
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#deposits">Deposits</a>
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#gift-certificates">Gift Certificates</a>
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#online-payments">Online Payments</a>
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#card-readers">In-Person Card Payments</a>
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#payment-reminders">Payment Reminders</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -69,6 +69,12 @@
|
||||
{
|
||||
<span class="badge bg-danger"><i class="bi bi-x-circle me-1"></i>Inactive</span>
|
||||
}
|
||||
@if ((bool?)ViewBag.CatalogDiscontinued == true)
|
||||
{
|
||||
<span class="badge bg-warning text-dark" title="Discontinued by the manufacturer — cannot reorder">
|
||||
<i class="bi bi-slash-circle me-1"></i>Discontinued by manufacturer
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
@@ -103,6 +109,20 @@
|
||||
<div><strong>Status:</strong> This item is inactive</div>
|
||||
</div>
|
||||
}
|
||||
@if ((bool?)ViewBag.CatalogDiscontinued == true)
|
||||
{
|
||||
<div class="alert alert-warning alert-permanent d-flex align-items-center mb-3">
|
||||
<i class="bi bi-slash-circle me-2"></i>
|
||||
<div>
|
||||
<strong>Discontinued by @(ViewBag.CatalogVendorName ?? "manufacturer"):</strong>
|
||||
this powder has been discontinued and cannot be reordered. Existing stock can still be used and quoted.
|
||||
@if (!string.IsNullOrEmpty(ViewBag.CatalogProductUrl as string))
|
||||
{
|
||||
<a href="@ViewBag.CatalogProductUrl" target="_blank" rel="noopener" class="alert-link ms-1">View product page</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="row g-4">
|
||||
<!-- Left Column -->
|
||||
@@ -411,6 +431,41 @@
|
||||
<label class="text-muted small mb-1">Total Stock Value</label>
|
||||
<p class="fw-semibold text-primary mb-0 fs-5">@((Model.QuantityOnHand * Model.UnitCost).ToString("C"))</p>
|
||||
</div>
|
||||
@if (Model.CatalogReferencePrice.HasValue && Model.CatalogReferencePrice.Value > 0)
|
||||
{
|
||||
<div class="col-12"><hr class="my-2" /></div>
|
||||
<div class="col-12">
|
||||
<label class="text-muted small mb-1">
|
||||
Current Catalog Price
|
||||
<i class="bi bi-info-circle ms-1" role="button" tabindex="0"
|
||||
data-bs-toggle="popover" data-bs-trigger="hover focus" data-bs-placement="top"
|
||||
data-bs-content="The current list price from the linked manufacturer catalog, refreshed by sync. New quotes use this price. It does not change your Unit Cost or stock value."></i>
|
||||
</label>
|
||||
<p class="fw-semibold text-success mb-0 fs-5">
|
||||
@Model.CatalogReferencePrice.Value.ToString("C")
|
||||
<span class="text-muted fs-6 fw-normal">/ @Model.UnitOfMeasure</span>
|
||||
</p>
|
||||
@{
|
||||
var catRef = Model.CatalogReferencePrice.Value;
|
||||
var paidPrice = (Model.LastPurchaseDate.HasValue && Model.LastPurchasePrice > 0)
|
||||
? Model.LastPurchasePrice : Model.UnitCost;
|
||||
}
|
||||
@if (paidPrice > 0 && Math.Abs(catRef - paidPrice) >= 0.01m)
|
||||
{
|
||||
var priceUp = catRef > paidPrice;
|
||||
<div class="mt-1">
|
||||
<span class="badge @(priceUp ? "bg-warning text-dark" : "bg-info text-dark")">
|
||||
<i class="bi @(priceUp ? "bi-arrow-up-right" : "bi-arrow-down-right") me-1"></i>
|
||||
Price @(priceUp ? "up" : "down") from @paidPrice.ToString("C") last paid
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
@if (Model.CatalogPriceUpdatedAt.HasValue)
|
||||
{
|
||||
<div class="text-muted small mt-1">Updated @Model.CatalogPriceUpdatedAt.Value.ToLocalTime().ToString("MMM d, yyyy")</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (Model.LastPurchaseDate.HasValue)
|
||||
{
|
||||
<div class="col-6">
|
||||
|
||||
@@ -644,12 +644,6 @@
|
||||
<button type="button" class="btn btn-success" data-bs-toggle="modal" data-bs-target="#recordPaymentModal">
|
||||
<i class="bi bi-cash me-2"></i>Record Payment
|
||||
</button>
|
||||
@if ((bool)(ViewBag.TerminalPaymentsEnabled ?? false))
|
||||
{
|
||||
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#cardReaderModal">
|
||||
<i class="bi bi-credit-card-2-front me-2"></i>Take Card Payment
|
||||
</button>
|
||||
}
|
||||
}
|
||||
<a asp-action="DownloadPdf" asp-route-id="@Model.Id" asp-route-inline="true"
|
||||
class="btn btn-outline-secondary" target="_blank" rel="noopener">
|
||||
@@ -1035,6 +1029,12 @@
|
||||
<label class="form-label">Notes</label>
|
||||
<textarea name="Notes" class="form-control" rows="2" placeholder="Optional"></textarea>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="SuppressNotification" value="true" id="suppressNotificationCheck" />
|
||||
<label class="form-check-label text-muted small" for="suppressNotificationCheck">
|
||||
Don’t notify customer
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
@@ -1048,67 +1048,6 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (canPay && (bool)(ViewBag.TerminalPaymentsEnabled ?? false))
|
||||
{
|
||||
var terminalReaders = ViewBag.TerminalReaders as IEnumerable<SelectListItem> ?? Enumerable.Empty<SelectListItem>();
|
||||
<!-- Take Card Payment (Stripe Terminal) Modal -->
|
||||
<div class="modal fade" id="cardReaderModal" tabindex="-1"
|
||||
data-invoice-id="@Model.Id"
|
||||
data-balance-due="@Model.BalanceDue.ToString("F2")"
|
||||
data-test-mode="@((bool)(ViewBag.TerminalTestMode ?? false) ? "true" : "false")">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="bi bi-credit-card-2-front me-2"></i>Take Card Payment</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@* Setup view: choose reader + amount, then send to the reader *@
|
||||
<div id="cardReaderSetup">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold" for="cardReaderSelect">Card Reader</label>
|
||||
<select id="cardReaderSelect" class="form-select">
|
||||
@foreach (var r in terminalReaders)
|
||||
{
|
||||
<option value="@r.Value">@r.Text</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold" for="cardReaderAmount">Amount <span class="text-danger">*</span></label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">$</span>
|
||||
<input type="number" id="cardReaderAmount" class="form-control" step="0.01" min="0.01"
|
||||
max="@Model.BalanceDue" value="@Model.BalanceDue.ToString("F2")" />
|
||||
</div>
|
||||
<div class="form-text">Balance due: @Model.BalanceDue.ToString("C")</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Status view: live progress while the customer presents their card *@
|
||||
<div id="cardReaderStatus" class="text-center py-4 d-none">
|
||||
<div id="cardReaderSpinner" class="spinner-border text-primary mb-3" role="status"></div>
|
||||
<div id="cardReaderStatusText" class="fw-semibold"></div>
|
||||
<div id="cardReaderStatusSub" class="text-muted small mt-1"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" id="cardReaderCancelBtn" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
@if ((bool)(ViewBag.TerminalTestMode ?? false))
|
||||
{
|
||||
<button type="button" id="cardReaderSimulateBtn" class="btn btn-outline-info d-none" title="Test mode only">
|
||||
<i class="bi bi-magic me-1"></i>Simulate Tap
|
||||
</button>
|
||||
}
|
||||
<button type="button" id="cardReaderProcessBtn" class="btn btn-primary">
|
||||
<i class="bi bi-send me-2"></i>Send to Reader
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Edit Payment Modal -->
|
||||
@if (!isVoided)
|
||||
{
|
||||
@@ -1598,18 +1537,6 @@
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@if (canPay && (bool)(ViewBag.TerminalPaymentsEnabled ?? false))
|
||||
{
|
||||
<script>
|
||||
window.terminalPayment = {
|
||||
processUrl: '@Url.Action("ProcessPayment", "Terminal")',
|
||||
statusUrl: '@Url.Action("PaymentStatus", "Terminal")',
|
||||
cancelUrl: '@Url.Action("CancelPayment", "Terminal")',
|
||||
simulateUrl: '@Url.Action("SimulateTap", "Terminal")'
|
||||
};
|
||||
</script>
|
||||
<script src="~/js/terminal-payment.js" asp-append-version="true"></script>
|
||||
}
|
||||
<script>
|
||||
function submitSendInvoice(sendEmail, sendSms) {
|
||||
document.getElementById('sendInvoiceSendEmail').value = sendEmail ? 'true' : 'false';
|
||||
|
||||
@@ -11,8 +11,13 @@
|
||||
ViewData["PageHelpContent"] = "Invoices are created from completed jobs and sent to the customer for payment. Lifecycle: Draft (editable) → Sent (locked, awaiting payment) → Partially Paid / Paid. Overdue = past due date with a balance still owed. Outstanding shows the total A/R balance across all unpaid invoices currently on screen. Use Void to cancel without deleting history.";
|
||||
var searchTerm = ViewBag.SearchTerm as string;
|
||||
var statusFilter = ViewBag.StatusFilter as InvoiceStatus?;
|
||||
var statusGroup = ViewBag.StatusGroup as string;
|
||||
var outstandingOnly = (bool)(ViewBag.OutstandingOnly ?? false);
|
||||
var thisMonthOnly = (bool)(ViewBag.ThisMonthOnly ?? false);
|
||||
var unpaidCount = (int)(ViewBag.UnpaidCount ?? 0);
|
||||
var partialCount = (int)(ViewBag.PartialCount ?? 0);
|
||||
var paidCount = (int)(ViewBag.PaidCount ?? 0);
|
||||
var allCount = (int)(ViewBag.AllCount ?? 0);
|
||||
}
|
||||
|
||||
@{
|
||||
@@ -52,52 +57,77 @@
|
||||
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header border-0 py-3">
|
||||
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-start align-items-lg-center gap-3">
|
||||
<form asp-action="Index" method="get" class="d-flex flex-nowrap gap-2 align-items-center">
|
||||
<input type="hidden" name="sortColumn" value="@ViewBag.SortColumn" />
|
||||
<input type="hidden" name="sortDirection" value="@ViewBag.SortDirection" />
|
||||
<input type="hidden" name="pageSize" value="@Model.PageSize" />
|
||||
@if (outstandingOnly) { <input type="hidden" name="outstandingOnly" value="true" /> }
|
||||
@if (thisMonthOnly) { <input type="hidden" name="thisMonthOnly" value="true" /> }
|
||||
<div class="input-group" style="max-width:280px; min-width:180px;">
|
||||
<span class="input-group-text border-end-0"><i class="bi bi-search text-muted"></i></span>
|
||||
<input type="text" name="searchTerm" class="form-control border-start-0"
|
||||
placeholder="Search invoices..." value="@searchTerm">
|
||||
</div>
|
||||
<select class="form-select" name="statusFilter" style="width:auto;">
|
||||
<option value="">All Statuses</option>
|
||||
@foreach (InvoiceStatus s in Enum.GetValues(typeof(InvoiceStatus)))
|
||||
<div class="d-flex flex-column gap-2">
|
||||
<!-- Row 1: search + dropdown + actions -->
|
||||
<div class="d-flex flex-wrap gap-2 justify-content-between align-items-center">
|
||||
<form asp-action="Index" method="get" class="d-flex flex-nowrap gap-2 align-items-center flex-grow-1" style="max-width:560px;">
|
||||
<input type="hidden" name="sortColumn" value="@ViewBag.SortColumn" />
|
||||
<input type="hidden" name="sortDirection" value="@ViewBag.SortDirection" />
|
||||
<input type="hidden" name="pageSize" value="@Model.PageSize" />
|
||||
@if (outstandingOnly) { <input type="hidden" name="outstandingOnly" value="true" /> }
|
||||
@if (thisMonthOnly) { <input type="hidden" name="thisMonthOnly" value="true" /> }
|
||||
<div class="input-group" style="min-width:180px;">
|
||||
<span class="input-group-text border-end-0"><i class="bi bi-search text-muted"></i></span>
|
||||
<input type="text" name="searchTerm" class="form-control border-start-0"
|
||||
placeholder="Search invoices…" value="@searchTerm">
|
||||
</div>
|
||||
<select class="form-select" name="statusFilter" style="width:auto;" onchange="this.form.submit()">
|
||||
<option value="">All Statuses</option>
|
||||
@foreach (InvoiceStatus s in Enum.GetValues(typeof(InvoiceStatus)))
|
||||
{
|
||||
<option value="@((int)s)" selected="@(statusFilter == s)">@InvoicesController.GetStatusDisplay(s)</option>
|
||||
}
|
||||
</select>
|
||||
<button type="submit" class="btn btn-primary"><i class="bi bi-search"></i></button>
|
||||
@if (!string.IsNullOrEmpty(searchTerm) || statusFilter.HasValue || outstandingOnly || thisMonthOnly || !string.IsNullOrEmpty(statusGroup))
|
||||
{
|
||||
<option value="@((int)s)" selected="@(statusFilter == s)">@InvoicesController.GetStatusDisplay(s)</option>
|
||||
<a asp-action="Index" asp-route-statusGroup="unpaid" class="btn btn-outline-secondary text-nowrap"><i class="bi bi-x-lg"></i></a>
|
||||
}
|
||||
</select>
|
||||
<button type="submit" class="btn btn-primary"><i class="bi bi-search"></i></button>
|
||||
@if (!string.IsNullOrEmpty(searchTerm) || statusFilter.HasValue || outstandingOnly || thisMonthOnly)
|
||||
{
|
||||
<a asp-action="Index" class="btn btn-outline-secondary">Clear</a>
|
||||
}
|
||||
@if (outstandingOnly)
|
||||
{
|
||||
</form>
|
||||
<a asp-action="Create" class="btn btn-primary text-nowrap">
|
||||
<i class="bi bi-plus-circle me-2"></i>New Invoice
|
||||
</a>
|
||||
</div>
|
||||
<!-- Row 2: status-group pills -->
|
||||
<div class="pcl-pill-group">
|
||||
<a href="@Url.Action("Index", new { statusGroup = "all" })" class="pcl-pill @(statusGroup == "all" ? "active" : "")">
|
||||
All <span class="pcl-pill-count">@allCount</span>
|
||||
</a>
|
||||
<a href="@Url.Action("Index", new { statusGroup = "unpaid" })" class="pcl-pill @(statusGroup == "unpaid" ? "active" : "")">
|
||||
Unpaid <span class="pcl-pill-count">@unpaidCount</span>
|
||||
</a>
|
||||
<a href="@Url.Action("Index", new { statusGroup = "partial" })" class="pcl-pill @(statusGroup == "partial" ? "active" : "")">
|
||||
Partial <span class="pcl-pill-count">@partialCount</span>
|
||||
</a>
|
||||
<a href="@Url.Action("Index", new { statusGroup = "paid" })" class="pcl-pill @(statusGroup == "paid" ? "active" : "")">
|
||||
Paid <span class="pcl-pill-count">@paidCount</span>
|
||||
</a>
|
||||
</div>
|
||||
<!-- Legacy filter badges (outstanding A/R, this-month) -->
|
||||
@if (outstandingOnly)
|
||||
{
|
||||
<div>
|
||||
<span class="badge bg-info text-dark fs-6 fw-normal">
|
||||
<i class="bi bi-funnel-fill me-1"></i>Outstanding A/R
|
||||
</span>
|
||||
}
|
||||
@if (thisMonthOnly && statusFilter == InvoiceStatus.Paid)
|
||||
{
|
||||
</div>
|
||||
}
|
||||
@if (thisMonthOnly && statusFilter == InvoiceStatus.Paid)
|
||||
{
|
||||
<div>
|
||||
<span class="badge bg-success fs-6 fw-normal">
|
||||
<i class="bi bi-funnel-fill me-1"></i>Paid — @DateTime.Now.ToString("MMMM yyyy")
|
||||
</span>
|
||||
}
|
||||
else if (thisMonthOnly)
|
||||
{
|
||||
</div>
|
||||
}
|
||||
else if (thisMonthOnly)
|
||||
{
|
||||
<div>
|
||||
<span class="badge bg-info text-dark fs-6 fw-normal">
|
||||
<i class="bi bi-funnel-fill me-1"></i>@DateTime.Now.ToString("MMMM yyyy")
|
||||
</span>
|
||||
}
|
||||
</form>
|
||||
<a asp-action="Create" class="btn btn-primary text-nowrap">
|
||||
<i class="bi bi-plus-circle me-2"></i>New Invoice
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
|
||||
@@ -2936,8 +2936,12 @@
|
||||
profitEl.className = profit >= 0 ? 'text-success' : 'text-danger';
|
||||
|
||||
document.getElementById('costingMargin').textContent = `${d.grossMargin}%`;
|
||||
document.getElementById('costingQuotedMargin').textContent =
|
||||
d.quotedPrice > 0 ? `${d.quotedMargin}% (quoted ${fmt(d.quotedPrice)})` : '—';
|
||||
const quotedMarginEl = document.getElementById('costingQuotedMargin');
|
||||
if (d.quotedPrice > 0) {
|
||||
quotedMarginEl.textContent = `${d.quotedMargin}% (quoted ${fmt(d.quotedPrice)})`;
|
||||
} else {
|
||||
quotedMarginEl.innerHTML = '—';
|
||||
}
|
||||
|
||||
// Powder detail lines
|
||||
const pBody = document.getElementById('powderLines');
|
||||
|
||||
@@ -110,12 +110,49 @@
|
||||
<div class="text-muted small">Platform-level lookup library for inventory autofill, SDS/TDS links, and curing specs.</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<form asp-action="SyncColumbia" method="post" class="d-inline"
|
||||
onsubmit="this.querySelector('button').disabled=true;this.querySelector('button').innerHTML='<span class=\'spinner-border spinner-border-sm me-1\'></span>Syncing…';">
|
||||
@Html.AntiForgeryToken()
|
||||
<button type="submit" class="btn btn-outline-primary" title="Pull the latest Columbia Coatings catalog now">
|
||||
<i class="bi bi-cloud-download me-1"></i>Sync Columbia
|
||||
</button>
|
||||
</form>
|
||||
<a asp-action="Create" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle me-1"></i>Add Powder
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@{
|
||||
var columbiaLastSynced = ViewBag.ColumbiaLastSyncedAt as string;
|
||||
var columbiaLastResult = ViewBag.ColumbiaLastResult as string;
|
||||
var columbiaEnabled = ViewBag.ColumbiaSyncEnabled is true;
|
||||
}
|
||||
<div class="alert alert-light border d-flex flex-wrap align-items-center gap-2 small mb-4 alert-permanent">
|
||||
<span class="badge @(columbiaEnabled ? "bg-success" : "bg-secondary")">
|
||||
Scheduled sync @(columbiaEnabled ? "on" : "off")
|
||||
</span>
|
||||
@if (!string.IsNullOrWhiteSpace(columbiaLastSynced) && DateTime.TryParse(columbiaLastSynced, out var lastSyncedAt))
|
||||
{
|
||||
<span class="text-muted">Last synced @lastSyncedAt.ToLocalTime().ToString("MMM d, yyyy h:mm tt")</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">Never synced</span>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(columbiaLastResult))
|
||||
{
|
||||
<span class="text-muted">— @columbiaLastResult</span>
|
||||
}
|
||||
<form asp-action="PurgeColumbiaData" method="post" class="ms-auto"
|
||||
onsubmit="return confirm('Remove ALL Columbia Coatings API data from the powder catalog? This deletes every record sourced from their feed (including PPG and KP Pigments products served through it) and unlinks inventory items. Inventory stock is preserved. This cannot be undone.');">
|
||||
@Html.AntiForgeryToken()
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm" title="Right-to-delete: remove all Columbia-sourced catalog data">
|
||||
<i class="bi bi-trash me-1"></i>Remove Columbia data
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-4 powder-catalog-summary">
|
||||
<div class="col-sm-6 col-xl-2">
|
||||
<div class="card h-100">
|
||||
|
||||
@@ -168,7 +168,7 @@
|
||||
|
||||
} catch (e) {
|
||||
document.getElementById('loadingState').classList.add('d-none');
|
||||
document.getElementById('errorMessage').textContent = 'Network error — please try again.';
|
||||
document.getElementById('errorMessage').innerHTML = 'Network error — please try again.';
|
||||
document.getElementById('errorState').classList.remove('d-none');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,7 +225,7 @@
|
||||
|
||||
} catch (e) {
|
||||
document.getElementById('loadingState').classList.add('d-none');
|
||||
document.getElementById('errorMessage').textContent = 'Network error — please try again.';
|
||||
document.getElementById('errorMessage').innerHTML = 'Network error — please try again.';
|
||||
document.getElementById('errorState').classList.remove('d-none');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,14 @@
|
||||
"ApiKey": "25651af3a4829559ef0dfa1758fa3edbf92b6b76"
|
||||
}
|
||||
},
|
||||
"Columbia": {
|
||||
"ApiKey": "cca_live_ffd5e355809e1d23007d82982684157de5727c226bc2b482",
|
||||
"BaseUrl": "https://columbiacoatings.com",
|
||||
"ApiBasePath": "/wp-json/cca/v1"
|
||||
},
|
||||
"CatalogImport": {
|
||||
"Token": ""
|
||||
},
|
||||
"SendGrid": {
|
||||
"ApiKey": "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",
|
||||
"FromEmail": "spouliot@scppowdercoating.com",
|
||||
|
||||
@@ -91,13 +91,23 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
ownerForm.addEventListener('submit', writeHiddenFields, { capture: true });
|
||||
|
||||
// Save scroll position before the form causes a full-page reload so we can
|
||||
// restore it after the server redirects back to this page. Key is path-specific
|
||||
// so navigating away and back doesn't restore a stale position.
|
||||
// restore it after the server redirects back to this page on a validation error.
|
||||
// Key is path-specific; cleared on pagehide unless we're leaving via a submit so
|
||||
// a fresh navigation to this page never restores a stale position.
|
||||
const scrollKey = 'wizardScrollY:' + location.pathname;
|
||||
let wizardSubmitting = false;
|
||||
ownerForm.addEventListener('submit', () => {
|
||||
wizardSubmitting = true;
|
||||
sessionStorage.setItem(scrollKey, String(Math.round(window.scrollY)));
|
||||
}, { capture: true });
|
||||
|
||||
// If the page unloads for any reason other than our own form submit (e.g. the
|
||||
// user clicks a nav link or the server redirects to a success page), discard the
|
||||
// saved position so it doesn't fire on the next fresh visit.
|
||||
window.addEventListener('pagehide', () => {
|
||||
if (!wizardSubmitting) sessionStorage.removeItem(scrollKey);
|
||||
});
|
||||
|
||||
// Restore on load — fire after layout is painted so scrollTo lands correctly.
|
||||
const savedY = sessionStorage.getItem(scrollKey);
|
||||
if (savedY !== null) {
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
// terminal-payment.js
|
||||
// Drives the "Take Card Payment" modal on the invoice Details page: pushes a card_present
|
||||
// PaymentIntent to a Stripe Terminal reader (WisePOS E), polls the reader's action status for live
|
||||
// feedback, and reloads the page once the webhook has recorded the payment. The webhook — not this
|
||||
// script — is the source of truth for the ledger; here we only report progress.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var cfg = window.terminalPayment;
|
||||
var modalEl = document.getElementById('cardReaderModal');
|
||||
if (!cfg || !modalEl) return;
|
||||
|
||||
var invoiceId = modalEl.dataset.invoiceId;
|
||||
var testMode = modalEl.dataset.testMode === 'true';
|
||||
|
||||
var setupView = document.getElementById('cardReaderSetup');
|
||||
var statusView = document.getElementById('cardReaderStatus');
|
||||
var statusText = document.getElementById('cardReaderStatusText');
|
||||
var statusSub = document.getElementById('cardReaderStatusSub');
|
||||
var spinner = document.getElementById('cardReaderSpinner');
|
||||
var readerSelect = document.getElementById('cardReaderSelect');
|
||||
var amountInput = document.getElementById('cardReaderAmount');
|
||||
var processBtn = document.getElementById('cardReaderProcessBtn');
|
||||
var cancelBtn = document.getElementById('cardReaderCancelBtn');
|
||||
var simulateBtn = document.getElementById('cardReaderSimulateBtn');
|
||||
|
||||
var POLL_MS = 2500;
|
||||
var TIMEOUT_MS = 90000;
|
||||
var pollTimer = null;
|
||||
var timeoutTimer = null;
|
||||
var currentPI = null;
|
||||
var currentReaderId = null;
|
||||
|
||||
function csrf() {
|
||||
var el = document.querySelector('input[name="__RequestVerificationToken"]');
|
||||
return el ? el.value : '';
|
||||
}
|
||||
|
||||
function post(url, data) {
|
||||
return fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'RequestVerificationToken': csrf(),
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: new URLSearchParams(data)
|
||||
}).then(function (r) { return r.json(); });
|
||||
}
|
||||
|
||||
function getJson(url) {
|
||||
return fetch(url, { headers: { 'RequestVerificationToken': csrf() } })
|
||||
.then(function (r) { return r.json(); });
|
||||
}
|
||||
|
||||
function clearTimers() {
|
||||
if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
|
||||
if (timeoutTimer) { clearTimeout(timeoutTimer); timeoutTimer = null; }
|
||||
}
|
||||
|
||||
function showStatus(text, sub, busy) {
|
||||
setupView.classList.add('d-none');
|
||||
statusView.classList.remove('d-none');
|
||||
statusText.textContent = text;
|
||||
statusSub.textContent = sub || '';
|
||||
spinner.classList.toggle('d-none', !busy);
|
||||
processBtn.classList.add('d-none');
|
||||
if (simulateBtn) simulateBtn.classList.toggle('d-none', !(testMode && busy));
|
||||
}
|
||||
|
||||
function backToSetup() {
|
||||
clearTimers();
|
||||
currentPI = null;
|
||||
statusView.classList.add('d-none');
|
||||
setupView.classList.remove('d-none');
|
||||
processBtn.classList.remove('d-none');
|
||||
processBtn.disabled = false;
|
||||
processBtn.innerHTML = '<i class="bi bi-send me-2"></i>Send to Reader';
|
||||
if (simulateBtn) simulateBtn.classList.add('d-none');
|
||||
cancelBtn.textContent = 'Cancel';
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
clearTimers();
|
||||
spinner.classList.add('d-none');
|
||||
statusText.textContent = 'Payment did not complete';
|
||||
statusSub.textContent = message || 'Please try again.';
|
||||
if (simulateBtn) simulateBtn.classList.add('d-none');
|
||||
// Offer a retry by returning to the setup view via the footer button.
|
||||
processBtn.classList.remove('d-none');
|
||||
processBtn.disabled = false;
|
||||
processBtn.innerHTML = '<i class="bi bi-arrow-repeat me-2"></i>Try Again';
|
||||
}
|
||||
|
||||
function succeed() {
|
||||
clearTimers();
|
||||
spinner.classList.add('d-none');
|
||||
statusText.textContent = 'Approved ✓';
|
||||
statusSub.textContent = 'Updating invoice…';
|
||||
// The webhook has recorded the payment; reload so the new payment row + balance show.
|
||||
setTimeout(function () { window.location.reload(); }, 900);
|
||||
}
|
||||
|
||||
function poll() {
|
||||
if (!currentPI) return;
|
||||
var url = cfg.statusUrl + '?readerId=' + encodeURIComponent(currentReaderId) +
|
||||
'&paymentIntentId=' + encodeURIComponent(currentPI);
|
||||
getJson(url).then(function (res) {
|
||||
if (res.webhookRecorded) { succeed(); return; }
|
||||
if (res.actionStatus === 'failed') {
|
||||
fail(res.failureMessage || 'The card was declined or the payment was cancelled.');
|
||||
return;
|
||||
}
|
||||
// still in_progress (or webhook not landed yet) — keep polling
|
||||
pollTimer = setTimeout(poll, POLL_MS);
|
||||
}).catch(function () {
|
||||
// Transient error — keep polling until the overall timeout fires.
|
||||
pollTimer = setTimeout(poll, POLL_MS);
|
||||
});
|
||||
}
|
||||
|
||||
function process() {
|
||||
var amount = parseFloat(amountInput.value);
|
||||
var balance = parseFloat(modalEl.dataset.balanceDue);
|
||||
if (isNaN(amount) || amount <= 0 || amount > balance + 0.0001) {
|
||||
amountInput.classList.add('is-invalid');
|
||||
return;
|
||||
}
|
||||
amountInput.classList.remove('is-invalid');
|
||||
currentReaderId = readerSelect.value;
|
||||
|
||||
processBtn.disabled = true;
|
||||
processBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Sending…';
|
||||
|
||||
post(cfg.processUrl, {
|
||||
invoiceId: invoiceId,
|
||||
readerId: currentReaderId,
|
||||
amount: amount.toFixed(2)
|
||||
}).then(function (res) {
|
||||
if (!res.success) {
|
||||
backToSetup();
|
||||
showInlineError(res.error || 'Could not start the payment.');
|
||||
return;
|
||||
}
|
||||
currentPI = res.paymentIntentId;
|
||||
cancelBtn.textContent = 'Cancel Payment';
|
||||
showStatus('Follow the prompts on the reader', 'Ask the customer to tap, insert, or swipe their card.', true);
|
||||
pollTimer = setTimeout(poll, POLL_MS);
|
||||
timeoutTimer = setTimeout(function () {
|
||||
fail('This took longer than expected. Check the reader, then try again.');
|
||||
}, TIMEOUT_MS);
|
||||
});
|
||||
}
|
||||
|
||||
function showInlineError(message) {
|
||||
var existing = document.getElementById('cardReaderInlineError');
|
||||
if (!existing) {
|
||||
existing = document.createElement('div');
|
||||
existing.id = 'cardReaderInlineError';
|
||||
existing.className = 'alert alert-danger mt-2 mb-0';
|
||||
setupView.appendChild(existing);
|
||||
}
|
||||
existing.textContent = message;
|
||||
}
|
||||
|
||||
function cancelOnReader() {
|
||||
if (!currentReaderId) return;
|
||||
post(cfg.cancelUrl, { readerId: currentReaderId });
|
||||
}
|
||||
|
||||
// Process / Try Again button.
|
||||
processBtn.addEventListener('click', function () {
|
||||
if (currentPI === null && statusView.classList.contains('d-none')) {
|
||||
process();
|
||||
} else {
|
||||
// "Try Again" after a failure — reset to setup, the next click processes.
|
||||
backToSetup();
|
||||
}
|
||||
});
|
||||
|
||||
// Cancel button: if a payment is in flight, cancel it on the reader before the modal closes.
|
||||
cancelBtn.addEventListener('click', function () {
|
||||
if (currentPI) cancelOnReader();
|
||||
});
|
||||
|
||||
if (simulateBtn) {
|
||||
simulateBtn.addEventListener('click', function () {
|
||||
simulateBtn.disabled = true;
|
||||
post(cfg.simulateUrl, { readerId: currentReaderId }).then(function () {
|
||||
simulateBtn.disabled = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Reset state whenever the modal is reopened.
|
||||
modalEl.addEventListener('show.bs.modal', function () {
|
||||
clearTimers();
|
||||
currentPI = null;
|
||||
currentReaderId = null;
|
||||
var err = document.getElementById('cardReaderInlineError');
|
||||
if (err) err.remove();
|
||||
backToSetup();
|
||||
amountInput.value = parseFloat(modalEl.dataset.balanceDue).toFixed(2);
|
||||
});
|
||||
|
||||
// If the clerk closes the modal mid-payment, stop polling (the webhook still records it).
|
||||
modalEl.addEventListener('hidden.bs.modal', function () {
|
||||
clearTimers();
|
||||
});
|
||||
})();
|
||||
@@ -1,129 +0,0 @@
|
||||
// terminal-readers.js
|
||||
// Powers the Company Settings "Card Readers" tab: registering, listing, and deactivating Stripe
|
||||
// Terminal readers, plus saving the in-person surcharge toggle. Loaded only when the company has an
|
||||
// active Stripe Connect account.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function token() {
|
||||
var el = document.querySelector('input[name="__RequestVerificationToken"]');
|
||||
return el ? el.value : '';
|
||||
}
|
||||
|
||||
function notifyOk(msg) {
|
||||
if (typeof showSuccess === 'function') showSuccess(msg); else console.log(msg);
|
||||
}
|
||||
function notifyErr(msg) {
|
||||
if (typeof showError === 'function') showError(msg); else console.error(msg);
|
||||
}
|
||||
|
||||
function post(url, data) {
|
||||
return fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'RequestVerificationToken': token(),
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: new URLSearchParams(data)
|
||||
}).then(function (r) { return r.json(); });
|
||||
}
|
||||
|
||||
var tableBody = document.getElementById('readersTableBody');
|
||||
var registerBtn = document.getElementById('registerReaderBtn');
|
||||
var saveSettingsBtn = document.getElementById('saveTerminalSettingsBtn');
|
||||
var loaded = false;
|
||||
|
||||
function escapeHtml(s) {
|
||||
return (s || '').replace(/[&<>"']/g, function (c) {
|
||||
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
||||
});
|
||||
}
|
||||
|
||||
function renderReaders(readers) {
|
||||
if (!readers || readers.length === 0) {
|
||||
tableBody.innerHTML = '<tr><td colspan="5" class="text-muted small">No readers registered yet.</td></tr>';
|
||||
return;
|
||||
}
|
||||
tableBody.innerHTML = readers.map(function (r) {
|
||||
var net = r.networkStatus
|
||||
? '<span class="badge bg-' + (r.networkStatus === 'online' ? 'success' : 'secondary') + '">' + escapeHtml(r.networkStatus) + '</span>'
|
||||
: '<span class="text-muted small">—</span>';
|
||||
return '<tr>' +
|
||||
'<td>' + escapeHtml(r.label) + '</td>' +
|
||||
'<td class="small text-muted">' + escapeHtml(r.deviceType) + '</td>' +
|
||||
'<td class="small text-muted">' + escapeHtml(r.serialNumber || '—') + '</td>' +
|
||||
'<td>' + net + '</td>' +
|
||||
'<td class="text-end"><button type="button" class="btn btn-outline-danger btn-sm" data-reader-id="' + r.id + '">' +
|
||||
'<i class="bi bi-trash"></i></button></td>' +
|
||||
'</tr>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function loadReaders() {
|
||||
fetch('/Terminal/ListReaders', { headers: { 'RequestVerificationToken': token() } })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (res) {
|
||||
if (res.success) renderReaders(res.readers);
|
||||
else tableBody.innerHTML = '<tr><td colspan="5" class="text-danger small">Could not load readers.</td></tr>';
|
||||
})
|
||||
.catch(function () {
|
||||
tableBody.innerHTML = '<tr><td colspan="5" class="text-danger small">Could not load readers.</td></tr>';
|
||||
});
|
||||
}
|
||||
|
||||
if (registerBtn) {
|
||||
registerBtn.addEventListener('click', function () {
|
||||
var code = document.getElementById('readerRegCode').value.trim();
|
||||
var label = document.getElementById('readerLabel').value.trim();
|
||||
if (!code || !label) { notifyErr('Enter both a registration code and a label.'); return; }
|
||||
|
||||
registerBtn.disabled = true;
|
||||
post('/Terminal/RegisterReader', { registrationCode: code, label: label }).then(function (res) {
|
||||
registerBtn.disabled = false;
|
||||
if (res.success) {
|
||||
notifyOk('Reader registered.');
|
||||
document.getElementById('readerRegCode').value = '';
|
||||
document.getElementById('readerLabel').value = '';
|
||||
loadReaders();
|
||||
} else {
|
||||
notifyErr(res.error || 'Could not register the reader.');
|
||||
}
|
||||
}).catch(function () {
|
||||
registerBtn.disabled = false;
|
||||
notifyErr('Could not register the reader.');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Deactivate (event delegation on the table body).
|
||||
if (tableBody) {
|
||||
tableBody.addEventListener('click', function (e) {
|
||||
var btn = e.target.closest('button[data-reader-id]');
|
||||
if (!btn) return;
|
||||
if (!confirm('Remove this reader? You can register it again later.')) return;
|
||||
btn.disabled = true;
|
||||
post('/Terminal/DeactivateReader', { id: btn.dataset.readerId }).then(function (res) {
|
||||
if (res.success) { notifyOk('Reader removed.'); loadReaders(); }
|
||||
else { btn.disabled = false; notifyErr(res.error || 'Could not remove the reader.'); }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (saveSettingsBtn) {
|
||||
saveSettingsBtn.addEventListener('click', function () {
|
||||
var enabled = document.getElementById('terminalSurchargeEnabled').checked;
|
||||
post('/Terminal/UpdateTerminalSettings', { surchargeEnabled: enabled }).then(function (res) {
|
||||
if (res.success) notifyOk('Reader settings saved.');
|
||||
else notifyErr(res.error || 'Could not save settings.');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Lazy-load the readers list the first time the tab is shown.
|
||||
var tabBtn = document.getElementById('card-readers-tab');
|
||||
if (tabBtn) {
|
||||
tabBtn.addEventListener('shown.bs.tab', function () {
|
||||
if (!loaded) { loadReaders(); loaded = true; }
|
||||
});
|
||||
}
|
||||
})();
|
||||
@@ -123,7 +123,6 @@
|
||||
var todayLine = result.dailyTotal.toFixed(2) + ' hrs today (' + result.segmentCount + (result.segmentCount === 1 ? ' segment' : ' segments') + ')';
|
||||
|
||||
document.getElementById('tc-confirm-icon').innerHTML = icon;
|
||||
document.getElementById('tc-confirm-title').textContent = result.displayName + ' — ' + title;
|
||||
document.getElementById('tc-confirm-title').innerHTML = escHtml(result.displayName) + ' — ' + title;
|
||||
document.getElementById('tc-confirm-time').textContent = timeStr;
|
||||
document.getElementById('tc-confirm-today').textContent = todayLine;
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
using System.Text.Json;
|
||||
using PowderCoating.Application.DTOs.Columbia;
|
||||
using PowderCoating.Infrastructure.Services.Columbia;
|
||||
|
||||
namespace PowderCoating.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the Columbia catalog mapper, focused on the fields that are tricky in the real feed:
|
||||
/// the free-text cure parser (multiple glyphs, multi-curve, partial-cure), manufacturer derivation
|
||||
/// from a multi-brand distributor, pricing across simple/variable products, and HTML stripping.
|
||||
/// Cases mirror records captured from the live API.
|
||||
/// </summary>
|
||||
public class ColumbiaCatalogMapperTests
|
||||
{
|
||||
// ── Cure schedule parsing ─────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ParseCureCurves_SimpleSchedule_ReturnsSingleCurve()
|
||||
{
|
||||
var curves = ColumbiaCatalogMapper.ParseCureCurves("10 minutes @ 400°F");
|
||||
Assert.Single(curves);
|
||||
Assert.Equal(400, curves[0].TempF);
|
||||
Assert.Equal(10, curves[0].Minutes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseCureCurves_MetalTemperaturePrefixWithCelsius_ParsesFahrenheitOnly()
|
||||
{
|
||||
var curves = ColumbiaCatalogMapper.ParseCureCurves("Metal Temperature: 10 minutes at 400°F (204°C)");
|
||||
Assert.Single(curves);
|
||||
Assert.Equal(400, curves[0].TempF);
|
||||
Assert.Equal(10, curves[0].Minutes);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("10 minutes @ 400˚F (204˚C)")] // U+02DA ring above
|
||||
[InlineData("10 minutes @ 400ºF (204ºC)")] // U+00BA masculine ordinal
|
||||
[InlineData("Metal temperature: 10 minutes @ 400F (204C)")] // no degree glyph at all
|
||||
public void ParseCureCurves_DegreeGlyphVariants_AllParse(string schedule)
|
||||
{
|
||||
var curves = ColumbiaCatalogMapper.ParseCureCurves(schedule);
|
||||
Assert.Single(curves);
|
||||
Assert.Equal(400, curves[0].TempF);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseCureCurves_MultiCurve_CapturesAllInOrder_PrimaryIsFirst()
|
||||
{
|
||||
// Real record S1790085-55: standard high-temp curve first, low-temp alternates after.
|
||||
var schedule = "Metal Temperature: 5 minutes at 400°F (204°C) -or- 10 minutes at 360°F (182°C)* 15 minutes at 340°F (171°C)* *Low-Temp cure curve";
|
||||
var curves = ColumbiaCatalogMapper.ParseCureCurves(schedule);
|
||||
|
||||
Assert.Equal(3, curves.Count);
|
||||
Assert.Equal(new ColumbiaCatalogMapper.CureCurve(400, 5), curves[0]); // primary
|
||||
Assert.Equal(new ColumbiaCatalogMapper.CureCurve(360, 10), curves[1]); // alternate
|
||||
Assert.Equal(new ColumbiaCatalogMapper.CureCurve(340, 15), curves[2]); // alternate
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseCureCurves_PartialCureInstructions_ReturnsEmpty()
|
||||
{
|
||||
// Illusion powder F1697027 — multi-step, no single temp/time pair.
|
||||
var schedule = "(1) - Apply a basecoat and partial cure. (2) - Apply this powder and partial cure (3). - Apply a clear coat and fully cure.";
|
||||
var curves = ColumbiaCatalogMapper.ParseCureCurves(schedule);
|
||||
Assert.Empty(curves);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Map_MultiCurve_SetsPrimaryTempTimeAndStoresAllCurvesJson()
|
||||
{
|
||||
var product = new ColumbiaProduct
|
||||
{
|
||||
Sku = "S1790085-55",
|
||||
Name = "Multi Cure",
|
||||
CureSchedule = "5 minutes at 400°F -or- 10 minutes at 360°F",
|
||||
};
|
||||
|
||||
var item = ColumbiaCatalogMapper.Map(product);
|
||||
|
||||
Assert.Equal(400m, item.CureTemperatureF);
|
||||
Assert.Equal(5, item.CureTimeMinutes);
|
||||
Assert.False(string.IsNullOrEmpty(item.CureCurvesJson));
|
||||
Assert.Equal("5 minutes at 400°F -or- 10 minutes at 360°F", item.CureScheduleText);
|
||||
|
||||
var curves = JsonSerializer.Deserialize<List<ColumbiaCatalogMapper.CureCurve>>(item.CureCurvesJson!);
|
||||
Assert.Equal(2, curves!.Count);
|
||||
}
|
||||
|
||||
// ── Manufacturer derivation ───────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void DeriveManufacturer_AddSkuPrefix_IsKpPigments()
|
||||
{
|
||||
var p = new ColumbiaProduct { Sku = "ADD-BRBDS", Name = "Barbados Blue ColorShift Pearl" };
|
||||
Assert.Equal("KP Pigments", ColumbiaCatalogMapper.DeriveManufacturer(p));
|
||||
Assert.True(ColumbiaCatalogMapper.IsAdditive(p));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeriveManufacturer_PpgCategory_IsPpg()
|
||||
{
|
||||
var p = new ColumbiaProduct
|
||||
{
|
||||
Sku = "PCU75139",
|
||||
Name = "PPG Chrome Shadow",
|
||||
Categories = { new ColumbiaNamed { Name = "PPG Powders" } },
|
||||
};
|
||||
Assert.Equal("PPG", ColumbiaCatalogMapper.DeriveManufacturer(p));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeriveManufacturer_HouseBrand_IsColumbia()
|
||||
{
|
||||
var p = new ColumbiaProduct
|
||||
{
|
||||
Sku = "X5004124",
|
||||
Name = "Blue Wrinkle",
|
||||
Categories = { new ColumbiaNamed { Name = "Powders" }, new ColumbiaNamed { Name = "New Releases" } },
|
||||
};
|
||||
Assert.Equal("Columbia Coatings", ColumbiaCatalogMapper.DeriveManufacturer(p));
|
||||
Assert.False(ColumbiaCatalogMapper.IsAdditive(p));
|
||||
}
|
||||
|
||||
// ── Excluded products (swatches + tester/sample size variants) ────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("T1696049-SW", "**SWATCH** - Copperhead II", true)] // swatch card
|
||||
[InlineData("T1696049-SW", "Copperhead II", true)] // -SW suffix alone
|
||||
[InlineData("X1", "**SWATCH** - Something", true)] // name marker alone
|
||||
[InlineData("T1696049-04", "Copperhead II (4 Ounce Tester)", true)] // tester = size variant
|
||||
[InlineData("XYZ", "Copperhead II (4 Ounce Tester)", true)] // tester by name
|
||||
[InlineData("S1760090-S", "Black Beauty Sample (5 lbs)", true)] // 5lb sample = size variant
|
||||
[InlineData("X5004124", "Blue Wrinkle", false)] // normal powder
|
||||
[InlineData("S5704126", "Smokey Blue", false)] // normal powder, SKU starts with S
|
||||
public void IsExcludedProduct_DetectsSwatchesTestersAndSamples(string sku, string name, bool expected)
|
||||
{
|
||||
var p = new ColumbiaProduct { Sku = sku, Name = name };
|
||||
Assert.Equal(expected, ColumbiaCatalogMapper.IsExcludedProduct(p));
|
||||
}
|
||||
|
||||
// ── Pricing ───────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ParseBasePrice_SimpleProduct_UsesTopLevelPrice()
|
||||
{
|
||||
var p = new ColumbiaProduct { Price = "18.85", RegularPrice = "18.85" };
|
||||
Assert.Equal(18.85m, ColumbiaCatalogMapper.ParseBasePrice(p));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseBasePrice_VariableProductWithZeroRegular_FallsBackToPriceThenVariants()
|
||||
{
|
||||
// Variable parent: price carries the lead variant, regular_price is "0".
|
||||
var p = new ColumbiaProduct
|
||||
{
|
||||
Price = "18.85",
|
||||
RegularPrice = "0",
|
||||
VariationPricing = new List<ColumbiaVariationPricing>
|
||||
{
|
||||
new() { Sku = "X-B", Price = "18.85" },
|
||||
new() { Sku = "X-P", Price = "18.85" },
|
||||
},
|
||||
};
|
||||
Assert.Equal(18.85m, ColumbiaCatalogMapper.ParseBasePrice(p));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildPriceTiersJson_VariableProduct_SerializesVariationPricing()
|
||||
{
|
||||
var p = new ColumbiaProduct
|
||||
{
|
||||
VariationPricing = new List<ColumbiaVariationPricing>
|
||||
{
|
||||
new() { Sku = "X-B", Price = "18.85" },
|
||||
},
|
||||
};
|
||||
var json = ColumbiaCatalogMapper.BuildPriceTiersJson(p);
|
||||
Assert.NotNull(json);
|
||||
Assert.Contains("X-B", json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildPriceTiersJson_EmptyTieredPricingArray_ReturnsNull()
|
||||
{
|
||||
// Variable products carry tiered_pricing as an empty array.
|
||||
var p = new ColumbiaProduct { TieredPricing = JsonDocument.Parse("[]").RootElement };
|
||||
Assert.Null(ColumbiaCatalogMapper.BuildPriceTiersJson(p));
|
||||
}
|
||||
|
||||
// ── Chemistry, color, HTML ────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("Polyester/TGIC", "Polyester/TGIC")]
|
||||
[InlineData("Polyester TGIC", "Polyester/TGIC")]
|
||||
[InlineData("TGIC Polyester", "Polyester/TGIC")]
|
||||
[InlineData("TGIC", "TGIC")]
|
||||
[InlineData("", null)]
|
||||
public void NormalizeChemistry_CollapsesPolyesterTgicVariants(string input, string? expected)
|
||||
{
|
||||
Assert.Equal(expected, ColumbiaCatalogMapper.NormalizeChemistry(input));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildColorFamilies_JoinsColorGroupNames()
|
||||
{
|
||||
var p = new ColumbiaProduct
|
||||
{
|
||||
PaColorGroup = { new ColumbiaNamed { Name = "Blue" }, new ColumbiaNamed { Name = "Green" } },
|
||||
};
|
||||
Assert.Equal("Blue,Green", ColumbiaCatalogMapper.BuildColorFamilies(p));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildColorFamilies_FallsBackToColorGroupAttribute()
|
||||
{
|
||||
var p = new ColumbiaProduct
|
||||
{
|
||||
Attributes =
|
||||
{
|
||||
new ColumbiaAttribute
|
||||
{
|
||||
Name = "Color Group",
|
||||
Options = { new ColumbiaNamed { Name = "Black" } },
|
||||
},
|
||||
},
|
||||
};
|
||||
Assert.Equal("Black", ColumbiaCatalogMapper.BuildColorFamilies(p));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StripHtml_RemovesTagsEntitiesAndCollapsesWhitespace()
|
||||
{
|
||||
var html = "<strong>Blue Wrinkle</strong>\r\n\r\nThis is a vibrant & bright coating.";
|
||||
var text = ColumbiaCatalogMapper.StripHtml(html);
|
||||
Assert.Equal("Blue Wrinkle This is a vibrant & bright coating.", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetectRequiresClearCoat_ExplicitRequirement_IsTrue()
|
||||
{
|
||||
var p = new ColumbiaProduct
|
||||
{
|
||||
Description = "This powder requires a clear coat to activate the effect.",
|
||||
};
|
||||
Assert.True(ColumbiaCatalogMapper.DetectRequiresClearCoat(p));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetectRequiresClearCoat_IllusionLine_IsTrue()
|
||||
{
|
||||
var p = new ColumbiaProduct { Name = "Illusion Cherry", Description = "A translucent red." };
|
||||
Assert.True(ColumbiaCatalogMapper.DetectRequiresClearCoat(p));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetectRequiresClearCoat_PartialCureSchedule_IsTrue()
|
||||
{
|
||||
var p = new ColumbiaProduct
|
||||
{
|
||||
Name = "Some Base",
|
||||
CureSchedule = "Partial Cure: 15 min total time in oven preheated to 400°F. Then apply clear.",
|
||||
};
|
||||
Assert.True(ColumbiaCatalogMapper.DetectRequiresClearCoat(p));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetectRequiresClearCoat_CasualMention_IsFalse()
|
||||
{
|
||||
// The over-flagging case: a passing mention is not a requirement.
|
||||
var p = new ColumbiaProduct
|
||||
{
|
||||
Name = "Gloss Black",
|
||||
Description = "Durable gloss black. Apply a clear coat for added protection if desired.",
|
||||
};
|
||||
Assert.False(ColumbiaCatalogMapper.DetectRequiresClearCoat(p));
|
||||
}
|
||||
|
||||
// ── End-to-end mapping invariants ─────────────────────────────────────
|
||||
|
||||
// ── Tolerant image deserialization (WordPress returns [] / false when empty) ──
|
||||
|
||||
private static readonly JsonSerializerOptions ClientJsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
Converters = { new ColumbiaImageJsonConverter() },
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Deserialize_FeaturedImageEmptyArray_YieldsNullImage()
|
||||
{
|
||||
// WordPress returns featured_image: [] for products with no image.
|
||||
var json = """{ "sku": "X1", "name": "No Image", "featured_image": [] }""";
|
||||
var product = JsonSerializer.Deserialize<ColumbiaProduct>(json, ClientJsonOptions);
|
||||
|
||||
Assert.NotNull(product);
|
||||
Assert.Null(product!.FeaturedImage);
|
||||
Assert.Null(ColumbiaCatalogMapper.Map(product).ImageUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deserialize_FeaturedImageObject_ParsesSrc()
|
||||
{
|
||||
var json = """{ "sku": "X1", "name": "Has Image", "featured_image": { "id": 5, "src": "https://x/img.png", "name": "i", "alt": "" } }""";
|
||||
var product = JsonSerializer.Deserialize<ColumbiaProduct>(json, ClientJsonOptions);
|
||||
|
||||
Assert.Equal("https://x/img.png", product!.FeaturedImage!.Src);
|
||||
Assert.Equal("https://x/img.png", ColumbiaCatalogMapper.Map(product).ImageUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Map_AlwaysStampsSource_AndLeavesEnrichmentFieldsNull()
|
||||
{
|
||||
var p = new ColumbiaProduct { Sku = "X5004124", Name = "Blue Wrinkle", Price = "18.85" };
|
||||
var item = ColumbiaCatalogMapper.Map(p);
|
||||
|
||||
Assert.Equal("Columbia Coatings API", item.Source);
|
||||
// Enrichment fields are not in the feed and must stay null for lazy TDS/AI enrichment.
|
||||
Assert.Null(item.SpecificGravity);
|
||||
Assert.Null(item.CoverageSqFtPerLb);
|
||||
Assert.Null(item.TransferEfficiency);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Infrastructure.Repositories;
|
||||
using PowderCoating.Infrastructure.Services;
|
||||
|
||||
namespace PowderCoating.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that catalog sync propagation updates a linked inventory item's quoting reference price
|
||||
/// and product data, while never touching the tenant-owned cost basis, quantity, notes, or image.
|
||||
/// </summary>
|
||||
public class PowderCatalogPropagationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Propagate_UpdatesReferencePriceAndSpecs_ButNotCostQuantityNotesOrImage()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
|
||||
var catalog = new PowderCatalogItem
|
||||
{
|
||||
VendorName = "Columbia Coatings",
|
||||
Sku = "CS1693053",
|
||||
ColorName = "Joker Jewel",
|
||||
Source = "Columbia Coatings API",
|
||||
UnitPrice = 28m, // new catalog price
|
||||
SdsUrl = "https://cc/sds.pdf",
|
||||
TdsUrl = "https://cc/tds.pdf",
|
||||
CureTemperatureF = 400m,
|
||||
CureTimeMinutes = 10,
|
||||
ColorFamilies = "Green,Purple",
|
||||
};
|
||||
context.PowderCatalogItems.Add(catalog);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var inv = new InventoryItem
|
||||
{
|
||||
CompanyId = 1,
|
||||
SKU = "POWD-2606-0001",
|
||||
Name = "Joker Jewel",
|
||||
PowderCatalogItemId = catalog.Id,
|
||||
UnitCost = 20m, // what they actually paid
|
||||
AverageCost = 20m,
|
||||
LastPurchasePrice = 20m,
|
||||
QuantityOnHand = 5m,
|
||||
Notes = "keep my note",
|
||||
ImageUrl = "my-own-photo.jpg",
|
||||
CatalogReferencePrice = null, // not yet set
|
||||
};
|
||||
context.InventoryItems.Add(inv);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = new PowderCatalogUpsertService(
|
||||
new UnitOfWork(context),
|
||||
Mock.Of<ILogger<PowderCatalogUpsertService>>());
|
||||
|
||||
var updated = await service.PropagateToLinkedInventoryAsync();
|
||||
|
||||
Assert.Equal(1, updated);
|
||||
|
||||
var refreshed = await context.InventoryItems.FindAsync(inv.Id);
|
||||
Assert.NotNull(refreshed);
|
||||
|
||||
// Quoting reference price + product data refreshed from the catalog.
|
||||
Assert.Equal(28m, refreshed!.CatalogReferencePrice);
|
||||
Assert.NotNull(refreshed.CatalogPriceUpdatedAt);
|
||||
Assert.Equal("https://cc/sds.pdf", refreshed.SdsUrl);
|
||||
Assert.Equal("https://cc/tds.pdf", refreshed.TdsUrl);
|
||||
Assert.Equal(400m, refreshed.CureTemperatureF);
|
||||
Assert.Equal("Green,Purple", refreshed.ColorFamilies);
|
||||
|
||||
// Tenant-owned fields untouched.
|
||||
Assert.Equal(20m, refreshed.UnitCost);
|
||||
Assert.Equal(20m, refreshed.AverageCost);
|
||||
Assert.Equal(20m, refreshed.LastPurchasePrice);
|
||||
Assert.Equal(5m, refreshed.QuantityOnHand);
|
||||
Assert.Equal("keep my note", refreshed.Notes);
|
||||
Assert.Equal("my-own-photo.jpg", refreshed.ImageUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Propagate_DoesNotSetReferencePrice_WhenCatalogPriceIsZero()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
|
||||
var catalog = new PowderCatalogItem
|
||||
{
|
||||
VendorName = "Columbia Coatings",
|
||||
Sku = "X1",
|
||||
ColorName = "No Price",
|
||||
Source = "Columbia Coatings API",
|
||||
UnitPrice = 0m, // unknown price — must not wipe quoting with $0
|
||||
};
|
||||
context.PowderCatalogItems.Add(catalog);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var inv = new InventoryItem
|
||||
{
|
||||
CompanyId = 1,
|
||||
SKU = "POWD-2606-0002",
|
||||
Name = "No Price",
|
||||
PowderCatalogItemId = catalog.Id,
|
||||
UnitCost = 15m,
|
||||
CatalogReferencePrice = null,
|
||||
};
|
||||
context.InventoryItems.Add(inv);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = new PowderCatalogUpsertService(
|
||||
new UnitOfWork(context),
|
||||
Mock.Of<ILogger<PowderCatalogUpsertService>>());
|
||||
|
||||
await service.PropagateToLinkedInventoryAsync();
|
||||
|
||||
var refreshed = await context.InventoryItems.FindAsync(inv.Id);
|
||||
Assert.Null(refreshed!.CatalogReferencePrice); // stays null -> quoting falls back to UnitCost
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Propagate_LinksUnlinkedItem_ByManufacturerAndPartNumber()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
|
||||
var catalog = new PowderCatalogItem
|
||||
{
|
||||
VendorName = "Columbia Coatings",
|
||||
Sku = "CS1693053",
|
||||
ColorName = "Joker Jewel",
|
||||
Source = "Columbia Coatings API",
|
||||
UnitPrice = 28m,
|
||||
};
|
||||
context.PowderCatalogItems.Add(catalog);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var inv = new InventoryItem
|
||||
{
|
||||
CompanyId = 1,
|
||||
SKU = "POWD-2606-0009",
|
||||
Name = "Joker Jewel",
|
||||
Manufacturer = "Columbia Coatings",
|
||||
ManufacturerPartNumber = "CS1693053", // matches catalog SKU
|
||||
PowderCatalogItemId = null, // not linked yet (legacy item)
|
||||
UnitCost = 20m,
|
||||
};
|
||||
context.InventoryItems.Add(inv);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = new PowderCatalogUpsertService(
|
||||
new UnitOfWork(context),
|
||||
Mock.Of<ILogger<PowderCatalogUpsertService>>());
|
||||
|
||||
await service.PropagateToLinkedInventoryAsync();
|
||||
|
||||
var refreshed = await context.InventoryItems.FindAsync(inv.Id);
|
||||
Assert.Equal(catalog.Id, refreshed!.PowderCatalogItemId); // self-healed link
|
||||
Assert.Equal(28m, refreshed.CatalogReferencePrice); // and got the price
|
||||
Assert.Equal(20m, refreshed.UnitCost); // cost untouched
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Propagate_DoesNotLink_WhenPartNumberDoesNotMatch()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
|
||||
context.PowderCatalogItems.Add(new PowderCatalogItem
|
||||
{
|
||||
VendorName = "Columbia Coatings",
|
||||
Sku = "CS1693053",
|
||||
ColorName = "Joker Jewel",
|
||||
UnitPrice = 28m,
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var inv = new InventoryItem
|
||||
{
|
||||
CompanyId = 1,
|
||||
SKU = "POWD-2606-0010",
|
||||
Name = "Something Else",
|
||||
Manufacturer = "Columbia Coatings",
|
||||
ManufacturerPartNumber = "NOPE-999", // no catalog match
|
||||
PowderCatalogItemId = null,
|
||||
};
|
||||
context.InventoryItems.Add(inv);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = new PowderCatalogUpsertService(
|
||||
new UnitOfWork(context),
|
||||
Mock.Of<ILogger<PowderCatalogUpsertService>>());
|
||||
|
||||
await service.PropagateToLinkedInventoryAsync();
|
||||
|
||||
var refreshed = await context.InventoryItems.FindAsync(inv.Id);
|
||||
Assert.Null(refreshed!.PowderCatalogItemId); // stays unlinked
|
||||
}
|
||||
|
||||
private static ApplicationDbContext CreateContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
|
||||
var identity = new ClaimsIdentity([new Claim(ClaimTypes.Role, "SuperAdmin")], "Test");
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
byte[]? noBytes = null;
|
||||
var sessionMock = new Mock<ISession>();
|
||||
sessionMock.Setup(s => s.TryGetValue(It.IsAny<string>(), out noBytes)).Returns(false);
|
||||
|
||||
var httpContextMock = new Mock<HttpContext>();
|
||||
httpContextMock.SetupGet(c => c.User).Returns(principal);
|
||||
httpContextMock.SetupGet(c => c.Session).Returns(sessionMock.Object);
|
||||
|
||||
var accessor = new Mock<IHttpContextAccessor>();
|
||||
accessor.SetupGet(a => a.HttpContext).Returns(httpContextMock.Object);
|
||||
|
||||
return new ApplicationDbContext(options, accessor.Object, null!);
|
||||
}
|
||||
}
|
||||
@@ -514,8 +514,7 @@ public class PricingStageFlowTests
|
||||
CreateTenantContext().Object,
|
||||
Mock.Of<INotificationService>(),
|
||||
Mock.Of<IAccountBalanceService>(),
|
||||
Mock.Of<ICompanyLogoService>(),
|
||||
new Microsoft.Extensions.Configuration.ConfigurationBuilder().Build());
|
||||
Mock.Of<ICompanyLogoService>());
|
||||
|
||||
var identity = new ClaimsIdentity([new Claim(ClaimTypes.Role, "SuperAdmin")], "Test");
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
@@ -1,232 +0,0 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Infrastructure.Repositories;
|
||||
using PowderCoating.Web.Controllers;
|
||||
|
||||
namespace PowderCoating.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Validation and surcharge-routing tests for the in-person Stripe Terminal payment flow.
|
||||
/// The Stripe API itself is mocked via <see cref="IStripeConnectService"/>; these tests cover the
|
||||
/// controller's guard rails and the TerminalSurchargeEnabled toggle, not Stripe behavior.
|
||||
/// </summary>
|
||||
public class TerminalControllerTests
|
||||
{
|
||||
private const int CompanyId = 1;
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessPayment_WhenAmountExceedsBalance_ReturnsError()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
SeedCompany(context, connected: true);
|
||||
SeedInvoice(context, total: 100m, amountPaid: 0m);
|
||||
SeedReader(context);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var stripe = new Mock<IStripeConnectService>();
|
||||
var controller = CreateController(context, stripe);
|
||||
|
||||
var result = await controller.ProcessPayment(invoiceId: 1, readerId: 1, amount: 250m);
|
||||
|
||||
using var doc = ParseJson(result);
|
||||
Assert.False(doc.RootElement.GetProperty("success").GetBoolean());
|
||||
stripe.Verify(s => s.ProcessInvoicePaymentOnReaderAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<decimal>(), It.IsAny<decimal>(),
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessPayment_WhenInvoiceVoided_ReturnsError()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
SeedCompany(context, connected: true);
|
||||
SeedInvoice(context, total: 100m, amountPaid: 0m, status: InvoiceStatus.Voided);
|
||||
SeedReader(context);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var stripe = new Mock<IStripeConnectService>();
|
||||
var controller = CreateController(context, stripe);
|
||||
|
||||
var result = await controller.ProcessPayment(invoiceId: 1, readerId: 1, amount: 50m);
|
||||
|
||||
using var doc = ParseJson(result);
|
||||
Assert.False(doc.RootElement.GetProperty("success").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessPayment_WhenStripeNotConnected_ReturnsError()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
SeedCompany(context, connected: false);
|
||||
SeedInvoice(context, total: 100m, amountPaid: 0m);
|
||||
SeedReader(context);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var stripe = new Mock<IStripeConnectService>();
|
||||
var controller = CreateController(context, stripe);
|
||||
|
||||
var result = await controller.ProcessPayment(invoiceId: 1, readerId: 1, amount: 50m);
|
||||
|
||||
using var doc = ParseJson(result);
|
||||
Assert.False(doc.RootElement.GetProperty("success").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessPayment_WhenSurchargeDisabled_PassesZeroSurchargeAndStoresPaymentIntent()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
SeedCompany(context, connected: true, surchargeEnabled: false, surchargePercent: 3m);
|
||||
SeedInvoice(context, total: 100m, amountPaid: 0m);
|
||||
SeedReader(context);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var stripe = new Mock<IStripeConnectService>();
|
||||
stripe.Setup(s => s.ProcessInvoicePaymentOnReaderAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<decimal>(), It.IsAny<decimal>(),
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>()))
|
||||
.ReturnsAsync((true, "pi_123", (string?)null));
|
||||
var controller = CreateController(context, stripe);
|
||||
|
||||
var result = await controller.ProcessPayment(invoiceId: 1, readerId: 1, amount: 100m);
|
||||
|
||||
using var doc = ParseJson(result);
|
||||
Assert.True(doc.RootElement.GetProperty("success").GetBoolean());
|
||||
Assert.Equal("pi_123", doc.RootElement.GetProperty("paymentIntentId").GetString());
|
||||
|
||||
// Surcharge must be 0 when the toggle is off, even though the company has a 3% online fee.
|
||||
stripe.Verify(s => s.ProcessInvoicePaymentOnReaderAsync(
|
||||
"acct_test", "tmr_test", 100m, 0m, "usd", "INV-1", 1), Times.Once);
|
||||
|
||||
// The PaymentIntent id must be persisted on the invoice for the webhook idempotency guard.
|
||||
var invoice = await context.Invoices.IgnoreQueryFilters().SingleAsync();
|
||||
Assert.Equal("pi_123", invoice.StripePaymentIntentId);
|
||||
Assert.Equal(OnlinePaymentStatus.Pending, invoice.OnlinePaymentStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessPayment_WhenSurchargeEnabled_PassesComputedSurcharge()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
SeedCompany(context, connected: true, surchargeEnabled: true, surchargePercent: 3m);
|
||||
SeedInvoice(context, total: 200m, amountPaid: 0m);
|
||||
SeedReader(context);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var stripe = new Mock<IStripeConnectService>();
|
||||
stripe.Setup(s => s.ProcessInvoicePaymentOnReaderAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<decimal>(), It.IsAny<decimal>(),
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>()))
|
||||
.ReturnsAsync((true, "pi_456", (string?)null));
|
||||
var controller = CreateController(context, stripe);
|
||||
|
||||
await controller.ProcessPayment(invoiceId: 1, readerId: 1, amount: 200m);
|
||||
|
||||
// 3% of 200 = 6.00
|
||||
stripe.Verify(s => s.ProcessInvoicePaymentOnReaderAsync(
|
||||
"acct_test", "tmr_test", 200m, 6m, "usd", "INV-1", 1), Times.Once);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
private static TerminalController CreateController(ApplicationDbContext context, Mock<IStripeConnectService> stripe)
|
||||
{
|
||||
var uow = new UnitOfWork(context);
|
||||
var tenant = new Mock<ITenantContext>();
|
||||
tenant.Setup(t => t.GetCurrentCompanyId()).Returns(CompanyId);
|
||||
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?> { ["Stripe:Connect:SecretKey"] = "sk_test_abc" })
|
||||
.Build();
|
||||
|
||||
var controller = new TerminalController(
|
||||
uow, stripe.Object, tenant.Object, config, Mock.Of<ILogger<TerminalController>>())
|
||||
{
|
||||
ControllerContext = new() { HttpContext = new DefaultHttpContext() }
|
||||
};
|
||||
return controller;
|
||||
}
|
||||
|
||||
private static void SeedCompany(ApplicationDbContext context, bool connected,
|
||||
bool surchargeEnabled = false, decimal surchargePercent = 0m)
|
||||
{
|
||||
context.Companies.Add(new Company
|
||||
{
|
||||
Id = CompanyId,
|
||||
CompanyName = "Test Shop",
|
||||
StripeAccountId = connected ? "acct_test" : null,
|
||||
StripeConnectStatus = connected ? StripeConnectStatus.Active : StripeConnectStatus.NotConnected,
|
||||
StripeTerminalLocationId = "tml_test",
|
||||
TerminalSurchargeEnabled = surchargeEnabled,
|
||||
OnlinePaymentSurchargeType = surchargePercent > 0 ? OnlinePaymentSurchargeType.Percent : OnlinePaymentSurchargeType.None,
|
||||
OnlinePaymentSurchargeValue = surchargePercent
|
||||
});
|
||||
}
|
||||
|
||||
private static void SeedInvoice(ApplicationDbContext context, decimal total, decimal amountPaid,
|
||||
InvoiceStatus status = InvoiceStatus.Sent)
|
||||
{
|
||||
context.Invoices.Add(new Invoice
|
||||
{
|
||||
Id = 1,
|
||||
CompanyId = CompanyId,
|
||||
InvoiceNumber = "INV-1",
|
||||
Total = total,
|
||||
AmountPaid = amountPaid,
|
||||
Status = status
|
||||
});
|
||||
}
|
||||
|
||||
private static void SeedReader(ApplicationDbContext context)
|
||||
{
|
||||
context.TerminalReaders.Add(new TerminalReader
|
||||
{
|
||||
Id = 1,
|
||||
CompanyId = CompanyId,
|
||||
StripeReaderId = "tmr_test",
|
||||
StripeLocationId = "tml_test",
|
||||
Label = "Front Counter",
|
||||
DeviceType = "simulated_wisepos_e",
|
||||
Status = TerminalReaderStatus.Active
|
||||
});
|
||||
}
|
||||
|
||||
private static JsonDocument ParseJson(Microsoft.AspNetCore.Mvc.IActionResult result)
|
||||
{
|
||||
var json = Assert.IsType<Microsoft.AspNetCore.Mvc.JsonResult>(result);
|
||||
return JsonDocument.Parse(JsonSerializer.Serialize(json.Value));
|
||||
}
|
||||
|
||||
private static ApplicationDbContext CreateContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
|
||||
// SuperAdmin principal so IsPlatformAdmin = true and the tenant query filter is bypassed in-test.
|
||||
var identity = new ClaimsIdentity(new[] { new Claim(ClaimTypes.Role, "SuperAdmin") }, "Test");
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
byte[]? noBytes = null;
|
||||
var sessionMock = new Mock<ISession>();
|
||||
sessionMock.Setup(s => s.TryGetValue(It.IsAny<string>(), out noBytes)).Returns(false);
|
||||
|
||||
var httpContextMock = new Mock<HttpContext>();
|
||||
httpContextMock.SetupGet(c => c.User).Returns(principal);
|
||||
httpContextMock.SetupGet(c => c.Session).Returns(sessionMock.Object);
|
||||
|
||||
var accessor = new Mock<IHttpContextAccessor>();
|
||||
accessor.SetupGet(a => a.HttpContext).Returns(httpContextMock.Object);
|
||||
|
||||
return new ApplicationDbContext(options, accessor.Object, null!);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user