feature/IO-3066-1-scaffolding: Fix some missing PR Notes around PPG

This commit is contained in:
Dave Richer
2025-05-05 10:37:42 -04:00
parent 2953aa420e
commit cdad47b82f
7 changed files with 330 additions and 158 deletions

View File

@@ -2,6 +2,7 @@ import { UUID } from "crypto";
import { parse, TypedQueryDocumentNode } from "graphql";
import { gql } from "graphql-request";
import { AvailableJobSchema } from "../decoder/decoder";
// Define types for the query result and variables
export interface ActiveBodyshopQueryResult {
bodyshops: Array<{
@@ -11,9 +12,8 @@ export interface ActiveBodyshopQueryResult {
convenient_company: string;
}>;
}
// No variables needed for this query
// Transform the string query into a TypedQueryDocumentNode
// No variables needed for this query
export const QUERY_ACTIVE_BODYSHOP_TYPED: TypedQueryDocumentNode<
ActiveBodyshopQueryResult,
Record<never, never>
@@ -34,9 +34,11 @@ export interface MasterdataQueryResult {
key: string;
}>;
}
interface MasterdataQueryVariables {
key: string;
}
export const QUERY_MASTERDATA_TYPED: TypedQueryDocumentNode<
MasterdataQueryResult,
MasterdataQueryVariables
@@ -54,9 +56,11 @@ export interface VehicleQueryResult {
id: UUID;
}>;
}
interface VehicleQueryVariables {
vin: string;
}
export const QUERY_VEHICLE_BY_VIN_TYPED: TypedQueryDocumentNode<
VehicleQueryResult,
VehicleQueryVariables
@@ -73,9 +77,11 @@ export interface QueryJobByClmNoResult {
id: UUID;
}>;
}
export interface QueryJobByClmNoVariables {
clm_no: string;
}
export const QUERY_JOB_BY_CLM_NO_TYPED: TypedQueryDocumentNode<
QueryJobByClmNoResult,
QueryJobByClmNoVariables
@@ -92,9 +98,11 @@ export interface InsertAvailableJobResult {
id: UUID;
}>;
}
export interface InsertAvailableJobVariables {
jobInput: Array<AvailableJobSchema>;
}
export const INSERT_AVAILABLE_JOB_TYPED: TypedQueryDocumentNode<
InsertAvailableJobResult,
InsertAvailableJobVariables
@@ -125,3 +133,140 @@ export const INSERT_AVAILABLE_JOB_TYPED: TypedQueryDocumentNode<
InsertAvailableJobResult,
InsertAvailableJobVariables
>;
// Add PpgData Query
export interface PpgDataQueryResult {
bodyshops_by_pk: {
id: string;
shopname: string;
imexshopid: string;
} | null;
jobs: Array<{
id: string;
ro_number: string;
status: string;
ownr_fn: string;
ownr_ln: string;
ownr_co_nm: string;
v_vin: string;
v_model_yr: string;
v_make_desc: string;
v_model_desc: string;
v_color: string;
plate_no: string;
ins_co_nm: string;
est_ct_fn: string;
est_ct_ln: string;
rate_mapa: number;
rate_lab: number;
job_totals: {
rates?: {
mapa?: {
total?: {
amount?: number;
};
};
};
totals?: {
subtotal?: {
amount?: number;
};
};
};
vehicle: {
v_paint_codes: {
paint_cd1?: string;
};
};
labhrs: {
aggregate: {
sum: {
mod_lb_hrs: number;
};
};
};
larhrs: {
aggregate: {
sum: {
mod_lb_hrs: number;
};
};
};
}>;
}
export interface PpgDataQueryVariables {
today: string;
todayplus5: string;
shopid: string;
}
export const PPG_DATA_QUERY_TYPED: TypedQueryDocumentNode<
PpgDataQueryResult,
PpgDataQueryVariables
> = parse(gql`
query PpgData(
$today: timestamptz!
$todayplus5: timestamptz!
$shopid: uuid!
) {
bodyshops_by_pk(id: $shopid) {
id
shopname
imexshopid
}
jobs(
where: {
_or: [
{
_and: [
{ scheduled_in: { _lte: $todayplus5 } }
{ scheduled_in: { _gte: $today } }
]
}
{ inproduction: { _eq: true } }
]
}
) {
id
ro_number
status
ownr_fn
ownr_ln
ownr_co_nm
v_vin
v_model_yr
v_make_desc
v_model_desc
v_color
plate_no
ins_co_nm
est_ct_fn
est_ct_ln
rate_mapa
rate_lab
job_totals
vehicle {
v_paint_codes
}
labhrs: joblines_aggregate(
where: { mod_lbr_ty: { _neq: "LAR" }, removed: { _eq: false } }
) {
aggregate {
sum {
mod_lb_hrs
}
}
}
larhrs: joblines_aggregate(
where: { mod_lbr_ty: { _eq: "LAR" }, removed: { _eq: false } }
) {
aggregate {
sum {
mod_lb_hrs
}
}
}
}
}
`) as TypedQueryDocumentNode<PpgDataQueryResult, PpgDataQueryVariables>;

View File

@@ -464,27 +464,32 @@ app.whenReady().then(async () => {
if (url.startsWith(`${protocol}://keep-alive`)) {
log.info("Keep-alive protocol received, app is already running.");
// Do nothing if already running
return; // Skip openMainWindow to avoid focusing the window
return;
} else {
openInExplorer(url);
}
} else {
openMainWindow(); // Focus window if no URL
}
// No action taken if no URL is provided
});
//Dynamically load ipcMain handlers once ready.
try {
const module = await import("./ipc/ipcMainConfig");
const { initializeCronTasks } = await import("./ipc/ipcMainConfig");
log.debug("Successfully loaded ipcMainConfig");
// Initialize cron tasks after loading ipcMainConfig
await module.initializeCronTasks();
log.info("Cron tasks initialized successfully");
try {
await initializeCronTasks();
log.info("Cron tasks initialized successfully");
} catch (error) {
log.warn("Non-fatal: Failed to initialize cron tasks", {
...ErrorTypeCheck(error),
});
}
} catch (error) {
log.error("Failed to load ipcMainConfig or initialize cron tasks", {
log.error("Fatal: Failed to load ipcMainConfig", {
...ErrorTypeCheck(error),
});
throw error; // Adjust based on whether the app should continue
}
//Create Tray
@@ -568,15 +573,10 @@ app.whenReady().then(async () => {
app.on("open-url", (event: Electron.Event, url: string) => {
event.preventDefault();
//Don't do anything for now. We just want to open the app.
if (url.startsWith(`${protocol}://keep-alive`)) {
log.info("Keep-alive protocol received.");
if (BrowserWindow.getAllWindows().length === 0) {
isKeepAliveLaunch = true;
openMainWindow(); // Launch app if not running
}
// Do nothing if already running
return; // Skip openMainWindow to avoid focusing the window
// Do nothing, whether app is running or not
return;
} else {
openInExplorer(url);
}

View File

@@ -12,7 +12,6 @@ import {
} from "../watcher/watcher";
import { PaintScaleConfig } from "../../util/types/paintScale";
// Initialize paint scale input configs in store if not set
if (!Store.get("settings.paintScaleInputConfigs")) {
Store.set("settings.paintScaleInputConfigs", []);
@@ -35,8 +34,8 @@ const SettingsWatchedFilePathsAdd = async (): Promise<string[]> => {
if (!result.canceled) {
Store.set(
"settings.filepaths",
_.union(result.filePaths, Store.get("settings.filepaths")),
"settings.filepaths",
_.union(result.filePaths, Store.get("settings.filepaths")),
);
addWatcherPath(result.filePaths);
}
@@ -45,12 +44,12 @@ const SettingsWatchedFilePathsAdd = async (): Promise<string[]> => {
};
const SettingsWatchedFilePathsRemove = async (
_event: IpcMainInvokeEvent,
path: string,
_event: IpcMainInvokeEvent,
path: string,
): Promise<string[]> => {
Store.set(
"settings.filepaths",
_.without(Store.get("settings.filepaths"), path),
"settings.filepaths",
_.without(Store.get("settings.filepaths"), path),
);
removeWatcherPath(path);
return Store.get("settings.filepaths");
@@ -65,16 +64,16 @@ const SettingsWatcherPollingGet = async (): Promise<{
interval: number;
}> => {
const pollingEnabled: { enabled: boolean; interval: number } =
Store.get("settings.polling");
Store.get("settings.polling");
return { enabled: pollingEnabled.enabled, interval: pollingEnabled.interval };
};
const SettingsWatcherPollingSet = async (
_event: IpcMainInvokeEvent,
pollingSettings: {
enabled: boolean;
interval: number;
},
_event: IpcMainInvokeEvent,
pollingSettings: {
enabled: boolean;
interval: number;
},
): Promise<{
enabled: boolean;
interval: number;
@@ -131,11 +130,13 @@ const SettingEmsOutFilePathSet = async (): Promise<string> => {
return (Store.get("settings.emsOutFilePath") as string) || "";
};
const SettingsPaintScaleInputConfigsGet = async (
_event?: IpcMainInvokeEvent,
): Promise<PaintScaleConfig[]> => {
const SettingsPaintScaleInputConfigsGet = (
_event?: IpcMainInvokeEvent,
): PaintScaleConfig[] => {
try {
const configs = Store.get("settings.paintScaleInputConfigs") as PaintScaleConfig[];
const configs = Store.get(
"settings.paintScaleInputConfigs",
) as PaintScaleConfig[];
log.debug("Retrieved paint scale input configs:", configs);
return configs || [];
} catch (error) {
@@ -145,8 +146,8 @@ const SettingsPaintScaleInputConfigsGet = async (
};
const SettingsPaintScaleInputConfigsSet = async (
_event: IpcMainInvokeEvent,
configs: PaintScaleConfig[],
_event: IpcMainInvokeEvent,
configs: PaintScaleConfig[],
): Promise<boolean> => {
try {
Store.set("settings.paintScaleInputConfigs", configs);
@@ -159,7 +160,7 @@ const SettingsPaintScaleInputConfigsSet = async (
};
const SettingsPaintScaleInputPathSet = async (
_event: IpcMainInvokeEvent,
_event: IpcMainInvokeEvent,
): Promise<string | null> => {
try {
const mainWindow = getMainWindow();
@@ -183,11 +184,13 @@ const SettingsPaintScaleInputPathSet = async (
}
};
const SettingsPaintScaleOutputConfigsGet = async (
_event?: IpcMainInvokeEvent,
): Promise<PaintScaleConfig[]> => {
const SettingsPaintScaleOutputConfigsGet = (
_event?: IpcMainInvokeEvent,
): PaintScaleConfig[] => {
try {
const configs = Store.get("settings.paintScaleOutputConfigs") as PaintScaleConfig[];
const configs = Store.get(
"settings.paintScaleOutputConfigs",
) as PaintScaleConfig[];
log.debug("Retrieved paint scale output configs:", configs);
return configs || [];
} catch (error) {
@@ -197,8 +200,8 @@ const SettingsPaintScaleOutputConfigsGet = async (
};
const SettingsPaintScaleOutputConfigsSet = async (
_event: IpcMainInvokeEvent,
configs: PaintScaleConfig[],
_event: IpcMainInvokeEvent,
configs: PaintScaleConfig[],
): Promise<boolean> => {
try {
Store.set("settings.paintScaleOutputConfigs", configs);
@@ -211,7 +214,7 @@ const SettingsPaintScaleOutputConfigsSet = async (
};
const SettingsPaintScaleOutputPathSet = async (
_event: IpcMainInvokeEvent,
_event: IpcMainInvokeEvent,
): Promise<string | null> => {
try {
const mainWindow = getMainWindow();
@@ -251,4 +254,4 @@ export {
SettingsPaintScaleOutputConfigsGet,
SettingsPaintScaleOutputConfigsSet,
SettingsPaintScaleOutputPathSet,
};
};

View File

@@ -5,24 +5,43 @@ import axios from "axios";
import { create } from "xmlbuilder2";
import { parseStringPromise } from "xml2js";
import store from "../../store/store";
import client from "../../graphql/graphql-client";
import client, { getTokenFromRenderer } from "../../graphql/graphql-client";
import { PaintScaleConfig } from "../../../util/types/paintScale";
import dayjs from "dayjs";
import {
PPG_DATA_QUERY_TYPED,
PpgDataQueryResult,
PpgDataQueryVariables,
} from "../../graphql/queries";
// PPG Input Handler
export async function ppgInputHandler(config: PaintScaleConfig): Promise<void> {
try {
log.info(
`Polling input directory for PPG config ${config.id}: ${config.path}`,
);
log.debug(
`Archive dir: ${path.join(config.path!, "archive")}, Error dir: ${path.join(config.path!, "error")}`,
);
// Ensure archive and error directories exist
const archiveDir = path.join(config.path!, "archive");
const errorDir = path.join(config.path!, "error");
await fs.mkdir(archiveDir, { recursive: true });
await fs.mkdir(errorDir, { recursive: true });
try {
await fs.mkdir(archiveDir, { recursive: true });
await fs.mkdir(errorDir, { recursive: true });
log.debug(
`Archive and error directories ensured: ${archiveDir}, ${errorDir}`,
);
} catch (dirError) {
log.error(`Failed to create directories for ${config.path}:`, dirError);
throw dirError;
}
// Check for files
const files = await fs.readdir(config.path!);
log.debug(`Found ${files.length} files in ${config.path}:`, files);
for (const file of files) {
// Only process XML files
if (!file.toLowerCase().endsWith(".xml")) {
@@ -30,8 +49,13 @@ export async function ppgInputHandler(config: PaintScaleConfig): Promise<void> {
}
const filePath = path.join(config.path!, file);
const stats = await fs.stat(filePath);
if (!stats.isFile()) {
try {
const stats = await fs.stat(filePath);
if (!stats.isFile()) {
continue;
}
} catch (statError) {
log.warn(`Failed to stat file ${filePath}:`, statError);
continue;
}
@@ -46,40 +70,61 @@ export async function ppgInputHandler(config: PaintScaleConfig): Promise<void> {
}
// Validate XML structure
let xmlContent : BlobPart;
let xmlContent: BlobPart;
try {
xmlContent = await fs.readFile(filePath, "utf8");
await parseStringPromise(xmlContent);
log.debug(`Successfully validated XML for ${filePath}`);
} catch (error) {
log.error(`Invalid XML in ${filePath}:`, error);
const timestamp = Date.now().toString(); // similar to DateTime.Now.Ticks in C#
const errorPath = path.join(errorDir, `${timestamp}.xml`);
await fs.rename(filePath, errorPath);
log.debug(`Moved invalid file to error: ${errorPath}`);
const timestamp = dayjs().format("YYYYMMDD_HHmmss");
const originalFilename = path.basename(file, path.extname(file));
const errorPath = path.join(
errorDir,
`${originalFilename}-${timestamp}.xml`,
);
try {
await fs.rename(filePath, errorPath);
log.debug(`Moved invalid file to error: ${errorPath}`);
} catch (moveError) {
log.error(
`Failed to move invalid file to error directory ${errorPath}:`,
moveError,
);
}
continue;
}
// Get authentication token
const token = (store.get("user") as any)?.stsTokenManager?.accessToken;
if (!token) {
log.error(`No authentication token for file: ${filePath}`);
let token: string | null;
try {
token = await getTokenFromRenderer();
if (!token) {
log.error(`No authentication token for file: ${filePath}`);
continue;
}
log.debug(
`Obtained authentication token for ${filePath}: ${token.slice(0, 10)}...`,
);
} catch (tokenError) {
log.error(
`Failed to obtain authentication token for ${filePath}:`,
tokenError,
);
continue;
}
// Upload file to API
const formData = new FormData();
formData.append("file", new Blob([xmlContent]), path.basename(filePath));
formData.append(
"shopId",
(store.get("app.bodyshop") as any)?.shopname || "",
);
const shopId = (store.get("app.bodyshop") as any)?.shopname || "";
formData.append("shopId", shopId);
log.debug(`Shop ID: ${shopId}`);
const baseURL = store.get("app.isTest")
? import.meta.env.VITE_API_TEST_URL
: import.meta.env.VITE_API_URL;
const finalUrl = `${baseURL}/mixdata/upload`;
log.debug(`Uploading file to ${finalUrl}`);
try {
@@ -88,23 +133,52 @@ export async function ppgInputHandler(config: PaintScaleConfig): Promise<void> {
Authorization: `Bearer ${token}`,
"Content-Type": "multipart/form-data",
},
timeout: 10000, // 10-second timeout
});
log.info(`Upload response for ${filePath}:`, {
status: response.status,
statusText: response.statusText,
data: response.data,
});
if (response.status === 200) {
log.info(`Successful upload of ${filePath}`);
// Move file to archive
const timestamp = Date.now().toString(); // generate new timestamp
const archivePath = path.join(archiveDir, `${timestamp}.xml`);
await fs.rename(filePath, archivePath);
log.debug(`Moved file to archive: ${archivePath}`);
const timestamp = dayjs().format("YYYYMMDD_HHmmss");
const originalFilename = path.basename(file, path.extname(file));
const archivePath = path.join(
archiveDir,
`${originalFilename}-${timestamp}.xml`,
);
try {
await fs.access(archiveDir, fs.constants.W_OK); // Verify archiveDir is writable
await fs.rename(filePath, archivePath);
log.info(`Moved file to archive: ${archivePath}`);
} catch (moveError) {
log.error(
`Failed to move file to archive directory ${archivePath}:`,
moveError,
);
}
} else {
log.error(
`Failed to upload ${filePath}: ${response.status} ${response.statusText}`,
response.data,
{ responseData: response.data },
);
}
} catch (error) {
log.error(`Error uploading ${filePath}:`, error);
} catch (error: any) {
log.error(`Error uploading ${filePath}:`, {
message: error.message,
code: error.code,
response: error.response
? {
status: error.response.status,
statusText: error.response.statusText,
data: error.response.data,
}
: null,
});
}
}
} catch (error) {
@@ -121,70 +195,16 @@ export async function ppgOutputHandler(
await fs.mkdir(config.path!, { recursive: true });
const query = `
query PpgData($today: timestamptz!, $todayplus5: timestamptz!, $shopid: uuid!) {
bodyshops_by_pk(id:$shopid) {
id
shopname
imexshopid
}
jobs(where: {
_or: [
{
_and: [
{ scheduled_in: { _lte: $todayplus5 } },
{ scheduled_in: { _gte: $today } }
]
},
{ inproduction: { _eq: true } }
]
}) {
id
ro_number
status
ownr_fn
ownr_ln
ownr_co_nm
v_vin
v_model_yr
v_make_desc
v_model_desc
v_color
plate_no
ins_co_nm
est_ct_fn
est_ct_ln
rate_mapa
rate_lab
job_totals
vehicle {
v_paint_codes
}
labhrs: joblines_aggregate(where: { mod_lbr_ty: { _neq: "LAR" }, removed: { _eq: false } }) {
aggregate {
sum {
mod_lb_hrs
}
}
}
larhrs: joblines_aggregate(where: { mod_lbr_ty: { _eq: "LAR" }, removed: { _eq: false } }) {
aggregate {
sum {
mod_lb_hrs
}
}
}
}
}
`;
const variables = {
today: new Date().toISOString(),
todayplus5: new Date(Date.now() + 5 * 86400000).toISOString(),
const variables: PpgDataQueryVariables = {
today: dayjs().toISOString(),
todayplus5: dayjs().add(5, "day").toISOString(),
shopid: (store.get("app.bodyshop") as any)?.id,
};
const response = (await client.request(query, variables)) as any;
const response = await client.request<
PpgDataQueryResult,
PpgDataQueryVariables
>(PPG_DATA_QUERY_TYPED, variables);
const jobs = response.jobs ?? [];
const header = {
@@ -197,18 +217,10 @@ export async function ppgOutputHandler(
},
Transaction: {
TransactionID: "",
TransactionDate: (() => {
const now = new Date();
const year = now.getFullYear();
const month = ("0" + (now.getMonth() + 1)).slice(-2);
const day = ("0" + now.getDate()).slice(-2);
const hours = ("0" + now.getHours()).slice(-2);
const minutes = ("0" + now.getMinutes()).slice(-2);
return `${year}-${month}-${day}:${hours}:${minutes}`;
})(),
TransactionDate: dayjs().format("YYYY-MM-DD:HH:mm"),
},
Product: {
Name: "ImEX Online",
Name: import.meta.env.VITE_COMPANY === "IMEX",
Version: "",
},
},
@@ -220,7 +232,7 @@ export async function ppgOutputHandler(
},
RepairOrders: {
ROCount: jobs.length.toString(),
RO: jobs.map((job: any) => ({
RO: jobs.map((job) => ({
RONumber: job.ro_number || "",
ROStatus: "Open",
Customer: `${job.ownr_ln || ""}, ${job.ownr_fn || ""}`,
@@ -258,4 +270,3 @@ export async function ppgOutputHandler(
log.error(`Error generating PPG output for config ${config.id}:`, error);
}
}

View File

@@ -6,9 +6,9 @@ const execPromise = promisify(exec);
// Define the interval as a variable (in minutes)
const KEEP_ALIVE_INTERVAL_MINUTES = 15;
const taskName = "ShopPartnerKeepAlive";
export async function setupKeepAliveTask(): Promise<void> {
const taskName = "ImEXShopPartnerKeepAlive";
const protocolUrl = "imexmedia://keep-alive";
// Use rundll32.exe to silently open the URL as a protocol
const command = `rundll32.exe url.dll,OpenURL "${protocolUrl}"`;
@@ -30,7 +30,6 @@ export async function setupKeepAliveTask(): Promise<void> {
}
export async function isKeepAliveTaskInstalled(): Promise<boolean> {
const taskName = "ImEXShopPartnerKeepAlive";
const maxRetries = 3;
const retryDelay = 500; // 500ms delay between retries

View File

@@ -13,9 +13,10 @@ import {
Space,
Table,
Tag,
theme,
Tooltip,
} from "antd";
import { FC, useState } from "react";
import { JSX, useState } from "react";
import { useTranslation } from "react-i18next";
import {
PaintScaleConfig,
@@ -24,8 +25,10 @@ import {
} from "../../../../util/types/paintScale";
import { usePaintScaleConfig } from "./PaintScale/usePaintScaleConfig";
const SettingsPaintScaleInputPaths: FC = () => {
const SettingsPaintScaleInputPaths = (): JSX.Element => {
const { t } = useTranslation();
const { token } = theme.useToken(); // Access theme tokens
const {
paintScaleConfigs,
handleAddConfig,
@@ -87,7 +90,7 @@ const SettingsPaintScaleInputPaths: FC = () => {
placeholder={t("settings.labels.paintScalePath")}
disabled
style={{
borderColor: isValid ? "#52c41a" : "#d9d9d9",
borderColor: isValid ? token.colorSuccess : token.colorError, // Use semantic tokens
}}
suffix={
<Tooltip
@@ -98,9 +101,9 @@ const SettingsPaintScaleInputPaths: FC = () => {
}
>
{isValid ? (
<CheckCircleFilled style={{ color: "#52c41a" }} />
<CheckCircleFilled style={{ color: token.colorSuccess }} />
) : (
<WarningFilled style={{ color: "#faad14" }} />
<WarningFilled style={{ color: token.colorError }} />
)}
</Tooltip>
}

View File

@@ -4,8 +4,18 @@ import {
FolderOpenFilled,
WarningFilled,
} from "@ant-design/icons";
import { Button, Card, Input, Modal, Select, Space, Table, Tag } from "antd";
import { FC, useState } from "react";
import {
Button,
Card,
Input,
Modal,
Select,
Space,
Table,
Tag,
theme,
} from "antd";
import { JSX, useState } from "react";
import { useTranslation } from "react-i18next";
import {
PaintScaleConfig,
@@ -14,7 +24,8 @@ import {
} from "../../../../util/types/paintScale";
import { usePaintScaleConfig } from "./PaintScale/usePaintScaleConfig";
const SettingsPaintScaleOutputPaths: FC = () => {
const SettingsPaintScaleOutputPaths = (): JSX.Element => {
const { token } = theme.useToken();
const { t } = useTranslation();
const {
paintScaleConfigs,
@@ -77,13 +88,13 @@ const SettingsPaintScaleOutputPaths: FC = () => {
placeholder={t("settings.labels.paintScalePath")}
disabled
style={{
borderColor: isValid ? "#52c41a" : "#d9d9d9",
borderColor: isValid ? token.colorSuccess : token.colorError,
}}
suffix={
isValid ? (
<CheckCircleFilled style={{ color: "#52c41a" }} />
<CheckCircleFilled style={{ color: token.colorSuccess }} />
) : (
<WarningFilled style={{ color: "#faad14" }} />
<WarningFilled style={{ color: token.colorError }} />
)
}
/>