diff --git a/.changeset/breezy-spies-smoke.md b/.changeset/breezy-spies-smoke.md new file mode 100644 index 000000000..985b11d66 --- /dev/null +++ b/.changeset/breezy-spies-smoke.md @@ -0,0 +1,5 @@ +--- +"@nmi-agro/fdm-core": minor +--- + +Added `getEnabledCultivationCataloguesForFarms` and `getEnabledFertilizerCataloguesForFarms` to retrieve the enabled catalogues for multiple farms in one query. Added `getCultivationsFromCatalogues` and `getFertilizersFromCatalogues` to fetch catalogue items for a given list of catalogue source IDs. These composable building blocks replace the removed `getCultivationsFromCatalogueForFarms` and `getFertilizersFromCatalogueForFarms` functions. diff --git a/.changeset/green-pumas-flash.md b/.changeset/green-pumas-flash.md new file mode 100644 index 000000000..1acf5a809 --- /dev/null +++ b/.changeset/green-pumas-flash.md @@ -0,0 +1,5 @@ +--- +"@nmi-agro/fdm-app": minor +--- + +Added organization-level nitrogen and organic matter balance plots with option to exclude certain farms from the calculation. diff --git a/.changeset/thin-steaks-sneeze.md b/.changeset/thin-steaks-sneeze.md new file mode 100644 index 000000000..fcdbf04f4 --- /dev/null +++ b/.changeset/thin-steaks-sneeze.md @@ -0,0 +1,5 @@ +--- +"@nmi-agro/fdm-calculator": minor +--- + +Added `collectInputForNitrogenBalanceForFarms` and `collectInputForOrganicMatterBalanceForFarms` to collect balance inputs for multiple farms, reducing database lookups by deduplicating catalogue queries across farms. The functions use a composable pattern: first fetch enabled catalogues for all farms in one query, then fetch catalogue items once per unique catalogue, then process each farm individually. diff --git a/fdm-app/app/components/blocks/balance/farm-select-dialog.tsx b/fdm-app/app/components/blocks/balance/farm-select-dialog.tsx new file mode 100644 index 000000000..684799d50 --- /dev/null +++ b/fdm-app/app/components/blocks/balance/farm-select-dialog.tsx @@ -0,0 +1,136 @@ +import { useRef } from "react" +import { useSearchParams } from "react-router" +import { Button } from "~/components/ui/button" +import { Checkbox } from "~/components/ui/checkbox" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "~/components/ui/dialog" + +/** + * Renders a button which, when clicked, shows a dialog where the user can change selection of farms included in balance calculation. + * + * - `farms` should be the complete list of farms that the user can select or ignore. + * - `defaultSelectedFarmIds` should be coming from the loader data after validation, and is used to set the initial state of the checkboxes. + * + * The dialog will set the `farmIds` search param directly when the selection changes. + * + * @param param0 component props + * @returns a React node + */ +export function FarmSelectDialog({ + farms, + defaultSelectedFarmIds, +}: { + farms: { + b_id_farm: string + b_name_farm: string | null + b_area_farm: number + }[] + defaultSelectedFarmIds: string[] +}) { + const formRef = useRef(null) + const [, setSearchParams] = useSearchParams() + + return ( + + + + + + + Wijzig selectie van bedrijven + + De geselecteerde bedrijven zijn uitgesloten in de + berekening. + + +
+ {farms.flatMap((farm) => { + const b_id_farm = farm.b_id_farm + const currentValue = defaultSelectedFarmIds.includes( + farm.b_id_farm, + ) + return ( +
+ +
+ {farm.b_name_farm ?? "Onbekend"} +
+
+ {Math.round(farm.b_area_farm * 10) / 10} ha +
+
+ ) + })} +
+ + { + const form = formRef.current + + const newlySelectedFarmIds: string[] = [] + if (form) { + const formData = new FormData(form) + for (const [ + b_id_farm, + selected, + ] of formData.entries()) { + if (selected) { + newlySelectedFarmIds.push(b_id_farm) + } + } + } + const sortedDefaultSelectedFarmIds = [ + ...defaultSelectedFarmIds, + ].sort() + newlySelectedFarmIds.sort() + if ( + sortedDefaultSelectedFarmIds.length !== + newlySelectedFarmIds.length || + newlySelectedFarmIds.some( + (selected_id, index) => + selected_id !== + sortedDefaultSelectedFarmIds[index], + ) + ) { + setSearchParams((searchParams) => { + const newSearchParams = new URLSearchParams( + searchParams, + ) + if (newlySelectedFarmIds.length > 0) { + newSearchParams.set( + "farmIds", + newlySelectedFarmIds.join(","), + ) + } else { + newSearchParams.delete("farmIds") + } + return newSearchParams + }) + } + }} + > + + + +
+
+ ) +} diff --git a/fdm-app/app/components/blocks/balance/nitrogen-chart.tsx b/fdm-app/app/components/blocks/balance/nitrogen-chart.tsx index bb7467bba..104f05d47 100644 --- a/fdm-app/app/components/blocks/balance/nitrogen-chart.tsx +++ b/fdm-app/app/components/blocks/balance/nitrogen-chart.tsx @@ -424,7 +424,7 @@ function buildChartDataAndLegend({ } export function NitrogenBalanceChart( - props: { balanceData: { balance: number; removal: number } } & ( + props: ( | { type: "farm"; balanceData: FarmBalanceData; fieldInput: unknown } | { type: "field" diff --git a/fdm-app/app/components/blocks/header/organization.tsx b/fdm-app/app/components/blocks/header/organization.tsx index 267fef9ca..592f4cf6b 100644 --- a/fdm-app/app/components/blocks/header/organization.tsx +++ b/fdm-app/app/components/blocks/header/organization.tsx @@ -1,5 +1,5 @@ import { ChevronDown } from "lucide-react" -import { NavLink, useLocation, useMatches } from "react-router" +import { NavLink, useLocation, useMatches, useParams } from "react-router" import { BreadcrumbItem, BreadcrumbLink, @@ -20,9 +20,10 @@ export function HeaderOrganization({ organizationOptions: HeaderOrganizationOption[] }) { const location = useLocation() + const params = useParams() + const matches = useMatches() const currentPath = String(location.pathname) - const matches = useMatches() const isSettingsRoute = !!matches.find( (match) => match.id === "routes/organization.$slug.settings", ) @@ -40,6 +41,14 @@ export function HeaderOrganization({ const isNewOrganizationRoute = !!matches.find( (match) => match.id === "routes/organization.new", ) + const typesOfBalanceRoutes = ["nitrogen", "organic-matter"] as const + const farmBalanceRouteType = typesOfBalanceRoutes.find((type) => + matches.find( + (match) => + match.id === + `routes/organization.$slug.$calendar.balance.${type}._index`, + ), + ) return ( <> @@ -123,6 +132,58 @@ export function HeaderOrganization({ Leden + ) : farmBalanceRouteType ? ( + <> + + + + Balans + + + + + + + + {farmBalanceRouteType === "nitrogen" + ? "Stikstof" + : "Organische stof"} + + + + + + + Stikstof + + + + + Organische stof + + + + + + ) : isFarmsRoute ? ( <> diff --git a/fdm-app/app/components/blocks/organization/no-farms-message.tsx b/fdm-app/app/components/blocks/organization/no-farms-message.tsx new file mode 100644 index 000000000..42401b3a4 --- /dev/null +++ b/fdm-app/app/components/blocks/organization/no-farms-message.tsx @@ -0,0 +1,36 @@ +import { NavLink } from "react-router" +import { Button } from "~/components/ui/button" +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyTitle, +} from "~/components/ui/empty" + +export function NoFarmsMessage({ + action, +}: { + action?: { label: string; to: string } +}) { + return ( + + + + Het lijkt erop dat je organisatie geen toegang heeft tot + bedrijven. :( + + + Neem contact op met bedrijven om toegang tot hen te krijgen. + + + {action && ( + + + + )} + + ) +} diff --git a/fdm-app/app/components/blocks/sidebar/organization-apps.tsx b/fdm-app/app/components/blocks/sidebar/organization-apps.tsx new file mode 100644 index 000000000..7fe0255ba --- /dev/null +++ b/fdm-app/app/components/blocks/sidebar/organization-apps.tsx @@ -0,0 +1,118 @@ +import { ArrowRightLeft, Minus, Plus } from "lucide-react" +import { NavLink, useLocation, useParams } from "react-router" +import { useCalendarStore } from "@/app/store/calendar" +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "~/components/ui/collapsible" +import { + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuSub, + SidebarMenuSubButton, + SidebarMenuSubItem, +} from "~/components/ui/sidebar" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "~/components/ui/tooltip" + +export function SidebarOrganizationApps() { + const location = useLocation() + const params = useParams() + + const storedCalendar = useCalendarStore((store) => store.calendar) + const calendar = params.calendar ?? storedCalendar + + const nitrogenBalanceLink = params.slug + ? `/organization/${params.slug}/${calendar}/balance/nitrogen` + : undefined + const omBalanceLink = params.slug + ? `/organization/${params.slug}/${calendar}/balance/organic-matter` + : undefined + + return ( + + Apps + + + + + {nitrogenBalanceLink ? ( + + + + Balans + + + + + ) : ( + + + + + + Balans + + + + + Selecteer een bedrijf om de balansen te + raadplegen + + + )} + + + + {nitrogenBalanceLink ? ( + + + Stikstof + + + ) : null} + + + {omBalanceLink ? ( + + + Organische stof + + + ) : null} + + + + + + + + + ) +} diff --git a/fdm-app/app/routes/about.whats-new._index.tsx b/fdm-app/app/routes/about.whats-new._index.tsx index 9de329fcc..ec781e8f8 100644 --- a/fdm-app/app/routes/about.whats-new._index.tsx +++ b/fdm-app/app/routes/about.whats-new._index.tsx @@ -84,10 +84,10 @@ export const changelogEntries: ChangelogEntry[] = [ date: "27 november 2025", title: "Nieuw: Bouwplan & OS Balans. Oogstregistratie is verbeterd", description: - "Deze update introduceert de Organische Stof Balans voor inzicht in bodemgezondheid, een nieuw Bouwplan pagina voor efficiënt gewasbeheer, en voegt nitraatuitspoeling toe aan de stikstofbalans.", + "Deze update introduceert de Organische stofbalans voor inzicht in bodemgezondheid, een nieuw Bouwplan pagina voor efficiënt gewasbeheer, en voegt nitraatuitspoeling toe aan de stikstofbalans.", items: [ "Bouwplan & Bulkacties: De nieuwe bouwplanpagina biedt een centraal overzicht van alle teelten op uw bedrijf. U kunt hier niet alleen uw bouwplan inzien, maar ook direct acties uitvoeren voor meerdere percelen tegelijk, zoals het toevoegen van een bemesting of oogst voor alle percelen met hetzelfde gewas.", - "OS Balans: Met de nieuwe 'Organische Stof Balans' krijgt u inzicht in de aanvoer van effectieve organische stof (EOS) uit gewassen, gewasresten en meststoffen, en de afbraak van organische stof. Dit helpt u bij het maken van plannen voor een gezonde bodem op de lange termijn.", + "OS Balans: Met de nieuwe 'Organische stofbalans' krijgt u inzicht in de aanvoer van effectieve organische stof (EOS) uit gewassen, gewasresten en meststoffen, en de afbraak van organische stof. Dit helpt u bij het maken van plannen voor een gezonde bodem op de lange termijn.", "Verbeterde Oogstregistratie: Het registreren van oogsten is slimmer en nauwkeuriger geworden. Het formulier vraagt nu specifiek om de parameters die relevant zijn voor het gekozen gewas (zoals vers opbrengst, tarra, droge stof, etc.). Voor niet-oogstbare gewassen (zoals groene braak) wordt de optie om te oogsten verborgen.", "Nitraatuitspoeling in Stikstofbalans: De stikstofbalans geeft nu een completer beeld door ook nitraatuitspoeling (NO3) inzichtelijk te maken, naast de al bestaande ammoniakemissie (NH3). De grafiek maakt nu ook onderscheid tussen deze twee emissiestromen.", "Kaartlagen Beheren: Op de kaarten is een nieuwe knop toegevoegd waarmee u de perceelslaag eenvoudig kunt verbergen of tonen, zodat u de basiskaart eronder beter kunt zien als u dat wilt.", diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.balance.organic-matter.$b_id.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.balance.organic-matter.$b_id.tsx index fa40106c8..6281bbf38 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.balance.organic-matter.$b_id.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.balance.organic-matter.$b_id.tsx @@ -40,7 +40,7 @@ import { useCalendarStore } from "~/store/calendar" export const meta: MetaFunction = () => { return [ { - title: `Organische Stof | Perceel | Nutriëntenbalans| ${clientConfig.name}`, + title: `Organische stof | Perceel | Nutriëntenbalans| ${clientConfig.name}`, }, { name: "description", diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.balance.organic-matter._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.balance.organic-matter._index.tsx index 9fca3ab55..5546a4e42 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.balance.organic-matter._index.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.balance.organic-matter._index.tsx @@ -37,7 +37,7 @@ import { fdm } from "~/lib/fdm.server" export const meta: MetaFunction = () => { return [ { - title: `Organische Stof | Bedrijf | Nutriëntenbalans| ${clientConfig.name}`, + title: `Organische stof | Bedrijf | Nutriëntenbalans| ${clientConfig.name}`, }, { name: "description", diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.balance.organic-matter.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.balance.organic-matter.tsx index cf328144b..20a130ff2 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.balance.organic-matter.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.balance.organic-matter.tsx @@ -19,7 +19,7 @@ import { fdm } from "~/lib/fdm.server" // Meta export const meta: MetaFunction = () => { return [ - { title: `Organische Stof | Nutriëntenbalans| ${clientConfig.name}` }, + { title: `Organische stof | Nutriëntenbalans| ${clientConfig.name}` }, { name: "description", content: "Bekijk de organische stofbalans van je bedrijf.", diff --git a/fdm-app/app/routes/organization.$slug.$calendar.balance.nitrogen._index.tsx b/fdm-app/app/routes/organization.$slug.$calendar.balance.nitrogen._index.tsx new file mode 100644 index 000000000..e7cf7fc3a --- /dev/null +++ b/fdm-app/app/routes/organization.$slug.$calendar.balance.nitrogen._index.tsx @@ -0,0 +1,605 @@ +import { + calculateNitrogenBalanceForFarms, + calculateNitrogenBalancesFieldToFarm, + collectInputForNitrogenBalanceForFarms, + type NitrogenBalanceFieldResultNumeric, + type NitrogenBalanceNumeric, +} from "@nmi-agro/fdm-calculator" +import { getFarms, getFields } from "@nmi-agro/fdm-core" +import { + ArrowDown, + ArrowRight, + ArrowRightFromLine, + ArrowRightLeft, + ArrowUpFromLine, + CircleAlert, + CircleCheck, + CircleX, +} from "lucide-react" +import { Suspense, use } from "react" +import { + data, + type LoaderFunctionArgs, + type MetaFunction, + NavLink, + useLoaderData, + useParams, +} from "react-router" +import { BufferStripInfo } from "~/components/blocks/balance/buffer-strip-info" +import { NitrogenBalanceChart } from "~/components/blocks/balance/nitrogen-chart" +import { NitrogenBalanceFallback } from "~/components/blocks/balance/skeletons" +import { NoFarmsMessage } from "~/components/blocks/organization/no-farms-message" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "~/components/ui/card" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "~/components/ui/tooltip" +import { auth, getSession } from "~/lib/auth.server" +import { getTimeframe } from "~/lib/calendar" +import { clientConfig } from "~/lib/config" +import { handleLoaderError, reportError } from "~/lib/error" +import { fdm } from "~/lib/fdm.server" +import { FarmSelectDialog } from "../components/blocks/balance/farm-select-dialog" + +type Farm = Awaited>[number] +type Organization = Awaited< + ReturnType +>[number] +type FarmResult = { + farm: Farm + totalArea: number + nitrogenBalanceResult: NitrogenBalanceNumeric & { + errorMessage?: string + } +} +type FarmExtended = Farm & { b_area_farm: number } +type AsyncData = { + farmResults: FarmResult[] + combinedResult: NitrogenBalanceNumeric + farms: FarmExtended[] +} +type LoaderData = + | { + farmIds: string[] + organization: Organization + noFarms: true + } + | { + farmIds: string[] + organization: Organization + noFarms: false + asyncData: Promise + } +// Meta +export const meta: MetaFunction = () => { + return [ + { + title: `Stikstof | Organisatie | Nutriëntenbalans| ${clientConfig.name}`, + }, + { + name: "description", + content: "Bekijk stikstof voor je nutriëntenbalans.", + }, + ] +} + +export async function loader({ + request, + params, +}: LoaderFunctionArgs): Promise { + try { + // Get the organization + const slug = params.slug + if (!slug) { + throw data("missing: slug", { + status: 404, + statusText: "missing: slug", + }) + } + + const url = new URL(request.url) + + let searchParamFarmIds: string[] | undefined + if (url.searchParams.has("farmIds")) { + searchParamFarmIds = url.searchParams + .get("farmIds") + ?.split(",") + .filter(Boolean) + if (!searchParamFarmIds || searchParamFarmIds.length === 0) { + throw data("invalid: farmIds", { + status: 400, + statusText: "invalid: farmIds", + }) + } + } + + // Get timeframe from calendar store + const timeframe = getTimeframe(params) + + // Get the user's session too (for error reporting) + const session = await getSession(request) + + const allOrganizations = await auth.api.listOrganizations({ + headers: request.headers, + }) + const organization = allOrganizations.find((org) => org.slug === slug) + if (!organization) { + throw data(`not found: ${slug}`, { + status: 404, + statusText: `not found: ${slug}`, + }) + } + + const farms = await getFarms(fdm, organization.id) + + // If the organization has no access to any farms, render the empty message + if (farms.length === 0) { + return { + organization: organization, + noFarms: true, + farmIds: [], + } + } + + const farmIds = + searchParamFarmIds ?? farms.map((farm) => farm.b_id_farm) + + const allFarmIds = new Set(farms.map((farm) => farm.b_id_farm)) + + if (farmIds.some((b_id_farm) => !allFarmIds.has(b_id_farm))) { + const statusText = + "You do not have permission to compute nitrogen balance for these farms" + throw data(statusText, { + status: 403, + statusText: statusText, + }) + } + + async function getAsyncData(principal_id: string) { + const inputs = await collectInputForNitrogenBalanceForFarms( + fdm, + principal_id, + farmIds, + timeframe, + ) + const fieldToFarmMap: Record = {} + for (const farmInput of inputs) { + for (const fieldInput of farmInput.fields) { + fieldToFarmMap[fieldInput.field.b_id] = farmInput.b_id_farm + } + } + + const combinedResult = await calculateNitrogenBalanceForFarms( + fdm, + inputs, + ) + const rawFarmResultsMap: Record< + string, + NitrogenBalanceFieldResultNumeric[] + > = {} + for (const result of combinedResult.fields) { + const b_id_farm = fieldToFarmMap[result.b_id] + if (!b_id_farm) { + console.warn( + `Field ${result.b_id} not found in fieldToFarmMap, skipping`, + ) + continue + } + rawFarmResultsMap[b_id_farm] ??= [] + rawFarmResultsMap[b_id_farm].push(result) + } + + // Compute farms + const farmsExtended = await Promise.all( + farms.map(async (farm) => { + const fields = await getFields( + fdm, + principal_id, + farm.b_id_farm, + ) + + const b_area_farm = fields.reduce( + (totalArea, field) => totalArea + (field.b_area ?? 0), + 0, + ) + + return { + ...farm, + b_area_farm: b_area_farm, + } + }), + ) + + // Sort farms by descending area, which will in turn also cause the results to be sorted + farmsExtended.sort((f1, f2) => f2.b_area_farm - f1.b_area_farm) + + const selectedFarmIds = new Set(farmIds) + const farmResults = await Promise.all( + farmsExtended + .filter((farm) => selectedFarmIds.has(farm.b_id_farm)) + .map(async (farm) => { + const fieldResults = rawFarmResultsMap[farm.b_id_farm] + if (!fieldResults || fieldResults.length === 0) { + return { + farm: farm, + totalArea: farm.b_area_farm, + nitrogenBalanceResult: { + hasErrors: true, + errorMessage: + "Geen veldgegevens beschikbaar", + } as NitrogenBalanceNumeric & { + errorMessage?: string + }, + } + } + try { + const nitrogenBalanceResult = + calculateNitrogenBalancesFieldToFarm( + fieldResults, + fieldResults.some( + (result) => result.errorMessage, + ), + fieldResults + .filter((result) => result.errorMessage) + .map( + (result) => result.errorMessage, + ) as string[], + ) + if (nitrogenBalanceResult.hasErrors) { + reportError( + nitrogenBalanceResult.fieldErrorMessages.join( + ",\n", + ), + { + page: "organization/{slug}/{calendar}/balance/nitrogen/_index", + scope: "loader", + }, + { + b_id_farm: farm.b_id_farm, + timeframe, + userId: session.principal_id, + }, + ) + } + + return { + farm: farm, + totalArea: farm.b_area_farm, + nitrogenBalanceResult: + nitrogenBalanceResult as NitrogenBalanceNumeric & { + errorMessage?: string + }, + } + } catch (error) { + return { + farm: farm, + totalArea: farm.b_area_farm, + nitrogenBalanceResult: { + hasErrors: true, + errorMessage: + error instanceof Error + ? error.message + : String(error), + } as NitrogenBalanceNumeric & { + errorMessage?: string + }, + } + } + }), + ) + + return { + farms: farmsExtended, + farmResults: farmResults, + combinedResult: combinedResult, + } + } + + const asyncData = getAsyncData(organization.id) + + return { + farmIds: farmIds.sort(), + organization: organization, + noFarms: false, + asyncData: asyncData, + } + } catch (error) { + throw handleLoaderError(error) + } +} + +export default function FarmBalanceNitrogenOverviewBlock() { + const loaderData = useLoaderData() + const farmIds = !loaderData.noFarms ? loaderData.farmIds : [] + return ( +
+

Stikstof

+ } + > + + +
+ ) +} + +/** + * Renders the page elements with asynchronously loaded data + * + * This has to be extracted into a separate component because of the `use(...)` hook. + * React will not render the component until `asyncData` resolves, but React Router + * handles it nicely via the `Suspense` component and server-to-client data streaming. + * If `use(...)` was added to `FarmBalanceNitrogenOverviewBlock` instead, the Suspense + * would not render until `asyncData` resolves and the fallback would never be shown. + */ +function OrganizationFarmBalanceNitrogenOverview(loaderData: LoaderData) { + const params = useParams() + + if (loaderData.noFarms) { + return ( +
+ +
+ ) + } + + const { farmIds, asyncData: asyncDataPromise } = loaderData + + // Unlike most React hooks `use` may be called conditionally + const asyncData = use(asyncDataPromise) + + const { combinedResult: resolvedNitrogenBalanceResult, farmResults } = + asyncData + const farmChartBalanceData = resolvedNitrogenBalanceResult + const hasErrors = farmResults.some( + ({ nitrogenBalanceResult }) => nitrogenBalanceResult.hasErrors, + ) + + const orgAverage = Number.isFinite(resolvedNitrogenBalanceResult.balance) + ? (resolvedNitrogenBalanceResult.balance as number) + : undefined + + const createFarmRow = (farmResult: (typeof farmResults)[number]) => { + const balanceResult = farmResult.nitrogenBalanceResult + const farmBalance = Number.isFinite(balanceResult.balance) + ? (balanceResult.balance as number) + : undefined + const delta = + farmBalance !== undefined && orgAverage !== undefined + ? farmBalance - orgAverage + : undefined + const deltaFormatted = + delta !== undefined + ? `${delta >= 0 ? "+" : ""}${(Math.round(delta * 10) / 10).toFixed(1)}` + : undefined + const deltaClass = + delta === undefined + ? "text-orange-500" + : delta < 0 + ? "text-green-600" + : "text-red-600" + return ( +
+ {balanceResult.hasErrors ? ( + + ) : Number.isFinite(balanceResult.balance) ? ( + balanceResult.balance <= balanceResult.target ? ( + + ) : ( + + ) + ) : ( + + )} + +
+ +

+ {farmResult.farm.b_name_farm ?? "Onbekende bedrijf"} +

+
+

+ {Math.round(farmResult.totalArea * 10) / 10} ha +

+
+
+ {!balanceResult.hasErrors ? ( + <> + {`${balanceResult.balance} / ${balanceResult.target}`} + {deltaFormatted !== undefined && ( + + + + {deltaFormatted} + + + + {`Verschil t.o.v. het organisatiegemiddelde (${(Math.round((orgAverage ?? 0) * 10) / 10).toFixed(1)} kg N / ha)`} + + + )} + + ) : ( + +

+ {"Bekijk foutmelding"} +

+
+ )} +
+
+ ) + } + return ( + <> +
+ + + + Overschot / Doel (Alle bedrijven) + + + + +
+
+

+ {`${resolvedNitrogenBalanceResult.balance} / ${resolvedNitrogenBalanceResult.target}`} +

+ {hasErrors ? ( + + + + + + Niet alle bedrijven konden worden + berekend + + + ) : resolvedNitrogenBalanceResult.balance <= + resolvedNitrogenBalanceResult.target ? ( + + ) : ( + + )} +
+
+

+ kg N / ha +

+
+
+ + + + Aanvoer + + + + +
+ {resolvedNitrogenBalanceResult.supply.total} +
+

+ kg N / ha +

+
+
+ + + + Afvoer + + + + +
+ {resolvedNitrogenBalanceResult.removal.total} +
+

+ kg N / ha +

+
+
+ + + + Ammoniakemissie + + + + +
+ { + resolvedNitrogenBalanceResult.emission.ammonia + .total + } +
+

+ kg N / ha +

+
+
+ + + + Nitraatuitspoeling + + + + +
+ {resolvedNitrogenBalanceResult.emission.nitrate} +
+

+ kg N / ha +

+
+
+
+
+ + + Balans + + De gemiddelde stikstofbalans voor de geselecteerde + bedrijven. De balans is het verschil tussen de + totale aanvoer, afvoer en emissie van stikstof. Een + positieve balans betekent een overschot aan + stikstof, een negatieve balans een tekort. U kunt de + selectie van de bedrijven wijzigen om de + uitschieters te identificeren. + + + + + + + + + +

Bedrijven

+ + +
+ +
+ +
+ {farmResults.map(createFarmRow)} +
+
+
+
+ + ) +} diff --git a/fdm-app/app/routes/organization.$slug.$calendar.balance.organic-matter._index.tsx b/fdm-app/app/routes/organization.$slug.$calendar.balance.organic-matter._index.tsx new file mode 100644 index 000000000..7cd89ce57 --- /dev/null +++ b/fdm-app/app/routes/organization.$slug.$calendar.balance.organic-matter._index.tsx @@ -0,0 +1,571 @@ +import { + calculateOrganicMatterBalanceForFarms, + calculateOrganicMatterBalancesFieldToFarm, + collectInputForOrganicMatterBalanceForFarms, + type OrganicMatterBalanceFieldResultNumeric, + type OrganicMatterBalanceNumeric, +} from "@nmi-agro/fdm-calculator" +import { getFarms, getFields } from "@nmi-agro/fdm-core" +import { + ArrowDownToLine, + ArrowRightLeft, + ArrowUpFromLine, + CircleAlert, + CircleCheck, + CircleX, +} from "lucide-react" +import { Suspense, use } from "react" +import { + data, + type LoaderFunctionArgs, + type MetaFunction, + NavLink, + useLoaderData, + useParams, +} from "react-router" +import { BufferStripInfo } from "~/components/blocks/balance/buffer-strip-info" +import { OrganicMatterBalanceChart } from "~/components/blocks/balance/organic-matter-chart" +import { NitrogenBalanceFallback } from "~/components/blocks/balance/skeletons" // Can be reused +import { NoFarmsMessage } from "~/components/blocks/organization/no-farms-message" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "~/components/ui/card" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "~/components/ui/tooltip" +import { auth, getSession } from "~/lib/auth.server" +import { getTimeframe } from "~/lib/calendar" +import { clientConfig } from "~/lib/config" +import { handleLoaderError, reportError } from "~/lib/error" +import { fdm } from "~/lib/fdm.server" +import { FarmSelectDialog } from "../components/blocks/balance/farm-select-dialog" + +type Farm = Awaited>[number] +type Organization = Awaited< + ReturnType +>[number] +type FarmResult = { + farm: Farm + totalArea: number + organicMatterBalanceResult: OrganicMatterBalanceNumeric & { + errorMessage?: string + } +} +type FarmExtended = Farm & { b_area_farm: number } +type AsyncData = { + farmResults: FarmResult[] + combinedResult: OrganicMatterBalanceNumeric + farms: FarmExtended[] +} +type LoaderData = + | { + farmIds: string[] + organization: Organization + noFarms: true + } + | { + farmIds: string[] + organization: Organization + noFarms: false + asyncData: Promise + } + +// Meta +export const meta: MetaFunction = () => { + return [ + { + title: `Organische stof | Organisatie | Nutriëntenbalans| ${clientConfig.name}`, + }, + { + name: "description", + content: "Bekijk de organische stofbalans van je organisatie.", + }, + ] +} + +export async function loader({ + request, + params, +}: LoaderFunctionArgs): Promise { + try { + // Get the organization + const slug = params.slug + if (!slug) { + throw data("missing: slug", { + status: 404, + statusText: "missing: slug", + }) + } + + const url = new URL(request.url) + + let searchParamFarmIds: string[] | undefined + if (url.searchParams.has("farmIds")) { + searchParamFarmIds = url.searchParams + .get("farmIds") + ?.split(",") + .filter(Boolean) + if (!searchParamFarmIds || searchParamFarmIds.length === 0) { + throw data("invalid: farmIds", { + status: 400, + statusText: "invalid: farmIds", + }) + } + } + + // Get timeframe from calendar store + const timeframe = getTimeframe(params) + + // Get the user's session too (for error reporting) + const session = await getSession(request) + + const allOrganizations = await auth.api.listOrganizations({ + headers: request.headers, + }) + const organization = allOrganizations.find((org) => org.slug === slug) + if (!organization) { + throw data(`not found: ${slug}`, { + status: 404, + statusText: `not found: ${slug}`, + }) + } + + const farms = await getFarms(fdm, organization.id) + + // If the organization has no access to any farms, render the empty message + if (farms.length === 0) { + return { + organization: organization, + noFarms: true, + farmIds: [], + } + } + + const farmIds = searchParamFarmIds + ? [...new Set(searchParamFarmIds)] + : farms.map((farm) => farm.b_id_farm) + + const allFarmIds = new Set(farms.map((farm) => farm.b_id_farm)) + + if (farmIds.some((b_id_farm) => !allFarmIds.has(b_id_farm))) { + const statusText = + "You do not have permission to compute organic matter balance for these farms" + throw data(statusText, { + status: 403, + statusText: statusText, + }) + } + + async function getAsyncData(principal_id: string) { + const inputs = await collectInputForOrganicMatterBalanceForFarms( + fdm, + principal_id, + farmIds, + timeframe, + ) + const fieldToFarmMap: Record = {} + for (const farmInput of inputs) { + for (const fieldInput of farmInput.fields) { + fieldToFarmMap[fieldInput.field.b_id] = farmInput.b_id_farm + } + } + + const combinedResult = await calculateOrganicMatterBalanceForFarms( + fdm, + inputs, + ) + const rawFarmResultsMap: Record< + string, + OrganicMatterBalanceFieldResultNumeric[] + > = {} + for (const result of combinedResult.fields) { + const b_id_farm = fieldToFarmMap[result.b_id] + if (!b_id_farm) { + console.warn( + `Field ${result.b_id} not found in fieldToFarmMap, skipping`, + ) + continue + } + rawFarmResultsMap[b_id_farm] ??= [] + rawFarmResultsMap[b_id_farm].push(result) + } + + // Compute farms + const farmsExtended = await Promise.all( + farms.map(async (farm) => { + const fields = await getFields( + fdm, + principal_id, + farm.b_id_farm, + ) + + const b_area_farm = fields.reduce( + (totalArea, field) => totalArea + (field.b_area ?? 0), + 0, + ) + + return { + ...farm, + b_area_farm: b_area_farm, + } + }), + ) + + // Sort farms by descending area, which will in turn also cause the results to be sorted + farmsExtended.sort((f1, f2) => f2.b_area_farm - f1.b_area_farm) + + const selectedFarmIds = new Set(farmIds) + const farmResults = await Promise.all( + farmsExtended + .filter((farm) => selectedFarmIds.has(farm.b_id_farm)) + .map(async (farm) => { + const fieldResults = rawFarmResultsMap[farm.b_id_farm] + if (!fieldResults || fieldResults.length === 0) { + return { + farm: farm, + totalArea: farm.b_area_farm, + organicMatterBalanceResult: { + hasErrors: true, + errorMessage: + "Geen veldgegevens beschikbaar", + } as OrganicMatterBalanceNumeric & { + errorMessage?: string + }, + } + } + try { + const organicMatterBalanceResult = + calculateOrganicMatterBalancesFieldToFarm( + fieldResults, + fieldResults.some( + (result) => result.errorMessage, + ), + fieldResults + .filter((result) => result.errorMessage) + .map( + (result) => result.errorMessage, + ) as string[], + ) + + if (organicMatterBalanceResult.hasErrors) { + reportError( + organicMatterBalanceResult.fieldErrorMessages.join( + ",\n", + ), + { + page: "organization/{slug}/{calendar}/balance/organic-matter/_index", + scope: "loader", + }, + { + b_id_farm: farm.b_id_farm, + timeframe, + userId: session.principal_id, + }, + ) + } + + return { + farm: farm, + totalArea: farm.b_area_farm, + organicMatterBalanceResult: + organicMatterBalanceResult as OrganicMatterBalanceNumeric & { + errorMessage?: string + }, + } + } catch (error) { + return { + farm: farm, + totalArea: farm.b_area_farm, + organicMatterBalanceResult: { + hasErrors: true, + errorMessage: + error instanceof Error + ? error.message + : String(error), + } as OrganicMatterBalanceNumeric & { + errorMessage?: string + }, + } + } + }), + ) + + return { + farms: farmsExtended, + farmResults: farmResults, + combinedResult: combinedResult, + } + } + + const asyncData = getAsyncData(organization.id) + + return { + farmIds: farmIds.sort(), + organization: organization, + noFarms: false, + asyncData: asyncData, + } + } catch (error) { + throw handleLoaderError(error) + } +} + +export default function FarmBalanceOrganicMatterOverviewBlock() { + const loaderData = useLoaderData() + const farmIds = !loaderData.noFarms ? loaderData.farmIds : [] + return ( +
+

+ Organische stof +

+ } + > + + +
+ ) +} + +/** + * Renders the page elements with asynchronously loaded data + * + * This has to be extracted into a separate component because of the `use(...)` hook. + * React will not render the component until `asyncData` resolves, but React Router + * handles it nicely via the `Suspense` component and server-to-client data streaming. + * If `use(...)` was added to `FarmBalanceOrganicMatterOverviewBlock` instead, the Suspense + * would not render until `asyncData` resolves and the fallback would never be shown. + */ +function OrganizationFarmBalanceOrganicMatterOverview(loaderData: LoaderData) { + const params = useParams() + + if (loaderData.noFarms) { + return ( +
+ +
+ ) + } + + const { farmIds, asyncData: asyncDataPromise } = loaderData + + // Unlike most React hooks `use` may be called conditionally + const asyncData = use(asyncDataPromise) + + const { combinedResult: resolvedOrganicMatterBalanceResult, farmResults } = + asyncData + const hasErrors = farmResults.some( + ({ organicMatterBalanceResult }) => + organicMatterBalanceResult.hasErrors, + ) + + const orgAverage = Number.isFinite( + resolvedOrganicMatterBalanceResult.balance, + ) + ? (resolvedOrganicMatterBalanceResult.balance as number) + : undefined + + const createFarmRow = (farmResult: (typeof farmResults)[number]) => { + const balanceResult = farmResult.organicMatterBalanceResult + const farmBalance = Number.isFinite(balanceResult.balance) + ? (balanceResult.balance as number) + : undefined + const delta = + farmBalance !== undefined && orgAverage !== undefined + ? farmBalance - orgAverage + : undefined + const deltaFormatted = + delta !== undefined + ? `${delta >= 0 ? "+" : ""}${(Math.round(delta * 10) / 10).toFixed(1)}` + : undefined + const deltaClass = + delta === undefined + ? "text-orange-500" + : delta < 0 + ? "text-red-600" + : "text-green-600" + return ( +
+ {Number.isFinite(balanceResult.balance) ? ( + balanceResult.balance > 0 ? ( + + ) : ( + + ) + ) : ( + + )} + +
+ +

+ {farmResult.farm.b_name_farm ?? "Onbekende bedrijf"} +

+
+

+ {Math.round(farmResult.totalArea * 10) / 10} ha +

+
+
+ {!balanceResult.hasErrors ? ( + <> + {balanceResult.balance} + {deltaFormatted !== undefined && ( + + + + {deltaFormatted} + + + + {`Verschil t.o.v. het organisatiegemiddelde (${(Math.round((orgAverage ?? 0) * 10) / 10).toFixed(1)} kg OS / ha)`} + + + )} + + ) : ( + +

+ {"Bekijk foutmelding"} +

+
+ )} +
+
+ ) + } + return ( + <> +
+ + + + Balans (Bedrijven) + + + + +
+
+

+ {resolvedOrganicMatterBalanceResult.balance} +

+ {hasErrors ? ( + + + + + + Niet alle bedrijven konden worden + berekend + + + ) : resolvedOrganicMatterBalanceResult.balance > + 0 ? ( + + ) : ( + + )} +
+
+

+ kg OS / ha +

+
+
+ + + + Aanvoer + + + + +
+ {resolvedOrganicMatterBalanceResult.supply} +
+

+ kg EOS / ha +

+
+
+ + + + Afbraak + + + + +
+ {resolvedOrganicMatterBalanceResult.degradation} +
+

+ kg OS / ha +

+
+
+
+
+ + + Balans + + De gemiddelde organische stofbalans voor de + geselecteerde bedrijven. De balans is het verschil + tussen de effectieve organische stof aanvoer en de + afbraak van organische stof. + + + + + + + + + +

Bedrijven

+ + +
+ +
+ +
+ {farmResults.map(createFarmRow)} +
+
+
+
+ + ) +} diff --git a/fdm-app/app/routes/organization.$slug.$calendar.farms.tsx b/fdm-app/app/routes/organization.$slug.$calendar.farms._index.tsx similarity index 88% rename from fdm-app/app/routes/organization.$slug.$calendar.farms.tsx rename to fdm-app/app/routes/organization.$slug.$calendar.farms._index.tsx index ad0809c96..e4c074f81 100644 --- a/fdm-app/app/routes/organization.$slug.$calendar.farms.tsx +++ b/fdm-app/app/routes/organization.$slug.$calendar.farms._index.tsx @@ -8,25 +8,18 @@ import { getFields, listPrincipalsForFarm, } from "@nmi-agro/fdm-core" -import { data, NavLink, useLoaderData } from "react-router" +import { data, useLoaderData } from "react-router" import { FarmContent } from "~/components/blocks/farm/farm-content" import { FarmTitle } from "~/components/blocks/farm/farm-title" import { columns, type FarmExtended } from "~/components/blocks/farms/columns" import { DataTable } from "~/components/blocks/farms/table" -import { Button } from "~/components/ui/button" -import { - Empty, - EmptyContent, - EmptyDescription, - EmptyHeader, - EmptyTitle, -} from "~/components/ui/empty" +import { NoFarmsMessage } from "~/components/blocks/organization/no-farms-message" import { auth } from "~/lib/auth.server" import { getTimeframe } from "~/lib/calendar" import { clientConfig } from "~/lib/config" import { handleLoaderError } from "~/lib/error" import { fdm } from "~/lib/fdm.server" -import type { Route } from "./+types/organization.$slug.$calendar.farms" +import type { Route } from "./+types/organization.$slug.$calendar.farms._index" // Meta export const meta: Route.MetaFunction = () => { @@ -250,27 +243,12 @@ export default function OrganizationFarmsPage() { ) : ( - - - - Het lijkt erop dat jouw organisatie tot geen - bedrijven toegang heeft. :( - - - Neem contact op met bedrijven om toegang tot hen - te krijgen. - - - - - - + )} diff --git a/fdm-app/app/routes/organization.$slug._index.tsx b/fdm-app/app/routes/organization.$slug._index.tsx index 9bb562532..78a11e979 100644 --- a/fdm-app/app/routes/organization.$slug._index.tsx +++ b/fdm-app/app/routes/organization.$slug._index.tsx @@ -16,6 +16,7 @@ import { import { FarmContent } from "~/components/blocks/farm/farm-content" import { FarmTitle } from "~/components/blocks/farm/farm-title" import { PendingInvitationCard } from "~/components/blocks/farm/pending-invitation" +import { NoFarmsMessage } from "~/components/blocks/organization/no-farms-message" import { Expandable, ExpandableContent, @@ -30,12 +31,6 @@ import { CardHeader, CardTitle, } from "~/components/ui/card" -import { - Empty, - EmptyDescription, - EmptyHeader, - EmptyTitle, -} from "~/components/ui/empty" import { Select, SelectContent, @@ -260,22 +255,7 @@ export default function AppIndex() { ) : ( loaderData.pendingInvitations.length === - 0 && ( - - - - Het lijkt erop dat je - organisatie geen toegang - heeft tot bedrijven. :( - - - Neem contact op met - bedrijven om toegang tot hen - te krijgen. - - - - ) + 0 && )} {loaderData.pendingInvitations.length > 0 && ( diff --git a/fdm-app/app/routes/organization.tsx b/fdm-app/app/routes/organization.tsx index 82531d0b3..c700091c2 100644 --- a/fdm-app/app/routes/organization.tsx +++ b/fdm-app/app/routes/organization.tsx @@ -6,6 +6,7 @@ import { Outlet } from "react-router-dom" import { Header } from "~/components/blocks/header/base" import { HeaderOrganization } from "~/components/blocks/header/organization" import { SidebarOrganization } from "~/components/blocks/sidebar/organization" +import { SidebarOrganizationApps } from "~/components/blocks/sidebar/organization-apps" import { SidebarSupport } from "~/components/blocks/sidebar/support" import { SidebarTitle } from "~/components/blocks/sidebar/title" import { SidebarUser } from "~/components/blocks/sidebar/user" @@ -123,6 +124,7 @@ export default function App() { organization={organization} roles={loaderData.selectedOrganizationRoles} /> + void + syncOrganization: (id: string, farmIds?: string[]) => void +} + +export const useOrganizationFarmSelectionStore = + create()( + persist( + (set, get) => ({ + organizationId: null, + farmIds: [], + setFarmIds(farmIds: string[]) { + set({ farmIds }) + }, + syncOrganization( + organizationId: string, + farmIds: string[] = [], + ) { + if (get().organizationId !== organizationId) { + set({ organizationId, farmIds: farmIds }) + } + }, + }), + { + name: "organization-farm-selection-storage", // unique name + storage: createJSONStorage(() => ssrSafeSessionJSONStorage), // Use SSR-safe storage + }, + ), + ) diff --git a/fdm-calculator/src/balance/nitrogen/index.ts b/fdm-calculator/src/balance/nitrogen/index.ts index 511dfe273..b3c5b6891 100644 --- a/fdm-calculator/src/balance/nitrogen/index.ts +++ b/fdm-calculator/src/balance/nitrogen/index.ts @@ -32,35 +32,78 @@ import type { export async function calculateNitrogenBalance( fdm: FdmType, nitrogenBalanceInput: NitrogenBalanceInput, -): Promise { - const { fields, fertilizerDetails, cultivationDetails, timeFrame } = - nitrogenBalanceInput +) { + return calculateNitrogenBalanceForFarms(fdm, [ + { + ...nitrogenBalanceInput, + b_id_farm: + ( + nitrogenBalanceInput as NitrogenBalanceInput & { + b_id_farm?: string + } + ).b_id_farm ?? "farm", + }, + ]) +} +/** + * Calculates the nitrogen balance for all the farms. + * + * This function orchestrates the nitrogen balance calculation for all fields on multiple farms. + * It calls `getNitrogenBalanceField` for each field and then aggregates the results + * using `calculateNitrogenBalancesFieldToFarm`. + * + * @param fdm - The FDM instance for database access (caching). + * @param nitrogenBalanceInput - The input data for the nitrogen balance calculation, including all fields. + * @returns A promise that resolves an array where each item is the aggregated nitrogen balance of a farm, + * including the b_id_farm. + */ +export async function calculateNitrogenBalanceForFarms( + fdm: FdmType, + inputs: (NitrogenBalanceInput & { b_id_farm: string })[], +) { + const fieldInputs: (NitrogenBalanceFieldInput & { b_id_farm: string })[] = + inputs.flatMap((input) => + input.fields.map((field) => ({ + b_id_farm: input.b_id_farm, + fieldInput: field, + fertilizerDetails: input.fertilizerDetails, + cultivationDetails: input.cultivationDetails, + timeFrame: input.timeFrame, + })), + ) + return calculateNitrogenBalanceBatched(fdm, fieldInputs) +} + +async function calculateNitrogenBalanceBatched( + fdm: FdmType, + fieldInputs: NitrogenBalanceFieldInput[], +): Promise { const fieldsWithBalanceResults: NitrogenBalanceFieldResultNumeric[] = [] const batchSize = 50 - for (let i = 0; i < fields.length; i += batchSize) { - const batch = fields.slice(i, i + batchSize) + for (let i = 0; i < fieldInputs.length; i += batchSize) { + const batch = fieldInputs.slice(i, i + batchSize) const batchResults = await Promise.all( batch.map(async (fieldInput) => { try { - const balance = await getNitrogenBalanceField(fdm, { + const balance = await getNitrogenBalanceField( + fdm, fieldInput, - fertilizerDetails, - cultivationDetails, - timeFrame, - }) + ) return { - b_id: fieldInput.field.b_id, - b_area: fieldInput.field.b_area ?? 0, - b_bufferstrip: fieldInput.field.b_bufferstrip ?? false, + b_id: fieldInput.fieldInput.field.b_id, + b_area: fieldInput.fieldInput.field.b_area ?? 0, + b_bufferstrip: + fieldInput.fieldInput.field.b_bufferstrip ?? false, balance, } } catch (error) { return { - b_id: fieldInput.field.b_id, - b_area: fieldInput.field.b_area ?? 0, - b_bufferstrip: fieldInput.field.b_bufferstrip ?? false, + b_id: fieldInput.fieldInput.field.b_id, + b_area: fieldInput.fieldInput.field.b_area ?? 0, + b_bufferstrip: + fieldInput.fieldInput.field.b_bufferstrip ?? false, errorMessage: error instanceof Error ? error.message diff --git a/fdm-calculator/src/balance/nitrogen/input.test.ts b/fdm-calculator/src/balance/nitrogen/input.test.ts index 6a312d5ca..31edc4eb4 100644 --- a/fdm-calculator/src/balance/nitrogen/input.test.ts +++ b/fdm-calculator/src/balance/nitrogen/input.test.ts @@ -2,8 +2,8 @@ import type { Cultivation, CultivationCatalogue, FdmType, - Fertilizer, FertilizerApplication, + FertilizerCatalogue, Field, fdmSchema, Harvest, @@ -12,16 +12,23 @@ import type { } from "@nmi-agro/fdm-core" import { getCultivations, + getCultivationsFromCatalogues, getCultivationsFromCatalogue, + getEnabledCultivationCataloguesForFarms, + getEnabledFertilizerCataloguesForFarms, getFertilizerApplications, - getFertilizers, + getFertilizersFromCatalogues, + getFertilizersFromCatalogue, getFields, getHarvests, getSoilAnalyses, } from "@nmi-agro/fdm-core" import Decimal from "decimal.js" import { beforeEach, describe, expect, it, vi } from "vitest" -import { collectInputForNitrogenBalance } from "./input" +import { + collectInputForNitrogenBalance, + collectInputForNitrogenBalanceForFarms, +} from "./input" import { calculateAllFieldsNitrogenSupplyByDeposition } from "./supply/deposition" import type { FieldInput, NitrogenBalanceInput } from "./types" @@ -35,8 +42,12 @@ vi.mock("@nmi-agro/fdm-core", async () => { getHarvests: vi.fn(), getSoilAnalyses: vi.fn(), getFertilizerApplications: vi.fn(), - getFertilizers: vi.fn(), getCultivationsFromCatalogue: vi.fn(), + getFertilizersFromCatalogue: vi.fn(), + getEnabledCultivationCataloguesForFarms: vi.fn(), + getEnabledFertilizerCataloguesForFarms: vi.fn(), + getCultivationsFromCatalogues: vi.fn(), + getFertilizersFromCatalogues: vi.fn(), } }) @@ -51,35 +62,24 @@ const mockedGetCultivations = vi.mocked(getCultivations) const mockedGetHarvests = vi.mocked(getHarvests) const mockedGetSoilAnalyses = vi.mocked(getSoilAnalyses) const mockedGetFertilizerApplications = vi.mocked(getFertilizerApplications) -const mockedGetFertilizers = vi.mocked(getFertilizers) -const mockedGetCultivationsFromCatalogue = vi.mocked( - getCultivationsFromCatalogue, +const mockedGetCultivationsFromCatalogue = vi.mocked(getCultivationsFromCatalogue) +const mockedGetFertilizersFromCatalogue = vi.mocked(getFertilizersFromCatalogue) +const mockedGetEnabledCultivationCataloguesForFarms = vi.mocked( + getEnabledCultivationCataloguesForFarms, +) +const mockedGetEnabledFertilizerCataloguesForFarms = vi.mocked( + getEnabledFertilizerCataloguesForFarms, ) +const mockedgetCultivationsFromCatalogues = vi.mocked(getCultivationsFromCatalogues) +const mockedgetFertilizersFromCatalogues = vi.mocked(getFertilizersFromCatalogues) const mockedCalculateAllFieldsNitrogenSupplyByDeposition = vi.mocked( calculateAllFieldsNitrogenSupplyByDeposition, ) -describe("collectInputForNitrogenBalance", () => { - const mockFdm: FdmType = { - // @ts-expect-error - we are mocking the transaction - transaction: async (callback) => callback(mockFdm), // Simplified mock transaction - // Add other FdmType properties if needed for type checking, or cast to any - } as FdmType - - const principal_id: PrincipalId = "test-principal-id" - const b_id_farm: fdmSchema.farmsTypeSelect["b_id_farm"] = "test-farm-id" - const timeframe = { - start: new Date("2023-01-01"), - end: new Date("2023-12-31"), - } - - beforeEach(() => { - vi.resetAllMocks() - }) - - it("should collect input successfully when all data is available", async () => { +function createMockData() { + return { // Mock data - const mockFieldsData: Field[] = [ + mockFieldsData: [ { b_id: "field-1", b_name: "Field 1", @@ -108,8 +108,8 @@ describe("collectInputForNitrogenBalance", () => { b_acquiring_method: "purchase", b_bufferstrip: false, }, - ] - const mockCultivationsData: Cultivation[] = [ + ] as Field[], + mockCultivationsData: [ { b_lu: "cult-1", b_lu_catalogue: "cat-cult-1", @@ -129,8 +129,29 @@ describe("collectInputForNitrogenBalance", () => { b_lu_variety: "variety", b_id: "cult-1", }, - ] - const mockHarvestsData: Harvest[] = [ + ] as Cultivation[], + mockCultivationsData2: [ + { + b_lu: "cult-2", + b_lu_catalogue: "cat-cult-2", + m_cropresidue: false, + b_lu_start: new Date("2023-04-01"), + b_lu_end: new Date("2023-09-01"), + b_lu_source: "source", + b_lu_name: "Cultivation 2", + b_lu_name_en: "Cultivation 2", + b_lu_hcat3: "hcat3", + b_lu_hcat3_name: "Hcat3 Name", + b_lu_croprotation: "maize", + b_lu_eom: 1, + b_lu_eom_residue: 1, + b_lu_harvestcat: "HC010", + b_lu_harvestable: "once", + b_lu_variety: "variety", + b_id: "cult-2", + }, + ] as Cultivation[], + mockHarvestsData: [ { b_id_harvesting: "harvest-1", b_lu: "cult-1", @@ -140,8 +161,8 @@ describe("collectInputForNitrogenBalance", () => { harvestable_analyses: [], }, }, - ] - const mockSoilAnalysesData = [ + ] as Harvest[], + mockSoilAnalysesData: [ { a_id: "sa-1", a_date: new Date(), @@ -157,21 +178,32 @@ describe("collectInputForNitrogenBalance", () => { a_som_loi: 5, b_gwl_class: "HIGH", }, - ] as unknown as SoilAnalysis[] - const mockFertilizerApplicationsData: FertilizerApplication[] = [ + ] as SoilAnalysis[], + mockFertilizerApplicationsData: [ { p_app_id: "fa-1", - p_id_catalogue: "fert-1", + p_id_catalogue: "fert-cat-1", p_name_nl: "test-product", p_app_amount: 100, p_app_method: "broadcasting", // match one of ApplicationMethods p_app_date: new Date(), - p_id: "", + p_id: "fert-1", }, - ] - const mockFertilizerDetailsData = [ + ] as FertilizerApplication[], + mockFertilizerApplicationsData2: [ + { + p_app_id: "fa-2", + p_id_catalogue: "fert-cat-2", + p_name_nl: "test-product", + p_app_amount: 100, + p_app_method: "broadcasting", // match one of ApplicationMethods + p_app_date: new Date(), + p_id: "fert-2", + }, + ] as FertilizerApplication[], + mockFertilizerDetailsData: [ { - p_id: "fert-cat-1", + p_id_catalogue: "fert-cat-1", p_n_rt: 5, p_type: "manure", p_no3_rt: 1, @@ -179,8 +211,19 @@ describe("collectInputForNitrogenBalance", () => { p_s_rt: 0, p_ef_nh3: 0.1, }, - ] as unknown as Fertilizer[] - const mockCultivationDetailsData = [ + ] as FertilizerCatalogue[], + mockFertilizerDetailsData2: [ + { + p_id_catalogue: "fert-cat-2", + p_n_rt: 5, + p_type: "manure", + p_no3_rt: 1, + p_nh4_rt: 2, + p_s_rt: 0, + p_ef_nh3: 0.1, + }, + ] as FertilizerCatalogue[], + mockCultivationDetailsData: [ { b_lu_catalogue: "cat-cult-1", b_lu_croprotation: "maize", @@ -190,13 +233,56 @@ describe("collectInputForNitrogenBalance", () => { b_lu_n_residue: 0.8, b_n_fixation: 0, }, - ] as unknown as CultivationCatalogue[] - const mockDepositionSupplyMap = new Map([ + ] as CultivationCatalogue[], + mockCultivationDetailsData2: [ + { + b_lu_catalogue: "cat-cult-2", + b_lu_croprotation: "cereal", + b_lu_yield: 5000, + b_lu_hi: 0.45, + b_lu_n_harvestable: 1.2, + b_lu_n_residue: 0.8, + b_n_fixation: 0, + }, + ] as CultivationCatalogue[], + mockDepositionSupplyMap: new Map([ ["field-1", { total: new Decimal(10) }], ["field-2", { total: new Decimal(20) }], - ]) + ["2-field-1", { total: new Decimal(10) }], + ["2-field-2", { total: new Decimal(20) }], + ]), + } +} +describe("collectInputForNitrogenBalance", () => { + const mockFdm: FdmType = { + // @ts-expect-error - we are mocking the transaction + transaction: async (callback) => callback(mockFdm), // Simplified mock transaction + // Add other FdmType properties if needed for type checking, or cast to any + } as FdmType + + const principal_id: PrincipalId = "test-principal-id" + const b_id_farm: fdmSchema.farmsTypeSelect["b_id_farm"] = "test-farm-id" + const timeframe = { + start: new Date("2023-01-01"), + end: new Date("2023-12-31"), + } + beforeEach(() => { + vi.resetAllMocks() + }) + + it("should collect input successfully when all data is available", async () => { // Setup mocks + const { + mockFieldsData, + mockCultivationsData, + mockHarvestsData, + mockSoilAnalysesData, + mockFertilizerApplicationsData, + mockFertilizerDetailsData, + mockCultivationDetailsData, + mockDepositionSupplyMap, + } = createMockData() mockedGetFields.mockResolvedValue(mockFieldsData) mockedGetCultivations.mockResolvedValue(mockCultivationsData) mockedGetHarvests.mockResolvedValue(mockHarvestsData) // For simplicity, same harvests for all cultivations @@ -204,9 +290,14 @@ describe("collectInputForNitrogenBalance", () => { mockedGetFertilizerApplications.mockResolvedValue( mockFertilizerApplicationsData, ) - mockedGetFertilizers.mockResolvedValue(mockFertilizerDetailsData) + const allFertilizerDetails = mockFertilizerDetailsData.map((fert) => ({ + ...fert, + })) + mockedGetFertilizersFromCatalogue.mockResolvedValue( + allFertilizerDetails as any, + ) mockedGetCultivationsFromCatalogue.mockResolvedValue( - mockCultivationDetailsData, + mockCultivationDetailsData as any, ) mockedCalculateAllFieldsNitrogenSupplyByDeposition.mockResolvedValue( mockDepositionSupplyMap, @@ -226,13 +317,16 @@ describe("collectInputForNitrogenBalance", () => { harvests: mockHarvestsData, soilAnalyses: mockSoilAnalysesData, fertilizerApplications: mockFertilizerApplicationsData, - depositionSupply: mockDepositionSupplyMap.get(fieldData.b_id)!, + depositionSupply: mockDepositionSupplyMap.get( + fieldData.b_id, + ) as { total: Decimal }, }), ) - const expectedResult: NitrogenBalanceInput = { + const expectedResult: NitrogenBalanceInput & { b_id_farm?: string } = { + b_id_farm: b_id_farm, fields: expectedFieldInputs, - fertilizerDetails: mockFertilizerDetailsData, + fertilizerDetails: allFertilizerDetails, cultivationDetails: mockCultivationDetailsData, timeFrame: timeframe, } @@ -275,7 +369,7 @@ describe("collectInputForNitrogenBalance", () => { timeframe, ) } - expect(mockedGetFertilizers).toHaveBeenCalledWith( + expect(mockedGetFertilizersFromCatalogue).toHaveBeenCalledWith( mockFdm, principal_id, b_id_farm, @@ -355,7 +449,7 @@ describe("collectInputForNitrogenBalance", () => { it("should handle empty arrays from core functions correctly", async () => { mockedGetFields.mockResolvedValue([]) - mockedGetFertilizers.mockResolvedValue([]) + mockedGetFertilizersFromCatalogue.mockResolvedValue([]) mockedGetCultivationsFromCatalogue.mockResolvedValue([]) const result = await collectInputForNitrogenBalance( @@ -365,7 +459,8 @@ describe("collectInputForNitrogenBalance", () => { timeframe, ) - const expectedResult: NitrogenBalanceInput = { + const expectedResult: NitrogenBalanceInput & { b_id_farm?: string } = { + b_id_farm: "test-farm-id", fields: [], fertilizerDetails: [], cultivationDetails: [], @@ -379,7 +474,7 @@ describe("collectInputForNitrogenBalance", () => { b_id_farm, timeframe, ) - expect(mockedGetFertilizers).toHaveBeenCalledWith( + expect(mockedGetFertilizersFromCatalogue).toHaveBeenCalledWith( mockFdm, principal_id, b_id_farm, @@ -396,3 +491,183 @@ describe("collectInputForNitrogenBalance", () => { expect(mockedGetFertilizerApplications).not.toHaveBeenCalled() }) }) + +describe("collectInputForNitrogenBalanceForFarms", () => { + const mockFdm: FdmType = { + // @ts-expect-error - we are mocking the transaction + transaction: async (callback) => callback(mockFdm), // Simplified mock transaction + // Add other FdmType properties if needed for type checking, or cast to any + } as FdmType + + const principal_id: PrincipalId = "test-principal-id" + const timeframe = { + start: new Date("2023-01-01"), + end: new Date("2023-12-31"), + } + + beforeEach(() => { + vi.resetAllMocks() + }) + + it("should collect cultivation details only once", async () => { + // Setup mocks + const { + mockFieldsData, + mockCultivationsData, + mockCultivationsData2, + mockHarvestsData, + mockSoilAnalysesData, + mockFertilizerApplicationsData, + mockFertilizerApplicationsData2, + mockFertilizerDetailsData, + mockFertilizerDetailsData2, + mockCultivationDetailsData, + mockCultivationDetailsData2, + mockDepositionSupplyMap, + } = createMockData() + const mockFieldsData2 = mockFieldsData.map((field) => ({ + ...field, + b_id: `2-${field.b_id}`, + b_id_farm: "test-farm-id-2", + })) + + // Setup mocks + mockedGetFields.mockImplementation(async (_1, _2, b_id_farm) => + b_id_farm === "test-farm-id-2" ? mockFieldsData2 : mockFieldsData, + ) + mockedGetCultivations.mockImplementation(async (_1, _2, b_id) => + b_id.startsWith("2-") + ? mockCultivationsData2 + : mockCultivationsData, + ) + mockedGetHarvests.mockResolvedValue(mockHarvestsData) // For simplicity, same harvests for all cultivations + mockedGetSoilAnalyses.mockResolvedValue(mockSoilAnalysesData) + mockedGetFertilizerApplications.mockImplementation( + async (_1, _2, b_id) => + b_id.startsWith("2-") + ? mockFertilizerApplicationsData2 + : mockFertilizerApplicationsData, + ) + const cultDetailsWithSource1 = mockCultivationDetailsData.map((c) => ({ + ...c, + b_lu_source: "brp", + })) + const cultDetailsWithSource2 = mockCultivationDetailsData2.map((c) => ({ + ...c, + b_lu_source: "brp", + })) + const allCultivationDetails = [ + ...cultDetailsWithSource1, + ...cultDetailsWithSource2, + ] + const fertData1 = mockFertilizerDetailsData.map((fert) => ({ + ...fert, + p_source: "test-farm-id", + })) + const fertData2 = mockFertilizerDetailsData2.map((fert) => ({ + ...fert, + p_source: "test-farm-id-2", + })) + const allFertilizerDetails = [...fertData1, ...fertData2] + mockedGetEnabledCultivationCataloguesForFarms.mockResolvedValue({ + "test-farm-id": ["brp"], + "test-farm-id-2": ["brp"], + }) + mockedGetEnabledFertilizerCataloguesForFarms.mockResolvedValue({ + "test-farm-id": ["test-farm-id"], + "test-farm-id-2": ["test-farm-id-2"], + }) + mockedgetCultivationsFromCatalogues.mockResolvedValue( + allCultivationDetails as any, + ) + mockedgetFertilizersFromCatalogues.mockResolvedValue( + allFertilizerDetails as any, + ) + mockedCalculateAllFieldsNitrogenSupplyByDeposition.mockResolvedValue( + mockDepositionSupplyMap, + ) + + const result = await collectInputForNitrogenBalanceForFarms( + mockFdm, + principal_id, + ["test-farm-id", "test-farm-id-2"], + timeframe, + ) + + const makeFieldInput = ( + fieldData: Field, + fertilizerApplications: FertilizerApplication[], + cultivations: Cultivation[], + ) => ({ + field: fieldData, + cultivations: cultivations, + harvests: mockHarvestsData, + soilAnalyses: mockSoilAnalysesData, + fertilizerApplications: fertilizerApplications, + depositionSupply: mockDepositionSupplyMap.get(fieldData.b_id) as { + total: Decimal + }, + }) + const expectedFieldInputs: FieldInput[] = mockFieldsData.map( + (fieldData) => + makeFieldInput( + fieldData, + mockFertilizerApplicationsData, + mockCultivationsData, + ), + ) + const expectedFieldInputs2: FieldInput[] = mockFieldsData2.map( + (fieldData) => + makeFieldInput( + fieldData, + mockFertilizerApplicationsData2, + mockCultivationsData2, + ), + ) + + const expectedResult: (NitrogenBalanceInput & { + b_id_farm?: string + })[] = [ + { + b_id_farm: "test-farm-id", + fields: expectedFieldInputs, + fertilizerDetails: fertData1, + cultivationDetails: cultDetailsWithSource1, + timeFrame: timeframe, + }, + { + b_id_farm: "test-farm-id-2", + fields: expectedFieldInputs2, + fertilizerDetails: fertData2, + cultivationDetails: cultDetailsWithSource2, + timeFrame: timeframe, + }, + ] + + expect(result).toEqual(expectedResult) + + expect(mockedGetEnabledCultivationCataloguesForFarms).toHaveBeenCalledWith( + mockFdm, + principal_id, + ["test-farm-id", "test-farm-id-2"], + ) + expect(mockedGetEnabledCultivationCataloguesForFarms).toHaveBeenCalledTimes(1) + expect(mockedGetEnabledFertilizerCataloguesForFarms).toHaveBeenCalledWith( + mockFdm, + principal_id, + ["test-farm-id", "test-farm-id-2"], + ) + expect(mockedGetEnabledFertilizerCataloguesForFarms).toHaveBeenCalledTimes(1) + expect(mockedgetCultivationsFromCatalogues).toHaveBeenCalledWith( + mockFdm, + ["brp"], + ) + expect(mockedgetCultivationsFromCatalogues).toHaveBeenCalledTimes(1) + expect(mockedgetFertilizersFromCatalogues).toHaveBeenCalledWith( + mockFdm, + principal_id, + expect.arrayContaining(["test-farm-id", "test-farm-id-2"]), + ) + expect(mockedgetFertilizersFromCatalogues).toHaveBeenCalledTimes(1) + }) +}) diff --git a/fdm-calculator/src/balance/nitrogen/input.ts b/fdm-calculator/src/balance/nitrogen/input.ts index a8de320c7..13b4abbff 100644 --- a/fdm-calculator/src/balance/nitrogen/input.ts +++ b/fdm-calculator/src/balance/nitrogen/input.ts @@ -6,45 +6,53 @@ import type { } from "@nmi-agro/fdm-core" import { getCultivations, + getCultivationsFromCatalogues, getCultivationsFromCatalogue, + getEnabledCultivationCataloguesForFarms, + getEnabledFertilizerCataloguesForFarms, getFertilizerApplications, - getFertilizers, + getFertilizersFromCatalogues, + getFertilizersFromCatalogue, getField, getFields, getHarvests, getSoilAnalyses, } from "@nmi-agro/fdm-core" import { getFdmPublicDataUrl } from "../../shared/public-data-url" +import { handleInputCollectionError } from "../shared/errors" import { calculateAllFieldsNitrogenSupplyByDeposition } from "./supply/deposition" -import type { NitrogenBalanceInput } from "./types" +import type { FieldInput, NitrogenBalanceInput } from "./types" /** - * Collects necessary input data from a FDM instance for calculating the nitrogen balance. + * Collects field-specific input data from a FDM instance for calculating the nitrogen balance. * * This function orchestrates the retrieval of data related to fields, cultivations, - * harvests, soil analyses, fertilizer applications, fertilizer details, and cultivation details - * within a specified farm and timeframe. It fetches data from the FDM database and structures - * it into a `NitrogenBalanceInput` object. + * harvests, soil analyses, fertilizer applications within a specified farm and timeframe. It + * fetches data from the FDM database and structures it into an array of `FieldInput` objects. + * A complete NitrogenBalanceInput object can be built by collecting the cultivationDetails and + * fertilizerDetails separately, then combining them in a new object along with the array + * returned from this function, ending up with a `NitrogenBalanceInput` object. * * @param fdm - The FDM instance for database interaction. * @param principal_id - The ID of the principal (user or service) initiating the data collection. * @param b_id_farm - The ID of the farm for which to collect the nitrogen balance input. * @param timeframe - The timeframe for which to collect the data. - * @returns A promise that resolves with a `NitrogenBalanceInput` object containing all the necessary data. + * @param b_id - Optional. If provided, the data collection will be limited to this specific field ID. Otherwise, data for all fields in the farm will be collected. + * @returns A promise that resolves with an array of `FieldInput` objects containing only the field-specific input data. * @throws {Error} - Throws an error if data collection or processing fails. * * @alpha */ -export async function collectInputForNitrogenBalance( +async function collectInputForNitrogenBalanceForFarm( fdm: FdmType, principal_id: PrincipalId, b_id_farm: fdmSchema.farmsTypeSelect["b_id_farm"], timeframe: Timeframe, b_id?: fdmSchema.fieldsTypeSelect["b_id"], -): Promise { +): Promise { try { - return await fdm.transaction(async (tx: FdmType) => { - // Collect the fields for the farm + // Collect the fields for the farm + return await fdm.transaction(async (tx: typeof fdm) => { let farmFields: Awaited> if (b_id) { const field = await getField(tx, principal_id, b_id) @@ -73,7 +81,7 @@ export async function collectInputForNitrogenBalance( ) // Collect the details per field - const fields = await Promise.all( + return await Promise.all( farmFields.map(async (field) => { // Collect the cultivations of the field const cultivations = await getCultivations( @@ -130,34 +138,204 @@ export async function collectInputForNitrogenBalance( } }), ) + }) + } catch (error) { + throw handleNitrogenBalanceInputCollectionError(error, b_id_farm) + } +} + +/** + * Collects necessary input data from a FDM instance for calculating the nitrogen balance while minimizing + * the data lookups. + * + * This function orchestrates the retrieval of data related to fields, cultivations, + * harvests, soil analyses, fertilizer applications, fertilizer details, and cultivation details + * across multiple farms and a timeframe. It fetches data from the FDM database and structures + * it into an array of `NitrogenBalanceInput` objects. + * + * @param fdm - The FDM instance for database interaction. + * @param principal_id - The ID of the principal (user or service) initiating the data collection. + * @param farmIds - The IDs of the farms for which to collect the nitrogen balance input. + * @param timeframe - The timeframe for which to collect the data. + * @returns A promise that resolves with an array of `NitrogenBalanceInput` objects with b_id_farm containing all the necessary data. + * @throws {Error} - Throws an error if data collection or processing fails. + * + * @alpha + */ +export async function collectInputForNitrogenBalanceForFarms( + fdm: FdmType, + principal_id: PrincipalId, + farmIds: fdmSchema.farmsTypeSelect["b_id_farm"][], + timeframe: Timeframe, +): Promise<(NitrogenBalanceInput & { b_id_farm: string })[]> { + try { + return await fdm.transaction(async (tx: FdmType) => { + const uniqueFarmIds = [...new Set(farmIds)] + + // Step 1: Get enabled catalogue sources for all farms in a single batch query + const [farmCultivationCatalogues, farmFertilizerCatalogues] = + await Promise.all([ + getEnabledCultivationCataloguesForFarms( + tx, + principal_id, + uniqueFarmIds, + ), + getEnabledFertilizerCataloguesForFarms( + tx, + principal_id, + uniqueFarmIds, + ), + ]) + + // Step 2: Deduplicate catalogue sources across farms and fetch items once + const uniqueCultivationSources = [ + ...new Set( + Object.values(farmCultivationCatalogues).flat(), + ), + ] + const uniqueFertilizerSources = [ + ...new Set( + Object.values(farmFertilizerCatalogues).flat(), + ), + ] + const [allCultivations, allFertilizers] = await Promise.all([ + getCultivationsFromCatalogues(tx, uniqueCultivationSources), + getFertilizersFromCatalogues(tx, principal_id, uniqueFertilizerSources), + ]) + + // Step 3: Process each farm using the pre-fetched catalogue data + const farmSettled = await Promise.allSettled( + uniqueFarmIds.map(async (b_id_farm) => { + const onlyFieldInput = + await collectInputForNitrogenBalanceForFarm( + tx, + principal_id, + b_id_farm, + timeframe, + ) + + // Filter catalogue items to only those referenced by this farm's fields + const farmCultivationSources = new Set( + farmCultivationCatalogues[b_id_farm] ?? [], + ) + const cultivationIds = new Set( + onlyFieldInput.flatMap((input) => + input.cultivations.map( + (cultivation) => cultivation.b_lu_catalogue, + ), + ), + ) + const cultivationDetailsForThisFarm = + allCultivations.filter( + (c) => + farmCultivationSources.has(c.b_lu_source) && + cultivationIds.has(c.b_lu_catalogue), + ) + + const farmFertilizerSources = new Set( + farmFertilizerCatalogues[b_id_farm] ?? [], + ) + const fertilizerIds = new Set( + onlyFieldInput.flatMap((input) => + input.fertilizerApplications.map( + (app) => app.p_id_catalogue, + ), + ), + ) + const fertilizerDetailsForThisFarm = + allFertilizers.filter( + (f) => + farmFertilizerSources.has(f.p_source) && + fertilizerIds.has(f.p_id_catalogue), + ) + + return { + b_id_farm: b_id_farm, + fields: onlyFieldInput, + fertilizerDetails: fertilizerDetailsForThisFarm, + cultivationDetails: cultivationDetailsForThisFarm, + timeFrame: timeframe, + } + }), + ) + return farmSettled + .filter((result) => { + if (result.status === "rejected") { + console.error( + handleNitrogenBalanceInputCollectionError( + result.reason, + ).message, + ) + return false + } + return true + }) + .map((result) => (result as PromiseFulfilledResult).value) + }) + } catch (error) { + throw handleNitrogenBalanceInputCollectionError(error) + } +} - // Collect the details of the fertilizers - const fertilizerDetails = await getFertilizers( +/** + * Collects necessary input data from a FDM instance for calculating the nitrogen balance. + * + * This function orchestrates the retrieval of data related to fields, cultivations, + * harvests, soil analyses, fertilizer applications, fertilizer details, and cultivation details + * within a specified farm and timeframe. It fetches data from the FDM database and structures + * it into a `NitrogenBalanceInput` object. + * + * @param fdm - The FDM instance for database interaction. + * @param principal_id - The ID of the principal (user or service) initiating the data collection. + * @param b_id_farm - The ID of the farm for which to collect the nitrogen balance input. + * @param timeframe - The timeframe for which to collect the data. + * @param b_id - Optional. If provided, the data collection will be limited to this specific field ID. Otherwise, data for all fields in the farm will be collected. + * @returns A promise that resolves with a `NitrogenBalanceInput` object containing all the necessary data. + * @throws {Error} - Throws an error if data collection or processing fails. + * + * @alpha + */ +export async function collectInputForNitrogenBalance( + fdm: FdmType, + principal_id: PrincipalId, + b_id_farm: fdmSchema.farmsTypeSelect["b_id_farm"], + timeframe: Timeframe, + b_id?: fdmSchema.fieldsTypeSelect["b_id"], +): Promise { + try { + return await fdm.transaction(async (tx: FdmType) => { + const cultivationDetails = await getCultivationsFromCatalogue( tx, principal_id, b_id_farm, ) - - // Collect the details of the cultivations - const cultivationDetails = await getCultivationsFromCatalogue( + const fertilizerDetails = await getFertilizersFromCatalogue( tx, principal_id, b_id_farm, ) - + const fields = await collectInputForNitrogenBalanceForFarm( + tx, + principal_id, + b_id_farm, + timeframe, + b_id, + ) return { + b_id_farm, fields, - fertilizerDetails: fertilizerDetails, - cultivationDetails: cultivationDetails, + fertilizerDetails, + cultivationDetails, timeFrame: timeframe, } }) } catch (error) { - throw new Error( - `Failed to collect nitrogen balance input for farm ${b_id_farm}: ${ - error instanceof Error ? error.message : String(error) - }`, - { cause: error }, - ) + throw handleNitrogenBalanceInputCollectionError(error) } } + +export const handleNitrogenBalanceInputCollectionError = + handleInputCollectionError( + "Failed to collect nitrogen balance input for farm", + "Failed to collect nitrogen balance input", + ) diff --git a/fdm-calculator/src/balance/organic-matter/index.ts b/fdm-calculator/src/balance/organic-matter/index.ts index 06e6a96cf..dd22c1357 100644 --- a/fdm-calculator/src/balance/organic-matter/index.ts +++ b/fdm-calculator/src/balance/organic-matter/index.ts @@ -34,39 +34,84 @@ import type { export async function calculateOrganicMatterBalance( fdm: FdmType, organicMatterBalanceInput: OrganicMatterBalanceInput, -): Promise { - // Destructure input for easier access. - const { fields, fertilizerDetails, cultivationDetails, timeFrame } = - organicMatterBalanceInput +) { + return calculateOrganicMatterBalanceForFarms(fdm, [ + { + ...organicMatterBalanceInput, + b_id_farm: + ( + organicMatterBalanceInput as OrganicMatterBalanceInput & { + b_id_farm?: string + } + ).b_id_farm ?? "farm", + }, + ]) +} + +/** + * Calculates the organic matter balance for multiple farms, aggregating results from all its fields. + * + * This function serves as the main entry point for the organic matter balance calculation. + * It takes a comprehensive set of input data for a farm, processes each field in batches + * to calculate its individual balance, and then aggregates these results into farm-level balances. + * The final output is a numeric representation of the balance, suitable for display or further analysis. + * + * @param fdm - The FDM instance for database access (caching). + * @param organicMatterBalanceInput - The complete dataset required for the calculation, including all fields, + * fertilizer catalogues, and cultivation catalogues for the farm. + * @returns A promise that resolves to the aggregated `OrganicMatterBalanceNumeric` object for the farm. + * @throws {Error} Throws an error if the calculation process fails for any reason. + */ +export async function calculateOrganicMatterBalanceForFarms( + fdm: FdmType, + inputs: (OrganicMatterBalanceInput & { b_id_farm: string })[], +) { + const fieldInputs: (OrganicMatterBalanceFieldInput & { + b_id_farm: string + })[] = inputs.flatMap((input) => + input.fields.map((field) => ({ + b_id_farm: input.b_id_farm, + fieldInput: field, + fertilizerDetails: input.fertilizerDetails, + cultivationDetails: input.cultivationDetails, + timeFrame: input.timeFrame, + })), + ) + return calculateOrganicMatterBalanceBatched(fdm, fieldInputs) +} +export async function calculateOrganicMatterBalanceBatched( + fdm: FdmType, + fieldInputs: (OrganicMatterBalanceFieldInput & { b_id_farm: string })[], +): Promise { // Process fields in batches to avoid overwhelming the system with concurrent promises, // especially for farms with a large number of fields. const fieldsWithBalanceResults: OrganicMatterBalanceFieldResultNumeric[] = [] const batchSize = 50 - for (let i = 0; i < fields.length; i += batchSize) { - const batch = fields.slice(i, i + batchSize) + for (let i = 0; i < fieldInputs.length; i += batchSize) { + const batch = fieldInputs.slice(i, i + batchSize) const batchResults = await Promise.all( batch.map(async (fieldInput) => { try { - const balance = await getOrganicMatterBalanceField(fdm, { + const balance = await getOrganicMatterBalanceField( + fdm, fieldInput, - fertilizerDetails, - cultivationDetails, - timeFrame, - }) + ) return { - b_id: fieldInput.field.b_id, - b_area: fieldInput.field.b_area ?? 0, - b_bufferstrip: fieldInput.field.b_bufferstrip ?? false, + b_id: fieldInput.fieldInput.field.b_id, + b_area: fieldInput.fieldInput.field.b_area ?? 0, + b_bufferstrip: + fieldInput.fieldInput.field.b_bufferstrip ?? false, balance, } } catch (error) { return { - b_id: fieldInput.field.b_id, - b_area: fieldInput.field.b_area ?? 0, - b_bufferstrip: fieldInput.field.b_bufferstrip ?? false, + b_id: fieldInput.fieldInput.field.b_id, + b_area: fieldInput.fieldInput.field.b_area ?? 0, + b_bufferstrip: + fieldInput.fieldInput.field.b_bufferstrip ?? false, errorMessage: error instanceof Error ? error.message diff --git a/fdm-calculator/src/balance/organic-matter/input.test.ts b/fdm-calculator/src/balance/organic-matter/input.test.ts index 6054586cc..41ea91495 100644 --- a/fdm-calculator/src/balance/organic-matter/input.test.ts +++ b/fdm-calculator/src/balance/organic-matter/input.test.ts @@ -1,22 +1,201 @@ +import type { + Cultivation, + CultivationCatalogue, + FdmType, + FertilizerApplication, + FertilizerCatalogue, + Field, + PrincipalId, + SoilAnalysis, +} from "@nmi-agro/fdm-core" import * as fdmCore from "@nmi-agro/fdm-core" -import { describe, expect, it, vi } from "vitest" -import { collectInputForOrganicMatterBalance } from "./input" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { + collectInputForOrganicMatterBalance, + collectInputForOrganicMatterBalanceForFarms, +} from "./input" +import type { FieldInput, OrganicMatterBalanceInput } from "./types" +// Mock the @nmi-agro/fdm-core module vi.mock("@nmi-agro/fdm-core", async () => { const original = await vi.importActual("@nmi-agro/fdm-core") return { ...original, getFields: vi.fn(), - getField: vi.fn(), getCultivations: vi.fn(), getHarvests: vi.fn(), getSoilAnalyses: vi.fn(), getFertilizerApplications: vi.fn(), - getFertilizers: vi.fn(), getCultivationsFromCatalogue: vi.fn(), + getFertilizersFromCatalogue: vi.fn(), + getEnabledCultivationCataloguesForFarms: vi.fn(), + getEnabledFertilizerCataloguesForFarms: vi.fn(), + getCultivationsFromCatalogues: vi.fn(), + getFertilizersFromCatalogues: vi.fn(), } }) +function createMockData() { + return { + // Mock data + mockFieldsData: [ + { + b_id: "field-1", + b_name: "Field 1", + b_id_farm: "test-farm-id", + b_id_source: "source-1", + b_geometry: { type: "Polygon", coordinates: [] }, + b_centroid: [0, 0], + b_area: 10, + b_perimeter: 10, + b_start: new Date("2023-01-01"), + b_end: new Date("2023-12-31"), + b_acquiring_method: "purchase", + b_bufferstrip: false, + }, + { + b_id: "field-2", + b_name: "Field 2", + b_id_farm: "test-farm-id", + b_id_source: "source-2", + b_geometry: { type: "Polygon", coordinates: [] }, + b_centroid: [1, 1], + b_area: 20, + b_perimeter: 20, + b_start: new Date("2023-01-01"), + b_end: new Date("2023-12-31"), + b_acquiring_method: "purchase", + b_bufferstrip: false, + }, + ] as Field[], + mockCultivationsData: [ + { + b_lu: "cult-1", + b_lu_catalogue: "cat-cult-1", + m_cropresidue: false, + b_lu_start: new Date("2023-04-01"), + b_lu_end: new Date("2023-09-01"), + b_lu_source: "source", + b_lu_name: "Cultivation 1", + b_lu_name_en: "Cultivation 1", + b_lu_hcat3: "hcat3", + b_lu_hcat3_name: "Hcat3 Name", + b_lu_croprotation: "maize", + b_lu_eom: 1, + b_lu_eom_residue: 1, + b_lu_harvestcat: "HC010", + b_lu_harvestable: "once", + b_lu_variety: "variety", + b_id: "cult-1", + }, + ] as Cultivation[], + mockCultivationsData2: [ + { + b_lu: "cult-2", + b_lu_catalogue: "cat-cult-2", + m_cropresidue: false, + b_lu_start: new Date("2023-04-01"), + b_lu_end: new Date("2023-09-01"), + b_lu_source: "source", + b_lu_name: "Cultivation 2", + b_lu_name_en: "Cultivation 2", + b_lu_hcat3: "hcat3", + b_lu_hcat3_name: "Hcat3 Name", + b_lu_croprotation: "maize", + b_lu_eom: 1, + b_lu_eom_residue: 1, + b_lu_harvestcat: "HC010", + b_lu_harvestable: "once", + b_lu_variety: "variety", + b_id: "cult-2", + }, + ] as Cultivation[], + mockSoilAnalysesData: [ + { + a_id: "sa-1", + a_date: new Date(), + a_depth_upper: 0, + a_depth_lower: 30, + a_source: "source", + a_c_of: 25, + a_cn_fr: 10, + a_density_sa: 1.5, + a_n_rt: 100, + b_soiltype_agr: "SAND", + b_sampling_date: new Date("2023-05-01"), + a_som_loi: 5, + b_gwl_class: "HIGH", + }, + ] as SoilAnalysis[], + mockFertilizerApplicationsData: [ + { + p_app_id: "fa-1", + p_id_catalogue: "fert-cat-1", + p_name_nl: "test-product", + p_app_amount: 100, + p_app_method: "broadcasting", // match one of ApplicationMethods + p_app_date: new Date(), + p_id: "fert-1", + }, + ] as FertilizerApplication[], + mockFertilizerApplicationsData2: [ + { + p_app_id: "fa-2", + p_id_catalogue: "fert-cat-2", + p_name_nl: "test-product", + p_app_amount: 100, + p_app_method: "broadcasting", // match one of ApplicationMethods + p_app_date: new Date(), + p_id: "fert-2", + }, + ] as FertilizerApplication[], + mockFertilizerDetailsData: [ + { + p_id_catalogue: "fert-cat-1", + p_n_rt: 5, + p_type: "manure", + p_no3_rt: 1, + p_nh4_rt: 2, + p_s_rt: 0, + p_ef_nh3: 0.1, + }, + ] as FertilizerCatalogue[], + mockFertilizerDetailsData2: [ + { + p_id_catalogue: "fert-cat-2", + p_n_rt: 5, + p_type: "manure", + p_no3_rt: 1, + p_nh4_rt: 2, + p_s_rt: 0, + p_ef_nh3: 0.1, + }, + ] as FertilizerCatalogue[], + mockCultivationDetailsData: [ + { + b_lu_catalogue: "cat-cult-1", + b_lu_croprotation: "maize", + b_lu_yield: 5000, + b_lu_hi: 0.45, + b_lu_n_harvestable: 1.2, + b_lu_n_residue: 0.8, + b_n_fixation: 0, + }, + ] as CultivationCatalogue[], + mockCultivationDetailsData2: [ + { + b_lu_catalogue: "cat-cult-2", + b_lu_croprotation: "cereal", + b_lu_yield: 5000, + b_lu_hi: 0.45, + b_lu_n_harvestable: 1.2, + b_lu_n_residue: 0.8, + b_n_fixation: 0, + }, + ] as CultivationCatalogue[], + } +} + describe("collectInputForOrganicMatterBalance", () => { const mockFdm: any = { transaction: (callback: any) => callback(mockFdm), @@ -30,13 +209,27 @@ describe("collectInputForOrganicMatterBalance", () => { it("should collect input for all fields in a farm", async () => { const mockFields = [{ b_id: "field1" }, { b_id: "field2" }] + const { + mockFertilizerApplicationsData, + mockFertilizerDetailsData, + mockCultivationsData, + mockCultivationDetailsData, + } = createMockData() vi.spyOn(fdmCore, "getFields").mockResolvedValue(mockFields as any) - vi.spyOn(fdmCore, "getCultivations").mockResolvedValue([]) + vi.spyOn(fdmCore, "getCultivations").mockResolvedValue( + mockCultivationsData, + ) vi.spyOn(fdmCore, "getHarvests").mockResolvedValue([]) vi.spyOn(fdmCore, "getSoilAnalyses").mockResolvedValue([]) - vi.spyOn(fdmCore, "getFertilizerApplications").mockResolvedValue([]) - vi.spyOn(fdmCore, "getFertilizers").mockResolvedValue([]) - vi.spyOn(fdmCore, "getCultivationsFromCatalogue").mockResolvedValue([]) + vi.spyOn(fdmCore, "getFertilizerApplications").mockResolvedValue( + mockFertilizerApplicationsData, + ) + vi.spyOn(fdmCore, "getFertilizersFromCatalogue").mockResolvedValue( + mockFertilizerDetailsData as any, + ) + vi.spyOn(fdmCore, "getCultivationsFromCatalogue").mockResolvedValue( + mockCultivationDetailsData as any, + ) const result = await collectInputForOrganicMatterBalance( mockFdm, @@ -62,7 +255,7 @@ describe("collectInputForOrganicMatterBalance", () => { vi.spyOn(fdmCore, "getHarvests").mockResolvedValue([]) vi.spyOn(fdmCore, "getSoilAnalyses").mockResolvedValue([]) vi.spyOn(fdmCore, "getFertilizerApplications").mockResolvedValue([]) - vi.spyOn(fdmCore, "getFertilizers").mockResolvedValue([]) + vi.spyOn(fdmCore, "getFertilizersFromCatalogue").mockResolvedValue([]) vi.spyOn(fdmCore, "getCultivationsFromCatalogue").mockResolvedValue([]) const result = await collectInputForOrganicMatterBalance( @@ -100,12 +293,12 @@ describe("collectInputForOrganicMatterBalance", () => { it("should correctly structure the output", async () => { const mockField = { b_id: "field1" } const mockCultivation = { b_lu: "cult1" } - const mockFertilizer = { p_id_catalogue: "fert1" } + const mockFertilizer = { p_id: "fert-1", p_id_catalogue: "fert-cat-1" } vi.spyOn(fdmCore, "getFields").mockResolvedValue([mockField] as any) vi.spyOn(fdmCore, "getCultivations").mockResolvedValue([ mockCultivation, ] as any) - vi.spyOn(fdmCore, "getFertilizers").mockResolvedValue([ + vi.spyOn(fdmCore, "getFertilizersFromCatalogue").mockResolvedValue([ mockFertilizer, ] as any) vi.spyOn(fdmCore, "getCultivationsFromCatalogue").mockResolvedValue([ @@ -113,7 +306,9 @@ describe("collectInputForOrganicMatterBalance", () => { ] as any) vi.spyOn(fdmCore, "getHarvests").mockResolvedValue([]) vi.spyOn(fdmCore, "getSoilAnalyses").mockResolvedValue([]) - vi.spyOn(fdmCore, "getFertilizerApplications").mockResolvedValue([]) + vi.spyOn(fdmCore, "getFertilizerApplications").mockResolvedValue([ + { p_id_catalogue: "fert-cat-1" } as any, + ]) const result = await collectInputForOrganicMatterBalance( mockFdm, @@ -126,6 +321,194 @@ describe("collectInputForOrganicMatterBalance", () => { expect(result).toHaveProperty("fertilizerDetails") expect(result).toHaveProperty("cultivationDetails") expect(result).toHaveProperty("timeFrame") - expect(result.fertilizerDetails[0].p_id_catalogue).toBe("fert1") + expect(result.fertilizerDetails[0].p_id_catalogue).toBe("fert-cat-1") + }) +}) + +describe("collectInputForOrganicMatterBalanceForFarms", () => { + const mockFdm: FdmType = { + // @ts-expect-error - we are mocking the transaction + transaction: async (callback) => callback(mockFdm), // Simplified mock transaction + // Add other FdmType properties if needed for type checking, or cast to any + } as FdmType + + const principal_id: PrincipalId = "test-principal-id" + const timeframe = { + start: new Date("2023-01-01"), + end: new Date("2023-12-31"), + } + + beforeEach(() => { + vi.resetAllMocks() + }) + + it("should collect cultivation details only once", async () => { + // Setup mocks + const { + mockFieldsData, + mockCultivationsData, + mockCultivationsData2, + mockSoilAnalysesData, + mockFertilizerApplicationsData, + mockFertilizerApplicationsData2, + mockFertilizerDetailsData, + mockFertilizerDetailsData2, + mockCultivationDetailsData, + mockCultivationDetailsData2, + } = createMockData() + const mockFieldsData2 = mockFieldsData.map((field) => ({ + ...field, + b_id: `2-${field.b_id}`, + b_id_farm: "test-farm-id-2", + })) + + // Setup mocks + vi.spyOn(fdmCore, "getFields").mockImplementation( + async (_1, _2, b_id_farm) => + b_id_farm === "test-farm-id-2" + ? mockFieldsData2 + : mockFieldsData, + ) + vi.spyOn(fdmCore, "getCultivations").mockImplementation( + async (_1, _2, b_id) => + b_id.startsWith("2-") + ? mockCultivationsData2 + : mockCultivationsData, + ) + vi.spyOn(fdmCore, "getSoilAnalyses").mockResolvedValue( + mockSoilAnalysesData, + ) + vi.spyOn(fdmCore, "getFertilizerApplications").mockImplementation( + async (_1, _2, b_id) => + b_id.startsWith("2-") + ? mockFertilizerApplicationsData2 + : mockFertilizerApplicationsData, + ) + const cultDetailsWithSource1 = mockCultivationDetailsData.map((c) => ({ + ...c, + b_lu_source: "brp", + })) + const cultDetailsWithSource2 = mockCultivationDetailsData2.map((c) => ({ + ...c, + b_lu_source: "brp", + })) + const allCultivationDetails = [ + ...cultDetailsWithSource1, + ...cultDetailsWithSource2, + ] + const fertData1 = mockFertilizerDetailsData.map((fert) => ({ + ...fert, + p_source: "test-farm-id", + })) + const fertData2 = mockFertilizerDetailsData2.map((fert) => ({ + ...fert, + p_source: "test-farm-id-2", + })) + const allFertilizerDetails = [...fertData1, ...fertData2] + vi.spyOn( + fdmCore, + "getEnabledCultivationCataloguesForFarms", + ).mockResolvedValue({ + "test-farm-id": ["brp"], + "test-farm-id-2": ["brp"], + }) + vi.spyOn( + fdmCore, + "getEnabledFertilizerCataloguesForFarms", + ).mockResolvedValue({ + "test-farm-id": ["test-farm-id"], + "test-farm-id-2": ["test-farm-id-2"], + }) + vi.spyOn(fdmCore, "getCultivationsFromCatalogues").mockResolvedValue( + allCultivationDetails as any, + ) + vi.spyOn(fdmCore, "getFertilizersFromCatalogues").mockResolvedValue( + allFertilizerDetails as any, + ) + + const result = await collectInputForOrganicMatterBalanceForFarms( + mockFdm, + principal_id, + ["test-farm-id", "test-farm-id-2"], + timeframe, + ) + + const makeFieldInput = ( + fieldData: Field, + fertilizerApplications: FertilizerApplication[], + cultivations: Cultivation[], + ) => ({ + field: fieldData, + cultivations: cultivations, + soilAnalyses: mockSoilAnalysesData, + fertilizerApplications: fertilizerApplications, + }) + const expectedFieldInputs: FieldInput[] = mockFieldsData.map( + (fieldData) => + makeFieldInput( + fieldData, + mockFertilizerApplicationsData, + mockCultivationsData, + ), + ) + const expectedFieldInputs2: FieldInput[] = mockFieldsData2.map( + (fieldData) => + makeFieldInput( + fieldData, + mockFertilizerApplicationsData2, + mockCultivationsData2, + ), + ) + + const expectedResult: (OrganicMatterBalanceInput & { + b_id_farm?: string + })[] = [ + { + b_id_farm: "test-farm-id", + fields: expectedFieldInputs, + fertilizerDetails: fertData1, + cultivationDetails: cultDetailsWithSource1, + timeFrame: timeframe, + }, + { + b_id_farm: "test-farm-id-2", + fields: expectedFieldInputs2, + fertilizerDetails: fertData2, + cultivationDetails: cultDetailsWithSource2, + timeFrame: timeframe, + }, + ] + + expect(result).toEqual(expectedResult) + + expect( + fdmCore.getEnabledCultivationCataloguesForFarms, + ).toHaveBeenCalledWith(mockFdm, principal_id, [ + "test-farm-id", + "test-farm-id-2", + ]) + expect( + fdmCore.getEnabledCultivationCataloguesForFarms, + ).toHaveBeenCalledTimes(1) + expect( + fdmCore.getEnabledFertilizerCataloguesForFarms, + ).toHaveBeenCalledWith(mockFdm, principal_id, [ + "test-farm-id", + "test-farm-id-2", + ]) + expect( + fdmCore.getEnabledFertilizerCataloguesForFarms, + ).toHaveBeenCalledTimes(1) + expect(fdmCore.getCultivationsFromCatalogues).toHaveBeenCalledWith( + mockFdm, + ["brp"], + ) + expect(fdmCore.getCultivationsFromCatalogues).toHaveBeenCalledTimes(1) + expect(fdmCore.getFertilizersFromCatalogues).toHaveBeenCalledWith( + mockFdm, + principal_id, + expect.arrayContaining(["test-farm-id", "test-farm-id-2"]), + ) + expect(fdmCore.getFertilizersFromCatalogues).toHaveBeenCalledTimes(1) }) }) diff --git a/fdm-calculator/src/balance/organic-matter/input.ts b/fdm-calculator/src/balance/organic-matter/input.ts index d04b4b120..3d346e8d0 100644 --- a/fdm-calculator/src/balance/organic-matter/input.ts +++ b/fdm-calculator/src/balance/organic-matter/input.ts @@ -6,26 +6,31 @@ import type { } from "@nmi-agro/fdm-core" import { getCultivations, + getCultivationsFromCatalogues, getCultivationsFromCatalogue, + getEnabledCultivationCataloguesForFarms, + getEnabledFertilizerCataloguesForFarms, getFertilizerApplications, - getFertilizers, + getFertilizersFromCatalogues, + getFertilizersFromCatalogue, getField, getFields, getSoilAnalyses, } from "@nmi-agro/fdm-core" -import type { OrganicMatterBalanceInput } from "./types" +import { handleInputCollectionError } from "../shared/errors" +import type { FieldInput, OrganicMatterBalanceInput } from "./types" /** - * Collects all necessary input data from an FDM instance to calculate the organic matter balance for a farm or a specific field. + * Collects all necessary input data from an FDM instance to calculate the organic matter balance of a single farm. * * This function acts as a data-gathering layer, interacting with the FDM core to fetch * all records required for the organic matter balance calculation. It retrieves data for a given farm * and timeframe, including field details, cultivation history, soil analyses, and fertilizer applications. * It also fetches the complete fertilizer and cultivation catalogues for the farm to provide necessary details * for the calculations (e.g., EOM values). - * - * The collected data is then structured into an `OrganicMatterBalanceInput` object, which can be directly - * passed to the main `calculateOrganicMatterBalance` function. + * A complete OrganicMatterBalanceInput object can be built by collecting the cultivationDetails and + * fertilizerDetails separately, then combining them in a new object along with the array + * returned from this function, ending up with a `OrganicMatterBalanceInput` object. * * @param fdm - The FDM instance, used for all database interactions. * @param principal_id - The ID of the user or service principal requesting the data, for authorization purposes. @@ -37,13 +42,13 @@ import type { OrganicMatterBalanceInput } from "./types" * * @alpha */ -export async function collectInputForOrganicMatterBalance( +async function collectInputForOrganicMatterBalanceForFarm( fdm: FdmType, principal_id: PrincipalId, b_id_farm: fdmSchema.farmsTypeSelect["b_id_farm"], timeframe: Timeframe, b_id?: fdmSchema.fieldsTypeSelect["b_id"], -): Promise { +): Promise { try { // All data fetching is wrapped in a single database transaction to ensure consistency. return await fdm.transaction(async (tx: FdmType) => { @@ -67,7 +72,7 @@ export async function collectInputForOrganicMatterBalance( } // 2. For each field, collect all related data concurrently. - const fields = await Promise.all( + return await Promise.all( farmFields.map(async (field) => { // Fetch cultivation history for the field. const cultivations = await getCultivations( @@ -103,22 +108,218 @@ export async function collectInputForOrganicMatterBalance( } }), ) + }) + } catch (error) { + throw handleOrganicMatterBalanceInputCollectionError(error, b_id_farm) + } +} +/** + * Collects all necessary input data from an FDM instance to calculate the organic matter balance for multiple farms or + * their specific field while minimizing data fetches. + * + * This function acts as a data-gathering layer, interacting with the FDM core to fetch + * all records required for the organic matter balance calculation. It retrieves data for a given farm + * and timeframe, including field details, cultivation history, soil analyses, and fertilizer applications. + * It also fetches the complete fertilizer and cultivation catalogues for the farm to provide necessary details + * for the calculations (e.g., EOM values). + * + * The collected data is then structured into an `OrganicMatterBalanceInput` object, which can be directly + * passed to the main `calculateOrganicMatterBalance` function. + * + * @param fdm - The FDM instance, used for all database interactions. + * @param principal_id - The ID of the user or service principal requesting the data, for authorization purposes. + * @param farmIds - The unique identifiers for the farms. + * @param timeframe - The time period (start and end dates) for which to collect the data. + * @param b_id - Optional. If provided, the data collection will be limited to this specific field ID. Otherwise, data for all fields in the farm will be collected. + * **Do not** provide this if collecting input for multiple farms, it will yield an unusable input. + * @returns A promise that resolves with a single `OrganicMatterBalanceInput` object containing all the structured data for the calculation. + * @throws {Error} Throws an error if any of the database queries fail or if a specified field is not found. + * + * @alpha + */ +/** + * Collects all necessary input data from an FDM instance to calculate the organic matter balance for multiple farms. + * + * This function acts as a data-gathering layer, interacting with the FDM core to fetch + * all records required for the organic matter balance calculation. It retrieves data for the given farms + * and timeframe, including field details, cultivation history, soil analyses, and fertilizer applications. + * It fetches the complete fertilizer and cultivation catalogues for all farms in batch to minimise + * database round-trips. + * + * @param fdm - The FDM instance, used for all database interactions. + * @param principal_id - The ID of the user or service principal requesting the data, for authorization purposes. + * @param farmIds - The unique identifiers for the farms. + * @param timeframe - The time period (start and end dates) for which to collect the data. + * @returns A promise that resolves with an array of `OrganicMatterBalanceInput` objects with b_id_farm containing all the structured data. + * @throws {Error} Throws an error if any of the database queries fail or if a specified field is not found. + * + * @alpha + */ +export async function collectInputForOrganicMatterBalanceForFarms( + fdm: FdmType, + principal_id: PrincipalId, + farmIds: fdmSchema.farmsTypeSelect["b_id_farm"][], + timeframe: Timeframe, +): Promise<(OrganicMatterBalanceInput & { b_id_farm: string })[]> { + try { + return await fdm.transaction(async (tx: FdmType) => { + const uniqueFarmIds = [...new Set(farmIds)] + + // Step 1: Get enabled catalogue sources for all farms in a single batch query + const [farmCultivationCatalogues, farmFertilizerCatalogues] = + await Promise.all([ + getEnabledCultivationCataloguesForFarms( + tx, + principal_id, + uniqueFarmIds, + ), + getEnabledFertilizerCataloguesForFarms( + tx, + principal_id, + uniqueFarmIds, + ), + ]) - // 3. Fetch farm-level catalogue data. - // These details are fetched once for the entire farm and reused for each field. - const fertilizerDetails = await getFertilizers( + // Step 2: Deduplicate catalogue sources across farms and fetch items once + const uniqueCultivationSources = [ + ...new Set( + Object.values(farmCultivationCatalogues).flat(), + ), + ] + const uniqueFertilizerSources = [ + ...new Set( + Object.values(farmFertilizerCatalogues).flat(), + ), + ] + const [allCultivations, allFertilizers] = await Promise.all([ + getCultivationsFromCatalogues(tx, uniqueCultivationSources), + getFertilizersFromCatalogues(tx, principal_id, uniqueFertilizerSources), + ]) + + // Step 3: Process each farm using the pre-fetched catalogue data + const farmSettled = await Promise.allSettled( + uniqueFarmIds.map(async (b_id_farm) => { + const onlyFieldInput = + await collectInputForOrganicMatterBalanceForFarm( + tx, + principal_id, + b_id_farm, + timeframe, + ) + + // Filter catalogue items to only those referenced by this farm's fields + const farmCultivationSources = new Set( + farmCultivationCatalogues[b_id_farm] ?? [], + ) + const cultivationIds = new Set( + onlyFieldInput.flatMap((input) => + input.cultivations.map( + (cultivation) => cultivation.b_lu_catalogue, + ), + ), + ) + const cultivationDetailsForThisFarm = + allCultivations.filter( + (c) => + farmCultivationSources.has(c.b_lu_source) && + cultivationIds.has(c.b_lu_catalogue), + ) + + const farmFertilizerSources = new Set( + farmFertilizerCatalogues[b_id_farm] ?? [], + ) + const fertilizerIds = new Set( + onlyFieldInput.flatMap((input) => + input.fertilizerApplications.map( + (app) => app.p_id_catalogue, + ), + ), + ) + const fertilizerDetailsForThisFarm = + allFertilizers.filter( + (f) => + farmFertilizerSources.has(f.p_source) && + fertilizerIds.has(f.p_id_catalogue), + ) + + return { + b_id_farm: b_id_farm, + fields: onlyFieldInput, + fertilizerDetails: fertilizerDetailsForThisFarm, + cultivationDetails: cultivationDetailsForThisFarm, + timeFrame: timeframe, + } + }), + ) + return farmSettled + .filter((result) => { + if (result.status === "rejected") { + console.error( + handleOrganicMatterBalanceInputCollectionError( + result.reason, + ).message, + ) + return false + } + return true + }) + .map((result) => (result as PromiseFulfilledResult).value) + }) + } catch (error) { + throw handleOrganicMatterBalanceInputCollectionError(error) + } +} + +/** + * Collects all necessary input data from an FDM instance to calculate the organic matter balance for a farm or a specific field. + * + * This function acts as a data-gathering layer, interacting with the FDM core to fetch + * all records required for the organic matter balance calculation. It retrieves data for a given farm + * and timeframe, including field details, cultivation history, soil analyses, and fertilizer applications. + * It also fetches the complete fertilizer and cultivation catalogues for the farm to provide necessary details + * for the calculations (e.g., EOM values). + * + * The collected data is then structured into an `OrganicMatterBalanceInput` object, which can be directly + * passed to the main `calculateOrganicMatterBalance` function. + * + * @param fdm - The FDM instance, used for all database interactions. + * @param principal_id - The ID of the user or service principal requesting the data, for authorization purposes. + * @param b_id_farm - The unique identifier for the farm. + * @param timeframe - The time period (start and end dates) for which to collect the data. + * @param b_id - Optional. If provided, the data collection will be limited to this specific field ID. Otherwise, data for all fields in the farm will be collected. + * @returns A promise that resolves with a single `OrganicMatterBalanceInput` object containing all the structured data for the calculation. + * @throws {Error} Throws an error if any of the database queries fail or if a specified field is not found. + * + * @alpha + */ +export async function collectInputForOrganicMatterBalance( + fdm: FdmType, + principal_id: PrincipalId, + b_id_farm: fdmSchema.farmsTypeSelect["b_id_farm"], + timeframe: Timeframe, + b_id?: fdmSchema.fieldsTypeSelect["b_id"], +) { + try { + return await fdm.transaction(async (tx: FdmType) => { + const cultivationDetails = await getCultivationsFromCatalogue( tx, principal_id, b_id_farm, ) - const cultivationDetails = await getCultivationsFromCatalogue( + const fertilizerDetails = await getFertilizersFromCatalogue( tx, principal_id, b_id_farm, ) - - // 4. Assemble the final input object. + const fields = await collectInputForOrganicMatterBalanceForFarm( + tx, + principal_id, + b_id_farm, + timeframe, + b_id, + ) return { + b_id_farm, fields, fertilizerDetails, cultivationDetails, @@ -126,12 +327,12 @@ export async function collectInputForOrganicMatterBalance( } }) } catch (error) { - // Wrap any errors in a more descriptive error message. - throw new Error( - `Failed to collect organic matter balance input for farm ${b_id_farm}: ${ - error instanceof Error ? error.message : String(error) - }`, - { cause: error }, - ) + throw handleOrganicMatterBalanceInputCollectionError(error) } } + +export const handleOrganicMatterBalanceInputCollectionError = + handleInputCollectionError( + "Failed to collect organic matter balance input for farm", + "Failed to collect organic matter balance input", + ) diff --git a/fdm-calculator/src/balance/shared/errors.test.ts b/fdm-calculator/src/balance/shared/errors.test.ts new file mode 100644 index 000000000..f5e9c17b7 --- /dev/null +++ b/fdm-calculator/src/balance/shared/errors.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest" +import { handleInputCollectionError } from "./errors" + +describe("handleInputCollectionError", () => { + const handleNutrientBalanceInputCollectionError = + handleInputCollectionError( + "Failed to collect nutrient balance input for farm", + "Failed to collect nutrient balance input", + ) + + it("should wrap errors in the context of a farm", () => { + const cause = new Error("Database transaction error") + try { + throw cause + } catch (error) { + const wrapped = handleNutrientBalanceInputCollectionError( + error, + "test-farm", + ) + expect(wrapped.message).toEqual( + "Failed to collect nutrient balance input for farm test-farm: Database transaction error", + ) + expect(wrapped.cause).toBe(cause) + } + }) + + it("should wrap errors outside the context of a farm", () => { + const cause = new Error("Database transaction error") + try { + throw cause + } catch (error) { + const wrapped = handleNutrientBalanceInputCollectionError(error) + expect(wrapped.message).toEqual( + "Failed to collect nutrient balance input: Database transaction error", + ) + expect(wrapped.cause).toBe(cause) + } + }) + + it("should not rewrap known errors in the context of a farm", () => { + const cause = new Error("Database transaction error") + try { + throw handleNutrientBalanceInputCollectionError(cause, "test-farm") + } catch (error) { + const wrapped = handleNutrientBalanceInputCollectionError(error) + expect(wrapped.message).toEqual( + "Failed to collect nutrient balance input for farm test-farm: Database transaction error", + ) + expect(wrapped.cause).toBe(cause) + } + }) + + it("should not rewrap known errors outside the context of a farm", () => { + const cause = new Error("Database transaction error") + try { + throw handleNutrientBalanceInputCollectionError(cause) + } catch (error) { + const wrapped = handleNutrientBalanceInputCollectionError( + error, + "test-farm", + ) + expect(wrapped.message).toEqual( + "Failed to collect nutrient balance input: Database transaction error", + ) + expect(wrapped.cause).toBe(cause) + } + }) + + it("should handle errors that are not instance of Error", () => { + try { + throw null + } catch (error) { + const wrapped = handleNutrientBalanceInputCollectionError(error) + expect(wrapped.message).toEqual( + "Failed to collect nutrient balance input: null", + ) + expect(wrapped.cause).toEqual(null) + } + }) +}) diff --git a/fdm-calculator/src/balance/shared/errors.ts b/fdm-calculator/src/balance/shared/errors.ts new file mode 100644 index 000000000..56e46ca63 --- /dev/null +++ b/fdm-calculator/src/balance/shared/errors.ts @@ -0,0 +1,31 @@ +/** + * Handle any errors that might occur during input collection for one or multiple farms or a field. + * + * It will wrap the error with a new error with a descriptive message if it was an unexpected error. + * + * @param error error to wrap and return or return as it is + * @param b_id_farm farm ID if the error occurs in the context of input collection for a specific farm + * @returns a wrapped error or the input error itself. The returned error is guaranteed to have a descriptive message. + */ +export const handleInputCollectionError = + (failedToCollectForFarmMessage: string, failedToCollectMessage: string) => + (error: unknown, b_id_farm?: string) => { + if ( + error instanceof Error && + (error.message?.startsWith(failedToCollectForFarmMessage) || + error.message?.startsWith(failedToCollectMessage)) + ) { + return error + } + // Wrap any errors in a more descriptive error message. + return new Error( + b_id_farm + ? `${failedToCollectForFarmMessage} ${b_id_farm}: ${ + error instanceof Error ? error.message : String(error) + }` + : `${failedToCollectMessage}: ${ + error instanceof Error ? error.message : String(error) + }`, + { cause: error }, + ) + } diff --git a/fdm-calculator/src/index.ts b/fdm-calculator/src/index.ts index 60094ac74..766d9f4b9 100644 --- a/fdm-calculator/src/index.ts +++ b/fdm-calculator/src/index.ts @@ -3,9 +3,14 @@ export const fdmCalculator = pkg export { calculateNitrogenBalance, calculateNitrogenBalanceField, + calculateNitrogenBalanceForFarms, + calculateNitrogenBalancesFieldToFarm, getNitrogenBalanceField, } from "./balance/nitrogen/index" -export { collectInputForNitrogenBalance } from "./balance/nitrogen/input" +export { + collectInputForNitrogenBalance, + collectInputForNitrogenBalanceForFarms, +} from "./balance/nitrogen/input" export type { FieldInput, NitrogenBalanceFieldInput, @@ -28,9 +33,14 @@ export type { export { calculateOrganicMatterBalance, calculateOrganicMatterBalanceField, + calculateOrganicMatterBalanceForFarms, + calculateOrganicMatterBalancesFieldToFarm, getOrganicMatterBalanceField, } from "./balance/organic-matter/index" -export { collectInputForOrganicMatterBalance } from "./balance/organic-matter/input" +export { + collectInputForOrganicMatterBalance, + collectInputForOrganicMatterBalanceForFarms, +} from "./balance/organic-matter/input" export type { OrganicMatterBalanceFieldNumeric, OrganicMatterBalanceFieldResultNumeric, diff --git a/fdm-core/src/calculator.ts b/fdm-core/src/calculator.ts index 5f5adbbbe..e4f1b8234 100644 --- a/fdm-core/src/calculator.ts +++ b/fdm-core/src/calculator.ts @@ -87,14 +87,17 @@ export async function setCachedCalculation( result: T_Output, ) { // Inserts a new cache record. If a record with the same calculation_hash already exists, - // this operation will likely cause a unique constraint violation error, as upsert was removed. - await fdm.insert(calculationCacheTable).values({ - calculation_hash: calculationHash, - calculation_function: calculationFunctionName, - calculator_version: calculatorVersion, - input: input, - result: result, - }) + // skip the insert — the stored result is identical since the hash is deterministic. + await fdm + .insert(calculationCacheTable) + .values({ + calculation_hash: calculationHash, + calculation_function: calculationFunctionName, + calculator_version: calculatorVersion, + input: input, + result: result, + }) + .onConflictDoNothing() } /** diff --git a/fdm-core/src/catalogues.ts b/fdm-core/src/catalogues.ts index da9de473b..cecf39c22 100644 --- a/fdm-core/src/catalogues.ts +++ b/fdm-core/src/catalogues.ts @@ -4,7 +4,7 @@ import { hashCultivation, hashFertilizer, } from "@nmi-agro/fdm-data" -import { and, eq } from "drizzle-orm" +import { and, eq, inArray } from "drizzle-orm" import { checkPermission } from "./authorization" import type { PrincipalId } from "./authorization.d" import * as schema from "./db/schema" @@ -96,6 +96,116 @@ export async function getEnabledCultivationCatalogues( } } +/** + * Gets all enabled fertilizer catalogues for multiple farms. + * + * @param fdm The FDM instance providing the connection to the database. The instance can be created with {@link createFdmServer}. + * @param principal_id The ID of the principal making the request. + * @param farmIds The IDs of the farms. + * @returns A Promise that resolves to a record mapping each farm ID to an array of its enabled fertilizer catalogue sources. + * @throws If retrieving the catalogues fails. + */ +export async function getEnabledFertilizerCataloguesForFarms( + fdm: FdmType, + principal_id: PrincipalId, + farmIds: schema.farmsTypeSelect["b_id_farm"][], +): Promise> { + try { + await Promise.all( + farmIds.map((b_id_farm) => + checkPermission( + fdm, + "farm", + "read", + b_id_farm, + principal_id, + "getEnabledFertilizerCataloguesForFarms", + ), + ), + ) + const rows = await fdm + .select({ + b_id_farm: schema.fertilizerCatalogueEnabling.b_id_farm, + p_source: schema.fertilizerCatalogueEnabling.p_source, + }) + .from(schema.fertilizerCatalogueEnabling) + .where( + inArray(schema.fertilizerCatalogueEnabling.b_id_farm, farmIds), + ) + + const result: Record = Object.fromEntries( + farmIds.map((id) => [id, [] as string[]]), + ) + for (const row of rows) { + result[row.b_id_farm].push(row.p_source) + } + return result + } catch (err) { + throw handleError( + err, + "Exception for getEnabledFertilizerCataloguesForFarms", + { principal_id, farmIds }, + ) + } +} + +/** + * Gets all enabled cultivation catalogues for multiple farms. + * + * @param fdm The FDM instance providing the connection to the database. The instance can be created with {@link createFdmServer}. + * @param principal_id The ID of the principal making the request. + * @param farmIds The IDs of the farms. + * @returns A Promise that resolves to a record mapping each farm ID to an array of its enabled cultivation catalogue sources. + * @throws If retrieving the catalogues fails. + */ +export async function getEnabledCultivationCataloguesForFarms( + fdm: FdmType, + principal_id: PrincipalId, + farmIds: schema.farmsTypeSelect["b_id_farm"][], +): Promise> { + try { + await Promise.all( + farmIds.map((b_id_farm) => + checkPermission( + fdm, + "farm", + "read", + b_id_farm, + principal_id, + "getEnabledCultivationCataloguesForFarms", + ), + ), + ) + const rows = await fdm + .select({ + b_id_farm: schema.cultivationCatalogueSelecting.b_id_farm, + b_lu_source: schema.cultivationCatalogueSelecting.b_lu_source, + }) + .from(schema.cultivationCatalogueSelecting) + .where( + inArray( + schema.cultivationCatalogueSelecting.b_id_farm, + farmIds, + ), + ) + + const result: Record = Object.fromEntries( + farmIds.map((id) => [id, [] as string[]]), + ) + for (const row of rows) { + result[row.b_id_farm].push(row.b_lu_source) + } + return result + } catch (err) { + throw handleError( + err, + "Exception for getEnabledCultivationCataloguesForFarms", + { principal_id, farmIds }, + ) + } +} + + /** * Enables a fertilizer catalogue for a farm. * diff --git a/fdm-core/src/cultivation.test.ts b/fdm-core/src/cultivation.test.ts index e0b6c541e..892ce292a 100644 --- a/fdm-core/src/cultivation.test.ts +++ b/fdm-core/src/cultivation.test.ts @@ -3,6 +3,7 @@ import { afterAll, beforeEach, describe, expect, inject, it } from "vitest" import { enableCultivationCatalogue, enableFertilizerCatalogue, + getEnabledCultivationCataloguesForFarms, } from "./catalogues" import { addCultivation, @@ -10,6 +11,7 @@ import { getCultivation, getCultivationPlan, getCultivations, + getCultivationsFromCatalogues, getCultivationsFromCatalogue, getDefaultDatesOfCultivation, removeCultivation, @@ -28,16 +30,21 @@ import { import { addField } from "./field" import { addHarvest } from "./harvest" import { createId } from "./id" +import { mockFdmThatThrowsOnSelectFrom } from "./test-util" describe("Cultivation Data Model", () => { let fdm: FdmServerType let b_lu_catalogue: string + let b_lu_catalogue_2: string let b_id_farm: string + let b_id_farm_2: string let b_id: string + let b_id_2: string let b_lu: string let b_lu_start: Date let principal_id: string let b_lu_source: string + let b_lu_source_2: string beforeEach(async () => { const host = inject("host") @@ -48,11 +55,15 @@ describe("Cultivation Data Model", () => { fdm = createFdmServer(host, port, user, password, database) b_lu_catalogue = createId() + b_lu_catalogue_2 = createId() const farmName = "Test Farm" + const farmName2 = "Test Farm 2" const farmBusinessId = "123456" const farmAddress = "123 Farm Lane" const farmPostalCode = "12345" principal_id = createId() + + // Farm 1 b_id_farm = await addFarm( fdm, principal_id, @@ -92,6 +103,46 @@ describe("Cultivation Data Model", () => { b_id_farm, b_lu_source, ) + + b_id_farm_2 = await addFarm( + fdm, + principal_id, + farmName2, + farmBusinessId, + farmAddress, + farmPostalCode, + ) + + b_id_2 = await addField( + fdm, + principal_id, + b_id_farm_2, + "test field 2", + "test source", + { + type: "Polygon", + coordinates: [ + [ + [30, 10], + [40, 40], + [20, 40], + [10, 20], + [30, 10], + ], + ], + }, + new Date("2023-01-01"), + "nl_01", + new Date("2023-12-31"), + ) + + b_lu_source_2 = "custom-2" + await enableCultivationCatalogue( + fdm, + principal_id, + b_id_farm_2, + b_lu_source_2, + ) }) afterAll(async () => { @@ -101,10 +152,7 @@ describe("Cultivation Data Model", () => { describe("Cultivation CRUD", () => { beforeEach(async () => { // Ensure catalogue entry exists before each test - await addCultivationToCatalogue(fdm, { - b_lu_catalogue, - b_lu_source: b_lu_source, - b_lu_name: "test-name", + const details = { b_lu_name_en: "test-name-en", b_lu_harvestable: "once", b_lu_hcat3: "test-hcat3", @@ -120,9 +168,21 @@ describe("Cultivation Data Model", () => { b_lu_eom: 100, b_lu_eom_residue: 50, b_lu_rest_oravib: false, - b_lu_variety_options: ["variety1", "variety2"], + b_lu_variety_options: ["variety1", "variety2"] as string[], b_lu_start_default: "03-01", b_date_harvest_default: "09-15", + } as const + await addCultivationToCatalogue(fdm, { + ...details, + b_lu_catalogue, + b_lu_source: b_lu_source, + b_lu_name: "test-name", + }) + await addCultivationToCatalogue(fdm, { + ...details, + b_lu_catalogue: b_lu_catalogue_2, + b_lu_source: b_lu_source_2, + b_lu_name: "test-name-2", }) b_lu_start = new Date("2024-01-01") @@ -133,6 +193,13 @@ describe("Cultivation Data Model", () => { b_id, b_lu_start, ) + await addCultivation( + fdm, + principal_id, + b_lu_catalogue_2, + b_id_2, + b_lu_start, + ) }) it("should get cultivations from catalogue", async () => { @@ -141,7 +208,132 @@ describe("Cultivation Data Model", () => { principal_id, b_id_farm, ) - expect(cultivations).toBeDefined() + expect( + cultivations.find( + (cultivation) => + cultivation.b_lu_catalogue === b_lu_catalogue, + ), + ).toBeDefined() + }) + + it("should get all cultivations for farms from catalogue using composable functions", async () => { + const farmCatalogues = await getEnabledCultivationCataloguesForFarms( + fdm, + principal_id, + [b_id_farm, b_id_farm_2], + ) + expect(farmCatalogues[b_id_farm]).toBeDefined() + expect(farmCatalogues[b_id_farm_2]).toBeDefined() + + const allSources = [ + ...new Set([ + ...farmCatalogues[b_id_farm], + ...farmCatalogues[b_id_farm_2], + ]), + ] + const cultivations = await getCultivationsFromCatalogues( + fdm, + allSources, + ) + const farmSources1 = new Set(farmCatalogues[b_id_farm]) + expect( + cultivations.find( + (c) => + c.b_lu_catalogue === b_lu_catalogue && + farmSources1.has(c.b_lu_source), + ), + ).toBeDefined() + const farmSources2 = new Set(farmCatalogues[b_id_farm_2]) + expect( + cultivations.find( + (c) => + c.b_lu_catalogue === b_lu_catalogue_2 && + farmSources2.has(c.b_lu_source), + ), + ).toBeDefined() + }) + + it("should handle empty catalogues", async () => { + const b_id_farm = await addFarm( + fdm, + principal_id, + "Test Farm No Cultivations In Catalogue", + undefined, + undefined, + undefined, + ) + await enableCultivationCatalogue( + fdm, + principal_id, + b_id_farm, + "invalid-catalogue", + ) + expect( + await getCultivationsFromCatalogue( + fdm, + principal_id, + b_id_farm, + ), + ).toEqual([]) + }) + + it("should handle no enabled catalogues", async () => { + const b_id_farm = await addFarm( + fdm, + principal_id, + "Test Farm No Cultivations In Catalogue", + undefined, + undefined, + undefined, + ) + expect( + await getCultivationsFromCatalogue( + fdm, + principal_id, + b_id_farm, + ), + ).toEqual([]) + }) + + it("(getCultivationsFromCatalogue) should wrap errors with the correct message", async () => { + const failError = new Error("Should have thrown.") + try { + await getCultivationsFromCatalogue( + mockFdmThatThrowsOnSelectFrom( + fdm, + schema.cultivationsCatalogue, + ), + principal_id, + b_id_farm, + ) + throw failError + } catch (e) { + expect(e).not.toBe(failError) + expect(e).toBeInstanceOf(Error) + expect((e as Error).message).toBe( + "Exception for getCultivationsFromCatalogue", + ) + } + }) + + it("(getCultivationsFromCatalogues) should wrap errors with the correct message", async () => { + const failError = new Error("Should have thrown.") + try { + await getCultivationsFromCatalogues( + mockFdmThatThrowsOnSelectFrom( + fdm, + schema.cultivationsCatalogue, + ), + [b_lu_source], + ) + throw failError + } catch (err) { + expect(err).not.toBe(failError) + expect(err).toBeInstanceOf(Error) + expect((err as Error).message).toBe( + "Exception for getCultivationsFromCatalogues", + ) + } }) it("should add a new cultivation to the catalogue", async () => { diff --git a/fdm-core/src/cultivation.ts b/fdm-core/src/cultivation.ts index 00309c353..93a89fc83 100644 --- a/fdm-core/src/cultivation.ts +++ b/fdm-core/src/cultivation.ts @@ -14,6 +14,7 @@ import { } from "drizzle-orm" import { checkPermission } from "./authorization" import type { PrincipalId } from "./authorization.d" +import { getEnabledCultivationCatalogues } from "./catalogues" import type { Cultivation, CultivationCatalogue, @@ -48,48 +49,53 @@ export async function getCultivationsFromCatalogue( b_id_farm: schema.farmsTypeSelect["b_id_farm"], ): Promise { try { - await checkPermission( + const catalogueIds = await getEnabledCultivationCatalogues( fdm, - "farm", - "read", - b_id_farm, principal_id, - "getCultivationsFromCatalogue", + b_id_farm, ) + return await getCultivationsFromCatalogues(fdm, catalogueIds) + } catch (err) { + throw handleError( + err, + "Exception for getCultivationsFromCatalogue", + { principal_id, b_id_farm }, + ) + } +} - // Get enabled catalogues for the farm - const enabledCatalogues = await fdm - .select({ - b_lu_source: schema.cultivationCatalogueSelecting.b_lu_source, - }) - .from(schema.cultivationCatalogueSelecting) - .where( - eq(schema.cultivationCatalogueSelecting.b_id_farm, b_id_farm), - ) - - // If no catalogues are enabled, return empty array - if (enabledCatalogues.length === 0) { +/** + * Retrieves cultivations available in the given list of catalogues. + * + * No permission checks are performed. If a catalogue permission system is implemented in the future this may change. + * + * @param fdm The FDM instance providing the connection to the database. The instance can be created with {@link createFdmServer}. + * @param catalogueIds The source IDs of the catalogues to retrieve cultivations from, such as "brp". + * @returns A Promise that resolves with a flat array of cultivation catalogue entries across all given catalogues. + * @alpha + */ +export async function getCultivationsFromCatalogues( + fdm: FdmType, + catalogueIds: schema.cultivationCatalogueSelectingTypeSelect["b_lu_source"][], +): Promise { + try { + if (catalogueIds.length === 0) { return [] } - // Get cultivations from enabled catalogues - const cultivationsCatalogue = await fdm + return fdm .select() .from(schema.cultivationsCatalogue) .where( - inArray( - schema.cultivationsCatalogue.b_lu_source, - enabledCatalogues.map( - (c: { b_lu_source: string }) => c.b_lu_source, - ), - ), + inArray(schema.cultivationsCatalogue.b_lu_source, catalogueIds), + ) + .orderBy( + asc(schema.cultivationsCatalogue.b_lu_source), + asc(schema.cultivationsCatalogue.b_lu_name), ) - - return cultivationsCatalogue } catch (err) { - throw handleError(err, "Exception for getCultivationsFromCatalogue", { - principal_id, - b_id_farm, + throw handleError(err, "Exception for getCultivationsFromCatalogues", { + catalogueIds, }) } } diff --git a/fdm-core/src/fertilizer.d.ts b/fdm-core/src/fertilizer.d.ts index cc077f762..8394ad66e 100644 --- a/fdm-core/src/fertilizer.d.ts +++ b/fdm-core/src/fertilizer.d.ts @@ -1,17 +1,13 @@ import type { ApplicationMethods } from "@nmi-agro/fdm-data" import type * as schema from "./db/schema" -export interface Fertilizer { - p_id: string +export interface FertilizerCatalogue { p_id_catalogue: string p_source: string p_name_nl: string | null p_name_en: string | null p_description: string | null p_app_method_options: ApplicationMethods[] | null - p_app_amount: number | null - p_date_acquiring: Date | null - p_picking_date: Date | null p_dm: number | null p_density: number | null p_om: number | null @@ -57,7 +53,16 @@ export interface Fertilizer { p_ef_nh3: number | null p_type: FertilizerType | null p_type_rvo: schema.fertilizersCatalogueTypeSelect["p_type_rvo"] + hash?: string | null } + +export interface Fertilizer extends FertilizerCatalogue { + p_id: string + p_date_acquiring: Date | null + p_picking_date: Date | null + p_app_amount: number | null +} + type FertilizerType = "manure" | "mineral" | "compost" export interface FertilizerApplication { diff --git a/fdm-core/src/fertilizer.test.ts b/fdm-core/src/fertilizer.test.ts index c3b495941..c15bc2bcd 100644 --- a/fdm-core/src/fertilizer.test.ts +++ b/fdm-core/src/fertilizer.test.ts @@ -10,7 +10,10 @@ import { import { disableFertilizerCatalogue, enableFertilizerCatalogue, + getEnabledFertilizerCataloguesForFarms, } from "./catalogues" +import * as schema from "./db/schema" +import { applicationMethodOptions, fertilizersCatalogue } from "./db/schema" import { addFarm } from "./farm" import { createFdmServer } from "./fdm-server" import type { FdmServerType } from "./fdm-server.d" @@ -23,6 +26,7 @@ import { getFertilizerApplications, getFertilizerParametersDescription, getFertilizers, + getFertilizersFromCatalogues, getFertilizersFromCatalogue, removeFertilizer, removeFertilizerApplication, @@ -31,11 +35,13 @@ import { } from "./fertilizer" import { addField } from "./field" import { createId } from "./id" +import { mockFdmThatThrowsOnSelectFrom } from "./test-util" describe("Fertilizer Data Model", () => { let fdm: FdmServerType let principal_id: string let b_id_farm: string + let b_id_farm_2: string beforeEach(async () => { const host = inject("host") @@ -58,8 +64,22 @@ describe("Fertilizer Data Model", () => { farmAddress, farmPostalCode, ) + b_id_farm_2 = await addFarm( + fdm, + principal_id, + farmName, + farmBusinessId, + farmAddress, + farmPostalCode, + ) await enableFertilizerCatalogue(fdm, principal_id, b_id_farm, b_id_farm) + await enableFertilizerCatalogue( + fdm, + principal_id, + b_id_farm_2, + b_id_farm_2, + ) }) afterAll(async () => {}) @@ -322,6 +342,199 @@ describe("Fertilizer Data Model", () => { expect(fertilizers.length).toBe(2) }) + it("should get fertilizers from a list of farms", async () => { + function makeFertilizer(name: string) { + const fert: Partial< + Parameters[3] + > = Object.fromEntries( + Object.keys(fertilizersCatalogue) + .filter((key) => key.startsWith("p_")) + .map((key) => [key, Math.random()]), + ) + const randomAppMethod = () => + applicationMethodOptions[ + Math.floor( + Math.random() * applicationMethodOptions.length, + ) + ].value + Object.assign(fert, { + p_id_catalogue: createId(), + p_name_nl: name, + p_name_en: name, + p_description: `This is ${name}`, + p_type: (["manure", "mineral", "compost", null] as const)[ + Math.floor(Math.random() * 4) + ], + p_type_rvo: "10", + p_app_method_options: [ + ...new Set([ + randomAppMethod(), + randomAppMethod(), + randomAppMethod(), + randomAppMethod(), + ]), + ], + }) + return fert as Parameters[3] + } + async function addTestFertilizer( + b_id_farm: string, + p_id_catalogue: string, + ) { + const p_acquiring_amount = 1000 + const p_acquiring_date = new Date() + + // Add two fertilizers to the farm + await addFertilizer( + fdm, + principal_id, + p_id_catalogue, + b_id_farm, + p_acquiring_amount, + p_acquiring_date, + ) + } + const farm_1_fert_1 = await addFertilizerToCatalogue( + fdm, + principal_id, + b_id_farm, + makeFertilizer("Farm 1 Example Fertilizer 1"), + ) + await addTestFertilizer(b_id_farm, farm_1_fert_1) + const farm_2_fert_1 = await addFertilizerToCatalogue( + fdm, + principal_id, + b_id_farm_2, + makeFertilizer("Farm 2 Example Fertilizer 1"), + ) + await addTestFertilizer(b_id_farm_2, farm_2_fert_1) + const farm_1_fert_2 = await addFertilizerToCatalogue( + fdm, + principal_id, + b_id_farm, + makeFertilizer("Farm 1 Example Fertilizer 2"), + ) + await addTestFertilizer(b_id_farm, farm_1_fert_2) + const farm_2_fert_2 = await addFertilizerToCatalogue( + fdm, + principal_id, + b_id_farm_2, + makeFertilizer("Farm 2 Example Fertilizer 2"), + ) + await addTestFertilizer(b_id_farm_2, farm_2_fert_2) + const farmCatalogues = await getEnabledFertilizerCataloguesForFarms( + fdm, + principal_id, + [b_id_farm, b_id_farm_2], + ) + expect(farmCatalogues[b_id_farm]).toBeDefined() + expect(farmCatalogues[b_id_farm_2]).toBeDefined() + + const allSources = [ + ...new Set([ + ...farmCatalogues[b_id_farm], + ...farmCatalogues[b_id_farm_2], + ]), + ] + const allFertilizers = await getFertilizersFromCatalogues( + fdm, + principal_id, + allSources, + ) + const farm1Sources = new Set(farmCatalogues[b_id_farm]) + const farm1Fertilizers = allFertilizers.filter((f) => + farm1Sources.has(f.p_source), + ) + expect(farm1Fertilizers.map((fert) => fert.p_name_nl)).toEqual([ + "Farm 1 Example Fertilizer 1", + "Farm 1 Example Fertilizer 2", + ]) + const farm2Sources = new Set(farmCatalogues[b_id_farm_2]) + const farm2Fertilizers = allFertilizers.filter((f) => + farm2Sources.has(f.p_source), + ) + expect(farm2Fertilizers.map((fert) => fert.p_name_nl)).toEqual([ + "Farm 2 Example Fertilizer 1", + "Farm 2 Example Fertilizer 2", + ]) + }) + + it("should return empty array when enabled catalogue source has no entries", async () => { + const b_id_farm = await addFarm( + fdm, + principal_id, + "Test Farm No Cultivations In Catalogue", + undefined, + undefined, + undefined, + ) + await enableFertilizerCatalogue( + fdm, + principal_id, + b_id_farm, + "invalid-catalogue", + ) + expect( + await getFertilizersFromCatalogue(fdm, principal_id, b_id_farm), + ).toEqual([]) + }) + + it("should handle no enabled catalogues", async () => { + const b_id_farm = await addFarm( + fdm, + principal_id, + "Test Farm No Enabled Catalogues", + undefined, + undefined, + undefined, + ) + expect( + await getFertilizersFromCatalogue(fdm, principal_id, b_id_farm), + ).toEqual([]) + }) + + it("(getFertilizersFromCatalogue) should wrap errors with the correct message", async () => { + const failError = new Error("Should have thrown.") + try { + await getFertilizersFromCatalogue( + mockFdmThatThrowsOnSelectFrom( + fdm, + schema.fertilizersCatalogue, + ), + principal_id, + b_id_farm, + ) + throw failError + } catch (e) { + expect(e).not.toBe(failError) + expect(e).toBeInstanceOf(Error) + expect((e as Error).message).toBe( + "Exception for getFertilizersFromCatalogue", + ) + } + }) + + it("(getFertilizersFromCatalogues) should wrap errors with the correct message", async () => { + const failError = new Error("Should have thrown.") + try { + await getFertilizersFromCatalogues( + mockFdmThatThrowsOnSelectFrom( + fdm, + schema.fertilizersCatalogue, + ), + principal_id, + [b_id_farm], + ) + throw failError + } catch (err) { + expect(err).not.toBe(failError) + expect(err).toBeInstanceOf(Error) + expect((err as Error).message).toBe( + "Exception for getFertilizersFromCatalogues", + ) + } + }) + it("should remove a fertilizer", async () => { // Add fertilizer to catalogue const p_name_nl = "Test Fertilizer" diff --git a/fdm-core/src/fertilizer.ts b/fdm-core/src/fertilizer.ts index 426b4cc97..cbc25bee0 100644 --- a/fdm-core/src/fertilizer.ts +++ b/fdm-core/src/fertilizer.ts @@ -1,16 +1,20 @@ import { + type ApplicationMethods, type CatalogueFertilizerItem, hashFertilizer, } from "@nmi-agro/fdm-data" -import { and, asc, desc, eq, gte, inArray, lte } from "drizzle-orm" +import { and, asc, desc, eq, gte, inArray, isNull, lte } from "drizzle-orm" import { checkPermission } from "./authorization" import type { PrincipalId } from "./authorization.d" +import { getEnabledFertilizerCatalogues } from "./catalogues" import * as schema from "./db/schema" +import * as authZSchema from "./db/schema-authz" import { handleError } from "./error" import type { FdmType } from "./fdm" import type { Fertilizer, FertilizerApplication, + FertilizerCatalogue, FertilizerParameterDescription, } from "./fertilizer.d" import { createId } from "./id" @@ -29,49 +33,106 @@ export async function getFertilizersFromCatalogue( fdm: FdmType, principal_id: PrincipalId, b_id_farm: schema.farmsTypeSelect["b_id_farm"], -): Promise { +): Promise { try { - await checkPermission( + const catalogueIds = await getEnabledFertilizerCatalogues( fdm, - "farm", - "read", - b_id_farm, principal_id, - "getFertilizersFromCatalogue", + b_id_farm, ) + return await getFertilizersFromCatalogues(fdm, principal_id, catalogueIds) + } catch (err) { + throw handleError( + err, + "Exception for getFertilizersFromCatalogue", + { principal_id, b_id_farm }, + ) + } +} - // Get enabled catalogues for the farm - const enabledCatalogues = await fdm - .select({ - p_source: schema.fertilizerCatalogueEnabling.p_source, - }) - .from(schema.fertilizerCatalogueEnabling) - .where(eq(schema.fertilizerCatalogueEnabling.b_id_farm, b_id_farm)) - - // If no catalogues are enabled, return empty array - if (enabledCatalogues.length === 0) { +/** + * Retrieves all fertilizers from the catalogues whose source IDs are given. + * + * Only catalogue sources that are enabled for farms accessible by the given principal are returned. + * + * @param fdm The FDM instance providing the connection to the database. The instance can be created with {@link createFdmServer}. + * @param principal_id The ID of the principal making the request. + * @param catalogueIds Catalogue source IDs to retrieve fertilizers from, such as "baat" or "srm". + * @returns A Promise that resolves with a flat array of fertilizer catalogue entries across all given catalogues. + * @alpha + */ +export async function getFertilizersFromCatalogues( + fdm: FdmType, + principal_id: PrincipalId, + catalogueIds: schema.fertilizersCatalogueTypeSelect["p_source"][], +): Promise { + try { + if (catalogueIds.length === 0) { return [] } - // Get fertilizers from enabled catalogues - const fertilizersCatalogue = await fdm - .select() - .from(schema.fertilizersCatalogue) + // Filter to only catalogue sources that are enabled for farms the principal can access + const authorizedRows = await fdm + .selectDistinct({ + p_source: schema.fertilizerCatalogueEnabling.p_source, + }) + .from(schema.fertilizerCatalogueEnabling) + .innerJoin( + authZSchema.role, + and( + eq(authZSchema.role.resource, "farm"), + eq( + authZSchema.role.resource_id, + schema.fertilizerCatalogueEnabling.b_id_farm, + ), + inArray( + authZSchema.role.principal_id, + [principal_id].flat(), + ), + isNull(authZSchema.role.deleted), + ), + ) .where( inArray( - schema.fertilizersCatalogue.p_source, - enabledCatalogues.map( - (c: { p_source: string }) => c.p_source, - ), + schema.fertilizerCatalogueEnabling.p_source, + catalogueIds, ), ) - .orderBy(asc(schema.fertilizersCatalogue.p_name_nl)) + const authorizedSources = new Set( + authorizedRows.map((r: { p_source: string }) => r.p_source), + ) + const filteredCatalogueIds = catalogueIds.filter((id) => + authorizedSources.has(id), + ) + if (filteredCatalogueIds.length === 0) { + return [] + } - return fertilizersCatalogue + const fertilizersCatalogue: schema.fertilizersCatalogueTypeSelect[] = + await fdm + .select() + .from(schema.fertilizersCatalogue) + .where( + inArray( + schema.fertilizersCatalogue.p_source, + filteredCatalogueIds, + ), + ) + .orderBy( + asc(schema.fertilizersCatalogue.p_source), + asc(schema.fertilizersCatalogue.p_name_nl), + ) + + return fertilizersCatalogue.map((result) => ({ + ...result, + p_app_method_options: result.p_app_method_options as + | ApplicationMethods[] + | null, + p_type: deriveFertilizerType(result), + })) } catch (err) { - throw handleError(err, "Exception for getFertilizersFromCatalogue", { - principal_id, - b_id_farm, + throw handleError(err, "Exception for getFertilizersFromCatalogues", { + catalogueIds, }) } } @@ -356,22 +417,9 @@ export async function getFertilizer( throw new Error("Fertilizer not found") } - let p_type: "manure" | "mineral" | "compost" | null = null - if (result.p_type_rvo) { - p_type = convertRvoTypeToFertilizerType(result.p_type_rvo) - } else { - if (result.p_type_manure) { - p_type = "manure" - } else if (result.p_type_mineral) { - p_type = "mineral" - } else if (result.p_type_compost) { - p_type = "compost" - } - } - return { ...result, - p_type: p_type, + p_type: deriveFertilizerType(result), } } catch (err) { throw handleError(err, "Exception for getFertilizer", { @@ -536,7 +584,7 @@ export async function getFertilizers( fdm: FdmType, principal_id: PrincipalId, b_id_farm: schema.fertilizerAcquiringTypeSelect["b_id_farm"], -): Promise { +) { try { await checkPermission( fdm, @@ -549,6 +597,7 @@ export async function getFertilizers( const fertilizers = await fdm .select({ + b_id_farm: schema.fertilizerAcquiring.b_id_farm, p_id: schema.fertilizers.p_id, p_id_catalogue: schema.fertilizersCatalogue.p_id_catalogue, p_source: schema.fertilizersCatalogue.p_source, @@ -629,22 +678,9 @@ export async function getFertilizers( .orderBy(asc(schema.fertilizersCatalogue.p_name_nl)) return fertilizers.map((f: (typeof fertilizers)[number]) => { - let p_type: "manure" | "mineral" | "compost" | null = null - if (f.p_type_rvo) { - p_type = convertRvoTypeToFertilizerType(f.p_type_rvo) - } else { - if (f.p_type_manure) { - p_type = "manure" - } else if (f.p_type_mineral) { - p_type = "mineral" - } else if (f.p_type_compost) { - p_type = "compost" - } - } - return { ...f, - p_type: p_type, + p_type: deriveFertilizerType(f), } }) } catch (err) { @@ -1370,3 +1406,28 @@ function convertRvoTypeToFertilizerType( return null } + +/** + * Determines the fertilizer type based on the fields of a fertilizer catalogue database entry. + * + * @param fertilizer Selected fertilizer catalogue row from the database, possibly joined with other tables + * @returns The fertilizer type ("manure", "mineral", "compost") or null if not classified. + * @internal + */ +function deriveFertilizerType( + fertilizer: Partial, +) { + if (fertilizer.p_type_rvo) { + return convertRvoTypeToFertilizerType(fertilizer.p_type_rvo) + } + if (fertilizer.p_type_manure) { + return "manure" + } + if (fertilizer.p_type_mineral) { + return "mineral" + } + if (fertilizer.p_type_compost) { + return "compost" + } + return null +} diff --git a/fdm-core/src/index.ts b/fdm-core/src/index.ts index ed3cf9905..ccea61e80 100644 --- a/fdm-core/src/index.ts +++ b/fdm-core/src/index.ts @@ -36,7 +36,9 @@ export { enableCultivationCatalogue, enableFertilizerCatalogue, getEnabledCultivationCatalogues, + getEnabledCultivationCataloguesForFarms, getEnabledFertilizerCatalogues, + getEnabledFertilizerCataloguesForFarms, isCultivationCatalogueEnabled, isFertilizerCatalogueEnabled, syncCatalogues, @@ -47,6 +49,7 @@ export { getCultivation, getCultivationPlan, getCultivations, + getCultivationsFromCatalogues, getCultivationsFromCatalogue, getDefaultDatesOfCultivation, removeCultivation, @@ -96,6 +99,7 @@ export { getFertilizerApplications, getFertilizerParametersDescription, getFertilizers, + getFertilizersFromCatalogues, getFertilizersFromCatalogue, removeFertilizer, removeFertilizerApplication, @@ -105,6 +109,7 @@ export { export type { Fertilizer, FertilizerApplication, + FertilizerCatalogue, FertilizerParameterDescription, FertilizerParameterDescriptionItem, FertilizerParameters, diff --git a/fdm-core/src/test-util.ts b/fdm-core/src/test-util.ts new file mode 100644 index 000000000..cff693f6a --- /dev/null +++ b/fdm-core/src/test-util.ts @@ -0,0 +1,52 @@ +/* v8 ignore start -- @preserve */ +import type { FdmType } from "./fdm" + +/** + * Returns a proxy for the fdm instance which throws an exception when selecting from the given table + * + * @param fdm fdm instance to proxy + * @param tableToThrowOn schema table to throw on + * @param errorMessage what message to throw. By default, "Error querying the database" is thrown. + * @returns a proxied fdm instance that can be used just like the given fdm instance + */ +export function mockFdmThatThrowsOnSelectFrom( + fdm: FdmType, + tableToThrowOn: unknown, + errorMessage = "Error querying the database", +) { + return new Proxy(fdm, { + get(target, property, receiver) { + if (property !== "select") { + return Reflect.get(target, property, receiver) + } + + return (...args: []) => { + const query = target.select(...args) + + return new Proxy(query, { + get(queryTarget, queryProperty, queryReceiver) { + if (queryProperty !== "from") { + return Reflect.get( + queryTarget, + queryProperty, + queryReceiver, + ) + } + + return (table: unknown) => { + if (table === tableToThrowOn) { + throw new Error(errorMessage) + } + + const from = Reflect.get(queryTarget, "from") as ( + table: unknown, + ) => unknown + return from.call(queryTarget, table) + } + }, + }) + } + }, + }) as typeof fdm +} +/* v8 ignore stop -- @preserve */