Initial Commit
This commit is contained in:
commit
c9054e34b3
32 changed files with 8969 additions and 0 deletions
57
.gitignore
vendored
Normal file
57
.gitignore
vendored
Normal 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
5
App.xaml
Normal 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
102
App.xaml.cs
Normal 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
33
InvoiceParser.csproj
Normal 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
13
MainWindow.xaml
Normal 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
48
MainWindow.xaml.cs
Normal 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
111
README.md
Normal 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
1129
Simpleware Exclamation.pdf
Normal file
File diff suppressed because it is too large
Load diff
22
app.manifest
Normal file
22
app.manifest
Normal 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
52
appsettings.json
Normal 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
BIN
assets/InvoiceParser.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
180
src/AppConfig.cs
Normal file
180
src/AppConfig.cs
Normal 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
387
src/BatchService.cs
Normal 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
609
src/DataService.cs
Normal 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
49
src/Db.cs
Normal 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
32
src/EventBroadcaster.cs
Normal 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
219
src/LlmClient.cs
Normal 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
142
src/Log.cs
Normal 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
377
src/MatchService.cs
Normal 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
505
src/ParseWorker.cs
Normal 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
129
src/PdfPrep.cs
Normal 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
117
src/PendingStore.cs
Normal 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
181
src/ProductCatalog.cs
Normal 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
261
src/SaveService.cs
Normal 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
99
src/Util.cs
Normal 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
517
src/WebHost.cs
Normal 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
327
wwwroot/archive.html
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
970
wwwroot/css/style.css
Normal file
970
wwwroot/css/style.css
Normal 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
324
wwwroot/index.html
Normal 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">« 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">−</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">📤</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
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
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
236
wwwroot/pending.html
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Reference in a new issue