diff --git a/.gitignore b/.gitignore index cdb11f59f0..ccbc8074a9 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,6 @@ packages/database/supabase/seed.sql .env*.local .react-router + +packages/cad-rust +packages/cad-engine diff --git a/.vscode/settings.json b/.vscode/settings.json index cdc2c1e7c4..11814b2b60 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,5 +2,6 @@ "deno.cacheOnSave": true, "deno.enablePaths": ["./packages/database"], "typescript.tsdk": "node_modules/typescript/lib", - "deno.enable": true +"deno.enable": true, +"git.ignoreLimitWarning": true } diff --git a/apps/erp/app/modules/items/items.models.ts b/apps/erp/app/modules/items/items.models.ts index ec838e0a5a..5c756dd449 100644 --- a/apps/erp/app/modules/items/items.models.ts +++ b/apps/erp/app/modules/items/items.models.ts @@ -520,7 +520,10 @@ export const supplierPartValidator = z.object({ supplierUnitOfMeasureCode: zfd.text(z.string().optional()), minimumOrderQuantity: zfd.numeric(z.number().min(0)), conversionFactor: zfd.numeric(z.number().min(0)), - unitPrice: zfd.numeric(z.number().min(0)) + unitPrice: zfd.numeric(z.number().min(0).optional()), + lastPurchaseDate: z.string().optional(), + lastPOQuantity: zfd.numeric(z.number().min(0).optional()), + lastPOId: z.string().optional() }); export const toolValidator = itemValidator.merge( diff --git a/apps/erp/app/modules/items/ui/Item/SupplierPartForm.tsx b/apps/erp/app/modules/items/ui/Item/SupplierPartForm.tsx index bd4b22eea7..f82c018c18 100644 --- a/apps/erp/app/modules/items/ui/Item/SupplierPartForm.tsx +++ b/apps/erp/app/modules/items/ui/Item/SupplierPartForm.tsx @@ -1,3 +1,4 @@ +import { useCarbon } from "@carbon/auth"; import { ValidatedForm } from "@carbon/form"; import { Button, @@ -25,7 +26,7 @@ import { Supplier, UnitOfMeasure } from "~/components/Form"; -import { usePermissions, useUser } from "~/hooks"; +import { useCurrencyFormatter, usePermissions, useUser } from "~/hooks"; import { path } from "~/utils/path"; import { supplierPartValidator } from "../../items.models"; @@ -42,7 +43,9 @@ const SupplierPartForm = ({ unitOfMeasureCode, onClose }: SupplierPartFormProps) => { + const { carbon } = useCarbon(); const permissions = usePermissions(); + const formatter = useCurrencyFormatter(); const { company } = useUser(); const baseCurrency = company?.baseCurrencyCode ?? "USD"; @@ -65,6 +68,29 @@ const SupplierPartForm = ({ const action = getAction(isEditing, type, itemId, initialValues.id); const fetcher = useFetcher<{ success: boolean; message: string }>(); + // Fetch price breaks for existing supplier parts + const [priceBreaks, setPriceBreaks] = useState< + { + quantity: number; + unitPrice: number; + leadTime: number | null; + sourceType: string; + }[] + >([]); + + useEffect(() => { + if (!carbon || !isEditing || !initialValues.id) return; + + carbon + .from("supplierPartPrice") + .select("quantity, unitPrice, leadTime, sourceType") + .eq("supplierPartId", initialValues.id) + .order("quantity", { ascending: true }) + .then(({ data }) => { + if (data?.length) setPriceBreaks(data); + }); + }, [carbon, isEditing, initialValues.id]); + useEffect(() => { if (fetcher.data?.success) { onClose(); @@ -80,7 +106,7 @@ const SupplierPartForm = ({ if (!open) onClose(); }} > - + + {/* Show last purchase info if available (read-only) */} + {initialValues.lastPurchaseDate && ( +
+
+ Last Purchase Info +
+
+ Date:{" "} + {new Date( + initialValues.lastPurchaseDate + ).toLocaleDateString()} +
+ {initialValues.lastPOQuantity != null && ( +
+ Quantity: {initialValues.lastPOQuantity.toLocaleString()} +
+ )} +
+ )} + {/* Show quantity price breaks if available */} + {priceBreaks.length > 0 && ( +
+
+ Price Breaks +
+ + + + + + + + + + + {priceBreaks.map((pb) => ( + + + + + + + ))} + +
QtyUnit PriceLead TimeSource
+ {pb.quantity.toLocaleString()} + + {formatter.format(pb.unitPrice)} + + {pb.leadTime != null ? `${pb.leadTime}d` : "—"} + + {pb.sourceType} +
+
+ )} ; type SupplierPartsProps = { @@ -37,10 +34,9 @@ const SupplierParts = ({ compact = false }: SupplierPartsProps) => { const navigate = useNavigate(); - const { canEdit, onCellEdit } = useSupplierParts(); + const { canEdit } = useSupplierParts(); const formatter = useCurrencyFormatter(); - const unitOfMeasureOptions = useUnitOfMeasure(); const customColumns = useCustomColumns("supplierPart"); const columns = useMemo[]>(() => { @@ -66,6 +62,24 @@ const SupplierParts = ({ renderTotal: true } }, + { + accessorKey: "lastPurchaseDate", + header: "Last Purchase", + cell: (item) => { + const date = item.getValue(); + if (!date) return "—"; + return new Date(date).toLocaleDateString(); + } + }, + { + accessorKey: "lastPOQuantity", + header: "Last PO Qty", + cell: (item) => { + const qty = item.getValue(); + if (qty == null) return "—"; + return qty.toLocaleString(); + } + }, { accessorKey: "supplierUnitOfMeasureCode", header: "Unit of Measure", @@ -85,34 +99,28 @@ const SupplierParts = ({ return [...defaultColumns, ...customColumns]; }, [customColumns, formatter]); - const editableComponents = useMemo( - () => ({ - supplierPartId: EditableText(onCellEdit), - supplierUnitOfMeasureCode: EditableList(onCellEdit, unitOfMeasureOptions), - minimumOrderQuantity: EditableNumber(onCellEdit), - conversionFactor: EditableNumber(onCellEdit), - unitPrice: EditableNumber(onCellEdit) - }), - [onCellEdit, unitOfMeasureOptions] + const renderContextMenu = useCallback( + (row: Part) => ( + navigate(row.id!)}> + } /> + Edit Supplier Part + + ), + [navigate] ); return ( <> - - - Supplier Parts - - - - contained={false} - data={supplierParts} - columns={columns} - canEdit={canEdit} - editableComponents={editableComponents} - onNewRow={canEdit ? () => navigate("new") : undefined} - /> - - + + data={supplierParts} + columns={columns} + count={supplierParts.length} + title="Supplier Parts" + renderContextMenu={canEdit ? renderContextMenu : undefined} + primaryAction={ + canEdit ? : undefined + } + /> ); diff --git a/apps/erp/app/modules/purchasing/ui/SupplierQuote/SupplierQuoteCompareDrawer.tsx b/apps/erp/app/modules/purchasing/ui/SupplierQuote/SupplierQuoteCompareDrawer.tsx index 55b6d40195..c5152dc382 100644 --- a/apps/erp/app/modules/purchasing/ui/SupplierQuote/SupplierQuoteCompareDrawer.tsx +++ b/apps/erp/app/modules/purchasing/ui/SupplierQuote/SupplierQuoteCompareDrawer.tsx @@ -199,7 +199,10 @@ const SupplierQuoteCompareDrawer = ({ {step === "compare" ? "Compare Supplier Quotes" - : `Create Order from ${selectedQuote?.supplier?.name ?? selectedQuote?.supplierQuoteId}`} + : `Create Order from ${ + selectedQuote?.supplier?.name ?? + selectedQuote?.supplierQuoteId + }`} {step === "compare" && totalQuotes > 0 && ( @@ -621,7 +624,8 @@ const ComparisonView = ({ - {linePrice.leadTime ?? 0} {pluralize(linePrice.leadTime ?? 0, "day")} + {linePrice.leadTime ?? 0}{" "} + {pluralize(linePrice.leadTime ?? 0, "day")} @@ -792,7 +796,10 @@ const LinePricingOptions = ({ {option.quantity} {formatter.format(option.supplierUnitPrice ?? 0)} {formatter.format(option.supplierShippingCost ?? 0)} - {option.leadTime ?? 0} {pluralize(option.leadTime ?? 0, "day")} + + {option.leadTime ?? 0}{" "} + {pluralize(option.leadTime ?? 0, "day")} + {formatter.format(option.supplierTaxAmount ?? 0)} {formatter.format( diff --git a/apps/erp/app/modules/purchasing/ui/SupplierQuote/SupplierQuoteToOrderDrawer.tsx b/apps/erp/app/modules/purchasing/ui/SupplierQuote/SupplierQuoteToOrderDrawer.tsx index 6dc5f8c6e7..be515bf469 100644 --- a/apps/erp/app/modules/purchasing/ui/SupplierQuote/SupplierQuoteToOrderDrawer.tsx +++ b/apps/erp/app/modules/purchasing/ui/SupplierQuote/SupplierQuoteToOrderDrawer.tsx @@ -343,7 +343,9 @@ const LinePricingOptions = ({ option.supplierShippingCost ?? 0 )} - {option.leadTime} {pluralize(option.leadTime, "day")} + + {option.leadTime} {pluralize(option.leadTime, "day")} + {presentationCurrencyFormatter.format( option.supplierTaxAmount ?? 0 diff --git a/apps/erp/app/modules/sales/ui/Quotes/QuoteBillOfMaterial.tsx b/apps/erp/app/modules/sales/ui/Quotes/QuoteBillOfMaterial.tsx index c5e8eb1ccf..903cf383c3 100644 --- a/apps/erp/app/modules/sales/ui/Quotes/QuoteBillOfMaterial.tsx +++ b/apps/erp/app/modules/sales/ui/Quotes/QuoteBillOfMaterial.tsx @@ -52,8 +52,6 @@ import { Hidden, InputControlled, Item, - // biome-ignore lint/suspicious/noShadowRestrictedNames: suppressed due to migration - Number, NumberControlled, Select, Shelf, @@ -689,6 +687,50 @@ function MaterialForm({ }); }; + // Lookup the best (lowest) price for a Buy item at a given quantity + // from supplierPartPrice across all vendors (SAP behavior) + const lookupBuyPrice = useCallback( + async (itemId: string, qty: number, fallbackCost: number) => { + if (!carbon) return fallbackCost; + + const supplierParts = await carbon + .from("supplierPart") + .select("id, unitPrice") + .eq("itemId", itemId); + + if (!supplierParts.data?.length) return fallbackCost; + + const supplierPartIds = supplierParts.data.map((sp) => sp.id); + + // Find the lowest price across all vendors for applicable qty tiers + // (SAP behavior: lowest valid PIR price wins) + const priceBreak = await carbon + .from("supplierPartPrice") + .select("unitPrice") + .in("supplierPartId", supplierPartIds) + .lte("quantity", qty) + .order("unitPrice", { ascending: true }) + .limit(1) + .single(); + + if (priceBreak.data?.unitPrice != null) { + return priceBreak.data.unitPrice; + } + + // Fall back to supplierPart.unitPrice (lowest across vendors) + const lowestSupplierPrice = supplierParts.data + .filter((sp) => sp.unitPrice != null) + .sort((a, b) => (a.unitPrice ?? 0) - (b.unitPrice ?? 0))[0]; + + if (lowestSupplierPrice?.unitPrice != null) { + return lowestSupplierPrice.unitPrice; + } + + return fallbackCost; + }, + [carbon] + ); + const onItemChange = async (itemId: string) => { if (!carbon) return; if (itemId === params.itemId) { @@ -713,11 +755,18 @@ function MaterialForm({ return; } + let unitCost = itemCost.data?.unitCost ?? 0; + const isBuyPart = item.data?.defaultMethodType === "Buy"; + + if (isBuyPart) { + unitCost = await lookupBuyPrice(itemId, itemData.quantity ?? 1, unitCost); + } + setItemData((d) => ({ ...d, itemId, description: item.data?.name ?? "", - unitCost: itemCost.data?.unitCost ?? 0, + unitCost, unitOfMeasureCode: item.data?.unitOfMeasureCode ?? "EA", methodType: item.data?.defaultMethodType ?? "Buy", requiresBatchTracking: item.data?.itemTrackingType === "Batch", @@ -729,6 +778,32 @@ function MaterialForm({ } }; + // Re-lookup price when quantity changes for Buy parts + const onQuantityChange = useCallback( + async (newQty: number) => { + setItemData((d) => ({ ...d, quantity: newQty })); + + if (itemData.methodType !== "Buy" || !itemData.itemId) return; + if (!carbon) return; + + const itemCost = await carbon + .from("itemCost") + .select("unitCost") + .eq("itemId", itemData.itemId) + .single(); + + const fallbackCost = itemCost.data?.unitCost ?? 0; + const unitCost = await lookupBuyPrice( + itemData.itemId, + newQty, + fallbackCost + ); + + setItemData((d) => ({ ...d, unitCost })); + }, + [carbon, itemData.methodType, itemData.itemId, lookupBuyPrice] + ); + const sourceDisclosure = useDisclosure(); const backflushDisclosure = useDisclosure(); const locationId = routeData?.quote?.locationId ?? undefined; @@ -757,7 +832,6 @@ function MaterialForm({ )} -
- + )}
-
-
- { + if (!carbon) return fallbackCost; + + const supplierParts = await carbon + .from("supplierPart") + .select("id, unitPrice") + .eq("itemId", itemId); + + if (!supplierParts.data?.length) return fallbackCost; + + const supplierPartIds = supplierParts.data.map((sp) => sp.id); + + // Find the lowest price across all vendors for applicable qty tiers + // (SAP behavior: lowest valid PIR price wins) + const priceBreak = await carbon + .from("supplierPartPrice") + .select("unitPrice") + .in("supplierPartId", supplierPartIds) + .lte("quantity", qty) + .order("unitPrice", { ascending: true }) + .limit(1) + .single(); + + if (priceBreak.data?.unitPrice != null) { + return priceBreak.data.unitPrice; + } + + // Fall back to supplierPart.unitPrice (lowest across vendors) + const lowestSupplierPrice = supplierParts.data + .filter((sp) => sp.unitPrice != null) + .sort((a, b) => (a.unitPrice ?? 0) - (b.unitPrice ?? 0))[0]; + + if (lowestSupplierPrice?.unitPrice != null) { + return lowestSupplierPrice.unitPrice; + } + + return fallbackCost; + }, + [carbon] + ); + const onItemChange = async (itemId: string) => { if (!carbon) return; @@ -107,16 +149,49 @@ const QuoteMaterialForm = ({ return; } + let unitCost = itemCost.data?.unitCost ?? 0; + const isBuyPart = item.data?.defaultMethodType === "Buy"; + + if (isBuyPart) { + unitCost = await lookupBuyPrice(itemId, itemData.quantity ?? 1, unitCost); + } + setItemData((d) => ({ ...d, itemId, description: item.data?.name ?? "", - unitCost: itemCost.data?.unitCost ?? 0, + unitCost, unitOfMeasureCode: item.data?.unitOfMeasureCode ?? "EA", methodType: item.data?.defaultMethodType ?? "Buy" })); }; + // Re-lookup price when quantity changes for Buy parts + const onQuantityChange = useCallback( + async (newQty: number) => { + setItemData((d) => ({ ...d, quantity: newQty })); + + if (itemData.methodType !== "Buy" || !itemData.itemId) return; + if (!carbon) return; + + const itemCost = await carbon + .from("itemCost") + .select("unitCost") + .eq("itemId", itemData.itemId) + .single(); + + const fallbackCost = itemCost.data?.unitCost ?? 0; + const unitCost = await lookupBuyPrice( + itemData.itemId, + newQty, + fallbackCost + ); + + setItemData((d) => ({ ...d, unitCost })); + }, + [carbon, itemData.methodType, itemData.itemId, lookupBuyPrice] + ); + const [, setSearchParams] = useUrlParams(); // biome-ignore lint/correctness/useExhaustiveDependencies: suppressed due to migration @@ -202,7 +277,12 @@ const QuoteMaterialForm = ({ value={itemData.methodType} replenishmentSystem="Buy and Make" /> - + - {option.leadTime} {pluralize(option.leadTime, "day")} + + {option.leadTime} {pluralize(option.leadTime, "day")} + {formatter.format( (option.convertedNetExtendedPrice ?? 0) + diff --git a/apps/erp/app/routes/x+/consumable+/$itemId.purchasing.$supplierPartId.tsx b/apps/erp/app/routes/x+/consumable+/$itemId.purchasing.$supplierPartId.tsx new file mode 100644 index 0000000000..0de9e5985b --- /dev/null +++ b/apps/erp/app/routes/x+/consumable+/$itemId.purchasing.$supplierPartId.tsx @@ -0,0 +1,106 @@ +import { assertIsPost, success } from "@carbon/auth"; +import { requirePermissions } from "@carbon/auth/auth.server"; +import { flash } from "@carbon/auth/session.server"; +import { validationError, validator } from "@carbon/form"; +import { useRouteData } from "@carbon/remix"; +import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router"; +import { redirect, useLoaderData, useNavigate, useParams } from "react-router"; +import type { ConsumableSummary } from "~/modules/items"; +import { supplierPartValidator, upsertSupplierPart } from "~/modules/items"; +import { SupplierPartForm } from "~/modules/items/ui/Item"; +import { setCustomFields } from "~/utils/form"; +import { path } from "~/utils/path"; + +export async function loader({ request, params }: LoaderFunctionArgs) { + const { client, companyId } = await requirePermissions(request, { + view: "parts" + }); + + const { supplierPartId } = params; + if (!supplierPartId) throw new Error("Could not find supplierPartId"); + + const supplierPart = await client + .from("supplierPart") + .select("*") + .eq("id", supplierPartId) + .eq("companyId", companyId) + .single(); + + if (!supplierPart?.data) throw new Error("Could not find supplier part"); + + return { supplierPart: supplierPart.data }; +} + +export async function action({ request, params }: ActionFunctionArgs) { + assertIsPost(request); + const { client, userId } = await requirePermissions(request, { + update: "parts" + }); + + const { itemId, supplierPartId } = params; + if (!itemId) throw new Error("Could not find itemId"); + if (!supplierPartId) throw new Error("Could not find supplierPartId"); + + const formData = await request.formData(); + const validation = await validator(supplierPartValidator).validate(formData); + + if (validation.error) { + return validationError(validation.error); + } + + // biome-ignore lint/correctness/noUnusedVariables: suppressed due to migration + const { id, ...d } = validation.data; + + const updatedSupplierPart = await upsertSupplierPart(client, { + id: supplierPartId, + ...d, + updatedBy: userId, + customFields: setCustomFields(formData) + }); + + if (updatedSupplierPart.error) { + return { success: false, message: "Failed to update supplier part" }; + } + + throw redirect( + path.to.consumablePurchasing(itemId), + await flash(request, success("Supplier part updated")) + ); +} + +export default function EditConsumableSupplierRoute() { + const { itemId } = useParams(); + const { supplierPart } = useLoaderData(); + + if (!itemId) throw new Error("itemId not found"); + + const routeData = useRouteData<{ consumableSummary: ConsumableSummary }>( + path.to.consumable(itemId) + ); + + const navigate = useNavigate(); + const onClose = () => navigate(path.to.consumablePurchasing(itemId)); + + const initialValues = { + id: supplierPart.id, + itemId: supplierPart.itemId, + supplierId: supplierPart.supplierId, + supplierPartId: supplierPart.supplierPartId ?? "", + unitPrice: supplierPart.unitPrice ?? 0, + supplierUnitOfMeasureCode: supplierPart.supplierUnitOfMeasureCode ?? "EA", + minimumOrderQuantity: supplierPart.minimumOrderQuantity ?? 1, + conversionFactor: supplierPart.conversionFactor ?? 1, + lastPurchaseDate: supplierPart.lastPurchaseDate, + lastPOQuantity: supplierPart.lastPOQuantity, + lastPOId: supplierPart.lastPOId + }; + + return ( + + ); +} diff --git a/apps/erp/app/routes/x+/material+/$itemId.purchasing.$supplierPartId.tsx b/apps/erp/app/routes/x+/material+/$itemId.purchasing.$supplierPartId.tsx new file mode 100644 index 0000000000..b3952e41b0 --- /dev/null +++ b/apps/erp/app/routes/x+/material+/$itemId.purchasing.$supplierPartId.tsx @@ -0,0 +1,106 @@ +import { assertIsPost, success } from "@carbon/auth"; +import { requirePermissions } from "@carbon/auth/auth.server"; +import { flash } from "@carbon/auth/session.server"; +import { validationError, validator } from "@carbon/form"; +import { useRouteData } from "@carbon/remix"; +import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router"; +import { redirect, useLoaderData, useNavigate, useParams } from "react-router"; +import type { MaterialSummary } from "~/modules/items"; +import { supplierPartValidator, upsertSupplierPart } from "~/modules/items"; +import { SupplierPartForm } from "~/modules/items/ui/Item"; +import { setCustomFields } from "~/utils/form"; +import { path } from "~/utils/path"; + +export async function loader({ request, params }: LoaderFunctionArgs) { + const { client, companyId } = await requirePermissions(request, { + view: "parts" + }); + + const { supplierPartId } = params; + if (!supplierPartId) throw new Error("Could not find supplierPartId"); + + const supplierPart = await client + .from("supplierPart") + .select("*") + .eq("id", supplierPartId) + .eq("companyId", companyId) + .single(); + + if (!supplierPart?.data) throw new Error("Could not find supplier part"); + + return { supplierPart: supplierPart.data }; +} + +export async function action({ request, params }: ActionFunctionArgs) { + assertIsPost(request); + const { client, userId } = await requirePermissions(request, { + update: "parts" + }); + + const { itemId, supplierPartId } = params; + if (!itemId) throw new Error("Could not find itemId"); + if (!supplierPartId) throw new Error("Could not find supplierPartId"); + + const formData = await request.formData(); + const validation = await validator(supplierPartValidator).validate(formData); + + if (validation.error) { + return validationError(validation.error); + } + + // biome-ignore lint/correctness/noUnusedVariables: suppressed due to migration + const { id, ...d } = validation.data; + + const updatedSupplierPart = await upsertSupplierPart(client, { + id: supplierPartId, + ...d, + updatedBy: userId, + customFields: setCustomFields(formData) + }); + + if (updatedSupplierPart.error) { + return { success: false, message: "Failed to update supplier part" }; + } + + throw redirect( + path.to.materialPurchasing(itemId), + await flash(request, success("Supplier part updated")) + ); +} + +export default function EditMaterialSupplierRoute() { + const { itemId } = useParams(); + const { supplierPart } = useLoaderData(); + + if (!itemId) throw new Error("itemId not found"); + + const routeData = useRouteData<{ materialSummary: MaterialSummary }>( + path.to.material(itemId) + ); + + const navigate = useNavigate(); + const onClose = () => navigate(path.to.materialPurchasing(itemId)); + + const initialValues = { + id: supplierPart.id, + itemId: supplierPart.itemId, + supplierId: supplierPart.supplierId, + supplierPartId: supplierPart.supplierPartId ?? "", + unitPrice: supplierPart.unitPrice ?? 0, + supplierUnitOfMeasureCode: supplierPart.supplierUnitOfMeasureCode ?? "EA", + minimumOrderQuantity: supplierPart.minimumOrderQuantity ?? 1, + conversionFactor: supplierPart.conversionFactor ?? 1, + lastPurchaseDate: supplierPart.lastPurchaseDate, + lastPOQuantity: supplierPart.lastPOQuantity, + lastPOId: supplierPart.lastPOId + }; + + return ( + + ); +} diff --git a/apps/erp/app/routes/x+/part+/$itemId.purchasing.$supplierPartId.tsx b/apps/erp/app/routes/x+/part+/$itemId.purchasing.$supplierPartId.tsx new file mode 100644 index 0000000000..4fbeb2a876 --- /dev/null +++ b/apps/erp/app/routes/x+/part+/$itemId.purchasing.$supplierPartId.tsx @@ -0,0 +1,106 @@ +import { assertIsPost, success } from "@carbon/auth"; +import { requirePermissions } from "@carbon/auth/auth.server"; +import { flash } from "@carbon/auth/session.server"; +import { validator } from "@carbon/form"; +import { useRouteData } from "@carbon/remix"; +import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router"; +import { redirect, useLoaderData, useNavigate, useParams } from "react-router"; +import type { PartSummary } from "~/modules/items"; +import { supplierPartValidator, upsertSupplierPart } from "~/modules/items"; +import { SupplierPartForm } from "~/modules/items/ui/Item"; +import { setCustomFields } from "~/utils/form"; +import { path } from "~/utils/path"; + +export async function loader({ request, params }: LoaderFunctionArgs) { + const { client, companyId } = await requirePermissions(request, { + view: "parts" + }); + + const { supplierPartId } = params; + if (!supplierPartId) throw new Error("Could not find supplierPartId"); + + const supplierPart = await client + .from("supplierPart") + .select("*") + .eq("id", supplierPartId) + .eq("companyId", companyId) + .single(); + + if (!supplierPart?.data) throw new Error("Could not find supplier part"); + + return { supplierPart: supplierPart.data }; +} + +export async function action({ request, params }: ActionFunctionArgs) { + assertIsPost(request); + const { client, userId } = await requirePermissions(request, { + update: "parts" + }); + + const { itemId, supplierPartId } = params; + if (!itemId) throw new Error("Could not find itemId"); + if (!supplierPartId) throw new Error("Could not find supplierPartId"); + + const formData = await request.formData(); + const validation = await validator(supplierPartValidator).validate(formData); + + if (validation.error) { + return { success: false, message: "Invalid form data" }; + } + + // biome-ignore lint/correctness/noUnusedVariables: suppressed due to migration + const { id, ...d } = validation.data; + + const updatedSupplierPart = await upsertSupplierPart(client, { + id: supplierPartId, + ...d, + updatedBy: userId, + customFields: setCustomFields(formData) + }); + + if (updatedSupplierPart.error) { + return { success: false, message: "Failed to update supplier part" }; + } + + throw redirect( + path.to.partPurchasing(itemId), + await flash(request, success("Supplier part updated")) + ); +} + +export default function EditPartSupplierRoute() { + const { itemId } = useParams(); + const { supplierPart } = useLoaderData(); + + if (!itemId) throw new Error("itemId not found"); + + const routeData = useRouteData<{ partSummary: PartSummary }>( + path.to.part(itemId) + ); + + const navigate = useNavigate(); + const onClose = () => navigate(path.to.partPurchasing(itemId)); + + const initialValues = { + id: supplierPart.id, + itemId: supplierPart.itemId, + supplierId: supplierPart.supplierId, + supplierPartId: supplierPart.supplierPartId ?? "", + unitPrice: supplierPart.unitPrice ?? 0, + supplierUnitOfMeasureCode: supplierPart.supplierUnitOfMeasureCode ?? "EA", + minimumOrderQuantity: supplierPart.minimumOrderQuantity ?? 1, + conversionFactor: supplierPart.conversionFactor ?? 1, + lastPurchaseDate: supplierPart.lastPurchaseDate, + lastPOQuantity: supplierPart.lastPOQuantity, + lastPOId: supplierPart.lastPOId + }; + + return ( + + ); +} diff --git a/apps/erp/app/routes/x+/procedure+/update.tsx b/apps/erp/app/routes/x+/procedure+/update.tsx index 1dfe41a004..932ec3dbc9 100644 --- a/apps/erp/app/routes/x+/procedure+/update.tsx +++ b/apps/erp/app/routes/x+/procedure+/update.tsx @@ -1,6 +1,8 @@ import { requirePermissions } from "@carbon/auth/auth.server"; -import type { ClientActionFunctionArgs } from "react-router"; -import type { ActionFunctionArgs } from "react-router"; +import type { + ActionFunctionArgs, + ClientActionFunctionArgs +} from "react-router"; import { getCompanyId, proceduresQuery } from "~/utils/react-query"; export async function action({ request }: ActionFunctionArgs) { diff --git a/apps/erp/app/routes/x+/supplier-quote+/$id.finalize.tsx b/apps/erp/app/routes/x+/supplier-quote+/$id.finalize.tsx index 6aab0ad59d..4d00899e95 100644 --- a/apps/erp/app/routes/x+/supplier-quote+/$id.finalize.tsx +++ b/apps/erp/app/routes/x+/supplier-quote+/$id.finalize.tsx @@ -127,6 +127,113 @@ export async function action(args: ActionFunctionArgs) { ) ); } + + // Copy price breaks from supplierQuoteLinePrice → supplierPartPrice + // This makes prices available for customer quote costing immediately + const supplierId = quote.data.supplierId; + if (!supplierId) throw new Error("Supplier quote has no supplier"); + + for (const line of lines) { + if (!line.id || !line.itemId) continue; + + const linePrices = prices.filter( + (p) => p.supplierQuoteLineId === line.id + ); + if (linePrices.length === 0) continue; + + // Find or create the supplierPart record for this item+supplier + const existingPart = await client + .from("supplierPart") + .select("id") + .eq("itemId", line.itemId) + .eq("supplierId", supplierId) + .eq("companyId", companyId) + .single(); + + let supplierPartId: string | undefined; + + if (existingPart.data?.id) { + supplierPartId = existingPart.data.id; + } else { + // Create a new supplierPart record + const newPart = await client + .from("supplierPart") + .insert({ + itemId: line.itemId, + supplierId, + supplierPartId: line.supplierPartId ?? undefined, + supplierUnitOfMeasureCode: + line.purchaseUnitOfMeasureCode ?? undefined, + conversionFactor: line.conversionFactor ?? 1, + companyId, + createdBy: userId + }) + .select("id") + .single(); + + if (newPart.error || !newPart.data?.id) { + console.error("Error creating supplier part:", newPart.error); + continue; + } + supplierPartId = newPart.data.id; + } + + if (!supplierPartId) continue; + + // Upsert price breaks into supplierPartPrice + const conversionFactor = line.conversionFactor ?? 1; + + for (const price of linePrices) { + if (!price.supplierUnitPrice || price.supplierUnitPrice === 0) continue; + + // Use the pre-computed unitPrice (= supplierUnitPrice / exchangeRate) + // which is already in company currency but still in purchase units. + // Divide by conversionFactor to get inventory unit price. + const unitPriceInInventoryUnit = + (price.unitPrice ?? 0) / conversionFactor; + + const upsertResult = await client.from("supplierPartPrice").upsert( + { + supplierPartId, + quantity: price.quantity ?? 1, + unitPrice: unitPriceInInventoryUnit, + leadTime: price.leadTime ?? 0, + sourceType: "Quote", + sourceDocumentId: id, + companyId, + createdBy: userId, + updatedBy: userId, + updatedAt: new Date().toISOString() + }, + { onConflict: "supplierPartId,quantity" } + ); + + if (upsertResult.error) { + console.error( + "Error upserting supplier part price:", + upsertResult.error + ); + } + } + + // Update supplierPart.unitPrice with the best (lowest) unit price + // across all tiers — gives purchasing a quick "best available" reference + const bestPrice = linePrices + .filter((p) => p.unitPrice != null && p.unitPrice !== 0) + .sort( + (a, b) => (a.unitPrice ?? Infinity) - (b.unitPrice ?? Infinity) + )[0]; + + if (bestPrice) { + await client + .from("supplierPart") + .update({ + unitPrice: (bestPrice.unitPrice ?? 0) / conversionFactor, + minimumOrderQuantity: bestPrice.quantity ?? 1 + }) + .eq("id", supplierPartId); + } + } } catch (err) { throw redirect( path.to.supplierQuote(id), diff --git a/apps/erp/app/routes/x+/tool+/$itemId.purchasing.$supplierPartId.tsx b/apps/erp/app/routes/x+/tool+/$itemId.purchasing.$supplierPartId.tsx new file mode 100644 index 0000000000..0d51fec0dc --- /dev/null +++ b/apps/erp/app/routes/x+/tool+/$itemId.purchasing.$supplierPartId.tsx @@ -0,0 +1,106 @@ +import { assertIsPost, success } from "@carbon/auth"; +import { requirePermissions } from "@carbon/auth/auth.server"; +import { flash } from "@carbon/auth/session.server"; +import { validator } from "@carbon/form"; +import { useRouteData } from "@carbon/remix"; +import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router"; +import { redirect, useLoaderData, useNavigate, useParams } from "react-router"; +import type { ToolSummary } from "~/modules/items"; +import { supplierPartValidator, upsertSupplierPart } from "~/modules/items"; +import { SupplierPartForm } from "~/modules/items/ui/Item"; +import { setCustomFields } from "~/utils/form"; +import { path } from "~/utils/path"; + +export async function loader({ request, params }: LoaderFunctionArgs) { + const { client, companyId } = await requirePermissions(request, { + view: "parts" + }); + + const { supplierPartId } = params; + if (!supplierPartId) throw new Error("Could not find supplierPartId"); + + const supplierPart = await client + .from("supplierPart") + .select("*") + .eq("id", supplierPartId) + .eq("companyId", companyId) + .single(); + + if (!supplierPart?.data) throw new Error("Could not find supplier part"); + + return { supplierPart: supplierPart.data }; +} + +export async function action({ request, params }: ActionFunctionArgs) { + assertIsPost(request); + const { client, userId } = await requirePermissions(request, { + update: "parts" + }); + + const { itemId, supplierPartId } = params; + if (!itemId) throw new Error("Could not find itemId"); + if (!supplierPartId) throw new Error("Could not find supplierPartId"); + + const formData = await request.formData(); + const validation = await validator(supplierPartValidator).validate(formData); + + if (validation.error) { + return { success: false, message: "Invalid form data" }; + } + + // biome-ignore lint/correctness/noUnusedVariables: suppressed due to migration + const { id, ...d } = validation.data; + + const updatedSupplierPart = await upsertSupplierPart(client, { + id: supplierPartId, + ...d, + updatedBy: userId, + customFields: setCustomFields(formData) + }); + + if (updatedSupplierPart.error) { + return { success: false, message: "Failed to update supplier part" }; + } + + throw redirect( + path.to.toolPurchasing(itemId), + await flash(request, success("Supplier part updated")) + ); +} + +export default function EditToolSupplierRoute() { + const { itemId } = useParams(); + const { supplierPart } = useLoaderData(); + + if (!itemId) throw new Error("itemId not found"); + + const routeData = useRouteData<{ toolSummary: ToolSummary }>( + path.to.tool(itemId) + ); + + const navigate = useNavigate(); + const onClose = () => navigate(path.to.toolPurchasing(itemId)); + + const initialValues = { + id: supplierPart.id, + itemId: supplierPart.itemId, + supplierId: supplierPart.supplierId, + supplierPartId: supplierPart.supplierPartId ?? "", + unitPrice: supplierPart.unitPrice ?? 0, + supplierUnitOfMeasureCode: supplierPart.supplierUnitOfMeasureCode ?? "EA", + minimumOrderQuantity: supplierPart.minimumOrderQuantity ?? 1, + conversionFactor: supplierPart.conversionFactor ?? 1, + lastPurchaseDate: supplierPart.lastPurchaseDate, + lastPOQuantity: supplierPart.lastPOQuantity, + lastPOId: supplierPart.lastPOId + }; + + return ( + + ); +} diff --git a/apps/erp/app/utils/path.ts b/apps/erp/app/utils/path.ts index e0332a6a2c..43eb05f74c 100644 --- a/apps/erp/app/utils/path.ts +++ b/apps/erp/app/utils/path.ts @@ -478,7 +478,7 @@ export const path = { generatePath(`${x}/consumable/${id}/purchasing`), consumableRoot: `${x}/consumable`, consumableSupplier: (itemId: string, id: string) => - generatePath(`${x}/consumable/${itemId}/suppliers/${id}`), + generatePath(`${x}/consumable/${itemId}/purchasing/${id}`), consumableSuppliers: (id: string) => generatePath(`${x}/consumable/${id}/suppliers`), convertQuoteToOrder: (id: string) => @@ -965,7 +965,7 @@ export const path = { generatePath(`${x}/material/${id}/purchasing`), materialRoot: `${x}/material`, materialSupplier: (itemId: string, id: string) => - generatePath(`${x}/material/${itemId}/suppliers/${id}`), + generatePath(`${x}/material/${itemId}/purchasing/${id}`), materialSuppliers: (id: string) => generatePath(`${x}/material/${id}/suppliers`), materials: `${x}/items/materials`, @@ -1178,7 +1178,7 @@ export const path = { partRoot: `${x}/part`, partSales: (id: string) => generatePath(`${x}/part/${id}/sales`), partSupplier: (itemId: string, id: string) => - generatePath(`${x}/part/${itemId}/suppliers/${id}`), + generatePath(`${x}/part/${itemId}/purchasing/${id}`), parts: `${x}/items/parts`, partner: (id: string, abilityId: string) => generatePath(`${x}/resources/partners/${id}/${abilityId}`), @@ -1465,7 +1465,7 @@ export const path = { servicePurchasing: (id: string) => generatePath(`${x}/service/${id}/purchasing`), serviceSupplier: (serviceId: string, id: string) => - generatePath(`${x}/service/${serviceId}/suppliers/${id}`), + generatePath(`${x}/service/${serviceId}/purchasing/${id}`), serviceSuppliers: (id: string) => generatePath(`${x}/service/${id}/suppliers`), settings: `${x}/settings`, @@ -1569,7 +1569,7 @@ export const path = { toolPurchasing: (id: string) => generatePath(`${x}/tool/${id}/purchasing`), toolRoot: `${x}/tool`, toolSupplier: (itemId: string, id: string) => - generatePath(`${x}/tool/${itemId}/view/suppliers/${id}`), + generatePath(`${x}/tool/${itemId}/purchasing/${id}`), toolSuppliers: (id: string) => generatePath(`${x}/tool/${id}/view/suppliers`), tools: `${x}/items/tools`, diff --git a/packages/database/src/swagger-docs-schema.ts b/packages/database/src/swagger-docs-schema.ts index 0fef4854ba..77c569901c 100644 --- a/packages/database/src/swagger-docs-schema.ts +++ b/packages/database/src/swagger-docs-schema.ts @@ -10818,6 +10818,15 @@ export default { { $ref: "#/parameters/rowFilter.supplierPart.tags", }, + { + $ref: "#/parameters/rowFilter.supplierPart.lastPurchaseDate", + }, + { + $ref: "#/parameters/rowFilter.supplierPart.lastPOQuantity", + }, + { + $ref: "#/parameters/rowFilter.supplierPart.lastPOId", + }, { $ref: "#/parameters/select", }, @@ -10925,6 +10934,15 @@ export default { { $ref: "#/parameters/rowFilter.supplierPart.tags", }, + { + $ref: "#/parameters/rowFilter.supplierPart.lastPurchaseDate", + }, + { + $ref: "#/parameters/rowFilter.supplierPart.lastPOQuantity", + }, + { + $ref: "#/parameters/rowFilter.supplierPart.lastPOId", + }, { $ref: "#/parameters/preferReturn", }, @@ -10986,6 +11004,15 @@ export default { { $ref: "#/parameters/rowFilter.supplierPart.tags", }, + { + $ref: "#/parameters/rowFilter.supplierPart.lastPurchaseDate", + }, + { + $ref: "#/parameters/rowFilter.supplierPart.lastPOQuantity", + }, + { + $ref: "#/parameters/rowFilter.supplierPart.lastPOId", + }, { $ref: "#/parameters/body.supplierPart", }, @@ -23055,6 +23082,195 @@ export default { tags: ["job"], }, }, + "/supplierPartPrice": { + get: { + parameters: [ + { + $ref: "#/parameters/rowFilter.supplierPartPrice.supplierPartId", + }, + { + $ref: "#/parameters/rowFilter.supplierPartPrice.quantity", + }, + { + $ref: "#/parameters/rowFilter.supplierPartPrice.unitPrice", + }, + { + $ref: "#/parameters/rowFilter.supplierPartPrice.leadTime", + }, + { + $ref: "#/parameters/rowFilter.supplierPartPrice.sourceType", + }, + { + $ref: "#/parameters/rowFilter.supplierPartPrice.sourceDocumentId", + }, + { + $ref: "#/parameters/rowFilter.supplierPartPrice.companyId", + }, + { + $ref: "#/parameters/rowFilter.supplierPartPrice.createdBy", + }, + { + $ref: "#/parameters/rowFilter.supplierPartPrice.createdAt", + }, + { + $ref: "#/parameters/rowFilter.supplierPartPrice.updatedBy", + }, + { + $ref: "#/parameters/rowFilter.supplierPartPrice.updatedAt", + }, + { + $ref: "#/parameters/select", + }, + { + $ref: "#/parameters/order", + }, + { + $ref: "#/parameters/range", + }, + { + $ref: "#/parameters/rangeUnit", + }, + { + $ref: "#/parameters/offset", + }, + { + $ref: "#/parameters/limit", + }, + { + $ref: "#/parameters/preferCount", + }, + ], + responses: { + "200": { + description: "OK", + schema: { + items: { + $ref: "#/definitions/supplierPartPrice", + }, + type: "array", + }, + }, + "206": { + description: "Partial Content", + }, + }, + tags: ["supplierPartPrice"], + }, + post: { + parameters: [ + { + $ref: "#/parameters/body.supplierPartPrice", + }, + { + $ref: "#/parameters/select", + }, + { + $ref: "#/parameters/preferPost", + }, + ], + responses: { + "201": { + description: "Created", + }, + }, + tags: ["supplierPartPrice"], + }, + delete: { + parameters: [ + { + $ref: "#/parameters/rowFilter.supplierPartPrice.supplierPartId", + }, + { + $ref: "#/parameters/rowFilter.supplierPartPrice.quantity", + }, + { + $ref: "#/parameters/rowFilter.supplierPartPrice.unitPrice", + }, + { + $ref: "#/parameters/rowFilter.supplierPartPrice.leadTime", + }, + { + $ref: "#/parameters/rowFilter.supplierPartPrice.sourceType", + }, + { + $ref: "#/parameters/rowFilter.supplierPartPrice.sourceDocumentId", + }, + { + $ref: "#/parameters/rowFilter.supplierPartPrice.companyId", + }, + { + $ref: "#/parameters/rowFilter.supplierPartPrice.createdBy", + }, + { + $ref: "#/parameters/rowFilter.supplierPartPrice.createdAt", + }, + { + $ref: "#/parameters/rowFilter.supplierPartPrice.updatedBy", + }, + { + $ref: "#/parameters/rowFilter.supplierPartPrice.updatedAt", + }, + { + $ref: "#/parameters/preferReturn", + }, + ], + responses: { + "204": { + description: "No Content", + }, + }, + tags: ["supplierPartPrice"], + }, + patch: { + parameters: [ + { + $ref: "#/parameters/rowFilter.supplierPartPrice.supplierPartId", + }, + { + $ref: "#/parameters/rowFilter.supplierPartPrice.quantity", + }, + { + $ref: "#/parameters/rowFilter.supplierPartPrice.unitPrice", + }, + { + $ref: "#/parameters/rowFilter.supplierPartPrice.leadTime", + }, + { + $ref: "#/parameters/rowFilter.supplierPartPrice.sourceType", + }, + { + $ref: "#/parameters/rowFilter.supplierPartPrice.sourceDocumentId", + }, + { + $ref: "#/parameters/rowFilter.supplierPartPrice.companyId", + }, + { + $ref: "#/parameters/rowFilter.supplierPartPrice.createdBy", + }, + { + $ref: "#/parameters/rowFilter.supplierPartPrice.createdAt", + }, + { + $ref: "#/parameters/rowFilter.supplierPartPrice.updatedBy", + }, + { + $ref: "#/parameters/rowFilter.supplierPartPrice.updatedAt", + }, + { + $ref: "#/parameters/body.supplierPartPrice", + }, + { + $ref: "#/parameters/preferReturn", + }, + ], + responses: { + "204": { + description: "No Content", + }, + }, + tags: ["supplierPartPrice"], + }, + }, "/contact": { get: { parameters: [ @@ -72994,6 +73210,20 @@ export default { }, type: "array", }, + lastPurchaseDate: { + format: "timestamp with time zone", + type: "string", + }, + lastPOQuantity: { + format: "numeric", + type: "number", + }, + lastPOId: { + description: + "Note:\nThis is a Foreign Key to `purchaseOrder.id`.", + format: "text", + type: "string", + }, }, type: "object", }, @@ -78609,6 +78839,76 @@ export default { }, type: "object", }, + supplierPartPrice: { + required: [ + "supplierPartId", + "quantity", + "unitPrice", + "sourceType", + "companyId", + "createdBy", + "createdAt", + ], + properties: { + supplierPartId: { + description: "Note:\nThis is a Primary Key.", + format: "text", + type: "string", + }, + quantity: { + default: 1, + description: "Note:\nThis is a Primary Key.", + format: "numeric", + type: "number", + }, + unitPrice: { + format: "numeric", + type: "number", + }, + leadTime: { + default: 0, + format: "numeric", + type: "number", + }, + sourceType: { + default: "Quote", + format: "text", + type: "string", + }, + sourceDocumentId: { + format: "text", + type: "string", + }, + companyId: { + description: + "Note:\nThis is a Foreign Key to `company.id`.", + format: "text", + type: "string", + }, + createdBy: { + description: + "Note:\nThis is a Foreign Key to `user.id`.", + format: "text", + type: "string", + }, + createdAt: { + default: "now()", + format: "timestamp with time zone", + type: "string", + }, + updatedBy: { + description: + "Note:\nThis is a Foreign Key to `user.id`.", + format: "text", + type: "string", + }, + updatedAt: { + format: "timestamp with time zone", + type: "string", + }, + }, + type: "object", + }, contact: { required: ["id", "companyId", "isCustomer"], properties: { @@ -103740,6 +104040,24 @@ export default { in: "query", type: "string", }, + "rowFilter.supplierPart.lastPurchaseDate": { + name: "lastPurchaseDate", + required: false, + in: "query", + type: "string", + }, + "rowFilter.supplierPart.lastPOQuantity": { + name: "lastPOQuantity", + required: false, + in: "query", + type: "string", + }, + "rowFilter.supplierPart.lastPOId": { + name: "lastPOId", + required: false, + in: "query", + type: "string", + }, "body.partner": { name: "partner", description: "partner", @@ -110030,6 +110348,81 @@ export default { in: "query", type: "string", }, + "body.supplierPartPrice": { + name: "supplierPartPrice", + description: "supplierPartPrice", + required: false, + in: "body", + schema: { + $ref: "#/definitions/supplierPartPrice", + }, + }, + "rowFilter.supplierPartPrice.supplierPartId": { + name: "supplierPartId", + required: false, + in: "query", + type: "string", + }, + "rowFilter.supplierPartPrice.quantity": { + name: "quantity", + required: false, + in: "query", + type: "string", + }, + "rowFilter.supplierPartPrice.unitPrice": { + name: "unitPrice", + required: false, + in: "query", + type: "string", + }, + "rowFilter.supplierPartPrice.leadTime": { + name: "leadTime", + required: false, + in: "query", + type: "string", + }, + "rowFilter.supplierPartPrice.sourceType": { + name: "sourceType", + required: false, + in: "query", + type: "string", + }, + "rowFilter.supplierPartPrice.sourceDocumentId": { + name: "sourceDocumentId", + required: false, + in: "query", + type: "string", + }, + "rowFilter.supplierPartPrice.companyId": { + name: "companyId", + required: false, + in: "query", + type: "string", + }, + "rowFilter.supplierPartPrice.createdBy": { + name: "createdBy", + required: false, + in: "query", + type: "string", + }, + "rowFilter.supplierPartPrice.createdAt": { + name: "createdAt", + required: false, + in: "query", + type: "string", + }, + "rowFilter.supplierPartPrice.updatedBy": { + name: "updatedBy", + required: false, + in: "query", + type: "string", + }, + "rowFilter.supplierPartPrice.updatedAt": { + name: "updatedAt", + required: false, + in: "query", + type: "string", + }, "body.contact": { name: "contact", description: "contact", diff --git a/packages/database/src/types.ts b/packages/database/src/types.ts index c415da4a61..0420ffd81d 100644 --- a/packages/database/src/types.ts +++ b/packages/database/src/types.ts @@ -36041,6 +36041,9 @@ export type Database = { customFields: Json | null id: string itemId: string + lastPOId: string | null + lastPOQuantity: number | null + lastPurchaseDate: string | null minimumOrderQuantity: number | null supplierId: string supplierPartId: string | null @@ -36059,6 +36062,9 @@ export type Database = { customFields?: Json | null id?: string itemId: string + lastPOId?: string | null + lastPOQuantity?: number | null + lastPurchaseDate?: string | null minimumOrderQuantity?: number | null supplierId: string supplierPartId?: string | null @@ -36077,6 +36083,9 @@ export type Database = { customFields?: Json | null id?: string itemId?: string + lastPOId?: string | null + lastPOQuantity?: number | null + lastPurchaseDate?: string | null minimumOrderQuantity?: number | null supplierId?: string supplierPartId?: string | null @@ -36262,6 +36271,175 @@ export type Database = { referencedRelation: "userDefaults" referencedColumns: ["userId"] }, + { + foreignKeyName: "supplierPart_lastPOId_fkey" + columns: ["lastPOId"] + isOneToOne: false + referencedRelation: "purchaseOrder" + referencedColumns: ["id"] + }, + { + foreignKeyName: "supplierPart_lastPOId_fkey" + columns: ["lastPOId"] + isOneToOne: false + referencedRelation: "purchaseOrderLocations" + referencedColumns: ["id"] + }, + { + foreignKeyName: "supplierPart_lastPOId_fkey" + columns: ["lastPOId"] + isOneToOne: false + referencedRelation: "purchaseOrders" + referencedColumns: ["id"] + }, + ] + } + supplierPartPrice: { + Row: { + companyId: string + createdAt: string + createdBy: string + leadTime: number | null + quantity: number + sourceDocumentId: string | null + sourceType: string + supplierPartId: string + unitPrice: number + updatedAt: string | null + updatedBy: string | null + } + Insert: { + companyId: string + createdAt?: string + createdBy: string + leadTime?: number | null + quantity?: number + sourceDocumentId?: string | null + sourceType?: string + supplierPartId: string + unitPrice: number + updatedAt?: string | null + updatedBy?: string | null + } + Update: { + companyId?: string + createdAt?: string + createdBy?: string + leadTime?: number | null + quantity?: number + sourceDocumentId?: string | null + sourceType?: string + supplierPartId?: string + unitPrice?: number + updatedAt?: string | null + updatedBy?: string | null + } + Relationships: [ + { + foreignKeyName: "supplierPartPrice_companyId_fkey" + columns: ["companyId"] + isOneToOne: false + referencedRelation: "companies" + referencedColumns: ["id"] + }, + { + foreignKeyName: "supplierPartPrice_companyId_fkey" + columns: ["companyId"] + isOneToOne: false + referencedRelation: "company" + referencedColumns: ["id"] + }, + { + foreignKeyName: "supplierPartPrice_companyId_fkey" + columns: ["companyId"] + isOneToOne: false + referencedRelation: "customFieldTables" + referencedColumns: ["companyId"] + }, + { + foreignKeyName: "supplierPartPrice_companyId_fkey" + columns: ["companyId"] + isOneToOne: false + referencedRelation: "integrations" + referencedColumns: ["companyId"] + }, + { + foreignKeyName: "supplierPartPrice_createdBy_fkey" + columns: ["createdBy"] + isOneToOne: false + referencedRelation: "employees" + referencedColumns: ["id"] + }, + { + foreignKeyName: "supplierPartPrice_createdBy_fkey" + columns: ["createdBy"] + isOneToOne: false + referencedRelation: "employeesAcrossCompanies" + referencedColumns: ["id"] + }, + { + foreignKeyName: "supplierPartPrice_createdBy_fkey" + columns: ["createdBy"] + isOneToOne: false + referencedRelation: "employeeSummary" + referencedColumns: ["id"] + }, + { + foreignKeyName: "supplierPartPrice_createdBy_fkey" + columns: ["createdBy"] + isOneToOne: false + referencedRelation: "user" + referencedColumns: ["id"] + }, + { + foreignKeyName: "supplierPartPrice_createdBy_fkey" + columns: ["createdBy"] + isOneToOne: false + referencedRelation: "userDefaults" + referencedColumns: ["userId"] + }, + { + foreignKeyName: "supplierPartPrice_supplierPartId_companyId_fkey" + columns: ["supplierPartId", "companyId"] + isOneToOne: false + referencedRelation: "supplierPart" + referencedColumns: ["id", "companyId"] + }, + { + foreignKeyName: "supplierPartPrice_updatedBy_fkey" + columns: ["updatedBy"] + isOneToOne: false + referencedRelation: "employees" + referencedColumns: ["id"] + }, + { + foreignKeyName: "supplierPartPrice_updatedBy_fkey" + columns: ["updatedBy"] + isOneToOne: false + referencedRelation: "employeesAcrossCompanies" + referencedColumns: ["id"] + }, + { + foreignKeyName: "supplierPartPrice_updatedBy_fkey" + columns: ["updatedBy"] + isOneToOne: false + referencedRelation: "employeeSummary" + referencedColumns: ["id"] + }, + { + foreignKeyName: "supplierPartPrice_updatedBy_fkey" + columns: ["updatedBy"] + isOneToOne: false + referencedRelation: "user" + referencedColumns: ["id"] + }, + { + foreignKeyName: "supplierPartPrice_updatedBy_fkey" + columns: ["updatedBy"] + isOneToOne: false + referencedRelation: "userDefaults" + referencedColumns: ["userId"] + }, ] } supplierPayment: { diff --git a/packages/database/supabase/functions/convert/index.ts b/packages/database/supabase/functions/convert/index.ts index ef00dfd67d..f702924949 100644 --- a/packages/database/supabase/functions/convert/index.ts +++ b/packages/database/supabase/functions/convert/index.ts @@ -1502,6 +1502,20 @@ serve(async (req: Request) => { ) .forEach((line) => { const key = `${line.itemId}-${quote.data.supplierId}`; + const selectedLine = selectedLines![line.id!]; + // Calculate unit price in inventory unit (company currency) + // exchangeRate = supplier currency per 1 company currency (e.g., 130 JPY/USD) + // So: company price = supplierUnitPrice / exchangeRate + // Then divide by conversionFactor to get inventory unit price + const exchangeRate = quote.data.exchangeRate ?? 1; + const unitPriceInInventoryUnit = + (selectedLine.supplierUnitPrice / + (exchangeRate === 0 ? 1 : exchangeRate)) / + (line.conversionFactor ?? 1); + // Calculate quantity in inventory units + const quantityInInventoryUnits = + selectedLine.quantity * (line.conversionFactor ?? 1); + supplierPartMap.set(key, { companyId, supplierId: quote.data?.supplierId!, @@ -1510,6 +1524,11 @@ serve(async (req: Request) => { conversionFactor: line.conversionFactor, itemId: line.itemId!, createdBy: userId, + // New pricing fields + unitPrice: unitPriceInInventoryUnit, + lastPurchaseDate: new Date().toISOString(), + lastPOQuantity: quantityInInventoryUnits, + lastPOId: insertedPurchaseOrderId, }); }); @@ -1526,9 +1545,103 @@ serve(async (req: Request) => { .columns(["itemId", "supplierId", "companyId"]) .doUpdateSet((eb) => ({ supplierPartId: eb.ref("excluded.supplierPartId"), + // Update pricing fields on conflict + unitPrice: eb.ref("excluded.unitPrice"), + lastPurchaseDate: eb.ref("excluded.lastPurchaseDate"), + lastPOQuantity: eb.ref("excluded.lastPOQuantity"), + lastPOId: eb.ref("excluded.lastPOId"), })) ) .execute(); + + // Also upsert into supplierPartPrice with sourceType='PurchaseOrder' + // This confirms the price tier for the specific quantity ordered + const supplierParts = await trx + .selectFrom("supplierPart") + .select(["id", "itemId"]) + .where("supplierId", "=", quote.data.supplierId) + .where("companyId", "=", companyId) + .where( + "itemId", + "in", + supplierPartToItemInserts.map((i: { itemId: string }) => i.itemId) + ) + .execute(); + + const supplierPartIdByItemId = new Map( + supplierParts.map((sp) => [sp.itemId, sp.id]) + ); + + for (const line of quoteLines.data.filter( + (l) => + !!l.itemId && + l.id && + selectedLines && + l.id in selectedLines + )) { + const spId = supplierPartIdByItemId.get(line.itemId); + if (!spId) continue; + + const selectedLine = selectedLines![line.id!]; + const exchangeRate = quote.data.exchangeRate ?? 1; + const conversionFactor = line.conversionFactor ?? 1; + // exchangeRate = supplier currency per 1 company currency + // Divide to convert to company currency, then divide by conversionFactor + const unitPriceInInventoryUnit = + (selectedLine.supplierUnitPrice / + (exchangeRate === 0 ? 1 : exchangeRate)) / + conversionFactor; + + await trx + .insertInto("supplierPartPrice") + .values({ + supplierPartId: spId, + quantity: selectedLine.quantity, + unitPrice: unitPriceInInventoryUnit, + leadTime: selectedLine.leadTime ?? 0, + sourceType: "PurchaseOrder", + sourceDocumentId: insertedPurchaseOrderId, + companyId, + createdBy: userId, + updatedBy: userId, + updatedAt: new Date().toISOString(), + }) + .onConflict((oc) => + oc + .columns(["supplierPartId", "quantity"]) + .doUpdateSet((eb) => ({ + unitPrice: eb.ref("excluded.unitPrice"), + leadTime: eb.ref("excluded.leadTime"), + sourceType: eb.ref("excluded.sourceType"), + sourceDocumentId: eb.ref("excluded.sourceDocumentId"), + updatedBy: eb.ref("excluded.updatedBy"), + updatedAt: eb.ref("excluded.updatedAt"), + })) + ) + .execute(); + } + + // Update each supplierPart with the best (lowest) price and its + // corresponding MOQ across all tiers (quotes + POs + manual) + for (const [, spId] of supplierPartIdByItemId) { + const bestTier = await trx + .selectFrom("supplierPartPrice") + .select(["unitPrice", "quantity"]) + .where("supplierPartId", "=", spId) + .orderBy("unitPrice", "asc") + .executeTakeFirst(); + + if (bestTier) { + await trx + .updateTable("supplierPart") + .set({ + unitPrice: Number(bestTier.unitPrice), + minimumOrderQuantity: Number(bestTier.quantity), + }) + .where("id", "=", spId) + .execute(); + } + } } }); diff --git a/packages/database/supabase/functions/lib/types.ts b/packages/database/supabase/functions/lib/types.ts index c415da4a61..0420ffd81d 100644 --- a/packages/database/supabase/functions/lib/types.ts +++ b/packages/database/supabase/functions/lib/types.ts @@ -36041,6 +36041,9 @@ export type Database = { customFields: Json | null id: string itemId: string + lastPOId: string | null + lastPOQuantity: number | null + lastPurchaseDate: string | null minimumOrderQuantity: number | null supplierId: string supplierPartId: string | null @@ -36059,6 +36062,9 @@ export type Database = { customFields?: Json | null id?: string itemId: string + lastPOId?: string | null + lastPOQuantity?: number | null + lastPurchaseDate?: string | null minimumOrderQuantity?: number | null supplierId: string supplierPartId?: string | null @@ -36077,6 +36083,9 @@ export type Database = { customFields?: Json | null id?: string itemId?: string + lastPOId?: string | null + lastPOQuantity?: number | null + lastPurchaseDate?: string | null minimumOrderQuantity?: number | null supplierId?: string supplierPartId?: string | null @@ -36262,6 +36271,175 @@ export type Database = { referencedRelation: "userDefaults" referencedColumns: ["userId"] }, + { + foreignKeyName: "supplierPart_lastPOId_fkey" + columns: ["lastPOId"] + isOneToOne: false + referencedRelation: "purchaseOrder" + referencedColumns: ["id"] + }, + { + foreignKeyName: "supplierPart_lastPOId_fkey" + columns: ["lastPOId"] + isOneToOne: false + referencedRelation: "purchaseOrderLocations" + referencedColumns: ["id"] + }, + { + foreignKeyName: "supplierPart_lastPOId_fkey" + columns: ["lastPOId"] + isOneToOne: false + referencedRelation: "purchaseOrders" + referencedColumns: ["id"] + }, + ] + } + supplierPartPrice: { + Row: { + companyId: string + createdAt: string + createdBy: string + leadTime: number | null + quantity: number + sourceDocumentId: string | null + sourceType: string + supplierPartId: string + unitPrice: number + updatedAt: string | null + updatedBy: string | null + } + Insert: { + companyId: string + createdAt?: string + createdBy: string + leadTime?: number | null + quantity?: number + sourceDocumentId?: string | null + sourceType?: string + supplierPartId: string + unitPrice: number + updatedAt?: string | null + updatedBy?: string | null + } + Update: { + companyId?: string + createdAt?: string + createdBy?: string + leadTime?: number | null + quantity?: number + sourceDocumentId?: string | null + sourceType?: string + supplierPartId?: string + unitPrice?: number + updatedAt?: string | null + updatedBy?: string | null + } + Relationships: [ + { + foreignKeyName: "supplierPartPrice_companyId_fkey" + columns: ["companyId"] + isOneToOne: false + referencedRelation: "companies" + referencedColumns: ["id"] + }, + { + foreignKeyName: "supplierPartPrice_companyId_fkey" + columns: ["companyId"] + isOneToOne: false + referencedRelation: "company" + referencedColumns: ["id"] + }, + { + foreignKeyName: "supplierPartPrice_companyId_fkey" + columns: ["companyId"] + isOneToOne: false + referencedRelation: "customFieldTables" + referencedColumns: ["companyId"] + }, + { + foreignKeyName: "supplierPartPrice_companyId_fkey" + columns: ["companyId"] + isOneToOne: false + referencedRelation: "integrations" + referencedColumns: ["companyId"] + }, + { + foreignKeyName: "supplierPartPrice_createdBy_fkey" + columns: ["createdBy"] + isOneToOne: false + referencedRelation: "employees" + referencedColumns: ["id"] + }, + { + foreignKeyName: "supplierPartPrice_createdBy_fkey" + columns: ["createdBy"] + isOneToOne: false + referencedRelation: "employeesAcrossCompanies" + referencedColumns: ["id"] + }, + { + foreignKeyName: "supplierPartPrice_createdBy_fkey" + columns: ["createdBy"] + isOneToOne: false + referencedRelation: "employeeSummary" + referencedColumns: ["id"] + }, + { + foreignKeyName: "supplierPartPrice_createdBy_fkey" + columns: ["createdBy"] + isOneToOne: false + referencedRelation: "user" + referencedColumns: ["id"] + }, + { + foreignKeyName: "supplierPartPrice_createdBy_fkey" + columns: ["createdBy"] + isOneToOne: false + referencedRelation: "userDefaults" + referencedColumns: ["userId"] + }, + { + foreignKeyName: "supplierPartPrice_supplierPartId_companyId_fkey" + columns: ["supplierPartId", "companyId"] + isOneToOne: false + referencedRelation: "supplierPart" + referencedColumns: ["id", "companyId"] + }, + { + foreignKeyName: "supplierPartPrice_updatedBy_fkey" + columns: ["updatedBy"] + isOneToOne: false + referencedRelation: "employees" + referencedColumns: ["id"] + }, + { + foreignKeyName: "supplierPartPrice_updatedBy_fkey" + columns: ["updatedBy"] + isOneToOne: false + referencedRelation: "employeesAcrossCompanies" + referencedColumns: ["id"] + }, + { + foreignKeyName: "supplierPartPrice_updatedBy_fkey" + columns: ["updatedBy"] + isOneToOne: false + referencedRelation: "employeeSummary" + referencedColumns: ["id"] + }, + { + foreignKeyName: "supplierPartPrice_updatedBy_fkey" + columns: ["updatedBy"] + isOneToOne: false + referencedRelation: "user" + referencedColumns: ["id"] + }, + { + foreignKeyName: "supplierPartPrice_updatedBy_fkey" + columns: ["updatedBy"] + isOneToOne: false + referencedRelation: "userDefaults" + referencedColumns: ["userId"] + }, ] } supplierPayment: { diff --git a/packages/database/supabase/migrations/20260129150000_quantity-price-breaks.sql b/packages/database/supabase/migrations/20260129150000_quantity-price-breaks.sql new file mode 100644 index 0000000000..9d2be8ed3b --- /dev/null +++ b/packages/database/supabase/migrations/20260129150000_quantity-price-breaks.sql @@ -0,0 +1,96 @@ +-- Add pricing fields to supplierPart table and create supplierPartPrice child table +-- This enables quantity-based pricing for customer quote material costing + +-- ============================================================================ +-- Part 1: Add "last price" fields to supplierPart (backward compat / quick ref) +-- ============================================================================ + +ALTER TABLE "supplierPart" +ADD COLUMN IF NOT EXISTS "unitPrice" NUMERIC(15, 5), +ADD COLUMN IF NOT EXISTS "lastPurchaseDate" TIMESTAMP WITH TIME ZONE, +ADD COLUMN IF NOT EXISTS "lastPOQuantity" NUMERIC(20, 2), +ADD COLUMN IF NOT EXISTS "lastPOId" TEXT; + +ALTER TABLE "supplierPart" +ADD CONSTRAINT "supplierPart_lastPOId_fkey" +FOREIGN KEY ("lastPOId") REFERENCES "purchaseOrder"("id") +ON DELETE SET NULL ON UPDATE CASCADE; + +-- ============================================================================ +-- Part 2: Create supplierPartPrice child table (quantity scales) +-- ============================================================================ + +CREATE TABLE "supplierPartPrice" ( + "supplierPartId" TEXT NOT NULL, + "quantity" NUMERIC(20, 2) NOT NULL DEFAULT 1, + "unitPrice" NUMERIC(15, 5) NOT NULL, + "leadTime" NUMERIC(10, 5) DEFAULT 0, + "sourceType" TEXT NOT NULL DEFAULT 'Quote', + "sourceDocumentId" TEXT, + "companyId" TEXT NOT NULL, + "createdBy" TEXT NOT NULL, + "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + "updatedBy" TEXT, + "updatedAt" TIMESTAMP WITH TIME ZONE, + + CONSTRAINT "supplierPartPrice_pkey" PRIMARY KEY ("supplierPartId", "quantity"), + CONSTRAINT "supplierPartPrice_supplierPartId_companyId_fkey" + FOREIGN KEY ("supplierPartId", "companyId") REFERENCES "supplierPart"("id", "companyId") + ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "supplierPartPrice_companyId_fkey" + FOREIGN KEY ("companyId") REFERENCES "company"("id") + ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "supplierPartPrice_createdBy_fkey" + FOREIGN KEY ("createdBy") REFERENCES "user"("id"), + CONSTRAINT "supplierPartPrice_updatedBy_fkey" + FOREIGN KEY ("updatedBy") REFERENCES "user"("id"), + CONSTRAINT "supplierPartPrice_sourceType_check" + CHECK ("sourceType" IN ('Quote', 'PurchaseOrder', 'Manual')) +); + +-- RLS: same access patterns as supplierPart +ALTER TABLE "supplierPartPrice" ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Employees with part/purchasing_view can view supplier part prices" ON "supplierPartPrice" + FOR SELECT + USING ( + ( + has_company_permission('parts_view', "companyId") OR + has_company_permission('purchasing_view', "companyId") + ) + AND has_role('employee', "companyId") + ); + +CREATE POLICY "Employees with parts_create can create supplier part prices" ON "supplierPartPrice" + FOR INSERT + WITH CHECK ( + has_role('employee', "companyId") AND + has_company_permission('parts_create', "companyId") + ); + +CREATE POLICY "Employees with parts_update can update supplier part prices" ON "supplierPartPrice" + FOR UPDATE + USING ( + has_role('employee', "companyId") AND + has_company_permission('parts_update', "companyId") + ); + +CREATE POLICY "Employees with parts_delete can delete supplier part prices" ON "supplierPartPrice" + FOR DELETE + USING ( + has_role('employee', "companyId") AND + has_company_permission('parts_delete', "companyId") + ); + +-- Suppliers can view their own supplier part prices +CREATE POLICY "Suppliers can view their own supplier part prices" ON "supplierPartPrice" + FOR SELECT + USING ( + has_role('supplier', "companyId") AND + has_company_permission('parts_view', "companyId") AND + "supplierPartId" IN ( + SELECT "id" FROM "supplierPart" WHERE "supplierId" IN ( + SELECT "supplierId" FROM "supplierAccount" WHERE id::uuid = auth.uid() + ) + ) + ); diff --git a/packages/documents/src/pdf/IssuePDF.tsx b/packages/documents/src/pdf/IssuePDF.tsx index 65cb1d642d..a7329583f7 100644 --- a/packages/documents/src/pdf/IssuePDF.tsx +++ b/packages/documents/src/pdf/IssuePDF.tsx @@ -185,9 +185,7 @@ const IssuePDF = ({ "flex flex-row gap-2 text-[10px] py-1 border-b border-gray-200" )} > - - Item: - + Item: {item.documentReadableId} @@ -426,9 +424,7 @@ const IssuePDF = ({ Inspections {jobOperationStepRecords - .filter( - (step) => step.nonConformanceActionId === task.id - ) + .filter((step) => step.nonConformanceActionId === task.id) .map((step) => step.jobOperationStepRecord ?.filter((record) => record.booleanValue !== null) @@ -467,18 +463,14 @@ const IssuePDF = ({ {step.name} {operationToJobId[step.operationId] && ( <> - Job{" "} - {operationToJobId[step.operationId]} •{" "} + Job {operationToJobId[step.operationId]} •{" "} )} - {assignees[record.createdBy] || "Unknown"}{" "} - •{" "} + {assignees[record.createdBy] || "Unknown"} •{" "} { new Date(record.createdAt) .toISOString() diff --git a/packages/documents/src/pdf/PackingSlipPDF.tsx b/packages/documents/src/pdf/PackingSlipPDF.tsx index 6e1049cc94..419993391a 100644 --- a/packages/documents/src/pdf/PackingSlipPDF.tsx +++ b/packages/documents/src/pdf/PackingSlipPDF.tsx @@ -117,9 +117,7 @@ const PackingSlipPDF = ({ {customer.name && ( {customer.name} )} - {addressLine1 && ( - {addressLine1} - )} + {addressLine1 && {addressLine1}} {addressLine2 && {addressLine2}} {(city || stateProvince || postalCode) && ( @@ -177,9 +175,7 @@ const PackingSlipPDF = ({ Payment - {paymentTerm?.name && ( - Terms: {paymentTerm.name} - )} + {paymentTerm?.name && Terms: {paymentTerm.name}} @@ -229,9 +225,7 @@ const PackingSlipPDF = ({ wrap={false} > {getLineDescription(line)} @@ -252,10 +246,7 @@ const PackingSlipPDF = ({ )} - + diff --git a/packages/documents/src/pdf/QuotePDF.tsx b/packages/documents/src/pdf/QuotePDF.tsx index 8426f54761..2f2d278051 100644 --- a/packages/documents/src/pdf/QuotePDF.tsx +++ b/packages/documents/src/pdf/QuotePDF.tsx @@ -388,7 +388,9 @@ const QuotePDF = ({ `${colWidth} text-right text-gray-600 pr-3` )} > - {leadTime > 0 ? `${leadTime} ${pluralize(leadTime, "day")}` : "-"} + {leadTime > 0 + ? `${leadTime} ${pluralize(leadTime, "day")}` + : "-"} )}