[WIP] Invoice parsing tool. Uses an LLM (can be self-hosted or hosted) to format data. Matches extracted products with the Retail database.
Find a file
2026-09-30 11:50:04 -07:00
assets Initial Commit 2026-09-16 13:09:19 -07:00
sql initial commit 2026-09-30 11:50:04 -07:00
src initial commit 2026-09-30 11:50:04 -07:00
wwwroot initial commit 2026-09-30 11:50:04 -07:00
.claude initial commit 2026-09-30 11:50:04 -07:00
.gitignore Initial Commit 2026-09-16 13:09:19 -07:00
app.manifest Initial Commit 2026-09-16 13:09:19 -07:00
App.xaml Initial Commit 2026-09-16 13:09:19 -07:00
App.xaml.cs initial commit 2026-09-30 11:50:04 -07:00
appsettings.json initial commit 2026-09-30 11:50:04 -07:00
InvoiceParser.csproj Initial Commit 2026-09-16 13:09:19 -07:00
MainWindow.xaml Initial Commit 2026-09-16 13:09:19 -07:00
MainWindow.xaml.cs Initial Commit 2026-09-16 13:09:19 -07:00
README.md initial commit 2026-09-30 11:50:04 -07:00
Simpleware Exclamation.pdf Initial Commit 2026-09-16 13:09:19 -07:00

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.

On first install, run sql\widen_invoicenumber.sql once (as a login with ALTER rights on posinv) to widen InvoiceNumber from varchar(14) to varchar(100). The app accepts up to 100 characters, so until this has been run, saving an invoice number longer than 14 characters fails.

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)