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, string vendorId, string invoiceNum) { 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,@vendor,@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); // Only the vendor picked on the review header - no vendor means NULL, not 0. cmd.Parameters.AddWithValue("@vendor", vendorId.Trim() == "" ? DBNull.Value : vendorId.Trim()); 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. var retail = priceMethod == 1 ? groupPrice / groupQty : normalPrice; // Change log. The product already exists, so a failed log row is only warned. try { using var log = conn.CreateCommand(); log.CommandText = $"IF OBJECT_ID(@tbl) IS NOT NULL INSERT INTO {_t.ProductChangesTable} " + "(ChangeType, UPC, Description, Vendor, InvoiceNumber, NewCost, Retail, Pack, EmpNo) " + "VALUES ('NEW', @upc, @desc, @vendor, @inv, @cost, @retail, @pack, @emp)"; log.Parameters.AddWithValue("@tbl", _t.ProductChangesTable); log.Parameters.AddWithValue("@upc", upc); log.Parameters.AddWithValue("@desc", desc); log.Parameters.AddWithValue("@vendor", vendorId.Trim() == "" ? DBNull.Value : vendorId.Trim()); log.Parameters.AddWithValue("@inv", invoiceNum.Trim() == "" ? DBNull.Value : Truncate(invoiceNum.Trim(), 100)); log.Parameters.AddWithValue("@cost", dCost); log.Parameters.AddWithValue("@retail", retail); log.Parameters.AddWithValue("@pack", cmd.Parameters["@pack"].Value); log.Parameters.AddWithValue("@emp", _cfg.ReceiverEmpNo); log.ExecuteNonQuery(); } catch (Exception ex) { Log.Warn("changes", $"new product {upc} not logged: {ex.Message}"); } return new JsonObject { ["success"] = true, ["upc"] = upc, ["description"] = desc, ["retail"] = retail }; } // ---- change log ---- public JsonArray ProductChanges(string dateFrom, string dateTo, string type) { var arr = new JsonArray(); using var conn = _db.Open(); using var cmd = conn.CreateCommand(); var where = " WHERE 1=1"; // Typed DateTime parameters: 'yyyy-MM-dd' text is DATEFORMAT dependent. if (Util.IsIsoDate(dateFrom)) { where += " AND c.ChangeDate >= @dateFrom"; cmd.Parameters.AddWithValue("@dateFrom", DateTime.ParseExact(dateFrom, "yyyy-MM-dd", CultureInfo.InvariantCulture)); } if (Util.IsIsoDate(dateTo)) { where += " AND c.ChangeDate < @dateTo"; cmd.Parameters.AddWithValue("@dateTo", DateTime.ParseExact(dateTo, "yyyy-MM-dd", CultureInfo.InvariantCulture).AddDays(1)); } if (type == "NEW" || type == "COST") { where += " AND c.ChangeType = @type"; cmd.Parameters.AddWithValue("@type", type); } cmd.CommandText = "SELECT TOP 2000 CONVERT(VARCHAR(19), c.ChangeDate, 120) AS ChangeDate, c.ChangeType, c.UPC, c.Description," + " c.Vendor, c.InvoiceNumber, c.PONumber, c.BatchNo, c.OldCost, c.NewCost, c.Retail, c.Pack," + $" ISNULL(v.{_t.VendorNameCol},'') AS VendorName" + $" FROM {_t.ProductChangesTable} c" + $" LEFT JOIN {_t.VendorTable} v ON v.{_t.VendorIdCol} = c.Vendor" + where + " ORDER BY c.ChangeDate DESC, c.ChangeID DESC"; using var r = cmd.ExecuteReader(); while (r.Read()) arr.Add(new JsonObject { ["date"] = Db.GetStr(r, "ChangeDate") ?? "", ["type"] = Db.GetStr(r, "ChangeType") ?? "", ["upc"] = Db.GetStr(r, "UPC") ?? "", ["description"] = Db.GetStr(r, "Description") ?? "", ["vendor"] = Db.GetStr(r, "Vendor") ?? "", ["vendor_name"] = Db.GetStr(r, "VendorName") ?? "", ["invoice_number"] = Db.GetStr(r, "InvoiceNumber") ?? "", ["po_number"] = Db.GetStr(r, "PONumber") ?? "", ["batch_no"] = Db.GetStr(r, "BatchNo") ?? "", ["old_cost"] = Db.GetNum(r, "OldCost"), ["new_cost"] = Db.GetNum(r, "NewCost"), ["retail"] = Db.GetNum(r, "Retail"), ["pack"] = Db.GetNum(r, "Pack") }); return arr; } // ---- 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 }; }