commit c9054e34b3bb0e2137bdb81f86427925af2d7b49 Author: Adal Date: Wed Sep 16 13:09:19 2026 -0700 Initial Commit 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 0000000..5bfd962 Binary files /dev/null and b/assets/InvoiceParser.ico differ 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.
+ + + + + + + + + + + + + + + + + +
+ + + + +