From c9054e34b3bb0e2137bdb81f86427925af2d7b49 Mon Sep 17 00:00:00 2001 From: Adal Date: Wed, 16 Sep 2026 13:09:19 -0700 Subject: [PATCH] Initial Commit --- .gitignore | 57 ++ App.xaml | 5 + App.xaml.cs | 102 ++ InvoiceParser.csproj | 33 + MainWindow.xaml | 13 + MainWindow.xaml.cs | 48 + README.md | 111 ++ Simpleware Exclamation.pdf | 1129 +++++++++++++++++++++ app.manifest | 22 + appsettings.json | 52 + assets/InvoiceParser.ico | Bin 0 -> 2880 bytes src/AppConfig.cs | 180 ++++ src/BatchService.cs | 387 +++++++ src/DataService.cs | 609 +++++++++++ src/Db.cs | 49 + src/EventBroadcaster.cs | 32 + src/LlmClient.cs | 219 ++++ src/Log.cs | 142 +++ src/MatchService.cs | 377 +++++++ src/ParseWorker.cs | 505 ++++++++++ src/PdfPrep.cs | 129 +++ src/PendingStore.cs | 117 +++ src/ProductCatalog.cs | 181 ++++ src/SaveService.cs | 261 +++++ src/Util.cs | 99 ++ src/WebHost.cs | 517 ++++++++++ wwwroot/archive.html | 327 ++++++ wwwroot/css/style.css | 970 ++++++++++++++++++ wwwroot/index.html | 324 ++++++ wwwroot/js/app.js | 1734 ++++++++++++++++++++++++++++++++ wwwroot/js/jquery-3.5.1.min.js | 2 + wwwroot/pending.html | 236 +++++ 32 files changed, 8969 insertions(+) create mode 100644 .gitignore create mode 100644 App.xaml create mode 100644 App.xaml.cs create mode 100644 InvoiceParser.csproj create mode 100644 MainWindow.xaml create mode 100644 MainWindow.xaml.cs create mode 100644 README.md create mode 100644 Simpleware Exclamation.pdf create mode 100644 app.manifest create mode 100644 appsettings.json create mode 100644 assets/InvoiceParser.ico create mode 100644 src/AppConfig.cs create mode 100644 src/BatchService.cs create mode 100644 src/DataService.cs create mode 100644 src/Db.cs create mode 100644 src/EventBroadcaster.cs create mode 100644 src/LlmClient.cs create mode 100644 src/Log.cs create mode 100644 src/MatchService.cs create mode 100644 src/ParseWorker.cs create mode 100644 src/PdfPrep.cs create mode 100644 src/PendingStore.cs create mode 100644 src/ProductCatalog.cs create mode 100644 src/SaveService.cs create mode 100644 src/Util.cs create mode 100644 src/WebHost.cs create mode 100644 wwwroot/archive.html create mode 100644 wwwroot/css/style.css create mode 100644 wwwroot/index.html create mode 100644 wwwroot/js/app.js create mode 100644 wwwroot/js/jquery-3.5.1.min.js create mode 100644 wwwroot/pending.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a74fa3a --- /dev/null +++ b/.gitignore @@ -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 +~$* diff --git a/App.xaml b/App.xaml new file mode 100644 index 0000000..d753ee4 --- /dev/null +++ b/App.xaml @@ -0,0 +1,5 @@ + + diff --git a/App.xaml.cs b/App.xaml.cs new file mode 100644 index 0000000..ff5c4d3 --- /dev/null +++ b/App.xaml.cs @@ -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(); + } +} diff --git a/InvoiceParser.csproj b/InvoiceParser.csproj new file mode 100644 index 0000000..c070d01 --- /dev/null +++ b/InvoiceParser.csproj @@ -0,0 +1,33 @@ + + + + WinExe + net8.0-windows + true + enable + enable + InvoiceParser + InvoiceParser + app.manifest + assets\InvoiceParser.ico + + true + + + + + + + + + + + + + + + + + + + diff --git a/MainWindow.xaml b/MainWindow.xaml new file mode 100644 index 0000000..81c1e70 --- /dev/null +++ b/MainWindow.xaml @@ -0,0 +1,13 @@ + + + + + diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs new file mode 100644 index 0000000..7f2fb71 --- /dev/null +++ b/MainWindow.xaml.cs @@ -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(); + } + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..7e8125c --- /dev/null +++ b/README.md @@ -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_` (`_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., pages\page_N.jpg + archive\ {vendorId}_{invoice}. + .preview cache + logs\ invoiceparser-YYYYMMDD.log + webview2_data\ WebView2 profile (created on first run) diff --git a/Simpleware Exclamation.pdf b/Simpleware Exclamation.pdf new file mode 100644 index 0000000..ec2c4b9 --- /dev/null +++ b/Simpleware Exclamation.pdf @@ -0,0 +1,1129 @@ +%PDF-1.6 %âãÏÓ +1 0 obj <>/OCGs[6 0 R]>>/Pages 3 0 R/Type/Catalog>> endobj 2 0 obj <>stream + + + + + application/pdf + + + Simpleware Exclamation + + + Adobe Illustrator 27.3 (Windows) + 2023-06-28T16:02:01-07:00 + 2023-06-28T16:02:01-07:00 + 2023-06-28T16:02:01-07:00 + + + + 256 + 120 + JPEG + /9j/4AAQSkZJRgABAgEASABIAAD/7QAsUGhvdG9zaG9wIDMuMAA4QklNA+0AAAAAABAASAAAAAEA AQBIAAAAAQAB/+4ADkFkb2JlAGTAAAAAAf/bAIQABgQEBAUEBgUFBgkGBQYJCwgGBggLDAoKCwoK DBAMDAwMDAwQDA4PEA8ODBMTFBQTExwbGxscHx8fHx8fHx8fHwEHBwcNDA0YEBAYGhURFRofHx8f Hx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8f/8AAEQgAeAEAAwER AAIRAQMRAf/EAaIAAAAHAQEBAQEAAAAAAAAAAAQFAwIGAQAHCAkKCwEAAgIDAQEBAQEAAAAAAAAA AQACAwQFBgcICQoLEAACAQMDAgQCBgcDBAIGAnMBAgMRBAAFIRIxQVEGE2EicYEUMpGhBxWxQiPB UtHhMxZi8CRygvElQzRTkqKyY3PCNUQnk6OzNhdUZHTD0uIIJoMJChgZhJRFRqS0VtNVKBry4/PE 1OT0ZXWFlaW1xdXl9WZ2hpamtsbW5vY3R1dnd4eXp7fH1+f3OEhYaHiImKi4yNjo+Ck5SVlpeYmZ qbnJ2en5KjpKWmp6ipqqusra6voRAAICAQIDBQUEBQYECAMDbQEAAhEDBCESMUEFURNhIgZxgZEy obHwFMHR4SNCFVJicvEzJDRDghaSUyWiY7LCB3PSNeJEgxdUkwgJChgZJjZFGidkdFU38qOzwygp 0+PzhJSktMTU5PRldYWVpbXF1eX1RlZmdoaWprbG1ub2R1dnd4eXp7fH1+f3OEhYaHiImKi4yNjo +DlJWWl5iZmpucnZ6fkqOkpaanqKmqq6ytrq+v/aAAwDAQACEQMRAD8A8/4snYq7FX6Cfll/5Lby n/2xtP8A+oWPFiyXFXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYqh NYVW0i+VgGVreUMp3BBQ7HFW/wBE6V/yxQf8ik/pirv0TpX/ACxQf8ik/piqE1XStLGl3hFnACIJ KH00/kPtir868WTsVdir9BPyy/8AJbeU/wDtjaf/ANQseLFkuKuxV2KuxV2KuxV2KuxV2KuxV2Ku xV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxVC6t/xyr3/jBL/wAQOKorFXYqhdW/45V7/wAYJf8AiBxV +cGLJ2KuxV+gn5Zf+S28p/8AbG0//qFjxYslxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2Ku xV2KuxV2KuxV2KuxV2KoXVv+OVe/8YJf+IHFUVirsVQurf8AHKvf+MEv/EDir84MWTsVdir9BPyy /wDJbeU/+2Np/wD1Cx4sWS4q7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7F XYq7FULq3/HKvf8AjBL/AMQOKorFXYqhdW/45V7/AMYJf+IHFX5wYsnYq7FX6Cfll/5Lbyn/ANsb T/8AqFjxYslxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KoXVv8A jlXv/GCX/iBxVFYq7FULq3/HKvf+MEv/ABA4q/ODFk7FXYq/QT8sv/JbeU/+2Np//ULHixZLirsV dirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVQurf8cq9/4wS/8QOKorFX YqhdW/45V7/xgl/4gcVfnHNDJDM8Mq8ZI2KOp7MpoRiyWYq7FX6Ffl9ay2fkLy1aTCk1vpVlFIP8 pLdFP4jFin+KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxVC6t/xy r3/jBL/xA4qisVdiqF1b/jlXv/GCX/iBxV8F/mtpP6J/MjzJYgcUTUJ5IlpSkczmVB/wLjFkxTFU doWmvqmt6fpiV531zDbLTrWaQIKf8Fir9GkRERUQBUUAKo6ADYDFi3irsVdirsVdirsVdirsVdir sVdirsVdirsVdirsVdirsVdirsVdirsVdirsVWyxJLE8Ug5RyKVdfEEUIxVR06V5dPtpJDykaJDI e/LiOVfpxVEYqhdT3tDH+zLJFE48UkkVHH0qTir4/wD+cptJ+pfmrNdBaLqdnb3NexKA25/5MYpD yDFLP/yF0kan+bflyFlDJDcNdtXoPqsTTKf+CQYqX3VixdirsVdirsVdirsVdirsVdirsVdirsVd irsVdirsVdirsVdirsVdirsVdirsVdiqF074UniP2op5eXh+8b1V/wCFkGKorFULdfFeWSD7Su8x H+QsbIT/AMFKuKvnL/nMXSaTeWtXVftLc2kzf6pSSMf8M+KQ+bcUvcf+cSNKNx5/1DUGFY7DTnCn wknlRV/4RXxQX1xih2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2Kux V2KoWD4NQu4x9l1inNevJg0ZHypCPxxVFYqhft6qe3oQbe/rv/D0PxxVhf5z/lnP+YXli20m2u47 K5tbtLqOeVSy0WN42Qhd9/Ur9GKh4t/0J75k/wCphs/+RUuKberfkf8Ak5d/lymrte30N/camYAr woy8Eg9Tb4v5jJ+GKl6jih2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2Kux V2KuxV2KoW4iuhdR3FuqOQjxyI7lK1KlTUK9eND9+Ku9TVf+WeD/AJHv/wBUcVbtIbgSTzXARZZS oUIxcCNF2WpVP2ix6d8VcdT00Ghu4a/8ZE/rkPEj3hh4ke8O/Smmf8tcP/IxP64+JHvC+JHvDv0p pn/LXD/yMT+uPiR7wviR7wiQQQCDUHcEZNm7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7 FXYq7FXYq7FXYq7FXYq0zKqlmICgVJOwAGKof9KaZ/y1w/8AIxP65DxI94YeJHvDv0ppn/LXD/yM T+uPiR7wviR7w4anppNBdw1/4yJ/XHxI94XxI94fOd1/vTN/rt+vOYlzeTlzKlkWLsVfSOl/8cy0 /wCMMf8AxAZ1OP6R7nrsf0j3InJs3Yq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq 7FXYq7FXYqgde/44eo/8ws3/ACbOV5voPuLVm+iXuL5zzl3k3Yqq2v8AvTD/AK6/ryUebKPMOuv9 6Zv9dv14y5rLmVLIsXYq+kdL/wCOZaf8YY/+IDOpx/SPc9dj+ke5E5Nm7FXYq7FXYq7FXYq7FXYq 7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FUDr3/HD1H/mFm/5NnK830H3FqzfRL3F855y7ybs VVbX/emH/XX9eSjzZR5h11/vTN/rt+vGXNZcypZFi7FX0jpf/HMtP+MMf/EBnU4/pHueux/SPcic mzdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdiqB17/AI4eo/8AMLN/ ybOV5voPuLVm+iXuL5zzl3k3Yqq2v+9MP+uv68lHmyjzD//Z + + + + uuid:9E3E5C9A8C81DB118734DB58FDDE4BA7 + xmp.did:9fb2722d-cd19-354c-a7d2-0dcd6c3bc425 + uuid:94901307-97d0-4a6f-bf52-d8d85582ae6a + proof:pdf + + uuid:449fb36d-4b5d-498a-8304-90eef12e6b87 + xmp.did:24a8a7ea-fe4f-b642-ae99-8bcfee5ac896 + uuid:9E3E5C9A8C81DB118734DB58FDDE4BA7 + default + + + + + saved + xmp.iid:24a8a7ea-fe4f-b642-ae99-8bcfee5ac896 + 2023-06-28T16:01:43-07:00 + Adobe Illustrator 27.3 (Windows) + / + + + saved + xmp.iid:9fb2722d-cd19-354c-a7d2-0dcd6c3bc425 + 2023-06-28T16:01:57-07:00 + Adobe Illustrator 27.3 (Windows) + / + + + + Basic RGB + Adobe Illustrator + 1 + False + False + + 1000.000000 + 1000.000000 + Points + + + + Cyan + Magenta + Yellow + Black + + + + + + Default Swatch Group + 0 + + + + White + RGB + PROCESS + 255 + 255 + 255 + + + Black + RGB + PROCESS + 0 + 0 + 0 + + + RGB Red + RGB + PROCESS + 255 + 0 + 0 + + + RGB Yellow + RGB + PROCESS + 255 + 255 + 0 + + + RGB Green + RGB + PROCESS + 0 + 255 + 0 + + + RGB Cyan + RGB + PROCESS + 0 + 255 + 255 + + + RGB Blue + RGB + PROCESS + 0 + 0 + 255 + + + RGB Magenta + RGB + PROCESS + 255 + 0 + 255 + + + R=193 G=39 B=45 + RGB + PROCESS + 193 + 39 + 45 + + + R=237 G=28 B=36 + RGB + PROCESS + 237 + 28 + 36 + + + R=241 G=90 B=36 + RGB + PROCESS + 241 + 90 + 36 + + + R=247 G=147 B=30 + RGB + PROCESS + 247 + 147 + 30 + + + R=251 G=176 B=59 + RGB + PROCESS + 251 + 176 + 59 + + + R=252 G=238 B=33 + RGB + PROCESS + 252 + 238 + 33 + + + R=217 G=224 B=33 + RGB + PROCESS + 217 + 224 + 33 + + + R=140 G=198 B=63 + RGB + PROCESS + 140 + 198 + 63 + + + R=57 G=181 B=74 + RGB + PROCESS + 57 + 181 + 74 + + + R=0 G=146 B=69 + RGB + PROCESS + 0 + 146 + 69 + + + R=0 G=104 B=55 + RGB + PROCESS + 0 + 104 + 55 + + + R=34 G=181 B=115 + RGB + PROCESS + 34 + 181 + 115 + + + R=0 G=169 B=157 + RGB + PROCESS + 0 + 169 + 157 + + + R=41 G=171 B=226 + RGB + PROCESS + 41 + 171 + 226 + + + R=0 G=113 B=188 + RGB + PROCESS + 0 + 113 + 188 + + + R=46 G=49 B=146 + RGB + PROCESS + 46 + 49 + 146 + + + R=27 G=20 B=100 + RGB + PROCESS + 27 + 20 + 100 + + + R=102 G=45 B=145 + RGB + PROCESS + 102 + 45 + 145 + + + R=147 G=39 B=143 + RGB + PROCESS + 147 + 39 + 143 + + + R=158 G=0 B=93 + RGB + PROCESS + 158 + 0 + 93 + + + R=212 G=20 B=90 + RGB + PROCESS + 212 + 20 + 90 + + + R=237 G=30 B=121 + RGB + PROCESS + 237 + 30 + 121 + + + R=199 G=178 B=153 + RGB + PROCESS + 199 + 178 + 153 + + + R=153 G=134 B=117 + RGB + PROCESS + 153 + 134 + 117 + + + R=115 G=99 B=87 + RGB + PROCESS + 115 + 99 + 87 + + + R=83 G=71 B=65 + RGB + PROCESS + 83 + 71 + 65 + + + R=198 G=156 B=109 + RGB + PROCESS + 198 + 156 + 109 + + + R=166 G=124 B=82 + RGB + PROCESS + 166 + 124 + 82 + + + R=140 G=98 B=57 + RGB + PROCESS + 140 + 98 + 57 + + + R=117 G=76 B=36 + RGB + PROCESS + 117 + 76 + 36 + + + R=96 G=56 B=19 + RGB + PROCESS + 96 + 56 + 19 + + + R=66 G=33 B=11 + RGB + PROCESS + 66 + 33 + 11 + + + + + + Cold + 1 + + + + C=56 M=0 Y=20 K=0 + RGB + PROCESS + 101 + 200 + 208 + + + C=51 M=43 Y=0 K=0 + RGB + PROCESS + 131 + 139 + 197 + + + C=26 M=41 Y=0 K=0 + RGB + PROCESS + 186 + 155 + 201 + + + + + + Grays + 1 + + + + R=0 G=0 B=0 + RGB + PROCESS + 0 + 0 + 0 + + + R=26 G=26 B=26 + RGB + PROCESS + 26 + 26 + 26 + + + R=51 G=51 B=51 + RGB + PROCESS + 51 + 51 + 51 + + + R=77 G=77 B=77 + RGB + PROCESS + 77 + 77 + 77 + + + R=102 G=102 B=102 + RGB + PROCESS + 102 + 102 + 102 + + + R=128 G=128 B=128 + RGB + PROCESS + 128 + 128 + 128 + + + R=153 G=153 B=153 + RGB + PROCESS + 153 + 153 + 153 + + + R=179 G=179 B=179 + RGB + PROCESS + 179 + 179 + 179 + + + R=204 G=204 B=204 + RGB + PROCESS + 204 + 204 + 204 + + + R=230 G=230 B=230 + RGB + PROCESS + 230 + 230 + 230 + + + R=242 G=242 B=242 + RGB + PROCESS + 242 + 242 + 242 + + + + + + + Adobe PDF library 17.00 + + + + + + + + + + + + + + + + + + + + + + + + + +endstream endobj 3 0 obj <> endobj 5 0 obj <>/Properties<>>>/Thumb 11 0 R/TrimBox[0.0 0.0 1000.0 1000.0]/Type/Page/PieceInfo<>>> endobj 8 0 obj <>/Properties<>>>/Thumb 14 0 R/TrimBox[0.0 0.0 1000.0 1000.0]/Type/Page/PieceInfo<>>> endobj 13 0 obj <>stream +H‰ÄPAÂ@¼ó +> –íÒ«ÕôÔ팶=XÿŸÈöäÁ»!!0̘æÔc3öŒûC� Xc�¡&Æù ¯h9BÐ,Qî:ÅœŒ¸UÇëê¨fMN->@DÉY µ;Y1܉eO�h&.í7²ÀÎÛfÆËð� xc.0Õ;(‰!S�¬®Õ³Ð5¥’°Œ;róØ!Á±×[>stream +8;Z]^9aZCn&:i/Gja$LH?)JI(/_Uo_d*MSWE91R@k?3rn8O%#P'FUHQ5p-^E"9O-> ++;!`kd'u9l!PJKeJLi5)8VDEKOOPdtXAGju_R;h*dV@N[.$Q\gdMn0kC'fU6$[lS1 +s6+W?'1n?4kuu71!%X(b~> +endstream endobj 12 0 obj <> endobj 16 0 obj <> endobj 17 0 obj <>stream +%!PS-Adobe-3.0 +%%Creator: Adobe Illustrator(R) 24.0 +%%AI8_CreatorVersion: 27.3.1 +%%For: (X13) () +%%Title: (Simpleware Exclamation.ai) +%%CreationDate: 6/28/2023 4:02 PM +%%Canvassize: 16383 +%%BoundingBox: 443 -773 1610 -226 +%%HiResBoundingBox: 443.599212646472 -772.667297363281 1609.40987826262 -226.83270263672 +%%DocumentProcessColors: Cyan Magenta Yellow Black +%AI5_FileFormat 14.0 +%AI12_BuildNumber: 629 +%AI3_ColorUsage: Color +%AI7_ImageSettings: 0 +%%RGBProcessColor: 0 0 0 ([Registration]) +%AI3_Cropmarks: 1052.50909090909 -1000 2052.50909090909 0 +%AI3_TemplateBox: 500.5 -500.5 500.5 -500.5 +%AI3_TileBox: 1246.50909090909 -896 1858.50909090909 -104 +%AI3_DocumentPreview: None +%AI5_ArtSize: 14400 14400 +%AI5_RulerUnits: 2 +%AI24_LargeCanvasScale: 1 +%AI9_ColorModel: 1 +%AI5_ArtFlags: 0 0 0 1 0 0 1 0 0 +%AI5_TargetResolution: 800 +%AI5_NumLayers: 1 +%AI17_Begin_Content_if_version_gt:24 4 +%AI10_OpenToVie: -815.654545454545 349.618181818181 0.572916666666667 0 8276.94545454546 8181.81818181818 1614 948 18 0 0 46 87 0 0 0 1 1 0 1 1 0 1 +%AI17_Alternate_Content +%AI9_OpenToView: -815.654545454545 349.618181818181 0.572916666666667 1614 948 18 0 0 46 87 0 0 0 1 1 0 1 1 0 1 +%AI17_End_Versioned_Content +%AI5_OpenViewLayers: 7 +%AI17_Begin_Content_if_version_gt:24 4 +%AI17_Alternate_Content +%AI17_End_Versioned_Content +%%PageOrigin:100 -800 +%AI7_GridSettings: 72 8 72 8 1 0 0.800000011920929 0.800000011920929 0.800000011920929 0.899999976158142 0.899999976158142 0.899999976158142 +%AI9_Flatten: 1 +%AI12_CMSettings: 00.MS +%%EndComments + +endstream endobj 18 0 obj <>stream +%AI24_ZStandard_Data(µ/ýXÄMžWß (ÐØ´Ò@£j VµŸ*>63Hnf¦£§\Ù›v–‡«CáïžAÁ6Ó È =]Ì® �*C=ñ,^O§9¾d?–ùÌIŠåpEejN/ë†u£*8"•2â+Æj%u+~xÄñ,ë5¤1Dq¼ ªhÅf½nj”LJ‚GdNŸÎ0ÅÐ÷ÑMd†gÆQØHyôÊÙ «a�R¶éèDápÍÙGÊá›äHG—’yç¥i°â:å£_ëá*›+lnÌap„ �P}T'VvT—34­6Q=ßqL£&Š$¹>=1…ÝÔ�œ*û.Ö{w±ñ�1[7ÑÍ�• ”͘CÞœQÇÜ´Î×´n¹�²4EžI±dZ?a/43î’jëÄϸ)J¬úëBî(QœL»H™yÔÄݩѸ‰]Œ~57Þ“¹‰^reìÜ ·ˆ•ÕôÇ™Þ_bv«ÑöúŽ~çG«ÿ(‹¹iWÅ_õêŒÞ3U·Q´Í™eJÎÈŠe«S†Y°ffôºá�Õ WW%}X‰+ñ�Ô5)51ô©=æ(jeâä$¤OÇÆœ‚œsýü™Ê¨Ë»º;»á˜¾±›_òÉ•¾ù´ ‹¼UŠG< çV^Í'SÂÌêÌ'ÕŒ’ +“vÐÄëâùÃs�T´î0FržÓÞ¸ +¹ ý|>…É:nâ|™û<*º8Ê„Iú"Ž¢aÍ¢�³©ùG"S/šWŽK�ëû�—ði5w‘!&Š?1¿f� ‡CL y1�Kýjœ 3NÍDR®óýꘪŠÐ­wÃNA>AË8©°‘gÂ#cä³zÃ÷£ò‰}W¾ÓoÈÆæh&Ÿî4¬)mòKìƒXQçi M¾ñrS�+A=ù謱!¿7ÅŠ’Ï õ2±/´lEQmÔÏ\RŠ"�OôäÃvv"�~Ç#+•nvT¿lœH•LŽtDzhj¾vfQÿ±*Ý>3ì(îŠ1I†^ät¶‡¶×ÝŒŠÐt³ÕàU­¶}ƒBQE/ýBäØmê+}ª4Ê(ÆÞŠk¶*úUhÐJŸ2hŽ *бêYIVºwG¯)e�Ù™RéeTtÚí"ƒ¤pùÆŠÚ¹Ó^ˉ.GÄ2+sy°<ç*ÙÓÉĆΎ'RÉIít¤_ƒEÁ2cíæáRX¥tôÚ9\õ•j—²�Š~*G—GÕØÈØ‡c£„¢Ìr£ÛL�*Š’Ÿ�ýˆlŒÐŠ¢ö“íW¦!BQb½J®¢ñ‹™RU5îEF¿ ñ†u¡Ý(ÝIÛ[6µL$fÅ:)Öš‰mŽ\sö³ÖX•E.¶ÛE£BqGeœAâ±,eÚnõŒŠ±NTsÓaçÒGh¤.CEw�^Pˆuô¶7¦(\w2R§‘È‚vÇœž['#uÚ©~6Gâ]¬h$ò«F^3cNøîØ +•ê~¦)kØÜYÆœ8™”êDµaC1E7Qw#S·pF™Âž¨Ñzzµ,aU£N”ãýJ/ŠŸg‚F£“]_.jÝvg bjB=* q�‰*") +rÉ s¤"4Cþ° ÷¤‘A“�lŸÐdRf9¹O?W];ÏíX÷Þ 4Ú0¸B`p„ 6ÐÀ„€ H !ƒ 0h€BÄt°`qÁ2à  >� AÃ(ƒ Dààƒ D(� @„é„P¨@PŒ@ˆp 4 ä +Â\! +&(¨@ +âÎRöËë8+¨‘" +Zej§O� ±h˜„˜� qÖ�ä4ò‹~TWÝb5ÄE´úPJ—Zµ^_….”¡ uèCe !‚Âm6“ɶ84ÌÂ,¼.È„ w¡oœ|Ã×þ.›5"†^C.ÎÜJU®:»ØËƒÃCÃÃÃÂÃîÁê�ü0}�‡‡‡‡;œƒ9”9¸t)ˆBÃéay!Y‡fÑx¬ªÚ 6¤:#ïiB©KKF"·ãNaBk˜‘’"|B„†uˆ8õR’ Ñ -º£±« +[CíênLîéÔ1%3v-güšn> + bºóù n Q‘)4@ˆ e««3TC9„ +"Ô Dh�©?(<,<0<4<8ÈŽá DøÙÑ ƒÝÌè¡j�jŠõ@ 4 f°‚Ïerêpµ:°B„#q8„³0Õ“ü0†¡&„ d¨B„†/,œÂ„vÕ` ·c¨…R€�ÈaaÈÁ 5„ +ó¹1“ÁB„…_Aˆ  + + + + +§`E +6œá WàÃ9œÂÝB„c(a8K9P-�„m i†Y˜[æaBVÈpsÌæ‡ütÙZÛ� +™éñBÇžÅvGE6r� Ò@7Ð5œA¦„R†.TÁL2p� +<š¤ƒ@ˆ� !˜P‚ 8àà%DãB®³±IFD|þ\†B?«!‚ì "ˆZæÛ¦‹‰Pdôê\'¥iâ!¢ê<©øº\‘FÕlˆBŸæÇŠÇ!bîL6-‰¨ì�Ç6ŸI�rÛqZ�æÊ• V¤ªokˆ¸“2Š��:¼á G7/"4üÎçs0kX0³²2ò˜C9”ƒ9˜Ã9Üá + +     "<8ȃ<ð�}èÃ<̃ü@ôPõ`öp÷……†‡‡+…Z¨†z(±šo Ö`ÆãÍÑAˆP >Ý®p†7Üá…Ú®ÀРp4‰ÄaYq€Á¤(ƒ†¡ d†‡‡…††…………/Ü‚-Ô-xaº  + + + + + + +W8S(R°Â(T� +¢ðp‡“ÜoˆCeXsóY†q˜‡¡k›\>+xÁ~°PiÈTttZ r bµ¦¨ª®¤òÀ‡†…ó!B¤Ähµ1„defg7[0k0{09^oÎGWgw÷S¸…c¸†s¸‡“óýܧ×í÷á o8„¸(P¡ +£`R(S8…+((,(0(4(8(<(Ⱥ0 ^ …Z°…[ø‚ÂÂÂÃBÂÊ0   d „eè0˜Á Ä@ ÅPÆp Çp†3(0(0,0,0040408080<0<0HØÐ†6LÃ4@ˆàj †j¨k°kxÃÄA8ã`;�9�|��ÁA¤A„aADáäÄÄŠ<"”†/\´!}ÞËÎánáîgWG7×ãÉ9Xƒ-˜‚ÝÎÌÈlc4±‡r¨†Z(…zYUMµX"8 +Ä6è`àÀ¡a�<Á@ˆP� ."B¢„ˆ +!„0¸@B—}„Gú¥é‰ÝH¶Ö±Ÿ¾ª,¿‘«*‡@ˆ "¥-udŒEC Dà„X" D HP1,@à&hð� ø ƒ j±ìï¥'寉Ú4ð`X„À�!ÐÀ LA¸ÀT0A Ð&`ðTð dÀ�>Ø@#íDù(ÎfQoðx¢üQÖ"¬Š¯Ñq¬q�0Õ ^Ä&h€�,ÀÁH¡pðA0H Ѓ8˜ð0`À�!˜°€�ñL`A( +"p `ƒN0�L�Xb°�(XÀ l€Å”Àƒ&l ÁÔ… D �„ 6X*è`ƒ &l@dðB�°€Aàt°A '8Á x0�8˜PHè á‚ D¨ÕTÅ:7Z­žÝÅãR¼N�½V5jVQs¹Êþ4N0�0à‚ D¨¶BXcV1¶8À +:X|€t°pÁ"ÌMQ`B 48Á 2H@`¢r‹©ÔW”8À„2hà   Aƒƒ4ø ƒd°�„8ˆ d ‚ ˜ T â6ø@ \p„€ÀP𠃇 .€�c†U€ƒ48�0€Á5@á‚ D�á +P tðA|pl°A„8€|�Á.¸B!c +0Á|@¡˜.D"¯í¤†6Ÿ�5;Ú<¬‰Øf±V-›M®ìísäFKÅ6LúA( +$HPXX€xðA 0'šc +HFsÓ!ABš ›–Ùæk{%eß–Ñ6ñÆo‰G«£ÎÈg4&ŸÆÓÖ¸š*Õø&$cÚ«i{¯ñmŸŒ2äÈfO¢¨èV~ëÎæ:Y�µôÖýF‰H»ÔUK®>|âAbÃò|ø„]♤¾3ÉLÉ3#ÚgKÏXÿ[ºÚ¦³§ÿ"1 WU¾ïFEF9ª¢YŽªÈLŽÊfvWrDE)qûf.+¾ç‰nU&œÛÑ í�Ý$e#ËÈQy+ïzÞѧÈŸû¼£KLY]KÆ�¨Xíèw£+Ÿ^®1éȶ¯•ºi<Œ8R–*²ÔÍ„FÑ”õóšgŽ¢2:’)©™šQæ ¡ÞÃc »–†Æ…‘oÔˆ*©1 9³×¹È„jìk¦¢šG\†¨È(¦jôˆ*’Šj3Š»•4tKeøÊ%–^‹Ç�”ËkPÑÉH†�âñð™ÄC¯íáë\‡†�÷ÑÎÐm®áûh…É�¡“ËæžnvÃG_çÃGñ!UQô¨£(yèHCÎk•�*èJ$Dç`o®·H‡‰�]kŒ é +¿1+ŠðãlUášu´rea…�øôË(© ªœIj–éY*H‚l˜tùv27ËV3²ÏW”øõØyµ¥EªýºÕ�¶ÔÖ–ö§ÝñŠ�¶´µªá u|ÙxŽd1¿‹8ˆ3-ÝL|«ý|öYøXÛ&ž!O§&ž ™ØÓR !a±«"YÐD»‘’ ±›*+Îe†â¢Ô("½„ä¤Dñiê!BÞ!/š"s#¦ˆÇH¶w<ã` )YFØ2rV²ª+‹í1+–Ø'Ž[SWŒ‰4K2Ó·1gT´ÛÁ4ÕȬG˜Ý!áàˆº+¢Ñ,+CŽ«˜êM¹ÃH#Vä›&–4²ÙfW·aÖÎh˜¶ Ëf>¨ÂRÏý¾4D¬d/"™�åîó·æEëŽÎEUó»_äwÒ|^’¦Â3¢RFÒý:Ña2bæGÒ­&äŸÉ¥ô£0 â a›�ID„yE¦AÑJÑÅh²(ÒhƆ…‰™ýZb/Ú,&CŒ2HɇŭΪ,2 +C¡‰n'4Ê,Z¼²ÝÝ0 ÉèÔ㸚¦KNFY«bô’¤šb$6ç²Úô¢²*MDšR©áut„±}Ñã\‘Ù¹J^˱JF1õ(¹«è¶Ô ÉI}…4ó­7–L¯±$L7Šêo�DE¬L·‘Ù •=n6Îâèr'7�s�3šøL¯–�3Š¢:Û1}ˆ7ä(¨\d]ê:|râÏ· —Mé¦5îΞžuÜ¢OéŽã¾ã-¬ò)Ý�ݰ?Ä7nÄTúÈkÌÙ±F ÕŒ$ç …çÌ$á¹A'©!iΡ߼ƒîHT«l’›;‹èuVƒŒ½Û̘ MQMHðꦨFŸè¢×J£&DzÔH˜´\ô²gÔĆ�8‘‹.¯5J ߯¡÷L$kɹÖ|Ë�—sßå"7lñrm>é�˜¢2)¡¬êoieÚ¨$¯’AÖøTEv®U´Ù©È2UâSGe’„ù¤È§“$2ÎbØù#�+ k %Óìè9ÓîJ3c£Wv2‘!Z}3¦˜h%=s‘£15Dûð5*öÌÉvW4Ož·òŠ•vªÚ]ò~£�ý‘‰ùLªÙìGs]_ëÙd¯„¶¡«\,c3—ëØ\jÂ×Ì�¬º2±vŽ;¤fZ%9„V3ö¥µÛ‰L‹Š­½µ™�ÑŒVÅ�´?†*|DŸ‘aîÉÔÍëý¨+®Äøj�݉Êvºcb¢R=f1MsJÕ¯Ælâš—gƒx’Ÿœ±ˆìÿCcCd±¡ùÑÜ ªÉQÅõf85¥ +’ÔhÚœî�³�ØíjˆÖA&•Ñ|Æ:,.¾*ùb¨<-_\kv#ÃDóc.:4¼1—*Ïm×8ªÜ˜—稢"5}Í]1$H>­ ›8~LtDþ)*â?ÓφÈnó{U•ܘûdžhh~#£�Ic.FzÓœžUOœ˜t|›7ÜÞå"þo²1&LËëW+ªþùšJ?*¿¾›6öÚ vLÆT£Ê‰Kågçƒ.ŠØOû³ï:z#Ý0)åÉÇ5£æ�¯l®†ÐF£f<+_\5„µ£ºˆ¤¿¬üZ/zb+ +.7&B¾íªd–í½óá¸íˆÌ�šK¤›+¡Pefδ|Jê'£bã.lU“pd›�#ã§TLy>»óW©˜±˜íb·|ldØ^M(G±bxD[¥J[yb‡½Jþ"UÝK&“q)’‡”ÞX‘n÷^~öŽ §åò§u‘lhøÖˆ‹|D"Þ7Šu�ŸÂÜkíuZ9d6 ÍVR¶¡Q±RÚ¥þ1%*31:Ñ•°X[‘n¨¯‰]^7ˆç12"&F¤ ª£3Z‰½Èè|KQ­Ìt¾�Õ¹Q‰}„8Šc¤bnŸñb>ÍØ~òsœÒJ¯rÆ +Û‘rªêP˜Ýfü“áp+c¯Á!½:w584úˆ¬ˆH£:«ºÍ<8B¼šA›Y£ejºª ™ŠrΆ I}êg¼|™V·³ ™ú3ç¸v\b«—¢ÖQ¥»Â×I×ÑšÆ7ßYÔ!¥ *Q=¨ŠÕ¯ºÖ +²+¢9G}±zâ·¦ÃSÑëP|O“k;Æ&ïr†Ä\Vq*“¡øœÞÙê*ß#tÍle3³¤U|£gØ©">…6$c»šR�ùü¼öbRø³7×9ݦoš NE}ˆX²Ÿ—�†¸‚¬²×K®ÆIÁ±¢Ó “ÈÊR*iRº¢‘!¹¹k¹ùÕ;È�:!áˆÜ\L³7ÏØÑX9T6·Õ�ì6o©¹fÉêSE6äR9w^·Þ –Âg®‘¢Õ’ͧ*|V­³t˜*îEŠš ©RÆRœmLÕNqùÉ7¬ƒXŠÎÐXÃDgÆ‹¥Ž*+G‹4,¦<2’oEÙDŠÆ)8«™¢66;U×oʃ6 +ÉaDwÜ2Ò¸Xæ1ih¥u¾Vª£kf}Épê5�)Þò˜ +µ5õ»Už’y-_¦èwVlƒåeªæ+Ê”©ºéã‹Y«ÎÞc†Ã*¬ŒvÝ¥bê§^•½¹ŒZ°¨Ê-zÐÛqìZ½FTï­£†U1ä1«WkÃʱùÙ Žð…>ly\#Üó”Œõ$ví6—ú�EŸãRÆO;^EÇšy¦va…Ø}­ qdÒ6¢¯9Ñ©›«SÍA2ƒFœU�|«|åkŠ3®6ãÄ2¯�‰f˜éäã ³0;Ô“bË4‘+?jqç2ÚéÊÉ!¯KÅnî‡�‘ ñ]hТ8uÏ(Ï9Zaÿ¦Ò[52”G¡²ëÕ�!‰2=5\žY/WÕ+YÝéj‡Y܈”!ÝdW£/wvEV¿³×:Y]¥ZVo‰ãJqEfÑ¡ÿõ³q÷8‰ó˜Äî‘Å—i¸„uJ¶¸¦˜º�“¨mO]LQbø¡‹®_¨ncÚ„Æú7– ûëã—¹a‰ö*M¤E+ÙÑH£!²¥Ý¥'Ÿ‘•;:U¬ª¯ªJŸ¼ªò&Wx²kÙ°BFTcãËÌÈ´!Ñ´)2�ü�pê!ÚÍͤ?ÿHø“eF†îÑL7™SKx7tž�)üœœ~::1¥:ßïWÇ#ÕD|ÌÃÁ¢£ù :›Õ¨¼Á¬Ö•q´Ú*G«dXÕÎFUµ5ÄÄhb´úeÐh¦©ªn2;¹ÞÉÑreegW÷°£#ÑG™Ý‡«ÊEw®#‘á.{}P�Y[VtÔßöc·ÐÆÄV?²Ý�UlVÿTxV}èL¯•PîZhû‘H°È[2!â±6^NêU¬³n¢œ§l¾ŒîºáYU­½¢òTº^SXCÒB±°LäI ë$æ0Ѽ°LôP;õš¯Ú µsmÉlµ–y'_YËÌSØ:c�Ts3i ѬŒMD¤Wi ±ˆ® ½J†"gvÕ—«O§ã%/6¬;vn©»š’¾Q™G‘%kÑ©V©‘#D²X� /$n¯X’eWŠüb&kŒŠdJHw+ž¼‡\dÓv½Ðdã×ÔÙˆÝH9aí%µ�H’ù|>ŸÏ[®_Vµ¹ëõænnÆÿ�ÇHhiÝéuú Óée›©cBI"g<|Õ&"'?* +kÔ3šŽ<åô‹%yÿ‘FS#ÒZ‹ý¬�¼jI¿WY]…dtço³q¥mÜod>Vo­¨CûœfjîìÔ¡ºMj2U kHCu롲!‡JeC²»†oË\Cþ\÷ÙÇ Í© y6"J qÞÙòÙ0�zÐÞÌÌtF‡�븩zˆkØpŠ£C¥ZOÕÕ!ê�FçSžš|Ì ÉGÇÖ››q¹©Ø1C¤îùC(s287Þ^‡”ÈFn”Œ‘!­ )ÀÈ©)¿åtÜ *ûÓ;ÊXû¸qH¥–!5™�¿<Òqæð‡Åljˆ�†Ý é�Q#%CX£ØQœQ¥A33jjæÆ{´�÷¼ñÒo¼õÈ�‘i]uÔäÌ8´å-ÿA¾oê0 Í0™LÃTc “Ij˜„„ˆˆˆˆ,5ƒ¨¦DÃAÔ!t’sé¨>JtóQò�Ò(•¨¥Q:é¸Í6òýnæQª""²ÑQQõ8Š$t.t£::6viÐ%£!6 ñÔ_Õnˆi�ÅÉeÄâ|ØXGÃtÄwÆÑÑQ‰Æiê<\däs»‰íÆÚ‡ýþúÉ£®�G¹M„¦ÐnEÊ&ÿý·»Œ•ðW-‘НfB¹Yí7T2©Í¦tÜo¬ÎÝF/îäßàyž?ÝØ°�ÝìVv»� ³±«¢AÄz˜hÆP;†^äЯ7ˆŒÞqdŽ"ROçvŒgŒÍ1_}ÔcÖ8{΃hÎlœíŒCüÓn6ã%’Üñ;^—u¼¯Ñ>lnîFIŠ$h×ó��ë�ê°7ªg´Q�z�* +2 !€ˆƒ  X`‚ à�˜ðAL0�œ  ‚.Æf%E2¥Ä²IV#Ïú‰ÕNìø*‘£Åj%�$«ŠUE+¢‰�ü âQ�~¼±1:Uií í%æa²R¯]h4â�Ð\)2ßJGh¶£‘HhÓ¥lEµZuì-VÑ„è1ͱV³‰U’Yåu5v¥óœˆ¬®=ÓY‘]o¦»²o˪ÊL/“ÐÌ%UVÖ3ßÙx,‘ëX£Ç#ÚIr–.ù�þO�Ù�UËîÎÅ»óI|«ªHîÜ­ìZ2Õ3«Û¼O1t/+ú|C7+𕽹NȾñÏ\Òù¹äJ?ö;Qb‘›âòÈM)MQhG$7ÅU#·«œIz«¢ðfŒ~Úù3^‘tÖ‘Ô’n%õ·Ó]y­[IïçÇp‚Æ|jj.©DdÒ£¡ ÙNj†#m•™5w‚æNCs=QfEsœô2ã°«“ž$çTkJ7ªM·i�†ÜÇ’1QÙ™••ê8&¬½I§SœÕÉ +IïN£ûòQõTîézÕÏßSÑ%È2s’"¡œJ|js)’͉ªK�r‡~žñIÕDåc£Úɸ©vr™Ú�w¯wjÆI5ùÙàp\�HTÓ_¨^Ç�Çj>NaƾX_i†míb¬]ªµ §¥PìÄ« ª�ŽhýzE¥Å/öc+²‘opHì'wŽ”+%V+rL£fÄM®ß¹ÄxWS9 ™W/»ŒêG�xÍ)6c#n$tT7BchC.R;´ÏÏ^Ž˜ªÒJFy�c�%UFRå1š^*U?£S •ÇT¾*•]–éçF}#Ȫì›ÍYÍ$ªLª#š#º·Êz«HÐhÞ*“¹X$!ÑLë¾w/YæóTŠ8uSšÁ9!Û‰ÌøŸÇëö2žê#Žh<•Q7šf„•´Ñ0±z¶N3L� Jy%ÌLGÔð~:Âcv/bŠu„�°ÇU”ÌÝéh„Jˆhvt×ož²1zb:!¾š~Sr"”¹å˜~77äLrcÂrÃS¼Q“ÑúÈÅ߉x>Mxõ…Eµ5FD1¬�Ñ�Ñ•�~6Rƒ(JÛèVug55J‘"GgFDœšY% +RñŸQël’!5$4™ë¢ë:q÷ØÔe–Iê"Ó“OE•¤¦lvlTÔØÌ™4HX�TëÌ»uö ÅTyØ´�K(è8¤ºãIÔôÍ i� ¹ѰèHØOõûˆÖÌ©†âNP\VÅ¡QøhÌ0TÈ%æ8݉«Õ,#¹Õï§Þêæú¨ÒDT棓t·šgD‘UAV)JÜHsõ–.ºº­F�¯ÞŽQŒÈ†Ïh$K1´#K8¢ÑÃb$V­{�¼¸®Lc•ÝÎÄ—ÍîCÅÒéÓ›dS¤VÑJº—^ieEµŽ�EQ…æÓ|„Æò§„&-–EšcÒnŢ笜ØoH¦sS–CXFÓNîúTëÖnTŸÓêÆM{ã¼úœ•N;1Ñœû¥jü„q~¤™Q$â¢TjÌBK˜™Ano쎕ðh†ú‹²Ó#Á¤K3CÒÌùwjšy+V43ZB©™Vû2ÑÑœ3B6Hâc–vÛEŒl|ñH§uð„�ü4B2¤Ô Ù‰3Sj>v§¹Xï4wÂ$�fHT¦§¢3ê‰b͜ә²#cÖí÷½µÍOJÿç-‘©¯$cžeeƒ&lLªO¥”‘`× K´5º‰Oì9K�Ôðâ×I]fl=A“ˆýšj°ÕrQÊØn¾›–†œèM´ Vz§GÛ#ÿ%ÆZrd4F'£%Ê$4—ÖäФ2¦„ËÒüJg\nÔ„‰ËòœðY©1´¡=ì¢flÌ?©]üScž›ñ8±*Ç|·hœÔ.ˆg5»¨‹z6Žhôe’¹Ó1Ç‹ÏH©Ø»°©2™Ø»Ú½¯bëjÒ_¯v’*û?«ÙËgÙËÕn�2£S™Bc§þ†…Œl˜g<¹ln”Œ•PLí£*—yFìõz˜OÎ{h¼™^C–l $P¹V¯;{Mf USÝý·Ê çËK+úÕ�)v÷×Jä¯�±¾”X«¡WǰôÜ ÍxÆ$ÍÔëls‹+äKÛÙ«n‡C­*ó9µ'&íL¥.ùûWª\*££¸¦×”\ñ¥ÿîKMi(EB¡œÕÕžQ¯(§‡r«ŦØy_jl[åÌÙ‹Øc�ý‘BãÇãq(§™Bc~B‡H&�t9zRm†¦&õ£š[¤ìkêÛåµsÙ_:ž‘l¤“|«’圚Žc¬³s¥¦çˆ®>ºØÑ݆GÕ“:ÚÎnŒ²z IV×ê™­®eŸõlVÖº©^Â9©ú k¼,óuuu•cÙÿ–�‹æïÌh謵‹&�™ÝÔ«ìÈ�Z®”éóHËfµõLxUR룜fV´2«²²*šTËLñþ ©ÝÊÈŠ%TFÅVYÝ�L†/Ö†IÊ�¡N‘)crÜd§Ó:S\ã˜(5D‘VÙOÕó‹�%âªiO‰ÅŸ­/ÖIH"ñèÍÕ�Lß*òHBUj¬¤ìVµ#ϬuÔ+êõ¨Æ9jy®¨6®¹jÍÕeŒWTá;Z1#IW÷t•Z«¬mfk$©‘v4–× ÿ¬¯Úë´¹(á†î+SE2†Ã+õu–iìÈ‹x±òĈìÕ¹%¡_�½rïÈêzc·#G¹X_Ë™2Ê�šúh™2ˆ´Æ¶¾ÖÌ̸²7WceÏOksJÊ·cÙ™=UÈ&û,ɾ¥4ÎÚg«r'{›í,ÕNÝ|›}�TNfmvJ&ŽÏÒŸî¾;#ý¤†ê˜24úíT ¾©2;?ÿdªºÐ…NbùrßyJ‰3e!³šÐï#mÇÍ®y¤VÉNN»òFŧϟÈqlL¬Æœue1½$'dm—lß|7ÎÚœí—T‘ä#Ѳ±rÔ*[—Ï™gT—VMò!—µÄF†>¥õJ4òÔ:Û^¾ARÍùä)Oi)éü¹1Ò2�‰¤Ž¤î&w®´-¥†~W5æÈ­)Õ\YĹ�jÖ”.fBç­ÎïÇHï³Gú‘®¡9ŠÕ”î5tújSgmhŽ´é˜ãÌñ|”Ò�$t¿Îç ’ÙYú_ëò�Hs'$ ½^ÒóIÊ{�¬Ä|Ãv¬ã1Ÿ”Œn¾«¹¨¥›´ü©?Ê’[®éX§Æ±Eš}kù²ì\U�/·ŽÈ•F±Td‹Õø¾…Æ/ë™ÌÔÍ vdLímZôÞ –%ÙÚ¸ +=Ó�Ýôwtíç‘�+Gò9ù¸t«|VÄÏËÕòÓ“©©ÓâÓùMGhšÓž~[[[Ò¸V8ì�˜¦Ä"£sîss{þjþþl\ó>rÉB_Úò˜V¦ÎŽÛ¶tŸßÕ>¯2Ù?Æ7½©i:7n­®VÄÎju;·•0¶«}u«~¸¾Ù™´µêc¼!å°ê×£ ë}~«ªõ-ÕÅ +‡µvÆa­^}/³Ø•]•kF!oªÌxFVõ»£Á!¯75f±>4CS çÄr×�2‰jd˜Ù»ÒCd²äZf§æÞzæ0³\›�çx®Z®>v5rd3e›òI¤õùg Iêä%ØÑ�^G? �‘òH4ëXªˆ'WJ–9"Ý8/ÖOt{báÝ+¬›(,9L¬�Srâ­X=ɃGš*šÎh‘e¢“Ñ6ÉÑ6�=¶!SäIäˆ~:›+$Ú7¬«ó"ÑÍH]�ňwÖÙîŒG—g,jéòÎ8£L•%*r�1¯jê=oŒ9b355Æ,š±÷iÝÄ7ΤY\¶HýÄÆsSc¾¬º˜Éð°ˆõ1CVͰ—+ùªj4wÝЈ†Mω¯®¦öÌê5/ËOæ®ê&¬«ú‰q°H½š]ÕY”0C7c=u,ûÙ,5‹~ç-�_îèLS®Ý­NTÓIx½œÒÒoØñ#•¤öó sÊÊØ ±,Ur¾ñœY̵ԅU£ˆ=›ÞÜô4ÌÈ»˜x$:žØèGSTTš*O§è'ý¨‘®Ec”¶J·¹u´a�wbÉ•é%¤qãY$ßX4=IŸ“d¿‘r©ôm§•Ìêýh„„èŒ&cMÆD�µ:sñþLçhXÚËÜ\RÞEê>vR£¤Õ±~.eæË��’ž‘/º—–Ù¤œº²°sRþ±4tv½Ä¯ÇNÍùX3²Ù©ŒÍ •ŒbÝÞ´ØQÉÔ�èÈÒ¾CZ«³ÞØÒÞÍöÙ³_v3Ž-öR¾í#¾#ºÓ¸´u¹ì§8Êú6å[QGi8—¢«dÍ¿ÛÝõÎ7KCŸ1i¯õn易%½ÎûÌæÖ±Ë�ÎvH¡Lég´!牅Nç]ºY2îXgœ~†~ÍüƘG⛚ÖÕxy£a-UÒ?ªÕjµ:ªcJ-ÇgLgœUkÜ �+~ÆÑ:ŽW%5ÊÆg ‰H¨Û°¹Ú(,+ÙùË-7"§þNd¿^ïW-iîª~ÄÞÉP̼9r³’XɰsùU[a§X}½Œ'òz•T’½�"WQQrv5Uwb¢/5L¬´ýeŠÎ[\^YÊ´²—Qå%$$&3&D,«\JeºêåYÏê.OH+»Üªw(fEuYå¹>¼-§�ìªŒÌŒŠ†tV&õš†öœ»°ÌÇ–è³^óèèì»�ÔtnffŠÌ3_rÑÍ]ÊDâù¤L<“&DUû°øª×ËË×®¨¤ïg6Ã"Ç)¥ÍÌHƒ8(CB±€p–>X›Eá°Š”¥2† pd"{ýR"¨@¡‘«èUŒ·Š\eu©>DLJxzTÿXöôCäÈŸâµruˆ™ä*£‡C¬~ ±­VÀâzXEc‹!âù¸r¼Ú…p±bUˆÕ°Êä¡[°Š.aBÐTÂÙ%Ã*î!¦ƒUÒ !Ê×·‚&~—É`�B5­‚`ö6Öp°Ê¤Ä(­"£0ÈUjeA©WýQ¿Á"v°2ÉQRcåÿs›VeEÔOE6ËXáîºe%ßé³ï~Z‰)l%×)·ë=|B¾pE7r¹"C]ç�˜ ð;ʺ+*¶§+’8Ì]‘ +7¯à�X{¯ Ô�Øy_Q®XäAÌš`½ñ°޵9 Køà,ñ –ôé–ØðãÎÀg¹fËêDX^·gôÀ2R7¸ÿÊš/íi‚ȑߵ¿²\^ˆ‰ \‚˜l`™ér�en:ëJÖš"X`ÙzdË#-–ë@Ôw�ÃtÊ‹H|�ùÙÕ`a~"ê`19 l…õu9vé@Tn’ Â…%È4"ˆÖ.é¶›  ¡ +B0â5™[&.Ÿta¹BÑÁ2¾Q¾u|Àßiß5 ‡¡л°ô‰�Ɇ ²aïfúCÃNˆ<§Þ¸«(ÿ0Ä2R7Še6ýa"Æ¢ü�6x¯Œ©cÑv~`Dd™·øá¸É¢•÷�0C™}°·²hV}àâË2WèÃÐ,Z0¨|p�³(`¬ga/]‹x\ ZêÒ=Ü^{˜£e>ìá™Ò¢f¬ŠTY`=Ì¡Ô2÷ôðÔjQ3zànkçy˜ÿ×¢7óÀÙ2¦òðmQy ¶eãá9Ü¢îðÀmuËâ�ž;oéÓï ¿e½CM‚Ëûºƒˆ4\Æ�µàR,.te;$äqɆ£í ¦ÉEï±ï.—¡À_›‹"nväCW‡�£ËÔW‡ÔE¹Ô�ØY—!œã»8M«wí‚Ëè€Ýeé H¾Ë×zqƒ—­q0,xy‡æ�“xYÉ`(•ÙÃFšyŠå0zÑn¨^ÄŸ}ö2×箼óÅ—Ð[ΗDåþë _‡f�Ñ"ö„¡ÃÛ *V/ûwàpýK% +`¼Êõ +˜35'ÄL³Ðr²ÜÀ@goØ”3d¼aÔ‚QÖn l3ã%Lnƒ‘×A{ Âþmøû$Œ6ÛN¤0C[^oalWÊd › sr³!°%Dn‰ 9ÝaFb~ï$]ÄLskˆVJÌ÷²M:1C¹ZßsiÁƒÅ`SjàÀÅ XjØcT5P$eÌtœ†kiŒö—ú@¦›4”™i©*`EŠIBѲÇ,�ùÇÄ@þ83r‡a0tF• }vfb +ÆxF³Ø3c õmªþ™u—šÉ†1�¾þÖšzZBB4IÖB*›â 93šYÙ !àÑ<ci†“îY'�Uï'¯4CiF`�« Páèÿ&0·*ã¶ Ùy8c¬ ñqþE4qâŒe&´]qÞ ÄÆ™?Lh6�ósaK�Hórbu áàã4¨Û3CÎ_ûþÈYí˜ ö¤“mpZ Ы$~+ÁÊ1!•/*g⤄*ç¹R‚&Yƒ.P'ad0/ .OŽ0Y§œ2Ž„gM$\­¢�0à”½rô¯‘Ç›x\Î ¸#ĈÞoröÏ�Àê–3„á2å—F+Œà²å(ÌE`ë/gᙣvŠÀ=š3î‰0?Ú-KFãœ!F„µu޶ Õé9óå~ÎÄ<„[AG:sgÏ5/î �ñ*„ù¥tô£e:â°�ެ‰:òÌM1'„íV�Ùòuö�Ð:³nf�Yùß:s‰îy}ÁŽLN,v¤Â+;hž8žvä1»í °ÃvÖçÏíü? wÌÛ—;ndÆ£îdaDhÝ¢wVþ�ï¼Þéw,d<žŠæ3xâÚ'j*<›ï¯wBH—�aÛáÑœ×Ä#Aa¾Ä3´*².ž½§Ï[‚¶@ž!ª‘<®6æ„ò¤ªu«#ËPž™–u�,Ï’�˜g§îšçwBæ<ërxë>OÈû}CO÷ƒž[—Ð=z&rl0=š§è õLP>اê‘-ì±kÖ#|0A^ÏÐÛƒQ±G‡ë¯³gvéÁÈk�ªÜ#"çÁ|ºgľGÞúh溧xàô•¦áaA0I#ð‘bø4Ãÿ(M³““Wð í�Ü-vPyÂŒê»H{=¥mò:ÝÔaܘî{¨xÉ™B9{ë÷xƒ®¤&2$Á;(Þ¬ßî#Ì>x;èØïúеâS‘„ÅÁ½ºG2e×ÁoæÜßÜ7r²£%½^ÈBÅë€É«-‚ºd¯ƒ}UÊH[è6Âö:`ù˜]ˆêsEÜëà[µÔå%õ ´ÿS|%GÍ»˜ñ:˜ƒL«*‰x”—eeG^©Êë@7”ReR‹ ½650Âl‹¾5Ú¢E£›pm*{]pß—ØXÔL�îÕkc]µoy I¯Hó?ø?=aÚ�…0 ü²bPÌ]Ä"\Bv_àdm\ÍÒ�ߦ�)TºY:È7�•îÛj�ÅÒ‹RÐØ`x·ÐÁòÂIr%‘¥ƒ,åNu@ëc#Ö˜¸¨Tâ@4à·¹:xp¥åKQ³RDLmEO¸t`#ç1ÉÖÕ3¡ó לƒˆ jÉDmæ`òG­›?çä€?fm%c~J'ÓÜß'#ˆE.n‰nD<Ù¸‘E¯ÑÖ½h»ÇØÆA´4+Qû,vZMëB‚”¦…ä€J9†ÅÉA:]«ÖG]^FrÐcqdS?5’ƒZ…9”ûþ$ý—™�¹Ží»x¯’éØR_SI† Tîž9#XòmÃIÚ”Oýˆ4b£S0mí �þíª†ÂÞàa‘‰(!$c´ü’gÍÄ�_.¹�ø-©*\¾Û$,�ê5þ˜¤ p|ú;‡¬UÉ“_+&r2¤ tPÔÈØ¤ lKìqï/(˜žl0“tå…èÈØ_ƒ�^,£œÊ¤¿ÆÉ?' IûרxN¦‘©û/ˆˆl¾!¬SKåŸBå‰gÔà5b”JX$Änl¾ºè›4`Õ3`WL�1Ë ¥‚+¦Æ @ƒ}¨¨*g�ÅãÅãÄ·ð €�?{´DÏß \‚WA(*O3›b”:á[’&³» <¯+ˆÊ@f!Š¥%ƒØ�¾*™H… j_Q D¢9T_¼y|C™*ö/›§÷0‰b@¸cËœWÌ1ýMdX4rap>‹ ´bšEŠƒ�znŒ„sò(}&$ÅŸ +Zà2…*™´Â@½×^àÓ™÷e8ÖÅ BG50H÷ÁÊ.8aÔ–¹ º@ÎÔØb.·•ãka5#²o�9¬ZÇ ¾¶ w¢>þrïY Vc3 €rL&Ñ‚¦˜v\5½‡QŸH%ÇTnç ²èSÒ/ 5LÊÀ?£–\®úæšTjư Ù&ošª¯ÀH 1ïíÑ h!]ACŹž £+pºk`IL!m× +$Zì d+p™¦Ãö¬@Ä*èDHU•lž +2VÄbä`­¬� +ºQ„]<ƒ@~ôC¾5ò¾,š‚Ñ/Tƒ…_í� + šˆ8�Å]ž)°>h­m@ð 6l “Ô�Eík*ø•Z?“Ó§‚(èwOûó)ˆÛÀ¾6ÿ:ïoèž 7ÄŸ «'©mKµÍµž ‡U,4‚ÄN�©ÕÃÀ޶7Æ â }V£[س ÐÚ݇Ž Ð1Ôz»Ø’=¦-OùkˆyÑùj–ì&à$4ÄyKÀ ¸[6ÿü–à2è$â|Êu%PøÃ~ÜH•Ð/$² +ÛR‚˜UçÎö±6ÄI ‡©- Þ.f’j g�?)¯+àšd4øÙ ‚}8²3³ã=K´ö‰K–ß³zð%j®p[€¢\Ç�#Ð9Mª„oÏJZc´h‚zE“2B ܬ†i×XòÄ|m"˜�}Y·dLDPžþ|usý‰”m‚æTöÀ‚E’Š"±Ü' +ÁÄ;Õâ ñƒ gÀe=öV=02‰ÝÊxÕIÄô�|wdw¦¬ØËÝñŸþ�«ˆ`9W9tüF?Y<©&,»€ü2¾s}`ï,—òŸI:‹@kD‘Ë*7�=€nPÂzàNcÏÃ�˜¤çãCJ^ŸŒ�9Ó"n|–R:@¬z«ȼ…ŸD¨/JØI‚ëÀ]‡ñeEÙÏC †­*s ƒÉñ›©…A}�óÆH{ä€ák·ø¶” \ì÷•*8dô7àù<Ðnrõ©l8îPȰêC:„jpÛ€w‘,Ëy¶É”jãxÙ€ ·wt"Í×·4…•ÕïaHRaá˜,^ˆ—I²Ï§„n˜,ÔïÎ�£@tfAƒàÍ Ââ­–�†J±Úe[Qà èx:Ä)ƒíäòÝø&¸è*10 ÄE|‰qÄ]•0Àbî«áh €öÑ\‘‘ß!´Èk–8Ø.À– ïR¯‚¹@Z—A{¼eÁÛ¹:ªÕ -UN ÀÂL:�ÒzY ÎÃ]V8ãÅN + 8^Vì©.W ¡2çl-ÊX�NÌX³×âtÒ§¢½-A8þ©ÀáU”Ý©À¢¸BÔÜÝ)8áN< –p> 5_As"R +—¡ð}«Ô8Åç”å™vÏ @!�½V¦púS=¥;¾à¦v'�…‡-ØÛ¨Zd¤^ãû‹ÛÙzbø˜zdõ}Ò­•@³CR73{£òq“@K5Ï%fªž мb•u¹/…þš7î2©«Ò=ÉHF@m˜³§Pu@_"±Ã:,¥Ä£ÀEäŽtZ°—r�ý)ë]lXvh +ëëæ?vAÄê�öÚ¢_ûì1€’e_¢îhí|@ &‚¡41û"õJºkîêOR³R×UvÔ¾Ž�x¤8Ã+<@�MÖMm5Hv@›µúp:~ãD=Ì Íx-‰ãª8`éä×¼y ›ü¸ *1˜Õl@Ât…XAP“£8›5 F¤@øO–f4€KB°ŽÚ ½f@Ò­]Ó¨¨óÒh`<¹ØZJ¸‰P>˜ñ/€ „6ûèÉJí’‘O#ä¶€ôÑÚÒk͆²=å9‘#­¹L à=ODÚ™g�ì=|TšaÏÉÛK(�~Dù­6ÈaVöÕ7´Ï=³Íq|—€{ÖHßÙað %ˆPÐPPz|LtPkWp@*µý}ißMÄØE\ƒš›þyë[#ˆûŒJÛÆ-üUƒ€•ù€oXæ½ÀØ –ö+™×Qò<Àñ³;~ôYQ`,:±™” ÖFÜùŸµ �m£´'È + {ôà 2ÀÚ“ÔH J­!R'û %ÀZ}$yOßÙÝ€Öaˆ—�`À`wy1õçvEJ¦Àœ¯¨'À¢Ô a?ž‰“bâók 1H”¾Ó9 `H}QÛšîUÞ´åC«-ÙܼJ¾ˆ _õY›�tôsA\ Ÿ~�aÎ@bÂ6ë’7Ø÷°.ndUÝ‹ ”Ñ&€7Ó"ê?1¼¬h Dz‘#ã ®ehô¦r’.ébæƒMÈ;A„�ç¤ÔÛŸN*bü@Ø+×õ ‚NxH“eYì {Úð.·)š5èúÂ÷ÈÚé„ÊVw‚ÞÿãÒ×IÔa“ þ?½Ú¯ÂÐwÂDKæÿ_´àœý«©@Îÿ92�çJ¥þoÝnX†²^Žþ— ¢µ;ñÂÿö~eׄ_xM€ÿßzÒ÷åd“ÁÿÉ0F”=µþçŠe])UÁÿ73€ÛªÂkŒê$Æ™°È�ÿA•S3‚¾—Žÿ8´ÿµê#¿½úê$˜È}:›“ÒÑKpýŸj~Ýþ;Ì\ÿ™HúÏ‹�|w×솫€x»£®zì ¬�Íÿ’õìo[8Óæ?’b}hñÿcŒ7؆ÔRŒêþI=OatU º-_¸{QT‚3ÝÖ.iXàóÕýWGŸîÔáÝÏHi ¨Õý+“�Õ˜ÒF$—û‡Š©çþËû`8ùì軾JßžuúgÒ”Šz~r÷ükŠyÝ‚‡çÑ0ÓÎ0d`Š=ÿÚøA}ò(BÏÿj 0Ðáƒ(ÿàNX0DWƒÙp,þkµÌÙYüC4êW¡ë>ƒ“H/ã~)�;ï?-C‹'¸¿ H§,¹F0Pa¨Ù_0ÞU¾É^uý©ÄÆÁ©š@×§ÔŸ¼›úÿÁ>`¯ ^lÒ{‰Ý"ýË„á'ä2#H1ÿùÖíÂK€øùÿ_š­Ð#ŠjG¾NJ|h¦,æþÒ™ãüAä/w²éF�ó0ßM\üŸ¥ã?SÜÀ—1þœ!BchëÙ þt°(‹ŠQ^¿›÷ X¡Ä<½ÿE¯G¥ršß+äN`ò¶1'ÓŸÅýtc™ÉbŸƒ¨RÈ�c>µ(Ž´[‹ÒÔV'!ýªª}ýÒ—äZÿòýîÂ,6«ÿt¡O” +µ`¤í4ôPšÖù+Hy1ÙaµìA|F*Á ÜX¢$ú—Øþ}¡ÿ ¢ Ìu 8j ÷Nt}ùίîéijuóoø÷”¥›ß˜úÅ5pޤ̯߯&‘‘¡'—_¢áº;åçVtÊùûIòó·Ðè±ÿ¥�üÆHùäW"_V»Zµøa7~vzÔ(?§G¼½^%þ®�í· ôÂáðß„1!´(·tNø—¥)1MêEÁ/˜Q0·üüA‘Þd7­áagíG©¬éîC˽¯M6{_§$™Êû<ê”üI¨æîŸ;ÁwºÏk1¤Ü'9‹}·§5÷\>Æ £™¸}ýpÈÚˆ—èÍ¿ök�#¢i¿1?SflY³íì†P+lõ8MÙ'gð(š·:ÕûÅ‚IÉ-Åþ³®ôð.¿È.°/�åEACÁ ^Ÿ‡Aáã š½õÇñjñ¤\Ǭ_o%z)x`ì芷�Týñ]ï|3:–tæ=Xê¯ÒgÛâ0-BýÀ*ÿ^—ˆëFN?{v}&© vîcúÒ +=â[{ôQúõã+€Bnè}B‘º£Vg®Â¢ŸÓéC± ¡O:’Ì:Û“£AЧ$�EZÈŒ}þã3š±‘tŸÿøOŒ�L-f;Z~˜êà³kaÇ*ŸÌèÁ EZ¢üM˜Óã[Èäóå^xµX‘É/–:2\ +%ÿŽ/5ˆziÙ ùêPèH|PGþÝlHó$[-˜ù; WÿG>W£€TæLUGþh³[RX!�·oýKå¶VëëÅ“ãßAÚÅR}fýÒoYÉS‘‡õùfC†¿Û®«'åÕ¸÷¿ FžX½"ÔR�VœTý"ËÉ­î¤XAõ±´‡¦ÝhÄ¥>Œ¤ó‰ÄOn¼åâ¨ÿ �´èÃð õ’o_Ñ f}zg÷¸)Eí k¢Ó¶àÂ@kMo~ÈW}ÓÇŸâ0mdìfé§ÉEê ôMAWÁªsáæH_½Ï¸g?Ùff€'=°Q;±Ú>/‚A×Õè½ó±ëk=kB³èÕýR\P‰~Á¥N›¹¦8ô%¤•‹”¿UÑJ¡§P߈MÑZÀゞÏ\Ö( ß­¸ €œ3üZ׺ðü‰0úÐî`ÕYZâa¿PÓT» +Q lꘚ£7ÊðUÝ +Ö�¯‡¯QüÁ™uÙéôs¬tÏàÖZú ß&s:ÕQÜ‚òÂ̩Þ/ßVÄ +ÿJðÉžÚ„÷ßÕbC„À¤óVJ9yðÎZ#‚ü¸=¥ú-ƒÇïÅÆ…`)ßg]ÖÉŠ,@ðZ§kd èñ§¹ÀXáÇɮыÕqâ@bx{Mþ�[O�Ž5²ÇòßÁú<ª¾{pþNr0Ük Þ—R¿§µ`¶Ãïüso ûþ¢w`*lëŸï=Ë{7¾e|/‡‡ä>áÀôÞ«È£ï´÷s7A8”•z}9 ·zß6¥\ް?|FsRŸ½ão "rMôNÉ-5Âïñbæ]¾Ø@Ô�!™Nä]60"ÚJ¼ŸLõ+lï ™8-g¦:Þý‚¾vvRU@Òü´ÃŽ¡g÷܉&yÝß²ÇæjR6¨ºËj5?N‰[ÝW €NÛÆ ÌN÷HŸ} U,DĠݥý[ÑÊ=ŽÂ�ç®Åb¸A�ë!ò \éñ±ÝùS¨ŸrG¬x{­×Uó&zš€ÜÓô�æ×eÞbe¼¸ûåŽ!FèêÕá>Ý»˜÷\pWmb8wÒ˜*�†´ Ø™óµ‡�×^Óܺˆiê”nŸ.¨uÀií�ÁàvõH”wàéµýe§žŽ�I£X|‡�qlhlïxOų�…{íý…88Ô^ÌÚK‰!c–fíâr UíeQÖ%ÔκòŽi×;F×ÖÆù½�v·À—“¢œ‰í¢',´“‡ÎÉÜ™ÙÅ0¼!²Ë^+�^7G—Ã/�L ¼d?+*›3· UÈÞæU®0!{‰ç¿œ»Ršsì‹.²ví>ec·‡‰¼®¥Ï"9½R›;z¤û ë½b§ ^ØU{\’QtAIûA"ÒÁžôéÆN!Ñ�Ý)¸dŒ#8ã_g`÷à¦jàØ}Y_¬¦q°!M†¦©×_U™z},—L/Àë#ZXèÖ(;çº^s«S¦Ë×Îu@A]™§÷N\O„ù)3=w·žA +±Ã¤ÈÖÛä1[·I¬tÃñÌÁ.Të�ã׉vÔ¡u]÷%½0ëúfa‚Ǻ§]Îü)< §|u !ß‹+B_½VñИê'|Œtõ}~ uÙj—u­®T+öèûµúM|ļ «_ƒTÌ£ºBVº°ûÈ�ª.äàÛ«U*"Õ—ÅA·˜©ýŸºÂÂòs@)cSŸOHr~KýÖ PYq¢ŒžfÓüÚF½Ó\]ÐÓq´î%ê@¼$²-5Dî¾|S7eG¨Ø"R€ºù¶,œ>yl0Ù/){x¥†�õt<¼‹xÑm +ùUf˜ÿ.À«å2½á´nÓ�ÙËCê-gÛøJ¦·‹Äõ…¶…³É7׈w!Ó>]¾™ÉÛ:éú*µQº'îÆÂïkÞ¥ÑÐ6?ÒÁØâ¨#†t;¶óµ0­\ÂâG7:³vèèÞ=Â¥zu�. ÷š;æŸó÷†Ñù:�º�A½P}E? ».•Ö^šèKæÅú¿‰èõÍŒ°4¼�õR ]žÒ…>vEû^Ì5YHñ’Ú-èŒe¤š¥ŽaÁ@7eû2=4;@ÔÜkÐñæBúúóÖQÍ_òˆŸw™oø5(d�>'ÝJ¿ç,΋˜ôüÖó‹·¤Æ�ðÍÒ@YèÑ<7”…†Æäæ9ð@$Ãô³÷ÏSe@�ÊsO˜ÉùÚÍܪ.dì\kQXÇrÎtÌòÌä"RãëtŽØú¤èt®mú²,QœÎ¿uŽç¼mësžÑ¦€*ÎËj«ôdX!ð9WfRC ûPçD’Pª}åqmA�§ìF;y� þQçà�‰“Gv®6¯…ÊÎ�¤õõ†åÔ9­k\JJ¤çI�­š5RçÁ€ØÌt½‘0¥Î=CƲƒ'u~ʤ“¨HÊÿÔ¹”Ï-à [O�ƒñÕDñ¥ÎŸ\ špðtZ ¤ÎËx]œ¯Ô9YGW¥Î©žu³ŒÅaÓ‚ÎåuXŒ¸œÖé^ÎAR—ó¿½Ië¹U\Îc˜Ñª½ã¼JWztL,?¬K¼®S–ï!w4Ÿeô²r°&Zž¤Ê-D­xP9ROý|óR—r°©!ÎGqå…xNC•¨C¹!Êì|½æF ì“S—R|`У­Éy¶+1ù‰;fGÄœKó€uC¬q·'¹vT¦HŽ%וTà"^�VçüõÓfäIÜ% ”ò*òÖºJ‹ÂBäúJ^טrvò}g¾) ²Õƒ\ÜôXr]–�üÏòÒ„/F†£?¾5£Þýq]D +FÏ*Í/Ï[]oäܯ +�_忬B3Ó¡ñ#éI´ÂY†Æqq*¶ïÑÔ_hÜu#²2Ê�¡ñ½å|‡ i™¼Ih\àeŸj?'¡q\_œ‡„J`BpéKh| 7í ¡ñQ€þ(ò)Q5^?¾Ž¥¿Ú 4�ºj@5.îç9ijL¹`·,Œ‡Â�ñvP¿ª«ÆC²gä¤2•¾j|ƘjNÑP3÷.¯7âGK_¬£ÆAa8Õ±A96~¶6Vpçbãƒm*Rj¼„ ÿÚ_6^²ý0ü†j:AG!/©;ŒÐ?={#E¤è†ÛÍ‚Éi?|ùlÈ) ¬¨ï¤B[yæpE鄯ɰŠCÊû +/?° ,øâ%ç2<ñÉ=KÚ׈èn'.ïÛÄk$}Á-cø8‚/c8õ$í½u£•ïó8«^8"ö¸ ÒÂÙƒ¶u^H>Xj�`Báá|—ÈÔ¬˜ðÔãŠâ‘Ï4�p„Ñ#¿u”Î á•g^¤Î‰8q.ϸ?Q=$4+<Íû&ÕÌwËrÖà¾O¯w/²ƒ”ñ×Êrn–_SÅ(UÔÐ{ZCÁÍNÈ…µ5*�àqÓú+ÀüŒœG#Ó~çuºέV)ß.ð)³Ô.�N}Øv|8àõʧŽ®ÖšØü.&à_ $œøl +pRÍ>8¾ÐYZÔ'¹Ü¿"Ù�üÅ¿Ó@ØO œÔn D›b=¾3¥Ð%Dê#ŸykˆæxÙú�€0-}nc£ãü¾|ÀýüŽD¸#™sÕ`aü¾þ‘r|zÉÿÚûÖu´!û{ß’¯Ý‡F¢R¸R0¨Õ7y§™êRP/û T4cr’ÄY~jÐñ¨î„À7é’™�o�ï÷ÀZ²‰ÍƒpÃï ð »õS“<í �W+Á¿áKoâ»1ö¦?þôýÇ`­wá6ôÇ-è¨÷.¯É=ÈHTHo÷ÛéáçíÖÔOÀ}hó6ð颕Tí–÷BZ¸±ëI$obÖ»ã�ÂÆûuÙ½´ï ºc¸€dÒ\ëIxû»RíáÈÄh†­óÁ’±½w£6úÒ9¯%H^Ÿ4'w7µƒª4I—»ÀA+á.èys»U¦_y5¦Ýµú²ƒPÙMó€C²ßî.L°{Åœ z™ãºÓmS®;%¹ÄQF LsLÎÃt‡ÖCA½ÒÞÔMˆ¤Éæ¶KÔuºE®4Sº½V(Ý?”Ù2¯t'™Øvt§¿ÅÂxîu1um� M‰ýæ.½µQ©•XeîNÅ�„ÿ„˜Fä,ÏåVîÒx¨´[¦Ü�§L<ƒ±¸ºä~r‚psA�d…Ü6r[/8LrÜâ}~ÖÑó×ÜáÐó]!YÈöÄ=ÌõÛÙçM™‡»¦ÆWä)2[á¶À å¿}§1¸w´Â5Z8pß#sv|݈Tè·¿Ÿ,¶”q|ŽT|º€7º=¢·Å6|à˜ðnÿ‰4èŒ?©n7KIÆÜÞ„v[ˆŒ³²?Ü&|DZ¾F­ºmw©ÔÆ"¶�ŒÝ[*I4µç�!¨l@ÛAŠ£ö¸YsÙN”æ )¿µ:¶Ç+•¤©†9r¶°ýfQëÄÖ|íJ‹d%smzBþ²m̵Y'œ«—L×ÚÏTM€h"¨M±v7 Ÿ3ßFšzV[ª<óÕÎm›§þ¨M•¹ïc+jX$PšéýÖTr´ã³NHØ¡“•6&ê(Y‹´K!kI¯àûŒ6( §HÀX‡hcF¶Ûak}ÐÎ#‹¥ègûùEGͳOYÉ¡U;¹ìu¶÷hÙsXLÅÙú>Ô.d³Ñ3–3U²gvLÐͳ²1{@Å»zÙ¯+m\Zöh%Ës]eßSh‚«EÙƒËÿhßÿspÊdßÏòÝÊ ÛÉ.19¬W߇lÕߺ¾¤<~lÙ +0¤Ï KÛBÕ¨5öÅã+ÆNø«K"iPXêB˜.Ù9;‰cˆ‡GÐ’u#6+#ãQŽO+îaþk©ãþ ;ËBH¬°?ü~<?VØÄ#ºÎ–/BØ[Á?Â,°Q5Ñl{ Ux�…š6ÇÂL,ö2™ +Rÿšä�àÅ G¿¦c1Í 'Ó‹±õµm¯Ý4À= ¡µGoMž™:óÁͶ×fù.}òºa�^ó�%- òuœÀ“à»ý]뵨nj»2ý,ᥴ®OQ?WòxtÝJ-óBxŠ× çC±šëE�¤Î{/r "öÚôÓ-¬ C_1K¼jú·^ÝПeµ[Ë[éb·µ­ï÷koi^œv)[wÃ’­�CFŒ¼õ÷-³©uÒ9ú�¼#£´¾dZMfz´bŸWÕßé%<~³þ<ùŽ“†ïÆZ-këØÂh5«D²æpV¶kß§�ÎR¬W š…X;Õ�-6>�ÆÖ +½˜YõW£•Izªˆ£­Ww}úÁGi¸��¿Zø_;pŽÚgY« £–š=0Åp¢ž«WîÁCÝY%pŸ7Ó +uE©ê‹A ]ÙC:Âf9&P7»,- 'ý¹E?=7CWULÌÕŒƒO{»VåÞ“ÞëÍÒyzš›êÝ;½]glR¨N÷ýÈŠM<ö˜Ó›K ÚD<ºá´ß¶jù©;ñ¦7éF¼i’Íûëva Iò¦mâÞ­Ï(ËÆ7𦯫šõ˜7ͳ…Æ Ù4˾xN¾4Ȧ§y°wMa>²éÙLÞGs—M Ñe<Ÿ”ROÙ4‡ù@/9]<5­0¸ºàƒsj1ÄŽÕïÙKM;óKž•Ì?˜š^·Šîï:ÚÔtD¨EL“¥—ÔôÚüŒs:CQ±¾�Üì¹ÃRšø Ÿ¦E4í-B- ^YšìÔe5så4}R¨,*"Pj:ûÙô4vÖüaU…fÓ«Øó ev6­ï<ÍòÒî ̦mËHÎ[2Þt?‰Õ UP¡î&èA8ÞâMCМC¤)çMy¯œªH�ŸlE¿~,é8EM?•]�IÔ´½×‡Ë›LÅiš�ž±A»éÖäÊûÓt¿-��»ò9Mÿp@Æ�‡«—ý'¿b;Ïžò‡M��ét½ Ÿ�­âj¦‡OÝ$…¦½õ¬Óòë}ÉA«LóC-E-û»ôzÔúk­xË@qrK»�Óÿܶµ¦S–všÒã\i¦Òzß»( +é&M¥C0C�|‡*¥{VCtƒÒ(mmºI^pÒ�Í:iƒ¨˜Ë™8æ%ÝþpB¦YbðÐnI:p¢vTq¤Ï‹1ê\•Ô2‘þ\A´ð¥ +éÛrL!=¦81B¢†ª5Òo^ögGsÙÙ+¢ØuZÅ*��"iΛ¨…©m]•ÕbÂüÏ™¾ÃiQ¥,“ODÀÙ©-p¾þ0F+–… ’3ŸQgAÿÏ(uBÌwÆF�ÿ+±Q‘á¨k +…1uôK,lŠy41P|„vÓ&äGî‘æ· } :€3så{+ëRXµˆ‘ò2œÈvU’ÞéEø‘¦ªŽZ(iƒLJtà +rRˆäpÒárãMúçƒhzºS›´T.]ý¤9Ð ‡”~D¥yú£‚×Jñj0–ÚN2 ²¥‚ÖÐ):Zã07`¯ÔpóIëGJq:G˜ÚB÷6¶³šðoÐ)^…J1¥?ÌO·”^X!Ûôû?mþ›î)÷h( á1[WtZs€kÚéÕ¥‚ã)ê +P=Å)á‘óid½v”þÔö”ø´[¨·2xPSz#¾€¡¶”}¢6?¹ÁŒV±í9˜?£ŠˆVÖË£v‘š¾žÜÉI­¾uÆ·+…Á€M##NSñ븜jÅW‹öT0= <¡jH=<�j}ûèÊRýe kžª>pÇ`H½ÍXtª6ÿ¸¬J)OËUušw˜í"Ûv�¨ú@{æñzÇÐ6² y+ÚÍN,©�$¥=N?eÚ…b½Óv�!jÙßö¦!€!rj£ê÷U˶"»¬½ÇÕó´vxNÐ[®y÷�¶½Ö3†dªžÛKÆö#K:ZÙ}L׳e=,¬wÚæ*ÈðÚr"uÜÖiËU®ëèéå*n]$š+âÜŸÃ=ôÜdÂŽ2èjÔ 4ƒÑMRéžž×nñtï$ýÔÕÞ§#X×Wv™r®Ëa×'-'šú²k­RrS» ýÏ×íêœ ‚æ.>“�¼wWIøn +@]÷ñj¼hÚ‘(¼!³¡ƒâ�!À_ÜV¾EÇ[ bò~¦ Øäª³ãÛƒ7o ÐÏ¿à :‘X‹=éõ�öÊSoÙîa̸^ Úû&{‰{Ùƒ'o½—– 2|Å6Í€Œo‘ùÓ +ž.ÝU÷|óxoŸ¾Ô{„~iÊ{Zp™ûn~½ÈÓ¾üV£”¬ãI$âñ¼÷xFô˜¿v¥Ø·Ì.ïoÏ„Á*þk €•Š#%ÀâNÀ©‰Þ +çܺá&�}Ä¢vÌi´ÀD_£ØÀ¾™ª}ý1‹+=�Þ¤Î6ŒÚ‚•Øt¿“Á4Ý`k½�šªmÍ;ø·…amí&ÂÍ$;–„Ã�±WAnÂW›A3)œn8µÂöñaí-%>þÈáV"Dr Æ=œ4bKÚ[­| ³ªEx[B߈„�ò~È£ºso¸µÄÕ×µ6ñ'ªP÷? �å#Åm"g8=”ÞÂáYÄý�S\VLJáb1*\·;&CañÚõ¶»@­×vW}zþ´_Fqã@‹¶OQ1¹ôÅùÄÀHºÁŽz_ÜWÔÞŠ:–s'ù+ÿf+auÓÅ‘½ÉyP]_£/÷ŠçÐò4T»´�=žd‹óE”“L¬¢ÖÙâè¼ð›×b�N:2R·ˆ¸¿dj6­Z|a•;•û$h1+ê4¶-Öµ6ˆBÎE‹±ÿk7q\éä -Ž|\‹ºcW0'c(óä(æÿÓbŽØ­ ÿÓÁnáH½+£¾V¥ö5aa\Œú;¹z4¥Äˆ‹Çbm5@åÒÛzÕÃB*�‹'9� úu4…¸ø›§o¿:löâP¾´¸T{y×h1‚˜‘ëãGŒÒ·H‹ß6›øNFT-i±.Tí¶Cáä)Çi‡ŒY` ´ø¶{�3¡Å÷ÊcÙöÆ^@‹gÀá,úŠ-Åô-”�o«•(tã0°˜ÍÆÜ?-¦N±bŒ”¯ãÙa]îS'Jc®ãé\S,ã»áüÞ6. XÜx/�CÀb w’¡zñŠS½;'\Äŵ˜xØi_¤à…ýö'� éÅ Bº-ºf¿U13¯ɪyUŒW ÏTÒ'ç‚Ýú_`4GU|A€ËõÇITÅ=ëó>¥ïM•§W¬ç‡ˆ h¯Š'L{:†ç[Ö¹œ„).cx/«b/�„\IUÌaø ,3€«â�ÚûÌ�¤Ïò.üÞ¿¨Ša,ôU±È L{ÅçFt쩊QÁTȽb^ðç;;¹WÌM",SÜ8‹‘ ž 1¡Å¥iQŽëg\¢Šú/$úaD\¡°í�wµ>‹7¬ÑÖt;î{Ëñí,f{ÔîÖU�Å‹�0—‘p×^ñ�q@Ñ‹+?UÞ+¦°œØ4—6¸T GÞVííÊ3àx¨ÏbÐ(¾; >\ìêÓ{òÒx15‚þ[ÜFS­�,Ò²ÜÚ‡ò x:»GmÄ#&T-ޱŽçq±2æ,P/†ƒ¸¶ËÁØM#ñÊ“s±deÌý83š0�ÀÈû2O ÷`pkøì2^tN}6˜ŸÓæÌýs§PP@ßU\:h…�µ6�€6<’81(t€¶ÌÜa�%e~5Á8ôĨÀÅÞ²Úû[• ã�UÍØµ6ü¨@™gÿöØ4û 4Å7×°¹¦0²Ð@{)«�4t×@ßîÿV;Ò@ÛâPÇÓ@ú"˜œ„ô³�0,èˆû©�+,gAÇ¥ýÿ¾Í.ê…Œ»hÔdIüpß +zÓÇ,ظ +껆Ê&Ú³ û2²· !PêƒN= +Ѽy.Ûå¬@K ƒKnZ‰™&Z²XÊă.”¦ªþší^³/Üè ¡®ôí‘åŒ +B¯þeðté�©IˆUÙh=Ðj™¶´M CøùÕýÌqAiÌg2‹psN“´‰ê5è2H:h=%�¢ê �“+;Ï´rM€B(.€î ƒ­æÇaÃkÝwÐýŠ�Ž&ô'ì6ñHâŽ.^Òð3uèÕÚœ¾½v¡ƒ9©ʉ  ­—‡àð }’A¿ÐÕj ‡FVŽ.¶ðBKlFœ`™²Z"Õ$ô…Ö:˜qh2æ‘ ÑôžÓz‰nõ梢KŸLmQ¢åÍè{غñ�®Ìx4†k—¼ïˆu(�ô™“‰ï³ i³ŠMÒ9p'Mš‰+n&$êJë$êø=@šEâ ĹÕ&ÓäÛ{’¦3ߦ¢Ÿ‡ÉimA¶m§·«ÑÌžN�ýÚû¤&‹PwÛ*éˆZ‚Q£v>A£m½cØÞFFËÆ 7¡»h64õÅ÷¬žºj�e �Žj–pèT÷�e”°î÷«¶µL¢Õa ÿÈãj�Ae>^›ÁÚ ôGµŒu}+w2C­=ë‡#`ñnè´VôØ^°55Æ=·»\ ®Ý�Qpm'¸•ë¦mºÓu=îÚ¢«–íòú3­v¯ Ðìñ¾¶ª+(9iH8GYDž*)»Ó%l–‰Ü°GÜ ‹$vò�Ib“#žºÅŽó ݱÁÝ}–�mÞlàï3�Ýmë¬d/ ›TvÏ­äÓˆ6È UqÌlçr�;òHòfCçŽj¡pSɼ²Ê‘[65ÍÖªÆ~ªhË5”ÝI›šƒÝùv˜D©ýj#2ûO=ž�¿µUyr “ìmKÒdۤѶÜ*–«2n[�a�8M",˜hA·q_%ˆºÝ£å;‹·›$'ôm·º™œ¾m§†Ž’Š!}{¸!�ÿ‚³òmYk cˆo—Œ0âÛ6Ëè�íà†o· SÈ,¾]ÅÑéÓøöÊ‹€é½μ­G~Ž‹=ñíüL„ùN` +ß>õb)³ßmÏÈÐ~,&ßíÅM+…| }·™p$¢�äöÝVóø:�&}·ÍE»,±1Ì» ÿ¯aïö)ª¹Ï”b{·½½�^—™WïvJÒÌÍ»í4&‘Nõn‡ñÇ ñmKËböÞ$ŒŒ—xM§»ÍÐa™$ŒJ¸ÛÙÛ˜@Èx·-ÛçX +SÃàwÛ`ÒÃñm�¾Š[Ir”á÷öpBÉáær�é½í~ÂíÆ·-3ÙM|‘ë£ÔÏ>ï6Š”m¹Û-oÞ>|J/w»ÛÕjÏÃÝî+ÝŠÏ|µÛ6ÓBùófÏò~ãîL¸boã)ÓO&²·eo¯•¿º>òÞö¶1Ø1ËsúeïÛöP‰W1~ôížp|ÜçÜpóôm$d +#â©ã¡ý#f`¢o÷IIÄoxäAº¾­Ö>0 žÓ  M6 Ö,øö +‚ˆæðí*ÐÔµœXŒ»}¯ÎÃ5囨ÆÕ!¥÷¨[»�hP…\�^7ãÚÞ]h”úCû p·Š6«ÛmYg±“Èí1MâfŠäö\Ž?袺ãvaÀô0Än? +ëÌôìÕÛ›wv曺]F¬8?9š„t»;žSýV¦ÛŸÉÑ +¥Û:­Ï؃ÛF» à¢-p1¸=ÉoV€Û© ®€!VÀmy—úh¶šƒÛß®<‰n§‚Ûä�‡Hèv.šoß»FSZ×píÛ¤9óµ�» +"Ð1G•màv1S"ê9K¨o#0YßòÄÀËjÅÝ�õíé(—àÎGoç†ûœôŠ©¸{¯�Aò¸±h(œÜê22²K´63ÁÜhw˜YÎíÄ +â„îoïP—ns64É�꥾C¾h‚îûúH¬•îuq@u·—µëÄ z•ÝRe€n·rMX¼»�ž‰=‡w!ÞÿÜÍÜZï:)¼£BÞSd]€|wÛCb;ôýÝõѺ@Ñ读ƒD,¹Žß}ñª1Iün˜‚ì´¸}Ñ÷»í^Þ@êÿá–åwã«¥YÝ]?&AzJÞCÝÝÐçjïWS_ êndö¸fb»c›ANl76ºkMf¼wíÎP(` ™1zíîžL¿vGvÀût·_¶Î‡[én&:?ènÿ)4jØöÜmñr/Ðü–ÒswØD;÷.¢ûØçî.:ö�$”ô^“é0¡}?w•­s>·#&rs7¹T„ksî¦1CÅŸTü½5wW÷Pƒ‚õôïbA7N®öîÙYˆ1W?ìÝø =$*ýà»Å“�¿\`«ïVòK‘RNú%=@÷ÝODuγûnxU“H±ò< Ö‚QÓ<öð§Tõ»‹`Y¦68Úï6TH¦Ã;žJ±ïЦsJH‡÷¹öj“¼ºäðŽ æ*ê]“ÎïGTÑ HoW­½ÕÞ6‘¡�+²,£ðF`IebùîŸ`ª…·¦¼¹·³�ä»!Ã*u¥ ošÎx:~˜`¬“¯ã½ƒJ +�‹ü�Ó*‰�gI»'”ìuú ÓòY9íóŽÐ|E½á˜×õ¾)€;Ù»vSÓî= szpÒ®%‰o§IÍß•ŽOa¼ŽE3þ)âCë÷]P F¿·Ù‡ßA(X“1�éߊåNZï²9œ€´ÒG¿ØoàY1Ú!Á}RŠKÖ‚³‚ .·ð’b„ÖŽ6†Nd"�XŽ­†›ˆ¼Tf�'í¬=¼×'Ûñºá÷TíÏ +¹§Ä¯ÄÁ #'®½¤¸û †ºŽÜ Jµ¸Š€­/®÷Žñ£P�gã‰Çx¾8ÞCGª:^ü³<0ÿm»àq/¬¸Ý㙑tù�'iî”ä=?Çò!ï'÷šaäÅe¨Ã; ;€*ù�ÚÜ™|uÜŸ'/pb~Qn‘q6N_å(µ ÙJ~42hyç±._v/^^ ˜Ûè”:*FZï¾�¼kÝfF€_TN¯ŸBš·äknò6# 1:À9*97´äÒ9�¾wþî‚h#/Ï­ ”ë=Çç“=#?G( âô޽ g> +=0;ô#–¹{³v‡®¿[`=ŽNlHg�YkŸ?n‡n¶LX+PÜô(�M™³¥·é¦Ìpn§c®q¾“{Rª.[« +Þ$õàSœú”A�”Kõ”v«þç�ÚêK—þ¾:°·»z¬c(Ðzв$°Rc"®¿¶Ö¬Öõ*¬Ûž^Gê×I„ 6Øùa�Ë· Æ~«401d÷¸,{ZÞ,®Å‰ª©�èhwµ?"PûAÈÐSk—ÓŠ–±]¿r“°í¹[¨E¬œYý> ~{g ÷�» ¥Oi5Œ~†Bw�´‚;ª;ÉËk72à œÊ»³ ⱋ÷Ä…d8ï:x½‡|Ÿ™¼Iè{�àd×Þq"F,îïo o8ð³s€}Á7ŸÚäžÁ…dø^ÔD³Ã ž,D¼ŸúV_Þì[ñIé/xzÏPç�ÏÙù&YEZ_o²4¾Ç£ˆ��Âô�ö4ò4w×–<ˆÌ“ŸòÆICËçÄe¸\Ë«´`~S敬q~˜æa7OŠE¦c;î<‹-Lò  o_"–úvØJ +@RžÒtéì8�gº™7zÌ*PÚI¯�YA.}¶0<°›žèR:PÏr¸[)"ãkÿ•Q½6ƒð×êC÷Õ±^š1PÌÖ3%0�l½Á=Ћ¾ +�N]/ͯw¶LûrXEÈÞÕWŸ +fÿe).'´o°W÷ÍÄj�ÙÍ9p`-÷g„€Bçž{¡\_÷‘ò +¼�öY•P½¦ïùÐvŸå�F± ü9NÅ °i0œÊ«ÚZøL“{”µIŽû˜àÅBÔ¶ì‰t*¤>>aX[ÙË6š´EN >‹}%š•X[àX„p+|#2¿—üˆßÞx‡_%få†Úá�‹ëìiÚUØ—ø}¸¬„±‰JùOdi‰Ÿÿ½�)ü‘øݹç…aª@"ñc—e4­ÌYIüxÀ¬R‹� Ÿ¾„�og±˜Eoå"ù~æ™ã7ü�ågóóÎÆ¹æD ­9¿mžNàóQ^qþ…¾9á¾£_U¼e.ý¨È²õÝ@nz©~´�Ì(²~5x›h×?“‡¢ˆ}÷nQu³o æDdª¾…éLtjÿ"¸ùݾ{¿ç~Þ†�ø1ß÷4„¿§J¬€2üé-Î¥Šß.ˆöø}¢_M|PªìN~Í’~y0¿Yï_,çOñÝè  +¡¿üµìL?’|•‘·lõÓ¾~{¥‰ÐþYONâþ2:+mzî5ª9øÿ™MÞþµüs®DÍZüñêèbEÿmë üëß'ƒ¤M$àœå`˜\é¿ëü4¹þâ(8B¶¿Ë“ŒHÏ?¿ðýoèã?¡ÿÙdÿ–þ'm}êIÿ¾ZËJ†»7eWÿ¿p@Ÿ€}_Â0�È­PW}�gJé™9ý,(µ +^š0€‡Àº„,ë�€FêèA`Ú*€`}- H~¬[ì<»H&†æ_¢ï½ÿŠìt”ÈlLA²ûetÐçЄ€¦‰«çTŸE6~ºÖeМÍßPÃìÆc„z¡k¨ +ñBhNÔèlCT ¡ÝEam¼]Ħõ-•™ã…WÔÂ×ÖYŒ+“;ü$•å¿·à e�ºZä„¶Ð/¹+y´9ÉORb�sN²½âÍ…Üеù95 ÛÄòû  S^bîùùð”M¡¿Ñ6E!º¤|ÝrŽÀ ú©Q hE—$èqI±•˸Úá�»V’lìX”ùü¿Ýu~æ)ÂÀ¹¦– uçãS›i ±!ØsýNˆî{Iô‡Î.^—¦� +4™è¢Z¤Õtzq[�`¥e¥½$¤W·ª»E1xKš£�%W ¥"ݖѺa»I�ñ,ƒˆ^©’ JqHKOzZª{³×§ã¤;»³£\6� ÀÍpÊüÝ µ5œš°ƒë7Ó~wXäk�%!› C)ýä ¾ÖÆ&ø… Éœ“ân ¢0±eÉ7[vJ‚%ˆþ+m§Kp™ýÀ�F·Æ¡æf×f¢Ø—K%šŽ +ž0ŠßR0É�5Tt›óøý֨כ]1XX…E6"žcyÅYüøW[3¿�Àkö;,¦Úæ¾fÎoÚi˜îÔuXtûÄNAz%…fÞ9~¬�ÖÊ»#ê„2ö–)}D?ú�(g7R]ê ŽEQ >ì³4׊‹?D¦ +B8'ÖœLnÁ¨òs@.{EMH½dÐÑ…jzè >;>ϨäÙ§_ÎZ±¤DjÖolŠöæ•¥äèÙã6~F‡ž%ä�2OYÅ:Îæ·ƒ÷Šÿ²ù|w°ûI@¦(°ršhûâÍÒ˜ÄN²L±ó`‡‚æº@¹“{ÉF• >W‚ÓHÔ3Ž"x#[UQgZƒ“ � *Q6neù˜oˆÇF7è:„=—;xTÜ¡ïüê¾V•mDŽ˜ l¦$„’ÀPãT5Ô–¿T4ô“8 (c‚aü˜Céa„îf?å½qCSVú‹øT•3ƶ”®Cnê­é¡FÿÝötß.¹Ië§�5Äx—tX‹ÂÛ‘4j>…áZ¿ ¾Z…Çv ÄŠz¢þyé†`Ù*púj&Ÿúh=�4v‹ÚT!„­_ÑâÑcžßç�…�ÐJeˆv=¡~É90,¥ƒÝÌ]0ü kœgÓkc„ÉŸJA?уSÌK÷Å-,#�¢ú5Æ^Ô *š8PÃ)nmÛ>ªþÈ�Ã@æÔ'Yæq!”¸´†Ÿ¹¼)²Éý"TßT÷`’·yl )’Ÿ�³MC¸œw:µ¼(RØy[× åªž™kA;ÝnÔ|æ˜>È�¬‚¬‹¬GáøšbO+íCu°ké  pÿå¡×M§£*„üÀ¼–«�)f�_r¯ª®V] +ÚgœðaYT>†æ0I �,$ŸR»Ó©�;ÍÑÏ(t¸° [ _ªV‹ò*;m®£cSÔ�2\‰”®¼M®PQ&š™vèJà­ÐX‘êˆéÙzºY`L~¶Ä»A5ãðãèÅ�k�Ѳðxx~À\@8!ŒBà ètŒ×ÏÌG;kšîŽF§À¦Að‚FêĨYÌ ¤£0Y|rá‹_푺٠]�ƒ`»ê{6o�ŠÈ{ƒl`ô#:¹]ŒÞ«yŠ~s Wå¼èžem +¦Ø=ýý82K*¿_�_\bò®¦÷ÌÅ›�N$xCE{ÂÊx(ƒÏæ.ÚŸèŠJgݼš+ñ0|¸äÜïú¢!„v·lYÒÒa.Æt·ˆaÿBÀá£+}l…˜užÓéS å¼o@Æ;¨ tíŒÒÝ8LU›Å™ü(û×±wH‡¯¹…ñ`Éœ@s‰­GµŽÄ +ÙÎ7‚‚^Ƭ.§š˜JpQùÕIuFÊA'O¸ˆ`n:?í½Z‡�:q¯LhD_QnaT–›‘ÒÁ£ +ãËB”f ^ÀÕ0éª|G0üT¸†|IB8‰¬ŠÁER(Ó¹Yº/rù¸Z‹¹˜îk¾Ã®í£)=T½Âôs (~1)º„É{ˤ×D42)Ãþ‹„˪V¨Ò‘GÌ&¹|9ú¿­x=ÕÊé©ÝÄ‚lEqK­É„Ó¤^<“åxv¸t +£Û¥4g=æl°ujN©‚ÿÛtè‹Û‹H2nð‚´gžk”ø1ÏñÏ•Oûq¨¼˜_ÞK`h�šÖó¶�„˜�Á‰T‡± ¸&£¦x(Ö&ˆ7C›p1ï6Å »§ãÌH(ÎÌ÷3ø]±ñ†¢™ä›yƒÉßkʲS˱$/ÆœË^•|D¢ª©6>–£dò›Û²‚"rn\´ýá[/URQ/?WVì\¤À&‚”¹órˆÅä½GêIXL+ããð#¡ºuâ,Šéh¤kàVšË»�ºPd|Ð*®�¦ýdØX ~×6ß £€>+Tm]³ :QëhØ�Ùx`Í*ZÊ$¦,=&ðMN[‚­OÙ€güß³%/¯ÛŠ&h ni;òEGJ4—âH�=ªSXH˜E©“–¢2-†™¢Ùó§d¬š‹žLg*ýçú›0Îþ³ÐÓc^ê/‘¸ÈNmÃþÕÔéEª—EÝëuZ7��B›‹ã칡 ±À‹F�•í-ë~Y€Áya;ôÎ….@;:ñ 9°strÜÝ •fQƒ4˜å8Ì:Bð¶l ±Ó Ò¦ä™KVÒ”(Z—›çãëÛªú¶gÅ ½îÀths%ÓÎÈúä’¥˜†ß /RÜÉɦ9{æñýp'F›¢ÈVm]'žÌ%©0Ñéu&Š(æ*g·`Æ¢1¨¼qê)ˆ��;€êÖkã®išÐ~³&%X«»5¢U=ÑN�I¡�‰”5áw\�OFLû†kè‹;y¬Ílæ ³«0Ø'fJû ŽÀ‘¿JPó8AR£"tõ;Ú鞉µ5ëjr‘”zÇæ33^kœ7ëרy×`|ÇÑ™„s"†&%?6mw?Dfêçܪ|�Ö+äs‡—¿Ðà‚#ç Tð‹…QÎŒú¸ªlnŽTb2Ak/$Ud¬È×ÍRíh¶@"nøáps¾0„.îQ¥ oo‘ßOŸ¸pzãpé:æuaôvÙåR ÆDZ…;ýÃÈ2º$S1”#ýØ0•:‘iTqzÓÑ ^+(T€ ÞFK >Ê©8¹e åw-Ú }MÊÖõÿ „àÏ +áˆi°ÕB…3ŽkÖf"@2îÿÏ�¿Ï, ò’0ð®¨7 oH¢Â6�Q½$G\8qEö¡çƒÁØ`Ô(xIRÙŽ�Æå1èùO\ý+ªÓ ·ÇëÑƆÍ §Æi=Ä'#µ"äŠ;íäæc—êƒØ¶NóW°£^É#a µÂVÚšºJµä>PÙÄjæ´¦â!�÷¯± )gŠÞa’~“ܪÎf<�­Æ'bË•¯ZYx( {·ƒÀÚÖMdGó5£$í)'OÙe¥Þ?T®A$W|©=¿¨—m>G섪Q]b Üxº"÷�Vn Õ¾häLÜUäü~¶ifx< <ÈôJì;3e…Yɤ†&Æ ÃÜÿ¹åÈß““AmtXBî–>ËpFó±��Œa·¹Îœ:–q˜PåØ©Ç“×zð(mKÄ„�“�-φÔzÿÒ)SNì'$I¶‡®ñ`ÊÑ,xøD§Ò5wn62'Ü%}Æ|_í@ÁÅ2Nwl'>#|ëk›¶¤ÌR�hYp™†oÙð§Ô³Ü]™hU L©;Fa„£Ï=èöOUOŽŽd?:Œ Y{ìP3°˜ºT;Þ¥pû ±d²J;‘xãD°:春ԺwÃ4Æ–( ¹¾r÷ÛQ¹@'xÊ,©V�J +’°$ól�Xíd²m˜H’„-þPíQ +3`*aê¹ZÄž×U½-©Fˆ!+ÿ8 Љ¢dîMHUƒ!‰¼ƒÎkãô&yµÄèå÷j¼pGµQvÖq;U̱½E Æýaàt;ž„ïbÚ2WvA¡SIwùéÊ0jp3é®ÂêœÂìaDm½8‰Æ6Ò²_èÃ>8Ícº|L€Zæ.Ž"õºÞ…icê +Þ¨Üq ú—a?oÖÐ?¾½WÁ‰nñLWŒ·/gAÿÑß’‚¸±ã¹&½�_Cctá¯0V‘¦�²ÁS÷`„$À³rhi:^´¨êk2Ç�SªQ™.(plNõô3Ðj»xò& áÞTR‰¹R" ¡ÇvoÏÒòn“¹Š1e"£y#ÈËÝrZ‚j‹ãÝ»/ÎÀ×¹5À(«~ÒX *#¬úÊI�{0‘�ü™dó'5f�.¥£»óuè­ÌPºÙ[xzú§'ø_yÒó¤ãî;x£dêÈÖêˆ1+Hh»¿Š~7�…œ­ý^™q™òL +ýN+ž“¸M‰`Ÿ2Oã’y4›ä½Ó7\s›ðíh™‰NXÁ ¡¶† ¨GpU1ë8Çq=Fò`ûŠý4¤!CÖI50Ç?&bôag¥êµïŸC?¡|8ŒÃ’®„ÄÂv[â _‘äÀ¬/�–­žãÒ|¾$ž�‡Ê od×¢jQl-ì½ñ TÉN³ê-FŒI”sºb˜ŠQM�6©îeæAJ‡sŹ0&pA¹ûyë €¼Œ·‹$ˆƒbñqgmÁ*)Ч)‹¾\œ-ß” ߇T Â¥w9(kÎTñXÙaôoû:íd+«}Ëú›U6Ÿh'5FÁ›¨ö…ÿ“2ê²½—­ô*sQ«0Q›8oª[.&è-0Ås«œW¬á ×ÃÝð~@‚›LíÊ@Cu£O‹C>‡ MwÁ# `sdh¤¬º®(_ʳž‹NÑ&ݘ�¿ +&çÛeÕ¢ÀéÄè,ɽ*ס#ƒ×áà̲q%ãܱ´ô˜1RÖéÁGUVÝh!³HdOý%Ó`Uè|ö•æUj«³æÆ�²Û=,8ä±R¹ãË"ØL“ÂûõÜËx‡’èw�p*;¥g—ØÍxÆ÷ �Œ6…�ä:|²'V¨ö6i•Hx ;³ÓÄÃÈ–ÀC Kr¡:N©ES.‹Xh÷³ÓEÉ’M8¦dÙ@ò·ÕpÛ3%Ïlt+ªU–¥øukõÐ4)^Û*�h×#@¬kXo25KbzoG‡”dIËgFÞã›Ï’ËY?ÙÒ)� ënö'ÿ¢VVí!†ŠNPpg ˆUàk^(ý6vÅR(íÂ(‘àr)À‰þÖ¸}10<«È1ùŸ�¤,CJÆÊ²µ>ôŸàڢƀ4Àéþ!RnÚÊàR xv£ö™® ¥ÃJõ²�R·]œé³Öž,Ä� Øi¥�–8ŽâÊѺŽcƒq{¾6²Ø�F«ü™,]ùh f4jÄ.gHÛª O5�P +{B‡gÓ�Ñ|ÿS˜º‡ð¡6«ÇèFä�:°»Ò¤wé““(&~»¿_ƒXT©r‚]Kö¥?TæQÂ6$ȘRE\2¸/·lE‰ T—|*%Îgü¸\ñ)ð$‰K¾êiÑÎ9ú�iÆ �GÎÕ’)Z.pâªî…âfuVîñ4þß²?P¹,²=‚uépæ˜uF�ˆ¬�ÈV2í “4(œL^sá¼BjsÀ<Ë{�BÁG(¡ B²Q® 2Å“Æ?ŒO­ä³Àîa¼PÔJªÍ\,ùþ<ôª³–ÈÙ¢ø©-jóõ¸·:-œº‡öj�Xª +AÜf°3m+˜ÝˆõÛ–(þqúdX8»éÊ“ß-aÄ$HÒêÎD2àE¬Ú|Ý¿•",”8ûæ€bÀ ízcpHA¡KËØfÄwì÷È�ðÜU`®`ûŸH?ùÔ”B²nnÒç‚ØÄVmÓ»ËåC‘+±lœ8¤AWwì¡*š=H±(?õ•C.Ñ*4µw)à´Ìc—Íԣŗz<’¯(©¬…ai2ºµ6òºAk@m¥FzÒ<#-–ö�I~Gƒ'ÇørÙBÂPØ*Å>ˆ–›”Gr2iÞ`í׳üœdu}Ù+gg=G,hn#¦H>8Ýœ?ò‘ ¦ì"ª7e)ÛæçYy†éyÍã­ö¹�(\[ßžmxYz³€,°šû¯«j¢6� Ÿ©œNÓ´0 ¦f’´…eû¾äE¢”ç /�¢69H0^nŠ€ƒÍJƒ´Çæ–à~{tè^-¥ªì+Øë]Ž'ÒàïM–ãv‡Õ¯&2¢Å#v�‘¶ ÍØìú>¹Â½ô[oPàãóã?0úÁ{½RFU¦½|¿|(ßmؽø­»`§œN±I|�­ †=W-ï$V×#�ÁzÔkæ7š`Ó®ñ/©b?L,Ìåô§°rÑHÈÆYj4É”ìõ¦ÞÐ3WÐÐ6®TQ=(SêöW™·RÚ.Hœ +¶N'®3æ‰F[e°þu2ëm�õJ¸× GFÈöè@{híEL1Ne€o<Ï^9¦Eœ…{z´¥gPB“Àà‰ðçáÅšg +#Ó4ÔŽ>ôWù'SÑ�Þ^ë­”C~ïQì5/6è×ñ简ðÈIцgúyÉ””4Ø�"*ŠÝk3Ù«`W¢ÃÃt£ÕvÒ¯ç�4U¦àä$ß.t&̪e“F�Ñ›xš•°lï~%£mñÊ2á¼Ïð +̧«|s;G$®™ Ø� ;'í8ÒùØ/$#µ#ÿê1©[YN|Š8 ´ñ˜Ue"C¿+¨% ›�Gçïîf™OzEm*†›?�³Þ±5 î“1ÖQ]cN×?뎭yš§AlÁ�Y•`T°,LLÇ6AzC!,ï®;–¿ùCxãØ¥7³Õ\×Ð ¼¤ ™Öµ`6£“ö»ªö¦!!ãnoBnë…¿&zò�£S†kÜÜõ—:?�C—‡N¶;Ž–Xæ™÷ªîšCàç€á´ôTäç±S´Ü˜ŸqÞŽw#±pË_ÂêAèÿ&ŸJÄf7¼QÆOæ7Eã ±G©jXû0›0jëG3ÿV_@mŠ(+¦ `ì&3ö›˜ZJxn‰n½¯‘î£ÈUŒKþM ]LY a¯rñJ˜r{þOØJ]Ãßÿ +_Ù¾òKñ¯o«!aåg] ÛúG»` +¡ IÜ]�ð«7 ¸¾ÿ\² àŸÎÇ@¬æ +;Ú¶>·‘ö7…d+ƒA•‰'Es|Ûv¸½rVñ²!‚ zóñƒB/𸜑-ß±$¬‘ª <ҽɦ\ØÈ«hó©ŸëÅ( +X`­C‚å)Ø»®øúŸÞ íð� v“جBj )¤4½Ôš­ XÇ�#x¹[Óù€Ì\( +®ûFLPfOŸ¢ƒñLrÌh†È˜ûÖ¬_º+5/}¥Š+ìÿ£Ñ:±#þ¬YHµ¦\ׯyóÜ’†ü{“öAZúO>²-T®W@+âTE;%æRuUWS4`qp-…&­nƒÂ +£Sˆ°¯Ó³‚ö²T|ZØ23�;Ž�ge¶»Ü&éSÛë­0·m'k÷ª�¸+Vƒf^ªEHÛ¦7Êγêe7(ù¢ˆÉUáÀL˜íÞwíKu¢Ò'Î#we…'ƒ¤y¯þ˜$Ø¿œLB¯8?ÿ¿Î×bɽXÆç¥ÿéœÂ0ц#I3‰Y›]ââº9¹ÿ³¤ @ý_JgS†lSZ*›óŸ–´��“{à=;Écè ^ÞR|Êv�…Yz°Ðw!f`MD-ý‘äQtS–æO&Ÿv?v9‰l8Ê­ž7ôI©\ć§Ë‰^¨ZÚÝè%qëûP�ц=äî+×§þ‘šr[äJƒœÉð3¨gUúׯÔPQ�ߘ2dÈW +,u6ĦÃca³wâ¥ÑÚ,¶œ¿SþÇ®nÌ1¼?Kàz™mDØyïîØo¦Ø@ÕÒÁ=óJ˜è Ubwm~U=À©ˆp�G(µ�L� tO©ŠÉ†ÀžëD™7=ž‚â‚sq%6„¢”`|…1£òb¶íjü“mO#F=õ› N½»åµÏ»ClémÕ}7¥cË>à¼;rœg,˜‰¿%[PÇ, ™×:`†R|uë`šg$ßiZ4Gkýƃ\�ÞYvÿÜAŒ³Ô†òšd›„;ÉrˆA}h„Ïß5xžP¶¦¿:pn‰ÿijúþT ;¬� ÍüÆ� 5^\O:ø¬›ôÕæ´Y•MÉÏá®^ä0e¡’±Ë#9XÜŸ�¼TuÉEM}Èö>cùÉï!êàÉ$qù6<¹P¨ˆI&Sjÿ.÷‰töåîùn\‹§ü‘œèNr£á¢�"D³jû\ zÈ¢ÈHv‹Ë]¹¡j&óÎ0Týý;nßTÃ@ ý‹ŒÀôÔ^`¹”^é½°-D¬Ktø{¢9¯‚Ð�ÿÀbkA"SmˆÍåšÉ÷ +0äz?)³£=Hì8 £Ç¸LÿKƆk$,bÊþ«™ ƒ÷Ï+Ò†`( BÂÉJ½ ÷àÈ3nµ¯ùpè×êä ®ÅâPàmµ¶¬2/n„NãÙð×G¿®”`_S Ã�·�ò‰£ö)Ápm÷$äÍ4›ÏqÃÅ‘z‡³�$ó¡C;z ‹Ô òOŸ1¥ÁÖØ‰‰¸Å"<3ƒ=ÂEúÇEMë`·Þ-¾p¤˜—ä‰&1ÿ'-êäGNrå� ü’"ÀÅÕO" ·êĹp†Íq/+NÎ�œé¼æ€zà\ÚRù¿ ügCù¸âÌ ˜ï¥~þ8¯W@Âi6m +'· Hl�˜þŠª¸Na©ué»GØ_¯žîÝlŽõa1lˆ]eM>’ü{ðIëëÓ)·_ÎéfüîÌ ¿Œ¨V¦‚¥LZÉT� Ú�ÙÜ16ܘ¼_¡*5Bñ†æ³Ý€& h7¦�èFªèxCx7'åxã½&6sa�l˰�Õ°*o¯ó�T·HfLRÆ3©úI¨%°ÌD¡Üž3GܵR¡^Oœ{Á ;§a —9½üþëí?G ƨgÜèÀHñ£íβ›ŽÕb{ ×ëÅé5]ªãòÚ�W"’ï8‰‡õE‰¯’¸£ ÿKÁ›™ ¸B»�×í�í7?÷Êí1î§Þpÿßqÿ/Ü·ÙÂÍŸ°}ÛsÝþ²í·¤ÿ^ý¯l,µçŒ“a`Æ‹õyêÄÔ_Qõ¬¿�ù~ú=gr|®”æsÝ'J¶ÍZÏWL`SSà‚é ,‹›ìlߊS +µŒÚ;çoBì#a”Jÿ9÷“žUG„ƒŒx¦ÔUAFC°_˜^ês†x>9ê_RÏ8õŠªÇ°z·ÕOö´bé*£L`†Çðö-wš¤Ìƒž˜E8²—¼Ðf‹(ú [ÿ¢^êY³¤P…_³mÛ!~íâ¿`ZõŒ[-å•ëB^qN�u5þ)µuÇl&‰yƤ­®Jì𮩆TÕÙ:SwO­’«�7]>´o-*8Xé²Ö¨1Z&VW³X—m¢Å^Ö@Yƒ@¦ ñž�ÏÞ·šÿg +MOX�ãi:NÉÄæ4ÑÌ6pÞs™šZê-%Óö­˜ÂÅÓæÝdvÐþgþÈÕJÅ1”anZ+\\ÁæZâÿG·&OìЩCiŒÛoÇ/Ô!·sŽähG�õÞúã¡zæÌÂ8ÍÚÈï?t² xÁÙø«STؤº\5’}ý9场ˆÞ®”#›*gÀx¬CvÕ÷›mK[´S祥G¸ï4–‰M±VÑáźØ9†úã½çk;þëé/îËÇÕ[‡ÁXöëì¨7úï…ø� +6µSçäÃ�ÒÞÛ=šÿoOxdàÍx”ã_֟߯´‰3¢oÜD¬oÌÇ]*9­g5›Y!4‹¾´½”L˜«ïjc¿½R33«qö™PÙ�ÖVEWòJ0¼g[9ü¾ Û—þ“Hô~÷j®OÔù 2žzg§ ®j“d|2ʺrŒ‚D2Ë軡z*Åî¾”Ô\‚#³å¨+bK9ÖKMŰò G®ï\I»í)œT†c쯇¨„«�ã™e-«‘§œ©f÷kÌhÔ™E£NÜ53#\̯5w�ÛMõÚfc£7íí¤“�ˆK¨õ1ߤM¥“ ÚÅÛqPéƒF–‹KDZ]Û#¢4­9*ÖÍK‹|dX¸õ¤yz_•O½�æ«Eó{ÚÿÝîg·ê§j;Je·B‡¾ –;T𣣅x>NÙÑÔy~ßþ°Ît»±nï´?u;x£�%%¦ÝŠºÄÌqÙØwŒŠzŸÒ¥¹JÜ©{ªê5®ë))� ÇŽ•^æ±3²ßáØZ­¬ù5ëÒXz1fhÏ‘ÇÔxsv+Êl«9ÛÖlîž:ãbáhm“YZÙÍîŠåÌ)¤qzsjjñ8V�²ml§´íž�vÍ¥)ûõ OÍ Mã:›²K¥NÙÑéiÌn±p) Ùëºc¥ï­XÀ€aƒ…Åòؽ¶Ó©R'Ö'+uÂå„kèÎ6‡©¯JõåúºÎ»«ÑÉt0É‚Y·bƒHGK­¸ƒXÀ`ÂÄ ÙðAY@)K¥Â€À 8L ™P +ð@òxPaX ÇÃWnL £òp0pÉÒ ,<ˆ<6Dx,`,4€L&L"x0ayHX(T$h�Àˆ%–‡ÃdáAäáàW‹ uÚ_ŸßQjFkE/ŽsË·¿a/Û13šÙZÇFÛÛœ>$ÛÚ¸Ks ÊA'úòqôÔe8wŸçÎd•s7ÝÜ}·Ú}5êÜqéÚ;^Z¯‡î~¤»uÖå Ò!݃«š‰ù!¬:Ò³A¬; êÔýnÅùêîðxsw+&kîr0iwî~¦›®ïîkã¼_/ÿÄ#:šÿTÓ~WÇÅÁ»½ßìY/Õ“§·t„ßIÊ#aâ­XàxÏ7{9ÆÔABîN!f!ÒNá" /éÆÊnŒˆcM\œ$šCÜ1âá)–Îá­8ÄCL3»Ä[Dü "~ 'ñ3Q‘Ò&‹f1=‹F“I9‹´‹¸x+OYf¥i¦¢¼•¤UYše‡S¥yiçÉ´Dó`Uî'�6—xs6™‰5é¥Iúîe®í`iîîj¥ ÞÍåÓ˾:4ÝåÝœë™:öåê1­ƒy˜y+r2óy“¨–úD–:ÓlR³2“L?ßÜü?Y:š›;I:›dóÓñæõ:Ò|=Ïɪñ­“hã¿îªc·M=þÙün|ÞOž^ý¹;ò䩞r“”4uq2þíóß9?¥G+F÷³·œÝÏÞæ °éAÝDßäw·r2냋“‰H“7¸¥æÁźÃ4ïÙ]æ{ÐG·Sh«NÝιpsO4X4¸´E†4FeXõι|âœý�ÏvŽ +­ºeôY“¥uµúØm²t,+S‘®«§¤5u·‚ÎÖ¦îwu[èÅïq,oxº£C#Ê¡»»×ZcMÝCOÒ`f¢M^ž¯æò.Õ®¿Ô˺Ïå—ÔT§0w +ÉnEaaÝMáñøRþhŽÇBœü58‡5.œÚúÌþ�Í>Ûæ3Ç6yqΧçÈt9öÍ oÚ·7x7xwňÑ^Mê\^ê\2@·dóÍý®ÑÎ>étÄA‚Ìx¾ÇÊIÃÁç=M5‚ùÃÿåë>={3º“&å礋×ñj›cª/Ï­7Ö«žêüŠ˜“úTÔÔO¦¦jjþV5×çâ­øõ>œëíu®“׫ùõz¬«¦^¹º°úÝÓ×y’‡Nkû«Ó~ÿ½í±ôW›ckkæµ}¦wi=š»]ÈÛ¤Ú¬Á5]ÈÄÅź9\ÌÍÜ×3k'1w¾_]Ýç Í.î î­ð÷ß½÷H„¤¨ú\$aUfnZÚ©QéÕŽ¥¢óHOŸJ¾záJz¥£DG™TªEÆ©<ëd•á8³Ti²4ÑŠR3-µÖaR™nU¶:fR+Nºj/úœ4I­Ð³1ÒÇ�öNçHI�”ôHÏHIÕJzEjÒÑõ÷VèÖÞá·ð{ö½Î]ŒÕžÇÆ”nps÷¦®-à&zk±´·Â[;¤£ªêUë­ØÓ»œkÞGUMo…*ªÞ +«ªš^Ò¸´´ê”T¿ þkíI•Çû~46´»ü²Ÿ}§žc7ªÛÕ‘îKÞÔ£Mk>5ŸißZóô¢Ÿge˜kY¶Á4 ¦íôQåN9U¶Óœ¶|ÙEõ4å˜×êã'fM¢1iŒj†9FÜ#¥ÄÅ«9<£ÏƆvºc†vÃM+[ÃOá¡§Ð +ͦÐÊæB³×]Ma]Y¡-k¼T³G6ë™é|¡Ðƒ…礩Z”Í6vhÒ±ZÓR�ÝÖ{çõ?Í(�^­C§iñÎ×ô)¯F£…$«3*-¤Ì×çük +%,�E!"� +e¢àX( +•2yHP˜Dx8$…AA²,” +Q…Žå‚�áá@�,A¦`Ö�TI ‹Còp@1sàqxHT¢ +D– 2¬™B%JÂ$²Pª0A�E0 @ +¼þ s<Ȥ,Ž …�LÇ’F£ÑhT£Ñh4´Ñh4´Q’‰"Ñh8ÀX$H £±P*‡ƒ™P&•Iå�"," +£ò°P �&à°ÈC¨B!R òƒRa4¡@D�²HY$FÆ�€ T$Kdi0 $ ÈÃÁ˜‚x8”ˆȤÁ�2i ‚e¡,™LPQ÷0ÇÄ�Š@Y¨,‘†ÊƒÂ8šÀå *HM0�M0ADEP€Š +ˆÈØ¢"yL XŠQy�L¨*’E2q €�¥Á` @<@<ÐX¬a(Ȥ¡€$‰¨7ǯ[&lL@jW�RÍzþïjözÞ"KL;o–9¢€ $ 8`ƒD-róâ�“Šj몑ËÖ.^æÉl cfF r÷æýPâx¤6ª!¢ZGhfªxtuŸÈLzWÆ’~C‰¡Ýj ÿ$E=QÕ�ψx{›å�3mN-uww4�¸J$l™w#¢!¤;RÂEöÐÕ}µ×zÀ–ž¯—ÒáÐæ·ìÒyUa@�pHx P 0@IIIÿWþö¹�ÚQwM÷¶¥xYeI,œÂ; #×)üunwm§q½P#Ö¨#º·¸ëŸË»@͹¬¹ó¡Ý¢mEç¼óšV¨dz’š¢ p0( ‹dBÉD�~€]n>¸b „¡�XÄa 0Àcb¨šÈ9 +Y)–7%<Ä'+ú®£p}Ué×ZëäONe}bä2Q(@û¸‘©º”2ôÏ8%ѪţuénÀɼ[Ÿ ƒ²· ½jzBÐ#Áï%º€YgÝYY« BéNï5­ëv¤M*’Î —Ȳ«ûÐμT? +QõÊöûþ±Qwfqb=ÔYÔ¼Ò¬cö¬ø=·ÀD§¨c‰�k Ma'¤Š!¥žë¤r3«�‹Å(jr Ô¤…;Aüië/¬|/Ä&CÍah‘u�¥èËók¶p¬ô4Þ>{¹7˜5];^û(l�+ÀßZ�£¡í±ª«+ þÒlÙyV‹ œ_BKŽÐ±Ü¥×ÀB_>BïIñ[Ýúûƒò>€$çý9ËwH�O¦¬¨lr(¹sÔJ)À$¢WÈ�ÖF�å¤ó#Âþ–ä{UÝdmç0‚t±z"‰¬4±wåv¼Ôïm>«O%Í �7—Éß1Ù:0�L]mívfÑ¢ SH%�µ€ì|=¿à˜aÂd‘•i6Í-hÂu×;9ì2ÝN‡+È­,x¯”QÔÌtŒQæ´™.½OiŸ ýíB�BÎ.e +â ׂÿ¯“a±ßúxW vNR°Œçû÷³�Ž$ÑÀ¯(‡ ¸¹'cö?ìk‹’tʉE€<ÏäÄùÜVü8-Otr6Y)A�p“x<&5|‚³I|#(¬<Ÿ(N½¬£ù5 i; ([ÇÝ]&9¬K‘áž�Ÿ +ì8ÂÒäX6æsRî/÷>²-b „�#ÇV!ê·+7•Lî*r³“1”ºÔFî!åû,»r®åˆg +N/Œ³éEª.[„ЉR“id¢¶7…[ÁQŒ¦!˾wÅgT¬ Ê“»Ÿþ„êÏ0ê|½¼F~»ÉâëÔð?án ¸²x‹îÚD„öº˜àf[ÓpJ¯Zt[ŽŸbý˜ípÎĈ¸btï¤�r Ÿ 2s¢áÞˇF1¡VÄeÊ÷ uô� N®(î¯.ôbï™ØHFC½oFkñ±º§s»çz¦‹Uòô&ïò®þt.§[º{éÒ…¿ÄÐk÷ûn}ÉôyFšßÕt§D-õæ¤~NÞ 2½c/ioS”Iá °˜JTÓô•ò¯ÓlíÆ$¡Àq‹©Tú·wWÇ(q~ömRaÝ]ª†µà²cê�_Š`Ö´{•¥´û?+Ô®{6 fj>¬3ôõ wÿ1‘ÝÙU‹ÜAˆ¯G…-3 �AæÙQ’;5‹Õ} ®'É­]l�Ý�ÀúØ�NÐ{]ùÀ—Dk’Ÿ�B5u +Ú—v�±”G>&Í*´qþAµ8P6ëv„ïS!Z''àP—'+&@-U§f !ÙZÙsuD†aj»®ìXHƒÎ|®á­ «€ î"?†�κ 7‡AécÛ¸Qê®:Òƒíy§|'}ʦܰ¤YÙä•Ê­ùciË ˜" Ü—¹ãÚÔð¢·&z´.”y„¡R#ðEAÛfÓHÚXXvïai[|N1A\PK,‰>NNÕ¨¥g*µQ®ž%tŒ‹?_*9cЇ2àç#¸ƒ«”‘¸¸æGhÊqìe\-‚lj×YJãò�gC¬¹.ž ·î<·êSÖeTCfAH¹Ù4ؾ:µ$Ùðe&y;­T�RýI*†By·+«6²: �Ò5ÂåÃ�Dzþ‡hÄkW¬Æ"æ ¢úô8ûÕÛ�’¿· Ës™ +Ò\Wâ¸_àpIQd�ÁFÿx\"ÀÁÊUpu'¨èG˜m#�¢³1OÚ›³rß»a ¯y"­Øivo!僵(3 8HŠo|ÎÎöŽ[…P-8yi!™;‘x?€OÉMÌldÝݯöKšÕüê:ý .˜`ðT¿z¤ìlRµ9†\‡ŒØ±Ú”´¬”E�ºé�6MØf½ßòцi‘™hê)¸;¹˜Â—bcépÐçp+¼ehÊjä ôó†k…97šq é«0•Rìt Ÿa€§Â86ƒŠûÇí£�Þ•”O²œú"¸kmÅ+¬ô)3Ï�;AœrVâ:ÈÚÖ›ÑX"ÔÁÄ?jÕÈp™•4cÿøö/Z¸ƒþꮄ=FÛî4ˆí‚~Œ8\àœ•PŸÅ‰F>œ×i†Šý`ßF4T©™e¡æD(Ö­õ_Ì-4Šß8�¥�‹nÐ uèëJT’³ê¡‰ºõ‰}X� Ñ)•ŽÕaÔIžâJG!Œm±Z8¸kœŽóÙë þ )¨Z$ Æyô¦a[F¶p“`‡AQ”@ýåaè=àOnŽn6¸ãÒ(%ã¯uT8øÿ4ôõ²¨xr4‡*MŠNb…ì¹°Ëb]á2—î¡Aƒöeá`|Ë"`žOË£aÍžð`¹ÒH®æv+)f6ä�¢8³2$^cÁ,ú²ã¦¸.^¨dç¤6cUÐ'¬û‹”#g¶Èn¿ßàËS³³ZFñcsYé¸þøñõëM#CPVFŽ!ü΄3ãfpIçS\¢¡겊Ԫ>tª¬„y!ÄPcjÌTÝÀjá2@Nx})ÓÊ>×b£ÞŸ%=»Â¿e½(á­w¶í�Kse\¡óES*O#âê<µYå`íÌ`l‡´8@_;‰gDH εVŠÜ÷QÊhËKœð¨NN3`;É^ +$ÿ?Ys“ùdnw‘È=ö|/*C�¦8îòª›×Â]ÙŸ�¼hZ§{~úÃû:ÈuÝœ”šP€ê:F£vµ“7Ç£F~~ÙÇ8]Á¨€™£éÚÏåe°�d�¦~PV94T‘ á�U?–QÀf±žDO4X\˜ºAÛÀpMHô6'�H¥‘¢ö‡´¤QH˜Aãëm%8`;€O,á! ½¦Óöc¿4ª›‹¹Îé��e ÆÂØY�2Bêæë˜ ù”&߬2KŠ¡ÒŸmÒB²€æØ3€ši9³˜‹¬1p�\+3Re<¶O­L#¡¿ § s¡]§$OÝF‡èIî5éC±2¿á¥0óÜ|g$ÉÏæÝ@ê;ØGßVw’ÿZŠñŸ™ÝM˯$å.�9]³Šð_½Õeå‡F¾’;ƒ”y»L�>IJ‘ƒûÓÓÁ À¸mç؇=~«Òǰ,ª€Œì9Mj*Àyroݧ5öº˜};6ôpùTäJæcSãÚ$ñ5Û©(Nc9SN(¤ÿU8?­Åë9 ýÛÖG¯îvx Ÿ(:”¥üîÌ ‚åôƒŠ?˜Õ?¡xj~�=pöëΧ�:û·Žâ›2¸À5ÝÁMF©Ø‡8Èã¹T•ÚZ]üÑŒˆþäÆr$Úè¬ôçHCÒ-IJ•GN Ë·‡™® �¾2seJnäȶœ²óµÇXŠ!5ÎMäÇà š!F(£­„Êÿ<ÏP¾šˆƒ•~J¢æX®Ï<Á»3‚étH‘ij±V²M‹&°ßo]Ž!ekw’„L­IþF×)ß'ÅI—m\Õ½·á%­’Ųƒ]¸G ûúŠ ~$,ýz•ß”÷¦ÅÚ4¯IPdTê¡�dà¿].ž‹n8K÷T¡•®L”Þê…zÎT:þL›)‘ÅJfBWá—í‡ocÛŸÓI•5 ]Ï0´8®5 _±mœ˜A‹nÕrš÷l(OR/§­`híÒ4ê”[ +hr¨ÉŽ\Ac„ìE,à¦ÿmCBr(R^#î«{¾0GL¢bÿi SR¶ÅX8WEŽ$ÍÃp„ûÿ=ªÏÂÓ!é^,op¶óhv¨Ý�ZYºõdCzQ½‘õø ]™ÊÚ/v�ÛÔ + œ ¾÷ÊÕ˜cÉmÛâá¾HYµâI h—5ìÐQßm¾:H—y /³”²ßª7Ì;|„ÿ¡ÿ4£¹}E¡š!Û¨Á+xsÄà=lù +%DÝï îPß§3®ƒ��t¯±™'ÎØ•˜,Ñã›É4G–Ͱ^=˜ÌeèeI|Û—™ +BCÏ*«‘ž >+…M‹C!Ýd}öš¤ T…u^³~$I�$RsLE¬Z «1¢·!:¬:k•ú‚d†GžÉMK¯*-è¾…Û.EÄPÖЗ_ 'ô=±•€žSÏ’‚UOhRŒ¸�y“w2~ø|XðyVÛÑ�çl8ι…Tœ§C�Hµj¬N…]Ÿ DÑ›RúŽü`%>ݵ›ü(ßÓÁ�tBŽTÛ.�–AЭ*Ú\èœØ�¤Þ@ÓWvŸZÖLîxò'6”`Dàt xXÞØ€kçå‘sEXfô>È%†#œÙ,ó15ý/ÐÁ\Êái ]Ä ìŒ°½x Í9nZ€�Qбä^ñ;äOcŽ kª„3²ê÷ì 2yØŸn Û§áH¬¹NAsñT‹Vlæ!EÍq\Ä/qÊ ùk.Y�¿|À'û ŸîüGoV*÷ÇËGoö�êc¢b¸¸äy]Z–ªŒ$PÒîœ µØ¢ÑÜÈ]nCRúÑCÄn?âMþ+lÏVÜlõî2JU-]v¹`‹ׇc|ºåîДøìv>eîT³§¥ÛI3Ÿ d*¥ü Ê$"µâ´4². •tèÖ¸~.‹á +‹LH7?ù‘Ú$¶r;ÞºV©Î¡7Ó`§^FDæ‰ä)(T:ËTyçmFA+ ªCÍoUp¯«Ìt f ýa,7É<ðêG6$�» Ç 0ýl‚¡êjo`'?Ä�i)l²�©9Žj1ö†È›˜’HÈuˆ»+7¨ÝÊ2Ÿb¸¸Ñz3³S¸›âMMk‚ˆ/;{ðA;qåøŒx^Q£Þ*[€PÏ¿€Î—RlêªTSj{̘ÇîÂÎY˜‘œì°ÌgŒ$¨œµmËøðQñ9 ¿-¬Õ¼ù;ÚœèÛBnd¶q`’áÅg÷Ýsóü÷‘ä™G"Œ1-Õ΄ÌÛ�!€‚Éi�°z¦,/¬GË)SŒ¶ßB`íqyˆ˜¡¦¢ÄÁKÐRdAWˆÃJ— 2��\¼ËŒÎT*À ÇØO# xN~—K‰K;¿åXúビd�„Ãj†ÖÖ]ÆŸùé¿ü Ê7QCZ)Húýp�‚;³4aIäEIÜY‘fO0Ùêb¸¯[�UÈrSÓcñ&ÌŠÕi\¶àâý¤rkîRjHV«Ï'�S°0xª*-êZ†¥!^Bå=.àlc÷Á£'‘´qˆ½¯¥Q Ú& `(pn©¾ø°ÖüRˆºmú}œˆf��7þ½lÊöÁ?F"øš—©ùc.3æ +Á*¿Áî›3ƒjа˜®f}ÒChë¿©ÄŒ +<ݰ×£PZî Y”4 ³Î jJF±×†aˆËç#õO5{óøÂ aVÇ SÖµ¿Iv±#‡Ÿù8 >|aÜËT.²«_ÙM,ki£f &ˆTU 1²r_¼‹ÙU+˜$Å�1¸Ÿ`Þq‚8ã!Xõb!y¥‘ŽHÝtmËôª¢É1ØÊ*-¹ éûÿð>*Ešu÷[À. ‹2:¥þ„èQWøšÖáO]aÌR±œ* ‚Cà)U—w…æ‹‘ˆ3l�ݵàcŸ�W£šb {`uÉ¢š€:ãøä�Ûþ…‰ ›¶0¯^¥9õ×Iä™áWLoü.á”ðgK%eQ�çBò•‚@�" ¢7À$Ó±¨‚úMÜòjaq%¯!Ž 9'ޤ2£Š’´;9°¼²Ðÿ#®“t °Qpk Å?›ŽšP¬vzÅ>4öì©!~¨ñí²éÌ><Üžã¥Ñ]cñË\QyiÓ‘úm9Ĭ8Úîm¯Wöl³7s³Õû¢Sôå$jºýM-ñ”!þ~lnäèÜ ®ny5Ø—o®ŽQ5•ˆ{• j9Yð£mfϾ i¢æîýD+2…f¸ºoÄŠ ¡Ù¥¹Á’÷â/ š9îâ`~óGj%¯‡\‘I�î`«yùØâ’ÝßǶ?ˆxkYÓ��ïuR)¬![rpÌ%äû¥ÏòH/M‰wÎK³9a€[LgXÙ‚z[Xª©H…¥Ÿ¡�ð B‰—·L…š )Ž �.S¥§Á»l«i¬5vp!bM¦R†’lAäRo÷‚ñ˜ï}¤&ÍéT(½ÄSñ·Ù¤”¹mç·J¡8!KCß;лôPƺ2å$˜±=ŸéB¸–¯}‘_”¿Tk_”¶ÊŒÚêi Ä ù^ÄC,„RN�Ë\ îá9W•2f[ 0oÌ cN”äü°T·O%þÜh3¼âYäm€˜|þe"6°�>2%Ë “¤¥+@—/7ÒÞH7žäEì1ˆä¬*TÙz¾¢ij¬<óVžÅ|ðò’!ϼŒjjÊ”³›ì;ÛL2ümˆÞTE`3!„Yg¥’•@;jv/vÊ’øI+÷F�P…4BX.§;†ZõnF”ð¼Òo„4QÜýV†ê®»�:˜TØÕdž=ù†Mð+çX§H`pÚñ·10`�îÒ‘ç„tò\]ZUV”¨*¢°ÈFÀ8Þ¸#B†-g ŒKSnŽFã®î(¢ù¿Q"PôL¼s÷•y¼ÍaUwU¤(öÏ:¿^v®i ÆZ–¦6Ùê²T�×d0ž À¡àMcð·¶I@Œw[jZ2BÔ|`ap¬Ýf¯ìáj´·†:é‹YDVÃ�…Ì­jþïö‰´ ®ÓßVœÊÏLQ´Ç²”�騉ç@•uv᪉”¶1¬ Mª9è.xð——›¨‘…)ª–>�2Í‚˜‹m· +ÍÏÐ×Ík âGZˆzê-Ñâ"‡·éÐèQS�S³ì¶6!mbgŽDxèº�‹xæ;ÅE™ â�ÄBîŠVÕ�J#4ë RË�F¾"cÎi<‰N½…cý°:®Z|Òí-™„µç”{ jiNäS(ÑM�Kì÷ �X„¼ u»‚»Á›ãŠu´Ð¡WÜÐ]n³„äæÂ˜Gb×@“¾f žBzî}[rOc!ï�ÚÆ¼ Qè$,lN$ +yo’ÖD�Å߈ë:Ñb{±¿  T€8Š‚bât»RÕ‰iaÕGH¤=%™;2âuiã99kÏËc™!tñ,°Ý�­L%ºÍ¾Z˜ká´ŠHK ¿ÕžI!¬RŠ�‘ ’W°ž¶wiKÀøû&ÝzåM¥TÛ#¸¥E€9D…%eñqJãqªšl×%Ùɬ7Ž¡ó+%É"õ¼ ¬°Ph/€ÖÏËELqAØÜl™£~ÐÒ +Eê²?EnÏ;‹ZpŒ"/Û§>íçßw¸m*¶�Fgüa¶~9/TÀ#ßïñÀLÉ l…`JCÃ!Í·"(‰™kÎÆÍ C½íO�iÒ­ðkE•¹Gk�·ƒíº»PaaH6ع…€‚(,`‰Ì<SÙRð%™ÃPމ,——¶s?ZxXŽû»¹È.—Þ@Ö…ºi]éÚyÈv«A`oHôxߥÌn°.8©ËÉýJ=nÚvl…iw2£X9ñ[XêëCth£–�þרª>f8záÚ‚@­ù*Ê›Ïq„¸SæÓØ„BèG쿹ìƒ_¡`¾a<ÆÞ…Œ�šcaÃP∠¨õõ0m©¯Ó Šò`do.xœ�Ö ð™,Pš-£vѰ²Û>FOÅ2éV£h¤)Ù ¯±9ˆFJ3wëщÎ1–^J½³ðv!WK/5бôwµ;Ùòñ'�!Æ×I‹,·¢ÍîàB¢ËÙÈ‚\@FnaпëÁE÷ ÑÇ�ÙÒqÀŸ(|•=öÜ;�õÁ ¿ˆÄØØsñ%[à�‹¦(†Óö ù°óÓ_ŒB5W†2PûkLAiA"Ö¨;W<ªÛ*ÉÅ +ÑÛ¼4’OÉ.¦ìü”¹wLðŒJ�ßIàè™’Õ�®ÂÅ4LDÚǨýo 8o§ò£ÁÀ¼OÐá|«Mq‹»=p`¶•ÇD]ÖÀ²øøvZ�y¡á š5&í$né ¤qv} +C²œßîYP·¹¶dR€†Y.bJZ¬ÐÑ�ñùó!…ÐaOm¶É¤RÿÍ€%$çâðëg¨Ýÿ¦AÔ,×ûªuDˆ@V�»kµqLºy>dt+ìk¿|!Єq«•šÝVZ#jAâc¢/—Æ„|÷o žr$Þ0 ‰<Ž3+~[¶YbèvÚèù—*¾ž�!Ï"’×¼qþ3Ì€ÎJóxÈñ1Y²{‚ŽR»I&Ïȶù¼W¬9Âu{ìçŽ(H#†&Hêè�QöØAž„D?^ï …ñ5—`|ÕÜYÅÎv÷ää'!%!UšFN†lâ�aï9™õºÒêÛþA÷Û-Œ«îjÐù5ÐìpkÞœ®Zê—))V…ºFQHê"zIã ‹*Çí¹Pz+T|«ýS„�•ïQŸëoÑó­2ÞiîýŽ£þðq�2Úø„]ýàȬÏM›±w¢‚Á#~ÅwÛêHò=‰gUðÅÜ@â^éÂG4Òb¹Áê®ÕR§xOpùÝwHÊ¢Çj2’L‹â9j9¢Ì5bhñz›±"f(ëµøZùö(#/ø^ì×I€{<&©A�;)"�^sŒ1Ž ¶$g^¤Fúk]Çø¹Cmãó�¡Š�Ðc{˜XÚ¡u7tou—\µÚ¼Ié˾WÃFߘÈ�³‡ÄrÆ LoFŒÉuñAî/`HâIoÁŠô–±ä�Ëtšl|®Ð¥E¡Vß²ó)ôrWž¢8ãÿýR¹KÁö†×Ð_†“ë2ËiÓí<±$½3£_�‡VY†ëk†k8 P‹)�iÿKCUÞbNÿÀ¦bMH=3Ѧá²hÅ�ãë–v´Ü7™D0úÇ ¤·„=Š�5óŒôLÀUâÝ +¦€‚ž—mØ,¼šñ¬+Ö¡q4OÔ‘àk  èQuNÇ79öŠ…ªSšÍ@ø=fŒ†¥%ÿ.Ü`ÿô� í“Ã*ÃaAטևjgäfüú®>Fê4Šx$¢,³%ZFM%¡¯3ÃËã–lƒw ™€Õù'D +å ÷´±´­¼5FWi£{–»!¦%ȱ0‰‹kŸÐèG`Ö‰ÝCPÀºU¡ +Ér™:­PßÙ’cÁê„ëï‚üu¸í:¥êtw§%x ÜDÝh)a/ç‡:ÛsiA¥èmHåT “¡SPÂ)®_à f¤nÏu +¼ÛGìòNtâ'ûvï£*ÊGÑ[aÅ�ú5e”îë?+åPJ›ôÀ:QÙ„T.„�´ +†ëœ‘1xDQ*uíŽèÿÂO¯›#9ăLói�­ì{_œÆŠÕÏêæÍÏ=ªég² +•ÇxÌÍ3üGLNv!$B «ú¦¿P‰u Ç›;«]�«Žë@Á’&€0= ”aHðdÎI›¡Ÿ§¸lŸ¬+g)ºï…UQrqB/âL¸�°»Œ] + #—ç¼v#]ùjE¼”ÂwJ{Ô…èWå½EÅ{ù 6;K 7¡ï�–¼n¨¶^&AçK{ÃTqoà€oå§Ûf´S9`ôª??ß1Õ„œ� Ç’EMVp1b—Ð3=õÚºR¤Ø¢æìv Ö½U&å21§‹)�´Ù^¬ÆxuUO;ù9ö(Ú/édž££}” �úDN,(Jlí3íîÔŸI[ u¡$˜R6 ›A(OÇrѼ@ÚöHýlâ@®W8ª ò"ò2"kÓД¸È+¹+‘I¡ Žéßî•}ë  lª¨a8-_¢«鵉ÿ…b]æâŽºÙã;ïlè¦v†–­›8´¦Óþó²,B(~"�»B•,P,F^XSk̼¤�êê0ÏÚ|*›C‰>á0‹£œ±&+ÀNæ\ôRaÈ5¸UbÐÇB°A”WÅö_ PTŸ˜U°ÓŠås¥œ,v\·¼Jw°9Aá ÖÒ¿æK‚Pþ<-½tqªÖ #r\´1`úH9¯Ó!¯["]� ‡,’:ÔÙ1ôƒ»XMꈈçTB�JSÝpp6ô¾£*iGU¸$Ëôïkñˆûøjžõy¿íÁ|›µLû¥‰Ô¶Ð1Á�px•§,§š�²9Ù‚ÕÈñú"Gî°cšÊ@½Ÿ®¨8êŽàDcñ¦¼3ß ó:M*2Ø�@´¹%Ý)&¢b:`Kç ÊËaðð…="nÙ‡{þñ/Âæfp½fwÿ�½ôD ¯X¨Oá~h ÝG: ±hå3—Õ8$"=`—§è¥Q9uNûM/‹øzKÀ5ª¢z¨ˆ v‰SˆÞŒ +õ ªùYºÓ‡ 7 +3¥B×çSÞŽbÜ 0�¸YFÂd¦`Rå@&ë +\‰’+J&“qOÛBßefØš[1e «`ÚÍß Aá\i%Œ ¹6%¡H‘ìèX»@ M+ú‡+”7y¥ˆ•Q$JÛ­´®ÐIÏGå¥[æùzjd—&'Kâä¸þ…¯k%` �ž|ˆõ'+÷ÜZ ž~í‡7œ¦ñĨU -)œÌV¡¡--Nnéåó2¸ã�]–0:Ò�Þµ{´fÐ'jËz�6NTÅGþü‚‘�탬7ÜDZ•Dfêƒp†IãÞ‚6ƒC”*ù1<Ä´¢êgAË; ÿ}¶!´ó¿�Ò+ºÞp; IŽ•á2¨NH\sЀŒùý?¥¬  E¼â)=ÝHȾ¡Y[HvƒH*Æ Ö1ðçì_8Šõ>­Æ_#åø–g0²ÀÜçû ,‰¿Ç;�ÔUN,Üo2Å%´f€'[D—T*ƒ½¢·n�?~ƒž$¸üRª3@ýËÞxˆ"9VÆí‘�}Õ|­X/yñðÜgƒ¡±½…+>²Áð�‰ÃиaV9¢B½QWÁ®óò(ó‚1ø9»�Ãæß¢R•É»¤A£êÜÓû`¹”\3O¯(¯#qf†h àpt÷àiš’‚K˜:"%ZÚü¨!ö?éÚfnŠI=�¥?ø–ÉCß'Ä¢�R¨9xú!>:¡¥ØDI‚9ÅÀòD�SÚãÞƒÇþ d³Ø¶ô'i²�dÌ“aGòVüŸ`¸�$%&?s¨GvÜÉoƒÕ6"B³�昅¼E$܆]™Ñýz] +çrRšO/÷ã†EFHEZ˜w‚e4‹Z8Û)A7ùrP{¶w +çuù³È Pˆ÷øq`‡+d Å4:*­ Á�üÃì¢âHn‘Ó2¦8vÌíËF�oï•ð²sšº.;"å«qJ$n7ˆ[£-£Þ¾ˆýËœÌ#‡ÍˇxE«½Wß+7ïKù:í9àït¢Õ £Q\ïŸu„@®V‰�3*˜Óâ³++³‰>ڻѴ]ܺ»˜Aý{™"Y,Ÿ¬¼�2 +^„³3�Z•Þ„ÁLü;Ä7ÿJ²;íøÚé6=H¼Ì©bã¤V⣚ظ¡Ÿ§#½âÌ«=Ù”¶kŽo˜vnÂy°hòí¡«Ï„ÜváÝoªð)|Ýú°iÄ·yéQ( ÓÈóþÏBœœƒ3¿½TýøEÉ]oã½KÒé¼1‘�C9Ú¡¢ff^¾ædç£nï‘#6ÖDEšÅ»ü\)G¯©ÆŸV5alò¬Ÿ§ÃÁðä ˆ­¤N®qS+ˆ.LÑ~`Sv�³ÃÞöÍÆ„¦ù»±cÜs‹î1èÜé4ä“€~ ?GpŒ Ð�ä¤ûè]†•¹”Þ¿xIÜ%áâ,‰'¢í +ã€êw~0Sy3œvúÉ!’à«ây#jtp‡š™!} Æsä‚02¸(©QÜÞÕ~ Hƒ|œ).2AáÚÌÌQó…9dóŠ%±ÃÈ„z`ŒRº{üÑ'1tÀpàMåb¬ÆàE€÷Á²ËVüÒà?dŒåôG²’Ñ%‘„Ç´FDÖ©å*•”�þûO{`0Ý8$i*.IEçžÒy½Y¹‹�ñLØ#aæ+¯��Ëp؆ø˜Sòç �‡‡jk +Ÿ@DãB?®õ®©bP ëoù€¹hJ€fTKõçõêÅXµÞŠìXËóK o˵NËŠ,âˆ:ûveVñI^»tXGì"h2—öaLQxû˜;a­ô.Ì‚»h+§ˆx¯4ÓÝ;Ý(<úGôJköÄå ¡(þKò´Õ+Úï�²j#áT¡N”¥WäØžJY2é Í6azÏSú¥Ó•¶ˆUŽcQa‹5OšxÙ2ÅNs*ˆy\!\è¶u�–þLÔIº…#:+ÛÎûåEôõø¿ôFs|'™ª:­¼�:µ¼z¬ +Á´`’I¬ðô ²\.K·^o²Rvžò¯êÎÆ±\u1N´*+ë=èhVüoªa¯µ+Úq— +@ Š3©?·SáàË]/¨žc¤°éà• k^¡FŸÕOÚ"¼T¡óyv�a+dÐ +.#¡V)Héìo$×)žbÒ bW¸çØ5LÊ +P%÷Æ«.ñ<^ûsìR‚FÝb6¸bð¥ cUf ,Ì«v�’¤Ò‘˜j ÖµòÈžng½:+·Ì­Z—øhK†Îl Ó§¥ƒë´$¹´Øqm¿ÈþÔóËžåK'×TÜ­ä)éP›g;èê�/¥mÒýOÒtg + Îæ*Uú2ý:;ø}·-…ƒš©ÜîЃ¨�ÄúØŸí1»!ƘªGçá]5Ò'�è(F kP¡†,£åS•3X)½©h‚™&÷ÿªeqRöÐNóNnv�WP÷Xœ�Y•Ñ*Áúp| ^˱L>^¯WûµU~3Ã›‘UX#ÐÀö]Œi&j�Tr›XéCĘO€—uk˜~Ýt¾ÅUз7†!ÔÜ|­°+ôC?ÅÓ%~d.lLÔPÁi1yÆð?Ý=Ýxù]r›Ôióž*eÏr˜(½kqU?XñLÖ²‚Y)-@ïÁµ�S„sYÓˆ¹a±‹´ò„wh¦È³ÜMeâà-í/6M¨8÷Þà'Pm¤¬Ó¯ãfÐ 6Tf˜„‘ÎUÃ|6@«·¬÷¾¢°Kçf|No„ärYå$‰&Z��¨jÓ£™|F0»O&¿ã‡ƒ7¬‰#RWÑÄj£Áö_$aõ 1¾QâEFÖ cÃv7«Œ¦8‚NÍÝŸ;�í}jæêñ‹…Œ/[sš©K�õõÂIa÷kÍ SË9e9Nz¹ƒ¹äì�}£z���™‰ëú½ è ãÏÂ5™–låã2|ÃÐR†zóÄ€smMˆÂC|³SCÜMۦÉ4De7g£5âÛomľ;ÅÙ"8´*âQæ÷g +˜¡ÒÚîƒ ‰õ)Y‚juñògöV³‚ôÒïŒ��&+Œ#qåL·5}ġȱ˜G9.-ÏîyË1FÓêbó�â&7¬ßöèúdcvå:̇¥} &JªAeŒG¿°'0ó£�ÕK%Ë‚7W z™Yÿ Êì`@íëí]~뛢sOÄiÓ;)\Mû»L�çÈTùöAAGì™ÐÍÖwíÈKI@�¢rZ!÷‰GÌûZjw“TQó¡nß5Ë£‘÷¼ìØ6!Ë7I€S:¬�ve(ŠÌJƒXþê'{¤¹(L¼sÄì#Ç‘h³‚»0MÀ™.qÑ2Û†íùlV&ãÊõ^‚¹Ûaí,^1V‡ê@kqÞô¬(�úMíd±Ò°Ô`Ù|ñih˜4;Hµ–�7œ�ñó`Yù`ºÌuãkÄRÊwåûêxæ?;2=¯zö.kåƒwŽÉýrµá‚?» É ÀqT¢Ù@·(cJÂÏ{‘¤“‚ÿm6Ús:JDÕƒr±XÇ +Ãd“�»_Ÿ<äÀ±¨@ßíTdàôõê5'*|á¼?’çÖ˜Ÿ¡8¬à«ß“>¼‡ ÀíRÃEHÖ¡| X˜H¥×Ž\F?Ùìì´ $¤1Œš[Þ‘Uo׺;$G¹ïn# }MÖI&ó¸�ÅÜC“±à̹˜yÀýÕKf? +B­*þ­C2©RšÄw]GQ¼È4½¥fG…‚¯… g\ýÊ úÐí8öÚßœ2l�™I7ÜÿáKÖ½­‰Q¦^DÞ$$MÛZj/ * õö ‹¨Zý«¼‡G +§³±ë^£zÙqý0@ÎͳP;‘ÚÅŸðÐõïIœŽ©G+~€¸Rz.zÿ'L¬½—c à#ÁªPpeö–ùŠ,qð_×ý‰àÌÅ(>Óˆ’jçH=‚¶*ý[»4màªè;�OÿQLŽ�-¡ÐÁ)%,Uäø `Í󶯇åJ_„M܃.:èñ­Ãð�Ú +ݤŒ»ÈÚèÈEi9÷D/ã^hnH +Eùø--6h +yiú;_„>úŒ¸Ñï}hõ'á¾J. rƒ�¤à4L:(áy šâIb»ÿ+áŽåöDwjhÑ[ƒ¬¸‚ΔaÉ—ˆÑ‚,­w_X‘@ˆ�1«*µ;Øä³ý8Ì"ŒÃ¸˜j¸�ÑGÜÀ:æ†üà6 +.¢w‘Ï¿Ïö çF ¸¦™_Üqw£÷SÐÑ#S(CŽ�äÄ™Â÷–ÃZ0YÍ�Á²ý\0 bt2ÙÓAP0qÁ:è&[ ¿D<ìßKØ—ù%µÃp7w@$ñ¼ƒ}cþxÿòÀS =ìt«,|‰3{íÇ`Aº‡$Sð½—¬X>`q˜U>$¢äƒÿ…<ù€Ë£¼Gû°w—ÀŽhÔ%‘ýÀí.Ñê·y‰çú`q á У•%ÍÑSû»KXXPS—Tbk좳pIàA´DBlZQË-1S ¡­Æ¸Ì†ˆZK·‘d|ˆ0Xˆð»òˆ8˜ζDEœ[KŒ©"pÒ’þ,‚k–ôò"zd LÅ]sFÄàk„ÿ+ñ ¡ìJNG°õ §Ä‘GˆWÚGB‚D&+éµ!‘”FŸ²H\ßGˆ*¡¢$À£’n` x9‰ö)é^(‘Õ”€º”Ð-%ö¦jIÉZ®ÃQ’ÔJ�ÝXb¬‰¤óP(y2•�'>Â_+Göa_ ñIv€0�o|.kL°9IŸÊDï&ë™Ð¨I¬Ó„z™d6Ál@æÜD­/C'}X’ˆ„‘%1ÃI*Ɇ2A@IÚ“'’ >¶'$0Ê>ч‘­Oé#ÁßE@nä„p�eÄH4�P€,t‡"‘�(ÈvD¢Oœ>$N�ECÒû+$Çâ£�×79RÐ~q3 ÝÅQ ©€±¾CcNÑHøzQ¨° H`8*”þçR!Ò�L�*˜úHÜ© +>"(«8Ø#Ž]úy¤�X�4šé ÷ÇZqÐŽ°(33>ß +Qô@ÔÕùzR«Ìz®ˆŽé]aÊq˽BäÈN{éÑÀý +~#R+X|¬1Ù°@®�4j±ÄFZ˾ÖHàcA�IQÌ-Ë,–¤xgA ’hAoFä�iq&ÓUT-.#LóZDJFÀì³…^”íl_•Æ-N #3Co�oZQcvß/ãbʡن&†ÊE¨ý9é¹™¦ç +§ ³:)Æ.&f»‹8,ñB芚Z¬ÈìÖ ZU$ ü½  Cð…¦ÈÀé îG‘tùQ %Ù_¯ ´OD&”Ndi`Àk"}ï,&&2؃Ád‰DN<”ˆ +†ñŽD<Ïa>"…ƒÐ'•¬Ä`¬ˆ€�bp‘ì-3""A5D6‘1ˆ"­nŒ† uÇðÿ!†d¨IÔk}-ôc”û®$£m�o“áÉCü¤ };dËÊà¥C2Õ2øä-ò2¦—¿÷2­!�MöÌ�›!Ư:É�œÁG ÉÎü¿MðŒ‡·S~ˆ£Ïø¸…8 tYHŤ+¤³¢Q¨B@<¶âùIC +À4ø:§1îU¨ArBÀ—ò%Ä@ÕÐMB†S:B”èj@žò7klÑ�©ß�²„DI­Åkœ¼Fs`Ã�ñ£Ø¢ƒ›l °žì'gÃ6°µgÚ�›A 1ˆÖkí‚��Û@² …À @édr£‚‚ U.AéÙDßnÜ‚8UÞ@÷Fyé78>õÇmÄbp Õ@º +ÒÃŽò +ž‡íbÆ!*9�P!y ˆü’ãŒÄ^TÌÀµïr Ìš9ðöÂ90 ÍðüÿG_Mï?Ut¸û‡]!©ü#lÓ1•ý�_ê F$EòÕÕ�ÔÝÖÁÁ};÷#÷_‘ýÙÁª~ G臓lâŽóÃÛî? +öºð£³’¼o}Ø yà¤> +?éãhyÈ€>°lôÍGôσèò¡Jz¼”z°’�oLJ[aâÙcQø´=x ñÔÉŸïT»Ç²Þ#“¢sÇœøà{{4ªä#I«Æ×F³Á‘†]þ (ÑÉüÁIèPCü Ã�÷žCÚsç ˆrPÇmŽ=�‰Ks8%shÊÄ�9€[Ž­Î•ãÝZÊ¡›?0ãä aOr ç�EŽI@©Þà§ûp㨣,RƱÉê-³ýŠQÍOqX%Qå ~ùG›{áxÖü!u~c•wã0æàhNpx‰ +øE’Xü�uüyßèmÌ7"ò»7$9ê �°¼!Ëó?¼!Æênì +ÿ±]9TÔ  èsC‘èý ³¿‡cé5npŸ?˜™?znX�½ Ðùc–Û ÿ`ç�5mcÁÛP^øì¿�Þôæ´A1ÿÆh£�׳qÕèÐl@Ä• Qc+¥2aC¥ÂØ §ŽšÿÈŸxü#Ùè ¡†ùÊqH»Øè[’V ¡Q©G¤-(ìƒXªÿØ6à3ù#Û k}Òç©�HX‡�ä6šfåiGõÆ|æ¶¢† Ö¾†�“×À%²J×XHÁ5$ε•ê¬~Y.ÖØ„'O~Wg5„¥ªŒ^ ¸Q�¶.JS£0ŠÔH–@hFÔÈ¡¦Ø#�+ì40J ›ÆüO¡B-bLC‹jiŒa¡(�?�t’® å—7$ᢊ‚Ø ÏZ}rÆgÐ�AÖ™lN3—‚F ¬ƒ@Ÿ]¼dÝù2ÁÏ !ô÷Œ`&„7ÏÐ +¹º3)ß×{‹¡§äÃ… ò‚ÝQ¾…  oA˜B·@¸û¶˜|J~k �¨0g‹Rƒr[©š‰- +P%Ì®P&k¡àTÂ0ÕB@”ìß¿´F• -†É -$±x|¬K3g!ê(¡Ô,t a:i…G2G†þd!íFž‘=ú¥ò*!—ÆB¬ä½báÀ±tذXlÂbÕQ°8î +X¨}• ù ® _ÑÓÏ+>;Š^¡u(x…QÊ%Üu…â` Ó¹BeÉâ +»vØ­€›d+vƒ%Wj…»²>hEA-¡—-uK’(Ô©¹Dæà½Kz° +®ãK`zûɈp%W�P˜�Ó*òXበª¶¹XFí©bà˜ü‹*<”L v!]&ÄSÑKl¦¢�–ÃØ¡RÑ0Á\ÂfIMdCQ±;Ÿp²É‡OÁ¾m¿Sô‹Ý$­#ýob») 'ª"‘•š 9Ñ )ŒÑ‰^˜bë„Ê¥H²vÂd)tz''•©ñJQÄó̤轞tH +€àï�˜>ÑñÊG£þD‘£p(bÅt V}á +ŠÃŠbò à›(Ú +%¢è9 ¥QàH +Í +�¡PAˆò¨P–(`EcaPü™¢. +m@‘-£-ºQÆýØ…G?‘RØõ I‰”C|ÂI +è Sa“‚£'z%¥dä ÐVŠø�ð6¶eØRPëá/�N4aœo#ST%'pH‰º4^°cnJøš›Sfw»‡¶mB:Òƒ9ÙDN.…oMÄV»aKxPé6&ˆ +/k&p»2ñV¦h!xŽ +‹1‘€’ +bB‹¾ & ›Ê^û%v÷v^"™TáÓ%DR•ç[ÂâV³–èŽU(³DÏ–èŠàĬ®D¤¦ÂÄJ¤«¨ôèU�§Ds°+%zsF‰L+D•”˜CÏXið€ž[©½XqqÆêh͘ëÿ’ÀÚXI¬¡I;gHâ¢ßAÚŠd/TÒu‘³9€™Mö›AŒÄæhfâ%…7°R¥#ÑÄ/¬Û&’x5¶bÈXÙšzhDf+â…‰LCXI(xÕ¬„’+ŽgÀ%¹3±‚êÍ)V<;ƒ�|å‰åž=d¬„�oŠDᯂ�H@+^HˆÅ VÄ +"�¨+²B÷#+ùD6=B¢Á5­#`Þ�#f�Œ°8‚iç�€R¬ ÍFlÕ À +A¿ÅŒüUFuŒXj³^a„ ¿ +ž/‚*Çí*á\Dóu• °_E P@rVÇ+ò¦_…Fq¯BÖwA…Äy­èq +^žq-�déÆà™õÿ~x–ò»ìê-§ƒ¬ µ¸Ã¼…!‘%ÖmŒ"Ý5‹ˆ"T2”ÊÖq �kźFV6Û¤¼Ü^_8žTœ¨q^*m +)+á¶ +%d¸µ4Oƒ�.1r$¨´TR�1ö§ÿÿ½Ño¹]bÏ×�b�5ÖXC�k÷ù")#Íœõ­› ùuúÅšîš½~ÝZk×ú)¾ÚnF…ÿ¼ï”J’u\m­ÿö­ß×wÞ�9ûéëÏ8g¹ïÖøÚŒé[ÏúÞ:Û©5Î2׺qÎêýý[_í_Ϭ«ÎõÎZë|YÏ{)%M|%uKîÌÚûµ>Þ5gI³Óý÷búY[›wÎtÖ›÷Õ^/uL)­˜êY_?ÍYf\óïš3Î^­çÇÔâŒ3­™ïߨúçI聯fýu>ÕTêü¹bû:Ë™½;oŒ÷þ ?Í&ýKU˜¾¤›îùX{ýŠ1§îÏ9giñ¾»­4Ï÷ü™ú¾o3™mö}©ï½]�¸æ\çkœõŹ櫯ߟµf^�Þlóo|w–öÞYq&]þß\µ•¹fŽ©žŸ=ÍüÒ·›~¶iÅ9û§ÙýDµ©¶O}Ò\³lã[í¼Ù¢Â“fòýâ­s–ÉLâÏ$Æ^í´¸nê>/¾™¼™¬¯)ÅÙÎpÍä_{3¼íû†ÕßO% %AIP’KaRTI÷m«}§s8øõçšågüvZ»mÕõR�3Í{ëm§Ι¼ø5­yÛ¯ž½Vœ«¥_M­¥xf{«O;·Í¤ëY3Y±”R¼;Y^œÉ*?g©³ÌúÒ$E5þûVã‹[�sf7Æ×úëªw­öf÷é¤ÎÍSëý€��νÖ3‰íÒÑWÓ™3‰ïÒ±Ù®H§JŒó¬òmþúuW,µ¦™Äùï�5»™¬6óüóÒlõgT? 0çÔû”zÆ™¤7“ÚfÒêLÎ+ï–ú> ù÷ÚŸ[ê÷:ŸÚúYFéÒ±4«//ÍþéÜìÒÌμ1I@$�‚÷y¯£×§þ=_Þý{¾ÄkCI�Ô(ÉꢂԡNº3O-k&µÄûZKïœ[ÏôÆ3ÛœQºîM·ïŒµ¤ŸÑ £ÿèé¸öéŸÑêñ½•fò~ÕÓõ¬”Òœ±ÖØâJóg6»ÝoçSm÷Oš§½>·¬TË×SKê6gÉN�§Ý™ÄÏ�Õ-ñKúºÖZ}»ÖÔó®›Ú:�âükMóo]õÓ©=ç,«4_[«ö¤Å‘Üïd?ÆsË-gM§ÍäÜrÓLï·xjYm&¯Ö¸N-+¦•N«ïOm§•™fRKëÙ¶¹â¹³DU÷ÜÙ�T^§SW�§¥ò+õéï¶NZ©ÌõÒii&í�–ÊK±®tîÏ|îÏòÔ¯?{Çtu!_:jëŒ{´A)ÀÂ�Em:«m~ªÍ4~™7¶sc\ýê;mVë¦6cìû¯ÅòfûtNìâ¤8ˆÊeu¹¯*˲Dåø‘p©Bm;å—ΪtÛ[§ã}©Ö¿ÿ¾½õÆolµ}ЧSMµüÏî”ÿ¿³�}þ;-½3¿¥»#ý³;÷�ûÎ-]ãÏlâérÇ |œ/û:ÛŠ‹…¶.¥ycø�>( ÅŠ C-�ãºÓ®?ÚP$Jò€HýÒ±´¯±¥•fò_cœI÷WIÿLúwÒ¿“]÷ýŽô„åsT™é¥™¤° I=g™/Ι´îîg’ÒLþÒâÈ—/}g-ÿIqR'“þ™Ì_³ß{Ï-i¾xn÷÷ßT˯ò«&ÿ3ÒÒ‘zËy5Ij:ý廤UR:w&iͲ„¤i&¯? -éþÞ7Ž1åé¾ì€ý•÷Aù�6’ßüs\æRy1µ */ÚX¿…(ÜX,ÑÓBŠÑÛ¼§c„}�L€= …!§¾ºõWÑK80r* ü¤ÂK¥u©Õ÷u¬ïë@ŸÕÂÖêûÈî}{GzÀÝ›®R©´ñ¤5¦ŸÙú¿Zm!¥þÞ=ÂMÜBü(ìΟÃïë@Z§¥d�,ÜFQ 3k#Ý�Å�ØVÞ(ܺG”›Çucìë¶I¹N‹MÈ–Ç�äîZÞfSi ‘µ‘lËã\"çiò3ÇS×Å@£Í˧Oû�ë4É�ž†´ù¸ÖJ[r�J†Kúo£�O×m�ÑÕâL¨†ðW-¿Z=²6ìÇ/uà€�_ŒýŸâ‹i¶ûÚKU\'>,‘À¿@B�Ø7Š1•6jž­aš˜ðÏ¥Æíjèe{©¾NA µ(Ñ›P¼f€ŠãvÚº”Æ…ZmÔVÚ¸Á%YPP’�’h£æÙ•S5ЉS¢–Zj÷t©ëÞ×5Å–ºÛ½ßîì¹ú¤^§Ö÷ïÕ5ÅøÒ|ý±ÿÎöo¦{[íNi&$­o[ñýìU¿¦;¿ß·¾Ûý;ï¼sö�±Þ7ç|ký–êkóÎÖïÆ^/¾úuµºæ·ÙæK-}Mï�¾]ÿ¶7ûµ6;­Vkª�b}ïL)þK©¿õ\ëûuýºêœõµÿŸó¾9g82çLHæ¼õÅ{¥ïúæºßõÍTã|ñô›áÈ¿™�ü Gbš I\©Î“î¼÷¦–ÞM÷ç«õkŒ1¥N5Ö{cÇ4s¤Ë›��˜f:–Rzßó¦”VL3²Ú „üKw¶¿3ö\=ûÿ�™é4ƒ!=!ñÍtêtßtÓlõÞû±Þõ÷oúúÞ’|YYu_GUâÔÌÍÕ¾ÆÚk~Š©Î÷sͶfŒßk¶™�Ä›ÖêWç|½RÿšqÆŸïÛùO§_[k}K +endstream endobj 19 0 obj <>stream +漏5­û©Õ׫uŒ)�n·¾šÖ½«®¶V›Ýbl)®TcL-ÆYû»ç­·Oߎoµ×ßZÇÚqöºíïœïv�¿n|?’z¾vº¬o]/®xz~¬«ÅŽ3ù9’UÓ:]{}K·½4׫1¶Ö:®ûÖKuÍŽ³ÎpäÞ™�¼Ÿm�ÚíÅÔq­[ïJ³×;]îì÷?Ѷ~&$ëý{]c××ú½ôZÿº5µÓñ½zת÷¾S¬-}Jíݻ֯Yëí{×L§_œ5µTkJŸV{k½OõÕÕéôŠëÍWkßô¯õÇx¿ž.qÆwïߎôLHꋵۯY×êšþSýXZ«ÿý^«mµïýú»®öi}·VkL±}L÷žg·µ~†#¯Í„$õßuº�.iÞZkJkÍWç{­ÞÛº½×f÷Ïp¤ÓíÓå¶Ú}¿µÓz±µ™�ÄöúçßÖ¯ÛúÛfL³?µ¯óÎõZ¼§¿Þ¸æý{k]_g¯Õ­cLïtìÛf=)Ípäçwü³n×Zïœíç\õÍçûŽÜO—ö«ïŒ­þKó¶vcßþõþ[ýok}�©ÓmóÜ™VŠó½Ž´nít™©½y»µŽ©¥Xß]©¿v[ëum½jM«ï�íÓz±ÿ®T>Ö–ÖZµSL³[¬mÎpäëÏ„d¾•fZ¿¾½÷·»ûÎk÷‹ó®ÖŸîßc›q†#õþLHºýÏS»»½·þ{Ý{~üîî×Òüïïûûo¥™Þ¬õÏZi&$©¬{º´˜Ò«±Öúi¥_ßî›áHœÿj§Ó÷¤ïŒ)Æ÷Òì;_‹/¶W{Ípd¶o7ÝtÏüîÚ?SêXïér?õ;Ý+¦¸¾ïLH¾­ø^û7?ÍNécºõôßú³ãß;O—¸f8RÓLHæJo¥´R¿Z»gMÿÖJ÷ç=ÝoÆYO§~ݯÍpä®™�¬û3øVMé¦ûcKu¦7Sœó×ü[ãÇwãŒÆ¿¥�ý3Ýbgë¯×}�^�uÕZ;ÐÑ~¯Åÿºz¾ô5­wk×½m Cïtÿë‹)Æ ùR{³õ:7Þ9ß{7Õ�}®6“ä„LÚz¶µ¾Tš¦*= H©Ò6Ž[§•VÞµGÉÿÆØÅ4ÖæÉÀøÊ+øº� ·1&¡õtYñÜŽ+Õøº~�±gmõ¶Wë·^ëåÆö×_«ôLf;_z†t)q +¡¶œ‡t*mbL¥�ß+ÔV+¯KmT¡¨ + ü\ù¹ÜN%jœŠáf!·ðcm¹ò4ÐÀGâ—àzœRÕfàƒy@VÞÖqVÞV೉Þu¨ë8ÐÈÅVÞ×C­„‘ˆ‚<À‰ÞˆB‘ˆ|™€ %::r+T´“HHc‰iR� ¢o„.lÊHT:‘ëýdå“×7€ZŒ -v6”È)'‹ñÑE€FH!*„¡H2ؤ:]Ø”±ŒšT§8^&2ËÛ˜$ 〈„†÷Ýφ6w0]"0™ ÕS\.ÅL8UÃ!Á`; D[³ÑØ <§Ò‰\ÉbºXÑJÀŒÆB&l„øq,+ +¨©£k^4Fî —�^Àpò®*ÚP„X´™ð +6è!m:#¦aDè«¥"Ã5úT°¼@<È…[ø8‚)C… -v[(0@<'o#üDp`ц€Â†¢Læ€v²Ž%4ÁmêÈ*”îˆMmÐü#~ B‡ÈaWcÉT�ä¹04<ä�ààd¸, IÂmD$ÀCà“ê®)"ìhˆ¸.oQ2å�È4aDõ³©¦­ª4§–¾Rj@Y�ŽOò€D8T"_šD¶v1‰`´ ˆ^"¬ìI"u“E¦Êé¼4–Èè ÉRa“ÂÈN")Í)‹¨›,ÑP¹DrÇ2‰\$2“Ë«DxÂA„v,“ˆEÌ’ÄË„×,O7Úèœ&° +gêgPp1xx6Di#a¡%bL'À'¢àØ£Ecä,±õ¶IE§– AB•`[OºYµ./y@R–¯hŒIíäÏ3¨¢�¨t"÷Ã]&„ìI"Úc¡èp]"§Lg‘ˆ©²D"# CÉRðºÀø—°AøŒd¡ƒx p<ŸØªÀ£-žAáôaÑF‡ágtÙ +<Ö¢S]3_ž«±08pˆü@ÿ†bƒãò ¥d8fDqJ G»^ò€Œ³£b¬¡N6¯I‰Æ ‹w †³±ó¬Ñ=é~Œáö… Ìul€W¨A„Û»÷q´0´ò¾Å&VP®ûJÜ(n:ÚZZɹQ3�Þ71;ý-ä<Œ )8ÍKÁb.ŠmÔÂÍÛB@ C[ÇQ\Ê‘žµ�‚¾B ¢µ™LlœEj 0ŠèA.T‰›gz|ÞË‹I‘c}rc²Ó<�7’¢&]ª˜(U\‚šcß­“'å7ÊÕ¶y2ö?yá¤Çi­–Ç�Üj“Þ'=­eì;°‰b…œ OÉÓ¤¨É­ãXÒÓdËã +h¢ô6RlyÜ8n¢”›k”[¨ÉMÜ\(�§d´9`â<>χÚÊÓÆ +ä)Ñm:(�§ô?ÚŒ"G’/o¤š£Ø½<νÿSéCñ±¼QêsèºOõmb¸ØÂ™ú:¶–6S_‡§u)�{‰3õutÝo$7zÜuqë@nìb[ít’�'?OîÑFÒ7u$ÈuŽÄÈ�=4àyL5iÖùŒ$'d)Ûèq¦òbá¶Ò\âµÖæ… X€ =ò@J®6�cmÊ ¸4ö©¾›"‚àob+üæ*tPu`Ç�sªº)‚\jŠ©˜8G�‚ §ša§š©¯ã?Ul~4ªæ*T=ש&h`ôÀT®ñ›0U·­>Ø +g7ŠþÖuŸêñÎsŠš vÌgØyÒ칎5W¡j5»QäX_j¦¾Pƒ�\j¦¾¹1õu€àìF‘2A­ ·ß\…*× APåµ8q[ÍÔ×±µ47CÐEqóF­c�›Î¬ûTSY[§ú<› Ã1Ü +xŸj‚>Vv«9z¬°5A]Jó¶)†«0ÔRãd±¾Õ¶u©Íã:Ö¶úDnô´ ˜,Ö÷±Ï¨‰ºíD‘ +ãžPôG›ç¼Š.n¦NK}]%9É_m[ÇáDn Ê=Ú|cl#GŠTXi ®/QƒM\e®ÓºOõ�bÇÉÑÀFQ’~%¹µ´XËÓHRÊÿѦ%ÇØ÷R}ÞË%nr�6/ʶ䕷…(zZ8Ê=Ú´dœIŽ1pŒIÈ=ÚxMÉ%•rj_j 5¹5µtëRrµî9)Ÿµ‘²¾ÖûàÂOD½/öuºn댬�tYéõÖw3a ¹�œ+€Å-ÁØIæ¸S×m�j`GW‹kyg¡í§ÚN«G� Áp+ðÉ-4abÁR‹H$j$ã‡2Œ�å)D-*j‰š1Ì!ïÒXBÏ`¿hhåÀ0j,!Z‰úZY@ ”» Ð>‘�Æ/àã3 ´5�σŒO†ñð /`GãóÞÓ�0=1ÁAÉ“�6l¾6)iLÊe²r˜ÃÕH^¨XÉHˆ‚­@b‹Ðbö—'Q“èY¨À‘\àÐCÉ’à!-e�yI +°I´ro�0@„ ’$¢¢S05LhqCò€ˆtÅC¡ ˆ +°æ€<"Ш¼L) +øˆŽ)i‘Yx}ùu�ñ‹zÈJ+Ô%«dP"’„b…ºDN,.‘&Ï%ò +T¦³饱DN®K$˜(‘�¾¢¢X1+·�Àã*D, Ý0h +›Ì ÆÊ²QƒÌf¢@S! Ë„ƒCñì°|vX>;,op]p0.2 ‹Ì½°ÈP¢Bš¢B”„1#s@%Ȥ$\( Á³‚@{‡ д A AhÏr!!M�f hp!pÀº8`]°@ ‹‹ÐÖXX\LFŸ'pÀr,·¼ÄÍ3–Ï…™ +„Ï6QØ <‚ÂáëKü,&>‹PEånþC'+ïP\P\P\T*³Â!òŽ�Ry”¶9¬‚•B°RH3©pœ.("ïˆç €C䑉†+©`¨‘‰†0´5’¤’©`x%“ñãX$_Áð"D$ u,%0Xn�9<ƒì³i,âDZ8�mP!(,ªì ê3TØ„€ Á R2Œê›£Ž +��Æ2rIPáG…aÔq`7è¨cT0Ž:*ž¸A?ÚÁÊG¡­ÉDÍØµ(ê‚CQ*y@D­‰‘ ª¨UyøP,"‚×Ç$ª)�E.T¨¨%¸àPšQ#€’Àº pÀ +­,¶ŽÍC« +”¶9ÄDC�Ák¿4– ‡-€C)©Ld¿DÚ‡V¡‹Ì}@Bý"`'$B[#y@2f‡å ÍBá\4Uò€ )ÉGS8± +Ò(ˆÌ=ˆ¡Fû¥±€Д|1 +�¦bܼÒX$Æ£ñ™<„*x ð?&°RH.T@Íd– +2ÒÃ3X>ŸI$4^0&•†ÓdÊ04–xÁÐ|“C:Á³Ä], +„÷\h"ÿÀŒCM$0˜"¥ ”b—ÏEc¹|FÎp@ <› #ñl. Gü8ÚÙ ¼äù .’�Fc™‰Ÿ÷d„¶Æs¡I&"/U„¶æ ¤±x$¤2F"¹Î1i §É7)i,&K]—¯¸|C˜™¬œcÒºˆb­ !ë”*’„¤a" a˜Y¸$L ªg Ò5”ññ‹‰½­\iR€)(>�Sˆ@ÌÀ°1L  ›` + –ù±Ðàd) +$×…é*Zİ`2Yí,D r`X ‚“�à È1+�x�a¿È‚Áu!$T +–¬”9 a)aB‹’äC°AøJ*Ãa + X¤:3" a»È`üÀÓ˜Œ¬(8¨šz²ï$àéj,– §ÓQMNä#¦a.|x02Hò ah€±)”Ò‰pPˆKň:D\›‰Q€ˆI«Ã¤±mã7 ·á4>_&y@°Hñ‹a¬t"·“pt2VÞAÈ:¦‹Ý „8(DwavÄ +‹è*P d>.º Ù<uØR›ÏAc4‰LH§”§’< K®Â„Æ"Á4 l‚C-^"°4väÉ’IðhD0)I€ðeñXx'‡T…ä)À Ïâ,›ÀK¬XMDZ”RH`–DŠD< I%`B74‡ �j�„Q)_¸<,y@ôK¤@½Æ8o’Že)ÀÏác‘»,`?�‘ð ˜Tˆ.Fi›K +Ží#é9‘ÀÀIè¸bÄáÛ6aè'‹q/¥…¡Adá¡“`P9",xqÝìá0�HÁ2v²ˆ`ž,Æ#š +1¼DF�Á¡• M#R€- cfò‰a˜!Ð�Š…’ä²I@àp’†ˆ¢9 I@¥RS¨ŒŠH˜¬ü“m4D²íã>^�±œ$¡—�P.y@ªÞ0°#"ëÄ × ÕÅè�< £—ÄÆÆÆÄÔGáy`à#Ae!y@ÀÌB¨BRI àƒ…‡2¬äIqL¸N-ïÀ‚R�±‰*¨“K�,Ãëi{¨ +§#ɲ7 Ü3´zå¬ È 9˜'+§™fÂ’äÛDŸe’/½4>Á Ò"fR2 †KÁ(y@02e;ØxÈ‚ãr­á½$Xò€€66@.Aa&ƒÜDø9†•hÀ€àÒ$t +Ì€€¦Ò™0m®q#² ¡@©"À7e¬5T(<×FˆQ°{J'}v²òÊwƒªœ,FÓˆ +ŸÔá9(4(…œåð“…Hˆ@ò€œ"X €‘©„žA1³á�rÉBêÀŠ;DТ �Šý`h×13ƒ¸ˆ’!�X�/…’CJdAzà°ä!E ˜F w@Mh@l„ŒÉÊ»ÐÕX:hÒ"fJ1’¿¨ÔQ(ÄÁP “Œ¡"C0( ‘Jc¡Ä”î€xX*R>8&Ç‚‘P$ ‹Â8Šƒ ¢(ˆa0Ř¡eÈiäÙÀ`—Ì|]œtIcýÒ£,šý¦¿á3òRöâÉ+ê&4;Ƹý™ l`jó¤˜Bsòí)ü5›-‹lv‚=jÔž†•†ÄÄ,–àå8p»GÂsY�©Š+Žeoð�©xüØÇ|ŠF„ËepÂ.þ÷Yˆú›4övVrí™ÕðÆ †vQ^“‘®æËA ÀÄ�£gÎni:Dì¥EÚ`çJ/³8QpBb€nÅ\Tà|†ìgåæU™“µ¦æhleEˆÆ…¥XœÌum"Ô�3ÃæIÔFý?|0>bnBÊálÑQÞmG(˜[ß&•ÊÕ|ö6 +Éèí� *˜ÎNŸaóéàý`¾·h�LÝqg¯ª‘‚¡Ù Á¨u®Y�l=êAzNoº:U†Ç"Zp"nuyC4ïò¾›¶¾>Ã¼ŠŸ¿ Võ½íÓ6,-̺/jkU¼,ˆ‹Ä¶ªQ¢Yð)4øôAÞÛzxo$¥Xa8¿ëc|w¶ºÖˆ¹v»ƒÚ«sHíAøÚÛDR¼ÀM1–˜ôÌp˜ÃQ19*LB…u‹jº†©f.­ÕSö1hÝ|l¾H+Än;Eb=€¥ËéßÂêN-óPhUìa Z"/„P¸ÍˆI–¯:¡›˜Â}¹A™°ýßb¢Ð3ŽÈJþ_mYðè»vuI…Øó­Ëhá>©øH{ú ‡T8ï`$ü —K�wrn¸€´šÉ#x¤k WnªànBUÎˬs[ëóŸ…±ÖÜC½>õ-ybîûêÛ™â­NQðlF¹tºAÞoä×C�ëbPP„Ù�Òßgqub«âÖ0®Ã4«0•EÁeÔb 8ºõòe$Ð9Co}ú#9æ¾ Í˜Å¸o8rm%çe �3Xš5˜÷�Ëÿìì¿Ò´’slUâ‚õkç QÈ]T$ +èO.;mL 4ÃHý „m–j éj:ùæwÕ†ÃF¾HÉ´ ¹õë”-“TYaÎz:4.�Ú>ª™ág×ób¿¶è»l ^ #¶3‘¹ö¾­ÁÕ‰Pý~/Ô$(³ 6|,w? ã}+Çê¯}ádõ{H^4Ä¥Þåv‡ºúœç­qÓÍގᦆð^,Kª ¨àzm€¨ä¥�¢„­ñ�wáqN·ÒO:¨ƒ*X§Qí»h¤Xo2ØŸV)þ®ÃT�÷¦�IÄ´ˆ½ Gƒ[«íÞ˜‹‡+²5¨¥ ªó6ÕN<8?RpÔÜ$¬Ï¦Õ¶Ð¯,e°O2¢·Ö£5X…>õ€èc X”bŸ›œŠÏ1W]ïÐÞÍ"ïïqGùþ`¿|/�lMc%±K ·;ÀúÈ‘6 (È…P•�d]F¥Ðµa)fu´µúH—þ£q,ÅU{ÎYS°œvµÓPŒ6y�¬v `vRŠ_ì¤.32e ëä�°œí‘±±/îˆÂÍ**ñ&ÃÑìªMMÍ�ÆÔh²Z½hSYßÅdJ¹�¡"ÑúÒI)ÑÆ4ùs'‚=V|Q¯Äcã3øÂº�c¤K¡RAŠS)/°ÂT�F÷›‰ðêZÝLu9ÿð¢k�sË<‰;E²o‚ÄX¤õĆ”ýÊnÃàwû>ÿȲ%cü/ ç0 +Ù¢6tÇÚéSÝ¥�Å ú&‰<Ì/Ú�NÃg©«DwUÎrÞ›ÑïNÀÑd�Ó˜e[8t‡öäD8Ýåq¾4·0¬oጡ�Ø—5JÔßá§päù­Ðþ*Œ‡j£ >0¿2Š!$ËÑâøss¤ z%ÈäͱÓ#Š.QÏ £:N‰j²CÎ.ZÓÜ+ÆöÐh9™ëFöBŸà[ìFfåJÎxr’×°žx²b „)ÈÄO ø]ÛéÈ,ªE¸­­š`ÀN�Æù•ZhÑÿÆ2#×P¡óÝ:~�1üˆþƒyoöa%Ú)†]œ*)÷»A°K5@[]§÷‹ç›þ¨úQ*o˜Ðh&„ì:¨ä9[ˆ÷ð%jÚ£^(£+�¢XoÈ9*› ¨`ʳKR–´S]°� +}X¼Xo×J¯(£åˆ}Ûlªàl^6q'„ÔaÕd~GFÛ,~%§mHU$VÛþÏ0ÀWÅEôÈÓ ¿£ ð}Qj�®í\t/Þ Èö·Ô`×ÐjÇC½X¢Gžj3ïîÿà†FHdp À†£Š¡ ªì%2¶ð[„Ù6R€0sÅGŸ4+»©Ç·8ðûBþˆ#AP1‹¬)Œµ– >Š|v™À[øñ0£tuäÊQŒöÁg#É»B’„ÀpؾŸs´6wVãÅÚD�ds|¬öÅ,ÁœWqøRð|]nÀɼ¸C¶4iÉ8  +[¯èëüåÂþ|!4t$ñ™Ì’{ž�<ØÙÂÒèþË)qŸÛ®ëÌ�•¬Òª6ÔÅ&Ÿq¨ŽQÞgAIÛ =¶d.pWÃd‰3{Ø 1ðc‡î¤4¨+„ûÙ `éÔ5›Ö²ñƒA}ÀK ÌGžüy‡T–ä�4D�sL|¾(õ™¤4%Dôﵞ?çI�š +åê ÉoM_K”J‘×L¼K1ë%UÏÐÖvÓ,(Í.á®)‡ÑÙp½—áø/ò;/ Oãá¨S‡éG²Ûg½†h… UG½‰Ö£~+ɉŸrÆ{§³øñc Ô3!FÊj“)|,(×…ãQžŽs-~¦Jòó.‰Ô¤Ç(×aÐ#+=£Çž|)oÀTQôß8žVÞ÷8ÿT'5PØR’ª7–0Âæ.nî<[tW![¿°Ni…»(ˆEÿÔC�£iÈßÂé¶ú ,‹Gë÷LtR÷Ô¦ˆð}ÆŽgÜÒõŽ|¥E`6õ)'‡ã^`�„h}�rÕª¶õè;ùlÛ(B:é¡%¢Ü 4ôxŠ·æ43DE§Šf}â‘вÔE›@ÈÈ!�\Mü®J¦ÁÙã-b-"o眈øóÖ|``†¨|é§? H"Ôˆ !›¢*?ævøÁh?ÞoFÜÛæx�8g\…HŸIÍ +áéà£Ü:ÎÙ䘯‹òœ(ÿèš0àø¬¦úY&Mi¥~•¯/«yMpZFñ=�nÓ—Ó³¾WÚbógçùeÕÙIJ¡ˆ“‡Èô%‰šÍ + -»"û?³Ã^ASLÆ-Ýëå’)½²Æ)Ìü+'×=dNG�¢´{yà&J�izlrÿs<äWQŠöKd™Š³]zqúíÈ×NqáQî ÈWÎöVDÙsß8ÏÂ/¤R�öù¤”={Xä¤+x©ïs-/�#xoWÚ¿)ßÞœà�)›Ò·i×ÚäŽ/rÔœ û%!ÑÎ¥òÆ+¸3žery2á`cUÕfÿ¾€à1 +sÞ`í\qnuããë^°¨�´Ÿâš] #:”0]”ö¬åŽæídU@«IOÞ×ý’KQ~!0µÃ=Á4±:ŒDŠrMª�CBŸÉ ôwèvÂÖÿ‘¤_¿7]ÕÜ(É#�dWÒÎ'v�¥ôzÇ¡DI6Û#Ëðâ+ádÙI¸c)ƒ y›f…‘~B>&(伯Q&IZ¾Uð~+ÿ…j¶íR®@¶¦mø‚O�uÈt`lDc(“ (-V¶“ãy€�›\ïÚ®âK¨s©5*^A6"@±ÇòÄö03‚æÍ‹@¦ÄgEÈãÈõ…¯"oòÜ  å|+·úŽ�HNù)Áù¼Ч”°õ×2\¸è­Ú,i/p…`UÈŸÀbÄ{¬‡  Ï+gTÍ�riç7wQŽmàVØ!yfA/ë¨X¨­W®œ9¡¦`A¥’¨ŸÀ-”��X•{«`'åé9ä�ç…â]Î Üv½ä=ïpàq¿@>¬¦æ ­Ïý©=ò¯‡E�È1pÜ<ÒæÏ0²Ï¿å±Fd|þb§kAEyAÕb?>ì±äµ�)æÿ·–º}oøü!$`1Ày++ÄOŸØz�he7Þ¹z?ßÑí#Á;”ŸƒÅw9…ØwAŒ™#¨wÝ5dÑ…ÄXZ1ÆŒíÝÎ+.#>3Ü�s–f¼a()¢B­éI®I)ÅoGòìwtÑñ*°¬a{Ø[¬E<¼™(v—ûÚ%ôx]¤ë!T0ÐeÃÜ›œ½Î˜Æ>ÓOòIÊúI©›B+—iPQ!¢ÿá(ñpy¦–ƒïËSÒLÎI½‚¢ˆÀ°9Øh2!È<¾´ûFfYœÊ¢@¼%eHÇÐ_“ï•Ô%½ÈÒ8#GŠ>9+¿{v—õ¹™m[6�zÆF>¡'su"‡°|Ë?fÎ\ñb?ÒHÂY~½\·®½ ÃoLÒ Ò~’Õ†¶{]„[rÀOkàÃWAÚ$ßÏwÁyŠL”L»DĆ}¬-Pê2"iàZ¡ÿQQ–‘Àµ£lÎFJùWÍU·› l2k.¦x=‰–Œ }ÍtU>_Téu9Ät„óâñ½Ò)†#î[†$½ Íl`óîóÍÙwáVΈ'á�§¹¤,yÐÅä¦Å]�x�À-Y^6ÐÇ>‚'OhKįTç–¿26³ýR¶G˜ +Îü¤Î«iÕYÙŒ “n¡2lh£1.CZCôS=¿ÝÉ!Aø’d?y}B„a—>I�ŠÊ6*^c½Bo­ƒ�’ÑRzˆ-!¹ö¢!稫‰’e@�U‘ßÞÐa£‰>Hþ ÅV+SÉü•£è•;tLî�X4>ÙtÉU´¿òC(aq¦9¸¬ @Ýl!«çï°hbM;œË@œ +ç�ý<Áí8˜ØUÉð�¤+³{ÿbÜþƒÏ'P…‡eãnMZÖ^¹T‡ jxûIû‚'0@?slÒ5Ü3l:Á½Ýd7�aÿ°I£r úuTè³™;ˆ]ØT¯„àà /É àTQ]…{>°lÒ9¸R!á~•¢RUAßí sÊó—.ôhʃ€^#GÅTB× +üqÒÛÕ›ˆ€ ¨¬xG™G&v• �60P0ºaú'ð×Äc0¸…¥Æ¦rN›¢- :�NÝâÔ^Æ$ÛIé´8Â+X€’|gë/$*¾brOúC!ón!ëÈ òðMÀ K²Øîy‹f]5ô5��dLNýÖå –ëež¾ùg— [b56ÔXJ§¯àŽ>Pu�I\ s…d¿%)yžû”“™�XT*Õ4%+Yº•eŠq°•f+Ö¨SOôT“ª}á­o Èo¢† ‰G7"p£Zu-¡+"~T{½ E²ÝoSLbÂä¥ÏC�žpõ—Gñσ»}DY^*ð²7k]tÍÀh½;�âòÃ"I³|:oÉñÿT·nVUÝݰl$/EZ½SA=Ãa"\~/:&§9²´P[Ÿª‡ i\.xË‘{½V +»ä€K:3(#æÚ +КE™‚€¥Grئ:.Ÿ·% 7Àü²s +’�_©_?³€ë%»–ºœÕ¡ƒG ̽PQP¤£ƒlœ«š0”´³ “a¬0«ZNöñ!_ôî(«G°¬ãX1{ÞáîIR£ï;|°‡°.‹²ÍÇûxÊçÅ`E öã¯Ü±öª@��òècÑ-Qê≣¢ ?&ÞMÓ¹¼qÖÅp)l¡º‚,߯ ¿ZÊzm�y\«Ý±˜¬èOç”ùá»ÄrÉ៎{•ú¤3šºôºa�OjU!×ûÃÉJl®ÂhÒRR÷V8ú\zŠëKdo¯p ìQG'°U<¦`UE»,¥JI_`tôöìëœoµ,ömMûù„ô™¥5]“þH|Ô%Q˜°š#ÚÃN}ã�x,[~%¦‡páõG,‡¬Ÿ?NwûðóÜÀs^ЩйW¦>„ý¯ý“mGÛ4þXñͦ£>ÁmÒŸcxN—r4“‚S›£ ÑWhª à“”¼Ê“ü·¹ïììÁ-»§óîåéÅBÌsåy—®2K]&,µ½ ŒqAmº'MŠkÎ5b|tä¬(?ÚÍá’K]ú¬t½ñ_ÇfîÙm‡MYjJv\Ý�G{ª4A¿=çHFã«<>!ÐJÁï3¤ +N€9 lÂÛ˜V)¨þð5˜^È¡‚±´·½’°pfkn3EEIU�Z½°�Ñš¼¥dÌ {Ñ@,"?c½Uúè¬Fãù2MUF9ªï:¢†àîÇKïùóÝаØ[LhÔûmÐ_f}•@Qb3;± +é‡9ÒÔúá�$V8,Æ%¨yꌄ‰s¯Y×.1cKl]’lå¤Ý4’Âfo ÇEÚ‚k@ù$É;Κ̆Õ&$ˆµzæªaY—bn'˜›äZåÀyÃc ž¼Åäã=‘|Hnìt!VÝñÚ{<÷qhd +†žº-õŸ+êMÂn¡›bâ°{ĵLƼe@…P�ÁÙ�Û†6ô÷âÁfUá»ÉXN´I„ÑÚ·‚ÑØö7N½a™E½ÄÁÅW'þy¬7bf^còß7ßùÉOÔÐ9Â`õ?ERJ^GÇÒ—¦|8,"�Go1âÐöú˜ù°‰Kv3A’û¹©2÷vNX~„1�;pò•ß'`ÉL1]àq¯ôËTHý^�R«à@Ú}.¤â,¿ 7«êÏþc·hH—C#BW¬áÂûàÁ}é¬8ã ¦Ø+±²7ª™«¬�õ8:›Íãâλ ý••�µ…©,иÃ?q¼Æmˆ¯]7¨uÔö£Ÿò`púö<ÉÈÓ +3Å—;-#<™WW±°ÙŽ3œGTÐZäJºÅÀ *#ø\âÑåÆd­ùà7É{1Yø]A¿ +wSò,*c€è»«¼q°›¡ÍÓî3LÒ0éÛ}@ßxoR¡3Éà‘® Òc„‘bîó›6Y ;d  ì²td@+…›Æ0vôH{‹p™Óbc21„Ì.9±ódW$b&6‘9GÞ®ÿ¿mœö]„Èć\}z (¦$õó¶:n.M(ë‚qE[*ë9Nîôà �úVì¤PÖ1oêòø&5ÞMf�ÔÓ[†áµ$‡%ÄÐѤ¹Zyeü©¼ÿ|�аþ"‘޲b;;}<á–½¸+¡„k›)á— 6‹éJG³kP娌c <¶1„YŒ#ë‘w·Q¡nñâ­bÉœŸM?yïð/ˆƒ×u †î¬À©·D¤~¨3‹QÑVN™noã�8´Ft$2öDR&�K=•þdDÓ˜kF™íütL¼Gc�¾DÍß¿ºù¤µ54‚ç+F¤ÖÏÐmóù! Àˆ�ågB|�³Ð?á‚I�Ø“ôuþ~' !ÊÉ„Np[q3œO>DIeÐßóNÅzX=7:Vç0 ¼Xeç¬�ˆÃ­ÑöºT‰tûZtúœ»%í}qÃ;ú¥%÷ò[ILq÷ ’›žFÐT¢’ÐnU¢Î‚'ßp^8ñÍG’ªX/šò£xy ¼²ˆÊT�öA�ZD°ì‚c{ +’°y<‹ú BÈGü‚ä¸öœƒ£éÂü%}è¡p›GBî±¢b(VV gŠNW¿.fuóê¥$Vx>ÂÄÔkç™v’;ÜŽÀê5,¹2@…O|E%èía”�,µs‘kÕóÙŒ“Õ)¨|ÿ^õŒÀý‹GÁž‚ãpœC,ñ‡� IÚ#¸>^~¬l Þ�<.fXË‘²ç$!�ö¢Šü%�ŠÅBèdì `ÝüZN—âÔ=þ‚ׂÄÎ3±YBQ�iñ ðIGü‚¯ŸÐ~F­UÖà °¶ÏA^ß#"ŒÂbU ]gŒ 1Гœ5 8]Þ͉µLG°‹¿(MšÚ XÜªÐæB —zpV¹ä %·ý¼—ô†]æšÎíÊÆŽ Ü¢¹Ì‰Ö˜Äˆ—ó�€N£Ž°¢Ñòo³žìP\S'ðö|,F¥0¥žB¶…¯+H;–]Çø9„@bw? ô�vSê†�òRÜ´\:+Ÿ sq‡|Ìx}¥¿�œá³aåèÖƒtÐgϪZËÝáp”.�k#ЧB|E”ØÞxlœŒè>V¾šµÝðÝJ2Ôâ*×–cAK‚j$C}ÆGI@æ�K,]\90*òúOa6Êà#ç{ M4K~ˆSV(/IÀª1[rr‹R�½åf(�À>§AÂç“£5€†Áµz§”#«»  +ðÒD¯LÁ˜ÅƒI»ÊS°YBÍfÿ’õ¼äþöíJ¬ªHKt�“C> ÔDŒiø�P×Sy|[ÄÃP3 ¡· ó3ØÅ®r'sy²Ã·¯é0¯/ÜŸ85"êàzÈUc‰êɪ^TQêf¦tê+›F)ÙäT“òB3bù–ß¾5E sœoÝŠ%ÿºÄ°×Ødu¸·Œ05gi£\ƒV‹P¹ó–x^[q]×;²_A‚¦ôl/˦>,šëÎ8luÈ\ãŽÐ³Z™=êa¡ÍO’�¤Í#žkɈIhœÇ~” q}ÏOK\j¿D�â;èÃ.[ÅK³Š€L¹(®RG¬¢�î�².2uªžÝ7)�¹q2ÖXpô>M÷ c¯¤kn3™ÇLR—þfä8Ó ÂöÁNgi»ì*‘È:­G)Á^ýì >£1(¾¸®›�‚ðmIK£ËÖÙò¸Ü¬/êVÎ'’ààlÿÅ& UV £x h_ÔaØ#Õ[*Ã0¢YYÚ‚,Ü@RIÀÂÚ¥ðƒ £ª$Ùìz蛹„Xº1¯å!Ìîìœ�ϨbÒFò¬Q2:g™³N¤{#R"ª%ú’Q +Ê�²hÁˆ�Æz€Gt œB¼"ǤAèã^*•&ˆp�Å—,‘‡EQ¹zvÛÓ² Ÿ ‡y +ù�DàUÚ°‹­$lË’¨ˆ&ŸÈ)dqS„‹ÇÐŽkSáÏ«wÏÑÏ*Ü0?¸ã·Ôœ竉ª'wòÇ‚[Áó RĈ’Øä€MO™Œ�<¸|$d‘¸j¨”_Î?"Оñ”lˆ˜ôMN7fHÖɲ~"Ê;»éÒû+„g…Ø!'°�nª›6ÉÙÀÞ�´ÖDq¦hëÕ.þ›�Õ�C\Â`S²A¯v&©;IUJRª°;½ÏfFÂÜ0ù …-.%:Uõ^ðV$;ñ€k ]—XÿkÅcH¼l—pgÈø™¾JÓVK¥èƒ,ösÝMÿÄÊÑhú^�ÖX3†¥+/š>¡&Aソˊ�Z°¬pm�w>Wâ=KÂE„]`¡>t)Öð-¼Ÿ]Hå6šŠ‚w1fB‡ è±AôÒ´RØšøŸ[¥œA·áóx¶¿d'Ýè<Ìâ€c�¸E׳nû8húæ´ „›õÈt†¶:ÞÜúçaãe3DêÉd~-“�OÓÕ!WYþëE $]ü¨�aHîéJ‚6U› .˜,ÅdEΊ÷Ú-Šq¦V~£ˆŒ�AYúÿÌ×IDPŽ¥[ÐÿC¸4€{ôÄ%#“²Ò§5zø^øÌ~‘Ý@�„»lÓì%Ç$Ê’æ�/R~W]ÐЋ¹,˜¢¯*Í2L¾É°â£O� ï=±ÒuĘeUÈ‹�§/dä0%›¿¤–Æ'îücfžMþøÞ,tdcæK�Í¥T,ÉÅS�…[ó; Àš•¯ÑrÚ�ÁÔÕ¾èäÂ"oaq¿ýGuòWk£GŒ …ÂiÇfðœ¬Giå›l�›é©e‡Éù­C[ƒ§Þ 0ûDû:ÿ1á:‘¿×*I¾£¶¡,áŽÖÄŒšÌÑ÷v €,àóþÚ‘T/U'ž~2o + UA5êM…¦Ðg` ñÙÑBtr&ųڦe�㨜€R° +9‡j¿ñÒV‘õ*Fþ�çMøèë«â›[‘å8馓-sáãøº¼C¼ö?ˆzÞB¹‘8 ¶?±H ˜1žZÇ3ch·¾§¹P ÙkK^+ôC÷FLÆL+™uiÀLŸ¸põ¡ÜÏOL`&ÄÀþ(-Â4J  ²gÿ²\Ë>%8lGx~#?˜*]ü�G �C0ÇaLä­@‹ŸvE~x ÏÃìEyÒ€6Q86IT<°-�5K/»z÷ä:ñîn^IT«Ä]>Mîßãy�$Teäð€'eÂÊÓÉÇàÖž«ÉàŽ ÔGtû!<³4ñS‘Û +[¸uº!œŠ¦piÖU^ŠGF-–uAoÏõwƒ8€îÈciCH²Ú¸ü² ïíªœ@êwóÔ"ÛœN „ñýÖdf7ã_Xö ˆ53·àveþý@íØÆ�¦´em @_Ѥ©-U�ß{Ým¾‡Hv�Á¼”M•|,а\Í §•¼�âàãÐYLMôYÑd¤Z–1cêF‚R­ŽýLN5<ŒHÇ3›×v©Þ\Àùüò:ºDv®à#0SÔ†N±ßý'x±z¥!,Ó,ƒÚAp�fáæEÅ“Òø‡ZOr‹ùÃù6Kñ¸lèeˆ…ÌÎÄŒTnu´qµp¤\fà×ê°&ƒ�ÈŒ–ö–\*zTC¾-3¥3×(‘ÌÿÛëü'ˆx®Æ-j–׊#”vp"[ô¾ÇÈBGýÎ�c;'e ©¤ž$ø�ŠpÔ@YÐtéŠgöj©¥ÃtöÁw„…fž¨ÏЊÏc4ïr�|ò­!h-ñæxD“74hˆ}§=×ÁQæµ�ÖÔK ßÀÝ^7¸öì®­Å—yö÷q!=y¯Hd¤î c¥Íê�ù±°úQw}¾ ú‰¯Ô  +¥ú7ª½U9uO°øe²�ìÅAƒöýp:\ à¡í°S ¶Hñ˜Gn„�Ø +®‘²ÈÇA“U �€ÚØü"‘2Ô8à1bô… C1˜w²0�Þüé6q˜†I¦Iážé�Юì¡v¾\ä 2ê®�-¼Œ"š{Êñd\!ÿoq«QæÄôŸ˜ózA=²J§b„ô)—Ø,êßOay¾W"±¬@H;g` …˜ÕäS¡.ªŽ5¾Q$G«€x‘hqù]l@˵ +�÷¦—'¼¤†3’b)À¹.¹Ê cÔW—™Pžœš�0€y çq}‡CµØ|%ŽLnIÈ`Y­¹ñÆÆ>P®"À«¼$�¶�™¶ÓɰÛÛ4æd4mJ‹õ“Q#îQl”7‘{3œ?“T¿~7óA³]GxhgÎ5‰48¬‚²@¬¨‹¦(L¤Šž?û5cÊŠµê1¦u $0µS�3Pš)i°O¨1Ô‡@¥JÕªqâï{f×Ñ#Q~*ˆèšªCl>x¨‚ùV\<³Cq‘Ð>¨ø‰3#ç4÷ ¨Yœž\“„y£`:;“Ÿ!•ñɯ¼/3·™Ûb0 ™â»ž8­šµA +½é ØÔ#0òäd@«žôK\$á1Øî¯&H {Þ¦Ó_Hq¢áþ>_¸ä”QyË!ü|áÒµÖD—*O̵5¿(�š†”É�›=Nü�¹¿ ¥Ÿ +^é§CŒ–rö˜H�v¶Gót  ²S cWH e?³r!,ÒˆVApoo% Nv¤Òq ™J졆PÂÃf>"xÔµ¬Aú¤ Å{FMQ cÍ•+Òh»Õ—GõfUÙ©áDîc è¾Ü!îZŃ'°L""ò•¥¬d®zþóâ]€es.C³%WYU™†â5MÆ� #Î"fþ2•¶¬þP8*UDÎ>:ÖM¯Šà´È\ôæ‡;@t¶ %2!bЀ¨ xÛ¢RY>.¦7!ù&n|$¢ÎD +;_8wpè†àSˆnÉx°qü|0CºK2*ûúè0x*›w*²„J³¢o¹Ì¸eÖúQ æGÍ/èðeE¥©;N¨�úPù ¡Pƒ§Oˆ6a3¡×d¨ÉG°.{ ba�ÌÍ8}: ] ؤŠÜÕ裡À1“ÂV}Ié€Ä˜khá“hÈ‘‘¸ �Ë Ý +ûCf¢…ÙÄÐúhÍ%(ƒ¨'lGIªI…ÁÎ’ v2à +%+5´ 4€`.G κ‰hsÆùzöX«DHC lÕÓ…vEb8� ÁÍsÉèV —0CÜ)·uþàžNiGS?î.œ€�h©3³…û¹o�‡Rš‚J#ý6¨ëÕ…¬lâGœž+×x€¨•û«�Š7ΖS+q/°ÍZLF«.'cÅ‹4¢§t™…– +tJö4gŸ+—ûu×ÃuhŸ†ô .WÌŽDÑ-JJ;(í£z¼¤S`¾�8)dZb)‡(é9¸þ¸@Þ -KÂ)”þ-®D&&Mõ¢–¾²�÷Ô&Þ—È­Ûÿ©¢ ÏVöì92ýŠBH›nÕY·:èF¬Ÿˆ^þ¾{8°>�¼ QTuÕ[mÁ_ÄœVfSßÑ)ÆdÓ`ZSöŒiÍgÑm‘@5V�;$”6Â# +? ?g>;Ôbgì=ÐFIm›Nt.ãdü�C qTl�_sÉÚŽ÷q¦êoFj§Í/Ýj–oÔ"”.›qj)Eb_ì$HÝ’ß®'§õÓÝìûÃ7p«yÙâ�RoÆÕâÈàoŠW†Žözبş« ݧ�]†¤¯cÁ‰­èT~;±Â³úáÀ`| j"€[B¯Õ¸ÕúîÍQBbúQ’wW hu&†.(˜ÖÓ‹S�Éðs +XÀÔ}ì3U$Äã©)¥�=Ñ^™X¾iwâÁ}€hc_(W§<‰ï¦ÛÇ®±kâfSãu¢­Í¹7H-. �2 \Y¥…›~ÓcÔ+Üõ­P&HáªF:h¾×ÃÕ|Uº<·í j«Æ":o=´!R÷®~ŸêZòÖµãã¦ØMc<"Ü¿¶œ5R—Éò_.±eG½xýÀAܬm­&??Úü’š_Bƒ�F_êÇŠçL5½±B~�çhŒ«%ÍQx3`ŠÂqš_õ;ЀÝýÙEˆ +.Ñ*3‰h“Heà˜ëåŸ5Z?d ßLK¸ËTV;Ph¦Ú~ôç�Ã| @:_Ö�›ý+hÐ]ÑDì#‰žÑ{À¸h˜{%°ë¹} ‘oÚw¬u§Àm®ÀÆH§è3Ž\ +çÿÍw[ƒ¹t�A(Eá38¸ò¡}Ö캶 ˜ò ¬Š|‹KîåÕ4œg�UÜ€KŒp¥(¥ÀQ>ã1«Q êBް…èÊ´ØÄxu9#ð�€lèâëûÝŒg–È5Ì"‘%À–Ø!Ô7LAí:­1V'�$q>ÀŒ$–Ñîì’Ù¹–k°2ÝØ-¥Ò7B2Î"ˆ"^ZQwS1¤�Õéd-_2#Õ¹�ç¯-‰ƒ¥ª>%áˆ�ôA!±Æ¯&-kŠ ä/’¥ã¡Uý†…btAtì$…Ùx`H�$ÍêöÕ}’PnN "ô.„L8ÜäðRkA£Â˜¾E{Ì‹ÕuÚÖ8Ââ êøå]¹‹Ìá�ªScm¼‘õõ´·�jV/�ÕRúHVÍšZTõ1˜ð gR( +øk7¤Â4…>9Ò7À*'¢hBï2™wå^’GšÕF;˜(…ùèN­Á7á +� c© +�¶É0¦Ò9Z Beî +NÀÚ¨)Å$¦Ä¨ÕèÝ¿®ï‹Éû—ÍêÍËÏêÈ®¹[×µå“�º7—¤~——QOÍ"Q§L¡jDOÿ6#§wÚ6kˆ6½u÷MO©lM߬™®(%˜®¢[”.�rEúhòˆôQa�{d{{k�¼}1¸�¸n‡±V™°s `„ +® ’SÅ"�[ Þã +!¨ã:m«K•L(3n®®.h±T¾`ÖG �pÄѨ ×~ƒ~ü×Iú>Lâ�jM2ÁÆÉ�¦ð#I’™ +FUŠgCN²©‘dS�oÞ'¿EªÏó<‡b=êßbžARL’�d Îl JžåÌ"Ø[–õR=Ö�c¸£†‹ad9Ɇ,dÙt/ ôý ¨ÎP�-ÁQ‰G%ºÓ6WF133³®Š«é4iÙigY@bûš´ô,‹ 5i éw&-e™‚PÎdÙ%2¹ ”™TÙ¡¯R=wû¥öB½i ã�Þñ'‰>é{Ñ'¡fÇ�¿�‚õa0‰ø X¡-TÚFËΞÅì4ܶ;[ò†aV‹•†÷ ÿÕ¸nß@&‰Kœð ,ð��:&©�˜]…y;Yz Ùú¤¨:ë³ÿYI€¤éái<¸Œ�:f¼ ƒƒÄÐßî¤Y=ƒ�»@ãįt6û’Nv+@½�zAsÓö’`� &L[ŒLZŽÌl§mžÕfñ)»H‹:NNoÉI®ËúÌT«$ä`©±�LÂ'¤ºPÖ'JX^é�;ôìȵM0{aÙŽmò‡z$Á£À—£À�? +¼ s8œ‹ äÙÍf3�\/奺—ê˜õ4š‚³<˲éβéÎY6ÝaG�÷$Å•hoT¢û Ó¤%$Z)¡IËβH!�ŸÉ^vÉ¥LA˜É^v}æ‚ »d· È®»2#©þÍŽ_Æ6:;~ÇÙìø~®¬¸Ÿ+é�Þè�¾_¾%X�ÚÞ‚\;¨+rÃäzWÄ�Ô¹äú€öch?¿ý´í盽ª“2»"ÙàðrÍD.”‡tf ©ÜààE^�Mr ⣽Á¡4íPÇ}×…ð¤YÝMA†–Ç$�꘧‘ì ˜IʼÛÓ1G’4« †bñfœp\JqAòÎ^Z‘“ÉSÈ©*±£�]…*I¥X*„×òíÜZvÚ&ÀŠ.ÜíêÉÇ:µÉ§„øgÌã{¶Ü$Däs’M±%'YÛÔ™;ai›`’Mup®Góù-Ìoñ¿ÅLÔðæ,MžC�OõX'Ö‰ub�ub�Íܹžeo +Èõ,)óR›ä˜Ø†m4]d5xöY6Ý5³|aàÆÀ(3ÏTRÜÙÝG%º�J® +Œ–βHgªLA(Ë™ìSÙ%SÊ™ìeâÓÐ"»>YñiLA(mÙƒ'¨²*ÏýÒÂw©û¥†i²C?jÁŽC—üQyO¥õׯÊ=ö +\ølTOžáÒ5Àê "SýéüˆiVwÚÖi›M8ãõehRê…OHŽòkZðç.ÍZ°wñàHð�ËûwF'ܾaõ=»˜Œ¥Za4ªZ²ü÷'½¿§²:Áo§m!1ÀìK§m.nN;ÌÓàì7¹¾Ó6‰ý+ä)ä(³DäÈáã.cbäd©Ã×i›çÅ’¬• i-/k믰³dª<áRâäáIIú2k†åE½VÎ(òðç÷ãf×ÂÙ®�)d>W¯?³#àQ0ŒªÃ¥~¸ûy¢¬Ä;�r¥ÖúZ¬Ñ»ŽRì.̺æF¯h@“YA†KzH”‰Ä"òmsÆ1ïPF‡*ö¦=ßib&P¼³™fÞ:öWù Óö�XN«Úcâ:é88î賈�[ß¡¥z"éÃ0 ð ²Ê¿ã§+_°˜„ÇN•áOþ2øT¾c@}1´‚ФºËÒþñ»Áßi[§m±ü„ØwJÔHL¤H G#×i[€À†T"癵ûZXû¤Ëê2÷¸’ß%q°TÕCàÂ?ˆÁk¥¤¤wÚÆ©fG§m© +Zb;m£7v71Ÿ#0ž<:ßt§m�¶É�¶µ3ÚiÛRïoj“tˆ¨À, È!u!(^ô(ÞNÛú5h-¬°xûÍUÌæõ´¤&—f-ãŒJªÁêžÍé-;mCt! ï¸(o‘Ooé„*j -´D­Ö²qOøÇ‹þÒ�?ì…ËQ=�îßW0Úi[KØhиӶ KP}è½I¶�¶}N§mœkô;m“•mfe5`¸µH +ë¬`T³¹Ú?g¼‚æM F_O€h!ª]ñ”pjü]¦•ClÍ�ñ +Io®F0ŽÛiÆÏNøÏ®b2˜�Æ!^ÕÃ!^’Jh�ðî³+ 4ùeã*âc‰èÂËŒ%,UE}€¤L- ¸µìH†÷l†‘²ïÑì´í,(Z0ʵãkôn{Ö•¼Ó¶ÎÅÛ¨$.{©,—TrÕ’Hô²ÓæäÅDBLÀÀÕœMfUyV¡kP�±–l¥³ •EZ×*Ê�är™{ô>��Joìî!X¡' W˜¡Ûò},ÇŠvê%²T9Ì,¹z<…„…†\Äã#‰ÉDŽ1ð “÷‹œƒ<…œÂ3¹Ã Š)aÙi} VÇå¦{løtå$U'üc/'ÿŒ•5°‰x16‡åƃÈ6'Æ| HC�"R’–EÚi›ŠbQ‘7è3dí´ÍŽÀ\kÝ9Q>zyw # z^Z±ªhú®56ÉUk:|«}í•ëÑ®|•©~U‰ÝNÛ@"6³ŽZ¶\€dîB%h˜ ±` 4¤¸˜ o|�þ)@£©äšZ¶\ b…TkÒ–«×²SÇ0U6{µ.¼+V…© wd Ðð�âNÀ4üÄùï¬Û0 òm3¥œÚ?ð4êÖ qöŶ¤Z‚ÙfÖNÛ" Ê:ˆÅÑkz5RlMº�íH +P £Ó“M Èõõ«Sqù¦ UðÍ0«|ø<Úo ³’γ}Cj…âxµvï HqIN4DQšTï: ÞÃÏØþNÛ _ÍEÔ“€Å{§Á;‚uÜ,¡ <.é•JnäË*n†YYµ‚°=Ù‚^ËÁ²±Á¡Ó¶D™p”àèå}t�ºÒã«ÁZZÅý/¤,4ä:y +9�å5rhkr¢11r¢ÃrìûÅNÛ: +yäÀÏr2j$r¯Ù +9õó„ÜÄã#÷/­ +ŸêaßE÷‰œïƒ‹ÕÉ$¤QɉJI½o±>¬™Åû8 Å–‰*K&Þaÿ¡@¿Èô1õÙĹÆ4RL%Ç,u£øÆÏÞQ�6C{ê '–„ *"Ÿ} J^AŠtŒ†™ KFÂËOÅ ©ðÎ)ÞkŠ ä‘o› ScÅ4Èõ†·ÂûH­ð>ã4ié/0ï³;ôAÐì ¨N>` °§ !!Oa$„”n…B>q’$YQx˦?/æRÜmÐ‰Š£ÙñÏNÃû ¡$rÉ䨞¼rˆ�GaÚš'nLÄŸ?ywˆZó üøÓˆ}㆚‚q¿9‚AÀïøxN«Æ¢RâSúHOìáD6Æ“GN>fò�¶…¡€ÉЉLž¡!o3ö­ÿ%�}äwÚÖß1&1Ø�¦,’"¸Ó¶O{Aæ ‘DˆÄ0¾ú§òº*"ÍÈGÐ~àeØËÊúc1,°ýƒ0zVKúÎÆ¾õ)'ŒRÇŠÞp+DÏšTï´­dà¨~1’šŠ! £¾+‹´ð±MŽ›}ÒØ^¬EÚi7I£+ÕX‚åÁ¦ äAŸŽ£v9Œ›AI( )~öYD «œhÉÀQ ¿1PpŠûBb0eø¢¬xÝ‘mÐ…¸â®]ñ—äD=<¿ž;N ‚£ã›“eGñ}ÉȨkl¨Øßþn’8Fû¹„U €P‚�wCn¹JˆÉɉ†¤){qr¢!EÀÂ0ŸFD¾mJH”Ô0je‘Øxƒ@ÃŽ‰þ} Z–Éý�Ey�=%j !�ò^#Hq× ¾ +™lRÙ ”áB\ñÂ=¯¸H²©âTï{ = Co4ôâ{t¢!ùݼf…܆OŠL +Š\¼˜}ž ÈÍÉ< ¹Ù2ÌrK²jÄÃ_ÕËMwµD�ÿQº…ò $ ˜„w*Ç{L jÙÒ*^¢6k‰IÖ’“N4L¨7Ýö]èV…Ò£á:QsNjÆîRm+vynØq|²¸Cäzr] ž�âÎ?7#ò –V ¦Ëx5ÞK`Q`”x;zL§ÇH5Ç7ý«ÁJþ[Anb5x JžÔ°NïÆ,ôãÒã0`Þ¤¸�AÉ‹-ÈõâÅDP½$FL$²ÆóÆ ËÌj>�_dø8¾"òm“óZ3k ‚P±¤€‚fe M×ðK‡÷Ä ÑrPÞƒŠM�":€¤tÄG$ à'_™´œ+¼Û(;ô½Éý~©ðNF*¼ÏÆG…wñ\ >d+Š»÷¯�Ãs5øN�yæ½Ö4¼?¾Œ…Èõ¢P]|ªƒ ØÛDІ‰òÑÔ±¢ ¤W‡|ÀÑ;?Lqq8ýqò®�±Nî½Ð¤ºã Ço‘êa|¢a(óR=>Ñð•8ùõ~»„’°x/�}BòÝV9ÑQ€gß�•ê�ÌÀųDäPå:ri¢%p�%äd��<Ѹ–…†ÜÆã#gN Wø(‘Tç4&ýë.þ«ÃåéÊ·¸pÙXPHðÃD,¤k殥^:‘$ID÷KkD²RÌDc�ÕÀr$$GC^Xàwb92®§��ñzp�ñê– ˆúy½ž¶Ó¶›ƒÉPœ…š©p˜5ÓzY]µ±U¼Šš"`nápHÿᵜGîB�E÷KC�¶uÚæ’ >“zˆô¼Î–ãF´-%O@§m…Ù{Œãqòö€œðÉGuª‘Øç¯š¥Ãj'Y#1 öŒŽ�²ê´­Ó6Ž ”(ä‘ h­@.ÚöRÜ®£J±ÀTvº†Ý6]  J1ú. P²´­¦[«R�¡J•.1ûiž§¶¹Ê¡8ÿÁ+¾ÙJ”Ýô'â@&fÙÍ�0e‰èä/Om‹$ Dï&y8ÏC+r8féMD?üOþÃáà½ÅI"¦ÿˆ�è „¢›eYviÒ¶–©P$¾/¿yzßËþO²ûCâp&ÌŸ8I3qzm‹(ÈD?¸fWxD© σ9že/Ý“…y¸ëuÔ€~` }h"’™»k¯m¯HÐø´Í Ðô3@€À×´JÔËí:mC½ÜQ‡fF~§m �³‡S +˜:mSE©Þi_h 2OB9Èd°ŠŠª¹‡2—jm”©¡„òw”Luðr ]õZŽñ±Æ¼±k:�¶…È q8¢Š†>ý‘’TøÈUÊuäX¨ 4~³7døé_eÑ-wrô*ïP +%L±TG‰Ð~HGtáO[êí(H¼£A’,JNr§m�¶uÚÖiÛ„…f^Ã8Zºã–JEîC�ðNÛ&\¦QSÔBô2Y寴“¬³Ó–,³r—«Î�-:m“I-ÐAR`-iU(e®Mõ …~üT#‚= !ÊýaÇ,øYðÊä½G‚ü…NÛ8‡#´ÇSÈÉ,4äB\aäÈÏFÞ/rÜg S—utSú“ ‚üë;,”“R),aÛÓ•÷,ºðúÃÂÞ2ðh©Ü+ ØÉ!#¡íŒ[5èÏ= +*oùÉ: „ò�­bº¯˜„?,VWÇÇÉÃc{ΘÙT¿Ïë´ÍÁI «ƒ“ê¸NÛå:Î +yä89ˆ× `íÇœɺép˜Ž?'§œB©†¢ÜENq…ÒC¸ßrðŠ…í‚­Á±’:Ä+%!‡XrËC§m—CQ»Ãý¯5ú¯žðJa¤Qoެòòù¹¬ìLhÿ7C—uÀcò²ÒéÒH9�¶õ—$1.hä¾Ã~4ù;m =f•8Pí¿åBûÙ�ðL|9yôsӽƄ*êÀ³¶?CG¨ø;m;m{µ�¶…,¥ËQ•fm×ÀÄU>_¤i-ePS+«�ˆŸ¹4ù_«˜îŽ&á’‰wªŽqò—ü-Ñh™Ø†NÛwÍUJ¥4k "S뤘BµˤÔÐýzZo³UÖZbD/ñ�ªFD&?]™ª0 ïü¥ý“ŽËµ†¦�j _w”«NÛ8�¶Ér”˜L䚘ljÉD�ÿU2#þ:r¸×*9g…�’5 „H8†£¤ï -ê´­Ó¶NÛ6¬™“8‘ëDËuä0﹂Àr��ÜC´¹Å„r0°ŠJ_Â% µÓ66–޾ŒOxå!«¼¥:UH„ò–›MùZÍÔ=*�g)äyBn}¿È™,¯‘sÙߥÂr¯Lܵ¼µ€‡l8Ì +�¬þ¢°&_‘�>áp6®ÇÇpHˉHBwæ¨év`8 a¢Ó¶�¡±Z!uGµÊ. ¯ˆ„ƒúºØ·J3Jï´ BPGÓQej,Ó�Íî°PvLØe€à e-W+Êò·%SµKÑ»ÒFÓõ C½4Ú†À]`šON4ä‹ì'"؇CNåÎÆRˆéìü�~*ú-PÒDy/€ýqò÷„ÀèL�(Éž¼�±Ø8yç"˦¿Q‰î%—“–ý\ ~TÙ¡ßš½šÂûKRàÔ'�hh8±ÐГN4ì—€µ$tšÓ!t¢áÂrGØ£B©è$¤W"7ˆ4¼›û4nÞe—†÷O¨›w_º‘A¨Ô÷S]‘+c†DF@š# ŒÉô ¥Ï6D. H:R$0"(‹ÂÀ0 ‡ÆÁp8£( †aŒ: u4IQâ݉?Êwµ’øÈœ”K*¡&¼“£¦d¨…‘ìƒëY2%:Æ·ë4öbåKæáq»³ƒô;�žÊ�tX-ûßæž’p� ßV>Læï\2U῵½)+ï:È_ÂE\|£þ=b¸›Ë;Ò…ãF�r«Iý~»1áγÿ”DUYßÎ f�GûIÛ.!ž§ .S\œ³8qíÅeÅ$‹ TòÄ¥ˆ`\_tÛÕWäv9÷‹ ™[´³k�¹ÌÅÕUÿùú½d2 œ£34Ì£{9 –º8à®ÎEç*™æM)wWOõ\à›Ü]�š\WMJ¢mnº.V|Ã.0DþŽ‹ßÇqi¥sñxYÔeÛ…®Ùþè¥àø¢å\Sn…»ŒŽŸhêó Hw�wÁ]øåàä¡Ë.x¸þ¦Å� 6¯WWo�wG?ê'4£[9î‘ýHZÐPñcŽ•~”БD“(#$õ££ŠF0êý¨ºŒ0?ß…¶›.:)JRX¾§P}R@þsI�ºôÂŽì©Øœ¼í­S°÷Øžò›“‚!N¾Naµ§üæ¤`Úg¶N�»§d5ÄC”²%]R¥=Õj'‚ùé')hÝS¿9)\X«N±Ôžbþ§SU«Ú¡ž’Ú^’‚»ñE½ÊÜSÕ;æ!GöQ/Hª7¼nu"êå²ý÷ƒç‚C¡í¦þ"ÅYwû Ä e>QU­âtu®‰ºˆ´¢®ªý½í� õ×G*´¿îÊ2£‚ßþw¡ª¿À +Àöª¢SÿeÊ»p?¶¿¸â˜ê—`qFýÍS–Ûß„?в—ºÕþvns[¢›�ú«ü¿É³ö¸›„¦«Êɬµý•|ËÑV;¹ý…¨M‰ò;ÞZHf_ÄMe©¨¿NÖ¬ýݽ�°ú[Ûß—€aePë)ªÒþúK¥)n¨Zû 9#ÚgÇvª¿¬½mf&Óþ¦$ÚtTЉ(�åvLb8!õ^²v;¤Èaí荒«¿&%ˆ)V;ç(!ï ;í¯x;§5ê/BÊŽíöw›O›Õ_…Úþf>˜š­ÈPçü&÷µ¿HO•4ê¯l„`¶¿ÃW–ó þ–Ï%ÏŠý•»«'.°½FÀjµ.¥§Û_ºó]ý½úö7 QÀª¿¨j ˆÂHõ÷mío0$ +HP‹7î¦cTÉí/:¤g]˜÷”Wé½ýF=”úk¾¾ýURÚèê¯$¶¿½�øª¿ßÛþµúK¿.�Ô·¿VÃJVÕ†Û_·©Zú©¿7jÔÝþ. HÕ_`jï@Šj½á°Ã´�O@*ÛßøQcCýÕˆŠ£'n‘¾ýR¿ V?ßþ×#�úû>YKï�àê/‘>ûêü., ª•úk·Fk¶¿qÚÎT¥¾ýíù¨Vý}xàÒþ"šHúœiÏ®îç1µ·Wxõ$¥¨h´iІF jøB“¥¡-u§ôB«aÖšÐFl?ÜOOîFªÍÞ)K€ 們ú>Y+#;W ªë¾¨èéC©‹«:‘¥ºöĮŚÁß;Ñûqu`…C{ß9%ÛD]£Ì›WЯðn‚ƒ²÷ysF|YøiË�g¿^B^f¥©¾‡úô)¶.j«Éàä" iàF޳å6?@§½CO1‰hÛPEòªMê²w†™E…×hlªÿ}~%Æ™¡á½�mê~ë«]ƒë’ËÐëkX¾Sß7O{š»ÿ¶´ªmÑt²� X²S„U)yg.™rpEJ!BàËér楓4®R²O${t“²•“âhÚ‡K0ÙÖêøA¢SáU<q½É®†C£É 7ãe…°Á÷•X[E�af]6® +ÖaJëNP0§g?5?~#Én\%ÒÝE›ñopˆ¹(Ó6v ÚÿJ`˜áÃÈ?·ÑàaÊL$´$#’sO,þ’�Ö#ÅçÚK↨1R"nÓК0í‘þeÊvÃç‹Ë±i«Ý‰3EKd‹-ÇÊó‡÷‰ƒZ¹H?Ô~áĉ0‹ÊãÄï{ +†š·ô¯×ˆMü+5{Zœçâܽ‚R¼Ô‰ ˜ûÙY‘ûqâI%4@Å('~lƒáá�7 �â¿¡ý.qŽæ&Þ¦_ÉqÚA‹«�O—ÍL~ˆ‚Ÿ'.K‹oʘŸó‹átkç•a§Åqn”HIðb1ùÑï¦WP²¡ +5BHŠSv§ÚT?[x<%9Èp¯÷ƒ·aˆ„¶snB©ëÒìÛD6{ÞÄT{¿+ˆíø�+Mç«×�Ý+5¦» +¸Œq%tñ H÷S}0{«(Û±Xpt¯L¦ÛÛAtrun·'¿µ—VƒDT�$°ÇøÛ¯ƒ³Ã\¤)W UjW„¶’�·6OâY]ÉPu†"_@-Q$·^HÕ3‘à«z+•ˆ1wL[‰ -¡nåö!›ód€ˆÙT¤ý *¤FmÁ¿ª¾%O޽*Èja¤utD â=¬Í@!Ð2ré¯É´%ÚüÑø«W¢aM³†›÷cvïÑ7¿\yoãqº›¨l ´[pá?4�5Z —³NâÜFƒË@üâ¨àr÷¤‹ËðçÑd&$4n?=ŒÇ(+ß=v0ÍŽ…8• º~ˆZb¯ _!åk8ݬg‰`×8XmÛª¡Rü@Ù“¿xsw8�J¢»2¬©ŒNž3_:üFÁ67/Û� ¥pˆ#Äh¶_™‡ÌØ.<´™]í evu4:„‚¥{Ñ·šr<«íÏÄ1ºx$o¨[bgU£ÉŒºyw¤k‡v³v,*ÀCí¯,qüËà*8¦VÀé¹’.³ „àÝÂ]“L�q߬‰AªwÀ�+à™± hD*Ô;Pü}·••Ï ’v•Põ­zájýæרN·\Ô&‚„)1[™I¡®k®WŠÒKx<BÙB ^™Ì¿¤]f&„Ø:¾ðý@q:ëÍ�&ሌ¥CòrEÞÌÃ$·�XZe‹%ø$ì£Ôù§6 ¡$–¢Oúü¢¤Eñv‰2eµ(ñVíD4ñ$GñvÿçÄ#($׫&ŸÕÀ=bØ€'0�vûv¸ÆR:íÎ<(¿ÛàLëGĬÔ9K#ˆ2\žϺI@¯þaÝ3�‘»ÿ¡›› Ì‘f²..Ù-9Øû¥¹�}–Ov¹F¢o~�PIaú^ocxnƒ @Öe6g$ÿ�xš‹ZâÈà·Ü/7ø×-²©{WPÀ}7Õ�ZŽXb5ûmKQ?E€i‘摈r;Ð&ŸÝ�jÇú¹¡ÜhŸÖQÉFùT`ß Mª*Jª{Û¿>Í“—�Øa>Q¯y“—q.”£¢Ç¤Ÿb2Kêî¼ýº : õRþŽ Ù¦ ]ƒ¿�-ŠòŒ0cP ˆ<åÁˆé0ýq -��rcËæzðß—ðäþŠŠ<æuróO¬ 2øËMÑåQ£íÇÂúkwz&×usLKçW†ÔºD�…€ÆGôžlß�çrç¸$ESÆÞ’Ü6yŒB¡¤Œmá}Æ\&&ÈõKIÅ$Õ÷Û÷šÄôJÉ3LîÔ°ìúr†‰Ä‹îÅB�»?æ�¥h]Gϰô&hÓÊŒ/3�칦ôµ©³ •Þós)ÃAð’�á.³vEJö¬kŽœyj>T +sÊ)ë$ù×"ã3Pì©$§K4-Pa—E8>5v”Á»O�‘Âf’˜þýjº‹=y}]í»¯ ŠL° +Ååmô³mŠPc&“QàS6̵‰aæ<Á«iE»ÿ_‘H)©¢¢]¢6Ú“$ÇFÜPêBZŽb¥…òÞ«þ- ž–Nƒô@f Ëf•¿G¨êÃ<)a™uuùLÿ +À"Ú¦«êå(É ®yù2Ê„ÕþòFlÙiÄèóÞˆùY¨ÄiÄþFŒˆõbsø| ËêÇ¢ol!ðœÎFŽ®Í*G$¸Uõé³$ãh;M#Ôj¥a£%Ί¯H’´«ÄÈÖ®>Ü SÖ +m�ú*wBlÇÊiÒÆ¥}E»A†©òm©×Ûz&ê²™h�¯�ŒëX2G^ ɧQ±èÇÍà¹V u ¯4y›5�ÇË_y(Ý5@·¼#5ÃFOµŸÕ÷aó¸übU/zç¢çæù<Щ`柛q�æ�Ý7n 4y5íÖî6#b½Q_$Z¯^á�ËZwþ¬qÎÏüª3F¹Ê‰ 8zÞ ™‚õ!æ dPŒzU¦T©Ž¥ ++aºc&ÉžÃP�âÉŒ€PG¬¥ÖÄé;ö%û4Ó9…Ï‚²bO4® ×ð�f§ë#Ü JQ8ÂNÅÇ@ z_ sÁ¹Õ�Û›0dŒgÛõñ“�ÆwüŸ³˜%è)&ñ�+jLmÊœêKÐfC©å¿AÉqÖ� ¦}7h5M½çËaâ�…7�‹ÞÒÙªŒ2*$:ΈqɶCÕg»Ø9HDúÜI‘Ðüô +Å—¦u{UµÅ“¡cC‰ÉG Õ(xi@,_}EhH.SªàRµI„ �¼Ô:K€8Ï;E ÙvÚH•ÖýÒ²º_’Ï@›çdå Ên.B=ê 6í3⋺âÆIÁ£¾æ¥ZkâC°eúâÚÊO®–†hðå˜W‰ÊQ·M×@,»�&CÛôÂQÅ<bº—»Ì}œ³–¥–¬ŸÄÏ_ŠeZ)jTºWGÑ@Ów[vˆÂ¨djw¯µ¦�àp·!…¨‡ì‚îîX¢sã–+öŒ©±ê¨‰+nCy¹z¯eâ0'Pf»h“d7ÌR�–Õû|î@3÷À%¦ÂG<ÖØ2¦æ3æ¡O¶\7À0=ÇcEÓÛwóÉà°“·s­v•²S45·¼c˦? +øwœƒ9”íU‹f;Ûzú§ÒáV ù� †uŠ*fªÅÁUAP4®ÆQá|§ CFE‘Œ´9Y(n¼?“}06üxo„9¾ ø¡ ÆlÝuÖ¬�ä3n;Ÿ©–\,`8Ì ü9” �–‚y\ë CA¢ÂÙöûá°-a™Õ6·=`‹Òç=«‹L¬#¬O@F—D†g�O¬6º³ }¦ß€–gqÖýz +§�'ã·­@ëÐzìñï…äC‰¶‹çǤuÏ  � ¼x»�Mñ£à“ÙiøP‘ih»GM²Éôß0‘wb§W³ñ%šzÛÑÊœ@; +)°4Ä$;svw•ãÿ›¢Áœé¯>êbßI œ`¹|!´•Ç cKìa.7ÃFz@Ã@”ZðlÈ’9Kìÿ§nн.^T¬ïèr]vŸZ';?îšà÷/ÁL~LÀ›ùœ7¹F-²ûƒÈf1]в³2™Žn6†�ù elP[`` +µŠ#H“]¸Ý4 +ù¦#€Öd®úðú7AQþ§T‚jEÀ�½´I%¥«=½;‹Øå6ëݧÁ‘M5烹Þ8ȤÖ_�A¶˜@«Â4l©R¼R“Si ß—§ýJÁ +I‹E�_äšz)J‰’ïAL@Pße¼‹+˜Ç­ +ßm 8— ¯üì-¬�Câëæ:^ ÕÏ ™Â¨Ÿe~zÿJ+õE×I+ÏÿŠ”²ñÌ#{÷9ƒ&¼ T±x'-ÄÍH6_2;•P˜«úå…<ŠCÎ<ÊC“Ü©`s±¿‚ñø·˜ ¾kš.à9pQ_‘ß'‚žÐÀÁùXç ä“̺i�ƒ–>ÆJo® ­TÒi‹z1ð~ó‚óq±X£è… „ƒŽj÷"«õg¤Sx®–oVØÁµ€x¬¨›€ËXkáÈ“¥íWÌN‰~¶ÁÀdí_–ô(=“|ýÇ@ÚØ!=}fâN¢’Öœ ühÁ �KrlŒ„í—ÏL�ÞU&ÜÊ…ò +ÚÔ�>´b-±„²ÚKe‰ºpíÐ,´æ(#ÎßÉÁ¼î¢¬©v”€ÕF'*•˜w%Ô¶™šSt$y¤nÀP4\lr3øÒè:õå÷h }&å ¼åÕ„yáIU6– «‚z/ÇŸ/° 2_z#¬Bû +KðÝ…í‹à 𲆄T =ˆä9já$H’4’ÍŠ§{œ¥Aqý”˜Ÿb~ë_Bb:Ö3è3,¯xÏé‹BÁP×¼^‘/kkmçÑÞxïÚ‚}=;-�Œäà ð‘x  2óàw„â•,ÁqJßZ©0‚ï ï³:±ƒ±°Ø•°IsEd3óìÃÂÉÝW¶â‹ÒѸÈùÞ¢SÿÊZç!”¦ú§I<þVa²Ž(ËïÙÑè³kT”àŠú¼Åá B“Íü5ÀÒ’ìÞ0ò@ÓFH¿¤÷$6íÛ¸[Ò†¦Ï|¥^ˆÜ)‡x6>û†¤†g¦’6½&-*ûÚ2Üé‰F^ý–‰²2*D" +@K-ZÏÆ‰﨩mqè +Re‡Ú½7­ +z‹Ü¯Ø °8‡&^YTd¤&o8+CLFÏá�oñŽäpŒ:5¯’Ö�â!ôJ§ø2…ݳÃÔœÚâ¢:™{� +ê‰_,¤ÃçÑ�œxN‹C‰õHú…÷i®6¿0‰rê—èÄû·8Q§�|âuúí$þJ�÷‹ ‚RĨ>EÑW'"x«¼Þ×ÿŽøð‡3¸›œ9ïBˆhŸá"]_ÏV঩í{I �+â\H>Ë�ü&}¶%®´�àf�ðïÜ»„‹óIKn^'òº,bhÜ[Øø¿ÕqT4 > ï„+9aPüÙ0á‚ úß¶ŸzEL�jÔ2àV\Lýüw™ÃY^JÖBǸc†ÃbÇ�“�’.¨W!ð¸ðß§ž›ûw�Ç»³þå-¿¯c÷½™ +8zœ)Øu¥ÊQ`Üo2µûÙFb8Y,¢m-�ÆÂ�q{’üw +?x*H¬2U3)JIQCEO‚äÒ-'„ntm¸úrI8sý;Â?A÷vh¼€ä&Ã`E|*l†¶Lçe"ÆBÛŒ>æS$`¹U)¸6í�“ã*ÝGÒÞÇ®ö«WA¯t‡¹G,•¦ëì(‹Geªpe¢LÝt- !© ñno9T¡n*Mä�ïBP…vy)�Ô¦B‘ÍG€‡˜"•·GS!iÁN�ôfÛ.yRÒÒ܇ÃΓ(pI�ç^‹‘¨‚W­„k ü}0—H`ÆOTVÉÛîIæ%¸$µ-œc�Y9ÁÐŽ(@>×–U³ØrOe·�¬0A÷i!JEdWB³D<ÿšðãŒT„àÜÿXÂý¡¼…å0ˆ—/êiøaEß±#èW²¸•»‡aq÷blíIJgV˜¹RFb +ÄT·él/(·…&l-zr 8¶£¹Tóxy›z�X�/5±½Ê‚ñi^.©•4ñª{$d0ÙxmHòÊl*H¥$Ó ï"ˆzÀÈ üot©¡è3ØÈ�”ÉcŠPª 8˜~D,Çó:^WQà 9A7õÒ{ú?x‚ÒÉ]ûˆúTÖHÈÈfœH«!§h9 ¤àM±1sÓß�Çs7íVc BÑÃá<·ÃwÏÿ'y²·Ý~¸ÀÌßk™k8O€+£7ùÁTCn»§Éuî”q‚áˆÉ…Dœ^ª,Þ=œë gÚòTû;º±…Bæn½•#eýL…÷dú AŠÆ…¸­Ã k#&û×ýd¬”Ê &˜ Œú»Ö£ŒYK1—¬•YM¶Qô! �m¯bŠCr¡ç6ØÀ…!9+2[ÆA’«ÙR#¹�í_¯ÊÕc5"ô ø!ЊF°«Âü�fjÝ·P2’¬lþg]wÜdnCÂ�lGû¸Ýæè¥”6¸~»Ê³Ðèvýc’Õº†¼[(�\“ðeÝz�¿œVfZ…ßóƒ¿Sꥄ ã¢Øf<Ã)��ÏT«šù–ÓþýQŸäþåΘ¶Ù@颲ímáO´ÒøæŽ; ÷ÊÔ·@(isâV“ª‚.£ÎÖà6å¼]¼F {á0¬ôÃQTFVªÂüammL®yˆ(kE´EB€ +ÝÕ0¸Î†ºÆ–§;¿ö»Wå$+Žzÿ +«?=B 5¶½;¼ñ³wX°toqß›: ¥õËF8X§qÆ3lªÃ�† Hßzä¨@Ö @„Þϼh´ÍîR î,�•®¢x0,{× K)ðÖØ„6P‹€|d¬jõ~‹^˜üàV»yº[M��ÖïuÞ³Zý©|dR¸÷Û}eIÕ½Ñ«Ž½™¾…V ³¤=h7¼ù6ð4 +™v²«,3>oäåÏEë#|nBƒ¥À¢»è~H[ÄŠ;6ªq½B£ –oŒJ]7ÒÐ3VÄóIØoòÅ"7—&Aç©”º"öÉEÜ{^šo†¨·„ßk4Þ x¶þ]£ÒvÓ…åÒ);ÚA•f2í¤*™ég³ +°UõVËxËÂì§N2»pý&<íœO`H²¼ßº�‘ ‚£áÓõkDd<Êf;ói¼¯Uó!»[ìœSšT´¡‹›Ì–«í ß/pE?7Mìª�ÐZ�Ò[â%]cŒ[Ðuju´¬”¥W¨ @TÝ�£tŒ4#váXX8¼L›ˆž, WBR\[•2«”È�½\Ó:DÙ¾¼@JØmY©y ¨­Z©›6–À�Ê>0 “ i'{ô¿ÒÔœ=;m’ p4xn¿·W"ö\q–D„3M*MôÖå~L@~ÙÒ§!!Tš”¥gH®Éá71l%/”ÎÇW ”H¤2 N“ŠÑ˜ë, ò˜RnqJ˜1Ì6”¨l“*`Tš€&EI `àà*Ò¤þAnb(PL*1¸ÖkbcR.˜¦q4©ÃÔ© + ‡Všvö.|sÏÜ+èzZ¥ $wh©„þ´]ãÆÏé%W}ؼ‚_5OE%›õÝJº>¥²6/rÁe¥&$ÌvV:�½Ê¢ÍýI¨°LK$FÝb.¡�fQjyf[ÄÒl¾ˆÑÍÊ#äÏ‚ú0'¶õ{Þ‹¤%ب˫×VÏ \ù¹ú}Ä£Ì9V$56é„«™Œ~-écΛ�?BéqÝkÐ!qwÏle"ÊZ1H2èC‹¿°RyN7€ò(™ê—<ç/²yKßo›€tžàëw:Ä¢þvˆÅn}ÁÀTO-�Ô€±n™£%HðwÂìöªñÀÞÏå› ](r¬” íâÔ¹Ñ ŒÄË—¶Üf9š-C>À¸)p‡LþÏ"ß×ûh«4DQTÅs¬˜aS»émê~wǤ²aQbÍã£W*¯ð¾zì­ô9ÔÈÕ¯®�iÚð« I�:­ôTˆU‡ÑëœÒԕ*Güg dx9²õåèï r£�ýHé8{CRÄ”OTqÖáØÜ†UÒ¤¦D|Üøo;¹ ‘¢K¯.¬ jHî žnÉŸu©oX®SLê寅1ôQœÊ%Q>q5íWÎpÂ$�_õYø� €ÝÅ”{¦‹É­Õ4.Yÿ‹�Ü?ŠýÉ$ŲqèƒMRž·ƒ>.ÂÀa瀷 ˆ¦ ++VHTòô¬£”nùƒáS$7†Q«70* ‰�Š*>|C�S[Q_5¥Kw^Ô"ï>Ø|ÝçkÜËT‘ÓúÅ)½¢SàWÞ§Ö­XëBˆ"+§T!:h­–¥uûÃŽg ¾ +@ʨѼæ¤Ð +«•,!—eã9¹ÎF3*„¨˜,=Bô™ï5Š”=™‰‘C@—ë�Fo›×a±ŒNp|^| +8ª×ô"ÌÐsþ]ö?§>Õüz”ÍqTB’/£‘¨¶0WèñN? Uȯ°&¡açì$¢2÷±±£j%Ĩûˆ™št{æ}kHç-VÄv‡¸p�ÝŸá×@‰ˆu· ç~�\žˆrìˆUm[€‰(ê�T¹‚ç!•²Í¦µÝ�ˆ"¨!%V1-2åUš1Ô)ÖR¼ì�i ¬2ž¦±âŸT†�Ö%å�C¼ 8ª#Ä5þYÜK ¼ÑØød"ÄšD-Pg£‚ñ „¸ÉÔbÆ[+(âûßi:bwy�œ# +%Ĭ×iºzO†i‚ÞC\,¡!ÄÃI§Òšÿ\!Α µU“)ÏC| 8æÍkB±òzt“Gà„*‹]QÍ!Þå <øvk +°X‚-ÚÉŽ.šÅÂ’QùTógß)¡$†«µTÛf…Á¬?«®"ÃDÓ(¹'_"@]0Ç&:ƒù±™ê¹Àà]GÏì¾±†¾ša΄™—­Èr«c\9€T¸ˆ¬POSßQ¥¶g!›nºÙÅHÚZ„éK•6; P¡r§æWB¦ˆ‘08qÊ¿S ˆ Vê{�4±ÚŸ·d™¬­ô Bý½ U4á; fJUšù4@îš¹Q‹ûâÎàUÙkƯ +ÒÔH½P_Üì¿#ͲÃ;ª‡Î`� :c¤åK’D ·&œÑœ¢‡ŸSroÅ�RÂ?ƒ»º—P¤4,NÜ3x!Â$dQ‘®¬ÿ@xµÒÚ༙5¶ÉÚ¶z ‚·“½­À“‘güÞJƒ D“Â4y‘ÐÍ™=©§›øËæˆrA2¹c2z Í:ºó‰ª¼Ç½Ù�Û‘!×öëå ¥�¼ô:=BʈP±Jß#„ˆ‡Ìø\R…7‚azáÄÌmˆŸ<»!Øœ ‚É\Öˆ¥S€˜ +0®ì»ñxßR“å¿¥=âÑË߬p·áàýsf“ià ™kò£¬Ñ8NwÈmÄx%› ”yl­§±I¿‚Ôç÷;Ué]ÕèNq¢¦ðÜN‘$¾Í•¹xçï(ƒàÆd-÷Ü C~)²VK�‰¶¾IªÉ~ÎzÜPÜï£ùWp¦rlÈÚ ‚ñgººïç•x³«w¹S�~ššp”êb&CuB+Ùõ¢Ïë¤*ˆ?³ÕH^î îm:Ä–×Í-Çj›–m€�Hµð€ˆAãèF@p*)™ä£ÊÓyÞ$œ¦…à=½#v�€ldUßà˜ îQ£ +‚ÉpüïxÎEò³`Bê»Vùl0æàÚónÎ.lp¯�¨¨7øºh!6˜Qy±É5`oðÓk€a +eÀ'“aš¿opàë•áçÌ� á‹7X m@ž þ1i<†yl0î%l£²¶»@ü%’Çx9ûÜ—’İ‘�:Ï^GYBJ”°!ª”ú˜ÝæËjéÄ…¯™�ßéoaöC 5[Àˆ’JÁFkiD×à><œ#Òh0¿�´rSÀÊbæÏ†ÊýÏË3»U3À‘<$¹!¨¢B‘n‰ªù4Ré^´q÷Ã]!p äÚÛQ‰¶ÒK?*‰òŒƒè¨²kp KcõáÌÕe(8Û´k•FÍ„àÐëˆ[~…¥Ïo¶'ÿ°‹iäGs,ª²“°ÎUSñCC³äª+Ü^wb/Ÿ9X¡{±ú‘%ðDi³orN<’MhÏ $GóZuƒøTHmbíå…õÔÑLsNÞ�·V*º/ÅE ®Aj@Z¬ª„ûdJ]Ú£ÉÚ5üîî/G_F/Ë^mî'›2? RÊ,ij~d'e%¨"U#™X�Û•VPsï[nPÐ…ËSmeÃÑ�‘o-¯š&k®'xº»e¶ž�[H\ÊÕ@«�‘×O2Ö&Ïê†i+°>{Œmy$i °UÉ;}jpŸ§g°¿ Øý¬mº÷–ïTv’Ô*ú?y?ùÑÚûøÂú¿ùÏ*­Š½Òû9£]h£û}#cohRkÄëGÃÈt”Q!ã O>™ ˆp9R:_‰Eó,µ�ÑÕÓt­„ Lë†À~j¸„‡E<¸¼YHéÛeç‰ÃÏ<´>'ü_ÍÐN3€�該ݓϮ³ IJ[›X ��;ݘ­Y(.5‚$ÍVÂk9ä2犊7pvcÁe”Q®`\z‡£K“» }½Eî±0QÌIúšû훩-±©™md±5öù +a[¥ï ˜gG +°ô!*GÒèR^“ç^$ˆµvxÜ£ ê«5'™@©ˆžï¥ïmêóÂË?2åKßü@x>½òù€Á¤¯!âõNg´æT䵎DB*û ÚÀ+–fÔ(á-¯kݧØ~s%ùÚ&s-êMe©M±î~Jkõ™|ÎÜ“ ²—3&Ÿ‡{î¶–c+ÓOˆÉw®ë÷‚œ8’…uM‚ɾÉ2ÂXu—|¯S‚‚kÀB9ùí µ™‚ÉÿÛ3 ÜcPÌ{A#ïd£Ëx…8ƒ(•¬Íƒ'o?y>ô2X«©\,ÚìlÂ8ì«IÁ3ïã)>–Á–å+äUwú�üñe6ÝŒû¾l-gvÑ›µòd/*QdøÎ,†Ï¾Xÿøƒúÿ3ÂÆËýöÚÅö¬cØ=÷9 +!òéÂK .óé]«ArE0AYÑÕdd~Þ×ã37NtÔÁøG¾�é>«·Øgï™Ï³%ì¸('ꎇÆÄ³Ù\yð˜ƒgUÒ+™æøƒÄ6êŠ$á¯P¸DFÛ0©Ô™¶}>æÏxÞüœ0v'ÃÝJZ+Ó¦¹¼¬Œæ´BT(•z¬ÃTà MëçÑOí/h“¶&ÕIãü@àá³L ÕokzÊÏø¦Æe×·#ñÝ&3¾À†~õúŽYMà\åë댤^…Caê+n‘ì]ß�æ¼øŒ–]ß/P­KÔ×QßXt}Eu§p Ðìõů¥ 6¤T ˜àú"bšþà0ÄUÃ:ž˜‰%0�à@æoSIú<6(ŽÏªã~‚`l»%ƒQ—*•cJb™ª.Éf{RÆþª ¥â^W­‚NjùÁ‘F‚#!ŠzŸâ—à8rèÛ¼�cæ�0fëרúžî@ÛËà$+p¢›Š]<©llî{óQ§-©W1éf9Á!Ð4EØe\ä¾)TÖØ¾"»ÐËH]èõÐgq±8¥Õt/ˆ>Â'ÿÏÙ7SåÌû¯†ñB2�¿!¡µ�öT:¢ÜÇÔ€1¿ÞÉ�Ë%LÏeÒr®”3®¹ãxÑ~(š ¡Éh·ý +endstream endobj 15 0 obj [/Indexed/DeviceRGB 255 20 0 R] endobj 20 0 obj <>stream +8;X]O>EqN@%''O_@%e@?J;%+8(9e>X=MR6S?i^YgA3=].HDXF.R$lIL@"pJ+EP(%0 +b]6ajmNZn*!='OQZeQ^Y*,=]?C.B+\Ulg9dhD*"iC[;*=3`oP1[!S^)?1)IZ4dup` +E1r!/,*0[*9.aFIR2&b-C#soRZ7Dl%MLY\.?d>Mn +6%Q2oYfNRF$$+ON<+]RUJmC0InDZ4OTs0S!saG>GGKUlQ*Q?45:CI&4J'_2j$XKrcYp0n+Xl_nU*O( +l[$6Nn+Z_Nq0]s7hs]`XX1nZ8&94a\~> +endstream endobj 6 0 obj <> endobj 21 0 obj [/View/Design] endobj 22 0 obj <>>> endobj 10 0 obj <> endobj 9 0 obj <>stream +H‰l�M +ÂP „÷ïsÓü½¾tkWEÄ#ˆÖ….¬÷SW.$ð&30éŽ#ºidlw# +“XCRõËŠe.Ýá̘ßåçÜ�ê0(ª9q¯�˳¬§•jA=;ED)Xÿ(œ{yslÄ�jX*Z‰[ÿ«ÜË­œ²•‰gŸ&‘Ôе•{ºÓdÖÒ,(<2-éñË5Ãû)¿ú0YÃ-R +endstream endobj 11 0 obj <>stream +8;Z\tYml4;$j7H&/d#XUA8/4L63PoQoIf&SEd`6omg&mn!!)d;HY[JX.OqpJm41bdVVU'Wjd5VSNXJ.o!>[nK`-7[_Hjr)u)apibF&Nu:+ +9*>4p!!)II"os;lN[+~> +endstream endobj 7 0 obj [6 0 R] endobj 23 0 obj <> endobj xref +0 24 +0000000000 65535 f +0000000016 00000 n +0000000144 00000 n +0000035610 00000 n +0000000000 00000 f +0000035667 00000 n +0000139756 00000 n +0000140644 00000 n +0000036039 00000 n +0000140055 00000 n +0000139942 00000 n +0000140282 00000 n +0000036948 00000 n +0000036412 00000 n +0000036650 00000 n +0000139194 00000 n +0000037022 00000 n +0000037202 00000 n +0000038825 00000 n +0000104414 00000 n +0000139242 00000 n +0000139826 00000 n +0000139857 00000 n +0000140667 00000 n +trailer +<]>> +startxref +140867 +%%EOF diff --git a/app.manifest b/app.manifest new file mode 100644 index 0000000..e915943 --- /dev/null +++ b/app.manifest @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + true + PerMonitorV2 + + + diff --git a/appsettings.json b/appsettings.json new file mode 100644 index 0000000..6f53b22 --- /dev/null +++ b/appsettings.json @@ -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" + } +} diff --git a/assets/InvoiceParser.ico b/assets/InvoiceParser.ico new file mode 100644 index 0000000000000000000000000000000000000000..5bfd962dc16987b9943c3dbd88259b3514b06deb GIT binary patch literal 2880 zcmeHJX;4#F6uvKsKv<)p8Usq|prDmDAR*uaPYOzegn~}1g-U4H0>vZ(28iMVT+k9N z3e+OCsZ`ObAXvqfT|hC&*02eZBB*F&5y7zbg+**TQ>#DPKfN<|=6>&c_nvdUbMBc7 z00h*4YCwPjN^k)9p=$)0z_u6wmd*eGh9|HK1VErV095J()noue4ln?rk2g70YhVadb=B)jmzNC_#5Uc%!8_^r^OSjFg zvX~waIly#pFm*6~&h2T*XBCgM?1;+i zSXmQnK5UcPrF{*x_-dRx8|LetPUneJ%!2Oq%L=r?fvaxPYNha+`B5>Iv$`xh$t;n?7Im%)#S_qzt-0HSPMf5+to8q!= zTF=USQh7S9>E!@#GV!Q-v7T5%FR;dGiFo%pp0x#J4RN|7Neh!td5GKa>^uW9u2v%~W-m0z!z zPO9aylHT>qa4x3uPTR;cmQ)$v{^WDZ_rb9LrJ?4vG_L(?o>neTz8h5NJQLYtHPmcE zOBayJ{b}4{Q4>?ze-z|Fk8aJIE|{y!mm1Z^q~YQ9P4~Cmue~11S*jOU9dD8&mX7=T z(UiY^{h}}ArkKk5!OjopfrV~3NtV>_`Daq+WhDi8m~*VfO4wimu|Qv4j1(H=M3bEHW+>P&6w7!bWKbA-(9HJ9hdC(RjTg^70r`>D@{}p3`dqjhCMsx zuU!lvD2K;E@Jh1)vgvi_WB{?q!rqEJGPwTT#xM8>ea^Fp1U&ss-LTDT_X4mM=(LG zp<+CR1q7HlBAoiKe7Y=vo;HmJi_@EO6c5RzqygFaqH`^VRtmAVy2q3cmlL3;L!(jR z^ea&v1GEDcz50CXA!BL}j(>i}l*0xF`OTN|Hn+`t2%R5tkqAG#@c&tt!n4a!AWenEc1UC_lH$PF7|Vuh%+S-rhhSYk1K=_JmxT zJ!AOhNV{A~E}b>*5}Gx1To4bH*IYZu+cK!%wKwO7HD4^VQ@i=r@}URWUsBNB{jD@Z z$S(w{ga4*_V3dZ(tLJB~palhQT|GcPSv`&B`wQ9nj&kF+xD%2a*bfWM0_e`)v*-y6 zHLmIw#PI{gj`Oht+oj5KzWI`pHMeEew}R<**iyHOI_Fy#!z`1rl^b3jH?3490#g4(&AHSM&W5N6p>rmgJ3&ESt_yQ?Nfh@5BYL(iOx$rXN z(;aZ9lT8y{VM)sn9r(D@Zk0XCw%9w%wj6(I5YRF6_+0pmEj%cE{85`Sa(KC(CyV|t z+I3<(N}P?OU(lPy^dau!zCj8K%2URr2#JjBdnYN_Dk(Ou<&XmMHBK-vw|h0mest02 z56H2Hq@0*)tlFA+)K0`rJ$^a67E044g@H{9hye45-_* Wzo)IFve^L8usWf`Wo4nS`}{lki?AX9 literal 0 HcmV?d00001 diff --git a/src/AppConfig.cs b/src/AppConfig.cs new file mode 100644 index 0000000..5402fcd --- /dev/null +++ b/src/AppConfig.cs @@ -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"; + } +} diff --git a/src/BatchService.cs b/src/BatchService.cs new file mode 100644 index 0000000..f94dff1 --- /dev/null +++ b/src/BatchService.cs @@ -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 _batchCols = new(StringComparer.OrdinalIgnoreCase); + private Dictionary _headerInfo = new(StringComparer.OrdinalIgnoreCase); + private List _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 FindCostChanges( + SqlConnection conn, SqlTransaction tx, IEnumerable<(string Upc, string Desc, double CaseCost, double? Pack)> lines) + { + var wanted = new Dictionary(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(); + + var products = ReadProducts(conn, tx, wanted.Keys); + var result = new List(); + + 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 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 ReadProducts( + SqlConnection conn, SqlTransaction tx, IEnumerable upcs) + { + var result = new Dictionary(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 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 { "BatchNo" }; + var vals = new List { "@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 { "BatchNo" }; + var sel = new List { "@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(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 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(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; + } +} diff --git a/src/DataService.cs b/src/DataService.cs new file mode 100644 index 0000000..e249269 --- /dev/null +++ b/src/DataService.cs @@ -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(); + 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 }; +} diff --git a/src/Db.cs b/src/Db.cs new file mode 100644 index 0000000..3705cb7 --- /dev/null +++ b/src/Db.cs @@ -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"; + } +} diff --git a/src/EventBroadcaster.cs b/src/EventBroadcaster.cs new file mode 100644 index 0000000..061e717 --- /dev/null +++ b/src/EventBroadcaster.cs @@ -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> _clients = new(); + + public (Guid, ChannelReader) Subscribe() + { + var ch = Channel.CreateUnbounded(); + 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 ?? "" }); +} diff --git a/src/LlmClient.cs b/src/LlmClient.cs new file mode 100644 index 0000000..f048941 --- /dev/null +++ b/src/LlmClient.cs @@ -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 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 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 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 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 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)); } + } +} diff --git a/src/Log.cs b/src/Log.cs new file mode 100644 index 0000000..00cc4ef --- /dev/null +++ b/src/Log.cs @@ -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)"; + } +} diff --git a/src/MatchService.cs b/src/MatchService.cs new file mode 100644 index 0000000..9337075 --- /dev/null +++ b/src/MatchService.cs @@ -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(); + 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(); + + 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); + } +} diff --git a/src/ParseWorker.cs b/src/ParseWorker.cs new file mode 100644 index 0000000..b8f65bf --- /dev/null +++ b/src/ParseWorker.cs @@ -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 _queue = Channel.CreateUnbounded(); + private readonly ConcurrentDictionary _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(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 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> RunPagesAsync( + IReadOnlyList pages, Func> 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(); + 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 PageImagesByNumber(string id) + { + var result = new Dictionary(); + 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(); + 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 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; + } +} diff --git a/src/PdfPrep.cs b/src/PdfPrep.cs new file mode 100644 index 0000000..4761aa8 --- /dev/null +++ b/src/PdfPrep.cs @@ -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? 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 ExtractLines(string pdfPath) + { + var outLines = new List(); + 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 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; + } +} diff --git a/src/PendingStore.cs b/src/PendingStore.cs new file mode 100644 index 0000000..97be250 --- /dev/null +++ b/src/PendingStore.cs @@ -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 List() + { + var result = new List(); + 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]; +} diff --git a/src/ProductCatalog.cs b/src/ProductCatalog.cs new file mode 100644 index 0000000..b48a249 --- /dev/null +++ b/src/ProductCatalog.cs @@ -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? _products; + private Dictionary>? _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(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>(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> index, string key, int i) + { + if (!index.TryGetValue(key, out var list)) index[key] = list = new List(); + 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 Candidates(string desc, string upc, int cap) + { + EnsureLoaded(); + List products; + Dictionary> index; + lock (_gate) + { + products = _products!; + index = _index!; + } + + var keys = new List(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(); + 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 KeysFor(string text) + { + var seen = new HashSet(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 + }; +} diff --git a/src/SaveService.cs b/src/SaveService.cs new file mode 100644 index 0000000..0d04c38 --- /dev/null +++ b/src/SaveService.cs @@ -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 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(); + 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(); + } + } + + // 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); + } +} diff --git a/src/Util.cs b/src/Util.cs new file mode 100644 index 0000000..a20a646 --- /dev/null +++ b/src/Util.cs @@ -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; + } +} diff --git a/src/WebHost.cs b/src/WebHost.cs new file mode 100644 index 0000000..c84f5cc --- /dev/null +++ b/src/WebHost.cs @@ -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(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddHostedService(sp => sp.GetRequiredService()); + + 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(); + _ = 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(); + var worker = app.Services.GetRequiredService(); + var events = app.Services.GetRequiredService(); + var match = app.Services.GetRequiredService(); + var catalog = app.Services.GetRequiredService(); + var save = app.Services.GetRequiredService(); + var data = app.Services.GetRequiredService(); + + // ---------- 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(); + 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 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 }; +} diff --git a/wwwroot/archive.html b/wwwroot/archive.html new file mode 100644 index 0000000..4a024b5 --- /dev/null +++ b/wwwroot/archive.html @@ -0,0 +1,327 @@ + + + + +Invoice Archive + + + + + +

+ Invoice Archive + + New Invoice + Pending + Archive +

+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ + + + + + + + + + +
DateVendorInvoice #Total
+
+ +
+
+
Select an invoice above to preview.
+
+
+ +
Select an invoice above.
+
+
+ + + + + diff --git a/wwwroot/css/style.css b/wwwroot/css/style.css new file mode 100644 index 0000000..ffc2785 --- /dev/null +++ b/wwwroot/css/style.css @@ -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; } diff --git a/wwwroot/index.html b/wwwroot/index.html new file mode 100644 index 0000000..c9e9dbe --- /dev/null +++ b/wwwroot/index.html @@ -0,0 +1,324 @@ + + + + +Invoice Parser + + + + + + +

+ Invoice Parser + + New Invoice + Pending + Archive + + +

+ + +
+ +
+ +
+ + 100% + + + Ctrl + scroll to zoom +
+
+ +
+ +
+ +
+ +
+ +
+ +
+

Invoice Header

+
+
+ +
+ + +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+
+
+ +
+ +
+ +
+
+
+ +
+

Line Items + + + + +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
UPCDescriptionCert CodePO CsPO PcPO $QtyCase CostPackUnit CostRetailGM %TotalSizeUOMMatch
+
+
+ +
+ Draft auto-saves to Pending + + + + + + +
+ +
+
+ +
+ + +
+ +
+
+ + 📤 + +
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.
+
+ +
+ +
+ Go to Pending Queue + + View Archive +
+ +
+ +
-
+
Ready to review
+
+ +
-
+
Queued
+
+
+ +
-
+
In progress
+
+ +
-
+
Errors
+
+
+
-
+
Uploaded today
+
+ +
-
+
Archived
+
+ +
-
+
Total pending
+
+
+
-
+
Pending value
+
+
+
-
+
Pending line items
+
+
+ +
+ +
+ + +
+ +
+ + +
+ +
+ + + diff --git a/wwwroot/js/app.js b/wwwroot/js/app.js new file mode 100644 index 0000000..eb0ed63 --- /dev/null +++ b/wwwroot/js/app.js @@ -0,0 +1,1734 @@ +// Invoice Parser desktop UI - port of invoice.js from the ASP web app. +// All parsing/prep now happens server-side; this page uploads, reviews and saves. + +var poCompareData = null +var pendingId = null +var pendingCreated = null +var pendingFileInfo = null +var pendingSaveTimer = null +var pendingDefaultState = null +var lastLlmResult = null +var rowCandidates = {} +var pdfLineMap = null // [{page, top, h}] per OCR text line; fractions of page height +var isDashboard = false +var nextRowIndex = 0 // data-index is a row key, not a position - manual rows get fresh ones + +$(document).ready(function () { + var resumeID = getQueryParam('pending') + isDashboard = !resumeID + + if (resumeID) { + $('body').addClass('review-page') + $('#layout').css('display', 'flex') + $('#pageMode').text('- Review') + initPreviewSplit() + initPreviewZoom() + var boot = [loadVendors(), loadDepartments(), loadPOList()] + Promise.all(boot).then(function () { resumePending(resumeID) }) + } else { + $('#dashboard').show() + loadDashboard() + setInterval(loadDashboard, 15000) + } + connectEvents() + + $('#fileInput').on('change', handleFilesSelected) + $('#poSelect').on('change', handlePOChange) + $('#btnScan').on('click', handleScan) + $('#btnSave').on('click', handleSave) + $('#btnDeleteDraft').on('click', handleDeleteDraft) + $('#btnRestoreDefaults').on('click', handleRestoreDefaults) + $('#btnAcceptAll').on('click', acceptAllRows) + $('#btnAddLine').on('click', addBlankRow) + $('#btnCreateProduct').on('click', handleCreateProduct) + $('#btnCloseModal').on('click', closeNewProductModal) + $('#btnParseQueued').on('click', handleParseQueued) + $('#btnViewParsed').on('click', openParsedModal) + $('#btnCloseParsed').on('click', closeParsedModal) + $('#parsedModal').on('click', function (e) { if (e.target === this) closeParsedModal() }) + $(document).on('click', '.parsed-tab', function () { + $('.parsed-tab').removeClass('active') + $(this).addClass('active') + $('.parsed-pane').hide() + $('#' + $(this).data('pane')).css('display', 'flex') + }) + $(document).on('keydown', function (e) { + if (e.key !== 'Escape') return + closeNewProductModal() + closeParsedModal() + }) + $(document).on('input change', '.item-upc,.item-cert,.item-qty,.item-cost', function () { + if ($('#poSelect').val() && poCompareData) applyPoComparison() + }) + $(document).on('input change', '#reviewSection input,#reviewSection textarea,#reviewSection select,#ocrText', function () { + queuePendingStateSave('editing') + }) + // scroll does not bubble, so the capture phase is the only way to catch + // the table scroller and drop the fixed-position search dropdown with it. + // Scrolling inside the dropdown itself must not close it. + document.addEventListener('scroll', function (e) { + if (e.target && e.target.nodeType === 1 && e.target.closest('.search-results')) return + $('.search-results:visible').hide() + }, true) + $(document).on('blur', '#np_upc', function () { + var v = $(this).val().replace(/\D/g, '') + if (v.length > 0) $(this).val(v.padStart(13, '0')) + }) + + $('#uploadZone').on('dragover', function (e) { + e.preventDefault() + $(this).addClass('drag-over') + }).on('dragleave drop', function (e) { + e.preventDefault() + $(this).removeClass('drag-over') + if (e.type === 'drop') { + queueFiles(e.originalEvent.dataTransfer.files) + } + }) +}) + +// ------------------------------------------------------- +// Preview / form split - drag the divider or hide the preview +// entirely so the line-item grid gets the whole window. +// ------------------------------------------------------- +function initPreviewSplit() { + var saved = null + try { saved = localStorage.getItem('ip_split_pct') } catch (e) {} + if (saved) $('#leftPanel').css('flex', '0 0 ' + saved + '%') + + var collapsed = false + try { collapsed = localStorage.getItem('ip_preview_hidden') === '1' } catch (e) {} + setPreviewCollapsed(collapsed) + + $('#btnTogglePreview').on('click', function () { + setPreviewCollapsed(!$('#leftPanel').hasClass('collapsed')) + }) + + var dragging = false + $('#splitter').on('mousedown', function (e) { + dragging = true + $(this).addClass('dragging') + $('body').css('user-select', 'none') + e.preventDefault() + }) + $(document).on('mousemove', function (e) { + if (!dragging) return + var rect = document.getElementById('layout').getBoundingClientRect() + var pct = ((e.clientX - rect.left) / rect.width) * 100 + pct = Math.max(15, Math.min(70, pct)) + $('#leftPanel').css('flex', '0 0 ' + pct.toFixed(1) + '%') + try { localStorage.setItem('ip_split_pct', pct.toFixed(1)) } catch (err) {} + }).on('mouseup', function () { + if (!dragging) return + dragging = false + $('#splitter').removeClass('dragging') + $('body').css('user-select', '') + applyPreviewZoom() + renderLineMarkers() + }) +} + +// ------------------------------------------------------- +// Preview zoom. 1 = fit the page to the panel width; the page +// wrapper is sized in px off the panel, so markers stay in place. +// ------------------------------------------------------- +var previewZoom = 1 + +function initPreviewZoom() { + var saved = null + try { saved = parseFloat(localStorage.getItem('ip_preview_zoom')) } catch (e) {} + if (saved && saved > 0) previewZoom = Math.max(0.25, Math.min(5, saved)) + + $('#btnZoomIn').on('click', function () { setPreviewZoom(previewZoom * 1.25) }) + $('#btnZoomOut').on('click', function () { setPreviewZoom(previewZoom / 1.25) }) + $('#btnZoomFit').on('click', function () { setPreviewZoom(1) }) + + $('#canvasWrapper').on('wheel', function (e) { + var ev = e.originalEvent + if (!ev.ctrlKey) return + e.preventDefault() + setPreviewZoom(previewZoom * (ev.deltaY < 0 ? 1.1 : 1 / 1.1)) + }) + + $(window).on('resize', applyPreviewZoom) + applyPreviewZoom() +} + +function setPreviewZoom(z) { + previewZoom = Math.max(0.25, Math.min(5, z)) + try { localStorage.setItem('ip_preview_zoom', previewZoom) } catch (e) {} + applyPreviewZoom() +} + +function applyPreviewZoom() { + var wrap = document.getElementById('canvasWrapper') + $('#zoomLevel').text(Math.round(previewZoom * 100) + '%') + if (!wrap) return + // clientWidth already excludes the scrollbar; subtract the 8px padding pair. + var avail = wrap.clientWidth - 16 + if (avail <= 0) return + $('#canvasWrapper .page-wrap').css('width', Math.round(avail * previewZoom) + 'px') +} + +function setPreviewCollapsed(collapsed) { + $('#leftPanel').toggleClass('collapsed', !!collapsed) + $('#btnTogglePreview').html(collapsed ? '» Show preview' : '« Hide preview') + try { localStorage.setItem('ip_preview_hidden', collapsed ? '1' : '0') } catch (e) {} + if (!collapsed) { applyPreviewZoom(); renderLineMarkers() } +} + +// ------------------------------------------------------- +// Live updates from the background worker (SSE) +// ------------------------------------------------------- +function connectEvents() { + try { + var es = new EventSource('/api/events') + es.onmessage = function (e) { + var msg + try { msg = JSON.parse(e.data) } catch (err) { return } + if (msg.type !== 'stage') return + if (isDashboard) { + loadDashboard() + return + } + if (pendingId && msg.id === pendingId) { + if (msg.stage === 'parsed') { + resumePending(pendingId) + } else if (msg.stage === 'error') { + setStatus('Background parsing failed: ' + (msg.error || 'unknown error') + '. Use Scan Invoice to retry.', 'error') + } else if (msg.stage === 'processing') { + setStatus('Parsing in the background...', 'loading') + } + } + } + es.onerror = function () { /* browser retries automatically */ } + } catch (e) { + console.warn('SSE unavailable:', e) + } +} + +// ------------------------------------------------------- +// Batch upload: the server stores the file, preps it and +// queues it for background parsing in one round trip. +// ------------------------------------------------------- +async function handleFilesSelected(e) { + await queueFiles(e.target.files) +} + +async function queueFiles(fileList) { + var all = Array.prototype.slice.call(fileList || []) + if (!all.length) return + + // The accept attribute does not apply to drag-and-drop. + var rejected = [] + var files = all.filter(function (f) { + var ok = /\.(pdf|jpe?g|png)$/i.test(f.name) + if (!ok) rejected.push(f.name) + return ok + }) + if (!files.length) { + setStatus('Not uploaded - only PDF, JPG or PNG files are supported: ' + rejected.join(', '), 'error') + $('#fileInput').val('') + return + } + + setStatus('Uploading ' + files.length + ' file(s)...', 'loading') + var fd = new FormData() + files.forEach(function (f) { fd.append('files', f, f.name) }) + + try { + var resp = await fetch('/api/upload', { method: 'POST', body: fd }) + var result = await readJsonResponse(resp, 'upload') + var ok = 0, fail = 0, failMsgs = [] + ;(result.results || []).forEach(function (r) { + if (r.ok) ok++ + else { fail++; failMsgs.push((r.file || 'file') + ': ' + (r.error || 'failed')) } + }) + var msg = 'Queued ' + ok + ' invoice(s)' + (fail ? (', ' + fail + ' failed (' + failMsgs.join('; ') + ')') : '') + + '. They parse automatically - check Pending.' + if (rejected.length) msg += ' Skipped (unsupported type): ' + rejected.join(', ') + setStatus(msg, (fail || rejected.length) ? 'error' : 'success') + } catch (err) { + setStatus('Upload failed: ' + err.message, 'error') + } + $('#fileInput').val('') + if (isDashboard) loadDashboard() +} + +// ------------------------------------------------------- +// Landing dashboard +// ------------------------------------------------------- +async function loadDashboard() { + try { + var resp = await fetch('/api/stats') + var s = await resp.json() + if (s.error) { showDbError(s.error); return } + $('#statReady').text(s.ready) + $('#statQueued').text(s.queued) + $('#statProcessingSub').text(s.processing ? (s.processing + ' processing') : '') + $('#statProgress').text(s.in_progress) + $('#statError').text(s.error) + $('#statToday').text(s.uploaded_today) + $('#statArchived').text(s.archived) + $('#statTotal').text(s.total) + $('#statValue').text('$' + (parseFloat(s.pending_value) || 0).toFixed(2)) + $('#statLines').text(s.pending_lines) + $('#dashUpdated').text('Updated ' + new Date().toLocaleTimeString()) + } catch (e) { + console.warn('Dashboard stats failed:', e) + } +} + +async function handleParseQueued() { + var btn = $('#btnParseQueued') + btn.prop('disabled', true) + try { + var resp = await fetch('/api/parse-queued', { method: 'POST' }) + var result = await readJsonResponse(resp, 'parse-queued') + var msg, kind = 'success' + if (result.queued) { + msg = 'Queued ' + result.queued + ' invoice(s) for parsing - they process in the background.' + } else if (result.already_waiting) { + msg = result.already_waiting + ' invoice(s) are already in the queue and still waiting their turn.' + } else { + msg = 'Nothing to parse.' + } + setStatus(msg, kind) + } catch (e) { + setStatus('Parse failed: ' + e.message, 'error') + } + btn.prop('disabled', false) + loadDashboard() +} + +// ------------------------------------------------------- +// Scan Invoice = re-parse this draft now (server-side) +// ------------------------------------------------------- +async function handleScan() { + if (!pendingId) return + if ($('#lineItemsBody tr').length && + !confirm('Re-scanning replaces the header and every line item with a fresh parse. Your edits on this draft will be lost. Continue?')) return + try { + setStatus('Queued for parsing...', 'loading') + var resp = await fetch('/api/pending/' + encodeURIComponent(pendingId) + '/parse', { method: 'POST' }) + var result = await readJsonResponse(resp, 'parse') + if (!result.success) throw new Error(result.error || 'parse failed') + setStatus('Parsing in the background - this screen updates automatically when it finishes.', 'loading') + } catch (err) { + setStatus('Error: ' + err.message, 'error') + } +} + +// ------------------------------------------------------- +// Matching +// ------------------------------------------------------- +async function callMatch(invoiceData) { + var fd = new URLSearchParams() + fd.append('vendor', invoiceData.vendor_name || '') + var items = invoiceData.line_items || [] + fd.append('item_count', items.length) + items.forEach(function (item, i) { + fd.append('item_' + i + '_upc', item.upc || '') + fd.append('item_' + i + '_desc', item.description || '') + fd.append('item_' + i + '_code', item.product_code || '') + }) + var resp = await fetch('/api/match', { method: 'POST', body: fd }) + var result = await readJsonResponse(resp, 'match') + if (result.error) throw new Error(result.error) + return result +} + +// ------------------------------------------------------- +// PO dropdown + comparison +// ------------------------------------------------------- +async function loadPOList() { + try { + var resp = await fetch('/api/polist') + var pos = await resp.json() + if (pos.error) { showDbError(pos.error); return } + var sel = $('#poSelect') + pos.forEach(function (p) { + var label = 'PO #' + p.po_number + if (p.vendor_name) label += ' - ' + p.vendor_name + if (p.po_date) label += ' (' + p.po_date + ')' + sel.append('') + }) + } catch (e) { + console.warn('Could not load PO list:', e) + } +} + +async function handlePOChange() { + var poNum = $('#poSelect').val() + poCompareData = null + clearPoComparison() + if (!poNum) return + try { + var resp = await fetch('/api/podetail?po=' + encodeURIComponent(poNum)) + if (!resp.ok) return + var detail = await resp.json() + if (detail.error) { showDbError(detail.error); return } + poCompareData = detail + if ($('#reviewSection').is(':visible')) applyPoComparison() + queuePendingStateSave('editing') + } catch(e) { + console.warn('PO detail load failed:', e) + } +} + +function normalizeUpc(u) { + return String(u).replace(/\D/g, '').replace(/^0+/, '') +} + +function clearPoComparison() { + $('#poCompareSummary').text('') + $('#lineItemsBody tr') + .removeClass('po-match po-diff po-inv-only') + .removeAttr('data-po-status') + .each(function () { + var row = $(this) + row.find('.po-cases,.po-pieces,.po-cost') + .text('-') + .removeClass('po-cell-diff') + .addClass('cmp-na') + row.find('.po-note').remove() + }) +} + +function applyPoComparison() { + var poNum = $('#poSelect').val() + if (!poNum || !poCompareData) { clearPoComparison(); return } + + clearPoComparison() + + var poPool = poCompareData.map(function(p) { return { used: false, item: p } }) + var matchedCount = 0 + var reviewCount = 0 + + $('#lineItemsBody tr').each(function() { + var row = $(this) + var inv = { + upc: row.find('.item-upc').val().trim(), + cert: row.find('.item-cert').val().trim(), + desc: row.find('.item-desc').val().trim(), + qty: parseFloat(row.find('.item-qty').val()) || 0, + cost: parseFloat(row.find('.item-cost').val()) || 0 + } + var normInv = normalizeUpc(inv.upc) + var found = null + + for (var j = 0; j < poPool.length; j++) { + if (poPool[j].used) continue + if (normInv && normalizeUpc(poPool[j].item.upc) === normInv) { found = poPool[j]; break } + } + if (!found && inv.cert) { + for (var j = 0; j < poPool.length; j++) { + if (poPool[j].used) continue + if (poPool[j].item.cert_code && poPool[j].item.cert_code.trim() === inv.cert) { found = poPool[j]; break } + } + } + + if (found) { + found.used = true + matchedCount++ + var po = found.item + var qDiff = Math.abs(inv.qty - (parseFloat(po.qty || 0))) > 0.005 + var cDiff = Math.abs(inv.cost - (parseFloat(po.cost || 0))) > 0.005 + row.find('.po-cases').text(formatPoNumber(po.qty)).removeClass('cmp-na') + row.find('.po-pieces').text(formatPoNumber(po.qty_pieces)).removeClass('cmp-na') + row.find('.po-cost').text(formatMoney(po.cost)).removeClass('cmp-na') + if (qDiff) row.find('.po-cases').addClass('po-cell-diff') + if (cDiff) row.find('.po-cost').addClass('po-cell-diff') + if (qDiff || cDiff) { + reviewCount++ + row.addClass('po-diff').attr('data-po-status', 'review') + row.find('.match-badge').last().after('VALIDATE DATA') + } else { + row.addClass('po-match').attr('data-po-status', 'match') + } + } else { + reviewCount++ + row.addClass('po-inv-only').attr('data-po-status', 'invoice-only') + row.find('.match-badge').last().after('VALIDATE DATA') + } + }) + + var poOnlyCount = 0 + poPool.forEach(function(p) { if (!p.used) poOnlyCount++ }) + + var summary = 'PO #' + poNum + ': ' + matchedCount + ' matched' + if (reviewCount) summary += ', ' + reviewCount + ' validate' + if (poOnlyCount) summary += ', ' + poOnlyCount + ' PO-only' + $('#poCompareSummary').text(summary) + refreshReviewSummary() +} + +function formatPoNumber(v) { + if (v == null || v === '') return '-' + var n = parseFloat(v) + if (isNaN(n)) return String(v) + return n % 1 === 0 ? String(n) : n.toFixed(2) +} + +function formatMoney(v) { + if (v == null || v === '') return '-' + var n = parseFloat(v || 0) + if (isNaN(n)) return String(v) + return '$' + n.toFixed(2) +} + +// ------------------------------------------------------- +// Vendor dropdown +// ------------------------------------------------------- +async function loadVendors() { + try { + var resp = await fetch('/api/vendors') + var vendors = await resp.json() + if (vendors.error) throw new Error(vendors.error) + var sel = $('#vendorSelect') + vendors.forEach(function (v) { + sel.append('') + }) + } catch (e) { + // Without the list the draft cannot be saved; do not fail silently. + showDbError('Vendor list failed to load: ' + e.message) + } +} + +// ------------------------------------------------------- +// Form population +// ------------------------------------------------------- +function populateForm(data, matchData) { + $('#invoiceNumber').val(data.invoice_number || '') + $('#invoiceDate').val(data.invoice_date || '') + $('#invoiceTotal').val(data.total != null ? data.total : '') + totalLabel = data.total_label || '' + totalPage = data.total_page || '' + setHint('hintVendor', data.vendor_name) + setHint('hintInvoiceNumber', data.invoice_number) + setHint('hintInvoiceDate', data.invoice_date) + setHint('hintInvoiceTotal', data.total, totalHintSuffix()) + + var v = matchData.vendor || {} + if (v.id != null) { + $('#vendorSelect').val(v.id) + } + if (v.label) { + var badgeClass = v.label === 'exact' ? 'badge-exact' : v.label === 'fuzzy' ? 'badge-fuzzy' : 'badge-none' + var badgeText = v.label === 'exact' ? 'matched' : 'VALIDATE DATA' + $('#vendorBadge').text(badgeText).attr('class', 'match-badge ' + badgeClass).show() + } + + var tbody = $('#lineItemsBody') + tbody.empty() + var items = data.line_items || [] + items.forEach(function (item, i) { + var match = (matchData.items && matchData.items[i]) ? matchData.items[i] : {} + tbody.append(buildRow(i, item, match)) + }) + nextRowIndex = items.length + + recalcAllMargins() + if ($('#poSelect').val() && poCompareData) applyPoComparison() + renderLineMarkers() + refreshReviewSummary() + pendingDefaultState = collectDraftSnapshot() +} + +// ------------------------------------------------------- +// Review sanity checks: header total vs line totals, rows that still need +// a decision, and what blocks Save. Cheap enough to run on every edit. +// ------------------------------------------------------- +var totalLabel = '' +var totalPage = '' + +function totalHintSuffix() { + var parts = [] + if (totalLabel) parts.push(totalLabel) + if (totalPage) parts.push('page ' + totalPage) + return parts.length ? ' (' + parts.join(', ') + ')' : '' +} + +function rowNeedsAttention(row) { + var s = row.attr('data-status') + if (s === 'unmatched' || s === 'fuzzy') return true + var po = row.attr('data-po-status') + return po === 'review' || po === 'invoice-only' +} + +function refreshReviewSummary() { + var rows = $('#lineItemsBody tr') + var attention = 0, unmatched = 0 + rows.each(function () { + var row = $(this) + if (rowNeedsAttention(row)) attention++ + var s = row.attr('data-status') + if (s === 'unmatched' || s === 'fuzzy') unmatched++ + }) + + var summary = rows.length + ' line' + (rows.length === 1 ? '' : 's') + if (rows.length) { + summary += attention + ? ' - ' + attention + ' need attention' + : ' - all reviewed' + } + $('#lineSummary').html(summary) + + var blockers = $('#saveBlockers') + if (unmatched) blockers.text(unmatched + ' row' + (unmatched === 1 ? '' : 's') + ' must be matched or accepted before saving').show() + else blockers.hide() +} + +// ------------------------------------------------------- +// Source-line markers on the preview (text PDFs only) +// ------------------------------------------------------- +function renderLineMarkers() { + $('#canvasWrapper .line-marker').remove() + $('#lineItemsBody tr').removeClass('has-source').removeAttr('title') + if (!pdfLineMap || !pdfLineMap.length) return + $('#lineItemsBody tr').each(function () { + var row = $(this) + var n = parseInt(row.attr('data-source-line'), 10) + if (isNaN(n) || n < 1 || n > pdfLineMap.length) return + var line = pdfLineMap[n - 1] + var pageWrap = $('#canvasWrapper .page-wrap[data-page="' + line.page + '"]') + if (!pageWrap.length) return + var desc = row.find('.item-desc').val() || '(no description)' + $('
') + .css({ top: (line.top * 100) + '%', height: Math.max(line.h * 100, 0.3) + '%' }) + .attr('data-row', row.attr('data-index')) + .attr('data-tip', 'Row ' + (row.index() + 1) + ': ' + desc) + .appendTo(pageWrap) + row.addClass('has-source').attr('title', 'Click to show this line on the invoice preview') + }) +} + +// Preview -> row +$(document).on('click', '.line-marker', function () { + var row = $('tr[data-index="' + $(this).attr('data-row') + '"]') + if (!row.length) return + $('#canvasWrapper .line-marker').removeClass('active') + $(this).addClass('active') + row[0].scrollIntoView({ behavior: 'smooth', block: 'center' }) + row.removeClass('row-flash') + void row[0].offsetWidth // restart the flash animation + row.addClass('row-flash') +}) + +// Row -> preview. Clicks on the fields and buttons in the row are left alone. +$(document).on('click', '#lineItemsBody tr.has-source', function (e) { + if ($(e.target).closest('input,textarea,select,button,.cand-item,.search-results').length) return + showRowOnPreview($(this)) +}) + +function showRowOnPreview(row) { + var idx = row.attr('data-index') + if (!$('#canvasWrapper .line-marker[data-row="' + idx + '"]').length) return + + // Expanding rebuilds the markers, so look the marker up again afterwards. + if ($('#leftPanel').hasClass('collapsed')) setPreviewCollapsed(false) + var marker = $('#canvasWrapper .line-marker[data-row="' + idx + '"]') + if (!marker.length) return + + $('#canvasWrapper .line-marker').removeClass('active') + marker.addClass('active') + + var wrap = document.getElementById('canvasWrapper') + var wr = wrap.getBoundingClientRect() + var mr = marker[0].getBoundingClientRect() + var top = wrap.scrollTop + (mr.top - wr.top) - (wrap.clientHeight / 2) + (mr.height / 2) + wrap.scrollTo({ top: Math.max(0, top), behavior: 'smooth' }) +} + +function buildRow(i, item, match) { + var parsedUpc = item.upc || '' + var parsedDesc = item.description || '' + var parsedCode = item.product_code || '' + var parsedSize = item.size || '' + var parsedUom = item.unit_of_measure || 'EA' + + var upc = match.upc || parsedUpc + var desc = match.description || parsedDesc + var cert = match.cert_code || parsedCode + var size = match.size || parsedSize + var uom = match.uom || parsedUom + + var qty = item.quantity != null ? item.quantity : '' + var cost = item.unit_price != null ? item.unit_price : '' + var total = item.total != null ? item.total : '' + + // Retail comes off the product file. Pack prefers the product file and + // falls back to the invoice's PACK/SIZE column when the product has none. + var parsedPack = (item.pack != null && item.pack !== '') ? item.pack : '' + var pack = match.pack != null ? match.pack : parsedPack + var retail = match.retail != null ? match.retail : '' + var parsedPackHint = (parsedPack !== '' && String(parsedPack) !== String(pack)) + ? 'inv: ' + escHtml(String(parsedPack)) + '' + : '' + + var label = match.match_label || 'unmatched' + var badgeCls = label === 'exact' ? 'badge-exact' + : (label === 'fuzzy' || label === 'cert_code' || label === 'upc_norm' || label === 'upc+fuzzy') ? 'badge-fuzzy' + : 'badge-none' + var status = badgeCls === 'badge-exact' ? 'exact' : badgeCls === 'badge-fuzzy' ? 'fuzzy' : 'unmatched' + var badgeText = label === 'exact' ? 'matched' : 'VALIDATE DATA' + var warnHtml = match.inactive ? 'INACTIVE' : '' + + // The search box is available on every row, matched or not - the suggested + // product is not always the right one. + var isUnmatched = (label === 'unmatched') + var searchHtml = '
' + + '' + + '
' + + // Only a fuzzy row can be accepted: it already carries a real product. + // An unmatched row has nothing to accept and must be matched or created. + var confirmHtml = (status === 'fuzzy') + ? '
' + : '' + + var matchedDesc = match.description || '' + var toggleHtml = buildDescToggle(i, parsedDesc, matchedDesc, matchedDesc ? 'matched' : 'parsed') + + var parsedCodeHint = parsedCode ? 'parsed: ' + escHtml(parsedCode) + '' : '' + + var parsedUpcDiv = (parsedUpc && parsedUpc !== upc) + ? '
' + escHtml(parsedUpc) + '
' + : '' + + var cands = (match.candidates || []).filter(function (c) { return c.upc !== upc }) + rowCandidates[i] = cands + var candHtml = '' + if (cands.length) { + candHtml = '
' + cands.forEach(function (c, j) { + candHtml += '
' + + escHtml(c.description || '') + + ' ' + Math.round((c.score || 0) * 100) + '%
' + }) + candHtml += '
' + } + + var srcLine = parseInt(item.source_line, 10) + + // Both sides are kept on the row so the parsed-vs-matched view can show + // what came off the invoice even after the fields have been edited. + return '' + + '
' + escHtml(upc) + '
' + parsedUpcDiv + + '' + + '' + toggleHtml + '' + + '' + parsedCodeHint + '' + + '-' + + '-' + + '-' + + '' + + '' + + '' + parsedPackHint + '' + + '-' + + '-' + + '' + + '-' + + '' + + '' + + '' + + '' + badgeText + '' + warnHtml + confirmHtml + candHtml + searchHtml + '' + + '' + + '' +} + +// ------------------------------------------------------- +// Gross margin. The invoice bills by the case; the product file prices by the +// unit, so the two only compare after the case cost is divided by the pack. +// A blank or zero pack is treated as 1 (the product file leaves it empty for +// singles) - fix the pack cell when a case row looks impossibly profitable. +// ------------------------------------------------------- +function numOrNull(v) { + var n = parseFloat(v) + return isNaN(n) ? null : n +} + +function recalcMargin(row) { + var caseCost = numOrNull(row.find('.item-cost').val()) + var pack = numOrNull(row.find('.item-pack').val()) + var retail = numOrNull(row.find('.retail-val').val()) + if (!pack || pack <= 0) pack = 1 + + var unit = caseCost != null ? caseCost / pack : null + var unitCell = row.find('.item-unitcost') + unitCell.text(unit != null ? '$' + unit.toFixed(3) : '-') + .toggleClass('cmp-na', unit == null) + + var retailCell = row.find('.item-retail') + retailCell.contents().filter(function () { return this.nodeType === 3 }).remove() + retailCell.prepend(document.createTextNode(retail != null ? '$' + retail.toFixed(2) : '-')) + retailCell.toggleClass('cmp-na', retail == null) + + var gmCell = row.find('.item-gm').removeClass('gm-good gm-thin gm-loss') + if (unit == null || retail == null || retail <= 0) { + gmCell.text('-').addClass('cmp-na') + return + } + var gm = ((retail - unit) / retail) * 100 + gmCell.text(gm.toFixed(1) + '%').removeClass('cmp-na') + .addClass(gm < 0 ? 'gm-loss' : gm < 15 ? 'gm-thin' : 'gm-good') +} + +function recalcAllMargins() { + $('#lineItemsBody tr').each(function () { recalcMargin($(this)) }) +} + +$(document).on('input change', '.item-cost,.item-pack', function () { + recalcMargin($(this).closest('tr')) +}) + +// Always rendered, on every row - you should never have to guess whether the +// description in the box came off the invoice or out of the product file. +function buildDescToggle(i, parsedDesc, matchedDesc, active) { + return '
' + + descSrcButton(i, 'matched', 'Matched', matchedDesc, active) + + descSrcButton(i, 'parsed', 'Parsed', parsedDesc, active) + + '
' +} + +function descSrcButton(i, source, label, value, active) { + if (!value) { + return '' + } + return '' +} + +function buildSearchUI(i) { + return '' + + '' + + '
' +} + +// ------------------------------------------------------- +// Manually added line (parser missed a row) +// ------------------------------------------------------- +function addBlankRow() { + var i = nextRowIndex++ + $('#lineItemsBody').append(buildRow(i, { unit_of_measure: 'EA' }, { match_label: 'unmatched' })) + var row = $('#lineItemsBody tr[data-index="' + i + '"]').addClass('row-added') + recalcMargin(row) + refreshReviewSummary() + row[0].scrollIntoView({ block: 'center' }) + row.find('.product-search').focus() + if ($('#poSelect').val() && poCompareData) applyPoComparison() + queuePendingStateSave('editing') +} + +// ------------------------------------------------------- +// Inline product search +// ------------------------------------------------------- +var searchTimers = {} +$(document).on('keyup', '.product-search', function () { + var inp = $(this) + var idx = inp.data('item') + clearTimeout(searchTimers[idx]) + searchTimers[idx] = setTimeout(async function () { + var q = inp.val().trim() + var div = $('#search_' + idx) + if (q.length < 2) { div.hide().empty(); return } + + try { + var resp = await fetch('/api/products/search?q=' + encodeURIComponent(q)) + var results = await resp.json() + if (results.error) { showDbError(results.error); div.hide().empty(); return } + div.empty() + if (results.length === 0) { + div.html( + '
No results
' + + '
[+ Create new product]
' + ).show() + positionSearchResults(inp, div) + return + } + results.forEach(function (r) { + var meta = escHtml(r.upc) + + (r.cert_code ? ' / ' + escHtml(r.cert_code) : '') + + (r.size ? ' - ' + escHtml(r.size) : '') + + (r.cost ? ' ($' + r.cost + ')' : '') + $('
') + .html('
' + escHtml(r.description) + '
' + + '
' + meta + '
') + .data('product', r) + .on('click', function () { applyProduct(idx, $(this).data('product')); div.hide().empty() }) + .appendTo(div) + }) + div.show() + positionSearchResults(inp, div) + } catch (e) { console.error(e) } + }, 300) +}) + +// The dropdown is position:fixed so the scrolling table cannot clip it, +// which means its coordinates have to be set against the input each time. +function positionSearchResults(inp, div) { + var r = inp[0].getBoundingClientRect() + var w = div.outerWidth() + var left = Math.max(6, Math.min(r.left, window.innerWidth - w - 10)) + var top = r.bottom + 2 + if (top + div.outerHeight() > window.innerHeight - 6) { + top = Math.max(6, r.top - div.outerHeight() - 2) + } + div.css({ left: left + 'px', top: top + 'px' }) +} + +$(document).on('click', '.search-toggle', function () { + $(this).hide() + var td = $(this).closest('td') + td.find('.search-inline-wrap').show() + td.find('.product-search').focus() +}) + +$(document).on('click', '.btn-search-cancel', function () { + var td = $(this).closest('td') + td.find('.search-results').hide().empty() + td.find('.product-search').val('') + td.find('.search-inline-wrap').hide() + td.find('.search-toggle').show() +}) + +$(document).on('click', '.cand-item', function () { + var i = $(this).data('item') + var c = (rowCandidates[i] || [])[$(this).data('cand')] + if (c) applyProduct(i, c) +}) + +function applyProduct(i, p) { + var row = $('tr[data-index="' + i + '"]') + row.attr('data-status', 'exact') + + var matchedDesc = p.description || '' + var parsedDesc = row.attr('data-parsed-desc') || '' + row.attr('data-matched-desc', matchedDesc) + row.attr('data-matched-upc', p.upc || '') + row.attr('data-matched-cert', p.cert_code || '') + row.attr('data-matched-size', p.size || '') + row.attr('data-matched-uom', p.uom || 'EA') + + row.find('.item-upc').val(p.upc || '') + row.find('.upc-matched').text(p.upc || '') + row.find('.upc-parsed-val').hide() + row.find('.item-desc').val(matchedDesc || parsedDesc) + row.find('.item-cert').val(p.cert_code || '') + row.find('.item-size').val(p.size || '') + row.find('.item-uom').val(p.uom || 'EA') + row.find('.item-pack').val(p.pack != null ? p.pack : '') + row.find('.retail-val').val(p.retail != null ? p.retail : '') + recalcMargin(row) + + row.find('.match-badge').text('selected').attr('class', 'match-badge badge-exact') + row.find('.btn-confirm').remove() + row.find('.cand-list').remove() + row.find('.search-results').hide().empty() + row.find('.product-search').val('') + row.find('.search-inline-wrap').hide() + row.find('.search-toggle').show() + + row.find('.desc-toggle-row').remove() + row.find('.item-desc').after(buildDescToggle(i, parsedDesc, matchedDesc, matchedDesc ? 'matched' : 'parsed')) + if ($('#poSelect').val() && poCompareData) applyPoComparison() + refreshReviewSummary() + queuePendingStateSave('editing') +} + +function acceptRow(row, skipSave) { + row.attr('data-status', 'confirmed') + row.find('.match-badge').first().text('accepted').attr('class', 'match-badge badge-exact') + row.find('.btn-confirm').remove() + row.find('.cand-list').remove() + if (!skipSave) { refreshReviewSummary(); queuePendingStateSave('editing') } +} + +function acceptAllRows() { + $('#lineItemsBody tr').each(function () { + if ($(this).attr('data-status') === 'unmatched') return + acceptRow($(this), true) + }) + refreshReviewSummary() + queuePendingStateSave('editing') +} + +$(document).on('click', '.btn-confirm', function () { + var idx = $(this).data('index') + acceptRow($('tr[data-index="' + idx + '"]')) +}) + +$(document).on('click', '.btn-remove-row', function () { + $('tr[data-index="' + $(this).data('index') + '"]').remove() + if ($('#poSelect').val() && poCompareData) applyPoComparison() + renderLineMarkers() + refreshReviewSummary() + queuePendingStateSave('editing') +}) + +$(document).on('click', '.btn-desc-src', function () { + var btn = $(this) + var i = btn.data('item') + var source = btn.data('source') + var row = $('tr[data-index="' + i + '"]') + var desc = source === 'parsed' ? row.attr('data-parsed-desc') : row.attr('data-matched-desc') + if (!desc) return // never blank the box by switching to a side that has nothing + row.find('.item-desc').val(desc) + row.find('.desc-toggle-row .btn-desc-src').removeClass('active') + btn.addClass('active') + queuePendingStateSave('editing') +}) + +$(document).on('click', '.search-result-new', function () { + var i = $(this).data('item') + $(this).closest('.search-results').hide().empty() + openNewProductModal(i) +}) + +$(document).on('click', '.btn-new-item', function () { + openNewProductModal($(this).data('item')) +}) + +// ------------------------------------------------------- +// Parsed vs matched - always reachable from the title bar so you can +// check what came off the invoice against the product it was matched to. +// ------------------------------------------------------- +function openParsedModal() { + renderParsedComparison() + $('#parsedModal').show() +} + +function closeParsedModal() { + $('#parsedModal').hide() +} + +function renderParsedComparison() { + var diffOnly = $('#chkDiffOnly').is(':checked') + + $('#compareHeaderBody').html( + headerCompareRow('Vendor', stripHint($('#hintVendor').text()), $('#vendorSelect option:selected').text()) + + headerCompareRow('Invoice #', stripHint($('#hintInvoiceNumber').text()), $('#invoiceNumber').val()) + + headerCompareRow('Invoice date', stripHint($('#hintInvoiceDate').text()), $('#invoiceDate').val()) + + headerCompareRow('Total', stripHint($('#hintInvoiceTotal').text()).replace(/\s*\(.*\)$/, ''), $('#invoiceTotal').val()) + ) + + var html = '' + var shown = 0 + $('#lineItemsBody tr').each(function (n) { + var row = $(this) + var p = { + desc: row.attr('data-parsed-desc') || '', + upc: row.attr('data-parsed-upc') || '', + cert: row.attr('data-parsed-cert') || '', + size: row.attr('data-parsed-size') || '', + uom: row.attr('data-parsed-uom') || '' + } + var m = { + desc: row.attr('data-matched-desc') || '', + upc: row.attr('data-matched-upc') || '', + cert: row.attr('data-matched-cert') || '', + size: row.attr('data-matched-size') || '', + uom: row.attr('data-matched-uom') || '' + } + var diff = { + desc: !sameText(p.desc, m.desc), + upc: normalizeUpc(p.upc) !== normalizeUpc(m.upc), + cert: !sameText(p.cert, m.cert), + size: !sameText(p.size, m.size), + uom: !sameText(p.uom, m.uom) + } + var anyDiff = diff.desc || diff.upc || diff.cert || diff.size || diff.uom + if (diffOnly && !anyDiff) return + + shown++ + var badge = row.find('.match-badge').first() + html += '' + + '' + (n + 1) + '' + + '' + compareCell(p, {}) + '' + + '' + compareCell(m, diff) + '' + + '' + + escHtml(badge.text() || 'unmatched') + '' + + '' + }) + + if (!shown) { + html = '' + + (diffOnly ? 'Every line matched the product file exactly.' : 'No line items on this draft.') + + '' + } + $('#compareBody').html(html) +} + +function headerCompareRow(label, parsed, current) { + var diff = !sameText(parsed, current) + return '' + escHtml(label) + '' + + '' + cmpVal(parsed, false) + '' + + '' + cmpVal(current, diff && parsed !== '') + '' +} + +// Meta line under the description: only the pieces that exist. +function compareCell(v, diff) { + var meta = [] + if (v.upc) meta.push(span(v.upc, diff.upc)) + if (v.cert) meta.push('cert ' + span(v.cert, diff.cert)) + if (v.size) meta.push(span(v.size, diff.size)) + if (v.uom) meta.push(span(v.uom, diff.uom)) + return '
' + (v.desc ? span(v.desc, diff.desc) : '(none)') + '
' + + (meta.length ? '
' + meta.join(' · ') + '
' : '') +} + +function span(text, isDiff) { + return isDiff ? '' + escHtml(text) + '' : escHtml(text) +} + +function cmpVal(v, isDiff) { + if (!v) return '(none)' + return span(v, isDiff) +} + +function sameText(a, b) { + return String(a || '').trim().toLowerCase() === String(b || '').trim().toLowerCase() +} + +// Hints render as "scanned: X" on a fresh parse and bare on a restored draft. +function stripHint(s) { + return String(s || '').replace(/^scanned:\s*/i, '').trim() +} + +$(document).on('change', '#chkDiffOnly', renderParsedComparison) + +$(document).on('click', '#compareBody tr[data-row]', function () { + var row = $('#lineItemsBody tr[data-index="' + $(this).attr('data-row') + '"]') + if (!row.length) return + closeParsedModal() + row[0].scrollIntoView({ behavior: 'smooth', block: 'center' }) + row.removeClass('row-flash') + void row[0].offsetWidth + row.addClass('row-flash') +}) + +// ------------------------------------------------------- +// New Product modal +// ------------------------------------------------------- +var _newProductTargetRow = null + +function openNewProductModal(rowIndex) { + _newProductTargetRow = (rowIndex !== undefined && rowIndex !== null) ? rowIndex : null + $('#np_upc').val('') + $('#np_desc').val('') + $('#np_price').val('') + $('#np_dept').val('') + $('#np_tax').val('0') + $('#np_fs').val('0') + $('#np_flagsNote').text('from department') + $('#np_cost').val('') + $('#np_pack').val('') + $('#np_size').val('') + $('#np_code').val('') + $('#newProductStatus').text('').hide() + + // Seed from the row that asked for it - the invoice already read most of this. + if (_newProductTargetRow !== null) { + var row = $('tr[data-index="' + _newProductTargetRow + '"]') + if (row.length) { + $('#np_upc').val(row.attr('data-parsed-upc') || row.find('.item-upc').val() || '') + $('#np_desc').val((row.find('.item-desc').val() || '').slice(0, 30)) + $('#np_code').val(row.find('.item-cert').val() || '') + $('#np_size').val((row.find('.item-size').val() || '').slice(0, 9)) + $('#np_pack').val(row.find('.item-pack').val() || '') + var caseCost = parseFloat(row.find('.item-cost').val()) + var pack = parseFloat(row.find('.item-pack').val()) + if (!isNaN(caseCost)) { + if (isNaN(pack) || pack <= 0) pack = 1 + $('#np_cost').val((caseCost / pack).toFixed(2)) + } + } + } + + $('#newProductModal').show() + $('#np_upc').focus() +} + +function closeNewProductModal() { + $('#newProductModal').hide() + _newProductTargetRow = null +} + +// dept_no -> { tax, fs } from posbdat..Departments; picking a department +// prefills the item's tax and food stamp flags the same way the POS does. +var deptFlags = {} + +async function loadDepartments() { + try { + var resp = await fetch('/api/departments') + var depts = await resp.json() + if (depts.error) throw new Error(depts.error) + var sel = $('#np_dept') + sel.empty().append('') + deptFlags = {} + depts.forEach(function (d) { + deptFlags[String(d.id)] = { tax: d.tax ? 1 : 0, fs: d.fs ? 1 : 0 } + sel.append('') + }) + } catch (e) { + showDbError('Department list failed to load: ' + e.message) + } +} + +$(document).on('change', '#np_dept', function () { + var f = deptFlags[String($(this).val())] + if (!f) { $('#np_flagsNote').text('from department'); return } + $('#np_tax').val(String(f.tax)) + $('#np_fs').val(String(f.fs)) + $('#np_flagsNote').text('prefilled from department') +}) + +async function handleCreateProduct() { + var upc = $('#np_upc').val().trim() + var desc = $('#np_desc').val().trim() + var price = $('#np_price').val().trim() + if (!upc || !desc || !price) { + $('#newProductStatus').text('UPC, Description, and Price are required.').css('color', '#ff8888').show() + return + } + if (!$('#np_dept').val()) { + $('#newProductStatus').text('Department is required - it sets tax, food stamp and age checking.').css('color', '#ff8888').show() + return + } + // "2/5.00" = 2 for $5.00; anything else must be a plain amount. + if (!/^\d+\s*\/\s*\d+(\.\d+)?$/.test(price) && !/^\d+(\.\d+)?$/.test(price)) { + $('#newProductStatus').text('Price must be an amount like 2.99, or a group price like 2/5.00.').css('color', '#ff8888').show() + return + } + + var fd = new URLSearchParams() + fd.append('upc', upc) + fd.append('description', desc) + fd.append('normal_price', price) + fd.append('dept_id', $('#np_dept').val()) + fd.append('cost', $('#np_cost').val()) + fd.append('item_code', $('#np_code').val()) + fd.append('tax', $('#np_tax').val()) + fd.append('foodstamp', $('#np_fs').val()) + fd.append('pack', $('#np_pack').val()) + fd.append('size', $('#np_size').val()) + + $('#newProductStatus').text('Creating...').css('color', '#ffee88').show() + try { + var resp = await fetch('/api/newproduct', { method: 'POST', body: fd }) + var result = await resp.json() + if (result.success) { + $('#newProductStatus').text('Created: ' + result.description + ' (' + result.upc + ')').css('color', '#88ff88').show() + if (_newProductTargetRow !== null) { + applyProduct(_newProductTargetRow, { + upc: result.upc, + description: result.description, + cert_code: $('#np_code').val() || '', + size: $('#np_size').val() || '', + uom: 'EA', + pack: $('#np_pack').val() !== '' ? parseFloat($('#np_pack').val()) : null, + retail: result.retail != null ? result.retail : null + }) + setTimeout(closeNewProductModal, 800) + } + } else { + $('#newProductStatus').text('Error: ' + result.error).css('color', '#ff8888').show() + } + } catch (e) { + $('#newProductStatus').text('Error: ' + e.message).css('color', '#ff8888').show() + } +} + +// ------------------------------------------------------- +// Pending draft state +// ------------------------------------------------------- +function getQueryParam(name) { + var params = new URLSearchParams(window.location.search) + return params.get(name) +} + +function queuePendingStateSave(stage) { + if (!pendingId) return + clearTimeout(pendingSaveTimer) + pendingSaveTimer = setTimeout(function () { + savePendingState(stage || 'editing').catch(function (e) { + console.warn('Pending state save failed:', e) + }) + }, 700) +} + +async function savePendingState(stage) { + if (!pendingId) return + var state = collectPendingState(stage || 'editing') + var resp = await fetch('/api/pending/' + encodeURIComponent(pendingId) + '/state', { + method: 'POST', + body: JSON.stringify(state), + headers: { 'Content-Type': 'application/json; charset=utf-8' } + }) + var result = await resp.json() + if (!result.success) throw new Error(result.error || 'state save failed') +} + +function collectPendingState(stage) { + var snap = collectDraftSnapshot() + return { + version: 1, + id: pendingId, + stage: stage || 'editing', + created_at: pendingCreated || new Date().toISOString(), + updated_at: new Date().toISOString(), + source: pendingFileInfo || {}, + parse_mode: _resumedParseMode || '', + ocr: { + text: $('#ocrText').val() || '', + method: $('#ocrMethod').text() || '', + line_map: pdfLineMap || null + }, + header: snap.header, + po_compare_data: snap.po_compare_data, + line_items: snap.line_items, + llm: lastLlmResult || null, + defaults: pendingDefaultState || null + } +} + +var _resumedParseMode = '' + +function collectDraftSnapshot() { + var selectedVendor = $('#vendorSelect option:selected') + return { + header: { + vendor_id: $('#vendorSelect').val() || '', + vendor_name: selectedVendor.length ? selectedVendor.text() : '', + vendor_hint: $('#hintVendor').text() || '', + vendor_badge_text: $('#vendorBadge').is(':visible') ? $('#vendorBadge').text() : '', + vendor_badge_class: $('#vendorBadge').is(':visible') ? $('#vendorBadge').attr('class') : '', + invoice_number: $('#invoiceNumber').val() || '', + invoice_date: $('#invoiceDate').val() || '', + po_number: $('#poSelect').val() || '', + invoice_total: $('#invoiceTotal').val() || '', + total_label: totalLabel || '', + total_page: totalPage || '' + }, + po_compare_data: poCompareData || null, + line_items: collectPendingLineItems() + } +} + +function collectPendingLineItems() { + var items = [] + $('#lineItemsBody tr').each(function () { + var row = $(this) + var badge = row.find('.match-badge').first() + items.push({ + upc: row.find('.item-upc').val() || '', + desc: row.find('.item-desc').val() || '', + cert: row.find('.item-cert').val() || '', + qty: row.find('.item-qty').val() || '', + cost: row.find('.item-cost').val() || '', + pack: row.find('.item-pack').val() || '', + retail: row.find('.retail-val').val() || '', + total: row.find('.item-total').val() || '', + size: row.find('.item-size').val() || '', + uom: row.find('.item-uom').val() || '', + source_line: row.attr('data-source-line') || '', + status: row.attr('data-status') || '', + parsed_desc: row.attr('data-parsed-desc') || '', + parsed_upc: row.attr('data-parsed-upc') || '', + parsed_cert: row.attr('data-parsed-cert') || '', + parsed_size: row.attr('data-parsed-size') || '', + parsed_pack: row.attr('data-parsed-pack') || '', + parsed_uom: row.attr('data-parsed-uom') || '', + matched_desc: row.attr('data-matched-desc') || '', + matched_upc: row.attr('data-matched-upc') || '', + matched_cert: row.attr('data-matched-cert') || '', + matched_size: row.attr('data-matched-size') || '', + matched_uom: row.attr('data-matched-uom') || '', + match_badge_text: badge.text() || '', + match_badge_class: badge.attr('class') || '', + po_status: row.attr('data-po-status') || '' + }) + }) + return items +} + +// ------------------------------------------------------- +// Resume a pending draft +// ------------------------------------------------------- +async function resumePending(id) { + try { + showBusy('Loading invoice...') + setStatus('Loading pending invoice...', 'loading') + var resp = await fetch('/api/pending/' + encodeURIComponent(id)) + if (!resp.ok) throw new Error('pending load returned ' + resp.status) + var state = await resp.json() + if (state.error && !state.stage) throw new Error(state.error) + + pendingId = state.id || id + pendingCreated = state.created_at || new Date().toISOString() + pendingFileInfo = state.source || {} + pendingDefaultState = state.defaults || null + lastLlmResult = state.llm || null + _resumedParseMode = state.parse_mode || '' + + try { + await renderPendingSourcePreview(pendingId) + } catch (previewErr) { + console.warn('Pending source preview failed:', previewErr) + $('#uploadPrompt').text('Pending source file could not be previewed.').show() + } + restorePendingState(state) + + if (state.stage === 'parsed' && state.llm && state.llm.parsed) { + // Background worker did the slow LLM step; finish with the fast match pass. + await matchAndPopulateFromParsed(state.llm.parsed) + await savePendingState('review') + setStatus('Parsed invoice loaded.', 'success') + } else if (state.stage === 'queued' || state.stage === 'processing') { + setStatus('This invoice is ' + state.stage + ' for background parsing - this screen updates automatically when it finishes.', 'loading') + } else if (state.stage === 'error') { + setStatus('Background parsing failed: ' + (state.error || 'unknown error') + + '. Use Scan Invoice to retry.', 'error') + } else { + setStatus('Pending invoice loaded.', 'success') + } + } catch (e) { + setStatus('Could not load pending invoice: ' + e.message, 'error') + console.error(e) + } finally { + hideBusy() + } +} + +async function matchAndPopulateFromParsed(invoiceData) { + var count = (invoiceData.line_items || []).length + showBusy('Matching ' + count + ' line item' + (count === 1 ? '' : 's') + ' against the product file...') + try { + var matchData = await callMatch(invoiceData) + populateForm(invoiceData, matchData) + $('#reviewSection').css('display', 'flex') + } finally { + hideBusy() + } +} + +// Full-panel busy overlay. Opening a parsed invoice does real work (matching +// every line item), and without this the review pane just sits blank. +function showBusy(msg) { + var el = $('#busyOverlay') + if (!el.length) { + el = $('
' + + '
' + + '
').appendTo('body') + } + el.find('.busy-msg').text(msg || 'Working...') + el.css('display', 'flex') +} + +function hideBusy() { + $('#busyOverlay').hide() +} + +// Preview pages come pre-rendered from the server (PDFium) - no PDF.js. +async function renderPendingSourcePreview(id) { + var resp = await fetch('/api/pending/' + encodeURIComponent(id) + '/preview') + var info = await resp.json() + if (info.error) throw new Error(info.error) + + var wrapper = document.getElementById('canvasWrapper') + $(wrapper).find('.page-wrap').remove() + $('#uploadPrompt').hide() + + for (var p = 1; p <= (info.pages || 1); p++) { + var pageWrap = document.createElement('div') + pageWrap.className = 'page-wrap' + pageWrap.setAttribute('data-page', p) + var img = document.createElement('img') + img.src = '/api/pending/' + encodeURIComponent(id) + '/preview/' + p + '?ts=' + Date.now() + pageWrap.appendChild(img) + wrapper.appendChild(pageWrap) + } + applyPreviewZoom() + $('#scanSection').css('display', 'flex') +} + +function restorePendingState(state) { + var h = state.header || {} + pdfLineMap = (state.ocr && state.ocr.line_map && state.ocr.line_map.length) ? state.ocr.line_map : null + $('#ocrText').val('') + $('#ocrMethod').text('') + $('#vendorBadge').hide().text('') + $('#hintVendor,#hintInvoiceNumber,#hintInvoiceDate,#hintInvoiceTotal').hide().text('') + // A vendor or PO missing from the dropdown (list failed to load, PO + // outside the TOP 500) would otherwise reset to blank and the next + // autosave would write that blank back into the draft. + ensureOption($('#vendorSelect'), h.vendor_id, h.vendor_name) + ensureOption($('#poSelect'), h.po_number, 'PO #' + h.po_number) + $('#vendorSelect').val(h.vendor_id || '') + $('#invoiceNumber').val(h.invoice_number || '') + $('#invoiceDate').val(h.invoice_date || '') + $('#invoiceTotal').val(h.invoice_total || '') + $('#poSelect').val(h.po_number || '') + totalLabel = h.total_label || '' + totalPage = h.total_page || '' + + if (h.vendor_badge_text) { + $('#vendorBadge').text(h.vendor_badge_text).attr('class', h.vendor_badge_class || 'match-badge badge-none').show() + } + if (h.vendor_hint) $('#hintVendor').text(h.vendor_hint).show() + if (h.invoice_number) $('#hintInvoiceNumber').text(h.invoice_number).show() + if (h.invoice_date) $('#hintInvoiceDate').text(h.invoice_date).show() + if (h.invoice_total) $('#hintInvoiceTotal').text(h.invoice_total + totalHintSuffix()).show() + + if (state.ocr && state.ocr.text) { + $('#ocrText').val(state.ocr.text) + $('#ocrMethod').text(state.ocr.method || 'from pending') + } + + restorePendingRows(state.line_items || []) + recalcAllMargins() + poCompareData = state.po_compare_data || null + if ((state.line_items || []).length) { + $('#reviewSection').css('display', 'flex') + if ($('#poSelect').val() && poCompareData) applyPoComparison() + renderLineMarkers() + } + refreshReviewSummary() +} + +function ensureOption(sel, value, label) { + if (!value) return + var exists = sel.find('option').filter(function () { return this.value === String(value) }).length + if (!exists) sel.append('') +} + +function restorePendingRows(items) { + var tbody = $('#lineItemsBody') + tbody.empty() + items.forEach(function (saved, i) { + var status = saved.status || 'unmatched' + var matchLabel = (status === 'unmatched' || status === 'fuzzy') ? status : 'exact' + var item = { + upc: saved.upc || '', + description: saved.desc || '', + product_code: saved.cert || '', + quantity: saved.qty || '', + unit_price: saved.cost || '', + total: saved.total || '', + size: saved.size || '', + unit_of_measure: saved.uom || 'EA', + pack: saved.parsed_pack || '', + source_line: saved.source_line || '' + } + var match = { + upc: saved.upc || '', + description: saved.desc || '', + cert_code: saved.cert || '', + size: saved.size || '', + uom: saved.uom || 'EA', + pack: saved.pack !== '' && saved.pack != null ? saved.pack : null, + retail: saved.retail !== '' && saved.retail != null ? saved.retail : null, + match_label: matchLabel + } + tbody.append(buildRow(i, item, match)) + var row = tbody.find('tr[data-index="' + i + '"]') + row.attr('data-status', status) + row.attr('data-parsed-desc', saved.parsed_desc || saved.desc || '') + row.attr('data-parsed-upc', saved.parsed_upc || saved.upc || '') + row.attr('data-parsed-cert', saved.parsed_cert || saved.cert || '') + row.attr('data-parsed-size', saved.parsed_size || saved.size || '') + row.attr('data-parsed-uom', saved.parsed_uom || saved.uom || '') + row.attr('data-matched-desc', saved.matched_desc || saved.desc || '') + row.attr('data-matched-upc', saved.matched_upc || saved.upc || '') + row.attr('data-matched-cert', saved.matched_cert || saved.cert || '') + row.attr('data-matched-size', saved.matched_size || saved.size || '') + row.attr('data-matched-uom', saved.matched_uom || saved.uom || '') + if (saved.match_badge_text) row.find('.match-badge').first().text(saved.match_badge_text) + if (saved.match_badge_class) row.find('.match-badge').first().attr('class', saved.match_badge_class) + if (status !== 'unmatched' && status !== 'fuzzy') row.find('.btn-confirm').remove() + }) + nextRowIndex = items.length +} + +async function handleDeleteDraft() { + if (!pendingId) return + if (!confirm('Delete this pending draft? This removes the saved draft and source file.')) return + + var id = pendingId + clearTimeout(pendingSaveTimer) + pendingId = null + try { + setStatus('Deleting draft...', 'loading') + 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') + window.location.href = 'pending.html' + } catch (e) { + pendingId = id + setStatus('Delete failed: ' + e.message, 'error') + } +} + +async function handleRestoreDefaults() { + if (!pendingDefaultState) { + alert('No saved defaults are available for this draft yet.') + return + } + if (!confirm('Restore the original parsed and matched values for this draft?')) return + + var restored = { + header: pendingDefaultState.header || {}, + line_items: pendingDefaultState.line_items || [], + po_compare_data: pendingDefaultState.po_compare_data || null, + ocr: { + text: $('#ocrText').val() || '', + method: $('#ocrMethod').text() || '', + line_map: pdfLineMap + } + } + restorePendingState(restored) + if (pendingId) { + try { + await savePendingState('editing') + } catch (e) { + console.warn('Pending state save failed after restore:', e) + } + } + setStatus('Defaults restored.', 'success') +} + +// ------------------------------------------------------- +// Save +// ------------------------------------------------------- +async function handleSave() { + var vendorId = $('#vendorSelect').val() + if (!vendorId) { alert('Please select a vendor before saving.'); return } + if (!$('#invoiceNumber').val().trim()) { alert('Please enter an invoice number before saving.'); return } + if (!$('#invoiceDate').val()) { alert('Please enter an invoice date before saving.'); return } + + var unconfirmed = [] + $('#lineItemsBody tr').each(function (idx) { + var s = $(this).attr('data-status') + if (s === 'unmatched' || s === 'fuzzy') unconfirmed.push(idx + 1) + }) + if (unconfirmed.length) { + alert('Row(s) ' + unconfirmed.join(', ') + ' must be matched to an existing product before saving.') + return + } + + var fd = new URLSearchParams() + fd.append('vendor_id', vendorId) + fd.append('invoice_number', $('#invoiceNumber').val()) + fd.append('invoice_date', $('#invoiceDate').val()) + fd.append('po_number', $('#poSelect').val()) + fd.append('vendor_total', $('#invoiceTotal').val()) + fd.append('pending_id', pendingId || '') + + var rows = $('#lineItemsBody tr') + fd.append('item_count', rows.length) + rows.each(function (i) { + var row = $(this) + fd.append('item_' + i + '_upc', row.find('.item-upc').val()) + fd.append('item_' + i + '_desc', row.find('.item-desc').val()) + fd.append('item_' + i + '_cert', row.find('.item-cert').val()) + fd.append('item_' + i + '_qty', row.find('.item-qty').val()) + fd.append('item_' + i + '_cost', row.find('.item-cost').val()) + fd.append('item_' + i + '_total', row.find('.item-total').val()) + fd.append('item_' + i + '_size', row.find('.item-size').val()) + fd.append('item_' + i + '_uom', row.find('.item-uom').val()) + // The server divides the case cost by this pack to get the unit cost + // it compares against the product file, so send the pack on screen. + fd.append('item_' + i + '_pack', row.find('.item-pack').val()) + }) + + setStatus('Saving...', 'loading') + try { + // The server archives the source file inside the save transaction. + var resp = await fetch('/api/save', { method: 'POST', body: fd }) + var result = await resp.json() + if (result.success) { + var msg = 'Saved successfully' + (result.po_number ? '. PO: ' + result.po_number : '') + if (result.batch_no) { + msg += '. Cost batch ' + result.batch_no + ': ' + result.batch_items + ' item' + + (result.batch_items === 1 ? '' : 's') + if (result.batch_flagged) msg += ', ' + result.batch_flagged + ' below target margin' + } + setStatus(msg, 'success') + if (pendingId) { + // Stop the debounced autosave first: firing after the delete + // would recreate the draft. + clearTimeout(pendingSaveTimer) + var doneId = pendingId + pendingId = null + try { await fetch('/api/pending/' + encodeURIComponent(doneId), { method: 'DELETE' }) } catch (e) {} + } + // Long enough to read the batch number before the page moves on. + setTimeout(function () { window.location.href = 'pending.html' }, result.batch_no ? 3000 : 900) + } else { + setStatus('Save failed: ' + result.error, 'error') + } + } catch (e) { + setStatus('Save failed: ' + e.message, 'error') + } +} + +// ------------------------------------------------------- +// Utilities +// ------------------------------------------------------- +function setStatus(msg, type) { + var el = isDashboard ? $('#dashStatusBar') : $('#statusBar') + el.text(msg).removeClass('status-loading status-error status-success') + if (type && msg) el.addClass('status-' + type).show() + else el.hide() +} + +// Persistent banner for database failures. Separate from the status bar, +// which later steps overwrite, so a dead SQL connection stays visible. +function showDbError(msg) { + var el = $('#dbWarning') + if (!el.length) el = $('
').insertAfter('h1') + el.text(msg).show() +} + +async function readJsonResponse(resp, label) { + var raw = await resp.text() + if (!resp.ok) { + throw new Error(label + ' returned ' + resp.status + (raw ? ': ' + raw.slice(0, 300) : '')) + } + try { + return JSON.parse(raw) + } catch (e) { + throw new Error(label + ' returned non-JSON: ' + raw.slice(0, 300)) + } +} + +function setHint(id, val, suffix) { + var el = $('#' + id) + if (val != null && val !== '') el.text('scanned: ' + val + (suffix || '')).show() + else el.hide() +} + +function escHtml(s) { + return String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} diff --git a/wwwroot/js/jquery-3.5.1.min.js b/wwwroot/js/jquery-3.5.1.min.js new file mode 100644 index 0000000..b061403 --- /dev/null +++ b/wwwroot/js/jquery-3.5.1.min.js @@ -0,0 +1,2 @@ +/*! jQuery v3.5.1 | (c) JS Foundation and other contributors | jquery.org/license */ +!function(e,t){"use strict";"object"==typeof module&&"object"==typeof module.exports?module.exports=e.document?t(e,!0):function(e){if(!e.document)throw new Error("jQuery requires a window with a document");return t(e)}:t(e)}("undefined"!=typeof window?window:this,function(C,e){"use strict";var t=[],r=Object.getPrototypeOf,s=t.slice,g=t.flat?function(e){return t.flat.call(e)}:function(e){return t.concat.apply([],e)},u=t.push,i=t.indexOf,n={},o=n.toString,v=n.hasOwnProperty,a=v.toString,l=a.call(Object),y={},m=function(e){return"function"==typeof e&&"number"!=typeof e.nodeType},x=function(e){return null!=e&&e===e.window},E=C.document,c={type:!0,src:!0,nonce:!0,noModule:!0};function b(e,t,n){var r,i,o=(n=n||E).createElement("script");if(o.text=e,t)for(r in c)(i=t[r]||t.getAttribute&&t.getAttribute(r))&&o.setAttribute(r,i);n.head.appendChild(o).parentNode.removeChild(o)}function w(e){return null==e?e+"":"object"==typeof e||"function"==typeof e?n[o.call(e)]||"object":typeof e}var f="3.5.1",S=function(e,t){return new S.fn.init(e,t)};function p(e){var t=!!e&&"length"in e&&e.length,n=w(e);return!m(e)&&!x(e)&&("array"===n||0===t||"number"==typeof t&&0+~]|"+M+")"+M+"*"),U=new RegExp(M+"|>"),X=new RegExp(F),V=new RegExp("^"+I+"$"),G={ID:new RegExp("^#("+I+")"),CLASS:new RegExp("^\\.("+I+")"),TAG:new RegExp("^("+I+"|[*])"),ATTR:new RegExp("^"+W),PSEUDO:new RegExp("^"+F),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+M+"*(even|odd|(([+-]|)(\\d*)n|)"+M+"*(?:([+-]|)"+M+"*(\\d+)|))"+M+"*\\)|)","i"),bool:new RegExp("^(?:"+R+")$","i"),needsContext:new RegExp("^"+M+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+M+"*((?:-\\d)?\\d*)"+M+"*\\)|)(?=[^-]|$)","i")},Y=/HTML$/i,Q=/^(?:input|select|textarea|button)$/i,J=/^h\d$/i,K=/^[^{]+\{\s*\[native \w/,Z=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,ee=/[+~]/,te=new RegExp("\\\\[\\da-fA-F]{1,6}"+M+"?|\\\\([^\\r\\n\\f])","g"),ne=function(e,t){var n="0x"+e.slice(1)-65536;return t||(n<0?String.fromCharCode(n+65536):String.fromCharCode(n>>10|55296,1023&n|56320))},re=/([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g,ie=function(e,t){return t?"\0"===e?"\ufffd":e.slice(0,-1)+"\\"+e.charCodeAt(e.length-1).toString(16)+" ":"\\"+e},oe=function(){T()},ae=be(function(e){return!0===e.disabled&&"fieldset"===e.nodeName.toLowerCase()},{dir:"parentNode",next:"legend"});try{H.apply(t=O.call(p.childNodes),p.childNodes),t[p.childNodes.length].nodeType}catch(e){H={apply:t.length?function(e,t){L.apply(e,O.call(t))}:function(e,t){var n=e.length,r=0;while(e[n++]=t[r++]);e.length=n-1}}}function se(t,e,n,r){var i,o,a,s,u,l,c,f=e&&e.ownerDocument,p=e?e.nodeType:9;if(n=n||[],"string"!=typeof t||!t||1!==p&&9!==p&&11!==p)return n;if(!r&&(T(e),e=e||C,E)){if(11!==p&&(u=Z.exec(t)))if(i=u[1]){if(9===p){if(!(a=e.getElementById(i)))return n;if(a.id===i)return n.push(a),n}else if(f&&(a=f.getElementById(i))&&y(e,a)&&a.id===i)return n.push(a),n}else{if(u[2])return H.apply(n,e.getElementsByTagName(t)),n;if((i=u[3])&&d.getElementsByClassName&&e.getElementsByClassName)return H.apply(n,e.getElementsByClassName(i)),n}if(d.qsa&&!N[t+" "]&&(!v||!v.test(t))&&(1!==p||"object"!==e.nodeName.toLowerCase())){if(c=t,f=e,1===p&&(U.test(t)||z.test(t))){(f=ee.test(t)&&ye(e.parentNode)||e)===e&&d.scope||((s=e.getAttribute("id"))?s=s.replace(re,ie):e.setAttribute("id",s=S)),o=(l=h(t)).length;while(o--)l[o]=(s?"#"+s:":scope")+" "+xe(l[o]);c=l.join(",")}try{return H.apply(n,f.querySelectorAll(c)),n}catch(e){N(t,!0)}finally{s===S&&e.removeAttribute("id")}}}return g(t.replace($,"$1"),e,n,r)}function ue(){var r=[];return function e(t,n){return r.push(t+" ")>b.cacheLength&&delete e[r.shift()],e[t+" "]=n}}function le(e){return e[S]=!0,e}function ce(e){var t=C.createElement("fieldset");try{return!!e(t)}catch(e){return!1}finally{t.parentNode&&t.parentNode.removeChild(t),t=null}}function fe(e,t){var n=e.split("|"),r=n.length;while(r--)b.attrHandle[n[r]]=t}function pe(e,t){var n=t&&e,r=n&&1===e.nodeType&&1===t.nodeType&&e.sourceIndex-t.sourceIndex;if(r)return r;if(n)while(n=n.nextSibling)if(n===t)return-1;return e?1:-1}function de(t){return function(e){return"input"===e.nodeName.toLowerCase()&&e.type===t}}function he(n){return function(e){var t=e.nodeName.toLowerCase();return("input"===t||"button"===t)&&e.type===n}}function ge(t){return function(e){return"form"in e?e.parentNode&&!1===e.disabled?"label"in e?"label"in e.parentNode?e.parentNode.disabled===t:e.disabled===t:e.isDisabled===t||e.isDisabled!==!t&&ae(e)===t:e.disabled===t:"label"in e&&e.disabled===t}}function ve(a){return le(function(o){return o=+o,le(function(e,t){var n,r=a([],e.length,o),i=r.length;while(i--)e[n=r[i]]&&(e[n]=!(t[n]=e[n]))})})}function ye(e){return e&&"undefined"!=typeof e.getElementsByTagName&&e}for(e in d=se.support={},i=se.isXML=function(e){var t=e.namespaceURI,n=(e.ownerDocument||e).documentElement;return!Y.test(t||n&&n.nodeName||"HTML")},T=se.setDocument=function(e){var t,n,r=e?e.ownerDocument||e:p;return r!=C&&9===r.nodeType&&r.documentElement&&(a=(C=r).documentElement,E=!i(C),p!=C&&(n=C.defaultView)&&n.top!==n&&(n.addEventListener?n.addEventListener("unload",oe,!1):n.attachEvent&&n.attachEvent("onunload",oe)),d.scope=ce(function(e){return a.appendChild(e).appendChild(C.createElement("div")),"undefined"!=typeof e.querySelectorAll&&!e.querySelectorAll(":scope fieldset div").length}),d.attributes=ce(function(e){return e.className="i",!e.getAttribute("className")}),d.getElementsByTagName=ce(function(e){return e.appendChild(C.createComment("")),!e.getElementsByTagName("*").length}),d.getElementsByClassName=K.test(C.getElementsByClassName),d.getById=ce(function(e){return a.appendChild(e).id=S,!C.getElementsByName||!C.getElementsByName(S).length}),d.getById?(b.filter.ID=function(e){var t=e.replace(te,ne);return function(e){return e.getAttribute("id")===t}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n=t.getElementById(e);return n?[n]:[]}}):(b.filter.ID=function(e){var n=e.replace(te,ne);return function(e){var t="undefined"!=typeof e.getAttributeNode&&e.getAttributeNode("id");return t&&t.value===n}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n,r,i,o=t.getElementById(e);if(o){if((n=o.getAttributeNode("id"))&&n.value===e)return[o];i=t.getElementsByName(e),r=0;while(o=i[r++])if((n=o.getAttributeNode("id"))&&n.value===e)return[o]}return[]}}),b.find.TAG=d.getElementsByTagName?function(e,t){return"undefined"!=typeof t.getElementsByTagName?t.getElementsByTagName(e):d.qsa?t.querySelectorAll(e):void 0}:function(e,t){var n,r=[],i=0,o=t.getElementsByTagName(e);if("*"===e){while(n=o[i++])1===n.nodeType&&r.push(n);return r}return o},b.find.CLASS=d.getElementsByClassName&&function(e,t){if("undefined"!=typeof t.getElementsByClassName&&E)return t.getElementsByClassName(e)},s=[],v=[],(d.qsa=K.test(C.querySelectorAll))&&(ce(function(e){var t;a.appendChild(e).innerHTML="",e.querySelectorAll("[msallowcapture^='']").length&&v.push("[*^$]="+M+"*(?:''|\"\")"),e.querySelectorAll("[selected]").length||v.push("\\["+M+"*(?:value|"+R+")"),e.querySelectorAll("[id~="+S+"-]").length||v.push("~="),(t=C.createElement("input")).setAttribute("name",""),e.appendChild(t),e.querySelectorAll("[name='']").length||v.push("\\["+M+"*name"+M+"*="+M+"*(?:''|\"\")"),e.querySelectorAll(":checked").length||v.push(":checked"),e.querySelectorAll("a#"+S+"+*").length||v.push(".#.+[+~]"),e.querySelectorAll("\\\f"),v.push("[\\r\\n\\f]")}),ce(function(e){e.innerHTML="";var t=C.createElement("input");t.setAttribute("type","hidden"),e.appendChild(t).setAttribute("name","D"),e.querySelectorAll("[name=d]").length&&v.push("name"+M+"*[*^$|!~]?="),2!==e.querySelectorAll(":enabled").length&&v.push(":enabled",":disabled"),a.appendChild(e).disabled=!0,2!==e.querySelectorAll(":disabled").length&&v.push(":enabled",":disabled"),e.querySelectorAll("*,:x"),v.push(",.*:")})),(d.matchesSelector=K.test(c=a.matches||a.webkitMatchesSelector||a.mozMatchesSelector||a.oMatchesSelector||a.msMatchesSelector))&&ce(function(e){d.disconnectedMatch=c.call(e,"*"),c.call(e,"[s!='']:x"),s.push("!=",F)}),v=v.length&&new RegExp(v.join("|")),s=s.length&&new RegExp(s.join("|")),t=K.test(a.compareDocumentPosition),y=t||K.test(a.contains)?function(e,t){var n=9===e.nodeType?e.documentElement:e,r=t&&t.parentNode;return e===r||!(!r||1!==r.nodeType||!(n.contains?n.contains(r):e.compareDocumentPosition&&16&e.compareDocumentPosition(r)))}:function(e,t){if(t)while(t=t.parentNode)if(t===e)return!0;return!1},D=t?function(e,t){if(e===t)return l=!0,0;var n=!e.compareDocumentPosition-!t.compareDocumentPosition;return n||(1&(n=(e.ownerDocument||e)==(t.ownerDocument||t)?e.compareDocumentPosition(t):1)||!d.sortDetached&&t.compareDocumentPosition(e)===n?e==C||e.ownerDocument==p&&y(p,e)?-1:t==C||t.ownerDocument==p&&y(p,t)?1:u?P(u,e)-P(u,t):0:4&n?-1:1)}:function(e,t){if(e===t)return l=!0,0;var n,r=0,i=e.parentNode,o=t.parentNode,a=[e],s=[t];if(!i||!o)return e==C?-1:t==C?1:i?-1:o?1:u?P(u,e)-P(u,t):0;if(i===o)return pe(e,t);n=e;while(n=n.parentNode)a.unshift(n);n=t;while(n=n.parentNode)s.unshift(n);while(a[r]===s[r])r++;return r?pe(a[r],s[r]):a[r]==p?-1:s[r]==p?1:0}),C},se.matches=function(e,t){return se(e,null,null,t)},se.matchesSelector=function(e,t){if(T(e),d.matchesSelector&&E&&!N[t+" "]&&(!s||!s.test(t))&&(!v||!v.test(t)))try{var n=c.call(e,t);if(n||d.disconnectedMatch||e.document&&11!==e.document.nodeType)return n}catch(e){N(t,!0)}return 0":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(te,ne),e[3]=(e[3]||e[4]||e[5]||"").replace(te,ne),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||se.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&se.error(e[0]),e},PSEUDO:function(e){var t,n=!e[6]&&e[2];return G.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||"":n&&X.test(n)&&(t=h(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(te,ne).toLowerCase();return"*"===e?function(){return!0}:function(e){return e.nodeName&&e.nodeName.toLowerCase()===t}},CLASS:function(e){var t=m[e+" "];return t||(t=new RegExp("(^|"+M+")"+e+"("+M+"|$)"))&&m(e,function(e){return t.test("string"==typeof e.className&&e.className||"undefined"!=typeof e.getAttribute&&e.getAttribute("class")||"")})},ATTR:function(n,r,i){return function(e){var t=se.attr(e,n);return null==t?"!="===r:!r||(t+="","="===r?t===i:"!="===r?t!==i:"^="===r?i&&0===t.indexOf(i):"*="===r?i&&-1:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;function D(e,n,r){return m(n)?S.grep(e,function(e,t){return!!n.call(e,t,e)!==r}):n.nodeType?S.grep(e,function(e){return e===n!==r}):"string"!=typeof n?S.grep(e,function(e){return-1)[^>]*|#([\w-]+))$/;(S.fn.init=function(e,t,n){var r,i;if(!e)return this;if(n=n||j,"string"==typeof e){if(!(r="<"===e[0]&&">"===e[e.length-1]&&3<=e.length?[null,e,null]:q.exec(e))||!r[1]&&t)return!t||t.jquery?(t||n).find(e):this.constructor(t).find(e);if(r[1]){if(t=t instanceof S?t[0]:t,S.merge(this,S.parseHTML(r[1],t&&t.nodeType?t.ownerDocument||t:E,!0)),N.test(r[1])&&S.isPlainObject(t))for(r in t)m(this[r])?this[r](t[r]):this.attr(r,t[r]);return this}return(i=E.getElementById(r[2]))&&(this[0]=i,this.length=1),this}return e.nodeType?(this[0]=e,this.length=1,this):m(e)?void 0!==n.ready?n.ready(e):e(S):S.makeArray(e,this)}).prototype=S.fn,j=S(E);var L=/^(?:parents|prev(?:Until|All))/,H={children:!0,contents:!0,next:!0,prev:!0};function O(e,t){while((e=e[t])&&1!==e.nodeType);return e}S.fn.extend({has:function(e){var t=S(e,this),n=t.length;return this.filter(function(){for(var e=0;e\x20\t\r\n\f]*)/i,he=/^$|^module$|\/(?:java|ecma)script/i;ce=E.createDocumentFragment().appendChild(E.createElement("div")),(fe=E.createElement("input")).setAttribute("type","radio"),fe.setAttribute("checked","checked"),fe.setAttribute("name","t"),ce.appendChild(fe),y.checkClone=ce.cloneNode(!0).cloneNode(!0).lastChild.checked,ce.innerHTML="",y.noCloneChecked=!!ce.cloneNode(!0).lastChild.defaultValue,ce.innerHTML="",y.option=!!ce.lastChild;var ge={thead:[1,"","
"],col:[2,"","
"],tr:[2,"","
"],td:[3,"","
"],_default:[0,"",""]};function ve(e,t){var n;return n="undefined"!=typeof e.getElementsByTagName?e.getElementsByTagName(t||"*"):"undefined"!=typeof e.querySelectorAll?e.querySelectorAll(t||"*"):[],void 0===t||t&&A(e,t)?S.merge([e],n):n}function ye(e,t){for(var n=0,r=e.length;n",""]);var me=/<|&#?\w+;/;function xe(e,t,n,r,i){for(var o,a,s,u,l,c,f=t.createDocumentFragment(),p=[],d=0,h=e.length;d\s*$/g;function qe(e,t){return A(e,"table")&&A(11!==t.nodeType?t:t.firstChild,"tr")&&S(e).children("tbody")[0]||e}function Le(e){return e.type=(null!==e.getAttribute("type"))+"/"+e.type,e}function He(e){return"true/"===(e.type||"").slice(0,5)?e.type=e.type.slice(5):e.removeAttribute("type"),e}function Oe(e,t){var n,r,i,o,a,s;if(1===t.nodeType){if(Y.hasData(e)&&(s=Y.get(e).events))for(i in Y.remove(t,"handle events"),s)for(n=0,r=s[i].length;n").attr(n.scriptAttrs||{}).prop({charset:n.scriptCharset,src:n.url}).on("load error",i=function(e){r.remove(),i=null,e&&t("error"===e.type?404:200,e.type)}),E.head.appendChild(r[0])},abort:function(){i&&i()}}});var Ut,Xt=[],Vt=/(=)\?(?=&|$)|\?\?/;S.ajaxSetup({jsonp:"callback",jsonpCallback:function(){var e=Xt.pop()||S.expando+"_"+Ct.guid++;return this[e]=!0,e}}),S.ajaxPrefilter("json jsonp",function(e,t,n){var r,i,o,a=!1!==e.jsonp&&(Vt.test(e.url)?"url":"string"==typeof e.data&&0===(e.contentType||"").indexOf("application/x-www-form-urlencoded")&&Vt.test(e.data)&&"data");if(a||"jsonp"===e.dataTypes[0])return r=e.jsonpCallback=m(e.jsonpCallback)?e.jsonpCallback():e.jsonpCallback,a?e[a]=e[a].replace(Vt,"$1"+r):!1!==e.jsonp&&(e.url+=(Et.test(e.url)?"&":"?")+e.jsonp+"="+r),e.converters["script json"]=function(){return o||S.error(r+" was not called"),o[0]},e.dataTypes[0]="json",i=C[r],C[r]=function(){o=arguments},n.always(function(){void 0===i?S(C).removeProp(r):C[r]=i,e[r]&&(e.jsonpCallback=t.jsonpCallback,Xt.push(r)),o&&m(i)&&i(o[0]),o=i=void 0}),"script"}),y.createHTMLDocument=((Ut=E.implementation.createHTMLDocument("").body).innerHTML="
",2===Ut.childNodes.length),S.parseHTML=function(e,t,n){return"string"!=typeof e?[]:("boolean"==typeof t&&(n=t,t=!1),t||(y.createHTMLDocument?((r=(t=E.implementation.createHTMLDocument("")).createElement("base")).href=E.location.href,t.head.appendChild(r)):t=E),o=!n&&[],(i=N.exec(e))?[t.createElement(i[1])]:(i=xe([e],t,o),o&&o.length&&S(o).remove(),S.merge([],i.childNodes)));var r,i,o},S.fn.load=function(e,t,n){var r,i,o,a=this,s=e.indexOf(" ");return-1").append(S.parseHTML(e)).find(r):e)}).always(n&&function(e,t){a.each(function(){n.apply(this,o||[e.responseText,t,e])})}),this},S.expr.pseudos.animated=function(t){return S.grep(S.timers,function(e){return t===e.elem}).length},S.offset={setOffset:function(e,t,n){var r,i,o,a,s,u,l=S.css(e,"position"),c=S(e),f={};"static"===l&&(e.style.position="relative"),s=c.offset(),o=S.css(e,"top"),u=S.css(e,"left"),("absolute"===l||"fixed"===l)&&-1<(o+u).indexOf("auto")?(a=(r=c.position()).top,i=r.left):(a=parseFloat(o)||0,i=parseFloat(u)||0),m(t)&&(t=t.call(e,n,S.extend({},s))),null!=t.top&&(f.top=t.top-s.top+a),null!=t.left&&(f.left=t.left-s.left+i),"using"in t?t.using.call(e,f):("number"==typeof f.top&&(f.top+="px"),"number"==typeof f.left&&(f.left+="px"),c.css(f))}},S.fn.extend({offset:function(t){if(arguments.length)return void 0===t?this:this.each(function(e){S.offset.setOffset(this,t,e)});var e,n,r=this[0];return r?r.getClientRects().length?(e=r.getBoundingClientRect(),n=r.ownerDocument.defaultView,{top:e.top+n.pageYOffset,left:e.left+n.pageXOffset}):{top:0,left:0}:void 0},position:function(){if(this[0]){var e,t,n,r=this[0],i={top:0,left:0};if("fixed"===S.css(r,"position"))t=r.getBoundingClientRect();else{t=this.offset(),n=r.ownerDocument,e=r.offsetParent||n.documentElement;while(e&&(e===n.body||e===n.documentElement)&&"static"===S.css(e,"position"))e=e.parentNode;e&&e!==r&&1===e.nodeType&&((i=S(e).offset()).top+=S.css(e,"borderTopWidth",!0),i.left+=S.css(e,"borderLeftWidth",!0))}return{top:t.top-i.top-S.css(r,"marginTop",!0),left:t.left-i.left-S.css(r,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){var e=this.offsetParent;while(e&&"static"===S.css(e,"position"))e=e.offsetParent;return e||re})}}),S.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(t,i){var o="pageYOffset"===i;S.fn[t]=function(e){return $(this,function(e,t,n){var r;if(x(e)?r=e:9===e.nodeType&&(r=e.defaultView),void 0===n)return r?r[i]:e[t];r?r.scrollTo(o?r.pageXOffset:n,o?n:r.pageYOffset):e[t]=n},t,e,arguments.length)}}),S.each(["top","left"],function(e,n){S.cssHooks[n]=$e(y.pixelPosition,function(e,t){if(t)return t=Be(e,n),Me.test(t)?S(e).position()[n]+"px":t})}),S.each({Height:"height",Width:"width"},function(a,s){S.each({padding:"inner"+a,content:s,"":"outer"+a},function(r,o){S.fn[o]=function(e,t){var n=arguments.length&&(r||"boolean"!=typeof e),i=r||(!0===e||!0===t?"margin":"border");return $(this,function(e,t,n){var r;return x(e)?0===o.indexOf("outer")?e["inner"+a]:e.document.documentElement["client"+a]:9===e.nodeType?(r=e.documentElement,Math.max(e.body["scroll"+a],r["scroll"+a],e.body["offset"+a],r["offset"+a],r["client"+a])):void 0===n?S.css(e,t,i):S.style(e,t,n,i)},s,n?e:void 0,n)}})}),S.each(["ajaxStart","ajaxStop","ajaxComplete","ajaxError","ajaxSuccess","ajaxSend"],function(e,t){S.fn[t]=function(e){return this.on(t,e)}}),S.fn.extend({bind:function(e,t,n){return this.on(e,null,t,n)},unbind:function(e,t){return this.off(e,null,t)},delegate:function(e,t,n,r){return this.on(t,e,n,r)},undelegate:function(e,t,n){return 1===arguments.length?this.off(e,"**"):this.off(t,e||"**",n)},hover:function(e,t){return this.mouseenter(e).mouseleave(t||e)}}),S.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(e,n){S.fn[n]=function(e,t){return 0 + + + +Pending Invoices + + + + + +

+ Pending Invoices + + New Invoice + Pending + Archive +

+ +
+
+ + Parses every invoice still marked queued (and retries errored ones) in the background. +
+
+
No pending invoices.
+ + + + + + + + + + + + + + + + + +
+ + + + +