test(materials per day): work on getting this running better

This commit is contained in:
2025-11-19 18:42:58 -06:00
parent 9aa0b31278
commit a30eebf5d3
9 changed files with 373 additions and 186 deletions

View File

@@ -9,11 +9,14 @@ import {
import { db } from "../../../../../database/dbclient.js"; import { db } from "../../../../../database/dbclient.js";
import { invHistoricalData } from "../../../../../database/schema/historicalINV.js"; import { invHistoricalData } from "../../../../../database/schema/historicalINV.js";
import { tryCatch } from "../../../../globalUtils/tryCatch.js"; import { tryCatch } from "../../../../globalUtils/tryCatch.js";
import { createLog } from "../../../logger/logger.js";
import { query } from "../../../sqlServer/prodSqlServer.js"; import { query } from "../../../sqlServer/prodSqlServer.js";
import { import {
materialPerDay, materialPerDay,
materialPurchasesPerDay, materialPurchasesPerDay,
} from "../../../sqlServer/querys/dataMart/materialPerDay.js"; } from "../../../sqlServer/querys/dataMart/materialPerDay.js";
import { singleArticle } from "../../../sqlServer/querys/misc/singleArticle.js";
import { sendEmail } from "../sendMail.js";
import { materialPurchases } from "./materialPurchases.js"; import { materialPurchases } from "./materialPurchases.js";
import { buildInventoryTimeline } from "./materialWithInv.js"; import { buildInventoryTimeline } from "./materialWithInv.js";
@@ -99,43 +102,77 @@ export default async function materialPerDayCheck() {
// purchases // purchases
const { data: p, error: pe } = (await tryCatch( const pOrders = (await materialPurchases({ startDate, endDate })) as any;
query(
materialPurchasesPerDay
.replace("[startDate]", startDate)
.replace("[endDate]", endDate),
"material check",
),
)) as any;
if (error) {
return {
success: false,
message: "Error getting the material data",
error,
};
}
if (!data.success) {
return {
success: false,
message: data.message,
data: [],
};
}
//console.log(pOrders);
const openingInventory: Record<string, number> = {}; const openingInventory: Record<string, number> = {};
const inventoryRows = await getInv(); const inventoryRows = await getInv();
for (const row of inventoryRows) { for (const row of inventoryRows) {
openingInventory[row.article] = Number(row.total_QTY) || 0; openingInventory[row.article] = Number(row.total_QTY) || 0;
} }
const materialsDemand = buildInventoryTimeline(
sumByMaterialAndWeek(data.data) as any,
openingInventory,
pOrders,
);
const { data: av, error: eav } = await tryCatch(
query(singleArticle.replace("[av]", "107"), "single article"),
);
const formattedMaterials = materialsDemand
.filter((n) => n.MaterialHumanReadableId === `${av?.data[0].article}`)
.map((i) => ({
...i,
OpeningInventory: i.OpeningInventory?.toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}),
Purchases: i.Purchases?.toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}),
Consumption: i.Consumption?.toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}),
ClosingInventory: i.ClosingInventory?.toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}),
}));
// send the email stuff
const emailSetup = {
email: "blake.matthes@alpla.com",
subject: `Material Week.`,
template: "materialPerDay",
context: {
items: formattedMaterials,
article: av?.data[0].combined,
},
};
const { data: sentEmail, error: sendEmailError } = await tryCatch(
sendEmail(emailSetup),
);
if (sendEmailError) {
createLog(
"error",
"blocking",
"notify",
"Failed to send email, will try again on next interval",
);
return {
success: false,
message: "Failed to send email, will try again on next interval",
};
}
return { return {
success: true, success: true,
message: "material data", message: "material data",
data: buildInventoryTimeline( data: formattedMaterials,
sumByMaterialAndWeek(data.data) as any,
openingInventory,
),
}; };
} }

View File

@@ -1,4 +1,7 @@
import { formatISO, parseISO, startOfWeek } from "date-fns"; import { formatISO, parseISO, startOfWeek } from "date-fns";
import { tryCatch } from "../../../../globalUtils/tryCatch.js";
import { query } from "../../../sqlServer/prodSqlServer.js";
import { materialPurchasesPerDay } from "../../../sqlServer/querys/dataMart/materialPerDay.js";
function toDate(val: any) { function toDate(val: any) {
if (val instanceof Date) return val; if (val instanceof Date) return val;
@@ -6,20 +9,52 @@ function toDate(val: any) {
return new Date(val); return new Date(val);
} }
export const materialPurchases = async (data: any) => { export const materialPurchases = async ({
startDate,
endDate,
}: {
startDate: string;
endDate: string;
}) => {
/** @type {Record<string, Record<string, number>>} */ /** @type {Record<string, Record<string, number>>} */
const grouped: any = {}; const grouped: any = {};
for (const r of data) { const { data: p, error } = (await tryCatch(
query(
materialPurchasesPerDay
.replace("[startDate]", startDate)
.replace("[endDate]", endDate),
"material check",
),
)) as any;
if (error) {
return {
success: false,
message: "Error getting the material data",
error,
};
}
if (!p.success) {
return {
success: false,
message: p.message,
data: [],
};
}
for (const r of p.data) {
const pOrder = String(r.purhcaseOrder);
const mat = String(r.MaterialHumanReadableId); const mat = String(r.MaterialHumanReadableId);
const d = toDate(r.CalDate); const d = toDate(r.deliveryDate);
const week = formatISO(startOfWeek(d, { weekStartsOn: 1 }), { const week = formatISO(startOfWeek(d, { weekStartsOn: 1 }), {
representation: "date", representation: "date",
}); });
grouped[mat] ??= {}; grouped[mat] ??= {};
grouped[mat][week] ??= 0; grouped[mat][week] ??= 0;
grouped[mat][week] += Number(r.DailyMaterialDemand) || 0; grouped[mat][week] += Number(r.qty) || 0;
} }
const result = []; const result = [];
@@ -29,7 +64,7 @@ export const materialPurchases = async (data: any) => {
result.push({ result.push({
MaterialHumanReadableId: mat, MaterialHumanReadableId: mat,
WeekStart: week, WeekStart: week,
WeeklyDemand: Number(total).toFixed(2), WeeklyPurchase: Number(total).toFixed(2),
}); });
} }
} }

View File

@@ -5,25 +5,51 @@ export const buildInventoryTimeline = (
WeeklyDemand: number; WeeklyDemand: number;
}>, }>,
opening: Record<string, number>, opening: Record<string, number>,
weeklyPurchases?: Array<{
MaterialHumanReadableId: string;
WeekStart: string;
WeeklyPurchase: number;
}>,
) => { ) => {
// group weekly demand by material // group weekly demand by material
const grouped: Record< const groupedDemand: Record<
string, string,
Array<{ WeekStart: string; Demand: number }> Array<{ WeekStart: string; Demand: number }>
> = {}; > = {};
for (const d of weeklyDemand) { for (const d of weeklyDemand) {
const mat = d.MaterialHumanReadableId; const mat = d.MaterialHumanReadableId;
grouped[mat] ??= []; groupedDemand[mat] ??= [];
grouped[mat].push({ groupedDemand[mat].push({
WeekStart: d.WeekStart, WeekStart: d.WeekStart,
Demand: Number(d.WeeklyDemand), Demand: Number(d.WeeklyDemand),
}); });
} }
// sort weeks chronologically per material // group weekly purchases by material
for (const mat of Object.keys(grouped)) { const groupedPurchases: Record<
grouped[mat].sort( string,
Array<{ WeekStart: string; Purchase: number }>
> = {};
if (weeklyPurchases) {
for (const p of weeklyPurchases) {
const mat = p.MaterialHumanReadableId;
groupedPurchases[mat] ??= [];
groupedPurchases[mat].push({
WeekStart: p.WeekStart,
Purchase: Number(p.WeeklyPurchase),
});
}
}
// sort both chronologically
for (const mat of Object.keys(groupedDemand)) {
groupedDemand[mat].sort(
(a, b) =>
new Date(a.WeekStart).getTime() - new Date(b.WeekStart).getTime(),
);
}
for (const mat of Object.keys(groupedPurchases)) {
groupedPurchases[mat].sort(
(a, b) => (a, b) =>
new Date(a.WeekStart).getTime() - new Date(b.WeekStart).getTime(), new Date(a.WeekStart).getTime() - new Date(b.WeekStart).getTime(),
); );
@@ -33,25 +59,30 @@ export const buildInventoryTimeline = (
MaterialHumanReadableId: string; MaterialHumanReadableId: string;
WeekStart: string; WeekStart: string;
OpeningInventory: number; OpeningInventory: number;
Purchases: number;
Consumption: number; Consumption: number;
ClosingInventory: number; ClosingInventory: number;
}> = []; }> = [];
for (const [mat, weeks] of Object.entries(grouped)) { for (const [mat, weeks] of Object.entries(groupedDemand)) {
// get starting inventory from the ERP result
let inv = opening[mat] ?? 0; let inv = opening[mat] ?? 0;
const purchasesForMaterial = groupedPurchases[mat] ?? [];
for (const w of weeks) { for (const week of weeks) {
const week = w.WeekStart; const demand = Number(week.Demand);
const demand = Number(w.Demand); const purchase =
purchasesForMaterial.find((p) => p.WeekStart === week.WeekStart)
?.Purchase ?? 0;
const openingInv = inv; const openingInv = inv;
const closingInv = openingInv - demand; const adjustedInv = openingInv + purchase;
const closingInv = adjustedInv - demand;
result.push({ result.push({
MaterialHumanReadableId: mat, MaterialHumanReadableId: mat,
WeekStart: week, WeekStart: week.WeekStart,
OpeningInventory: Number(openingInv.toFixed(2)), OpeningInventory: Number(openingInv.toFixed(2)),
Purchases: Number(purchase.toFixed(2)),
Consumption: Number(demand.toFixed(2)), Consumption: Number(demand.toFixed(2)),
ClosingInventory: Number(closingInv.toFixed(2)), ClosingInventory: Number(closingInv.toFixed(2)),
}); });

View File

@@ -1,149 +1,152 @@
import { tryCatch } from "../../../globalUtils/tryCatch.js"; import Handlebars from "handlebars";
import { db } from "../../../../database/dbclient.js";
import { settings } from "../../../../database/schema/settings.js";
import nodemailer from "nodemailer";
import type { Transporter } from "nodemailer"; import type { Transporter } from "nodemailer";
import type SMTPTransport from "nodemailer/lib/smtp-transport/index.js"; import nodemailer from "nodemailer";
import type Mail from "nodemailer/lib/mailer/index.js"; import type Mail from "nodemailer/lib/mailer/index.js";
import type { Address } from "nodemailer/lib/mailer/index.js"; import type { Address } from "nodemailer/lib/mailer/index.js";
import type SMTPTransport from "nodemailer/lib/smtp-transport/index.js";
import hbs from "nodemailer-express-handlebars";
import path from "path"; import path from "path";
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
import hbs from "nodemailer-express-handlebars";
import { promisify } from "util"; import { promisify } from "util";
import { createLog } from "../../logger/logger.js"; import { db } from "../../../../database/dbclient.js";
import { settings } from "../../../../database/schema/settings.js";
import { tryCatch } from "../../../globalUtils/tryCatch.js";
import { installed } from "../../../index.js"; import { installed } from "../../../index.js";
import { createLog } from "../../logger/logger.js";
interface HandlebarsMailOptions extends Mail.Options { interface HandlebarsMailOptions extends Mail.Options {
template: string; template: string;
context: Record<string, unknown>; // Use a generic object for context context: Record<string, unknown>; // Use a generic object for context
} }
interface EmailData { interface EmailData {
email: string; email: string;
subject: string; subject: string;
template: string; template: string;
context: []; context: [];
} }
export const sendEmail = async (data: any): Promise<any> => { export const sendEmail = async (data: any): Promise<any> => {
if (!installed) { if (!installed) {
createLog("error", "notify", "notify", "server not installed."); createLog("error", "notify", "notify", "server not installed.");
return; return;
} }
let transporter: Transporter; let transporter: Transporter;
let fromEmail: string | Address; let fromEmail: string | Address;
const { data: settingData, error: settingError } = await tryCatch( const { data: settingData, error: settingError } = await tryCatch(
db.select().from(settings) db.select().from(settings),
); );
if (settingError) { if (settingError) {
return { return {
success: false, success: false,
message: "There was an error getting the settings.", message: "There was an error getting the settings.",
settingError, settingError,
}; };
} }
// get the plantToken // get the plantToken
const server = settingData.filter((n) => n.name === "server"); const server = settingData.filter((n) => n.name === "server");
if ( if (
server[0].value === "localhost" && server[0].value === "localhostx" &&
process.env.EMAIL_USER && process.env.EMAIL_USER &&
process.env.EMAIL_PASSWORD process.env.EMAIL_PASSWORD
) { ) {
transporter = nodemailer.createTransport({ transporter = nodemailer.createTransport({
service: "gmail", service: "gmail",
auth: { host: "smtp.gmail.com",
user: process.env.EMAIL_USER, port: 465,
pass: process.env.EMAIL_PASSWORD, auth: {
}, user: process.env.EMAIL_USER,
//debug: true, pass: process.env.EMAIL_PASSWORD,
}); },
//debug: true,
});
// update the from email // update the from email
fromEmail = process.env.EMAIL_USER; fromEmail = process.env.EMAIL_USER;
} else { } else {
// convert to the correct plant token. // convert to the correct plant token.
const plantToken = settingData.filter((s) => s.name === "plantToken"); const plantToken = settingData.filter((s) => s.name === "plantToken");
let host = `${plantToken[0].value}-smtp.alpla.net`; let host = `${plantToken[0].value}-smtp.alpla.net`;
const testServers = ["test1", "test2", "test3"]; const testServers = ["test1", "test2", "test3"];
if (testServers.includes(plantToken[0].value)) { if (testServers.includes(plantToken[0].value)) {
host = "USMCD1-smtp.alpla.net"; host = "USMCD1-smtp.alpla.net";
} }
if (plantToken[0].value === "usiow2") { if (plantToken[0].value === "usiow2") {
host = "USIOW1-smtp.alpla.net"; host = "USIOW1-smtp.alpla.net";
} }
transporter = nodemailer.createTransport({ transporter = nodemailer.createTransport({
host: host, host: host,
port: 25, port: 25,
rejectUnauthorized: false, rejectUnauthorized: false,
//secure: false, //secure: false,
// auth: { // auth: {
// user: "alplaprod", // user: "alplaprod",
// pass: "obelix", // pass: "obelix",
// }, // },
debug: true, debug: true,
} as SMTPTransport.Options); } as SMTPTransport.Options);
// update the from email // update the from email
fromEmail = `noreply@alpla.com`; fromEmail = `noreply@alpla.com`;
} }
// creating the handlbar options // creating the handlbar options
const viewPath = path.resolve( const viewPath = path.resolve(
path.dirname(fileURLToPath(import.meta.url)), path.dirname(fileURLToPath(import.meta.url)),
"../utils/views/" "../utils/views/",
); );
const handlebarOptions = { const handlebarOptions = {
viewEngine: { viewEngine: {
extname: ".hbs", extname: ".hbs",
//layoutsDir: path.resolve(viewPath, "layouts"), // Path to layouts directory //layoutsDir: path.resolve(viewPath, "layouts"), // Path to layouts directory
defaultLayout: "", // Specify the default layout defaultLayout: "", // Specify the default layout
partialsDir: viewPath, partialsDir: viewPath,
}, },
viewPath: viewPath, viewPath: viewPath,
extName: ".hbs", // File extension for Handlebars templates extName: ".hbs", // File extension for Handlebars templates
}; };
transporter.use("compile", hbs(handlebarOptions)); transporter.use("compile", hbs(handlebarOptions));
const mailOptions: HandlebarsMailOptions = { const mailOptions: HandlebarsMailOptions = {
from: fromEmail, from: fromEmail,
to: data.email, to: data.email,
subject: data.subject, subject: data.subject,
//text: "You will have a reset token here and only have 30min to click the link before it expires.", //text: "You will have a reset token here and only have 30min to click the link before it expires.",
//html: emailTemplate("BlakesTest", "This is an example with css"), //html: emailTemplate("BlakesTest", "This is an example with css"),
template: data.template, // Name of the Handlebars template (e.g., 'welcome.hbs') template: data.template, // Name of the Handlebars template (e.g., 'welcome.hbs')
context: data.context, context: data.context,
}; };
// now verify and send the email // now verify and send the email
const sendMailPromise = promisify(transporter.sendMail).bind(transporter); const sendMailPromise = promisify(transporter.sendMail).bind(transporter);
try { try {
// Send email and await the result // Send email and await the result
const info = await sendMailPromise(mailOptions); const info = await sendMailPromise(mailOptions);
createLog( createLog(
"info", "info",
"notification", "notification",
"system", "system",
`Email was sent to: ${data.email}` `Email was sent to: ${data.email}`,
); );
return { success: true, message: "Email sent.", data: info }; return { success: true, message: "Email sent.", data: info };
} catch (err) { } catch (err) {
console.log(err); console.log(err);
createLog( createLog(
"error", "error",
"notification", "notification",
"system", "system",
`Error sending Email: ${JSON.stringify(err)}` `Error sending Email: ${JSON.stringify(err)}`,
); );
return { success: false, message: "Error sending email.", error: err }; return { success: false, message: "Error sending email.", error: err };
} }
}; };

View File

@@ -0,0 +1,28 @@
// an external way to creating logs
import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
import { apiHit } from "../../../globalUtils/apiHits.js";
import { responses } from "../../../globalUtils/routeDefs/responses.js";
import materialPerDayCheck from "../controller/materialsPerDay/materialPerDay.js";
import runTiImport from "../controller/notifications/tiIntergration.js";
const app = new OpenAPIHono({ strict: false });
app.openapi(
createRoute({
tags: ["notify"],
summary: "Manually trigger TI intergrations.",
method: "get",
path: "/materialPerDayCheck",
//middleware: authMiddleware,
responses: responses(),
}),
async (c) => {
apiHit(c, { endpoint: "/materialPerDayCheck" });
const tiImport = await materialPerDayCheck();
return c.json({
success: tiImport?.success,
message: tiImport?.message,
});
},
);
export default app;

View File

@@ -1,28 +1,27 @@
// an external way to creating logs // an external way to creating logs
import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi"; import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
import { responses } from "../../../globalUtils/routeDefs/responses.js";
import runTiImport from "../controller/notifications/tiIntergration.js";
import { apiHit } from "../../../globalUtils/apiHits.js"; import { apiHit } from "../../../globalUtils/apiHits.js";
import { responses } from "../../../globalUtils/routeDefs/responses.js";
import runTiImport from "../controller/notifications/tiIntergration.js";
const app = new OpenAPIHono({ strict: false }); const app = new OpenAPIHono({ strict: false });
app.openapi( app.openapi(
createRoute({ createRoute({
tags: ["notify"], tags: ["notify"],
summary: "Manually trigger TI intergrations.", summary: "Manually trigger TI intergrations.",
method: "get", method: "get",
path: "/tiTrigger", path: "/tiTrigger",
//middleware: authMiddleware, //middleware: authMiddleware,
responses: responses(), responses: responses(),
}), }),
async (c) => { async (c) => {
apiHit(c, { endpoint: "/tiTrigger" }); apiHit(c, { endpoint: "/tiTrigger" });
const tiImport = await runTiImport(); const tiImport = await runTiImport();
return c.json({ return c.json({
success: tiImport?.success, success: tiImport?.success,
message: tiImport?.message, message: tiImport?.message,
}); });
} },
); );
export default app; export default app;

View File

@@ -0,0 +1,44 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{{!-- <link rel="stylesheet" href="styles/styles.css" /> --}}
{{> styles}}
</head>
<body>
<p>All,</p>
<p>Below is the weekly material demand for Article: {{article}}.</p>
<table >
<thead>
<tr>
<th>AV</th>
<th>Week</th>
<th>Opening Inventory</th>
<th>Purchases</th>
<th>Consumption</th>
<th>Closing Inventory</th>
</tr>
</thead>
<tbody>
{{#each items}}
<tr>
<td>{{MaterialHumanReadableId}}</td>
<td>{{WeekStart}}</td>
<td>{{OpeningInventory}}</td>
<td>{{Purchases}}</td>
<td>{{Consumption}}</td>
<td>{{ClosingInventory}}</td>
</tr>
{{/each}}
</tbody>
</table>
<div>
<p>Thank you,</p>
<p>LST Team</p>
</div>
</body>
</html>

View File

@@ -3,7 +3,7 @@
* startdate and end date should be passed over * startdate and end date should be passed over
*/ */
export const materialPerDay = ` export const materialPerDay = `
use [test3_AlplaPROD2.0_Read] use [test1_AlplaPROD2.0_Read]
DECLARE @ShiftStartHour INT = 6 DECLARE @ShiftStartHour INT = 6
declare @startDate nvarchar(max) = '[startDate]' declare @startDate nvarchar(max) = '[startDate]'
@@ -121,7 +121,7 @@ declare @endDate nvarchar(max) = '[endDate]'
SELECT SELECT
[IdBestellung] as purhcaseOrder [IdBestellung] as purhcaseOrder
,[IdArtikelVarianten] ,[IdArtikelVarianten] as MaterialHumanReadableId
,convert(date, [BestellDatum], 120) as orderDate ,convert(date, [BestellDatum], 120) as orderDate
,convert(date, [Lieferdatum], 120) as deliveryDate ,convert(date, [Lieferdatum], 120) as deliveryDate
,[BestellMenge] as qty ,[BestellMenge] as qty

View File

@@ -0,0 +1,10 @@
export const singleArticle = `
use AlplaPROD_test1
select
idartikelvarianten as article,
CONCAT(idartikelvarianten , ' - ' , Alias) as combined
from V_Artikel (nolock)
where idartikelvarianten = '[av]'
`;