Initial Commit

This commit is contained in:
Adal 2026-09-16 13:09:19 -07:00
commit c9054e34b3
32 changed files with 8969 additions and 0 deletions

57
.gitignore vendored Normal file
View file

@ -0,0 +1,57 @@
# ---- secrets (connection string, API keys) - see README ----
appsettings.local.json
appsettings.*.local.json
*.pfx
*.snk
# ---- build output ----
bin/
obj/
publish/
[Dd]ebug/
[Rr]elease/
x64/
x86/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
*.dll
*.exe
*.pdb
*.cache
*.user
*.suo
*.binlog
project.lock.json
project.assets.json
*.nuget.props
*.nuget.targets
# ---- runtime data ----
pending/
archive/
logs/
webview2_data/
*.log
# ---- IDE / tooling ----
.vs/
.vscode/
.idea/
_ReSharper*/
*.DotSettings.user
.claude/
# ---- OS ----
Thumbs.db
ehthumbs.db
Desktop.ini
desktop.ini
$RECYCLE.BIN/
*.lnk
# ---- scratch ----
*.tmp
*.bak
*.orig
*.swp
~$*

5
App.xaml Normal file
View file

@ -0,0 +1,5 @@
<Application x:Class="InvoiceParser.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
ShutdownMode="OnMainWindowClose">
</Application>

102
App.xaml.cs Normal file
View file

@ -0,0 +1,102 @@
using System.Diagnostics;
using System.IO;
using System.Threading;
using System.Windows;
using InvoiceParser.Src;
namespace InvoiceParser;
public partial class App : Application
{
private WebHost? _host;
private Mutex? _singleInstance;
protected override async void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
// Nothing else catches these, and a silent crash on an unattended
// machine is exactly what the log exists to explain.
AppDomain.CurrentDomain.UnhandledException += (_, args) =>
Log.Error("crash", "unhandled exception", args.ExceptionObject as Exception);
DispatcherUnhandledException += (_, args) =>
Log.Error("crash", "unhandled UI exception", args.Exception);
TaskScheduler.UnobservedTaskException += (_, args) =>
{
Log.Error("crash", "unobserved task exception", args.Exception);
args.SetObserved();
};
_singleInstance = new Mutex(true, "InvoiceParserApp_SingleInstance", out bool isNew);
if (!isNew)
{
// Bring the running instance forward instead of just saying it exists.
FocusRunningInstance();
Shutdown();
return;
}
try
{
var config = AppConfig.Load(AppContext.BaseDirectory);
_host = new WebHost(config);
await _host.StartAsync();
var win = new MainWindow(_host.BaseUrl);
MainWindow = win;
win.Show();
}
catch (Exception ex)
{
Log.Error("startup", "startup failed", ex);
MessageBox.Show("Startup failed: " + ex.Message, "Invoice Parser",
MessageBoxButton.OK, MessageBoxImage.Error);
Shutdown();
}
}
[System.Runtime.InteropServices.DllImport("user32.dll")]
private static extern bool SetForegroundWindow(IntPtr hWnd);
[System.Runtime.InteropServices.DllImport("user32.dll")]
private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
private static void FocusRunningInstance()
{
const int SW_RESTORE = 9;
var me = Process.GetCurrentProcess();
foreach (var p in Process.GetProcessesByName(me.ProcessName))
{
if (p.Id == me.Id || p.MainWindowHandle == IntPtr.Zero) continue;
ShowWindow(p.MainWindowHandle, SW_RESTORE);
SetForegroundWindow(p.MainWindowHandle);
return;
}
MessageBox.Show("Invoice Parser is already running.", "Invoice Parser",
MessageBoxButton.OK, MessageBoxImage.Information);
}
protected override void OnExit(ExitEventArgs e)
{
// Stop the host off the UI thread: awaiting it here can deadlock on the
// WPF SynchronizationContext, leaving a windowless process holding the
// single-instance mutex.
try
{
var host = _host;
if (host != null)
Task.Run(() => host.StopAsync()).Wait(TimeSpan.FromSeconds(5));
}
catch (Exception ex) { Log.Error("shutdown", "web host stop failed", ex); }
Log.Info("shutdown", "exiting with code " + e.ApplicationExitCode);
_singleInstance?.Dispose();
base.OnExit(e);
// Hard terminate. Environment.Exit runs finalizers and COM cleanup, which
// hangs on WebView2's browser processes and leaves a windowless process
// that holds the single-instance mutex and locks the exe against rebuilds.
// Safe here: every state.json write is synchronous and already flushed,
// and a parse abandoned mid-call is reset from "processing" to "queued"
// by ParseWorker on the next start.
Process.GetCurrentProcess().Kill();
}
}

33
InvoiceParser.csproj Normal file
View file

@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<UseWPF>true</UseWPF>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AssemblyName>InvoiceParser</AssemblyName>
<RootNamespace>InvoiceParser</RootNamespace>
<ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>assets\InvoiceParser.ico</ApplicationIcon>
<!-- needed for single-file publish (see README build command) -->
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.2592.51" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="5.2.2" />
<PackageReference Include="PdfPig" Version="0.1.8" />
<PackageReference Include="Docnet.Core" Version="2.6.0" />
</ItemGroup>
<ItemGroup>
<!-- Also embedded as a resource so the window and taskbar use it, not just the exe. -->
<Resource Include="assets\InvoiceParser.ico" />
<Content Include="appsettings.json" CopyToOutputDirectory="PreserveNewest" />
<Content Include="appsettings.local.json" Condition="Exists('appsettings.local.json')" CopyToOutputDirectory="PreserveNewest" />
<Content Include="wwwroot\**\*" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

13
MainWindow.xaml Normal file
View file

@ -0,0 +1,13 @@
<Window x:Class="InvoiceParser.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:wv2="clr-namespace:Microsoft.Web.WebView2.Wpf;assembly=Microsoft.Web.WebView2.Wpf"
Title="Invoice Parser"
Icon="assets/InvoiceParser.ico"
Width="1500" Height="950"
WindowStartupLocation="CenterScreen"
Background="#333333">
<Grid>
<wv2:WebView2 x:Name="Browser" />
</Grid>
</Window>

48
MainWindow.xaml.cs Normal file
View file

@ -0,0 +1,48 @@
using System.IO;
using System.Windows;
using Microsoft.Web.WebView2.Core;
namespace InvoiceParser;
public partial class MainWindow : Window
{
private readonly string _url;
public MainWindow(string url)
{
_url = url;
InitializeComponent();
Loaded += OnLoaded;
}
protected override void OnClosed(EventArgs e)
{
// Releases the WebView2 COM objects and terminates its msedgewebview2
// child processes. Left undisposed they keep this process alive after
// the window is gone.
try { Browser.Dispose(); } catch { }
base.OnClosed(e);
}
private async void OnLoaded(object sender, RoutedEventArgs e)
{
try
{
// Keep WebView2 profile data next to the exe so the app is fully portable.
var userData = Path.Combine(AppContext.BaseDirectory, "webview2_data");
var env = await CoreWebView2Environment.CreateAsync(null, userData);
await Browser.EnsureCoreWebView2Async(env);
Browser.CoreWebView2.Settings.AreDefaultContextMenusEnabled = true;
Browser.CoreWebView2.Settings.IsStatusBarEnabled = false;
Browser.CoreWebView2.Navigate(_url);
}
catch (Exception ex)
{
MessageBox.Show(
"WebView2 could not start: " + ex.Message +
"\n\nInstall the WebView2 Runtime (https://go.microsoft.com/fwlink/p/?LinkId=2124703) and try again.",
"Invoice Parser", MessageBoxButton.OK, MessageBoxImage.Error);
Close();
}
}
}

111
README.md Normal file
View file

@ -0,0 +1,111 @@
# Invoice Parser (desktop)
Windows desktop replacement for the `invoiceProject` Classic ASP web app.
One exe: WPF window hosting WebView2, an embedded local web server (loopback
only), and a single background worker that parses queued invoices with the
configured LLM. No IIS, no browser needed for parsing.
## What changed vs the web app
- Upload -> prep -> parse is fully automatic. Drop files on the dashboard;
the server extracts the PDF text layer (PdfPig) or renders pages (PDFium),
queues the invoice, and the in-process worker calls the LLM. The UI gets
live updates over SSE - no polling, no "open it and press Scan Invoice".
- Scanned PDFs no longer dead-end: pages are rendered server-side on demand.
- One worker owns the queue (replaces pending_process.asp, parse_queue.ps1
and the browser-side parser), so the claim/steal/stale logic is gone.
- All SQL is parameterized.
- `pending/{id}/state.json` keeps the same format - existing drafts can be
migrated by copying `invoiceProject\pending\*` into this app's `pending\`.
## Configuration (appsettings.json next to the exe)
Secrets live in `appsettings.local.json` (same shape, git-ignored, loaded on
top of `appsettings.json`). Put `ConnectionStrings:Pos` and the two `ApiKey`
values there; `appsettings.json` ships with those blank. Use a SQL login
limited to the posbdat/posinv tables rather than `sa`.
- `ConnectionStrings:Pos` - SQL Server connection string.
- `Llm:TextRoute` / `Llm:ImageRoute` - `{ Url, Model, ApiKey }` per customer.
- Local llama.cpp: `Url: "http://host:8080"`, empty ApiKey.
- DeepSeek/OpenAI-compatible: full base URL + ApiKey (sends Bearer header).
- Gemini: `Url: "https://generativelanguage.googleapis.com"`, Model
`gemini-...`, ApiKey (sends x-goog-api-key).
- `Llm:Prompt` - override the extraction prompt; empty = built-in default.
- `Llm:TimeoutSeconds`, `Llm:MaxImageDim` - LLM call timeout, vision image cap.
- `Llm:AttachPageImages` - text-layer PDFs: send each rendered page image with
its extracted text (default true). Fixes vendors whose text layer is garbled
by the form overlay. Needs a vision model on `TextRoute`; set false for
text-only models (DeepSeek, a text-only local llama.cpp).
- `Paths:Pending` / `Paths:Archive` - relative to the exe or absolute.
- `Port` - local UI port (falls back to a random port if taken).
- `Logging` - `Enabled`, `Directory` (default `logs`), `RetainDays` (14),
`MaxFileMB` (20), `LogRequests`. See "Log" below.
- `Batch` - `Enabled`, `NamePrefix` (`INV_`), `CreatedBy` (`INVOICE`),
`MinCostDelta` (0.01), `MarginThreshold` (15). See "Cost batches" below.
- `Tables:ProductBatchesHeaderTable` / `Tables:ProductBatchesTable` - the
posbdat batch tables a cost batch is written to.
## Log
`logs\invoiceparser-YYYYMMDD.log`, one file per day, pruned after
`Logging:RetainDays`. Copy the folder or read it over a share to see what a
machine did without sitting at it. One line per event:
2026-09-11 14:22:31.104 INFO [http] POST /api/save 200 412ms
Categories: `startup`, `shutdown`, `crash` (unhandled WPF/AppDomain/task
exceptions), `http` (every request; 4xx logs WARN, 5xx ERROR), `upload`,
`prep`, `worker` (queue and per-invoice stage changes), `llm` (endpoint, status,
elapsed, bytes; failures carry the response body), `match`, `db`, `catalog`,
`save`, `batch`, `product`, `pending`, `archive`. Connection-string passwords
and API keys are stripped before anything is written. Set
`Logging:LogRequests` to false to keep only warnings and errors.
## Cost batches
Saving an invoice also writes a posbdat price/cost batch for the items whose
cost moved. The invoice bills by the case, the product file costs by the unit,
so the comparison is `case cost / pack` against `Products.cost`; a gap of at
least `Batch:MinCostDelta` is a change, and an item with no cost on file counts
as one. Rows with no invoiced cost, and UPCs that are not in the product file,
are left out.
The batch is written inside the same transaction as InvoiceHeader and
InvoiceDetail - if it cannot be written, nothing is saved - and
`InvoiceHeader.Price_Cost_Change` is set to 1 when the invoice produced one.
It is named `INV_<invoice number>` (`_1`, `_2`... if that name is taken) and
carries the new cost only; retail is left alone, and items whose gross margin
at the new cost falls under `Batch:MarginThreshold` are flagged on the archive
screen for manual repricing. Apply it the usual way (`sp_ApplyBatch`).
The batch tables' column lists are read from INFORMATION_SCHEMA on first use and
whatever `Products` and `ProductBatches` genuinely have in common is copied
across, so a differently shaped install does not need a code change. If the
batch tables are missing or have no `cost` column the invoice still saves and
the reason is logged under `batch`.
## Build / run (dev)
dotnet build
bin\Debug\net8.0-windows\InvoiceParser.exe
## Deploy (copy-paste, no install)
dotnet publish -c Release -r win-x64 --self-contained -p:PublishSingleFile=true
Copy the publish folder (exe + appsettings.json + appsettings.local.json +
wwwroot) to the target machine and run the exe. Requirements on the target: the WebView2 Runtime
(preinstalled on updated Windows 10/11; otherwise the 2 MB Evergreen
bootstrapper from Microsoft), network access to SQL Server and the LLM host.
## Layout at runtime
InvoiceParser.exe
appsettings.json
appsettings.local.json connection string + API keys (not in git)
wwwroot\ UI (HTML/JS/CSS, local jQuery)
pending\{id}\ state.json, source.<ext>, pages\page_N.jpg
archive\ {vendorId}_{invoice}.<ext> + .preview cache
logs\ invoiceparser-YYYYMMDD.log
webview2_data\ WebView2 profile (created on first run)

1129
Simpleware Exclamation.pdf Normal file

File diff suppressed because it is too large Load diff

22
app.manifest Normal file
View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- Windows 10 / 11 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true</dpiAware>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
</windowsSettings>
</application>
</assembly>

52
appsettings.json Normal file
View file

@ -0,0 +1,52 @@
{
"Port": 5197,
"ConnectionStrings": {
"Pos": ""
},
"Llm": {
"TextRoute": { "Url": "https://generativelanguage.googleapis.com", "Model": "gemini-2.5-flash", "ApiKey": "" },
"ImageRoute": { "Url": "https://generativelanguage.googleapis.com", "Model": "gemini-2.5-flash", "ApiKey": "" },
"TimeoutSeconds": 600,
"MaxImageDim": 1600,
"MaxParallelPages": 3,
"AttachPageImages": true,
"Prompt": ""
},
"Paths": {
"Pending": "pending",
"Archive": "archive"
},
"Logging": {
"Enabled": true,
"Directory": "logs",
"RetainDays": 14,
"MaxFileMB": 20,
"LogRequests": true
},
"Batch": {
"Enabled": true,
"NamePrefix": "INV_",
"CreatedBy": "INVOICE",
"MinCostDelta": 0.01,
"MarginThreshold": 15
},
"Tables": {
"VendorTable": "Vendors",
"VendorIdCol": "Vendor_ID",
"VendorNameCol": "name",
"ProductsTable": "posbdat.dbo.Products",
"DepartmentsTable": "posbdat.dbo.Departments",
"InvoiceHeaderTable": "posinv.dbo.InvoiceHeader",
"InvoiceDetailTable": "posinv.dbo.InvoiceDetail",
"PoHeaderTable": "posinv.dbo.POHeader",
"PoDetailTable": "posinv.dbo.PODetail",
"ProductBatchesHeaderTable": "posbdat.dbo.ProductBatchesHeader",
"ProductBatchesTable": "posbdat.dbo.ProductBatches"
}
}

BIN
assets/InvoiceParser.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

180
src/AppConfig.cs Normal file
View file

@ -0,0 +1,180 @@
using System.IO;
using Microsoft.Extensions.Configuration;
namespace InvoiceParser.Src;
public class LlmRouteConfig
{
public string Url { get; set; } = "";
public string Model { get; set; } = "local";
public string ApiKey { get; set; } = "";
public bool IsGemini => Url.Contains("generativelanguage.googleapis.com", StringComparison.OrdinalIgnoreCase);
public string BackendName =>
Url.Contains("api.deepseek.com", StringComparison.OrdinalIgnoreCase) ? "deepseek"
: IsGemini ? "gemini"
: "local";
public string ChatCompletionsUrl()
{
var s = Url.Trim().TrimEnd('/');
if (s.Contains("/chat/completions", StringComparison.OrdinalIgnoreCase)) return Url.Trim();
return s + "/v1/chat/completions";
}
}
public class LlmConfig
{
public LlmRouteConfig TextRoute { get; set; } = new();
public LlmRouteConfig ImageRoute { get; set; } = new();
public int TimeoutSeconds { get; set; } = 600;
public int MaxImageDim { get; set; } = 1600;
// Pages of one invoice sent concurrently. Raise for more speed, lower if
// the provider rate-limits.
public int MaxParallelPages { get; set; } = 3;
// Text-layer PDFs: send the rendered page image together with the
// extracted text. Some vendors' text layers are garbled by the form
// overlay; the image is the reliable source and the numbered text only
// has to supply source_line. Requires a vision model on TextRoute.
public bool AttachPageImages { get; set; } = true;
public string Prompt { get; set; } = "";
public const string DefaultPrompt =
"You are an invoice parser. Extract all invoice data and return ONLY valid JSON with no markdown or explanation. " +
"Fields required: vendor_name (string), invoice_number (string), invoice_date (YYYY-MM-DD string), total (number), " +
"total_label (string), " +
"line_items (array where each item has: upc, description, product_code, quantity, unit_price, total, pack, size, unit_of_measure). " +
"Use null for any field you cannot determine. " +
"Read the column headings and keep every value in its own column. quantity is the number of cases shipped, " +
"unit_price the cost of one case, total the extended amount for the row. pack is the units per case (number). " +
"size is the size of ONE unit as a string without the unit; unit_of_measure is that unit (OZ, FZ, LB, CT, EA...). " +
"Wholesale invoices print pack and size together in one PACK/SIZE column, e.g. '12 16OZ' means pack 12, size 16, unit_of_measure OZ; " +
"'6 32 FZ' means pack 6, size 32, FZ; '4 07.5 OZ' means pack 4, size 7.5, OZ. " +
"Never run pack and size together into one number - a size like 1216, 632 or 407.5 is a pack and a size that were merged. " +
"Every line item must have a upc or a product_code; a row with neither (category totals such as REGULAR, PVT LBL, NETCOST) is a summary row - omit it. " +
"A multi-page invoice is sent one page at a time. total is the invoice GRAND total only, printed with a label such as " +
"NET INVOICE TOTAL, INVOICE TOTAL, TOTAL DUE, AMOUNT DUE or BALANCE DUE; total_label is that label as printed. " +
"If this page shows no grand total, or only a page subtotal, running total, recap, allowance or summary figure, set total and total_label to null. " +
"line_items are the purchased products only: never turn rows from a recap, allowance, deposit, summary or totals section into line items.";
public string EffectivePrompt => string.IsNullOrWhiteSpace(Prompt) ? DefaultPrompt : Prompt;
// Text-route variant: input lines are numbered so parsed items can point
// back at their source line for the preview markers.
public string EffectiveTextPrompt => EffectivePrompt +
" The input lines are prefixed with line numbers like 'L12: '. Never include these prefixes in any extracted value. " +
"Each line item must also include source_line: the integer line number of the line the item was extracted from " +
"(e.g. 12 for 'L12: '), or null if unknown.";
// Hybrid variant: page image plus its text layer in the same request.
public string EffectiveHybridPrompt => EffectiveTextPrompt +
" The page image is attached together with the text layer extracted from it. The text layer can be garbled where " +
"the form overlay overlaps the printed values, so read every value from the image and use the numbered text only " +
"to find the matching source_line for each item.";
}
public class PathsConfig
{
public string Pending { get; set; } = "pending";
public string Archive { get; set; } = "archive";
}
public class LoggingConfig
{
public bool Enabled { get; set; } = true;
public string Directory { get; set; } = "logs";
public int RetainDays { get; set; } = 14;
public int MaxFileMB { get; set; } = 20;
// One line per HTTP request. Off leaves only warnings and errors.
public bool LogRequests { get; set; } = true;
}
public class BatchConfig
{
public bool Enabled { get; set; } = true;
// BatchNo is Prefix + invoice number, trimmed to the column's own length.
public string NamePrefix { get; set; } = "INV_";
public string CreatedBy { get; set; } = "INVOICE";
// Smaller unit-cost moves are float noise, not a price change.
public double MinCostDelta { get; set; } = 0.01;
// Gross margin at the new cost below which the item is flagged for repricing.
public double MarginThreshold { get; set; } = 15;
}
public class TablesConfig
{
public string VendorTable { get; set; } = "Vendors";
public string VendorIdCol { get; set; } = "Vendor_ID";
public string VendorNameCol { get; set; } = "name";
public string ProductsTable { get; set; } = "posbdat.dbo.Products";
public string DepartmentsTable { get; set; } = "posbdat.dbo.Departments";
public string InvoiceHeaderTable { get; set; } = "posinv.dbo.InvoiceHeader";
public string InvoiceDetailTable { get; set; } = "posinv.dbo.InvoiceDetail";
public string PoHeaderTable { get; set; } = "posinv.dbo.POHeader";
public string PoDetailTable { get; set; } = "posinv.dbo.PODetail";
public string ProductBatchesHeaderTable { get; set; } = "posbdat.dbo.ProductBatchesHeader";
public string ProductBatchesTable { get; set; } = "posbdat.dbo.ProductBatches";
}
public class AppConfig
{
public int Port { get; set; } = 5197;
public string ConnectionString { get; set; } = "";
public LlmConfig Llm { get; set; } = new();
public PathsConfig Paths { get; set; } = new();
public TablesConfig Tables { get; set; } = new();
public LoggingConfig Logging { get; set; } = new();
public BatchConfig Batch { get; set; } = new();
public string BaseDirectory { get; set; } = "";
public string PendingRoot => ResolvePath(Paths.Pending);
public string ArchiveRoot => ResolvePath(Paths.Archive);
public string WebRoot => Path.Combine(BaseDirectory, "wwwroot");
private string ResolvePath(string p) =>
Path.IsPathRooted(p) ? p : Path.Combine(BaseDirectory, p);
public static AppConfig Load(string baseDirectory)
{
// appsettings.local.json holds the connection string and API keys and
// is git-ignored; appsettings.json ships with placeholders only.
var builder = new ConfigurationBuilder()
.SetBasePath(baseDirectory)
.AddJsonFile("appsettings.json", optional: false)
.AddJsonFile("appsettings.local.json", optional: true);
var root = builder.Build();
var cfg = new AppConfig { BaseDirectory = baseDirectory };
root.Bind(cfg);
cfg.ConnectionString = root.GetConnectionString("Pos") ?? "";
Directory.CreateDirectory(cfg.PendingRoot);
Directory.CreateDirectory(cfg.ArchiveRoot);
Src.Log.Init(cfg.Logging, baseDirectory);
Src.Log.Info("startup", "config loaded: " + cfg.Describe());
return cfg;
}
// Startup banner for the log: enough to tell which server, model and paths
// a remote machine is actually running, with no credentials in it.
public string Describe()
{
string server = "", db = "";
foreach (var part in ConnectionString.Split(';'))
{
var kv = part.Split('=', 2);
if (kv.Length != 2) continue;
var key = kv[0].Trim().ToLowerInvariant();
if (key == "server" || key == "data source") server = kv[1].Trim();
else if (key == "database" || key == "initial catalog") db = kv[1].Trim();
}
return $"port={Port} sql={server}/{db} " +
$"text={Llm.TextRoute.BackendName}:{Llm.TextRoute.Model} " +
$"image={Llm.ImageRoute.BackendName}:{Llm.ImageRoute.Model} " +
$"attachPageImages={Llm.AttachPageImages} timeout={Llm.TimeoutSeconds}s " +
$"pending={PendingRoot} archive={ArchiveRoot} " +
$"batch={(Batch.Enabled ? "on" : "off")} logRetain={Logging.RetainDays}d";
}
}

387
src/BatchService.cs Normal file
View file

@ -0,0 +1,387 @@
using System.Data;
using System.Globalization;
using System.Text;
using Microsoft.Data.SqlClient;
namespace InvoiceParser.Src;
// One product whose invoiced unit cost differs from the cost on the product file.
public record BatchCandidate(
string Upc, string Description, double NewCost, double? OldCost, double? Retail, double? Pack)
{
public double? NewMargin =>
Retail is > 0 ? (Retail.Value - NewCost) / Retail.Value * 100 : null;
public double? OldMargin =>
Retail is > 0 && OldCost != null ? (Retail.Value - OldCost.Value) / Retail.Value * 100 : null;
}
// Builds a posbdat cost batch from a saved invoice, the same shape the store's
// other importers produce (importUNFI\dataprocessor.asp, Batches\dataprocessor.asp)
// so sp_ApplyBatch can apply it unchanged.
//
// The column list is read from INFORMATION_SCHEMA rather than hard-coded: these
// two tables carry ~70 columns that differ between installs, and a single wrong
// name inside the save transaction would block every invoice from saving.
// Whatever Products and ProductBatches genuinely have in common is copied
// across; cost and the audit columns are the only ones set explicitly.
public class BatchService
{
private readonly TablesConfig _t;
private readonly BatchConfig _cfg;
private readonly object _gate = new();
private bool _schemaLoaded;
private bool _usable;
private string _unusableReason = "";
private HashSet<string> _batchCols = new(StringComparer.OrdinalIgnoreCase);
private Dictionary<string, ColumnInfo> _headerInfo = new(StringComparer.OrdinalIgnoreCase);
private List<string> _copyCols = new();
private int _batchNoLen = 25;
private bool HasHeader(string col) => _headerInfo.ContainsKey(col);
// Set explicitly on every batch row, so they are never copied from Products.
private static readonly string[] Explicit =
{ "BatchNo", "cost", "Created", "CreatedBy", "WhoCreated", "modified", "cost_modified", "modifiedby" };
public BatchService(AppConfig cfg)
{
_t = cfg.Tables;
_cfg = cfg.Batch;
}
public bool Enabled => _cfg.Enabled;
// ---- candidate selection ----
/// Reads the current cost, pack and retail for the invoiced UPCs and returns
/// the rows whose invoiced unit cost is a real change. An item with no cost
/// on file counts as a change; a row with no invoiced cost never does.
public List<BatchCandidate> FindCostChanges(
SqlConnection conn, SqlTransaction tx, IEnumerable<(string Upc, string Desc, double CaseCost, double? Pack)> lines)
{
var wanted = new Dictionary<string, (string Desc, double CaseCost, double? Pack)>(StringComparer.Ordinal);
foreach (var (upc, desc, caseCost, pack) in lines)
{
if (upc == "" || caseCost <= 0) continue;
if (!wanted.ContainsKey(upc)) wanted[upc] = (desc, caseCost, pack);
else Log.Warn("batch", $"upc {upc} appears more than once on this invoice - first row wins");
}
if (wanted.Count == 0) return new List<BatchCandidate>();
var products = ReadProducts(conn, tx, wanted.Keys);
var result = new List<BatchCandidate>();
foreach (var (upc, line) in wanted)
{
if (!products.TryGetValue(upc, out var p))
{
Log.Info("batch", $"upc {upc} is not in the product file - no batch row");
continue;
}
// The invoice bills by the case, the product file costs by the unit.
var pack = line.Pack ?? p.Pack;
if (pack == null || pack <= 0) pack = 1;
var newCost = Math.Round(line.CaseCost / pack.Value, 4);
bool changed = p.Cost == null || p.Cost.Value == 0
? true
: Math.Abs(newCost - p.Cost.Value) >= _cfg.MinCostDelta;
if (!changed) continue;
result.Add(new BatchCandidate(upc, line.Desc, newCost, p.Cost, p.Retail, pack));
}
return result;
}
public int FlaggedCount(IEnumerable<BatchCandidate> items) =>
items.Count(c => c.NewMargin != null && c.NewMargin.Value < _cfg.MarginThreshold);
public double MarginThreshold => _cfg.MarginThreshold;
private record ProductRow(double? Cost, double? Pack, double? Retail);
private Dictionary<string, ProductRow> ReadProducts(
SqlConnection conn, SqlTransaction tx, IEnumerable<string> upcs)
{
var result = new Dictionary<string, ProductRow>(StringComparer.Ordinal);
var list = upcs.ToList();
// SQL Server caps a statement at 2100 parameters; invoices are capped at
// 500 lines, so one chunk of 500 is always enough, but keep it explicit.
for (int offset = 0; offset < list.Count; offset += 500)
{
var chunk = list.Skip(offset).Take(500).ToList();
var names = new StringBuilder();
using var cmd = conn.CreateCommand();
cmd.Transaction = tx;
for (int i = 0; i < chunk.Count; i++)
{
if (i > 0) names.Append(',');
names.Append("@u").Append(i);
cmd.Parameters.AddWithValue("@u" + i, chunk[i]);
}
cmd.CommandText =
$"SELECT upc, cost, pack, {ProductSql.Retail} FROM {_t.ProductsTable} " +
$"WHERE upc IN ({names})";
using var r = cmd.ExecuteReader();
while (r.Read())
result[Db.GetStr(r, "upc") ?? ""] =
new ProductRow(Db.GetNum(r, "cost"), Db.GetNum(r, "pack"), Db.GetNum(r, "retail"));
}
return result;
}
// ---- batch creation ----
/// Creates the header and one detail row per changed item. Returns the
/// BatchNo actually used, or null when there is nothing to write or the
/// batch tables are not usable on this install.
public string? Create(SqlConnection conn, SqlTransaction tx, string invoiceNumber,
string vendorId, IReadOnlyList<BatchCandidate> items)
{
if (!_cfg.Enabled || items.Count == 0) return null;
EnsureSchema(conn, tx);
if (!_usable)
{
Log.Warn("batch", "skipped - " + _unusableReason);
return null;
}
var batchNo = UniqueBatchNo(conn, tx, invoiceNumber);
var description = Clip($"Invoice {invoiceNumber} vendor {vendorId} cost change", HeaderLen("Description", 50));
InsertHeader(conn, tx, batchNo, description, items.Count);
int written = 0;
foreach (var item in items)
{
if (InsertDetail(conn, tx, batchNo, item)) written++;
else Log.Warn("batch", $"{batchNo}: upc {item.Upc} produced no batch row");
}
if (written == 0)
{
using var del = Cmd(conn, tx, $"DELETE FROM {_t.ProductBatchesHeaderTable} WHERE BatchNo = @b");
del.Parameters.AddWithValue("@b", batchNo);
del.ExecuteNonQuery();
Log.Warn("batch", $"{batchNo}: no rows written, header removed");
return null;
}
if (HasHeader("TotalItems"))
{
using var upd = Cmd(conn, tx,
$"UPDATE {_t.ProductBatchesHeaderTable} SET TotalItems = @n WHERE BatchNo = @b");
upd.Parameters.AddWithValue("@n", written);
upd.Parameters.AddWithValue("@b", batchNo);
upd.ExecuteNonQuery();
}
Log.Info("batch", $"created {batchNo} with {written} of {items.Count} cost change(s)");
return batchNo;
}
private string UniqueBatchNo(SqlConnection conn, SqlTransaction tx, string invoiceNumber)
{
var baseName = Clip(_cfg.NamePrefix + invoiceNumber, _batchNoLen);
for (int n = 0; n < 100; n++)
{
var candidate = n == 0
? baseName
: Clip(baseName, _batchNoLen - ("_" + n).Length) + "_" + n;
using var cmd = Cmd(conn, tx,
$"SELECT TOP 1 BatchNo FROM {_t.ProductBatchesHeaderTable} WITH (UPDLOCK, HOLDLOCK) WHERE BatchNo = @b");
cmd.Parameters.AddWithValue("@b", candidate);
if (cmd.ExecuteScalar() == null) return candidate;
}
throw new Exception("Could not find a free batch number for invoice " + invoiceNumber);
}
private void InsertHeader(SqlConnection conn, SqlTransaction tx, string batchNo, string description, int count)
{
var cols = new List<string> { "BatchNo" };
var vals = new List<string> { "@b" };
void Add(string col, string val) { if (HasHeader(col)) { cols.Add(col); vals.Add(val); } }
Add("Description", "@d");
// Same shape the other batch producers write (PricingFiles, Rewards
// dataprocessor.asp): an all-day window on today's date, priority 1,
// type PB, operator 888.
Add("StartDate", "GETDATE()");
Add("EndDate", "GETDATE()");
Add("StartTime", "'1900-01-01 00:00:00.000'");
Add("EndTime", "'1900-01-01 23:59:00.000'");
Add("Priority", "1");
Add("Type", "'PB'");
Add("WhoApplied", "888");
Add("WhoCreated", "888");
Add("Created", "GETDATE()");
Add("Createdby", "@by");
Add("Modified", "GETDATE()");
Add("Modifiedby", "@by");
Add("TotalItems", "@n");
using var cmd = Cmd(conn, tx,
$"INSERT INTO {_t.ProductBatchesHeaderTable} ([{string.Join("],[", cols)}]) " +
$"VALUES ({string.Join(",", vals)})");
cmd.Parameters.AddWithValue("@b", batchNo);
if (cols.Contains("Description")) cmd.Parameters.AddWithValue("@d", description);
if (vals.Contains("@by")) cmd.Parameters.AddWithValue("@by", Clip(_cfg.CreatedBy, 20));
if (cols.Contains("TotalItems")) cmd.Parameters.AddWithValue("@n", count);
cmd.ExecuteNonQuery();
}
private bool InsertDetail(SqlConnection conn, SqlTransaction tx, string batchNo, BatchCandidate item)
{
var cols = new List<string> { "BatchNo" };
var sel = new List<string> { "@b" };
foreach (var c in _copyCols)
{
cols.Add(c);
sel.Add("p." + c);
}
void Add(string col, string val) { if (_batchCols.Contains(col)) { cols.Add(col); sel.Add(val); } }
Add("cost", "@cost");
Add("Created", "GETDATE()");
Add("CreatedBy", "@by");
Add("modified", "GETDATE()");
Add("cost_modified", "GETDATE()");
Add("modifiedby", "@by");
// The pack the margin was worked out from is the pack the batch should
// carry, otherwise applying it re-divides the cost by a stale pack.
if (item.Pack != null)
{
var at = cols.FindIndex(c => string.Equals(c, "pack", StringComparison.OrdinalIgnoreCase));
if (at >= 0) sel[at] = "@pack";
}
using var cmd = Cmd(conn, tx,
$"INSERT INTO {_t.ProductBatchesTable} ({string.Join(",", cols)}) " +
$"SELECT {string.Join(",", sel)} FROM {_t.ProductsTable} p " +
"WHERE p.upc = @upc AND NOT EXISTS (SELECT 1 FROM " + _t.ProductBatchesTable +
" x WHERE x.BatchNo = @b AND x.upc = @upc)");
cmd.Parameters.AddWithValue("@b", batchNo);
cmd.Parameters.AddWithValue("@upc", item.Upc);
if (sel.Contains("@cost")) cmd.Parameters.AddWithValue("@cost", item.NewCost);
if (sel.Contains("@by")) cmd.Parameters.AddWithValue("@by", Clip(_cfg.CreatedBy, 20));
if (sel.Contains("@pack")) cmd.Parameters.AddWithValue("@pack", item.Pack!.Value);
return cmd.ExecuteNonQuery() > 0;
}
// ---- schema discovery ----
private void EnsureSchema(SqlConnection conn, SqlTransaction tx)
{
lock (_gate)
{
if (_schemaLoaded) return;
_schemaLoaded = true;
try
{
var batch = ReadColumns(conn, tx, _t.ProductBatchesTable);
var header = ReadColumns(conn, tx, _t.ProductBatchesHeaderTable);
var products = ReadColumns(conn, tx, _t.ProductsTable);
_batchCols = new HashSet<string>(batch.Keys, StringComparer.OrdinalIgnoreCase);
_headerInfo = header;
if (_batchCols.Count == 0) { Fail($"{_t.ProductBatchesTable} has no readable columns"); return; }
if (_headerInfo.Count == 0) { Fail($"{_t.ProductBatchesHeaderTable} has no readable columns"); return; }
if (!_batchCols.Contains("BatchNo") || !_batchCols.Contains("upc"))
{ Fail($"{_t.ProductBatchesTable} has no BatchNo/upc column"); return; }
if (!_batchCols.Contains("cost"))
{ Fail($"{_t.ProductBatchesTable} has no cost column - a cost batch cannot be built"); return; }
if (!HasHeader("BatchNo"))
{ Fail($"{_t.ProductBatchesHeaderTable} has no BatchNo column"); return; }
// Everything the two tables share, except what this batch sets
// itself and anything the server generates.
_copyCols = batch
.Where(c => !c.Value.Generated)
.Select(c => c.Key)
.Where(name => products.ContainsKey(name) && !products[name].Generated)
.Where(name => !Explicit.Contains(name, StringComparer.OrdinalIgnoreCase))
.OrderBy(n => n, StringComparer.OrdinalIgnoreCase)
.ToList();
if (header.TryGetValue("BatchNo", out var bn) && bn.MaxLen > 0) _batchNoLen = bn.MaxLen;
_usable = true;
Log.Info("batch", $"schema ok: copying {_copyCols.Count} column(s) from " +
$"{_t.ProductsTable} into {_t.ProductBatchesTable}, BatchNo max {_batchNoLen}");
}
catch (Exception ex)
{
// A dropped connection should not disable batches for the life
// of the process - let the next save probe again.
_schemaLoaded = false;
Fail("schema probe failed: " + ex.Message);
Log.Error("batch", "schema probe failed", ex);
}
}
}
private void Fail(string reason)
{
_usable = false;
_unusableReason = reason;
}
private record ColumnInfo(int MaxLen, bool Generated);
// Identity and computed columns cannot appear in an INSERT column list.
private static Dictionary<string, ColumnInfo> ReadColumns(
SqlConnection conn, SqlTransaction tx, string qualifiedTable)
{
var (db, schema, table) = SplitName(qualifiedTable);
var catalog = db == "" ? "" : "[" + db + "].";
using var cmd = Cmd(conn, tx,
$"SELECT c.COLUMN_NAME, ISNULL(c.CHARACTER_MAXIMUM_LENGTH, 0) AS MaxLen, " +
" ISNULL(COLUMNPROPERTY(OBJECT_ID(QUOTENAME(c.TABLE_CATALOG) + '.' + QUOTENAME(c.TABLE_SCHEMA) + '.' + QUOTENAME(c.TABLE_NAME)), c.COLUMN_NAME, 'IsIdentity'), 0)" +
" + ISNULL(COLUMNPROPERTY(OBJECT_ID(QUOTENAME(c.TABLE_CATALOG) + '.' + QUOTENAME(c.TABLE_SCHEMA) + '.' + QUOTENAME(c.TABLE_NAME)), c.COLUMN_NAME, 'IsComputed'), 0) AS Generated " +
$"FROM {catalog}INFORMATION_SCHEMA.COLUMNS c " +
"WHERE c.TABLE_SCHEMA = @s AND c.TABLE_NAME = @t");
cmd.Parameters.AddWithValue("@s", schema);
cmd.Parameters.AddWithValue("@t", table);
var result = new Dictionary<string, ColumnInfo>(StringComparer.OrdinalIgnoreCase);
using var r = cmd.ExecuteReader();
while (r.Read())
result[r.GetString(0)] = new ColumnInfo(Convert.ToInt32(r.GetValue(1)), Convert.ToInt32(r.GetValue(2)) > 0);
return result;
}
// "posbdat.dbo.ProductBatches" -> (posbdat, dbo, ProductBatches).
// "Products" -> ("", dbo, Products) which resolves in the connection's own database.
private static (string Db, string Schema, string Table) SplitName(string qualified)
{
var parts = qualified.Split('.').Select(p => p.Trim('[', ']')).ToArray();
return parts.Length switch
{
>= 3 => (parts[^3], parts[^2], parts[^1]),
2 => ("", parts[0], parts[1]),
_ => ("", "dbo", parts[0])
};
}
private int HeaderLen(string col, int fallback) =>
_headerInfo.TryGetValue(col, out var info) && info.MaxLen > 0 ? info.MaxLen : fallback;
private static string Clip(string s, int n) =>
n <= 0 || s.Length <= n ? s : s[..n];
private static SqlCommand Cmd(SqlConnection conn, SqlTransaction tx, string sql)
{
var cmd = conn.CreateCommand();
cmd.Transaction = tx;
cmd.CommandText = sql;
return cmd;
}
}

609
src/DataService.cs Normal file
View file

@ -0,0 +1,609 @@
using System.Globalization;
using System.IO;
using System.Text.Json.Nodes;
using Microsoft.Data.SqlClient;
namespace InvoiceParser.Src;
// Ports of the small read endpoints: vendors.asp, polist.asp, podetail.asp,
// newproduct.asp, archive.asp (list/detail/getfile), pending_stats.asp.
public class DataService
{
private readonly Db _db;
private readonly AppConfig _cfg;
private readonly TablesConfig _t;
public DataService(Db db, AppConfig cfg)
{
_db = db;
_cfg = cfg;
_t = cfg.Tables;
}
public JsonArray Vendors()
{
var arr = new JsonArray();
using var conn = _db.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText =
$"SELECT {_t.VendorIdCol}, {_t.VendorNameCol} FROM {_t.VendorTable} ORDER BY {_t.VendorNameCol}";
using var r = cmd.ExecuteReader();
while (r.Read())
arr.Add(new JsonObject
{
["id"] = Convert.ToString(r.GetValue(0)),
["name"] = Convert.ToString(r.GetValue(1))
});
return arr;
}
public JsonArray PoList()
{
var arr = new JsonArray();
try
{
using var conn = _db.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText =
"SELECT TOP 500 h.PONumber, CONVERT(VARCHAR(10), h.PODate, 120) AS PODate," +
$" ISNULL(v.{_t.VendorNameCol},'') AS VendorName" +
$" FROM {_t.PoHeaderTable} h" +
$" LEFT JOIN {_t.VendorTable} v ON v.{_t.VendorIdCol} = h.Vendor" +
" ORDER BY h.PONumber DESC";
using var r = cmd.ExecuteReader();
while (r.Read())
arr.Add(new JsonObject
{
["po_number"] = Db.GetStr(r, "PONumber") ?? "",
["po_date"] = Db.GetStr(r, "PODate") ?? "",
["vendor_name"] = Db.GetStr(r, "VendorName") ?? ""
});
}
catch { /* PO tables are optional - mirror polist.asp returning [] */ }
return arr;
}
public JsonArray PoDetail(string poNumber)
{
var arr = new JsonArray();
if (poNumber == "") return arr;
try
{
using var conn = _db.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText =
"SELECT UPC, Cert_code, Description, Cost, Total, QuantityCase, QuantityPieces, Size, UnitOfMeasure" +
$" FROM {_t.PoDetailTable} WHERE PONumber = @po ORDER BY UPC";
cmd.Parameters.AddWithValue("@po", poNumber);
using var r = cmd.ExecuteReader();
while (r.Read())
arr.Add(new JsonObject
{
["upc"] = Db.GetStr(r, "UPC") ?? "",
["cert_code"] = Db.GetStr(r, "Cert_code") ?? "",
["description"] = Db.GetStr(r, "Description") ?? "",
["cost"] = Db.GetNum(r, "Cost"),
["qty"] = Db.GetNum(r, "QuantityCase"),
["qty_pieces"] = Db.GetNum(r, "QuantityPieces"),
["size"] = Db.GetStr(r, "Size") ?? "",
["uom"] = Db.GetStr(r, "UnitOfMeasure") ?? ""
});
}
catch { }
return arr;
}
public JsonArray Departments()
{
var arr = new JsonArray();
using var conn = _db.Open();
using var cmd = conn.CreateCommand();
// dept_tax / dept_fs drive the tax + food stamp defaults on new products,
// the same way newitem/product-edit-processor.asp seeds them.
// dept_sub = 0 only. Departments is keyed on (dept_no, dept_sub), but
// Products.department stores a bare dept_no - there is no way to point a
// product at a sub-department. Without this filter dept_no 1 returns two
// rows (GROCERY-NO TAX sub 0, GROCERY TAXABLE sub 1) that collide on the
// same option value, and the second one silently wins the tax/fs lookup.
// sql_GetAllDepartments() in swapps includes/SiteSQL.asp filters the same way.
cmd.CommandText =
$"SELECT dept_no, dept_name, ISNULL(dept_tax,0) AS dept_tax, ISNULL(dept_fs,0) AS dept_fs " +
$"FROM {_t.DepartmentsTable} WHERE dept_sub = 0 ORDER BY dept_name";
using var r = cmd.ExecuteReader();
while (r.Read())
arr.Add(new JsonObject
{
["id"] = Convert.ToInt64(r.GetValue(0)),
["name"] = Convert.ToString(r.GetValue(1)),
["tax"] = Convert.ToInt32(r.GetValue(2)) != 0 ? 1 : 0,
["fs"] = Convert.ToInt32(r.GetValue(3)) != 0 ? 1 : 0
});
return arr;
}
public JsonObject CreateProduct(string upcRaw, string desc, string price, string deptId,
string cost, string itemCode, string tax, string foodstamp,
string scale, string wicable, string pack, string size)
{
var rawDigits = Util.CleanDigits(upcRaw);
var upc = Util.NormalizeUpcForStorage(rawDigits);
desc = Truncate(desc.Trim(), 30);
itemCode = Truncate(itemCode.Trim(), 15);
size = Truncate(size.Trim(), 9);
if (rawDigits == "" || rawDigits.Replace("0", "") == "" || rawDigits.Length > 13 || desc == "")
return Fail("UPC, Description, and Price are required");
// Department drives tax, foodstamp, attributes and validage, so a blank
// one silently produces an untaxed, un-carded item. product-edit-processor.asp
// rejects it for the same reason.
if (!long.TryParse(deptId, out var deptNo) || deptNo <= 0)
return Fail("Department is required");
// Price takes either a plain amount or the POS group form "n/total"
// ("2/5.00" = 2 for $5.00), the syntax product-edit-processor.asp parses.
// Group pricing leaves normal_price NULL and carries the price in
// groupprice/quantity - 898 active products are stored that way.
int priceMethod = 0;
double normalPrice = 0, groupPrice = 0;
long groupQty = 0;
price = price.Trim();
var slash = price.IndexOf('/');
if (slash >= 0)
{
var qtyPart = price[..slash].Trim();
var amtPart = price[(slash + 1)..].Trim();
if (!long.TryParse(qtyPart, NumberStyles.Integer, CultureInfo.InvariantCulture, out groupQty) ||
groupQty <= 0 || groupQty > short.MaxValue)
return Fail("Group price quantity must be a whole number from 1 to 32767 (e.g. 2/5.00)");
if (!double.TryParse(amtPart, NumberStyles.Any, CultureInfo.InvariantCulture, out groupPrice) ||
groupPrice <= 0)
return Fail("Group price amount must be greater than zero (e.g. 2/5.00)");
priceMethod = 1;
}
else
{
if (!double.TryParse(price, NumberStyles.Any, CultureInfo.InvariantCulture, out normalPrice))
return Fail("UPC, Description, and Price are required");
if (normalPrice < 0) return Fail("Price cannot be negative");
}
double dCost = 0;
if (double.TryParse(cost, NumberStyles.Any, CultureInfo.InvariantCulture, out var c)) dCost = c;
if (dCost < 0) return Fail("Cost cannot be negative");
using var conn = _db.Open();
using (var check = conn.CreateCommand())
{
check.CommandText = $"SELECT TOP 1 description FROM {_t.ProductsTable} WHERE upc = @upc";
check.Parameters.AddWithValue("@upc", upc);
var existing = check.ExecuteScalar();
if (existing != null) return Fail("UPC already exists: " + existing);
}
// Age restriction is inherited from the department, exactly as
// newitem/product-edit-processor.asp does it: a department flagged in
// attributes passes its validage down and marks the product as carded.
// Nothing in this UI exposes these, so missing them would quietly create
// beer and tobacco items that never prompt at the register.
int attributes = 0, validAge = 0;
using (var dept = conn.CreateCommand())
{
dept.CommandText =
$"SELECT TOP 1 ISNULL(attributes,0) AS attributes, ISNULL(validage,0) AS validage " +
$"FROM {_t.DepartmentsTable} WHERE dept_no = @dept AND dept_sub = 0";
dept.Parameters.AddWithValue("@dept", deptNo);
using var dr = dept.ExecuteReader();
if (dr.Read() && (Convert.ToInt32(dr.GetValue(0)) & 1) != 0)
{
attributes = 2;
validAge = Convert.ToInt32(dr.GetValue(1));
}
}
using var cmd = conn.CreateCommand();
cmd.CommandText =
$"INSERT INTO {_t.ProductsTable} " +
"(upc,cert_code,description,cost,normal_price,pricemethod,groupprice,quantity," +
"special_price,specialcost,specialpricemethod,specialgroupprice,specialquantity," +
"start_date,end_date,target_margin,department,section,vendor,pack,size,tax," +
"foodstamp,scale,dsd,upc_link,mixmatchcode,modified,cost_modified,active,advertised," +
"whomodified,picture_name,longdescription,seconddescription,tareweight,discount," +
"unitofmeasure,effectiveschedule,wicable,validage,deleted,currentbatchno,maxdiscount," +
"attributes,QualifiedPercent,QualifiedFlatAmount,TOB,Created,CreatedBy,Points," +
"groupprice2,quantity2,groupprice3,quantity3,groupprice4,quantity4,groupprice5,quantity5," +
"Specialgroupprice2,Specialquantity2,Specialgroupprice3,Specialquantity3," +
"Specialgroupprice4,Specialquantity4,Specialgroupprice5,Specialquantity5," +
"StartTime,EndTime,LookupRanking,FSACategory,CGID,CGPurchaseRequired,PLU,IsLotto) " +
"VALUES (" +
"@upc,@cert,@desc,@cost,@price," +
"@pricemethod,@groupprice,@quantity,NULL,NULL,NULL,NULL,NULL,NULL,NULL,0," +
"@dept,NULL,0,@pack,@size,@tax,@foodstamp,@scale," +
"0,NULL,NULL,getdate(),getdate(),1,0," +
"'111',NULL,NULL,NULL,NULL,0,NULL,NULL,@wicable," +
"@validage,0,NULL,0,@attributes,NULL,NULL,NULL,getdate(),111,0," +
"NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL," +
"NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL," +
"NULL,NULL,NULL,NULL,NULL,NULL,NULL,0)";
cmd.Parameters.AddWithValue("@upc", upc);
cmd.Parameters.AddWithValue("@cert", itemCode == "" ? DBNull.Value : itemCode);
cmd.Parameters.AddWithValue("@desc", desc);
cmd.Parameters.AddWithValue("@cost", dCost);
cmd.Parameters.AddWithValue("@price", priceMethod == 1 ? DBNull.Value : normalPrice);
cmd.Parameters.AddWithValue("@pricemethod", priceMethod);
cmd.Parameters.AddWithValue("@groupprice", priceMethod == 1 ? groupPrice : DBNull.Value);
cmd.Parameters.AddWithValue("@quantity", priceMethod == 1 ? groupQty : DBNull.Value);
cmd.Parameters.AddWithValue("@dept", deptNo);
cmd.Parameters.AddWithValue("@validage", validAge);
cmd.Parameters.AddWithValue("@attributes", attributes);
cmd.Parameters.AddWithValue("@pack",
double.TryParse(pack, NumberStyles.Any, CultureInfo.InvariantCulture, out var p) ? p : DBNull.Value);
cmd.Parameters.AddWithValue("@size", size == "" ? DBNull.Value : size);
cmd.Parameters.AddWithValue("@tax", tax == "1" ? 1 : 0);
cmd.Parameters.AddWithValue("@foodstamp", foodstamp == "1" ? 1 : 0);
cmd.Parameters.AddWithValue("@scale", scale == "1" ? 1 : 0);
cmd.Parameters.AddWithValue("@wicable", wicable == "1" ? 1 : 0);
try { cmd.ExecuteNonQuery(); }
catch (Exception ex) { return Fail(ex.Message); }
// Hand back the effective shelf retail so the row's margin is right
// immediately - for a group price that is the per-unit slice, not the total.
return new JsonObject
{
["success"] = true,
["upc"] = upc,
["description"] = desc,
["retail"] = priceMethod == 1 ? groupPrice / groupQty : normalPrice
};
}
// ---- archive ----
public JsonArray ArchiveList(string vendor, string inv, string po, string dateFrom,
string dateTo, string amtMin, string amtMax, string product)
{
var arr = new JsonArray();
using var conn = _db.Open();
using var cmd = conn.CreateCommand();
var where = " WHERE 1=1";
if (vendor != "")
{
where += " AND h.Vendor = @vendor";
cmd.Parameters.AddWithValue("@vendor", vendor);
}
if (inv != "")
{
where += " AND h.InvoiceNumber LIKE @inv";
cmd.Parameters.AddWithValue("@inv", "%" + Util.EscapeLike(Truncate(inv, 30)) + "%");
}
if (po != "")
{
where += " AND h.PONumber LIKE @po";
cmd.Parameters.AddWithValue("@po", "%" + Util.EscapeLike(Truncate(po, 30)) + "%");
}
// Typed DateTime parameters: 'yyyy-MM-dd' text is DATEFORMAT dependent.
if (Util.IsIsoDate(dateFrom))
{
where += " AND h.InvoiceDate >= @dateFrom";
cmd.Parameters.AddWithValue("@dateFrom", DateTime.ParseExact(dateFrom, "yyyy-MM-dd", CultureInfo.InvariantCulture));
}
if (Util.IsIsoDate(dateTo))
{
where += " AND h.InvoiceDate < @dateTo";
cmd.Parameters.AddWithValue("@dateTo", DateTime.ParseExact(dateTo, "yyyy-MM-dd", CultureInfo.InvariantCulture).AddDays(1));
}
if (amtMin != "" && double.TryParse(amtMin, NumberStyles.Any, CultureInfo.InvariantCulture, out var mn))
{
where += " AND ISNULL(h.VendorTotal,0) >= @amtMin";
cmd.Parameters.AddWithValue("@amtMin", mn);
}
if (amtMax != "" && double.TryParse(amtMax, NumberStyles.Any, CultureInfo.InvariantCulture, out var mx))
{
where += " AND ISNULL(h.VendorTotal,0) <= @amtMax";
cmd.Parameters.AddWithValue("@amtMax", mx);
}
if (product != "")
{
where += $" AND EXISTS (SELECT 1 FROM {_t.InvoiceDetailTable} d" +
" WHERE d.PONumber = h.PONumber" +
" AND (d.UPC LIKE @product OR d.Cert_code LIKE @product OR d.Description LIKE @product))";
cmd.Parameters.AddWithValue("@product", "%" + Util.EscapeLike(Truncate(product, 80)) + "%");
}
cmd.CommandText =
"SELECT TOP 500 h.InvoiceNumber, CONVERT(VARCHAR(10), h.InvoiceDate, 120) AS InvDate," +
" h.Vendor, ISNULL(h.VendorTotal,0) AS VendorTotal," +
$" ISNULL(v.{_t.VendorNameCol},'Unknown') AS VendorName" +
$" FROM {_t.InvoiceHeaderTable} h" +
$" LEFT JOIN {_t.VendorTable} v ON v.{_t.VendorIdCol} = h.Vendor" +
where +
" ORDER BY h.InvoiceDate DESC, h.InvoiceNumber DESC";
using var r = cmd.ExecuteReader();
while (r.Read())
arr.Add(new JsonObject
{
["invoice_number"] = Db.GetStr(r, "InvoiceNumber") ?? "",
["invoice_date"] = Db.GetStr(r, "InvDate") ?? "",
["vendor"] = Db.GetStr(r, "Vendor") ?? "",
["vendor_total"] = Db.GetNum(r, "VendorTotal"),
["vendor_name"] = Db.GetStr(r, "VendorName") ?? ""
});
return arr;
}
public JsonObject ArchiveDetail(string inv, string vendor)
{
if (inv == "" || vendor == "")
return new JsonObject { ["error"] = "Missing parameters" };
using var conn = _db.Open();
JsonObject header;
using (var cmd = conn.CreateCommand())
{
cmd.CommandText =
"SELECT TOP 1 h.InvoiceNumber, CONVERT(VARCHAR(10), h.InvoiceDate, 120) AS InvDate," +
" ISNULL(h.PONumber,'') AS PONumber, ISNULL(h.VendorTotal,0) AS VendorTotal," +
$" ISNULL(v.{_t.VendorNameCol},'') AS VendorName" +
$" FROM {_t.InvoiceHeaderTable} h" +
$" LEFT JOIN {_t.VendorTable} v ON v.{_t.VendorIdCol} = h.Vendor" +
" WHERE h.InvoiceNumber = @inv AND h.Vendor = @vendor" +
" ORDER BY h.InvoiceDate DESC";
cmd.Parameters.AddWithValue("@inv", inv);
cmd.Parameters.AddWithValue("@vendor", vendor);
using var r = cmd.ExecuteReader();
if (!r.Read()) return new JsonObject { ["error"] = "Invoice not found" };
header = new JsonObject
{
["invoice_number"] = Db.GetStr(r, "InvoiceNumber") ?? "",
["invoice_date"] = Db.GetStr(r, "InvDate") ?? "",
["po_number"] = Db.GetStr(r, "PONumber") ?? "",
["vendor_total"] = Db.GetNum(r, "VendorTotal"),
["vendor_name"] = Db.GetStr(r, "VendorName") ?? ""
};
}
// Line items come back in the order they were entered, the same order
// the review screen shows them in, not sorted by UPC. The detail table
// has no line number column, so its identity column is the insertion
// order; without one there is nothing to recover and UPC is the only
// stable fallback.
var orderBy = DetailOrderColumn(conn);
var items = new JsonArray();
using (var cmd = conn.CreateCommand())
{
cmd.CommandText =
"SELECT d.UPC, d.Cert_code, d.Description, d.Cost, d.Total, d.QuantityCase, d.Size, d.UnitOfMeasure" +
$" FROM {_t.InvoiceDetailTable} d" +
$" JOIN {_t.InvoiceHeaderTable} h ON h.PONumber = d.PONumber" +
$" WHERE h.InvoiceNumber = @inv AND h.Vendor = @vendor ORDER BY d.[{orderBy}]";
cmd.Parameters.AddWithValue("@inv", inv);
cmd.Parameters.AddWithValue("@vendor", vendor);
using var r = cmd.ExecuteReader();
while (r.Read())
items.Add(new JsonObject
{
["upc"] = Db.GetStr(r, "UPC") ?? "",
["cert"] = Db.GetStr(r, "Cert_code") ?? "",
["desc"] = Db.GetStr(r, "Description") ?? "",
["cost"] = Db.GetNum(r, "Cost"),
["total"] = Db.GetNum(r, "Total"),
["qty"] = Db.GetNum(r, "QuantityCase"),
["size"] = Db.GetStr(r, "Size") ?? "",
["uom"] = Db.GetStr(r, "UnitOfMeasure") ?? ""
});
}
header["items"] = items;
header["batch"] = ArchiveBatch(conn, inv, vendor);
return header;
}
// The identity column of InvoiceDetail, discovered once. Installs differ on
// its name (RECID, ID, DetailID...), and hard-coding a guess would break
// every archive detail view on the ones that named it something else.
private string? _detailOrderCol;
private string DetailOrderColumn(SqlConnection conn)
{
if (_detailOrderCol != null) return _detailOrderCol;
try
{
// sys.identity_columns is database-scoped. The detail table lives in
// posinv while the connection is open on posbdat, so the probe has
// to read the catalog view of the table's own database or it always
// comes back empty and every archive falls back to UPC order.
using var cmd = conn.CreateCommand();
cmd.CommandText =
$"SELECT TOP 1 c.name FROM {CatalogPrefix(_t.InvoiceDetailTable)}sys.identity_columns c " +
$"WHERE c.object_id = OBJECT_ID(@t)";
cmd.Parameters.AddWithValue("@t", _t.InvoiceDetailTable);
_detailOrderCol = Convert.ToString(cmd.ExecuteScalar()) ?? "";
}
catch (Exception ex)
{
Log.Warn("archive", "identity column probe failed, ordering by UPC: " + ex.Message);
_detailOrderCol = "";
}
if (_detailOrderCol == "")
{
Log.Info("archive", $"{_t.InvoiceDetailTable} has no identity column - ordering line items by UPC");
_detailOrderCol = "UPC";
}
else Log.Info("archive", $"ordering line items by {_t.InvoiceDetailTable}.{_detailOrderCol}");
return _detailOrderCol;
}
// "posinv.dbo.InvoiceDetail" -> "[posinv].", an unqualified name -> "" so
// the catalog view resolves in the connection's own database.
private static string CatalogPrefix(string qualified)
{
var parts = qualified.Split('.');
return parts.Length >= 3 ? "[" + parts[^3].Trim('[', ']') + "]." : "";
}
// The price/cost batch this invoice produced, if any, plus its rows. Missing
// batch tables are not an error - a store that does not use batches still
// gets its archive.
private JsonNode? ArchiveBatch(SqlConnection conn, string inv, string vendor)
{
if (!_cfg.Batch.Enabled) return null;
try
{
var baseName = _cfg.Batch.NamePrefix + inv;
JsonObject? head = null;
using (var cmd = conn.CreateCommand())
{
// Two vendors can share an invoice number, so a collision is
// parked as BatchNo_1; the description carries the vendor.
cmd.CommandText =
"SELECT TOP 10 BatchNo, Description, CONVERT(VARCHAR(19), Created, 120) AS Created" +
$" FROM {_t.ProductBatchesHeaderTable}" +
" WHERE BatchNo = @exact OR BatchNo LIKE @like" +
" ORDER BY BatchNo";
cmd.Parameters.AddWithValue("@exact", baseName);
cmd.Parameters.AddWithValue("@like", Util.EscapeLike(baseName) + "[_]%");
using var r = cmd.ExecuteReader();
var rows = new List<JsonObject>();
while (r.Read())
rows.Add(new JsonObject
{
["batch_no"] = Db.GetStr(r, "BatchNo") ?? "",
["description"] = Db.GetStr(r, "Description") ?? "",
["created"] = Db.GetStr(r, "Created") ?? ""
});
head = rows.FirstOrDefault(x => (x["description"]?.ToString() ?? "").Contains("vendor " + vendor))
?? rows.FirstOrDefault(x => x["batch_no"]?.ToString() == baseName);
}
if (head == null) return null;
var batchNo = head["batch_no"]!.ToString();
var rowsOut = new JsonArray();
using (var cmd = conn.CreateCommand())
{
// Retail is the shelf price off the product file, not off the
// batch row - the batch only carries the new cost.
cmd.CommandText =
"SELECT b.upc, b.cert_code, b.description, b.cost, b.pack, b.size," +
$" p.cost AS current_cost, {ProductSql.RetailFor("p")}" +
$" FROM {_t.ProductBatchesTable} b" +
$" LEFT JOIN {_t.ProductsTable} p ON p.upc = b.upc" +
" WHERE b.BatchNo = @b ORDER BY b.upc";
cmd.Parameters.AddWithValue("@b", batchNo);
using var r = cmd.ExecuteReader();
while (r.Read())
{
var cost = Db.GetNum(r, "cost");
var retail = Db.GetNum(r, "retail");
rowsOut.Add(new JsonObject
{
["upc"] = Db.GetStr(r, "upc") ?? "",
["cert"] = Db.GetStr(r, "cert_code") ?? "",
["desc"] = Db.GetStr(r, "description") ?? "",
["cost"] = cost,
["current_cost"] = Db.GetNum(r, "current_cost"),
["pack"] = Db.GetNum(r, "pack"),
["size"] = Db.GetStr(r, "size") ?? "",
["retail"] = retail,
["margin"] = retail is > 0 && cost != null
? (retail.Value - cost.Value) / retail.Value * 100
: (double?)null
});
}
}
head["items"] = rowsOut;
head["margin_threshold"] = _cfg.Batch.MarginThreshold;
return head;
}
catch (Exception ex)
{
Log.Warn("archive", $"batch lookup for invoice {inv} failed: " + ex.Message);
return null;
}
}
// Locates the archived source file for an invoice, verifying the DB record exists.
public string? ArchiveFile(string inv, string vendor, out string ext)
{
ext = "";
if (inv == "" || vendor == "") return null;
using (var conn = _db.Open())
using (var cmd = conn.CreateCommand())
{
cmd.CommandText =
$"SELECT TOP 1 InvoiceNumber FROM {_t.InvoiceHeaderTable} " +
"WHERE InvoiceNumber = @inv AND Vendor = @vendor";
cmd.Parameters.AddWithValue("@inv", inv);
cmd.Parameters.AddWithValue("@vendor", vendor);
if (cmd.ExecuteScalar() == null) return null;
}
var baseName = Util.SafeFileName(vendor) + "_" + Util.SafeFileName(inv);
return Util.FindInvoiceFile(_cfg.ArchiveRoot, baseName, out ext);
}
// ---- dashboard stats (pending_stats.asp port) ----
public JsonObject Stats(PendingStore store)
{
int queued = 0, processing = 0, parsed = 0, review = 0, editing = 0,
error = 0, other = 0, total = 0, today = 0, pendingLines = 0;
double pendingValue = 0;
var todayStr = DateTime.UtcNow.ToString("yyyy-MM-dd");
foreach (var item in store.List())
{
var state = store.ReadState(item.Id);
if (state == null) continue;
total++;
switch (state["stage"]?.ToString() ?? "")
{
case "queued": case "queue": queued++; break;
case "processing": processing++; break;
case "parsed": parsed++; break;
case "review": review++; break;
case "editing": editing++; break;
case "error": error++; break;
default: other++; break;
}
var created = state["created_at"]?.ToString() ?? "";
if (created.Length >= 10 && created[..10] == todayStr) today++;
var totalStr = state["header"]?["invoice_total"]?.ToString() ?? "";
if (double.TryParse(totalStr, NumberStyles.Any, CultureInfo.InvariantCulture, out var t))
pendingValue += t;
if (state["line_items"] is JsonArray li) pendingLines += li.Count;
}
int archived = Directory.Exists(_cfg.ArchiveRoot)
? Directory.GetFiles(_cfg.ArchiveRoot).Length : 0;
return new JsonObject
{
["queued"] = queued,
["processing"] = processing,
["parsed"] = parsed,
["review"] = review,
["editing"] = editing,
["error"] = error,
["total"] = total,
["ready"] = parsed,
["in_progress"] = review + editing,
["needs_parse"] = queued + processing,
["uploaded_today"] = today,
["archived"] = archived,
["pending_value"] = pendingValue,
["pending_lines"] = pendingLines
};
}
private static string Truncate(string s, int n) => s.Length <= n ? s : s[..n];
private static JsonObject Fail(string error) =>
new() { ["success"] = false, ["error"] = error };
}

49
src/Db.cs Normal file
View file

@ -0,0 +1,49 @@
using Microsoft.Data.SqlClient;
namespace InvoiceParser.Src;
public class Db
{
private readonly AppConfig _cfg;
public Db(AppConfig cfg) => _cfg = cfg;
public SqlConnection Open()
{
var conn = new SqlConnection(_cfg.ConnectionString);
conn.Open();
return conn;
}
public static string? GetStr(SqlDataReader r, string col)
{
var i = r.GetOrdinal(col);
return r.IsDBNull(i) ? null : Convert.ToString(r.GetValue(i));
}
public static double? GetNum(SqlDataReader r, string col)
{
var i = r.GetOrdinal(col);
return r.IsDBNull(i) ? null : Convert.ToDouble(r.GetValue(i));
}
}
// Shared SQL fragments over posbdat..Products.
public static class ProductSql
{
// Shelf retail. pricemethod 0 = normal_price, 1 = group price (n for $x),
// anything else is the average of the two - the convention the POS reports use.
public const string Retail =
"CASE WHEN pricemethod = 1 AND ISNULL(quantity, 0) > 0 THEN groupprice / quantity " +
"WHEN pricemethod > 1 AND ISNULL(quantity, 0) > 0 THEN (groupprice / quantity + ISNULL(normal_price, 0)) / 2 " +
"ELSE normal_price END AS retail";
/// Same expression against a specific table alias, for queries that join
/// Products to something else that also has a normal_price column.
public static string RetailFor(string alias)
{
var a = alias + ".";
return $"CASE WHEN {a}pricemethod = 1 AND ISNULL({a}quantity, 0) > 0 THEN {a}groupprice / {a}quantity " +
$"WHEN {a}pricemethod > 1 AND ISNULL({a}quantity, 0) > 0 THEN ({a}groupprice / {a}quantity + ISNULL({a}normal_price, 0)) / 2 " +
$"ELSE {a}normal_price END AS retail";
}
}

32
src/EventBroadcaster.cs Normal file
View file

@ -0,0 +1,32 @@
using System.Collections.Concurrent;
using System.Threading.Channels;
namespace InvoiceParser.Src;
// Fan-out of queue events to any number of connected SSE clients.
public class EventBroadcaster
{
private readonly ConcurrentDictionary<Guid, Channel<string>> _clients = new();
public (Guid, ChannelReader<string>) Subscribe()
{
var ch = Channel.CreateUnbounded<string>();
var id = Guid.NewGuid();
_clients[id] = ch;
return (id, ch.Reader);
}
public void Unsubscribe(Guid id)
{
if (_clients.TryRemove(id, out var ch)) ch.Writer.TryComplete();
}
public void Publish(object payload)
{
var json = System.Text.Json.JsonSerializer.Serialize(payload);
foreach (var ch in _clients.Values) ch.Writer.TryWrite(json);
}
public void StageChanged(string id, string stage, string? error = null) =>
Publish(new { type = "stage", id, stage, error = error ?? "" });
}

219
src/LlmClient.cs Normal file
View file

@ -0,0 +1,219 @@
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace InvoiceParser.Src;
public class LlmClient
{
private readonly LlmConfig _cfg;
private readonly HttpClient _http;
public LlmClient(LlmConfig cfg)
{
_cfg = cfg;
_http = new HttpClient { Timeout = TimeSpan.FromSeconds(Math.Max(30, cfg.TimeoutSeconds)) };
}
public string TextBackend => _cfg.TextRoute.BackendName;
public string ImageBackend => _cfg.ImageRoute.BackendName;
// Prefix each line with "Ln: " so the LLM can report source_line.
public static string NumberLines(string text)
{
var lines = text.Replace("\r\n", "\n").Split('\n');
var sb = new StringBuilder();
for (int i = 0; i < lines.Length; i++)
{
if (i > 0) sb.Append('\n');
sb.Append('L').Append(i + 1).Append(": ").Append(lines[i]);
}
return sb.ToString();
}
// Text route. With pageImage set the rendered page goes along with its
// numbered text (hybrid mode) - TextRoute must then be a vision model.
public Task<string> ParseTextAsync(string numberedText, (string Mime, string B64)? pageImage, CancellationToken ct)
{
var route = _cfg.TextRoute;
var prompt = pageImage != null ? _cfg.EffectiveHybridPrompt : _cfg.EffectiveTextPrompt;
if (route.IsGemini)
{
var parts = new JsonArray();
if (pageImage != null)
parts.Add(new JsonObject
{
["inline_data"] = new JsonObject { ["mime_type"] = pageImage.Value.Mime, ["data"] = pageImage.Value.B64 }
});
parts.Add(new JsonObject { ["text"] = numberedText });
return SendGeminiAsync(route, parts, prompt, ct);
}
JsonNode userContent;
if (pageImage != null)
{
userContent = new JsonArray
{
new JsonObject
{
["type"] = "image_url",
["image_url"] = new JsonObject { ["url"] = $"data:{pageImage.Value.Mime};base64,{pageImage.Value.B64}" }
},
new JsonObject { ["type"] = "text", ["text"] = numberedText }
};
}
else userContent = numberedText;
var body = new JsonObject
{
["model"] = route.Model,
["temperature"] = 0,
["response_format"] = new JsonObject { ["type"] = "json_object" },
["messages"] = new JsonArray
{
new JsonObject { ["role"] = "system", ["content"] = prompt },
new JsonObject { ["role"] = "user", ["content"] = userContent }
}
};
return SendOpenAiAsync(route, body, ct);
}
// images: (mime, base64) per page.
public Task<string> ParseImagesAsync(List<(string Mime, string B64)> images, CancellationToken ct)
{
var route = _cfg.ImageRoute;
if (route.IsGemini)
{
var parts = new JsonArray();
foreach (var (mime, b64) in images)
parts.Add(new JsonObject
{
["inline_data"] = new JsonObject { ["mime_type"] = mime, ["data"] = b64 }
});
return SendGeminiAsync(route, parts, _cfg.EffectivePrompt, ct);
}
var content = new JsonArray();
foreach (var (mime, b64) in images)
content.Add(new JsonObject
{
["type"] = "image_url",
["image_url"] = new JsonObject { ["url"] = $"data:{mime};base64,{b64}" }
});
content.Add(new JsonObject { ["type"] = "text", ["text"] = _cfg.EffectivePrompt });
var body = new JsonObject
{
["model"] = route.Model,
["temperature"] = 0,
["messages"] = new JsonArray
{
new JsonObject { ["role"] = "user", ["content"] = content }
}
};
return SendOpenAiAsync(route, body, ct);
}
private async Task<string> SendOpenAiAsync(LlmRouteConfig route, JsonObject body, CancellationToken ct)
{
using var req = new HttpRequestMessage(HttpMethod.Post, route.ChatCompletionsUrl());
if (!string.IsNullOrWhiteSpace(route.ApiKey))
req.Headers.TryAddWithoutValidation("Authorization", "Bearer " + route.ApiKey);
req.Content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json");
return await SendAsync(req, ct);
}
private async Task<string> SendGeminiAsync(LlmRouteConfig route, JsonArray parts, string prompt, CancellationToken ct)
{
parts.Add(new JsonObject { ["text"] = prompt });
var body = new JsonObject
{
["contents"] = new JsonArray { new JsonObject { ["parts"] = parts } },
["generationConfig"] = new JsonObject
{
["temperature"] = 0,
["response_mime_type"] = "application/json"
}
};
var url = route.Url.TrimEnd('/') + "/v1beta/models/" + route.Model + ":generateContent";
using var req = new HttpRequestMessage(HttpMethod.Post, url);
if (!string.IsNullOrWhiteSpace(route.ApiKey))
req.Headers.TryAddWithoutValidation("x-goog-api-key", route.ApiKey);
req.Content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json");
return await SendAsync(req, ct);
}
private async Task<string> SendAsync(HttpRequestMessage req, CancellationToken ct)
{
var started = System.Diagnostics.Stopwatch.StartNew();
var target = req.RequestUri?.GetLeftPart(UriPartial.Path) ?? "";
var sent = req.Content?.Headers.ContentLength ?? 0;
HttpResponseMessage resp;
try
{
resp = await _http.SendAsync(req, ct);
}
// HttpClient reports its own timeout as a cancellation. Left as one it
// is indistinguishable from an app shutdown, and the parse worker has
// to treat those differently, so convert it to a plain failure here.
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
Log.Error("llm", $"POST {target} timed out after {started.ElapsedMilliseconds}ms (sent {sent} bytes)");
throw new Exception($"LLM request timed out after {_http.Timeout.TotalSeconds:0}s");
}
catch (Exception ex)
{
Log.Error("llm", $"POST {target} failed after {started.ElapsedMilliseconds}ms (sent {sent} bytes)", ex);
throw;
}
using var _ = resp;
var text = await resp.Content.ReadAsStringAsync(ct);
if (!resp.IsSuccessStatusCode)
{
Log.Error("llm", $"POST {target} -> {(int)resp.StatusCode} {resp.ReasonPhrase} " +
$"in {started.ElapsedMilliseconds}ms: {Log.Clip(text)}");
throw new Exception($"endpoint returned {(int)resp.StatusCode} {resp.ReasonPhrase}: {Truncate(text, 300)}");
}
Log.Info("llm", $"POST {target} -> 200 in {started.ElapsedMilliseconds}ms " +
$"(sent {sent} bytes, got {text.Length} chars)");
return text;
}
private static string Truncate(string s, int n) => s.Length <= n ? s : s[..n];
// Unwraps the provider response (OpenAI or Gemini shape) down to the
// invoice JSON the model produced. Throws with a useful message otherwise.
public static JsonNode ExtractInvoiceData(JsonNode api)
{
var err = api["error"];
if (err != null)
{
var msg = err is JsonValue ? err.ToString() : err["message"]?.ToString() ?? "LLM error";
throw new Exception(msg);
}
string? content = null;
if (api["choices"] is JsonArray choices && choices.Count > 0)
content = choices[0]?["message"]?["content"]?.ToString();
else if (api["candidates"] is JsonArray cands && cands.Count > 0)
{
var sb = new StringBuilder();
if (cands[0]?["content"]?["parts"] is JsonArray parts)
foreach (var p in parts) sb.Append(p?["text"]?.ToString() ?? "");
content = sb.ToString();
}
if (string.IsNullOrEmpty(content)) throw new Exception("LLM response had no content");
content = System.Text.RegularExpressions.Regex.Replace(content, @"^\s*```json\s*", "",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
content = System.Text.RegularExpressions.Regex.Replace(content, @"\s*```\s*$", "");
content = content.Trim();
try { return JsonNode.Parse(content)!; }
catch { throw new Exception("AI returned non-JSON: " + Truncate(content, 300)); }
}
}

142
src/Log.cs Normal file
View file

@ -0,0 +1,142 @@
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
namespace InvoiceParser.Src;
// Rolling text log so a machine running the parser unattended can be checked
// over a file share instead of by standing at it. One file per day under
// logs\, one line per event, oldest files pruned after RetainDays.
//
// Static because everything logs: the Kestrel pipeline, the parse worker, the
// LLM client and the WPF shell all write to the same file, and threading a
// logger through every constructor buys nothing here.
public static class Log
{
private static readonly object _gate = new();
private static string _dir = "";
private static int _retainDays = 14;
private static long _maxBytes = 20L * 1024 * 1024;
private static bool _enabled;
private static string _openDate = "";
public static bool Requests { get; private set; } = true;
public static bool Enabled => _enabled;
public static string Directory_ => _dir;
public static void Init(LoggingConfig cfg, string baseDirectory)
{
_enabled = cfg.Enabled;
if (!_enabled) return;
_dir = Path.IsPathRooted(cfg.Directory)
? cfg.Directory
: Path.Combine(baseDirectory, cfg.Directory);
_retainDays = Math.Max(1, cfg.RetainDays);
_maxBytes = Math.Max(1, cfg.MaxFileMB) * 1024L * 1024L;
Requests = cfg.LogRequests;
try { System.IO.Directory.CreateDirectory(_dir); } catch { _enabled = false; return; }
Prune();
}
public static void Info(string category, string message) => Write("INFO ", category, message, null);
public static void Warn(string category, string message) => Write("WARN ", category, message, null);
public static void Error(string category, string message, Exception? ex = null) => Write("ERROR", category, message, ex);
// Wraps an action so a background failure still lands in the log instead of
// disappearing into a swallowed catch.
public static void Try(string category, string what, Action action)
{
try { action(); }
catch (Exception ex) { Error(category, what + " failed", ex); }
}
private static void Write(string level, string category, string message, Exception? ex)
{
if (!_enabled) return;
var line = new StringBuilder()
.Append(DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff"))
.Append(' ').Append(level)
.Append(" [").Append(category).Append("] ")
.Append(Redact(message));
if (ex != null)
{
line.Append(" | ").Append(ex.GetType().Name).Append(": ").Append(Redact(ex.Message));
var trace = ex.StackTrace;
if (!string.IsNullOrEmpty(trace))
line.Append('\n').Append(Redact(trace));
var inner = ex.InnerException;
for (int depth = 0; inner != null && depth < 3; depth++, inner = inner.InnerException)
line.Append("\n inner: ").Append(inner.GetType().Name).Append(": ").Append(Redact(inner.Message));
}
line.Append('\n');
lock (_gate)
{
try
{
var path = CurrentPath();
File.AppendAllText(path, line.ToString(), Encoding.UTF8);
if (new FileInfo(path).Length > _maxBytes) RollOversize(path);
}
catch { /* logging must never take the app down */ }
}
}
private static string CurrentPath()
{
var today = DateTime.Now.ToString("yyyyMMdd");
if (today != _openDate)
{
_openDate = today;
Prune();
}
return Path.Combine(_dir, "invoiceparser-" + today + ".log");
}
// A single day that blows past MaxFileMB is parked as .1, .2 ... so the
// live file stays small enough to open.
private static void RollOversize(string path)
{
for (int n = 1; n < 100; n++)
{
var candidate = path + "." + n;
if (File.Exists(candidate)) continue;
File.Move(path, candidate);
return;
}
}
private static void Prune()
{
try
{
var cutoff = DateTime.Now.Date.AddDays(-_retainDays);
foreach (var f in System.IO.Directory.GetFiles(_dir, "invoiceparser-*.log*"))
if (File.GetLastWriteTime(f) < cutoff) File.Delete(f);
}
catch { }
}
// Connection strings and API keys travel inside exception messages and
// config dumps; the log is meant to be shared, so they never reach it.
private static readonly Regex Secrets = new(
@"(?i)\b(password|pwd|api[_-]?key|authorization|x-goog-api-key)\b\s*[=:]\s*[^;,\s""}]+",
RegexOptions.Compiled);
private static readonly Regex Bearer = new(@"(?i)bearer\s+[A-Za-z0-9._\-]+", RegexOptions.Compiled);
public static string Redact(string s)
{
if (string.IsNullOrEmpty(s)) return s ?? "";
s = Secrets.Replace(s, m => m.Groups[1].Value + "=***");
return Bearer.Replace(s, "Bearer ***");
}
// Keeps a single log line readable when the payload is a whole LLM response.
public static string Clip(string? s, int n = 400)
{
s ??= "";
s = s.Replace("\r", " ").Replace("\n", " ");
return s.Length <= n ? s : s[..n] + "...(" + s.Length + " chars)";
}
}

377
src/MatchService.cs Normal file
View file

@ -0,0 +1,377 @@
using System.Text.Json.Nodes;
using Microsoft.Data.SqlClient;
namespace InvoiceParser.Src;
// Port of match.asp: vendor + product matching with the same tiers
// (exact UPC, UPC core normalization, cert code, token Jaro-Winkler fuzzy)
// and the same JSON response shapes the review UI expects.
public class MatchService
{
private const int MatchExact = 100;
private const int MatchCertCode = 90;
private const int MatchFuzzy = 70;
private readonly Db _db;
private readonly TablesConfig _t;
private readonly ProductCatalog _catalog;
public MatchService(Db db, AppConfig cfg, ProductCatalog catalog)
{
_db = db;
_t = cfg.Tables;
_catalog = catalog;
}
private static readonly string ProductFields =
"upc, cert_code, description, longdescription, cost, pack, size, unitofmeasure, active, deleted, " +
ProductSql.Retail;
// ---- product search box (match.asp?search=1) ----
public JsonArray SearchProducts(string q)
{
var results = new JsonArray();
if (q.Length < 2) return results;
using var conn = _db.Open();
using var cmd = conn.CreateCommand();
var upcCond = "upc LIKE @upcPrefix";
cmd.Parameters.AddWithValue("@upcPrefix", Util.EscapeLike(q) + "%");
if (q.All(char.IsDigit) && q.Length > 0 && q.Length < 13)
{
upcCond = "(" + upcCond + " OR upc = @upcPadded)";
cmd.Parameters.AddWithValue("@upcPadded", q.PadLeft(13, '0'));
}
cmd.CommandText =
$"SELECT TOP 10 upc, cert_code, description, cost, pack, size, unitofmeasure, {ProductSql.Retail} " +
$"FROM {_t.ProductsTable} " +
"WHERE deleted = 0 AND active = 1 " +
$"AND ({upcCond} OR description LIKE @likeQ OR longdescription LIKE @likeQ) " +
"ORDER BY description";
cmd.Parameters.AddWithValue("@likeQ", "%" + Util.EscapeLike(q) + "%");
using var r = cmd.ExecuteReader();
while (r.Read())
{
results.Add(new JsonObject
{
["upc"] = Db.GetStr(r, "upc") ?? "",
["cert_code"] = Db.GetStr(r, "cert_code") ?? "",
["description"] = Db.GetStr(r, "description") ?? "",
["cost"] = Db.GetNum(r, "cost"),
["pack"] = Db.GetNum(r, "pack"),
["retail"] = Db.GetNum(r, "retail"),
["size"] = Db.GetStr(r, "size") ?? "",
["uom"] = Db.GetStr(r, "unitofmeasure") ?? ""
});
}
return results;
}
// ---- full match: vendor + all line items ----
public JsonObject MatchAll(string vendorName, List<(string Upc, string Desc, string Code)> items)
{
var result = new JsonObject
{
["vendor"] = MatchVendor(vendorName)
};
var itemArr = new JsonArray();
for (int i = 0; i < items.Count; i++)
itemArr.Add(MatchProduct(items[i].Upc, items[i].Desc, items[i].Code, i));
result["items"] = itemArr;
return result;
}
private JsonObject MatchVendor(string vendorName)
{
var unmatched = new JsonObject
{
["id"] = null, ["name"] = "", ["confidence"] = 0, ["label"] = "unmatched"
};
if (vendorName == "") return unmatched;
using var conn = _db.Open();
using (var cmd = conn.CreateCommand())
{
cmd.CommandText =
$"SELECT TOP 1 {_t.VendorIdCol}, {_t.VendorNameCol} FROM {_t.VendorTable} " +
$"WHERE {_t.VendorNameCol} = @name";
cmd.Parameters.AddWithValue("@name", vendorName);
using var r = cmd.ExecuteReader();
if (r.Read())
{
return new JsonObject
{
["id"] = Convert.ToString(r.GetValue(0)),
["name"] = Convert.ToString(r.GetValue(1)),
["confidence"] = 100,
["label"] = "exact"
};
}
}
// Fuzzy: LIKE on significant words narrows the pool, token JW picks the best.
var words = vendorName.Split(' ')
.Select(w => w.Trim()).Where(w => w.Length >= 3).Take(4).ToList();
if (words.Count == 0) return unmatched;
using (var cmd = conn.CreateCommand())
{
var conds = new List<string>();
for (int i = 0; i < words.Count; i++)
{
conds.Add($"{_t.VendorNameCol} LIKE @w{i}");
cmd.Parameters.AddWithValue($"@w{i}", "%" + Util.EscapeLike(words[i]) + "%");
}
cmd.CommandText =
$"SELECT TOP 40 {_t.VendorIdCol}, {_t.VendorNameCol} FROM {_t.VendorTable} " +
"WHERE " + string.Join(" OR ", conds);
double best = 0;
JsonObject? bestJson = null;
using var r = cmd.ExecuteReader();
while (r.Read())
{
var name = Convert.ToString(r.GetValue(1)) ?? "";
var score = TokenJw(vendorName, name);
if (score > best)
{
best = score;
bestJson = new JsonObject
{
["id"] = Convert.ToString(r.GetValue(0)),
["name"] = name,
["confidence"] = (int)(MatchFuzzy * score),
["label"] = "fuzzy"
};
}
}
if (best >= 0.5 && bestJson != null) return bestJson;
}
return unmatched;
}
private record Candidate(JsonObject Json, double Score);
public JsonObject MatchProduct(string upc, string desc, string code, int idx)
{
using var conn = _db.Open();
// Tier 1 - exact UPC (raw or 13-digit zero-padded)
if (upc != "")
{
var padded = upc.PadLeft(13, '0');
using (var cmd = conn.CreateCommand())
{
cmd.CommandText =
$"SELECT TOP 1 {ProductFields} FROM {_t.ProductsTable} " +
"WHERE upc IN (@u1, @u2) AND deleted = 0 AND active = 1 " +
"ORDER BY CASE WHEN upc = @u1 THEN 0 ELSE 1 END";
cmd.Parameters.AddWithValue("@u1", upc);
cmd.Parameters.AddWithValue("@u2", padded);
using var r = cmd.ExecuteReader();
if (r.Read()) return BuildItemJson(r, idx, "exact", MatchExact);
}
// Tier 1b - UPC core normalization (UPC-A vs EAN-13 mismatches)
var core = UpcCore(upc);
if (core != "")
{
using var cmd = conn.CreateCommand();
cmd.CommandText =
$"SELECT TOP 1 {ProductFields} FROM {_t.ProductsTable} " +
"WHERE SUBSTRING(upc, PATINDEX('%[^0]%', upc + 'x'), LEN(upc)) = @core " +
"AND deleted = 0 AND active = 1";
cmd.Parameters.AddWithValue("@core", core);
using var r = cmd.ExecuteReader();
if (r.Read()) return BuildItemJson(r, idx, "upc_norm", MatchExact);
}
}
// Tier 2 - cert_code
if (code != "")
{
using var cmd = conn.CreateCommand();
cmd.CommandText =
$"SELECT TOP 1 {ProductFields} FROM {_t.ProductsTable} " +
"WHERE cert_code = @code AND deleted = 0 AND active = 1";
cmd.Parameters.AddWithValue("@code", code);
using var r = cmd.ExecuteReader();
if (r.Read()) return BuildItemJson(r, idx, "cert_code", MatchCertCode);
}
// Tier 3 - token Jaro-Winkler over description + UPC prefix pool
double bestScore = 0, debugScore = 0;
JsonObject? bestJson = null;
string debugDesc = "", debugUpc = "";
var candidates = new List<Candidate>();
if (desc != "" || upc != "")
{
// Pool comes from the in-memory catalog, not a per-line SQL scan.
// See ProductCatalog for why: a leading-wildcard LIKE on a rare word
// forces a full table scan that no query shape can avoid.
foreach (var p in _catalog.Candidates(desc, upc, 400))
{
double jwDesc = 0, jwUpc = 0;
int dims = 0;
if (desc != "") { jwDesc = TokenJw(desc, p.Desc + " " + p.LongDesc); dims++; }
if (upc != "" && p.Upc != "") { jwUpc = JaroWinkler(upc, p.Upc); dims++; }
double combined = dims > 0 ? (jwDesc + jwUpc) / dims : 0;
if (combined > debugScore)
{
debugScore = combined;
debugDesc = p.Desc;
debugUpc = p.Upc;
}
if (combined >= 0.2)
candidates.Add(new Candidate(ProductCatalog.CandidateJson(p, combined), combined));
if (combined > bestScore)
{
bestScore = combined;
var label = jwUpc > 0 ? "upc+fuzzy" : "fuzzy";
bestJson = ProductCatalog.ItemJson(p, idx, label, (int)(MatchFuzzy * combined));
}
}
}
var topCands = new JsonArray();
foreach (var c in candidates.OrderByDescending(c => c.Score).Take(3))
topCands.Add(c.Json);
if (bestScore >= 0.3 && bestJson != null)
{
if (topCands.Count > 0) bestJson["candidates"] = topCands;
return bestJson;
}
// Tier 4 - no match
var none = new JsonObject
{
["index"] = idx,
["match_label"] = "unmatched",
["confidence"] = 0
};
if (debugDesc != "" || debugUpc != "")
{
none["debug_best_desc"] = debugDesc;
none["debug_best_upc"] = debugUpc;
none["debug_best_score"] = debugScore;
}
if (topCands.Count > 0) none["candidates"] = topCands;
return none;
}
private static JsonObject BuildItemJson(SqlDataReader r, int idx, string label, int confidence)
{
var active = Convert.ToInt32(r.GetValue(r.GetOrdinal("active")));
var deleted = Convert.ToInt32(r.GetValue(r.GetOrdinal("deleted")));
return new JsonObject
{
["index"] = idx,
["upc"] = Db.GetStr(r, "upc") ?? "",
["cert_code"] = Db.GetStr(r, "cert_code") ?? "",
["description"] = Db.GetStr(r, "description") ?? "",
["longdescription"] = Db.GetStr(r, "longdescription") ?? "",
["cost"] = Db.GetNum(r, "cost"),
["pack"] = Db.GetNum(r, "pack"),
["retail"] = Db.GetNum(r, "retail"),
["size"] = Db.GetStr(r, "size") ?? "",
["uom"] = Db.GetStr(r, "unitofmeasure") ?? "",
["match_label"] = label,
["confidence"] = confidence,
["inactive"] = active == 0 || deleted != 0
};
}
// ---- string similarity (verbatim ports) ----
private static string[] SplitWords(string s)
{
// Any non-alphanumeric becomes a separator, so vendor noise like the
// leading "&" in "&COFFEEMT" does not become part of the token (and
// then fail every LIKE it is used in).
var chars = s.ToLowerInvariant().Select(c => char.IsLetterOrDigit(c) ? c : ' ').ToArray();
return new string(chars).Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
// Normalized UPC core: strip leading zeros, then the check digit when 12+ digits.
public static string UpcCore(string upc)
{
var s = upc.TrimStart('0');
if (s == "" && upc.Length > 0) s = "0";
if (s.Length >= 12) s = s[..^1];
return s;
}
// Token-level Jaro-Winkler: best JW per parsed token, averaged.
public static double TokenJw(string parsed, string candidate)
{
var tok1 = SplitWords(parsed).Where(t => t.Length >= 3).ToArray();
var tok2 = SplitWords(candidate).Where(t => t.Length >= 3).ToArray();
if (tok1.Length == 0) return 0;
double total = 0;
foreach (var t1 in tok1)
{
double best = 0;
foreach (var t2 in tok2)
{
var s = JaroWinkler(t1, t2);
if (s > best) best = s;
}
total += best;
}
return total / tok1.Length;
}
public static double JaroWinkler(string s1, string s2)
{
int l1 = s1.Length, l2 = s2.Length;
if (l1 == 0 && l2 == 0) return 1;
if (l1 == 0 || l2 == 0) return 0;
int matchWindow = Math.Max(0, Math.Max(l1, l2) / 2 - 1);
var m1 = new bool[l1];
var m2 = new bool[l2];
int m = 0;
for (int i = 0; i < l1; i++)
{
int start = Math.Max(0, i - matchWindow);
int end = Math.Min(l2 - 1, i + matchWindow);
for (int j = start; j <= end; j++)
{
if (!m2[j] && s1[i] == s2[j])
{
m1[i] = true;
m2[j] = true;
m++;
break;
}
}
}
if (m == 0) return 0;
int t = 0, k = 0;
for (int i = 0; i < l1; i++)
{
if (!m1[i]) continue;
while (!m2[k]) k++;
if (s1[i] != s2[k]) t++;
k++;
}
t /= 2;
double jaro = ((double)m / l1 + (double)m / l2 + (double)(m - t) / m) / 3;
int prefix = 0;
while (prefix < 4 && prefix < l1 && prefix < l2 && s1[prefix] == s2[prefix]) prefix++;
return jaro + prefix * 0.1 * (1 - jaro);
}
}

505
src/ParseWorker.cs Normal file
View file

@ -0,0 +1,505 @@
using System.Collections.Concurrent;
using System.IO;
using System.Text.Json.Nodes;
using System.Threading.Channels;
using Microsoft.Extensions.Hosting;
namespace InvoiceParser.Src;
// The single owner of the parse queue. Replaces the old triple-worker setup
// (pending_process.asp, parse_queue.ps1, browser-side parse) so there is no
// claim/steal logic left - one in-process worker drains one channel.
public class ParseWorker : BackgroundService
{
private readonly AppConfig _cfg;
private readonly PendingStore _store;
private readonly LlmClient _llm;
private readonly EventBroadcaster _events;
private readonly Channel<string> _queue = Channel.CreateUnbounded<string>();
private readonly ConcurrentDictionary<string, byte> _inQueue = new();
public ParseWorker(AppConfig cfg, PendingStore store, LlmClient llm, EventBroadcaster events)
{
_cfg = cfg;
_store = store;
_llm = llm;
_events = events;
}
public bool Enqueue(string id)
{
if (!_inQueue.TryAdd(id, 0)) return false; // already waiting
_queue.Writer.TryWrite(id);
return true;
}
// Enqueues queued + errored items (the "Process queued now" button).
// Returns how many were newly queued and how many were already waiting, so
// a caller that gets 0 can tell "nothing to do" from "already in flight".
public (int Queued, int AlreadyWaiting) EnqueueEligible(bool includeErrors)
{
int queued = 0, waiting = 0;
foreach (var item in _store.List())
{
var stage = _store.Stage(item.Id);
bool eligible = stage is "queued" or "queue" || (includeErrors && stage == "error");
if (!eligible) continue;
if (Enqueue(item.Id)) queued++;
else waiting++;
}
return (queued, waiting);
}
protected override async Task ExecuteAsync(CancellationToken ct)
{
// Recover from a previous crash: anything stranded at "processing"
// goes back to "queued", then all queued work is picked up.
int recovered = 0;
foreach (var item in _store.List())
{
if (_store.Stage(item.Id) != "processing") continue;
_store.SetStage(item.Id, "queued");
recovered++;
}
Log.Info("worker", $"started - {recovered} draft(s) reset from processing to queued");
// The drain loop must outlive any single failure. An exception escaping
// it leaves the channel with no reader: uploads still queue, _inQueue
// still dedupes them, and every invoice sits at "queued" forever with no
// error shown - the host keeps serving because nothing awaits its
// shutdown request, so the dead queue is completely silent.
while (!ct.IsCancellationRequested)
{
try
{
await DrainAsync(ct);
return; // channel completed - only happens on shutdown
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
return;
}
catch (Exception ex)
{
// Anything stranded in the dedupe set was never read; forget it
// so the next sweep can queue it again.
Log.Error("worker", "drain loop crashed - restarting it", ex);
_inQueue.Clear();
RequeueUnfinished();
}
}
}
private async Task DrainAsync(CancellationToken ct)
{
EnqueueEligible(includeErrors: false);
await foreach (var id in _queue.Reader.ReadAllAsync(ct))
{
_inQueue.TryRemove(id, out _);
var started = System.Diagnostics.Stopwatch.StartNew();
try
{
await ProcessOne(id, ct);
Log.Info("worker", $"{id} finished in {started.ElapsedMilliseconds}ms (stage {_store.Stage(id)})");
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
catch (Exception ex)
{
// Includes a request timeout, which arrives as a cancellation
// that has nothing to do with our token.
Log.Error("worker", $"{id} failed after {started.ElapsedMilliseconds}ms", ex);
_store.SetError(id, ex.Message);
_events.StageChanged(id, "error", ex.Message);
}
}
}
private void RequeueUnfinished()
{
foreach (var item in _store.List())
{
if (_store.Stage(item.Id) == "processing") _store.SetStage(item.Id, "queued");
}
}
private async Task ProcessOne(string id, CancellationToken ct)
{
var state = _store.ReadState(id);
if (state == null) return;
_store.SetStage(id, "processing");
_events.StageChanged(id, "processing");
state = _store.ReadState(id)!;
Log.Info("worker", $"{id} processing \"{state["source"]?["file_name"]}\"");
// Fresh uploads arrive unprepped so the upload request returns at once;
// drafts from the old app carry no flag and are already prepped.
if (state["prepped"] is JsonValue pv && pv.TryGetValue<bool>(out var prepped) && !prepped)
{
Prep(id);
state = _store.ReadState(id)!;
}
string parseMode = state["parse_mode"]?.ToString() ?? "";
string ocrText = state["ocr"]?["text"]?.ToString() ?? "";
// One request per page, never the whole invoice at once. Asked for a
// 6-page invoice in a single call the model returned finishReason=STOP
// after 124 items and silently omitted a whole page of line items - not
// a token-limit truncation, just fidelity loss over a long output. Page
// sized requests are small enough to come back complete.
string backend;
List<string> responses;
try
{
if (parseMode == "text" && !string.IsNullOrWhiteSpace(ocrText))
{
backend = _llm.TextBackend;
var chunks = SplitTextByPage(ocrText, state);
var pageImages = _cfg.Llm.AttachPageImages ? PageImagesByNumber(id) : null;
Log.Info("worker", $"{id} text route ({backend}) - {chunks.Count} page(s), " +
$"{ocrText.Length} chars, images={(pageImages?.Count ?? 0)}");
responses = await RunPagesAsync(chunks, (c, t) =>
{
(string, string)? img = null;
if (pageImages != null && pageImages.TryGetValue(c.Page, out var found)) img = found;
return _llm.ParseTextAsync(c.Text, img, t);
}, ct);
}
else
{
backend = _llm.ImageBackend;
var images = CollectImages(id, state);
Log.Info("worker", $"{id} image route ({backend}) - {images.Count} page image(s)");
responses = await RunPagesAsync(images,
(img, t) => _llm.ParseImagesAsync(new List<(string, string)> { img }, t), ct);
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
catch (Exception ex)
{
Log.Error("worker", $"{id} LLM call failed", ex);
_store.SetError(id, ex.Message);
_events.StageChanged(id, "error", ex.Message);
return;
}
// The LLM call can take minutes; if the user opened the draft and started
// editing meanwhile, do not clobber their state.
if (_store.Stage(id) != "processing")
{
Log.Warn("worker", $"{id} left processing while the LLM ran (now {_store.Stage(id)}) - result discarded");
return;
}
JsonNode api;
JsonNode parsed;
try
{
(api, parsed) = MergeResponses(responses);
}
catch (Exception ex)
{
Log.Error("worker", $"{id} response merge failed", ex);
_store.SetError(id, ex.Message);
_events.StageChanged(id, "error", ex.Message);
return;
}
var newState = BuildParsedState(state, api, parsed, backend);
_store.WriteState(id, newState);
_events.StageChanged(id, "parsed");
Log.Info("worker", $"{id} parsed: invoice={parsed["invoice_number"]} date={parsed["invoice_date"]} " +
$"total={parsed["total"]} lines={(parsed["line_items"] as JsonArray)?.Count ?? 0}");
}
// Server-side prep (was browser-side in the old app): text-layer extraction
// for digital PDFs, page rendering for scanned ones.
private void Prep(string id)
{
var src = _store.FindSource(id, out var ext)
?? throw new Exception("source file missing after upload");
string parseMode = "image";
string ocrText = "", ocrMethod = "";
JsonNode? lineMap = null;
if (ext.Equals("pdf", StringComparison.OrdinalIgnoreCase))
{
var lines = PdfPrep.ExtractLines(src);
var text = string.Join("\n", lines.Select(l => l.Text));
if (text.Trim().Length > 50)
{
parseMode = "text";
ocrText = text;
ocrMethod = "via PDF text layer";
lineMap = PdfPrep.LineMapToJson(lines);
}
else
{
PdfPrep.RenderAllPages(src, Path.Combine(_store.Dir(id), "pages"), _cfg.Llm.MaxImageDim);
}
}
var state = _store.ReadState(id) ?? throw new Exception("state.json missing");
state["parse_mode"] = parseMode;
state["ocr"] = new JsonObject
{
["text"] = ocrText,
["method"] = ocrMethod,
["line_map"] = lineMap
};
state["prepped"] = true;
state["updated_at"] = Util.NowIso();
_store.WriteState(id, state);
Log.Info("prep", $"{id} ext={ext} mode={parseMode} textChars={ocrText.Length} method=\"{ocrMethod}\"");
}
// Runs the per-page calls with bounded concurrency, preserving page order.
// Sequentially six pages took longer than the (lossy) single call, because
// each request pays its own model overhead; overlapping them removes that.
private async Task<List<string>> RunPagesAsync<T>(
IReadOnlyList<T> pages, Func<T, CancellationToken, Task<string>> call, CancellationToken ct)
{
int limit = Math.Max(1, _cfg.Llm.MaxParallelPages);
var results = new string[pages.Count];
using var gate = new SemaphoreSlim(limit);
var tasks = pages.Select(async (page, i) =>
{
await gate.WaitAsync(ct);
try { results[i] = await call(page, ct); }
finally { gate.Release(); }
}).ToList();
await Task.WhenAll(tasks);
return results.ToList();
}
// Splits the OCR text into one numbered chunk per PDF page, using the
// per-line page numbers in ocr.line_map. Line numbers stay GLOBAL across
// chunks so the source_line values the model returns still line up with
// line_map and the preview markers. Falls back to a single chunk when a
// draft has no line_map (older drafts, or image sources).
private static List<(int Page, string Text)> SplitTextByPage(string ocrText, JsonNode state)
{
var numbered = LlmClient.NumberLines(ocrText).Replace("\r\n", "\n").Split('\n');
var map = state["ocr"]?["line_map"] as JsonArray;
if (map == null || map.Count != numbered.Length)
return new List<(int, string)> { (1, string.Join("\n", numbered)) };
var chunks = new List<(int, string)>();
var current = new List<string>();
int currentPage = -1;
for (int i = 0; i < numbered.Length; i++)
{
int page = int.TryParse(map[i]?["page"]?.ToString(), out var p) ? p : 1;
if (page != currentPage && current.Count > 0)
{
chunks.Add((currentPage, string.Join("\n", current)));
current.Clear();
}
currentPage = page;
current.Add(numbered[i]);
}
if (current.Count > 0) chunks.Add((currentPage, string.Join("\n", current)));
return chunks;
}
// Rendered pages keyed by page number for hybrid text+image parsing. A
// text-layer PDF is not rendered during prep, so render it here; the
// preview endpoint reuses the same files afterwards.
private Dictionary<int, (string Mime, string B64)> PageImagesByNumber(string id)
{
var result = new Dictionary<int, (string, string)>();
var src = _store.FindSource(id, out var ext);
if (src == null || !ext.Equals("pdf", StringComparison.OrdinalIgnoreCase)) return result;
var pagesDir = Path.Combine(_store.Dir(id), "pages");
var files = Directory.Exists(pagesDir) ? Directory.GetFiles(pagesDir, "page_*.jpg") : Array.Empty<string>();
if (files.Length == 0)
{
PdfPrep.RenderAllPages(src, pagesDir, _cfg.Llm.MaxImageDim);
files = Directory.GetFiles(pagesDir, "page_*.jpg");
}
foreach (var f in files)
result[PageNumber(f)] = ("image/jpeg", Convert.ToBase64String(File.ReadAllBytes(f)));
return result;
}
// Folds the per-page responses into one invoice. Header fields are taken
// from the first page that supplies them (they live on page 1 of a real
// invoice); line items are concatenated in page order.
private static (JsonNode Api, JsonNode Parsed) MergeResponses(List<string> responses)
{
var rawArray = new JsonArray();
var merged = new JsonObject();
var items = new JsonArray();
bool grandTotalFound = false;
for (int pageNo = 0; pageNo < responses.Count; pageNo++)
{
var raw = responses[pageNo];
JsonNode api;
JsonNode parsed;
try
{
api = JsonNode.Parse(raw)!;
parsed = LlmClient.ExtractInvoiceData(api);
}
catch (Exception ex)
{
// A page that cannot be read is a failed parse, not a shorter
// invoice: skipping it would drop its line items with no trace.
throw new Exception($"Page {pageNo + 1} of {responses.Count}: {ex.Message}");
}
rawArray.Add(api.DeepClone());
// Identity fields come from the first page that has them.
foreach (var field in new[] { "vendor_name", "invoice_number", "invoice_date" })
{
if (merged[field] != null) continue;
var v = parsed[field];
if (v != null && v.GetValueKind() != System.Text.Json.JsonValueKind.Null &&
v.ToString() != "")
merged[field] = v.DeepClone();
}
// The total: a page whose label names the invoice grand total wins
// outright. Otherwise the last page with a total is kept, which is
// wrong when a recap/allowance page follows the totals page - the
// prompt asks for null there, the label check is the backstop.
var t = parsed["total"];
if (t != null && t.GetValueKind() != System.Text.Json.JsonValueKind.Null && t.ToString() != "")
{
var label = parsed["total_label"]?.ToString() ?? "";
bool grand = IsGrandTotalLabel(label);
if (!grandTotalFound || grand)
{
merged["total"] = t.DeepClone();
merged["total_label"] = label;
merged["total_page"] = pageNo + 1;
grandTotalFound = grandTotalFound || grand;
}
}
if (parsed["line_items"] is JsonArray li)
foreach (var it in li)
if (it != null) items.Add(it.DeepClone());
}
if (rawArray.Count == 0) throw new Exception("LLM returned no usable response");
merged["line_items"] = items;
return (rawArray, merged);
}
private static bool IsGrandTotalLabel(string label) =>
System.Text.RegularExpressions.Regex.IsMatch(label,
@"invoice\s*total|total\s*due|amount\s*due|balance\s*due|net\s*total|grand\s*total|net\s*invoice|total\s*invoice",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
// Vision inputs, in order of preference: rendered pages on disk,
// legacy base64 page_images inside state.json, or the raw source image.
// A scanned PDF with no rendered pages gets rendered right here.
private List<(string Mime, string B64)> CollectImages(string id, JsonNode state)
{
var pagesDir = Path.Combine(_store.Dir(id), "pages");
if (Directory.Exists(pagesDir))
{
var files = Directory.GetFiles(pagesDir, "page_*.jpg")
.OrderBy(PageNumber).ToList();
if (files.Count > 0)
return files.Select(f => ("image/jpeg", Convert.ToBase64String(File.ReadAllBytes(f)))).ToList();
}
if (state["page_images"] is JsonArray legacy && legacy.Count > 0)
return legacy.Select(n => ("image/jpeg", n?.ToString() ?? "")).ToList();
var src = _store.FindSource(id, out var ext);
if (src == null)
throw new Exception("No page images and no source file for this invoice");
if (ext.Equals("pdf", StringComparison.OrdinalIgnoreCase))
{
PdfPrep.RenderAllPages(src, pagesDir, _cfg.Llm.MaxImageDim);
var files = Directory.GetFiles(pagesDir, "page_*.jpg").OrderBy(PageNumber).ToList();
if (files.Count == 0) throw new Exception("PDF produced no page images");
return files.Select(f => ("image/jpeg", Convert.ToBase64String(File.ReadAllBytes(f)))).ToList();
}
return new List<(string, string)>
{
(Util.MimeFromExt(ext), Convert.ToBase64String(File.ReadAllBytes(src)))
};
}
private static int PageNumber(string path)
{
var name = Path.GetFileNameWithoutExtension(path);
return int.TryParse(name.Replace("page_", ""), out var n) ? n : 0;
}
// Port of jsBuildParsedState from pending_process.asp - same output shape
// the review screen expects.
private static JsonNode BuildParsedState(JsonNode state, JsonNode api, JsonNode parsed, string backend)
{
var now = Util.NowIso();
state["stage"] = "parsed";
state["error"] = "";
state["updated_at"] = now;
state["header"] = new JsonObject
{
["vendor_id"] = "",
["vendor_name"] = parsed["vendor_name"]?.ToString() ?? "",
["vendor_hint"] = parsed["vendor_name"]?.ToString() ?? "",
["invoice_number"] = parsed["invoice_number"]?.ToString() ?? "",
["invoice_date"] = parsed["invoice_date"]?.ToString() ?? "",
["po_number"] = "",
["invoice_total"] = parsed["total"]?.ToString() ?? "",
["total_label"] = parsed["total_label"]?.ToString() ?? "",
["total_page"] = parsed["total_page"]?.ToString() ?? ""
};
var items = new JsonArray();
if (parsed["line_items"] is JsonArray src)
{
foreach (var itNode in src)
{
var it = itNode as JsonObject ?? new JsonObject();
items.Add(new JsonObject
{
["upc"] = it["upc"]?.ToString() ?? "",
["desc"] = it["description"]?.ToString() ?? "",
["cert"] = it["product_code"]?.ToString() ?? "",
["qty"] = it["quantity"]?.ToString() ?? "",
["cost"] = it["unit_price"]?.ToString() ?? "",
["total"] = it["total"]?.ToString() ?? "",
["pack"] = it["pack"]?.ToString() ?? "",
["size"] = it["size"]?.ToString() ?? "",
["uom"] = string.IsNullOrEmpty(it["unit_of_measure"]?.ToString()) ? "EA" : it["unit_of_measure"]!.ToString(),
["source_line"] = it["source_line"]?.ToString() ?? "",
["status"] = "unmatched",
["parsed_desc"] = it["description"]?.ToString() ?? "",
["matched_desc"] = "",
["match_badge_text"] = "",
["match_badge_class"] = "",
["po_status"] = ""
});
}
}
state["line_items"] = items;
state["page_images"] = new JsonArray(); // drop legacy heavy images now that parsing is done
state["llm"] = new JsonObject
{
["backend"] = backend,
["at"] = now,
["raw_response"] = api.DeepClone(),
["parsed"] = parsed.DeepClone()
};
return state;
}
}

129
src/PdfPrep.cs Normal file
View file

@ -0,0 +1,129 @@
using System.IO;
using System.Text.Json.Nodes;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using Docnet.Core;
using Docnet.Core.Converters;
using Docnet.Core.Models;
using UglyToad.PdfPig;
namespace InvoiceParser.Src;
public record PdfLine(int Page, string Text, double Top, double H);
public record PrepResult(string ParseMode, string OcrText, string OcrMethod, List<PdfLine>? LineMap, int PageCount);
// PDF text extraction (PdfPig) and page rasterization (Docnet/PDFium).
// Replaces the browser-side PDF.js prep so parsing never needs a browser open.
public static class PdfPrep
{
// One entry per visual text line, clustered by baseline y with a tolerance of
// half a line height - mirrors getPdfLines in the old invoice.js so the Nth
// text line is the Nth entry in line_map and the preview markers line up.
public static List<PdfLine> ExtractLines(string pdfPath)
{
var outLines = new List<PdfLine>();
using var doc = PdfDocument.Open(pdfPath);
int pageNo = 0;
foreach (var page in doc.GetPages())
{
pageNo++;
double pageH = page.Height;
var items = new List<(string Str, double X, double Y, double H)>();
foreach (var w in page.GetWords())
{
var text = w.Text;
if (string.IsNullOrWhiteSpace(text)) continue;
var box = w.BoundingBox;
double h = Math.Abs(box.Top - box.Bottom);
if (h <= 0) h = 12;
// convert bottom-left origin to top-down "baseline" y
items.Add((text, box.Left, pageH - box.Bottom, h));
}
items.Sort((a, b) => a.Y != b.Y ? a.Y.CompareTo(b.Y) : a.X.CompareTo(b.X));
var lines = new List<(double Y, double H, List<(string Str, double X)> Parts)>();
foreach (var it in items)
{
if (lines.Count == 0 ||
Math.Abs(it.Y - lines[^1].Y) > Math.Max(it.H, lines[^1].H) * 0.5)
{
lines.Add((it.Y, it.H, new List<(string, double)> { (it.Str, it.X) }));
}
else
{
var last = lines[^1];
last.Parts.Add((it.Str, it.X));
if (it.H > last.H) lines[^1] = (last.Y, it.H, last.Parts);
}
}
foreach (var l in lines)
{
l.Parts.Sort((a, b) => a.X.CompareTo(b.X));
var text = string.Join(" ", l.Parts.Select(p => p.Str)).Trim();
text = System.Text.RegularExpressions.Regex.Replace(text, @"\s+", " ");
outLines.Add(new PdfLine(
pageNo,
text,
Math.Round(Math.Max(0, (l.Y - l.H) / pageH), 4),
Math.Round(l.H / pageH, 4)));
}
}
return outLines;
}
public static int GetPageCount(string pdfPath)
{
using var docReader = DocLib.Instance.GetDocReader(
File.ReadAllBytes(pdfPath), new PageDimensions(1.0));
return docReader.GetPageCount();
}
// Renders every page to dir\page_N.jpg (1-based). Returns page count.
public static int RenderAllPages(string pdfPath, string dir, int maxDim)
{
Directory.CreateDirectory(dir);
var bytes = File.ReadAllBytes(pdfPath);
using var docReader = DocLib.Instance.GetDocReader(bytes, new PageDimensions(2.0));
int count = docReader.GetPageCount();
for (int i = 0; i < count; i++)
{
using var pageReader = docReader.GetPageReader(i);
var raw = pageReader.GetImage(new NaiveTransparencyRemover());
int w = pageReader.GetPageWidth();
int h = pageReader.GetPageHeight();
SaveJpeg(raw, w, h, maxDim, Path.Combine(dir, $"page_{i + 1}.jpg"));
}
return count;
}
private static void SaveJpeg(byte[] bgra, int width, int height, int maxDim, string path)
{
var src = BitmapSource.Create(width, height, 96, 96,
PixelFormats.Bgra32, null, bgra, width * 4);
src.Freeze();
BitmapSource final = src;
double scale = Math.Min(1.0, (double)maxDim / Math.Max(width, height));
if (scale < 1.0)
{
var scaled = new TransformedBitmap(src, new ScaleTransform(scale, scale));
scaled.Freeze();
final = scaled;
}
var enc = new JpegBitmapEncoder { QualityLevel = 85 };
enc.Frames.Add(BitmapFrame.Create(final));
using var fs = File.Create(path);
enc.Save(fs);
}
public static JsonArray LineMapToJson(List<PdfLine> lines)
{
var arr = new JsonArray();
foreach (var l in lines)
arr.Add(new JsonObject { ["page"] = l.Page, ["top"] = l.Top, ["h"] = l.H });
return arr;
}
}

117
src/PendingStore.cs Normal file
View file

@ -0,0 +1,117 @@
using System.IO;
using System.Text;
using System.Text.Json.Nodes;
namespace InvoiceParser.Src;
public record PendingListItem(string Id, string UpdatedAt);
// Owns the pending/{id}/ folders (state.json + source file + rendered pages).
// State stays schema-free (JsonNode) so drafts written by the old web app load unchanged.
public class PendingStore
{
private readonly AppConfig _cfg;
private static readonly UTF8Encoding Utf8NoBom = new(false);
// Worker, HTTP handlers and the stats poll all touch state.json; on Windows a
// read during a write throws, so every access goes through one lock.
private readonly object _io = new();
public PendingStore(AppConfig cfg) => _cfg = cfg;
public string Dir(string id) => Path.Combine(_cfg.PendingRoot, id);
public string StatePath(string id) => Path.Combine(Dir(id), "state.json");
public List<PendingListItem> List()
{
var result = new List<PendingListItem>();
if (!Directory.Exists(_cfg.PendingRoot)) return result;
foreach (var dir in Directory.GetDirectories(_cfg.PendingRoot))
{
var id = Path.GetFileName(dir);
if (!Util.IsSafePendingId(id)) continue;
var sp = Path.Combine(dir, "state.json");
if (!File.Exists(sp)) continue;
var mtime = File.GetLastWriteTimeUtc(sp).ToString("yyyy-MM-dd'T'HH:mm:ss'Z'");
result.Add(new PendingListItem(id, mtime));
}
return result;
}
public string? ReadStateText(string id)
{
var p = StatePath(id);
lock (_io)
return File.Exists(p) ? File.ReadAllText(p, Encoding.UTF8) : null;
}
public JsonNode? ReadState(string id)
{
var text = ReadStateText(id);
if (text == null) return null;
try { return JsonNode.Parse(text); } catch { return null; }
}
public void WriteState(string id, JsonNode state) => WriteStateText(id, state.ToJsonString());
// Written to a temp file and moved into place so a reader never sees a
// half-written state.json.
public void WriteStateText(string id, string json)
{
lock (_io)
{
Directory.CreateDirectory(Dir(id));
var path = StatePath(id);
var tmp = path + ".tmp";
File.WriteAllText(tmp, json, Utf8NoBom);
File.Move(tmp, path, true);
}
}
public bool Exists(string id) => Directory.Exists(Dir(id));
public void Delete(string id)
{
lock (_io)
{
var d = Dir(id);
if (Directory.Exists(d)) Directory.Delete(d, true);
}
}
public string? FindSource(string id, out string ext) =>
Util.FindInvoiceFile(Dir(id), "source", out ext);
public string Stage(string id) =>
ReadState(id)?["stage"]?.ToString() ?? "";
public void SetStage(string id, string stage)
{
lock (_io)
{
var state = ReadState(id);
if (state == null) return;
state["stage"] = stage;
state["updated_at"] = Util.NowIso();
WriteState(id, state);
}
}
// No-op once the draft folder is gone: a delete that lands mid-parse must
// not be undone by the failure that follows it.
public void SetError(string id, string message)
{
lock (_io)
{
if (!Exists(id)) return;
var state = ReadState(id) ?? new JsonObject();
state["stage"] = "error";
state["error"] = message;
state["updated_at"] = Util.NowIso();
WriteState(id, state);
}
}
public static string NewId() =>
"p" + DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + "_" +
Guid.NewGuid().ToString("N")[..8];
}

181
src/ProductCatalog.cs Normal file
View file

@ -0,0 +1,181 @@
using System.Text.Json.Nodes;
namespace InvoiceParser.Src;
// In-memory product catalog used only by the fuzzy tier of MatchService.
//
// Why this exists: fuzzy matching needs to find rows by a rare word ("HZN"),
// and a leading-wildcard LIKE on a rare word cannot use an index - SQL Server
// must scan the whole table. Doing that per line item cost ~2.6s each, roughly
// three minutes for a 66-line invoice. Loading the catalog once and matching in
// memory does the same 66 lines in ~250ms.
//
// Exact UPC and cert_code lookups stay in SQL: they are indexed, fast, and must
// read live data.
public class ProductCatalog
{
public record Product(
string Upc, string CertCode, string Desc, string LongDesc,
double? Cost, double? Pack, string Size, string Uom, double? Retail, bool Inactive);
private readonly Db _db;
private readonly TablesConfig _t;
private readonly object _gate = new();
private List<Product>? _products;
private Dictionary<string, List<int>>? _index; // token key -> product indices
public ProductCatalog(Db db, AppConfig cfg)
{
_db = db;
_t = cfg.Tables;
}
public bool IsLoaded { get { lock (_gate) return _products != null; } }
public int Count { get { lock (_gate) return _products?.Count ?? 0; } }
/// Drops the cache so the next match reloads. Call after creating a product.
public void Invalidate()
{
lock (_gate)
{
_products = null;
_index = null;
}
}
/// Loads the catalog if it is not already in memory. Safe to call from a
/// warm-up task at startup; concurrent callers block rather than duplicate.
public void EnsureLoaded()
{
lock (_gate)
{
if (_products != null) return;
var products = new List<Product>(50000);
using (var conn = _db.Open())
using (var cmd = conn.CreateCommand())
{
cmd.CommandText =
"SELECT upc, cert_code, description, longdescription, cost, pack, size, unitofmeasure, " +
ProductSql.Retail + " " +
$"FROM {_t.ProductsTable} WHERE deleted = 0 AND active = 1";
cmd.CommandTimeout = 300;
using var r = cmd.ExecuteReader();
while (r.Read())
products.Add(new Product(
Db.GetStr(r, "upc") ?? "",
Db.GetStr(r, "cert_code") ?? "",
Db.GetStr(r, "description") ?? "",
Db.GetStr(r, "longdescription") ?? "",
Db.GetNum(r, "cost"),
Db.GetNum(r, "pack"),
Db.GetStr(r, "size") ?? "",
Db.GetStr(r, "unitofmeasure") ?? "",
Db.GetNum(r, "retail"),
false)); // the query already excludes inactive/deleted
}
var index = new Dictionary<string, List<int>>(StringComparer.Ordinal);
for (int i = 0; i < products.Count; i++)
{
foreach (var key in KeysFor(products[i].Desc + " " + products[i].LongDesc))
Add(index, key, i);
var u = products[i].Upc;
if (u.Length >= 6) Add(index, "#" + u.TrimStart('0').PadLeft(13, '0')[..6], i);
}
_products = products;
_index = index;
}
}
private static void Add(Dictionary<string, List<int>> index, string key, int i)
{
if (!index.TryGetValue(key, out var list)) index[key] = list = new List<int>();
if (list.Count == 0 || list[^1] != i) list.Add(i);
}
/// Candidate pool for one invoice line, rarest token bucket first so a
/// single-row token like HZN is never crowded out by a 16,000-row token
/// like CHOC - which is exactly what the old SQL TOP-N pool did wrong.
public List<Product> Candidates(string desc, string upc, int cap)
{
EnsureLoaded();
List<Product> products;
Dictionary<string, List<int>> index;
lock (_gate)
{
products = _products!;
index = _index!;
}
var keys = new List<string>(KeysFor(desc));
if (upc.Length >= 6) keys.Add("#" + upc.TrimStart('0').PadLeft(13, '0')[..6]);
var buckets = keys
.Select(k => index.TryGetValue(k, out var l) ? l : null)
.Where(l => l != null)
.OrderBy(l => l!.Count)
.ToList();
var picked = new HashSet<int>();
foreach (var b in buckets)
{
foreach (var i in b!)
{
picked.Add(i);
if (picked.Count >= cap) break;
}
if (picked.Count >= cap) break;
}
return picked.Select(i => products[i]).ToList();
}
// Each word is filed under its 3-char prefix and its consonant skeleton, so
// FROG and FRG share a bucket - the vowel-dropping abbreviation style these
// POS descriptions use.
private static IEnumerable<string> KeysFor(string text)
{
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var raw in text.ToLowerInvariant().Split(' ', '-', '/', ',', '(', ')', '.', '&', '*', '#'))
{
var w = new string(raw.Where(char.IsLetterOrDigit).ToArray());
if (w.Length < 3) continue;
if (seen.Add(w[..3])) yield return w[..3];
var skel = new string(w.Where(c => !"aeiou".Contains(c)).ToArray());
if (skel.Length >= 3 && seen.Add("!" + skel)) yield return "!" + skel;
}
}
public static JsonObject ItemJson(Product p, int idx, string label, int confidence) => new()
{
["index"] = idx,
["upc"] = p.Upc,
["cert_code"] = p.CertCode,
["description"] = p.Desc,
["longdescription"] = p.LongDesc,
["cost"] = p.Cost,
["pack"] = p.Pack,
["retail"] = p.Retail,
["size"] = p.Size,
["uom"] = p.Uom,
["match_label"] = label,
["confidence"] = confidence,
["inactive"] = p.Inactive
};
public static JsonObject CandidateJson(Product p, double score) => new()
{
["upc"] = p.Upc,
["cert_code"] = p.CertCode,
["description"] = p.Desc,
["size"] = p.Size,
["uom"] = p.Uom,
["cost"] = p.Cost,
["pack"] = p.Pack,
["retail"] = p.Retail,
["score"] = score
};
}

261
src/SaveService.cs Normal file
View file

@ -0,0 +1,261 @@
using System.Globalization;
using System.IO;
using System.Text.Json.Nodes;
using Microsoft.Data.SqlClient;
namespace InvoiceParser.Src;
public record SaveLineItem(string Upc, string Desc, string Cert, string Qty, string Cost,
string Total, string Size, string Uom, string Pack);
// Port of save.asp: transactional InvoiceHeader + InvoiceDetail insert with
// duplicate checks and PO auto-generation under update locks.
public class SaveService
{
private readonly Db _db;
private readonly AppConfig _cfg;
private readonly TablesConfig _t;
private readonly BatchService _batch;
public SaveService(Db db, AppConfig cfg, BatchService batch)
{
_db = db;
_cfg = cfg;
_t = cfg.Tables;
_batch = batch;
}
// sourceFile: the pending source to archive as {vendorId}_{invoice}.{ext}.
// Copied inside the transaction, after the duplicate check and before
// commit, so a rejected save can never overwrite an existing archive and a
// failed copy leaves no header row behind.
public JsonObject Save(string vendorId, string invoiceNum, string invoiceDate,
string poNumber, string vendorTotal, List<SaveLineItem> items,
string? sourceFile, string sourceExt)
{
invoiceNum = Truncate(invoiceNum.Trim(), 14);
poNumber = Truncate(poNumber.Trim(), 12);
var started = DateTime.UtcNow;
Log.Info("save", $"vendor={vendorId} invoice={invoiceNum} date={invoiceDate} " +
$"po={(poNumber == "" ? "(auto)" : poNumber)} lines={items.Count}");
if (vendorId == "")
return FailLogged("Vendor is required");
if (invoiceNum == "")
return FailLogged("Invoice number is required");
if (!Util.IsIsoDate(invoiceDate))
return FailLogged("Invoice date is required in YYYY-MM-DD format");
// Typed parameter: a 'yyyy-MM-dd' string bound to a datetime column is
// read under the login's DATEFORMAT and can flip month and day.
var invoiceDateVal = DateTime.ParseExact(invoiceDate, "yyyy-MM-dd", CultureInfo.InvariantCulture);
if (vendorTotal != "" && !double.TryParse(vendorTotal, NumberStyles.Any, CultureInfo.InvariantCulture, out _))
return FailLogged("Invoice total must be numeric");
if (items.Count > 500) items = items.Take(500).ToList();
double totalQty = items.Sum(it =>
double.TryParse(it.Qty, NumberStyles.Any, CultureInfo.InvariantCulture, out var q) ? q : 0);
using var conn = _db.Open();
using var tx = conn.BeginTransaction();
try
{
// Duplicate vendor/invoice check
using (var cmd = Cmd(conn, tx,
$"SELECT TOP 1 InvoiceNumber FROM {_t.InvoiceHeaderTable} WITH (UPDLOCK, HOLDLOCK) " +
"WHERE InvoiceNumber = @inv AND Vendor = @vendor"))
{
cmd.Parameters.AddWithValue("@inv", invoiceNum);
cmd.Parameters.AddWithValue("@vendor", vendorId);
if (cmd.ExecuteScalar() != null)
{
tx.Rollback();
return FailLogged("Invoice already exists for this vendor");
}
}
// PO collision check
if (poNumber != "")
{
using var cmd = Cmd(conn, tx,
$"SELECT TOP 1 PONumber FROM {_t.InvoiceHeaderTable} WITH (UPDLOCK, HOLDLOCK) " +
"WHERE PONumber = @po");
cmd.Parameters.AddWithValue("@po", poNumber);
if (cmd.ExecuteScalar() != null)
{
tx.Rollback();
return FailLogged("An invoice already exists for this PO number");
}
}
// Auto-generate PO number when none provided (max+1 across both tables,
// inside the transaction with update locks so concurrent saves cannot reuse it).
if (poNumber == "")
{
using var cmd = Cmd(conn, tx,
"SELECT CASE WHEN InvoiceMax > POMax THEN InvoiceMax + 1 ELSE POMax + 1 END AS NextNum," +
" RIGHT(REPLICATE('0', 12) + CAST(CASE WHEN InvoiceMax > POMax THEN InvoiceMax + 1 ELSE POMax + 1 END AS VARCHAR(20)), 12) AS NextPO" +
" FROM (SELECT" +
$" ISNULL((SELECT MAX(TRY_CONVERT(BIGINT, LTRIM(RTRIM(PONumber)))) FROM {_t.InvoiceHeaderTable} WITH (UPDLOCK, HOLDLOCK) WHERE LTRIM(RTRIM(PONumber)) NOT LIKE '%[^0-9]%'), 0) AS InvoiceMax," +
$" ISNULL((SELECT MAX(TRY_CONVERT(BIGINT, LTRIM(RTRIM(PONumber)))) FROM {_t.PoHeaderTable} WITH (UPDLOCK, HOLDLOCK) WHERE LTRIM(RTRIM(PONumber)) NOT LIKE '%[^0-9]%'), 0) AS POMax" +
") AS M");
using var r = cmd.ExecuteReader();
long nextNum = 1;
if (r.Read())
{
nextNum = Convert.ToInt64(r.GetValue(0));
poNumber = Convert.ToString(r.GetValue(1)) ?? "";
}
r.Close();
if (nextNum > 999999999999L)
{
tx.Rollback();
return FailLogged("PO number generation failed: max + 1 exceeds 12 digits");
}
}
// Cost changes are worked out before the header goes in, because
// Price_Cost_Change on the header records whether this invoice
// produced a batch.
var changes = new List<BatchCandidate>();
if (_batch.Enabled)
{
try
{
changes = _batch.FindCostChanges(conn, tx, items
.Where(it => it.Upc.Trim() != "")
.Select(it => (
Truncate(it.Upc.Trim(), 13),
it.Desc.Trim(),
NumOrZero(it.Cost),
NumOrNull(it.Pack))));
}
catch (Exception ex)
{
// A cost comparison that fails must not cost the store its
// invoice; the batch is the part that gets dropped.
Log.Error("batch", "cost comparison failed - saving invoice without a batch", ex);
changes = new List<BatchCandidate>();
}
}
// InvoiceHeader
using (var cmd = Cmd(conn, tx,
$"INSERT INTO {_t.InvoiceHeaderTable} " +
"(PONumber, InvoiceDate, Vendor, InvoiceNumber, VendorTotal, ReceivedInvoice, Price_Cost_Change, InvoiceQuantity) " +
"VALUES (@po, @date, @vendor, @inv, @total, 0, @pcc, @qty)"))
{
cmd.Parameters.AddWithValue("@pcc", changes.Count > 0 ? 1 : 0);
cmd.Parameters.AddWithValue("@po", poNumber);
cmd.Parameters.AddWithValue("@date", invoiceDateVal);
cmd.Parameters.AddWithValue("@vendor", vendorId);
cmd.Parameters.AddWithValue("@inv", invoiceNum);
cmd.Parameters.AddWithValue("@total",
double.TryParse(vendorTotal, NumberStyles.Any, CultureInfo.InvariantCulture, out var vt)
? vt : DBNull.Value);
cmd.Parameters.AddWithValue("@qty", totalQty);
cmd.ExecuteNonQuery();
}
// InvoiceDetail rows (skip rows without a UPC, like save.asp)
foreach (var it in items)
{
var upc = Truncate(it.Upc.Trim(), 13);
if (upc == "") continue;
using var cmd = Cmd(conn, tx,
$"INSERT INTO {_t.InvoiceDetailTable} " +
"(PONumber, UPC, Cert_code, Description, Cost, Total, QuantityCase, Size, UnitOfMeasure, PurchaseType) " +
"VALUES (@po, @upc, @cert, @desc, @cost, @total, @qty, @size, @uom, 0)");
cmd.Parameters.AddWithValue("@po", poNumber);
cmd.Parameters.AddWithValue("@upc", upc);
cmd.Parameters.AddWithValue("@cert",
it.Cert.Trim() == "" ? DBNull.Value : Truncate(it.Cert.Trim(), 15));
cmd.Parameters.AddWithValue("@desc", Truncate(it.Desc.Trim(), 30));
cmd.Parameters.AddWithValue("@cost", NumOrDefault(it.Cost, 0));
cmd.Parameters.AddWithValue("@total", NumOrDefault(it.Total, 0));
cmd.Parameters.AddWithValue("@qty", NumOrDefault(it.Qty, 0));
cmd.Parameters.AddWithValue("@size",
it.Size.Trim() == "" ? DBNull.Value : Truncate(it.Size.Trim(), 10));
cmd.Parameters.AddWithValue("@uom",
it.Uom.Trim() == "" ? "EA" : Truncate(it.Uom.Trim(), 10));
cmd.ExecuteNonQuery();
}
string? batchNo = null;
int flagged = 0;
if (changes.Count > 0)
{
try
{
batchNo = _batch.Create(conn, tx, invoiceNum, vendorId, changes);
}
catch (Exception ex)
{
// The batch shares the invoice's transaction, so this takes
// the whole save down. Say which half failed.
throw new Exception(
"the invoice is valid but the cost batch could not be written (" + ex.Message +
"). Set Batch:Enabled to false in appsettings to save without one.", ex);
}
flagged = _batch.FlaggedCount(changes);
}
if (sourceFile != null)
{
Directory.CreateDirectory(_cfg.ArchiveRoot);
var dest = Path.Combine(_cfg.ArchiveRoot,
Util.SafeFileName(vendorId) + "_" + Util.SafeFileName(invoiceNum) + "." + sourceExt);
File.Copy(sourceFile, dest, true);
}
tx.Commit();
Log.Info("save", $"committed invoice={invoiceNum} po={poNumber} lines={items.Count} " +
$"costChanges={changes.Count} batch={batchNo ?? "none"} flagged={flagged} " +
$"in {(DateTime.UtcNow - started).TotalMilliseconds:0}ms");
var result = new JsonObject { ["success"] = true, ["po_number"] = poNumber };
if (batchNo != null)
{
result["batch_no"] = batchNo;
result["batch_items"] = changes.Count;
result["batch_flagged"] = flagged;
}
return result;
}
catch (Exception ex)
{
try { tx.Rollback(); } catch { }
Log.Error("save", $"rolled back invoice={invoiceNum} vendor={vendorId}", ex);
return Fail("Save failed: " + ex.Message);
}
}
private static SqlCommand Cmd(SqlConnection conn, SqlTransaction tx, string sql)
{
var cmd = conn.CreateCommand();
cmd.Transaction = tx;
cmd.CommandText = sql;
return cmd;
}
private static object NumOrDefault(string s, double def) =>
double.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out var v) ? v : def;
private static double NumOrZero(string s) =>
double.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out var v) ? v : 0;
private static double? NumOrNull(string s) =>
double.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out var v) ? v : null;
private static string Truncate(string s, int n) => s.Length <= n ? s : s[..n];
private static JsonObject Fail(string error) =>
new() { ["success"] = false, ["error"] = error };
private static JsonObject FailLogged(string error)
{
Log.Warn("save", "rejected: " + error);
return Fail(error);
}
}

99
src/Util.cs Normal file
View file

@ -0,0 +1,99 @@
using System.IO;
using System.Text.RegularExpressions;
namespace InvoiceParser.Src;
public static class Util
{
public static string NowIso() =>
DateTime.UtcNow.ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'");
public static bool IsSafePendingId(string? s)
{
if (string.IsNullOrEmpty(s) || s.Length < 4 || s.Length > 80) return false;
return Regex.IsMatch(s, "^[A-Za-z0-9_-]+$");
}
public static string SafeFileName(string s)
{
var chars = s.Select(c =>
(c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' ? c : '_');
return new string(chars.ToArray());
}
public static string CleanDigits(string s) =>
new string(s.Where(char.IsDigit).ToArray());
// Port of UPC_AfterUpdate / Upcdecode / Upcdecode_e from swapps includes/SiteSQL.asp.
// This is the house UPC standard - newitem and label-processor both store UPCs
// through it, so a product created any other way is one the POS will not find.
// A plain 13-digit left-pad is NOT equivalent: it skips UPC-E expansion and
// leaves the EAN-13 check digit in place.
public static string NormalizeUpcForStorage(string raw)
{
var c = (raw ?? "").Trim();
if (c.Length == 0) return "";
// 13 digits not starting with 0 is an EAN-13: drop the trailing check digit.
if (c.Length == 13 && c[0] != '0') c = c[..12];
var decoded = UpcDecode(c);
var padded = "00" + decoded;
return padded.Length <= 13 ? padded : padded[^13..];
}
private static string UpcDecode(string raw)
{
if (raw.Length == 7 && raw[0] == '0') return UpcDecodeE(raw);
if (raw.Length == 11) return raw;
var s = "0000000000000" + raw;
return s[^13..];
}
// UPC-E to UPC-A expansion. The last digit selects the layout.
private static string UpcDecodeE(string r)
{
var last = r[6];
if (last <= '2') return r[..3] + r[6] + "0000" + r.Substring(3, 3);
if (last == '3') return r[..4] + "00000" + r.Substring(4, 2);
if (last == '4') return r[..5] + "00000" + r.Substring(5, 1);
return r[..6] + "0000" + r.Substring(6, 1);
}
public static bool IsIsoDate(string? s)
{
if (s == null || s.Length != 10) return false;
return DateTime.TryParseExact(s, "yyyy-MM-dd", null,
System.Globalization.DateTimeStyles.None, out _);
}
// Escapes LIKE wildcards so user input cannot widen a pattern.
public static string EscapeLike(string s) =>
s.Replace("[", "[[]").Replace("%", "[%]").Replace("_", "[_]");
public static string ExtFromMime(string? mime)
{
var s = (mime ?? "").ToLowerInvariant();
if (s.Contains("jpeg") || s.Contains("jpg")) return "jpg";
if (s.Contains("png")) return "png";
return "pdf";
}
public static string MimeFromExt(string ext) => ext.ToLowerInvariant() switch
{
"pdf" => "application/pdf",
"jpg" or "jpeg" => "image/jpeg",
"png" => "image/png",
_ => "application/octet-stream"
};
// Probes dir for base.pdf / .jpg / .jpeg / .png. Returns full path or null.
public static string? FindInvoiceFile(string dir, string baseName, out string ext)
{
foreach (var e in new[] { "pdf", "jpg", "jpeg", "png" })
{
var p = Path.Combine(dir, baseName + "." + e);
if (File.Exists(p)) { ext = e; return p; }
}
ext = "";
return null;
}
}

517
src/WebHost.cs Normal file
View file

@ -0,0 +1,517 @@
using System.IO;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace InvoiceParser.Src;
// Embedded Kestrel server: serves the UI from wwwroot and exposes the API
// the pages call. Everything binds to loopback only.
public class WebHost
{
private readonly AppConfig _cfg;
private WebApplication? _app;
public string BaseUrl { get; private set; } = "";
public WebHost(AppConfig cfg) => _cfg = cfg;
public async Task StartAsync()
{
try
{
_app = Build(_cfg.Port);
await _app.StartAsync();
}
catch (IOException ex)
{
// Configured port taken - fall back to a dynamic one.
Log.Warn("startup", $"port {_cfg.Port} unavailable ({ex.Message}) - using a dynamic port");
_app = Build(0);
await _app.StartAsync();
}
var url = _app.Urls.First();
BaseUrl = url.Replace("127.0.0.1", "127.0.0.1") + "/index.html";
Log.Info("startup", "listening on " + BaseUrl);
}
public async Task StopAsync()
{
Log.Info("shutdown", "stopping web host");
if (_app != null) await _app.StopAsync(TimeSpan.FromSeconds(3));
}
private WebApplication Build(int port)
{
var builder = WebApplication.CreateBuilder(new WebApplicationOptions
{
ContentRootPath = _cfg.BaseDirectory,
WebRootPath = _cfg.WebRoot
});
builder.WebHost.UseUrls($"http://127.0.0.1:{(port == 0 ? "0" : port.ToString())}");
builder.Logging.ClearProviders();
builder.Services.AddSingleton(_cfg);
builder.Services.AddSingleton(_cfg.Llm);
builder.Services.AddSingleton<Db>();
builder.Services.AddSingleton<PendingStore>();
builder.Services.AddSingleton<LlmClient>();
builder.Services.AddSingleton<EventBroadcaster>();
builder.Services.AddSingleton<ProductCatalog>();
builder.Services.AddSingleton<MatchService>();
builder.Services.AddSingleton<BatchService>();
builder.Services.AddSingleton<SaveService>();
builder.Services.AddSingleton<DataService>();
builder.Services.AddSingleton<ParseWorker>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<ParseWorker>());
var app = builder.Build();
// One line per request, before the static-file and endpoint handlers so
// a 404 on a missing asset shows up too. Anything that escapes an
// endpoint lands here as well: Kestrel's own logging is off, so without
// this an unhandled failure is a bare 500 with no record anywhere.
app.Use(async (ctx, next) =>
{
var started = System.Diagnostics.Stopwatch.StartNew();
try
{
await next();
}
catch (Exception ex)
{
Log.Error("http", $"{ctx.Request.Method} {ctx.Request.Path}{ctx.Request.QueryString} threw " +
$"after {started.ElapsedMilliseconds}ms", ex);
throw;
}
if (!Log.Requests && ctx.Response.StatusCode < 400) return;
var line = $"{ctx.Request.Method} {ctx.Request.Path}{ctx.Request.QueryString} " +
$"{ctx.Response.StatusCode} {started.ElapsedMilliseconds}ms";
if (ctx.Response.StatusCode >= 500) Log.Error("http", line);
else if (ctx.Response.StatusCode >= 400) Log.Warn("http", line);
else Log.Info("http", line);
});
app.UseDefaultFiles();
app.UseStaticFiles();
// Warm the product catalog off the startup path so the first invoice
// opened does not pay the load cost. Failures are ignored here; the
// match endpoint reports them properly when it actually needs the data.
var catalog = app.Services.GetRequiredService<ProductCatalog>();
_ = Task.Run(() =>
{
var started = System.Diagnostics.Stopwatch.StartNew();
try
{
catalog.EnsureLoaded();
Log.Info("catalog", $"warmed {catalog.Count} product(s) in {started.ElapsedMilliseconds}ms");
}
catch (Exception ex)
{
Log.Error("catalog", "warm-up failed - the first match will retry", ex);
}
});
MapApi(app);
return app;
}
private static IResult Json(JsonNode node) =>
Results.Text(node.ToJsonString(), "application/json");
private static IResult JsonText(string json) =>
Results.Text(json, "application/json");
private void MapApi(WebApplication app)
{
var store = app.Services.GetRequiredService<PendingStore>();
var worker = app.Services.GetRequiredService<ParseWorker>();
var events = app.Services.GetRequiredService<EventBroadcaster>();
var match = app.Services.GetRequiredService<MatchService>();
var catalog = app.Services.GetRequiredService<ProductCatalog>();
var save = app.Services.GetRequiredService<SaveService>();
var data = app.Services.GetRequiredService<DataService>();
// ---------- upload ----------
app.MapPost("/api/upload", async (HttpRequest req) =>
{
var form = await req.ReadFormAsync();
var results = new JsonArray();
foreach (var file in form.Files)
{
var id = PendingStore.NewId();
try
{
// Prep (text extraction / page rendering) runs in the
// worker, so a batch of scanned PDFs does not hold the
// upload request open while every page renders.
await SaveUpload(store, file, id);
worker.Enqueue(id);
events.StageChanged(id, "queued");
Log.Info("upload", $"{id} accepted \"{file.FileName}\" ({file.Length} bytes, {file.ContentType})");
results.Add(new JsonObject { ["id"] = id, ["ok"] = true, ["file"] = file.FileName });
}
catch (Exception ex)
{
Log.Error("upload", $"{id} rejected \"{file.FileName}\"", ex);
try { store.SetError(id, "Upload prep failed: " + ex.Message); } catch { }
results.Add(new JsonObject { ["id"] = id, ["ok"] = false, ["file"] = file.FileName, ["error"] = ex.Message });
}
}
return Json(new JsonObject { ["results"] = results });
});
// ---------- pending queue ----------
app.MapGet("/api/pending", () =>
{
var arr = new JsonArray();
foreach (var item in store.List())
arr.Add(new JsonObject { ["id"] = item.Id, ["updated_at"] = item.UpdatedAt });
return Json(arr);
});
app.MapGet("/api/pending/{id}", (string id) =>
{
if (!Util.IsSafePendingId(id)) return Json(Err("Invalid pending id"));
var text = store.ReadStateText(id);
return text == null ? Json(Err("Pending state not found")) : JsonText(text);
});
app.MapPost("/api/pending/{id}/state", async (string id, HttpRequest req) =>
{
if (!Util.IsSafePendingId(id)) return Json(Fail("Invalid pending id"));
using var reader = new StreamReader(req.Body);
var body = await reader.ReadToEndAsync();
if (string.IsNullOrWhiteSpace(body)) return Json(Fail("No state JSON received"));
try { JsonNode.Parse(body); } catch { return Json(Fail("Body is not valid JSON")); }
// A late autosave from a page whose draft was just saved or deleted
// must not recreate the folder as a zombie draft.
if (!store.Exists(id)) return Json(Fail("Pending draft no longer exists"));
store.WriteStateText(id, body);
return Json(new JsonObject { ["success"] = true, ["id"] = id });
});
app.MapDelete("/api/pending/{id}", (string id) =>
{
if (!Util.IsSafePendingId(id)) return Json(Fail("Invalid pending id"));
try { store.Delete(id); }
catch (Exception ex)
{
Log.Error("pending", $"delete {id} failed", ex);
return Json(Fail("Pending delete failed: " + ex.Message));
}
Log.Info("pending", $"deleted draft {id}");
return Json(new JsonObject { ["success"] = true });
});
app.MapGet("/api/pending/{id}/file", (string id) =>
{
if (!Util.IsSafePendingId(id)) return Results.BadRequest();
var src = store.FindSource(id, out var ext);
return src == null ? Results.NotFound() : Results.File(src, Util.MimeFromExt(ext));
});
// Preview pages: images are served as themselves; PDFs are rendered
// once to pending/{id}/pages/page_N.jpg and cached.
app.MapGet("/api/pending/{id}/preview", (string id) =>
{
if (!Util.IsSafePendingId(id)) return Json(Err("Invalid pending id"));
var src = store.FindSource(id, out var ext);
if (src == null) return Json(Err("Source file not found"));
if (!ext.Equals("pdf", StringComparison.OrdinalIgnoreCase))
return Json(new JsonObject { ["type"] = "image", ["pages"] = 1 });
var pagesDir = Path.Combine(store.Dir(id), "pages");
int count = Directory.Exists(pagesDir)
? Directory.GetFiles(pagesDir, "page_*.jpg").Length : 0;
if (count == 0)
count = PdfPrep.RenderAllPages(src, pagesDir, 2000);
return Json(new JsonObject { ["type"] = "pdf", ["pages"] = count });
});
app.MapGet("/api/pending/{id}/preview/{n:int}", (string id, int n) =>
{
if (!Util.IsSafePendingId(id)) return Results.BadRequest();
var src = store.FindSource(id, out var ext);
if (src == null) return Results.NotFound();
if (!ext.Equals("pdf", StringComparison.OrdinalIgnoreCase))
return Results.File(src, Util.MimeFromExt(ext));
var page = Path.Combine(store.Dir(id), "pages", $"page_{n}.jpg");
return File.Exists(page) ? Results.File(page, "image/jpeg") : Results.NotFound();
});
app.MapPost("/api/pending/{id}/parse", (string id) =>
{
if (!Util.IsSafePendingId(id)) return Json(Fail("Invalid pending id"));
var stage = store.Stage(id);
if (stage == "") return Json(Fail("Pending state not found"));
if (stage == "processing") return Json(Fail("Already processing"));
store.SetStage(id, "queued");
events.StageChanged(id, "queued");
worker.Enqueue(id);
return Json(new JsonObject { ["success"] = true });
});
app.MapPost("/api/parse-queued", () =>
{
var (queued, waiting) = worker.EnqueueEligible(includeErrors: true);
return Json(new JsonObject
{
["success"] = true,
["queued"] = queued,
["already_waiting"] = waiting
});
});
app.MapGet("/api/stats", () => Guarded(() => data.Stats(store)));
// ---------- live updates (SSE) ----------
app.MapGet("/api/events", async (HttpContext ctx) =>
{
ctx.Response.Headers.ContentType = "text/event-stream";
ctx.Response.Headers.CacheControl = "no-cache";
var (subId, reader) = events.Subscribe();
try
{
await ctx.Response.WriteAsync(": connected\n\n");
await ctx.Response.Body.FlushAsync();
while (!ctx.RequestAborted.IsCancellationRequested)
{
var readTask = reader.WaitToReadAsync(ctx.RequestAborted).AsTask();
var completed = await Task.WhenAny(readTask, Task.Delay(TimeSpan.FromSeconds(25), ctx.RequestAborted));
if (completed == readTask)
{
if (!await readTask) break;
while (reader.TryRead(out var msg))
await ctx.Response.WriteAsync("data: " + msg + "\n\n");
}
else
{
await ctx.Response.WriteAsync(": ping\n\n");
}
await ctx.Response.Body.FlushAsync();
}
}
catch (OperationCanceledException) { }
finally { events.Unsubscribe(subId); }
});
// ---------- reference data ----------
app.MapGet("/api/vendors", () => Guarded(data.Vendors));
app.MapGet("/api/polist", () => Guarded(data.PoList));
app.MapGet("/api/podetail", (string? po) => Guarded(() => data.PoDetail(po?.Trim() ?? "")));
app.MapGet("/api/departments", () => Guarded(data.Departments));
app.MapGet("/api/products/search", (string? q) => Guarded(() => match.SearchProducts(q?.Trim() ?? "")));
// ---------- matching ----------
app.MapPost("/api/match", async (HttpRequest req) =>
{
var form = await req.ReadFormAsync();
var vendor = (form["vendor"].ToString() ?? "").Trim();
int count = int.TryParse(form["item_count"], out var n) ? n : 0;
var items = new List<(string, string, string)>();
for (int i = 0; i < count; i++)
{
items.Add((
form[$"item_{i}_upc"].ToString().Trim(),
form[$"item_{i}_desc"].ToString().Trim(),
form[$"item_{i}_code"].ToString().Trim()));
}
// Report the real reason instead of a bare 500 the UI cannot explain.
var started = System.Diagnostics.Stopwatch.StartNew();
try
{
var result = match.MatchAll(vendor, items);
Log.Info("match", $"vendor={vendor} lines={items.Count} in {started.ElapsedMilliseconds}ms");
return Json(result);
}
catch (Exception ex)
{
Log.Error("match", $"vendor={vendor} lines={items.Count} failed", ex);
return Json(Err("Matching failed: " + ex.Message));
}
});
// ---------- save ----------
app.MapPost("/api/save", async (HttpRequest req) =>
{
var form = await req.ReadFormAsync();
int count = int.TryParse(form["item_count"], out var n) ? n : 0;
var items = new List<SaveLineItem>();
for (int i = 0; i < count; i++)
{
items.Add(new SaveLineItem(
form[$"item_{i}_upc"].ToString(),
form[$"item_{i}_desc"].ToString(),
form[$"item_{i}_cert"].ToString(),
form[$"item_{i}_qty"].ToString(),
form[$"item_{i}_cost"].ToString(),
form[$"item_{i}_total"].ToString(),
form[$"item_{i}_size"].ToString(),
form[$"item_{i}_uom"].ToString(),
form[$"item_{i}_pack"].ToString()));
}
// The pending source is archived inside the save transaction.
string? sourceFile = null;
string sourceExt = "";
var pendingId = form["pending_id"].ToString().Trim();
if (pendingId != "")
{
if (!Util.IsSafePendingId(pendingId)) return Json(Fail("Invalid pending id"));
sourceFile = store.FindSource(pendingId, out sourceExt);
if (sourceFile == null) return Json(Fail("Pending source file not found"));
}
return Json(save.Save(
form["vendor_id"].ToString().Trim(),
form["invoice_number"].ToString(),
form["invoice_date"].ToString().Trim(),
form["po_number"].ToString(),
form["vendor_total"].ToString().Trim(),
items, sourceFile, sourceExt));
});
// ---------- new product ----------
app.MapPost("/api/newproduct", async (HttpRequest req) =>
{
var form = await req.ReadFormAsync();
// The new product must be visible to the next fuzzy match.
catalog.Invalidate();
Log.Info("product", $"create upc={form["upc"]} desc=\"{form["description"]}\" dept={form["dept_id"]}");
return Json(data.CreateProduct(
form["upc"].ToString(), form["description"].ToString(), form["normal_price"].ToString().Trim(),
form["dept_id"].ToString().Trim(), form["cost"].ToString().Trim(), form["item_code"].ToString(),
form["tax"].ToString().Trim(), form["foodstamp"].ToString().Trim(),
form["scale"].ToString().Trim(), form["wicable"].ToString().Trim(),
form["pack"].ToString().Trim(), form["size"].ToString()));
});
// ---------- archive ----------
app.MapGet("/api/archive", (string? vendor, string? inv, string? po, string? date_from,
string? date_to, string? amt_min, string? amt_max, string? product) =>
Guarded(() => data.ArchiveList(
vendor?.Trim() ?? "", inv?.Trim() ?? "", po?.Trim() ?? "",
date_from?.Trim() ?? "", date_to?.Trim() ?? "",
amt_min?.Trim() ?? "", amt_max?.Trim() ?? "", product?.Trim() ?? "")));
app.MapGet("/api/archive/detail", (string? inv, string? v) =>
Guarded(() => data.ArchiveDetail(inv?.Trim() ?? "", v?.Trim() ?? "")));
app.MapMethods("/api/archive/file", new[] { "GET", "HEAD" }, (string? inv, string? v) =>
{
var path = data.ArchiveFile(inv?.Trim() ?? "", v?.Trim() ?? "", out var ext);
return path == null ? Results.NotFound() : Results.File(path, Util.MimeFromExt(ext));
});
// Archive PDF preview rendered server-side, cached next to the archive.
app.MapGet("/api/archive/preview", (string? inv, string? v) =>
{
var path = data.ArchiveFile(inv?.Trim() ?? "", v?.Trim() ?? "", out var ext);
if (path == null) return Json(Err("No file archived for this invoice"));
// version lets the page cache-bust only when the archived file changes.
var srcTime = File.GetLastWriteTimeUtc(path);
if (!ext.Equals("pdf", StringComparison.OrdinalIgnoreCase))
return Json(new JsonObject { ["type"] = "image", ["pages"] = 1, ["version"] = srcTime.Ticks });
var cacheDir = PreviewCacheDir(path);
var firstPage = Path.Combine(cacheDir, "page_1.jpg");
if (File.Exists(firstPage) && File.GetLastWriteTimeUtc(firstPage) < srcTime)
Directory.Delete(cacheDir, true); // re-archived: cached pages are stale
int count = Directory.Exists(cacheDir)
? Directory.GetFiles(cacheDir, "page_*.jpg").Length : 0;
if (count == 0)
count = PdfPrep.RenderAllPages(path, cacheDir, 2000);
return Json(new JsonObject { ["type"] = "pdf", ["pages"] = count, ["version"] = srcTime.Ticks });
});
app.MapGet("/api/archive/preview/{n:int}", (int n, string? inv, string? v) =>
{
var path = data.ArchiveFile(inv?.Trim() ?? "", v?.Trim() ?? "", out var ext);
if (path == null) return Results.NotFound();
if (!ext.Equals("pdf", StringComparison.OrdinalIgnoreCase))
return Results.File(path, Util.MimeFromExt(ext));
var page = Path.Combine(PreviewCacheDir(path), $"page_{n}.jpg");
return File.Exists(page) ? Results.File(page, "image/jpeg") : Results.NotFound();
});
}
private string PreviewCacheDir(string archiveFile) =>
Path.Combine(_cfg.ArchiveRoot, ".preview", Path.GetFileNameWithoutExtension(archiveFile));
// Stores the file and writes the initial queued state.json. The worker
// does the prep (ParseWorker.Prep) when it picks the draft up.
private static async Task SaveUpload(PendingStore store, IFormFile file, string id)
{
// Filename extension first, content type second; anything else is
// rejected up front rather than saved as a bogus .pdf that fails later.
var nameExt = Path.GetExtension(file.FileName ?? "").TrimStart('.').ToLowerInvariant();
string? ext = nameExt switch
{
"pdf" => "pdf",
"jpg" or "jpeg" => "jpg",
"png" => "png",
_ => null
};
if (ext == null)
{
var ct = (file.ContentType ?? "").ToLowerInvariant();
if (ct.Contains("pdf") || ct.Contains("jpeg") || ct.Contains("jpg") || ct.Contains("png"))
ext = Util.ExtFromMime(ct);
}
if (ext == null) throw new Exception("Unsupported file type - PDF, JPG or PNG only");
var dir = store.Dir(id);
Directory.CreateDirectory(dir);
var sourcePath = Path.Combine(dir, "source." + ext);
using (var fs = File.Create(sourcePath))
await file.CopyToAsync(fs);
var now = Util.NowIso();
var state = new JsonObject
{
["version"] = 1,
["id"] = id,
["stage"] = "queued",
["created_at"] = now,
["updated_at"] = now,
["source"] = new JsonObject
{
["file_name"] = file.FileName ?? "",
["mime_type"] = string.IsNullOrEmpty(file.ContentType) ? Util.MimeFromExt(ext) : file.ContentType,
["file_ext"] = ext
},
["prepped"] = false,
["parse_mode"] = "",
["ocr"] = new JsonObject { ["text"] = "", ["method"] = "", ["line_map"] = null },
["page_images"] = new JsonArray(),
["header"] = new JsonObject(),
["line_items"] = new JsonArray(),
["error"] = "",
["llm"] = null,
["defaults"] = null
};
store.WriteState(id, state);
}
// Logging is off, so a DB failure would otherwise be a bare 500 the UI
// cannot explain. Report it as JSON the pages can show.
private static IResult Guarded(Func<JsonNode> query)
{
try { return Json(query()); }
catch (Exception ex)
{
Log.Error("db", "query failed", ex);
return Json(Err("Database error: " + ex.Message));
}
}
private static JsonObject Err(string msg) => new() { ["error"] = msg };
private static JsonObject Fail(string msg) => new() { ["success"] = false, ["error"] = msg };
}

327
wwwroot/archive.html Normal file
View file

@ -0,0 +1,327 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Invoice Archive</title>
<link rel="stylesheet" href="css/style.css">
<script src="js/jquery-3.5.1.min.js"></script>
</head>
<body class="archive-page">
<h1>
Invoice Archive
<a href="index.html">+ New Invoice</a>
<a href="pending.html">Pending</a>
<a href="archive.html">Archive</a>
</h1>
<div id="filterPanel">
<form id="filterForm" onsubmit="return false">
<div class="filter-field">
<label>Vendor</label>
<select name="vendor" id="fVendor" style="width:210px">
<option value="">All Vendors</option>
</select>
</div>
<div class="filter-field">
<label>Invoice #</label>
<input name="inv" id="fInv" style="width:115px">
</div>
<div class="filter-field">
<label>PO #</label>
<input name="po" id="fPo" style="width:115px">
</div>
<div class="filter-field">
<label>From</label>
<input type="date" name="date_from" id="fDateFrom">
</div>
<div class="filter-field">
<label>To</label>
<input type="date" name="date_to" id="fDateTo">
</div>
<div class="filter-field">
<label>Min $</label>
<input name="amt_min" id="fAmtMin" style="width:80px">
</div>
<div class="filter-field">
<label>Max $</label>
<input name="amt_max" id="fAmtMax" style="width:80px">
</div>
<div class="filter-field">
<label>UPC / Cert / Product</label>
<input name="product" id="fProduct" style="width:180px">
</div>
<div class="filter-actions">
<button class="filter-btn primary" id="btnFilter" type="submit">Filter</button>
<button class="filter-btn" id="btnReset" type="button">Reset</button>
</div>
</form>
</div>
<div id="listPanel">
<table>
<thead>
<tr>
<th>Date</th>
<th>Vendor</th>
<th>Invoice #</th>
<th style="text-align:right">Total</th>
</tr>
</thead>
<tbody id="archiveBody"></tbody>
</table>
</div>
<div id="detailLayout">
<div id="archiveLeft">
<div id="pdfPlaceholder">Select an invoice above to preview.</div>
</div>
<div id="archiveRight">
<div id="detailContent" style="display:none">
<div class="detail-top">
<div class="info-card">
<h2>Invoice Header</h2>
<div class="info-row"><span class="info-label">Vendor</span><span class="info-value" id="dVendor"></span></div>
<div class="info-row"><span class="info-label">Invoice #</span><span class="info-value" id="dInvNum"></span></div>
<div class="info-row"><span class="info-label">Date</span><span class="info-value" id="dDate"></span></div>
<div class="info-row"><span class="info-label">PO #</span><span class="info-value" id="dPO"></span></div>
<div class="info-row"><span class="info-label">Total</span><span class="info-value" id="dTotal"></span></div>
</div>
<div class="info-card" id="batchCard">
<h2>Cost Batch <span id="bFlagged" class="batch-flag" style="display:none"></span></h2>
<div id="batchNone" class="batch-none">No cost changes - this invoice created no batch.</div>
<div id="batchBody" style="display:none">
<div class="info-row"><span class="info-label">Batch #</span><span class="info-value" id="bNo"></span></div>
<div class="info-row"><span class="info-label">Created</span><span class="info-value" id="bCreated"></span></div>
<div class="info-row"><span class="info-label">Items</span><span class="info-value" id="bCount"></span></div>
<div class="batch-scroll">
<table id="batchTable">
<thead>
<tr>
<th>UPC</th>
<th>Description</th>
<th title="Unit cost this batch sets">New</th>
<th title="Unit cost on the product file right now - equal to New once the batch is applied">Now</th>
<th>Retail</th>
<th title="Gross margin at the new cost">GM %</th>
</tr>
</thead>
<tbody id="batchBodyRows"></tbody>
</table>
</div>
</div>
</div>
</div>
<div class="info-card">
<h2>Line Items</h2>
<div style="overflow-x:auto">
<table id="itemsTable">
<thead>
<tr>
<th>UPC</th>
<th>Description</th>
<th>Cert</th>
<th>Qty</th>
<th>Case Cost</th>
<th>Total</th>
<th>Size</th>
<th>UOM</th>
</tr>
</thead>
<tbody id="itemsBody"></tbody>
</table>
</div>
</div>
</div>
<div id="noSelection" style="color:#777;padding:40px 20px;text-align:center">Select an invoice above.</div>
</div>
</div>
<script>
$(document).ready(function () {
loadVendors()
loadList()
$('#btnFilter').on('click', loadList)
$('#btnReset').on('click', function () {
$('#filterForm')[0].reset()
loadList()
})
})
async function loadVendors() {
try {
var vendors = await (await fetch('/api/vendors')).json()
if (vendors.error) throw new Error(vendors.error)
var sel = $('#fVendor')
vendors.forEach(function (v) {
sel.append('<option value="' + v.id + '">' + esc(v.name) + '</option>')
})
} catch (e) { showDbError('Vendor list failed to load: ' + e.message) }
}
async function loadList() {
var params = new URLSearchParams({
vendor: $('#fVendor').val() || '',
inv: $('#fInv').val() || '',
po: $('#fPo').val() || '',
date_from: $('#fDateFrom').val() || '',
date_to: $('#fDateTo').val() || '',
amt_min: $('#fAmtMin').val() || '',
amt_max: $('#fAmtMax').val() || '',
product: $('#fProduct').val() || ''
})
try {
var list = await (await fetch('/api/archive?' + params)).json()
if (list.error) throw new Error(list.error)
var body = $('#archiveBody')
body.empty()
list.forEach(function (r) {
body.append('<tr class="inv-row" data-inv="' + esc(r.invoice_number) + '" data-vendor="' + r.vendor + '">' +
'<td>' + esc(r.invoice_date) + '</td>' +
'<td>' + esc(r.vendor_name) + '</td>' +
'<td>' + esc(r.invoice_number) + '</td>' +
'<td style="text-align:right">$' + (parseFloat(r.vendor_total) || 0).toFixed(2) + '</td>' +
'</tr>')
})
if (!list.length) {
body.append('<tr><td colspan="4" style="color:#777;text-align:center;padding:12px">No invoices found.</td></tr>')
}
} catch (e) {
showDbError('Could not load archive list: ' + e.message)
}
}
function showDbError(msg) {
var el = $('#dbWarning')
if (!el.length) el = $('<div id="dbWarning" class="db-warning"></div>').insertAfter('h1')
el.text(msg).show()
}
$(document).on('click', '.inv-row', function () {
$('.inv-row').removeClass('selected')
$(this).addClass('selected')
loadDetail($(this).data('inv'), $(this).data('vendor'))
})
async function loadDetail(inv, vendor) {
$('#noSelection').hide()
$('#detailContent').show()
$('#itemsBody').empty()
setPdfStatus('Loading...', true)
var q = 'inv=' + encodeURIComponent(inv) + '&v=' + encodeURIComponent(vendor)
try {
var detail = await (await fetch('/api/archive/detail?' + q)).json()
if (detail.error) { setPdfStatus(detail.error, false); return }
renderHeader(detail)
renderItems(detail.items || [])
renderBatch(detail.batch)
} catch (e) {
setPdfStatus('Failed to load invoice: ' + e.message, false)
return
}
// Preview pages are rendered server-side; images are just <img> tags.
try {
var info = await (await fetch('/api/archive/preview?' + q)).json()
if (info.error) { setPdfStatus(info.error, false); return }
var left = $('#archiveLeft')
left.find('img').remove()
$('#pdfPlaceholder').hide()
// version changes only when the archived file does, so revisiting an
// invoice reuses the browser-cached pages instead of refetching them.
for (var p = 1; p <= (info.pages || 1); p++) {
left.append($('<img>').attr('src', '/api/archive/preview/' + p + '?' + q + '&ver=' + (info.version || 0)))
}
} catch (e) {
setPdfStatus('Could not load file.', false)
}
}
function renderHeader(d) {
$('#dVendor').text(d.vendor_name || '-')
$('#dInvNum').text(d.invoice_number || '-')
$('#dDate').text(d.invoice_date || '-')
$('#dPO').text(d.po_number || '-')
$('#dTotal').text(d.vendor_total != null ? '$' + parseFloat(d.vendor_total).toFixed(2) : '-')
}
function renderItems(items) {
var tbody = $('#itemsBody')
tbody.empty()
if (!items.length) {
tbody.append('<tr><td colspan="8" style="color:#777;text-align:center;padding:8px">No line items recorded.</td></tr>')
return
}
items.forEach(function (it) {
tbody.append('<tr>' +
'<td>' + esc(it.upc) + '</td>' +
'<td>' + esc(it.desc) + '</td>' +
'<td>' + esc(it.cert) + '</td>' +
'<td>' + (it.qty != null ? it.qty : '') + '</td>' +
'<td>' + (it.cost != null ? '$' + parseFloat(it.cost).toFixed(2) : '') + '</td>' +
'<td>' + (it.total != null ? '$' + parseFloat(it.total).toFixed(2) : '') + '</td>' +
'<td>' + esc(it.size) + '</td>' +
'<td>' + esc(it.uom) + '</td>' +
'</tr>')
})
}
// The price/cost batch this invoice produced. Null when nothing moved, which
// is the normal case for a re-order at the same cost.
function renderBatch(b) {
var rows = $('#batchBodyRows').empty()
if (!b || !b.batch_no) {
$('#batchBody').hide()
$('#bFlagged').hide()
$('#batchNone').show()
return
}
$('#batchNone').hide()
$('#batchBody').show()
$('#bNo').text(b.batch_no)
$('#bCreated').text(b.created || '-')
var items = b.items || []
var threshold = b.margin_threshold != null ? b.margin_threshold : 15
$('#bCount').text(items.length)
var flagged = 0
items.forEach(function (it) {
var low = it.margin != null && it.margin < threshold
if (low) flagged++
rows.append('<tr' + (low ? ' class="batch-low"' : '') + '>' +
'<td>' + esc(it.upc) + '</td>' +
'<td>' + esc(it.desc) + '</td>' +
'<td>' + money(it.cost) + '</td>' +
'<td>' + money(it.current_cost) + '</td>' +
'<td>' + money(it.retail) + '</td>' +
'<td>' + (it.margin != null ? it.margin.toFixed(1) + '%' : '-') + '</td>' +
'</tr>')
})
if (!items.length) {
rows.append('<tr><td colspan="6" style="color:#777;text-align:center;padding:8px">Batch has no rows.</td></tr>')
}
$('#bFlagged')
.text(flagged ? flagged + ' below ' + threshold + '% margin' : '')
.toggle(flagged > 0)
}
function money(v) {
return v != null && v !== '' ? '$' + parseFloat(v).toFixed(3).replace(/0$/, '') : '-'
}
function setPdfStatus(msg, loading) {
$('#archiveLeft').find('img').remove()
$('#pdfPlaceholder').text(msg).css('color', loading ? '#ffee88' : '#888').show()
}
function esc(s) {
return String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
</script>
</body>
</html>

970
wwwroot/css/style.css Normal file
View file

@ -0,0 +1,970 @@
/* Shared styles - ported from the invoiceProject ASP pages (highlight #6699CC). */
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: #333;
color: #eee;
font-family: Arial, sans-serif;
font-size: 13px;
}
h1 {
display: flex;
align-items: center;
background: #444;
color: #6699CC;
padding: 8px 16px;
font-size: 16px;
border-bottom: 1px solid #555;
}
h1 a { color: #aaa; font-size: 12px; font-weight: normal; margin-left: 16px; text-decoration: none; }
h1 a:hover { color: #eee; }
#pageMode { margin-left: 6px; }
#btnTogglePreview, #btnViewParsed {
display: none;
background: #555;
color: #eee;
border: 1px solid #666;
border-radius: 3px;
padding: 3px 10px;
font-size: 11px;
cursor: pointer;
}
#btnViewParsed { margin-left: auto; }
#btnTogglePreview { margin-left: 6px; }
#btnTogglePreview:hover, #btnViewParsed:hover { border-color: #6699CC; color: #6699CC; }
body.review-page #btnTogglePreview, body.review-page #btnViewParsed { display: inline-block; }
/* The review screen owns the whole viewport: nothing outside the preview
and the line-item table scrolls, so the form never needs a browser zoom-out. */
body.review-page { height: 100vh; overflow: hidden; display: flex; flex-direction: column; }
body.review-page h1 { flex-shrink: 0; }
/* Two-column layout */
#layout {
display: none;
gap: 0;
padding: 10px;
flex: 1;
min-height: 0;
}
#leftPanel {
flex: 0 0 38%;
min-width: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
#leftPanel.collapsed, #leftPanel.collapsed + #splitter { display: none; }
#rightPanel {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* Drag handle between the preview and the form */
#splitter {
flex: 0 0 8px;
cursor: col-resize;
background: transparent;
border-radius: 3px;
position: relative;
}
#splitter::after {
content: "";
position: absolute;
top: 0; bottom: 0; left: 3px;
width: 2px;
background: #4a4a4a;
border-radius: 2px;
}
#splitter:hover::after, #splitter.dragging::after { background: #6699CC; }
#canvasWrapper {
flex: 1;
overflow: auto;
background: #222;
border-radius: 4px;
padding: 8px;
}
#previewToolbar {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 6px;
}
#previewToolbar button {
background: #555;
color: #eee;
border: 1px solid #666;
border-radius: 3px;
padding: 2px 9px;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
#previewToolbar button:hover { border-color: #6699CC; color: #6699CC; }
#zoomLevel { color: #bbb; font-size: 11px; min-width: 40px; text-align: center; }
/* One wrapper per rendered page. app.js sets its width for the zoom level;
the image fills it, so percentage-positioned line markers stay aligned. */
.page-wrap { position: relative; width: 100%; }
.page-wrap + .page-wrap { margin-top: 8px; }
.page-wrap img { display: block; width: 100%; height: auto; border-radius: 2px; }
.line-marker {
position: absolute;
left: 0;
width: 16px;
min-height: 3px;
background: rgba(220, 40, 40, 0.8);
border-radius: 0 2px 2px 0;
cursor: pointer;
z-index: 5;
}
.line-marker:hover {
background: #ff3030;
width: 26px;
}
/* The line last jumped to, from either direction. */
.line-marker.active {
background: #ffcc44;
width: 30px;
box-shadow: 0 0 6px rgba(255, 204, 68, 0.7);
}
.line-marker:hover::after {
content: attr(data-tip);
position: absolute;
left: 30px;
top: -4px;
background: #111;
color: #eee;
border: 1px solid #555;
border-radius: 4px;
padding: 3px 8px;
font-size: 11px;
white-space: nowrap;
pointer-events: none;
z-index: 10;
}
#lineItemsBody tr.has-source { cursor: pointer; }
#lineItemsBody tr.has-source input,
#lineItemsBody tr.has-source textarea,
#lineItemsBody tr.has-source select,
#lineItemsBody tr.has-source button { cursor: auto; }
#lineItemsBody tr.has-source button, #lineItemsBody tr.has-source .cand-item { cursor: pointer; }
#lineItemsBody tr.row-flash td { animation: rowFlash 1.6s ease-out; }
@keyframes rowFlash {
0% { background-color: rgba(220, 40, 40, 0.5); }
100% { background-color: transparent; }
}
#statusBar {
display: none;
flex: 0 0 auto;
padding: 7px 12px;
border-radius: 4px;
font-size: 12px;
margin-bottom: 8px;
}
.status-loading { background: #4a4a00; color: #ffee88; }
.status-error { background: #4a0000; color: #ff9999; }
.status-success { background: #004a00; color: #99ff99; }
/* Shown with display:flex by app.js - it is a full-height column so the
header and the action bar stay put and only the item table scrolls. */
#reviewSection {
display: none;
flex-direction: column;
flex: 1;
min-height: 0;
gap: 8px;
}
.form-section {
background: #3a3a3a;
border-radius: 6px;
padding: 10px 12px;
}
.form-section h2 {
display: flex;
align-items: center;
font-size: 13px;
color: #6699CC;
margin-bottom: 8px;
padding-bottom: 5px;
border-bottom: 1px solid #555;
}
.header-section, .actions-section { flex: 0 0 auto; }
.items-section {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.table-scroll { flex: 1; min-height: 0; overflow: auto; }
.actions-section { display: flex; align-items: center; gap: 10px; }
.actions-right { margin-left: auto; display: flex; gap: 8px; }
/* Header fields wrap across the panel width instead of stacking, which
keeps the vertical space for the line items. */
.hdr-grid { display: flex; flex-wrap: wrap; gap: 6px 14px; }
.hdr-field { display: flex; flex-direction: column; gap: 2px; }
.hdr-field > label {
color: #bbb;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.hdr-ctl { display: flex; align-items: center; gap: 6px; }
.hdr-field input, .hdr-field select {
background: #2a2a2a;
border: 1px solid #555;
color: #eee;
padding: 4px 7px;
border-radius: 3px;
font-size: 13px;
}
.hdr-field input:focus, .hdr-field select:focus { outline: none; border-color: #6699CC; }
.field-row {
display: flex;
align-items: center;
margin-bottom: 7px;
gap: 8px;
}
.field-row label {
width: 110px;
text-align: right;
color: #bbb;
flex-shrink: 0;
}
.field-row input, .field-row select {
background: #2a2a2a;
border: 1px solid #555;
color: #eee;
padding: 4px 7px;
border-radius: 3px;
font-size: 13px;
}
.field-row input:focus, .field-row select:focus {
outline: none;
border-color: #6699CC;
}
.field-note { color: #888; font-size: 11px; }
.parsed-hint { color: #777; font-size: 10px; font-style: italic; }
.match-badge {
display: inline-block;
padding: 2px 7px;
border-radius: 10px;
font-size: 11px;
font-weight: bold;
}
.badge-exact { background: #1a4a1a; color: #88ff88; }
.badge-fuzzy { background: #4a3a00; color: #ffcc44; }
.badge-none { background: #4a1a1a; color: #ff8888; }
.warn-badge {
display: inline-block;
margin-left: 5px;
padding: 2px 6px;
background: #4a0000;
color: #ff6666;
border-radius: 3px;
font-size: 10px;
}
#lineItemsTable {
width: 100%;
min-width: 1180px; /* below this the table-scroll wrapper scrolls sideways */
table-layout: fixed;
border-collapse: collapse;
font-size: 11.5px;
}
/* Fixed layout: these widths drive the columns, Description takes the rest. */
#lineItemsTable col.c-upc { width: 92px; }
#lineItemsTable col.c-cert { width: 78px; }
#lineItemsTable col.c-po { width: 44px; }
#lineItemsTable col.c-pocost { width: 56px; }
#lineItemsTable col.c-qty { width: 52px; }
#lineItemsTable col.c-cost { width: 68px; }
#lineItemsTable col.c-pack { width: 50px; }
#lineItemsTable col.c-unitcost { width: 62px; }
#lineItemsTable col.c-retail { width: 58px; }
#lineItemsTable col.c-gm { width: 54px; }
#lineItemsTable col.c-total { width: 64px; }
#lineItemsTable col.c-size { width: 48px; }
#lineItemsTable col.c-uom { width: 42px; }
#lineItemsTable col.c-match { width: 152px; }
#lineItemsTable col.c-del { width: 28px; }
#lineItemsTable th {
background: #444;
color: #6699CC;
padding: 4px 5px;
text-align: left;
border-bottom: 1px solid #555;
white-space: nowrap;
position: sticky;
top: 0;
z-index: 3;
}
#lineItemsTable td {
padding: 3px 4px;
border-bottom: 1px solid #444;
vertical-align: top;
}
#lineItemsTable tr:hover td { background: #3f3f3f; }
#lineItemsTable input {
background: #2a2a2a;
border: 1px solid #555;
color: #eee;
padding: 2px 4px;
border-radius: 2px;
font-size: 11.5px;
width: 100%;
}
#lineItemsTable input:focus { outline: none; border-color: #6699CC; }
#lineItemsBody tr.row-added td { background: rgba(102, 153, 204, 0.08); }
#lineItemsTable textarea.item-desc {
background: #2a2a2a;
border: 1px solid #555;
color: #eee;
padding: 2px 4px;
border-radius: 2px;
font-size: 11.5px;
width: 100%;
min-height: 30px;
resize: vertical;
font-family: Arial, sans-serif;
}
#lineItemsTable textarea.item-desc:focus { outline: none; border-color: #6699CC; }
/* Fixed, not absolute: the table scroller would otherwise clip the dropdown.
app.js positions it against the search box it belongs to. */
.search-results {
display: none;
position: fixed;
background: #2a2a2a;
border: 1px solid #6699CC;
border-radius: 3px;
z-index: 400;
max-height: 260px;
overflow-y: auto;
overscroll-behavior: contain; /* do not chain into the table and close itself */
width: 380px;
}
.search-result-item {
padding: 6px 8px;
cursor: pointer;
font-size: 11px;
border-bottom: 1px solid #3a3a3a;
}
.search-result-item:hover { background: #3a3a3a; }
.search-result-item:hover .search-result-desc { color: #6699CC; }
.search-result-desc { color: #eee; }
.search-result-meta { color: #777; font-size: 10px; }
.search-result-empty { padding: 5px 8px; color: #888; font-size: 11px; }
#lineItemsTable td:last-child, #lineItemsTable td:nth-last-child(2) { position: relative; }
.cand-list { margin-top: 3px; }
.cand-item {
color: #aaa;
font-size: 10px;
cursor: pointer;
padding: 1px 4px;
border: 1px solid #444;
border-radius: 3px;
margin-top: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cand-item:hover { border-color: #6699CC; color: #6699CC; }
.cand-meta { color: #666; }
/* Always available, on every row - the suggested match is not always right. */
.search-toggle {
display: inline-block;
margin-top: 3px;
background: transparent;
border: 1px solid #666;
color: #bbb;
border-radius: 3px;
padding: 1px 7px;
font-size: 10px;
cursor: pointer;
}
.search-toggle:hover { border-color: #6699CC; color: #6699CC; }
/* Sits under "Change product": nothing in the file fits, so make one. */
.btn-new-item {
display: inline-block;
margin-top: 3px;
background: transparent;
border: 1px solid #4c7a4c;
color: #8fce8f;
border-radius: 3px;
padding: 1px 7px;
font-size: 10px;
cursor: pointer;
}
.btn-new-item:hover { border-color: #88ff88; color: #88ff88; }
.search-inline-wrap { margin-top: 3px; }
.search-inline-wrap .product-search { width: 100%; }
.btn-search-cancel {
background: transparent;
border: none;
color: #777;
font-size: 10px;
cursor: pointer;
padding: 2px 0 0;
}
.btn-search-cancel:hover { color: #6699CC; }
#btnSave, #btnScan, #btnDeleteDraft, #btnRestoreDefaults {
background: #6699CC;
color: #111;
border: none;
padding: 7px 18px;
border-radius: 4px;
font-size: 12px;
font-weight: bold;
cursor: pointer;
}
#btnDeleteDraft {
background: #3a2a2a;
color: #ff9999;
border: 1px solid #663333;
}
#btnRestoreDefaults {
background: #4a3a00;
color: #ffcc44;
border: 1px solid #776600;
}
#btnSave:hover, #btnScan:hover, #btnDeleteDraft:hover, #btnRestoreDefaults:hover { opacity: 0.85; }
/* ---------- parsed data viewer ---------- */
#parsedModal {
display: none;
position: fixed;
top: 0; left: 0;
width: 100%; height: 100%;
z-index: 550;
background: rgba(0,0,0,0.75);
}
#parsedModal .parsed-box {
position: absolute;
top: 50%; left: 50%;
transform: translate(-50%,-50%);
background: #3a3a3a;
border: 1px solid #555;
border-radius: 8px;
padding: 16px 18px;
width: min(860px, 92vw);
height: min(700px, 88vh);
display: flex;
flex-direction: column;
}
#parsedModal h2 {
display: flex;
align-items: center;
font-size: 14px;
color: #6699CC;
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid #555;
}
#btnCloseParsed {
margin-left: auto;
background: #555;
color: #eee;
border: 1px solid #666;
border-radius: 3px;
padding: 3px 12px;
font-size: 11px;
cursor: pointer;
}
#btnCloseParsed:hover { border-color: #6699CC; color: #6699CC; }
.parsed-tabs { display: flex; gap: 6px; margin-bottom: 8px; }
.parsed-tab {
background: transparent;
border: 1px solid #555;
color: #aaa;
border-radius: 3px;
padding: 4px 12px;
font-size: 11px;
cursor: pointer;
}
.parsed-tab:hover { color: #eee; }
.parsed-tab.active { border-color: #6699CC; color: #6699CC; background: #333; }
.parsed-pane { flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 6px; }
.parsed-pane .field-note { flex: 0 0 auto; }
#ocrText {
flex: 1;
width: 100%;
background: #2a2a2a;
color: #eee;
border: 1px solid #555;
padding: 8px;
border-radius: 3px;
font-size: 11px;
font-family: monospace;
resize: none;
}
#ocrText:focus { outline: none; border-color: #6699CC; }
#ocrMethod { color: #888; font-size: 11px; font-weight: normal; margin-left: 8px; }
.diff-only {
margin-left: 14px;
color: #aaa;
font-size: 11px;
font-weight: normal;
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
}
.diff-only input { cursor: pointer; }
#compareScroll {
flex: 1;
overflow: auto;
background: #2a2a2a;
border: 1px solid #555;
border-radius: 3px;
padding: 8px;
}
#compareHeader, #compareTable { width: 100%; border-collapse: collapse; font-size: 11.5px; }
#compareHeader { margin-bottom: 14px; }
#compareHeader th, #compareTable th {
background: #444;
color: #6699CC;
padding: 4px 6px;
text-align: left;
border-bottom: 1px solid #555;
position: sticky;
top: -8px;
z-index: 2;
}
#compareHeader td, #compareTable td {
padding: 4px 6px;
border-bottom: 1px solid #3a3a3a;
vertical-align: top;
color: #ccc;
}
#compareHeader th:first-child, #compareTable th:first-child { width: 110px; }
#compareTable th:first-child { width: 34px; }
#compareTable th:last-child { width: 96px; }
#compareBody tr { cursor: pointer; }
#compareBody tr:hover td { background: #3a3a3a; }
.cmp-desc { color: #eee; }
.cmp-meta { color: #777; font-size: 10px; margin-top: 2px; }
.cmp-diff { color: #ffcc44; font-weight: bold; }
.cmp-empty { color: #666; font-style: italic; }
#compareBody tr.has-diff td:first-child { border-left: 3px solid #d0a84c; }
.cmp-none { color: #888; padding: 14px; text-align: center; }
.btn-confirm {
margin-top: 4px;
background: #4a3a00;
color: #ffcc44;
border: 1px solid #776600;
padding: 2px 8px;
border-radius: 3px;
font-size: 10px;
cursor: pointer;
}
.btn-confirm:hover { background: #665200; }
.btn-remove-row {
background: transparent;
border: 1px solid #553333;
color: #cc5555;
width: 22px;
height: 22px;
border-radius: 3px;
cursor: pointer;
font-size: 13px;
line-height: 1;
padding: 0;
}
.btn-remove-row:hover { background: #4a1a1a; color: #ff8888; border-color: #884444; }
.read-only-field {
display: inline-block;
padding: 4px 7px;
color: #ccc;
font-size: 13px;
}
.ro-val {
display: inline-block;
color: #ccc;
font-size: 12px;
padding: 2px 0;
min-width: 20px;
}
.desc-toggle-row { margin-top: 3px; display: flex; align-items: center; gap: 4px; }
.btn-desc-src {
background: transparent;
border: 1px solid #555;
color: #888;
padding: 1px 7px;
border-radius: 2px;
font-size: 10px;
cursor: pointer;
}
.btn-desc-src:hover:not(:disabled) { color: #eee; }
.btn-desc-src.active { border-color: #6699CC; color: #6699CC; background: #333; }
.btn-desc-src:disabled { color: #4d4d4d; border-color: #444; cursor: default; }
.upc-matched { font-size: 12px; color: #eee; }
.upc-parsed-val { font-size: 10px; color: #666; font-style: italic; margin-top: 2px; }
#lineItemsTable tr.po-diff td,
#lineItemsTable tr.po-inv-only td { background: rgba(255,204,68,0.06); }
#lineItemsTable tr.po-match td { background: rgba(80,180,100,0.035); }
.po-value { color: #ccc; font-size: 12px; white-space: nowrap; }
.po-cell-diff { color: #ffcc44; font-weight: bold; }
.cmp-na { color: #555; }
.po-note {
display: inline-block;
margin-left: 5px;
padding: 2px 6px;
background: #4a3a00;
color: #ffcc44;
border-radius: 3px;
font-size: 10px;
font-weight: bold;
}
.po-summary { color: #888; font-size: 11px; font-weight: normal; margin-left: 8px; }
.h2-actions { margin-left: auto; display: flex; gap: 6px; }
#btnAcceptAll, #btnAddLine {
background: #555;
color: #eee;
border: 1px solid #666;
padding: 4px 10px;
border-radius: 3px;
font-size: 11px;
font-weight: bold;
cursor: pointer;
}
#btnAddLine { background: #2a3a35; border-color: #336655; color: #88ffcc; }
#btnAcceptAll:hover, #btnAddLine:hover { border-color: #6699CC; color: #6699CC; }
.search-result-new {
padding: 5px 8px;
cursor: pointer;
font-size: 11px;
color: #6699CC;
border-top: 1px solid #3a3a3a;
}
.search-result-new:hover { background: #3a3a3a; }
/* ---------- Landing dashboard ---------- */
#dashboard {
display: none;
max-width: 980px;
margin: 0 auto;
padding: 26px 16px 40px;
}
.dash-hero {
background: linear-gradient(160deg, #3d3d3d, #353535);
border: 1px solid #555;
border-radius: 10px;
padding: 30px;
margin-bottom: 22px;
}
.dash-hero #uploadZone {
border: 2px dashed #6a6a6a;
border-radius: 8px;
padding: 40px 24px;
text-align: center;
cursor: pointer;
background: #333;
transition: border-color 0.2s, background 0.2s;
}
.dash-hero #uploadZone:hover, .dash-hero #uploadZone.drag-over {
border-color: #6699CC;
background: #3a3a3a;
}
.dash-hero #uploadZone .up-icon { font-size: 40px; display: block; margin-bottom: 10px; }
.dash-hero #uploadZone label {
cursor: pointer;
color: #6699CC;
font-size: 20px;
font-weight: bold;
}
.dash-hero #uploadPrompt { color: #aaa; margin-top: 10px; font-size: 13px; }
.dash-hero #statusBar { margin-top: 14px; }
#uploadZone input[type=file] { display: none; }
.dash-actions { display: flex; gap: 10px; margin-bottom: 22px; flex-wrap: wrap; }
.dash-btn {
background: #444;
color: #eee;
border: 1px solid #666;
border-radius: 5px;
padding: 9px 18px;
font-size: 13px;
font-weight: bold;
cursor: pointer;
text-decoration: none;
display: inline-block;
}
.dash-btn:hover { border-color: #6699CC; color: #6699CC; }
.dash-btn.primary { background: #6699CC; color: #111; border-color: #6699CC; }
.dash-btn.primary:hover { opacity: 0.85; color: #111; }
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 12px;
}
.stat-card {
background: #3a3a3a;
border: 1px solid #4d4d4d;
border-left: 4px solid #666;
border-radius: 8px;
padding: 16px 18px;
text-decoration: none;
color: #eee;
display: block;
transition: transform 0.1s, border-color 0.2s;
}
.stat-card:hover { transform: translateY(-2px); border-color: #777; }
.stat-num { font-size: 30px; font-weight: bold; line-height: 1; }
.stat-label { color: #aaa; font-size: 12px; margin-top: 7px; text-transform: uppercase; letter-spacing: 0.5px; }
.stat-sub { color: #777; font-size: 11px; margin-top: 3px; }
.sc-ready { border-left-color: #4caf50; }
.sc-ready .stat-num { color: #88ff88; }
.sc-queued { border-left-color: #5a8fd0; }
.sc-queued .stat-num { color: #88bbff; }
.sc-progress { border-left-color: #d0a84c; }
.sc-progress .stat-num { color: #ffcc44; }
.sc-error { border-left-color: #c05050; }
.sc-error .stat-num { color: #ff8888; }
.sc-archived .stat-num { color: #ccc; }
.sc-total { border-left-color: #9c7fd0; }
.sc-total .stat-num { color: #bba6ff; }
.sc-value { border-left-color: #4caf50; }
.sc-value .stat-num { color: #88ff88; }
.sc-lines { border-left-color: #5a8fd0; }
.sc-lines .stat-num { color: #88bbff; }
.dash-foot { color: #777; font-size: 11px; margin-top: 16px; text-align: right; }
/* ---------- pending list page ---------- */
#content { padding: 12px; }
#content #statusBar { margin-bottom: 10px; }
table.list { width: 100%; border-collapse: collapse; font-size: 12px; background: #3a3a3a; }
table.list th {
background: #444;
color: #6699CC;
padding: 6px 8px;
text-align: left;
border-bottom: 1px solid #555;
white-space: nowrap;
}
table.list td { padding: 6px 8px; border-bottom: 1px solid #444; vertical-align: top; }
table.list tr:hover td { background: #3f3f3f; }
.muted { color: #888; }
.stage {
display: inline-block;
padding: 2px 7px;
border-radius: 10px;
background: #4a3a00;
color: #ffcc44;
font-size: 11px;
font-weight: bold;
}
.stage-queued { background: #2a3a4a; color: #88bbff; }
.stage-processing { background: #3a3a00; color: #ffee88; }
.stage-parsed { background: #1a4a1a; color: #88ff88; }
.stage-review { background: #1a4a1a; color: #88ff88; }
.stage-editing { background: #1a4a1a; color: #88ff88; }
.stage-error { background: #4a1a1a; color: #ff8888; }
.btn {
border: 1px solid #666;
background: #555;
color: #eee;
border-radius: 3px;
padding: 4px 10px;
font-size: 11px;
cursor: pointer;
text-decoration: none;
display: inline-block;
margin-right: 5px;
}
.btn:hover { border-color: #6699CC; color: #6699CC; }
.btn-delete { border-color: #663333; color: #ff9999; background: #3a2a2a; }
.btn-process { border-color: #336655; color: #88ffcc; background: #2a3a35; }
#queueToolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; }
#queueToolbar .btn-process { padding: 7px 16px; font-size: 13px; font-weight: bold; }
#queueToolbar .btn-process:disabled { opacity: 0.6; cursor: default; }
#emptyState { color: #888; padding: 28px; text-align: center; background: #3a3a3a; border-radius: 5px; display: none; }
/* ---------- archive page ---------- */
body.archive-page { display: flex; flex-direction: column; height: 100vh; overflow: hidden; }
#listPanel { flex-shrink: 0; height: 210px; overflow-y: auto; border-bottom: 2px solid #555; background: #2e2e2e; }
#filterPanel { flex-shrink: 0; background: #333; border-bottom: 1px solid #555; padding: 10px 12px; }
#filterPanel form { display: flex; flex-wrap: wrap; gap: 8px 10px; align-items: flex-end; }
.filter-field { display: flex; flex-direction: column; gap: 3px; }
.filter-field label { color: #aaa; font-size: 11px; }
.filter-field input, .filter-field select {
background: #2a2a2a;
border: 1px solid #555;
color: #eee;
border-radius: 3px;
padding: 4px 6px;
font-size: 12px;
}
.filter-field input:focus, .filter-field select:focus { outline: none; border-color: #6699CC; }
.filter-actions { display: flex; gap: 6px; align-items: center; padding-bottom: 1px; }
.filter-btn {
border: 1px solid #666;
background: #555;
color: #eee;
border-radius: 3px;
padding: 5px 11px;
font-size: 12px;
cursor: pointer;
text-decoration: none;
}
.filter-btn.primary { background: #6699CC; color: #111; border-color: #6699CC; font-weight: bold; }
.filter-btn:hover { opacity: 0.85; }
#listPanel table { width: 100%; border-collapse: collapse; font-size: 12px; }
#listPanel th { background: #3a3a3a; color: #6699CC; padding: 5px 8px; text-align: left; border-bottom: 1px solid #555; position: sticky; top: 0; z-index: 1; white-space: nowrap; }
#listPanel td { padding: 4px 8px; border-bottom: 1px solid #3a3a3a; white-space: nowrap; }
.inv-row { cursor: pointer; }
.inv-row:hover td { background: #3a3a3a; }
.inv-row.selected td { background: #1a3a5a; }
#detailLayout { display: flex; flex: 1; overflow: hidden; }
#archiveLeft { flex: 1; overflow-y: auto; background: #222; padding: 10px; }
#archiveLeft img { max-width: 100%; display: block; border-radius: 2px; }
#archiveLeft img + img { margin-top: 8px; }
#archiveRight { flex: 1; overflow-y: auto; padding: 12px; background: #333; border-left: 1px solid #444; }
#pdfPlaceholder { color: #666; font-size: 12px; padding: 20px; text-align: center; }
.info-card { background: #3a3a3a; border-radius: 5px; padding: 12px 14px; margin-bottom: 12px; }
.info-card h2 { font-size: 13px; color: #6699CC; margin-bottom: 10px; padding-bottom: 5px; border-bottom: 1px solid #555; }
.info-row { display: flex; margin-bottom: 5px; gap: 8px; }
.info-label { color: #aaa; width: 100px; flex-shrink: 0; text-align: right; font-size: 12px; }
.info-value { color: #eee; font-size: 12px; }
/* Header and the batch it produced sit side by side; the batch card takes the
space the header card was leaving empty. Both stretch to the taller of the
two so the header card does not leave a ragged gap beside the batch list. */
.detail-top { display: flex; gap: 12px; align-items: stretch; flex-wrap: wrap; }
.detail-top > .info-card { flex: 1 1 300px; min-width: 0; }
.detail-top > .info-card:first-child { flex: 0 1 auto; }
#batchCard h2 { display: flex; align-items: center; gap: 8px; }
.batch-flag { background: #4a3a00; color: #ffcc44; font-size: 10px; font-weight: bold; padding: 2px 6px; border-radius: 3px; }
.batch-none { color: #888; font-size: 12px; }
.batch-scroll { max-height: 190px; overflow: auto; margin-top: 8px; border-top: 1px solid #555; }
#batchTable { width: 100%; border-collapse: collapse; font-size: 11px; }
#batchTable th { background: #444; color: #6699CC; padding: 4px 5px; text-align: left; border-bottom: 1px solid #555; white-space: nowrap; position: sticky; top: 0; }
#batchTable td { padding: 3px 5px; border-bottom: 1px solid #3a3a3a; white-space: nowrap; }
#batchTable td:nth-child(3), #batchTable td:nth-child(4),
#batchTable td:nth-child(5), #batchTable td:nth-child(6) { text-align: right; }
#batchTable tr.batch-low td { color: #ffcc44; }
#itemsTable { width: 100%; border-collapse: collapse; font-size: 12px; }
#itemsTable th { background: #444; color: #6699CC; padding: 5px 6px; text-align: left; border-bottom: 1px solid #555; white-space: nowrap; }
#itemsTable td { padding: 4px 5px; border-bottom: 1px solid #3a3a3a; vertical-align: top; }
#itemsTable tr:hover td { background: #3f3f3f; }
#itemsTable td:nth-child(4), #itemsTable td:nth-child(5), #itemsTable td:nth-child(6) { text-align: right; }
/* busy overlay */
#busyOverlay {
display: none;
position: fixed;
top: 0; left: 0;
width: 100%; height: 100%;
z-index: 600;
background: rgba(0, 0, 0, 0.55);
align-items: center;
justify-content: center;
}
#busyOverlay .busy-box {
background: #3a3a3a;
border: 1px solid #555;
border-radius: 8px;
padding: 22px 30px;
text-align: center;
min-width: 260px;
}
#busyOverlay .busy-spinner {
width: 30px;
height: 30px;
margin: 0 auto 12px;
border: 3px solid #555;
border-top-color: #6699CC;
border-radius: 50%;
animation: busySpin 0.8s linear infinite;
}
#busyOverlay .busy-msg { color: #ccc; font-size: 13px; }
@keyframes busySpin { to { transform: rotate(360deg); } }
/* new product modal */
#newProductModal {
display: none;
position: fixed;
top: 0; left: 0;
width: 100%; height: 100%;
z-index: 500;
background: rgba(0,0,0,0.75);
}
#newProductModal .modal-box {
position: absolute;
top: 50%; left: 50%;
transform: translate(-50%,-50%);
background: #3a3a3a;
border: 1px solid #555;
border-radius: 8px;
padding: 20px;
width: 420px;
max-height: 90vh;
overflow-y: auto;
}
#newProductModal h2 {
font-size: 14px;
color: #6699CC;
margin-bottom: 14px;
padding-bottom: 8px;
border-bottom: 1px solid #555;
}
/* ---- unit cost / retail / gross margin ---------------------------------- */
/* Computed cells: not inputs, so they read as derived rather than editable. */
.calc-cell {
text-align: right;
padding: 4px 6px;
color: #ccc;
font-variant-numeric: tabular-nums;
}
.calc-cell.cmp-na { color: #666; }
.gm-cell { font-weight: bold; }
.gm-good { color: #88ff88; }
.gm-thin { color: #ffcc66; }
.gm-loss { color: #ff8888; }
.np-select {
background: #2a2a2a;
border: 1px solid #555;
color: #eee;
padding: 4px 7px;
border-radius: 3px;
font-size: 13px;
}
/* Database failure banner (showDbError). Stays until the page reloads. */
.db-warning { display: none; margin: 0 12px 8px; padding: 7px 12px; border-radius: 4px; font-size: 12px; background: #4a0000; color: #ff9999; flex-shrink: 0; }
/* ---- review sanity checks ---------------------------------------------- */
.attention-count { color: #ffcc44; font-weight: bold; }
.attention-none { color: #88ff88; }
.save-blockers { color: #ffcc44; font-size: 11px; margin-left: 12px; }

324
wwwroot/index.html Normal file
View file

@ -0,0 +1,324 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Invoice Parser</title>
<link rel="stylesheet" href="css/style.css">
<script src="js/jquery-3.5.1.min.js"></script>
<script src="js/app.js"></script>
</head>
<body>
<h1>
Invoice Parser <span id="pageMode" style="color:#aaa;font-size:12px;font-weight:normal"></span>
<a href="index.html">+ New Invoice</a>
<a href="pending.html">Pending</a>
<a href="archive.html">Archive</a>
<button id="btnViewParsed" type="button" title="Compare what the parser read against the products it matched">Parsed vs matched</button>
<button id="btnTogglePreview" type="button" title="Hide the invoice preview to give the form the full window">&laquo; Hide preview</button>
</h1>
<!-- ============ Review layout (?pending=<id>) ============ -->
<div id="layout">
<div id="leftPanel">
<div id="uploadPrompt" style="display:none"></div>
<div id="previewToolbar">
<button id="btnZoomOut" type="button" title="Zoom out">&minus;</button>
<span id="zoomLevel">100%</span>
<button id="btnZoomIn" type="button" title="Zoom in">+</button>
<button id="btnZoomFit" type="button" title="Fit the page to the panel width">Fit</button>
<span class="field-note">Ctrl + scroll to zoom</span>
</div>
<div id="canvasWrapper"></div>
<div id="scanSection" style="display:none;align-items:center;gap:10px;padding:4px 0">
<button id="btnScan">Scan Invoice</button>
</div>
</div>
<div id="splitter" title="Drag to resize the preview"></div>
<div id="rightPanel">
<div id="statusBar"></div>
<div id="reviewSection">
<div class="form-section header-section">
<h2>Invoice Header</h2>
<div class="hdr-grid">
<div class="hdr-field">
<label for="vendorSelect">Vendor</label>
<div class="hdr-ctl">
<select id="vendorSelect" name="vendor_id" style="width:210px">
<option value="">-- Select Vendor --</option>
</select>
<span id="vendorBadge" class="match-badge" style="display:none"></span>
</div>
<small class="parsed-hint" id="hintVendor" style="display:none"></small>
</div>
<div class="hdr-field">
<label for="invoiceNumber">Invoice #</label>
<div class="hdr-ctl">
<input id="invoiceNumber" name="invoice_number" style="width:130px" maxlength="14">
</div>
<small class="parsed-hint" id="hintInvoiceNumber" style="display:none"></small>
</div>
<div class="hdr-field">
<label for="invoiceDate">Invoice Date</label>
<div class="hdr-ctl">
<input id="invoiceDate" name="invoice_date" type="date" style="width:135px">
</div>
<small class="parsed-hint" id="hintInvoiceDate" style="display:none"></small>
</div>
<div class="hdr-field">
<label for="poSelect">PO Number</label>
<div class="hdr-ctl">
<select id="poSelect" style="width:230px">
<option value="">No PO (auto-assign)</option>
</select>
</div>
</div>
<div class="hdr-field">
<label for="invoiceTotal">Total</label>
<div class="hdr-ctl">
<input id="invoiceTotal" name="vendor_total" type="number" step="0.01" style="width:100px">
</div>
<small class="parsed-hint" id="hintInvoiceTotal" style="display:none"></small>
</div>
</div>
</div>
<div class="form-section items-section">
<h2>Line Items <span id="lineSummary" class="po-summary"></span><span id="poCompareSummary" class="po-summary"></span>
<span class="h2-actions">
<button id="btnAddLine" type="button" title="Add a line item the parser missed">+ Add Line</button>
<button id="btnAcceptAll" type="button">Accept All</button>
</span>
</h2>
<div class="table-scroll">
<table id="lineItemsTable">
<colgroup>
<col class="c-upc">
<col class="c-desc">
<col class="c-cert">
<col class="c-po">
<col class="c-po">
<col class="c-pocost">
<col class="c-qty">
<col class="c-cost">
<col class="c-pack">
<col class="c-unitcost">
<col class="c-retail">
<col class="c-gm">
<col class="c-total">
<col class="c-size">
<col class="c-uom">
<col class="c-match">
<col class="c-del">
</colgroup>
<thead>
<tr>
<th>UPC</th>
<th>Description</th>
<th>Cert Code</th>
<th title="PO cases">PO Cs</th>
<th title="PO pieces">PO Pc</th>
<th title="PO cost">PO $</th>
<th title="Invoice quantity">Qty</th>
<th title="Cost of one case as billed on the invoice">Case Cost</th>
<th title="Units per case from the product file, or from the invoice PACK/SIZE column when the product has none - edit it to fix the margin">Pack</th>
<th title="Case cost divided by pack">Unit Cost</th>
<th title="Shelf retail from the product file">Retail</th>
<th title="Gross margin: (retail - unit cost) / retail">GM %</th>
<th>Total</th>
<th>Size</th>
<th>UOM</th>
<th>Match</th>
<th></th>
</tr>
</thead>
<tbody id="lineItemsBody"></tbody>
</table>
</div>
</div>
<div class="form-section actions-section">
<span class="field-note">Draft auto-saves to Pending</span>
<span id="saveBlockers" class="save-blockers" style="display:none"></span>
<span class="actions-right">
<button id="btnRestoreDefaults" type="button">Restore Defaults</button>
<button id="btnDeleteDraft" type="button">Delete Draft</button>
<button id="btnSave">Save Invoice</button>
</span>
</div>
</div><!-- /reviewSection -->
</div><!-- /rightPanel -->
</div><!-- /layout -->
<!-- ============ Landing dashboard ============ -->
<div id="dashboard">
<div class="dash-hero">
<div id="uploadZone" onclick="document.getElementById('fileInput').click()">
<input type="file" id="fileInput" accept=".pdf,.jpg,.jpeg,.png" multiple>
<span class="up-icon">&#128228;</span>
<label>Scan Invoices</label>
<div id="uploadPromptDash" style="color:#aaa;margin-top:10px;font-size:13px">Drag and drop or click - PDF or image (JPG, PNG). Select multiple to batch upload. Files parse automatically in the background; review them under Pending.</div>
</div>
<div id="dashStatusBar" style="display:none;padding:7px 12px;border-radius:4px;font-size:12px;margin-top:14px"></div>
</div>
<div class="dash-actions">
<a class="dash-btn primary" href="pending.html">Go to Pending Queue</a>
<button class="dash-btn" id="btnParseQueued" type="button">Parse queued now</button>
<a class="dash-btn" href="archive.html">View Archive</a>
</div>
<div class="stats-grid">
<a class="stat-card sc-ready" href="pending.html" title="Parsed and waiting for your review">
<div class="stat-num" id="statReady">-</div>
<div class="stat-label">Ready to review</div>
</a>
<a class="stat-card sc-queued" href="pending.html" title="Uploaded, waiting for the parser">
<div class="stat-num" id="statQueued">-</div>
<div class="stat-label">Queued</div>
<div class="stat-sub" id="statProcessingSub"></div>
</a>
<a class="stat-card sc-progress" href="pending.html" title="Drafts you have started editing">
<div class="stat-num" id="statProgress">-</div>
<div class="stat-label">In progress</div>
</a>
<a class="stat-card sc-error" href="pending.html" title="Background parsing failed - retry from Pending">
<div class="stat-num" id="statError">-</div>
<div class="stat-label">Errors</div>
</a>
<div class="stat-card" title="Uploaded today">
<div class="stat-num" id="statToday">-</div>
<div class="stat-label">Uploaded today</div>
</div>
<a class="stat-card sc-archived" href="archive.html" title="Completed and archived">
<div class="stat-num" id="statArchived">-</div>
<div class="stat-label">Archived</div>
</a>
<a class="stat-card sc-total" href="pending.html" title="All invoices in the pending queue">
<div class="stat-num" id="statTotal">-</div>
<div class="stat-label">Total pending</div>
</a>
<div class="stat-card sc-value" title="Sum of invoice totals across all pending drafts">
<div class="stat-num" id="statValue">-</div>
<div class="stat-label">Pending value</div>
</div>
<div class="stat-card sc-lines" title="Line items across all pending drafts">
<div class="stat-num" id="statLines">-</div>
<div class="stat-label">Pending line items</div>
</div>
</div>
<div class="dash-foot" id="dashUpdated"></div>
</div><!-- /dashboard -->
<!-- Parsed data viewer -->
<div id="parsedModal">
<div class="modal-box parsed-box">
<h2>
Parsed vs matched
<span id="ocrMethod"></span>
<label class="diff-only"><input type="checkbox" id="chkDiffOnly"> differences only</label>
<button id="btnCloseParsed" type="button">Close</button>
</h2>
<div class="parsed-tabs">
<button class="parsed-tab active" type="button" data-pane="paneCompare">Line items</button>
<button class="parsed-tab" type="button" data-pane="paneText">Extracted text</button>
</div>
<div class="parsed-pane" id="paneCompare" style="display:flex">
<div class="field-note">Left is what the parser read off the invoice; right is the product it was matched to. Differences are highlighted. Click a row to jump to it.</div>
<div id="compareScroll">
<table id="compareHeader">
<thead>
<tr><th>Header field</th><th>Parsed</th><th>In use</th></tr>
</thead>
<tbody id="compareHeaderBody"></tbody>
</table>
<table id="compareTable">
<thead>
<tr><th>#</th><th>Parsed from invoice</th><th>Matched product</th><th>Status</th></tr>
</thead>
<tbody id="compareBody"></tbody>
</table>
</div>
</div>
<div class="parsed-pane" id="paneText" style="display:none">
<div class="field-note">Text pulled off the invoice. Edits are kept with the draft and used if you re-scan.</div>
<textarea id="ocrText"></textarea>
</div>
</div>
</div>
<!-- New Product Modal -->
<div id="newProductModal">
<div class="modal-box">
<h2>Create New Product</h2>
<div class="field-row">
<label>UPC <span style="color:#ff8888">*</span></label>
<input id="np_upc" style="width:180px" maxlength="13" placeholder="e.g. 012345678901">
</div>
<div class="field-row">
<label>Description <span style="color:#ff8888">*</span></label>
<input id="np_desc" style="width:220px" maxlength="30">
</div>
<div class="field-row">
<label>Price <span style="color:#ff8888">*</span></label>
<input id="np_price" type="text" style="width:120px" placeholder="2.99 or 2/5.00">
<small class="field-note">plain price, or n/total for group pricing</small>
</div>
<div class="field-row">
<label>Department <span style="color:#ff8888">*</span></label>
<select id="np_dept" class="np-select" style="width:200px">
<option value="">-- Select Department --</option>
</select>
</div>
<div class="field-row">
<label>Tax</label>
<select id="np_tax" class="np-select" style="width:110px">
<option value="0">0 - No</option>
<option value="1">1 - Yes</option>
</select>
<small class="field-note" id="np_flagsNote">from department</small>
</div>
<div class="field-row">
<label>Food Stamp</label>
<select id="np_fs" class="np-select" style="width:110px">
<option value="0">0 - No</option>
<option value="1">1 - Yes</option>
</select>
</div>
<div class="field-row">
<label>Cost</label>
<input id="np_cost" type="number" step="0.01" min="0" style="width:100px">
</div>
<div class="field-row">
<label>Pack</label>
<input id="np_pack" type="number" step="1" min="0" style="width:100px">
</div>
<div class="field-row">
<label>Size</label>
<input id="np_size" style="width:100px" maxlength="9">
</div>
<div class="field-row">
<label>Item Code</label>
<input id="np_code" style="width:140px" maxlength="15" placeholder="cert / item code">
</div>
<div style="margin-top:14px;display:flex;gap:8px">
<button id="btnCreateProduct" style="background:#6699CC;color:#111;border:none;padding:7px 18px;border-radius:4px;font-size:13px;font-weight:bold;cursor:pointer">Create Product</button>
<button id="btnCloseModal" style="background:#555;color:#eee;border:none;padding:7px 16px;border-radius:4px;font-size:13px;cursor:pointer">Cancel</button>
</div>
<div id="newProductStatus" style="display:none;margin-top:10px;font-size:11px"></div>
</div>
</div>
</body>
</html>

1734
wwwroot/js/app.js Normal file

File diff suppressed because it is too large Load diff

2
wwwroot/js/jquery-3.5.1.min.js vendored Normal file

File diff suppressed because one or more lines are too long

236
wwwroot/pending.html Normal file
View file

@ -0,0 +1,236 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Pending Invoices</title>
<link rel="stylesheet" href="css/style.css">
<script src="js/jquery-3.5.1.min.js"></script>
</head>
<body>
<h1>
Pending Invoices
<a href="index.html">+ New Invoice</a>
<a href="pending.html">Pending</a>
<a href="archive.html">Archive</a>
</h1>
<div id="content">
<div id="queueToolbar">
<button class="btn btn-process" id="btnProcessQueue">Process queued now</button>
<span class="muted" id="queueHint">Parses every invoice still marked <b>queued</b> (and retries errored ones) in the background.</span>
</div>
<div id="statusBar"></div>
<div id="emptyState">No pending invoices.</div>
<table id="pendingTable" class="list" style="display:none">
<thead>
<tr>
<th>Updated</th>
<th>Vendor</th>
<th>Invoice #</th>
<th>Date</th>
<th>PO #</th>
<th>Total</th>
<th>Lines</th>
<th>Status</th>
<th>Source</th>
<th></th>
</tr>
</thead>
<tbody id="pendingBody"></tbody>
</table>
</div>
<script>
$(document).ready(function () {
loadPending()
connectEvents()
})
// Live refresh whenever the background worker changes any item's stage.
function connectEvents() {
try {
var es = new EventSource('/api/events')
var refreshTimer = null
es.onmessage = function (e) {
var msg
try { msg = JSON.parse(e.data) } catch (err) { return }
if (msg.type !== 'stage') return
clearTimeout(refreshTimer)
refreshTimer = setTimeout(loadPending, 400) // coalesce bursts
}
} catch (e) { console.warn('SSE unavailable:', e) }
}
async function loadPending() {
try {
var resp = await fetch('/api/pending')
var list = await resp.json()
list.sort(function (a, b) { return String(b.updated_at || '').localeCompare(String(a.updated_at || '')) })
var body = $('#pendingBody')
if (!list.length) {
body.empty()
$('#pendingTable').hide()
$('#emptyState').show()
setStatus('', '')
return
}
var states = await Promise.all(list.map(function (rec) {
return fetch('/api/pending/' + encodeURIComponent(rec.id))
.then(function (r) { return r.json() })
.catch(function () { return { error: true } })
}))
body.empty()
list.forEach(function (rec, i) {
if (!states[i] || (states[i].error && !states[i].stage)) return
body.append(buildRow(rec, states[i]))
})
$('#emptyState').toggle(body.children().length === 0)
$('#pendingTable').toggle(body.children().length > 0)
var active = $('#pendingBody tr').filter(function () {
var s = $(this).data('stage'); return s === 'queued' || s === 'queue' || s === 'processing'
}).length
if (active) setStatus(active + ' invoice(s) queued/processing in the background - this list refreshes automatically. You can leave this page.', 'loading')
else setStatus('', '')
} catch (e) {
setStatus('Could not load pending invoices: ' + e.message, 'error')
}
}
function buildRow(rec, state) {
var h = state.header || {}
var src = state.source || {}
var vendor = h.vendor_name || h.vendor_hint || ''
if (vendor === '-- Select Vendor --') vendor = ''
var stage = state.stage || 'pending'
var stageLabel = stage.replace(/_/g, ' ')
if (stage === 'error' && state.error) stageLabel += ': ' + state.error
var resumeLabel = (stage === 'parsed') ? 'Review'
: (stage === 'queued' || stage === 'processing') ? 'Open'
: 'Resume'
var parseBtn = (stage === 'queued' || stage === 'error')
? '<button class="btn btn-process btn-parsenow" data-id="' + esc(rec.id) + '">Parse now</button>'
: ''
return '<tr data-id="' + esc(rec.id) + '" data-stage="' + esc(stage) + '">' +
'<td>' + esc(formatDate(rec.updated_at || state.updated_at)) + '</td>' +
'<td>' + esc(vendor || '-') + '</td>' +
'<td>' + esc(h.invoice_number || '-') + '</td>' +
'<td>' + esc(h.invoice_date || '-') + '</td>' +
'<td>' + esc(h.po_number || '-') + '</td>' +
'<td>' + esc(h.invoice_total ? '$' + parseFloat(h.invoice_total).toFixed(2) : '-') + '</td>' +
'<td>' + ((state.line_items || []).length) + '</td>' +
'<td><span class="stage stage-' + esc(stage) + '">' + esc(stageLabel) + '</span></td>' +
'<td class="muted">' + esc(src.file_name || ('pending_' + rec.id)) + '</td>' +
'<td style="white-space:nowrap">' +
'<a class="btn" href="index.html?pending=' + encodeURIComponent(rec.id) + '">' + resumeLabel + '</a>' +
parseBtn +
'<button class="btn btn-restore" data-id="' + esc(rec.id) + '">Restore Defaults</button>' +
'<button class="btn btn-delete" data-id="' + esc(rec.id) + '">Delete</button>' +
'</td>' +
'</tr>'
}
// Queue everything still queued (and errored) - the background worker does the rest.
$('#btnProcessQueue').on('click', async function (e) {
e.preventDefault()
var btn = $(this)
btn.prop('disabled', true)
try {
var resp = await fetch('/api/parse-queued', { method: 'POST' })
var result = await resp.json()
setStatus(result.queued
? 'Queued ' + result.queued + ' invoice(s) - parsing runs in the background and this list updates automatically.'
: 'No queued invoices to parse.', 'success')
} catch (err) {
setStatus('Parse failed: ' + err.message, 'error')
}
btn.prop('disabled', false)
loadPending()
})
$(document).on('click', '.btn-parsenow', async function () {
var btn = $(this)
var id = btn.data('id')
btn.text('Queued...').prop('disabled', true)
try {
var resp = await fetch('/api/pending/' + encodeURIComponent(id) + '/parse', { method: 'POST' })
var result = await resp.json()
if (!result.success) throw new Error(result.error || 'parse failed')
setStatus('Parsing in the background - this list updates automatically.', 'loading')
} catch (e) {
setStatus('Parse failed: ' + e.message, 'error')
}
loadPending()
})
$(document).on('click', '.btn-delete', async function () {
var id = $(this).data('id')
if (!confirm('Delete this pending invoice?')) return
try {
var resp = await fetch('/api/pending/' + encodeURIComponent(id), { method: 'DELETE' })
var result = await resp.json()
if (!result.success) throw new Error(result.error || 'delete failed')
$('tr[data-id="' + id + '"]').remove()
$('#emptyState').toggle($('#pendingBody').children().length === 0)
$('#pendingTable').toggle($('#pendingBody').children().length > 0)
} catch (e) {
setStatus('Delete failed: ' + e.message, 'error')
}
})
$(document).on('click', '.btn-restore', async function () {
var id = $(this).data('id')
if (!confirm('Restore this pending invoice to its original parsed defaults?')) return
try {
var stateResp = await fetch('/api/pending/' + encodeURIComponent(id))
var state = await stateResp.json()
if (state.error && !state.stage) throw new Error(state.error)
if (!state.defaults || !state.defaults.header || !state.defaults.line_items) {
throw new Error('This draft does not have saved defaults.')
}
state.header = state.defaults.header
state.line_items = state.defaults.line_items
state.po_compare_data = state.defaults.po_compare_data || null
state.stage = 'review'
state.updated_at = new Date().toISOString()
var saveResp = await fetch('/api/pending/' + encodeURIComponent(id) + '/state', {
method: 'POST',
body: JSON.stringify(state),
headers: { 'Content-Type': 'application/json; charset=utf-8' }
})
var result = await saveResp.json()
if (!result.success) throw new Error(result.error || 'restore failed')
await loadPending()
} catch (e) {
setStatus('Restore failed: ' + e.message, 'error')
}
})
function setStatus(msg, type) {
var el = $('#statusBar')
el.text(msg).removeClass('status-loading status-error status-success')
if (msg) el.addClass('status-' + type).show()
else el.hide()
}
function formatDate(s) {
if (!s) return ''
var d = new Date(s)
if (!isNaN(d.getTime())) return d.toLocaleString()
return String(s).replace('T', ' ')
}
function esc(s) {
return String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
</script>
</body>
</html>