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 };
|
|
|
|
|
}
|