InvoiceParser/src/DataService.cs

696 lines
32 KiB
C#
Raw Normal View History

2026-09-16 20:09:19 +00:00
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,
2026-09-30 18:50:04 +00:00
string scale, string wicable, string pack, string size,
string vendorId, string invoiceNum)
2026-09-16 20:09:19 +00:00
{
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," +
2026-09-30 18:50:04 +00:00
"@dept,NULL,@vendor,@pack,@size,@tax,@foodstamp,@scale," +
2026-09-16 20:09:19 +00:00
"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);
2026-09-30 18:50:04 +00:00
// Only the vendor picked on the review header - no vendor means NULL, not 0.
cmd.Parameters.AddWithValue("@vendor",
vendorId.Trim() == "" ? DBNull.Value : vendorId.Trim());
2026-09-16 20:09:19 +00:00
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.
2026-09-30 18:50:04 +00:00
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}");
}
2026-09-16 20:09:19 +00:00
return new JsonObject
{
["success"] = true,
["upc"] = upc,
["description"] = desc,
2026-09-30 18:50:04 +00:00
["retail"] = retail
2026-09-16 20:09:19 +00:00
};
}
2026-09-30 18:50:04 +00:00
// ---- 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;
}
2026-09-16 20:09:19 +00:00
// ---- archive ----
public JsonArray ArchiveList(string vendor, string inv, string po, string dateFrom,
string dateTo, string amtMin, string amtMax, string product)
{
var arr = new JsonArray();
using var conn = _db.Open();
using var cmd = conn.CreateCommand();
var where = " WHERE 1=1";
if (vendor != "")
{
where += " AND h.Vendor = @vendor";
cmd.Parameters.AddWithValue("@vendor", vendor);
}
if (inv != "")
{
where += " AND h.InvoiceNumber LIKE @inv";
cmd.Parameters.AddWithValue("@inv", "%" + Util.EscapeLike(Truncate(inv, 30)) + "%");
}
if (po != "")
{
where += " AND h.PONumber LIKE @po";
cmd.Parameters.AddWithValue("@po", "%" + Util.EscapeLike(Truncate(po, 30)) + "%");
}
// Typed DateTime parameters: 'yyyy-MM-dd' text is DATEFORMAT dependent.
if (Util.IsIsoDate(dateFrom))
{
where += " AND h.InvoiceDate >= @dateFrom";
cmd.Parameters.AddWithValue("@dateFrom", DateTime.ParseExact(dateFrom, "yyyy-MM-dd", CultureInfo.InvariantCulture));
}
if (Util.IsIsoDate(dateTo))
{
where += " AND h.InvoiceDate < @dateTo";
cmd.Parameters.AddWithValue("@dateTo", DateTime.ParseExact(dateTo, "yyyy-MM-dd", CultureInfo.InvariantCulture).AddDays(1));
}
if (amtMin != "" && double.TryParse(amtMin, NumberStyles.Any, CultureInfo.InvariantCulture, out var mn))
{
where += " AND ISNULL(h.VendorTotal,0) >= @amtMin";
cmd.Parameters.AddWithValue("@amtMin", mn);
}
if (amtMax != "" && double.TryParse(amtMax, NumberStyles.Any, CultureInfo.InvariantCulture, out var mx))
{
where += " AND ISNULL(h.VendorTotal,0) <= @amtMax";
cmd.Parameters.AddWithValue("@amtMax", mx);
}
if (product != "")
{
where += $" AND EXISTS (SELECT 1 FROM {_t.InvoiceDetailTable} d" +
" WHERE d.PONumber = h.PONumber" +
" AND (d.UPC LIKE @product OR d.Cert_code LIKE @product OR d.Description LIKE @product))";
cmd.Parameters.AddWithValue("@product", "%" + Util.EscapeLike(Truncate(product, 80)) + "%");
}
cmd.CommandText =
"SELECT TOP 500 h.InvoiceNumber, CONVERT(VARCHAR(10), h.InvoiceDate, 120) AS InvDate," +
" h.Vendor, ISNULL(h.VendorTotal,0) AS VendorTotal," +
$" ISNULL(v.{_t.VendorNameCol},'Unknown') AS VendorName" +
$" FROM {_t.InvoiceHeaderTable} h" +
$" LEFT JOIN {_t.VendorTable} v ON v.{_t.VendorIdCol} = h.Vendor" +
where +
" ORDER BY h.InvoiceDate DESC, h.InvoiceNumber DESC";
using var r = cmd.ExecuteReader();
while (r.Read())
arr.Add(new JsonObject
{
["invoice_number"] = Db.GetStr(r, "InvoiceNumber") ?? "",
["invoice_date"] = Db.GetStr(r, "InvDate") ?? "",
["vendor"] = Db.GetStr(r, "Vendor") ?? "",
["vendor_total"] = Db.GetNum(r, "VendorTotal"),
["vendor_name"] = Db.GetStr(r, "VendorName") ?? ""
});
return arr;
}
public JsonObject ArchiveDetail(string inv, string vendor)
{
if (inv == "" || vendor == "")
return new JsonObject { ["error"] = "Missing parameters" };
using var conn = _db.Open();
JsonObject header;
using (var cmd = conn.CreateCommand())
{
cmd.CommandText =
"SELECT TOP 1 h.InvoiceNumber, CONVERT(VARCHAR(10), h.InvoiceDate, 120) AS InvDate," +
" ISNULL(h.PONumber,'') AS PONumber, ISNULL(h.VendorTotal,0) AS VendorTotal," +
$" ISNULL(v.{_t.VendorNameCol},'') AS VendorName" +
$" FROM {_t.InvoiceHeaderTable} h" +
$" LEFT JOIN {_t.VendorTable} v ON v.{_t.VendorIdCol} = h.Vendor" +
" WHERE h.InvoiceNumber = @inv AND h.Vendor = @vendor" +
" ORDER BY h.InvoiceDate DESC";
cmd.Parameters.AddWithValue("@inv", inv);
cmd.Parameters.AddWithValue("@vendor", vendor);
using var r = cmd.ExecuteReader();
if (!r.Read()) return new JsonObject { ["error"] = "Invoice not found" };
header = new JsonObject
{
["invoice_number"] = Db.GetStr(r, "InvoiceNumber") ?? "",
["invoice_date"] = Db.GetStr(r, "InvDate") ?? "",
["po_number"] = Db.GetStr(r, "PONumber") ?? "",
["vendor_total"] = Db.GetNum(r, "VendorTotal"),
["vendor_name"] = Db.GetStr(r, "VendorName") ?? ""
};
}
// Line items come back in the order they were entered, the same order
// the review screen shows them in, not sorted by UPC. The detail table
// has no line number column, so its identity column is the insertion
// order; without one there is nothing to recover and UPC is the only
// stable fallback.
var orderBy = DetailOrderColumn(conn);
var items = new JsonArray();
using (var cmd = conn.CreateCommand())
{
cmd.CommandText =
"SELECT d.UPC, d.Cert_code, d.Description, d.Cost, d.Total, d.QuantityCase, d.Size, d.UnitOfMeasure" +
$" FROM {_t.InvoiceDetailTable} d" +
$" JOIN {_t.InvoiceHeaderTable} h ON h.PONumber = d.PONumber" +
$" WHERE h.InvoiceNumber = @inv AND h.Vendor = @vendor ORDER BY d.[{orderBy}]";
cmd.Parameters.AddWithValue("@inv", inv);
cmd.Parameters.AddWithValue("@vendor", vendor);
using var r = cmd.ExecuteReader();
while (r.Read())
items.Add(new JsonObject
{
["upc"] = Db.GetStr(r, "UPC") ?? "",
["cert"] = Db.GetStr(r, "Cert_code") ?? "",
["desc"] = Db.GetStr(r, "Description") ?? "",
["cost"] = Db.GetNum(r, "Cost"),
["total"] = Db.GetNum(r, "Total"),
["qty"] = Db.GetNum(r, "QuantityCase"),
["size"] = Db.GetStr(r, "Size") ?? "",
["uom"] = Db.GetStr(r, "UnitOfMeasure") ?? ""
});
}
header["items"] = items;
header["batch"] = ArchiveBatch(conn, inv, vendor);
return header;
}
// The identity column of InvoiceDetail, discovered once. Installs differ on
// its name (RECID, ID, DetailID...), and hard-coding a guess would break
// every archive detail view on the ones that named it something else.
private string? _detailOrderCol;
private string DetailOrderColumn(SqlConnection conn)
{
if (_detailOrderCol != null) return _detailOrderCol;
try
{
// sys.identity_columns is database-scoped. The detail table lives in
// posinv while the connection is open on posbdat, so the probe has
// to read the catalog view of the table's own database or it always
// comes back empty and every archive falls back to UPC order.
using var cmd = conn.CreateCommand();
cmd.CommandText =
$"SELECT TOP 1 c.name FROM {CatalogPrefix(_t.InvoiceDetailTable)}sys.identity_columns c " +
$"WHERE c.object_id = OBJECT_ID(@t)";
cmd.Parameters.AddWithValue("@t", _t.InvoiceDetailTable);
_detailOrderCol = Convert.ToString(cmd.ExecuteScalar()) ?? "";
}
catch (Exception ex)
{
Log.Warn("archive", "identity column probe failed, ordering by UPC: " + ex.Message);
_detailOrderCol = "";
}
if (_detailOrderCol == "")
{
Log.Info("archive", $"{_t.InvoiceDetailTable} has no identity column - ordering line items by UPC");
_detailOrderCol = "UPC";
}
else Log.Info("archive", $"ordering line items by {_t.InvoiceDetailTable}.{_detailOrderCol}");
return _detailOrderCol;
}
// "posinv.dbo.InvoiceDetail" -> "[posinv].", an unqualified name -> "" so
// the catalog view resolves in the connection's own database.
private static string CatalogPrefix(string qualified)
{
var parts = qualified.Split('.');
return parts.Length >= 3 ? "[" + parts[^3].Trim('[', ']') + "]." : "";
}
// The price/cost batch this invoice produced, if any, plus its rows. Missing
// batch tables are not an error - a store that does not use batches still
// gets its archive.
private JsonNode? ArchiveBatch(SqlConnection conn, string inv, string vendor)
{
if (!_cfg.Batch.Enabled) return null;
try
{
var baseName = _cfg.Batch.NamePrefix + inv;
JsonObject? head = null;
using (var cmd = conn.CreateCommand())
{
// Two vendors can share an invoice number, so a collision is
// parked as BatchNo_1; the description carries the vendor.
cmd.CommandText =
"SELECT TOP 10 BatchNo, Description, CONVERT(VARCHAR(19), Created, 120) AS Created" +
$" FROM {_t.ProductBatchesHeaderTable}" +
" WHERE BatchNo = @exact OR BatchNo LIKE @like" +
" ORDER BY BatchNo";
cmd.Parameters.AddWithValue("@exact", baseName);
cmd.Parameters.AddWithValue("@like", Util.EscapeLike(baseName) + "[_]%");
using var r = cmd.ExecuteReader();
var rows = new List<JsonObject>();
while (r.Read())
rows.Add(new JsonObject
{
["batch_no"] = Db.GetStr(r, "BatchNo") ?? "",
["description"] = Db.GetStr(r, "Description") ?? "",
["created"] = Db.GetStr(r, "Created") ?? ""
});
head = rows.FirstOrDefault(x => (x["description"]?.ToString() ?? "").Contains("vendor " + vendor))
?? rows.FirstOrDefault(x => x["batch_no"]?.ToString() == baseName);
}
if (head == null) return null;
var batchNo = head["batch_no"]!.ToString();
var rowsOut = new JsonArray();
using (var cmd = conn.CreateCommand())
{
// Retail is the shelf price off the product file, not off the
// batch row - the batch only carries the new cost.
cmd.CommandText =
"SELECT b.upc, b.cert_code, b.description, b.cost, b.pack, b.size," +
$" p.cost AS current_cost, {ProductSql.RetailFor("p")}" +
$" FROM {_t.ProductBatchesTable} b" +
$" LEFT JOIN {_t.ProductsTable} p ON p.upc = b.upc" +
" WHERE b.BatchNo = @b ORDER BY b.upc";
cmd.Parameters.AddWithValue("@b", batchNo);
using var r = cmd.ExecuteReader();
while (r.Read())
{
var cost = Db.GetNum(r, "cost");
var retail = Db.GetNum(r, "retail");
rowsOut.Add(new JsonObject
{
["upc"] = Db.GetStr(r, "upc") ?? "",
["cert"] = Db.GetStr(r, "cert_code") ?? "",
["desc"] = Db.GetStr(r, "description") ?? "",
["cost"] = cost,
["current_cost"] = Db.GetNum(r, "current_cost"),
["pack"] = Db.GetNum(r, "pack"),
["size"] = Db.GetStr(r, "size") ?? "",
["retail"] = retail,
["margin"] = retail is > 0 && cost != null
? (retail.Value - cost.Value) / retail.Value * 100
: (double?)null
});
}
}
head["items"] = rowsOut;
head["margin_threshold"] = _cfg.Batch.MarginThreshold;
return head;
}
catch (Exception ex)
{
Log.Warn("archive", $"batch lookup for invoice {inv} failed: " + ex.Message);
return null;
}
}
// Locates the archived source file for an invoice, verifying the DB record exists.
public string? ArchiveFile(string inv, string vendor, out string ext)
{
ext = "";
if (inv == "" || vendor == "") return null;
using (var conn = _db.Open())
using (var cmd = conn.CreateCommand())
{
cmd.CommandText =
$"SELECT TOP 1 InvoiceNumber FROM {_t.InvoiceHeaderTable} " +
"WHERE InvoiceNumber = @inv AND Vendor = @vendor";
cmd.Parameters.AddWithValue("@inv", inv);
cmd.Parameters.AddWithValue("@vendor", vendor);
if (cmd.ExecuteScalar() == null) return null;
}
var baseName = Util.SafeFileName(vendor) + "_" + Util.SafeFileName(inv);
return Util.FindInvoiceFile(_cfg.ArchiveRoot, baseName, out ext);
}
// ---- dashboard stats (pending_stats.asp port) ----
public JsonObject Stats(PendingStore store)
{
int queued = 0, processing = 0, parsed = 0, review = 0, editing = 0,
error = 0, other = 0, total = 0, today = 0, pendingLines = 0;
double pendingValue = 0;
var todayStr = DateTime.UtcNow.ToString("yyyy-MM-dd");
foreach (var item in store.List())
{
var state = store.ReadState(item.Id);
if (state == null) continue;
total++;
switch (state["stage"]?.ToString() ?? "")
{
case "queued": case "queue": queued++; break;
case "processing": processing++; break;
case "parsed": parsed++; break;
case "review": review++; break;
case "editing": editing++; break;
case "error": error++; break;
default: other++; break;
}
var created = state["created_at"]?.ToString() ?? "";
if (created.Length >= 10 && created[..10] == todayStr) today++;
var totalStr = state["header"]?["invoice_total"]?.ToString() ?? "";
if (double.TryParse(totalStr, NumberStyles.Any, CultureInfo.InvariantCulture, out var t))
pendingValue += t;
if (state["line_items"] is JsonArray li) pendingLines += li.Count;
}
int archived = Directory.Exists(_cfg.ArchiveRoot)
? Directory.GetFiles(_cfg.ArchiveRoot).Length : 0;
return new JsonObject
{
["queued"] = queued,
["processing"] = processing,
["parsed"] = parsed,
["review"] = review,
["editing"] = editing,
["error"] = error,
["total"] = total,
["ready"] = parsed,
["in_progress"] = review + editing,
["needs_parse"] = queued + processing,
["uploaded_today"] = today,
["archived"] = archived,
["pending_value"] = pendingValue,
["pending_lines"] = pendingLines
};
}
private static string Truncate(string s, int n) => s.Length <= n ? s : s[..n];
private static JsonObject Fail(string error) =>
new() { ["success"] = false, ["error"] = error };
}