From 0f41a5bdac60b8b71919f7462fc36c978fd28b26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Fri, 13 Mar 2026 14:49:23 +0100 Subject: [PATCH 01/48] Add prototype for organization balance page --- .../components/blocks/header/organization.tsx | 63 +- ...nization.$slug.$calendar.farms._index.tsx} | 2 +- ...calendar.farms.balance.nitrogen._index.tsx | 640 ++++++++++++++++++ .../app/store/organization-farm-selection.ts | 35 + 4 files changed, 738 insertions(+), 2 deletions(-) rename fdm-app/app/routes/{organization.$slug.$calendar.farms.tsx => organization.$slug.$calendar.farms._index.tsx} (99%) create mode 100644 fdm-app/app/routes/organization.$slug.$calendar.farms.balance.nitrogen._index.tsx create mode 100644 fdm-app/app/store/organization-farm-selection.ts diff --git a/fdm-app/app/components/blocks/header/organization.tsx b/fdm-app/app/components/blocks/header/organization.tsx index 267fef9ca..62cab90f9 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, @@ -40,6 +40,15 @@ 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.farms.balance.${type}._index`, + ), + ) + const params = useParams() 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/routes/organization.$slug.$calendar.farms.tsx b/fdm-app/app/routes/organization.$slug.$calendar.farms._index.tsx similarity index 99% 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..8bbe95ade 100644 --- a/fdm-app/app/routes/organization.$slug.$calendar.farms.tsx +++ b/fdm-app/app/routes/organization.$slug.$calendar.farms._index.tsx @@ -26,7 +26,7 @@ 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 = () => { diff --git a/fdm-app/app/routes/organization.$slug.$calendar.farms.balance.nitrogen._index.tsx b/fdm-app/app/routes/organization.$slug.$calendar.farms.balance.nitrogen._index.tsx new file mode 100644 index 000000000..a40ae5c6b --- /dev/null +++ b/fdm-app/app/routes/organization.$slug.$calendar.farms.balance.nitrogen._index.tsx @@ -0,0 +1,640 @@ +import type { NitrogenBalanceNumeric } from "@nmi-agro/fdm-calculator" +import { getFarms, getFields, listPrincipalsForFarm } from "@nmi-agro/fdm-core" +import { + ArrowDown, + ArrowRight, + ArrowRightFromLine, + ArrowRightLeft, + ArrowUpFromLine, + CircleAlert, + CircleCheck, + CircleX, +} from "lucide-react" +import { Suspense, use, useEffect, useMemo } 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 { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "~/components/ui/card" +import { Checkbox } from "~/components/ui/checkbox" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "~/components/ui/dialog" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "~/components/ui/tooltip" +import { getNitrogenBalanceForFarm } from "~/integrations/calculator" +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 { useOrganizationFarmSelectionStore } from "~/store/organization-farm-selection" +import { Button } from "../components/ui/button" + +// Meta +export const meta: MetaFunction = () => { + return [ + { + title: `Stikstof | Bedrijf | Nutriëntenbalans| ${clientConfig.name}`, + }, + { + name: "description", + content: "Bekijk stikstof voor je nutriëntenbalans.", + }, + ] +} + +export async function loader({ request, params }: LoaderFunctionArgs) { + try { + // Get the organization + const slug = params.slug + if (!slug) { + throw data("missing: slug", { + status: 404, + statusText: "missing: slug", + }) + } + + // 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) + + const asyncData = Promise.all( + farms.map(async (farm) => { + const farmPrincipals = await listPrincipalsForFarm( + fdm, + organization.id, + farm.b_id_farm, + ) + const owner = farmPrincipals.find( + (p) => p.role === "owner" && p.type === "user", + ) + + const fields = await getFields( + fdm, + organization.id, + farm.b_id_farm, + ) + + const totalArea = fields.reduce( + (totalArea, field) => totalArea + (field.b_area ?? 0), + 0, + ) + try { + const nitrogenBalanceResult = + await getNitrogenBalanceForFarm({ + fdm, + principal_id: organization.id, + b_id_farm: farm.b_id_farm, + timeframe, + }) + + if (nitrogenBalanceResult.hasErrors) { + reportError( + nitrogenBalanceResult.fieldErrorMessages.join( + ",\n", + ), + { + page: "organization/{slug}/{calendar}/farms/balance/nitrogen/_index", + scope: "loader", + }, + { + b_id_farm: farm.b_id_farm, + timeframe, + userId: session.principal_id, + }, + ) + } + + return { + farm: farm, + owner: owner, + fields: fields, + totalArea: totalArea, + nitrogenBalanceResult: + nitrogenBalanceResult as NitrogenBalanceNumeric & { + errorMessage: undefined + }, + } + } catch (error) { + return { + farm: farm, + owner: owner, + fields: fields, + totalArea: totalArea, + nitrogenBalanceResult: { + hasErrors: true, + errorMessage: + error instanceof Error + ? error.message + : String(error), + } as NitrogenBalanceNumeric & { errorMessage: string }, + } + } + }), + ) + + return { + organization: organization, + asyncData: asyncData, + } + } catch (error) { + throw handleLoaderError(error) + } +} + +export default function FarmBalanceNitrogenOverviewBlock() { + const loaderData = useLoaderData() + + return ( +
+ } + > + + +
+ ) +} + +type FarmResult = Awaited< + Awaited>["asyncData"] +>[number] +/** + * 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({ + organization, + asyncData, +}: Awaited>) { + const farmResults = use(asyncData) + const farm = farmResults[0].farm + const fields = farmResults[0].fields + const params = useParams() + + const { syncOrganization, farmIds, setFarmIds } = + useOrganizationFarmSelectionStore() + + useEffect(() => { + syncOrganization( + organization.id, + farmResults.map((result) => result.farm.b_id_farm), + ) + }, [organization.id, syncOrganization, farmResults]) + + const resultByFarmId = useMemo( + () => + Object.fromEntries( + farmResults.map((result) => [result.farm.b_id_farm, result]), + ), + [farmResults], + ) + + const allResults = farmIds + .map((b_id_farm) => resultByFarmId[b_id_farm]) + .filter(Boolean) + + const resolvedNitrogenBalanceResult = useMemo(() => { + const results = allResults.filter( + (result) => !result.nitrogenBalanceResult.hasErrors, + ) + + const totalArea = results.reduce( + (totalArea, result) => totalArea + result.totalArea, + 0, + ) + + const fertilizerResultKeys = [ + "total", + "mineral", + "manure", + "compost", + "other", + ] as const + type FertilizerResult = { + [k in (typeof fertilizerResultKeys)[number]]: number + } + function weightedAvg( + accessor: (result: NitrogenBalanceNumeric) => number, + ) { + return Math.round( + results.reduce( + (total, result) => + total + + accessor(result.nitrogenBalanceResult) * + result.totalArea, + 0, + ) / totalArea, + ) + } + + function weightedFertilizerAvg( + accessor: (result: NitrogenBalanceNumeric) => FertilizerResult, + ) { + return Object.fromEntries( + fertilizerResultKeys.map((key) => [ + key, + weightedAvg((result) => accessor(result)[key]), + ]), + ) as FertilizerResult + } + + return { + balance: weightedAvg((result) => result.balance), + target: weightedAvg((result) => result.target), + supply: { + total: weightedAvg((result) => result.supply.total), + deposition: weightedAvg((result) => result.supply.deposition), + fixation: weightedAvg((result) => result.supply.fixation), + mineralisation: weightedAvg( + (result) => result.supply.mineralisation, + ), + fertilizers: weightedFertilizerAvg( + (result) => result.supply.fertilizers, + ), + }, + removal: { + total: weightedAvg((result) => result.removal.total), + harvests: weightedAvg((result) => result.removal.harvests), + residues: weightedAvg((result) => result.removal.residues), + }, + emission: { + total: weightedAvg((result) => result.emission.total), + ammonia: { + total: weightedAvg( + (result) => result.emission.ammonia.total, + ), + fertilizers: weightedFertilizerAvg( + (result) => result.emission.ammonia.fertilizers, + ), + residues: weightedAvg( + (result) => result.emission.ammonia.residues, + ), + }, + nitrate: weightedAvg((result) => result.emission.nitrate), + }, + fields: allResults.flatMap( + (result) => result.nitrogenBalanceResult.fields, + ), + hasErrors: allResults.some( + (result) => result.nitrogenBalanceResult.hasErrors, + ), + fieldErrorMessages: allResults.flatMap( + (result) => result.nitrogenBalanceResult.fieldErrorMessages, + ), + errorMessage: allResults.find((result) => result.errorMessage) + ?.errorMessage as string | undefined, + } + }, [allResults]) + + if (resolvedNitrogenBalanceResult.errorMessage) { + return ( +
+ + + + Helaas is het niet mogelijk om je balans uit te + rekenen + + + +
+

+ Er is helaas wat misgegaan. Probeer opnieuw of + neem contact op met Ondersteuning en deel de + volgende foutmelding: +

+
+
+                                    {JSON.stringify(
+                                        {
+                                            message:
+                                                resolvedNitrogenBalanceResult.errorMessage,
+                                            timestamp: new Date(),
+                                        },
+                                        null,
+                                        2,
+                                    )}
+                                
+
+
+
+
+
+ ) + } + + const { hasErrors } = resolvedNitrogenBalanceResult + + const createFarmRow = (farmResult: FarmResult) => { + const balanceResult = farmResult.nitrogenBalanceResult + return ( +
+ {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}` + ) : ( + +

+ {"Bekijk foutmelding"} +

+
+ )} +
+
+ ) + } + return ( +
+

Stikstof

+
+ + + + 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 stikstofbalans voor alle percelen van{" "} + {farm.b_name_farm}. 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. + + + + + + + + + +

Bedrijven

+ + + + + + + + Wijzig selectie van bedrijven + + + De geselecteerde bedrijven zijn + uitgesloten in de berekening. + + +
+ {farmResults.map((result) => { + const b_id_farm = + result.farm.b_id_farm + return ( +
+ { + if ( + value && + !farmIds.includes( + result.farm + .b_id_farm, + ) + ) { + setFarmIds([ + ...farmIds, + result.farm + .b_id_farm, + ]) + } else if ( + farmIds.includes( + result.farm + .b_id_farm, + ) + ) { + setFarmIds( + farmIds.filter( + ( + current_b_id_farm, + ) => + current_b_id_farm !== + b_id_farm, + ), + ) + } + }} + /> + {createFarmRow(result)} +
+ ) + })} +
+ + + + + +
+
+ +
+ +
+ +
+ {allResults.map(createFarmRow)} +
+
+
+
+
+ ) +} diff --git a/fdm-app/app/store/organization-farm-selection.ts b/fdm-app/app/store/organization-farm-selection.ts new file mode 100644 index 000000000..37a62b848 --- /dev/null +++ b/fdm-app/app/store/organization-farm-selection.ts @@ -0,0 +1,35 @@ +import { create } from "zustand" +import { createJSONStorage, persist } from "zustand/middleware" +import { ssrSafeSessionJSONStorage } from "./storage" + +interface OrganizationFarmSelectionState { + organizationId: string | null + farmIds: string[] + setFarmIds: (fieldIds: string[]) => 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 + }, + ), + ) From 6d4a4186c462d8d1264ac422ab1e8c8456f23d6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Mon, 16 Mar 2026 11:45:09 +0100 Subject: [PATCH 02/48] Use batched computation of farm nitrogen balances for organizations --- fdm-app/app/integrations/calculator.ts | 116 +++++++++++++- ...calendar.farms.balance.nitrogen._index.tsx | 151 ++++++++++-------- fdm-calculator/src/balance/nitrogen/input.ts | 69 ++++++-- fdm-calculator/src/index.ts | 6 +- fdm-core/src/cultivation.ts | 57 +++++-- fdm-core/src/index.ts | 1 + 6 files changed, 304 insertions(+), 96 deletions(-) diff --git a/fdm-app/app/integrations/calculator.ts b/fdm-app/app/integrations/calculator.ts index 815c46611..f94be2ff0 100644 --- a/fdm-app/app/integrations/calculator.ts +++ b/fdm-app/app/integrations/calculator.ts @@ -1,15 +1,18 @@ import { calculateDose, calculateNitrogenBalance, + calculateNitrogenBalancesFieldToFarm, calculateOrganicMatterBalance, collectInputForNitrogenBalance, collectInputForOrganicMatterBalance, + collectOnlyFieldInputForNitrogenBalance, createFunctionsForFertilizerApplicationFilling, createFunctionsForNorms, type FieldInput, getNitrogenBalanceField, getNutrientAdvice, getOrganicMatterBalanceField, + type NitrogenBalanceFieldInput, type NitrogenBalanceFieldResultNumeric, type NitrogenBalanceNumeric, type NutrientAdvice, @@ -17,10 +20,11 @@ import { type OrganicMatterBalanceNumeric, } from "@nmi-agro/fdm-calculator" import { + type fdmSchema, type FdmType, type Field, - type fdmSchema, getCultivations, + getCultivationsOfPrincipalFromCatalogue, getCurrentSoilData, getFertilizerApplications, getFertilizers, @@ -89,6 +93,116 @@ export async function getNitrogenBalanceForFarm({ return calculateNitrogenBalance(fdm, input) } +export async function getNitrogenBalanceForFarms({ + fdm, + principal_id, + farmIds, + timeframe, +}: { + fdm: FdmType + principal_id: PrincipalId + farmIds: fdmSchema.farmsTypeSelect["b_id_farm"][] + timeframe: Timeframe +}) { + const [cultivationCatalogue, onlyFieldInputs] = await Promise.all([ + getCultivationsOfPrincipalFromCatalogue(fdm, principal_id), + Promise.all( + farmIds.map(async (b_id_farm) => ({ + b_id_farm: b_id_farm, + fertilizerDetails: await getFertilizers( + fdm, + principal_id, + b_id_farm, + ), + inputs: await collectOnlyFieldInputForNitrogenBalance( + fdm, + principal_id, + b_id_farm, + timeframe, + ), + })), + ), + ]) + + const inputs: (NitrogenBalanceFieldInput & { b_id_farm: string })[] = + onlyFieldInputs.flatMap(({ fertilizerDetails, inputs }, farmIndex) => { + const b_lu_catalogues = new Set( + inputs.flatMap((input) => + input.cultivations.map( + (cultivation) => cultivation.b_lu_catalogue, + ), + ), + ) + const cultivationDetails = cultivationCatalogue.filter( + (cultivation) => + b_lu_catalogues.has(cultivation.b_lu_catalogue), + ) + return inputs.map((input) => { + return { + b_id_farm: farmIds[farmIndex], + fieldInput: input, + fertilizerDetails: fertilizerDetails, + cultivationDetails: cultivationDetails, + timeFrame: timeframe, + } + }) + }) + + const batchSize = 50 + const farmsWithBalanceResults: Record< + string, + NitrogenBalanceFieldResultNumeric[] + > = {} + for (let i = 0; i < inputs.length; i += batchSize) { + const batch = inputs.slice(i, i + batchSize) + const batchResults = await Promise.all( + batch.map(async (input) => { + const fieldInput = input.fieldInput + try { + const balance = await getNitrogenBalanceField(fdm, input) + return { + b_id_farm: input.b_id_farm, + b_id: fieldInput.field.b_id, + b_area: fieldInput.field.b_area ?? 0, + b_bufferstrip: fieldInput.field.b_bufferstrip ?? false, + balance, + } + } catch (error) { + return { + b_id_farm: input.b_id_farm, + b_id: fieldInput.field.b_id, + b_area: fieldInput.field.b_area ?? 0, + b_bufferstrip: fieldInput.field.b_bufferstrip ?? false, + errorMessage: + error instanceof Error + ? error.message + : String(error), + } + } + }), + ) + batchResults.forEach((result) => { + farmsWithBalanceResults[result.b_id_farm] ??= [] + farmsWithBalanceResults[result.b_id_farm].push(result) + }) + } + + return farmIds.map((b_id_farm) => { + const fieldResults = farmsWithBalanceResults[b_id_farm] ?? [] + const fieldErrorMessages = fieldResults + .map((result) => result.errorMessage) + .filter((msg) => msg) as string[] + return { + b_id_farm: b_id_farm, + ...calculateNitrogenBalancesFieldToFarm( + fieldResults, + fieldErrorMessages.length > 0, + fieldErrorMessages, + ), + } + }) +} + // Get organic matter balance for a field export async function getOrganicMatterBalanceForField({ fdm, diff --git a/fdm-app/app/routes/organization.$slug.$calendar.farms.balance.nitrogen._index.tsx b/fdm-app/app/routes/organization.$slug.$calendar.farms.balance.nitrogen._index.tsx index a40ae5c6b..2cdfe26a8 100644 --- a/fdm-app/app/routes/organization.$slug.$calendar.farms.balance.nitrogen._index.tsx +++ b/fdm-app/app/routes/organization.$slug.$calendar.farms.balance.nitrogen._index.tsx @@ -45,7 +45,10 @@ import { TooltipContent, TooltipTrigger, } from "~/components/ui/tooltip" -import { getNitrogenBalanceForFarm } from "~/integrations/calculator" +import { + getNitrogenBalanceForFarm, + getNitrogenBalanceForFarms, +} from "~/integrations/calculator" import { auth, getSession } from "~/lib/auth.server" import { getTimeframe } from "~/lib/calendar" import { clientConfig } from "~/lib/config" @@ -97,79 +100,85 @@ export async function loader({ request, params }: LoaderFunctionArgs) { const farms = await getFarms(fdm, organization.id) - const asyncData = Promise.all( - farms.map(async (farm) => { - const farmPrincipals = await listPrincipalsForFarm( - fdm, - organization.id, - farm.b_id_farm, - ) - const owner = farmPrincipals.find( - (p) => p.role === "owner" && p.type === "user", - ) + const farmsMap = Object.fromEntries( + farms.map((farm) => [farm.b_id_farm, farm]), + ) - const fields = await getFields( - fdm, - organization.id, - farm.b_id_farm, - ) + const asyncData = getNitrogenBalanceForFarms({ + fdm: fdm, + principal_id: organization.id, + farmIds: farms.map((farm) => farm.b_id_farm), + timeframe: timeframe, + }).then((results) => + Promise.all( + results.map(async (nitrogenBalanceResult) => { + const farm = farmsMap[nitrogenBalanceResult.b_id_farm] + const farmPrincipals = await listPrincipalsForFarm( + fdm, + organization.id, + farm.b_id_farm, + ) + const owner = farmPrincipals.find( + (p) => p.role === "owner" && p.type === "user", + ) - const totalArea = fields.reduce( - (totalArea, field) => totalArea + (field.b_area ?? 0), - 0, - ) - try { - const nitrogenBalanceResult = - await getNitrogenBalanceForFarm({ - fdm, - principal_id: organization.id, - b_id_farm: farm.b_id_farm, - timeframe, - }) + const fields = await getFields( + fdm, + organization.id, + farm.b_id_farm, + ) - if (nitrogenBalanceResult.hasErrors) { - reportError( - nitrogenBalanceResult.fieldErrorMessages.join( - ",\n", - ), - { - page: "organization/{slug}/{calendar}/farms/balance/nitrogen/_index", - scope: "loader", - }, - { - b_id_farm: farm.b_id_farm, - timeframe, - userId: session.principal_id, - }, - ) - } + const totalArea = fields.reduce( + (totalArea, field) => totalArea + (field.b_area ?? 0), + 0, + ) + try { + if (nitrogenBalanceResult.hasErrors) { + reportError( + nitrogenBalanceResult.fieldErrorMessages.join( + ",\n", + ), + { + page: "organization/{slug}/{calendar}/farms/balance/nitrogen/_index", + scope: "loader", + }, + { + b_id_farm: farm.b_id_farm, + timeframe, + userId: session.principal_id, + }, + ) + } - return { - farm: farm, - owner: owner, - fields: fields, - totalArea: totalArea, - nitrogenBalanceResult: - nitrogenBalanceResult as NitrogenBalanceNumeric & { - errorMessage: undefined + return { + farm: farm, + owner: owner, + fields: fields, + totalArea: totalArea, + nitrogenBalanceResult: + nitrogenBalanceResult as NitrogenBalanceNumeric & { + errorMessage?: undefined + }, + } + } catch (error) { + return { + farm: farm, + owner: owner, + fields: fields, + totalArea: totalArea, + nitrogenBalanceResult: { + hasErrors: true, + errorMessage: + error instanceof Error + ? error.message + : String(error), + } as NitrogenBalanceNumeric & { + errorMessage?: string }, + } } - } catch (error) { - return { - farm: farm, - owner: owner, - fields: fields, - totalArea: totalArea, - nitrogenBalanceResult: { - hasErrors: true, - errorMessage: - error instanceof Error - ? error.message - : String(error), - } as NitrogenBalanceNumeric & { errorMessage: string }, - } - } - }), + }), + ), ) return { @@ -214,7 +223,6 @@ function OrganizationFarmBalanceNitrogenOverview({ }: Awaited>) { const farmResults = use(asyncData) const farm = farmResults[0].farm - const fields = farmResults[0].fields const params = useParams() const { syncOrganization, farmIds, setFarmIds } = @@ -327,8 +335,9 @@ function OrganizationFarmBalanceNitrogenOverview({ fieldErrorMessages: allResults.flatMap( (result) => result.nitrogenBalanceResult.fieldErrorMessages, ), - errorMessage: allResults.find((result) => result.errorMessage) - ?.errorMessage as string | undefined, + errorMessage: allResults.find( + (result) => result.nitrogenBalanceResult.errorMessage, + )?.nitrogenBalanceResult.errorMessage as string | undefined, } }, [allResults]) diff --git a/fdm-calculator/src/balance/nitrogen/input.ts b/fdm-calculator/src/balance/nitrogen/input.ts index a8de320c7..2a5211ecf 100644 --- a/fdm-calculator/src/balance/nitrogen/input.ts +++ b/fdm-calculator/src/balance/nitrogen/input.ts @@ -19,32 +19,34 @@ import { calculateAllFieldsNitrogenSupplyByDeposition } from "./supply/depositio import type { 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. + * @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( +export async function collectOnlyFieldInputForNitrogenBalance( 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) => { - // 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 +75,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( @@ -127,10 +129,55 @@ export async function collectInputForNitrogenBalance( fertilizerApplications: fertilizerApplications, soilAnalyses: soilAnalyses, depositionSupply: depositionByField.get(field.b_id), + timeframe: timeframe, } }), ) + }) + } catch (error) { + throw new Error( + `Failed to collect field nitrogen balance input for farm ${b_id_farm}: ${ + error instanceof Error ? error.message : String(error) + }`, + { cause: error }, + ) + } +} +/** + * 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. + * @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 onlyFieldInput = + await collectOnlyFieldInputForNitrogenBalance( + fdm, + principal_id, + b_id_farm, + timeframe, + b_id, + ) // Collect the details of the fertilizers const fertilizerDetails = await getFertilizers( tx, @@ -146,7 +193,7 @@ export async function collectInputForNitrogenBalance( ) return { - fields, + fields: onlyFieldInput, fertilizerDetails: fertilizerDetails, cultivationDetails: cultivationDetails, timeFrame: timeframe, diff --git a/fdm-calculator/src/index.ts b/fdm-calculator/src/index.ts index 60094ac74..766f304fe 100644 --- a/fdm-calculator/src/index.ts +++ b/fdm-calculator/src/index.ts @@ -3,9 +3,13 @@ export const fdmCalculator = pkg export { calculateNitrogenBalance, calculateNitrogenBalanceField, + calculateNitrogenBalancesFieldToFarm, getNitrogenBalanceField, } from "./balance/nitrogen/index" -export { collectInputForNitrogenBalance } from "./balance/nitrogen/input" +export { + collectInputForNitrogenBalance, + collectOnlyFieldInputForNitrogenBalance, +} from "./balance/nitrogen/input" export type { FieldInput, NitrogenBalanceFieldInput, diff --git a/fdm-core/src/cultivation.ts b/fdm-core/src/cultivation.ts index 8be5642d0..ee1165ea3 100644 --- a/fdm-core/src/cultivation.ts +++ b/fdm-core/src/cultivation.ts @@ -12,7 +12,7 @@ import { type SQL, sql, } from "drizzle-orm" -import { checkPermission } from "./authorization" +import { checkPermission, listResources } from "./authorization" import type { PrincipalId } from "./authorization.d" import type { Cultivation, @@ -42,19 +42,23 @@ import type { Timeframe } from "./timeframe" * @returns A Promise that resolves with an array of cultivation catalogue entries. * @alpha */ -export async function getCultivationsFromCatalogue( +export async function getCultivationsOfFarmsFromCatalogue( fdm: FdmType, principal_id: PrincipalId, - b_id_farm: schema.farmsTypeSelect["b_id_farm"], + farmIds: schema.farmsTypeSelect["b_id_farm"][], ): Promise { try { - await checkPermission( - fdm, - "farm", - "read", - b_id_farm, - principal_id, - "getCultivationsFromCatalogue", + await Promise.all( + farmIds.map((b_id_farm) => + checkPermission( + fdm, + "farm", + "read", + b_id_farm, + principal_id, + "getCultivationsFromCatalogue", + ), + ), ) // Get enabled catalogues for the farm @@ -64,7 +68,10 @@ export async function getCultivationsFromCatalogue( }) .from(schema.cultivationCatalogueSelecting) .where( - eq(schema.cultivationCatalogueSelecting.b_id_farm, b_id_farm), + inArray( + schema.cultivationCatalogueSelecting.b_id_farm, + farmIds, + ), ) // If no catalogues are enabled, return empty array @@ -89,11 +96,37 @@ export async function getCultivationsFromCatalogue( } catch (err) { throw handleError(err, "Exception for getCultivationsFromCatalogue", { principal_id, - b_id_farm, + farmIds, }) } } +export async function getCultivationsOfPrincipalFromCatalogue( + fdm: FdmType, + principal_id: PrincipalId, +) { + try { + const farmIds = await listResources(fdm, "farm", "read", principal_id) + return getCultivationsOfFarmsFromCatalogue(fdm, principal_id, farmIds) + } catch (err) { + throw handleError( + err, + "Exception for getCultivationsOfPrincipalFromCatalogue", + { + principal_id, + }, + ) + } +} + +export async function getCultivationsFromCatalogue( + fdm: FdmType, + principal_id: PrincipalId, + b_id_farm: string, +) { + return getCultivationsOfFarmsFromCatalogue(fdm, principal_id, [b_id_farm]) +} + /** * Adds a new cultivation to the catalogue. * diff --git a/fdm-core/src/index.ts b/fdm-core/src/index.ts index 6ae2bb4f2..4ec9cd382 100644 --- a/fdm-core/src/index.ts +++ b/fdm-core/src/index.ts @@ -47,6 +47,7 @@ export { getCultivation, getCultivationPlan, getCultivations, + getCultivationsOfPrincipalFromCatalogue, getCultivationsFromCatalogue, getDefaultDatesOfCultivation, removeCultivation, From b62603b0fbf740aca088c4edc015944f0b6c023a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Mon, 16 Mar 2026 15:58:28 +0100 Subject: [PATCH 03/48] Move the multiple-farm calculation function into fdm-calculator --- fdm-app/app/integrations/calculator.ts | 116 +------------------ fdm-calculator/src/balance/nitrogen/index.ts | 74 ++++++++++++ fdm-calculator/src/balance/nitrogen/input.ts | 85 +++++++++++++- fdm-calculator/src/index.ts | 4 +- 4 files changed, 160 insertions(+), 119 deletions(-) diff --git a/fdm-app/app/integrations/calculator.ts b/fdm-app/app/integrations/calculator.ts index f94be2ff0..815c46611 100644 --- a/fdm-app/app/integrations/calculator.ts +++ b/fdm-app/app/integrations/calculator.ts @@ -1,18 +1,15 @@ import { calculateDose, calculateNitrogenBalance, - calculateNitrogenBalancesFieldToFarm, calculateOrganicMatterBalance, collectInputForNitrogenBalance, collectInputForOrganicMatterBalance, - collectOnlyFieldInputForNitrogenBalance, createFunctionsForFertilizerApplicationFilling, createFunctionsForNorms, type FieldInput, getNitrogenBalanceField, getNutrientAdvice, getOrganicMatterBalanceField, - type NitrogenBalanceFieldInput, type NitrogenBalanceFieldResultNumeric, type NitrogenBalanceNumeric, type NutrientAdvice, @@ -20,11 +17,10 @@ import { type OrganicMatterBalanceNumeric, } from "@nmi-agro/fdm-calculator" import { - type fdmSchema, type FdmType, type Field, + type fdmSchema, getCultivations, - getCultivationsOfPrincipalFromCatalogue, getCurrentSoilData, getFertilizerApplications, getFertilizers, @@ -93,116 +89,6 @@ export async function getNitrogenBalanceForFarm({ return calculateNitrogenBalance(fdm, input) } -export async function getNitrogenBalanceForFarms({ - fdm, - principal_id, - farmIds, - timeframe, -}: { - fdm: FdmType - principal_id: PrincipalId - farmIds: fdmSchema.farmsTypeSelect["b_id_farm"][] - timeframe: Timeframe -}) { - const [cultivationCatalogue, onlyFieldInputs] = await Promise.all([ - getCultivationsOfPrincipalFromCatalogue(fdm, principal_id), - Promise.all( - farmIds.map(async (b_id_farm) => ({ - b_id_farm: b_id_farm, - fertilizerDetails: await getFertilizers( - fdm, - principal_id, - b_id_farm, - ), - inputs: await collectOnlyFieldInputForNitrogenBalance( - fdm, - principal_id, - b_id_farm, - timeframe, - ), - })), - ), - ]) - - const inputs: (NitrogenBalanceFieldInput & { b_id_farm: string })[] = - onlyFieldInputs.flatMap(({ fertilizerDetails, inputs }, farmIndex) => { - const b_lu_catalogues = new Set( - inputs.flatMap((input) => - input.cultivations.map( - (cultivation) => cultivation.b_lu_catalogue, - ), - ), - ) - const cultivationDetails = cultivationCatalogue.filter( - (cultivation) => - b_lu_catalogues.has(cultivation.b_lu_catalogue), - ) - return inputs.map((input) => { - return { - b_id_farm: farmIds[farmIndex], - fieldInput: input, - fertilizerDetails: fertilizerDetails, - cultivationDetails: cultivationDetails, - timeFrame: timeframe, - } - }) - }) - - const batchSize = 50 - const farmsWithBalanceResults: Record< - string, - NitrogenBalanceFieldResultNumeric[] - > = {} - for (let i = 0; i < inputs.length; i += batchSize) { - const batch = inputs.slice(i, i + batchSize) - const batchResults = await Promise.all( - batch.map(async (input) => { - const fieldInput = input.fieldInput - try { - const balance = await getNitrogenBalanceField(fdm, input) - return { - b_id_farm: input.b_id_farm, - b_id: fieldInput.field.b_id, - b_area: fieldInput.field.b_area ?? 0, - b_bufferstrip: fieldInput.field.b_bufferstrip ?? false, - balance, - } - } catch (error) { - return { - b_id_farm: input.b_id_farm, - b_id: fieldInput.field.b_id, - b_area: fieldInput.field.b_area ?? 0, - b_bufferstrip: fieldInput.field.b_bufferstrip ?? false, - errorMessage: - error instanceof Error - ? error.message - : String(error), - } - } - }), - ) - batchResults.forEach((result) => { - farmsWithBalanceResults[result.b_id_farm] ??= [] - farmsWithBalanceResults[result.b_id_farm].push(result) - }) - } - - return farmIds.map((b_id_farm) => { - const fieldResults = farmsWithBalanceResults[b_id_farm] ?? [] - const fieldErrorMessages = fieldResults - .map((result) => result.errorMessage) - .filter((msg) => msg) as string[] - return { - b_id_farm: b_id_farm, - ...calculateNitrogenBalancesFieldToFarm( - fieldResults, - fieldErrorMessages.length > 0, - fieldErrorMessages, - ), - } - }) -} - // Get organic matter balance for a field export async function getOrganicMatterBalanceForField({ fdm, diff --git a/fdm-calculator/src/balance/nitrogen/index.ts b/fdm-calculator/src/balance/nitrogen/index.ts index 511dfe273..7ac9d8d41 100644 --- a/fdm-calculator/src/balance/nitrogen/index.ts +++ b/fdm-calculator/src/balance/nitrogen/index.ts @@ -5,6 +5,7 @@ import { convertDecimalToNumberRecursive } from "../shared/conversion" import { combineSoilAnalyses } from "../shared/soil" import { calculateNitrogenEmission } from "./emission" import { calculateNitrogenEmissionViaNitrate } from "./emission/nitrate" +import type { collectInputForNitrogenBalanceForPrincipal } from "./input" import { calculateNitrogenRemoval } from "./removal" import { calculateNitrogenSupply } from "./supply" import { calculateTargetForNitrogenBalance } from "./target" @@ -86,6 +87,79 @@ export async function calculateNitrogenBalance( ) } +/** + * Calculates the nitrogen balance for all farms readable by a principal. + * + * This function orchestrates the nitrogen balance calculation for all fields on a farm. + * 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 calculateNitrogenBalanceForPrincipal( + fdm: FdmType, + inputs: Awaited< + ReturnType + >, +) { + const batchSize = 50 + const farmsWithBalanceResults: Record< + string, + NitrogenBalanceFieldResultNumeric[] + > = {} + for (let i = 0; i < inputs.length; i += batchSize) { + const batch = inputs.slice(i, i + batchSize) + const batchResults = await Promise.all( + batch.map(async (input) => { + const fieldInput = input.fieldInput + try { + const balance = await getNitrogenBalanceField(fdm, input) + return { + b_id_farm: input.b_id_farm, + b_id: fieldInput.field.b_id, + b_area: fieldInput.field.b_area ?? 0, + b_bufferstrip: fieldInput.field.b_bufferstrip ?? false, + balance, + } + } catch (error) { + return { + b_id_farm: input.b_id_farm, + b_id: fieldInput.field.b_id, + b_area: fieldInput.field.b_area ?? 0, + b_bufferstrip: fieldInput.field.b_bufferstrip ?? false, + errorMessage: + error instanceof Error + ? error.message + : String(error), + } + } + }), + ) + batchResults.forEach((result) => { + farmsWithBalanceResults[result.b_id_farm] ??= [] + farmsWithBalanceResults[result.b_id_farm].push(result) + }) + } + + return inputs.map(({ b_id_farm }) => { + const fieldResults = farmsWithBalanceResults[b_id_farm] ?? [] + const fieldErrorMessages = fieldResults + .map((result) => result.errorMessage) + .filter((msg) => msg) as string[] + return { + b_id_farm: b_id_farm, + ...calculateNitrogenBalancesFieldToFarm( + fieldResults, + fieldErrorMessages.length > 0, + fieldErrorMessages, + ), + } + }) +} + /** * Calculates the nitrogen balance for a single field, considering nitrogen supply, removal, and emission. * diff --git a/fdm-calculator/src/balance/nitrogen/input.ts b/fdm-calculator/src/balance/nitrogen/input.ts index 2a5211ecf..8efdf2068 100644 --- a/fdm-calculator/src/balance/nitrogen/input.ts +++ b/fdm-calculator/src/balance/nitrogen/input.ts @@ -7,6 +7,8 @@ import type { import { getCultivations, getCultivationsFromCatalogue, + getCultivationsOfPrincipalFromCatalogue, + getFarms, getFertilizerApplications, getFertilizers, getField, @@ -16,7 +18,11 @@ import { } from "@nmi-agro/fdm-core" import { getFdmPublicDataUrl } from "../../shared/public-data-url" import { calculateAllFieldsNitrogenSupplyByDeposition } from "./supply/deposition" -import type { NitrogenBalanceInput } from "./types" +import type { + FieldInput, + NitrogenBalanceFieldInput, + NitrogenBalanceInput, +} from "./types" /** * Collects field-specific input data from a FDM instance for calculating the nitrogen balance. @@ -43,7 +49,7 @@ export async function collectOnlyFieldInputForNitrogenBalance( b_id_farm: fdmSchema.farmsTypeSelect["b_id_farm"], timeframe: Timeframe, b_id?: fdmSchema.fieldsTypeSelect["b_id"], -) { +): Promise { try { // Collect the fields for the farm return await fdm.transaction(async (tx: typeof fdm) => { @@ -208,3 +214,78 @@ export async function collectInputForNitrogenBalance( ) } } + +/** + * 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 + * 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 timeframe - The timeframe for which to collect the data. + * @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 collectInputForNitrogenBalanceForPrincipal({ + fdm, + principal_id, + timeframe, +}: { + fdm: FdmType + principal_id: PrincipalId + timeframe: Timeframe +}): Promise<(NitrogenBalanceFieldInput & { b_id_farm: string })[]> { + const farms = await getFarms(fdm, principal_id) + const [cultivationCatalogue, farmInputsFieldInputsOnly] = await Promise.all( + [ + getCultivationsOfPrincipalFromCatalogue(fdm, principal_id), + Promise.all( + farms.map(async ({ b_id_farm }) => ({ + b_id_farm: b_id_farm, + fertilizerDetails: await getFertilizers( + fdm, + principal_id, + b_id_farm, + ), + fieldInputs: await collectOnlyFieldInputForNitrogenBalance( + fdm, + principal_id, + b_id_farm, + timeframe, + ), + })), + ), + ], + ) + + return farmInputsFieldInputsOnly.flatMap( + ({ fertilizerDetails, fieldInputs }, farmIndex) => { + const b_lu_catalogues = new Set( + fieldInputs.flatMap((input) => + input.cultivations.map( + (cultivation) => cultivation.b_lu_catalogue, + ), + ), + ) + const cultivationDetails = cultivationCatalogue.filter( + (cultivation) => + b_lu_catalogues.has(cultivation.b_lu_catalogue), + ) + return fieldInputs.map((input) => { + return { + b_id_farm: farms[farmIndex].b_id_farm, + fieldInput: input, + fertilizerDetails: fertilizerDetails, + cultivationDetails: cultivationDetails, + timeFrame: timeframe, + } + }) + }, + ) +} diff --git a/fdm-calculator/src/index.ts b/fdm-calculator/src/index.ts index 766f304fe..3cb6517e2 100644 --- a/fdm-calculator/src/index.ts +++ b/fdm-calculator/src/index.ts @@ -3,12 +3,12 @@ export const fdmCalculator = pkg export { calculateNitrogenBalance, calculateNitrogenBalanceField, - calculateNitrogenBalancesFieldToFarm, + calculateNitrogenBalanceForPrincipal, getNitrogenBalanceField, } from "./balance/nitrogen/index" export { collectInputForNitrogenBalance, - collectOnlyFieldInputForNitrogenBalance, + collectInputForNitrogenBalanceForPrincipal, } from "./balance/nitrogen/input" export type { FieldInput, From 6429005e912c2fc9c3836bd0578b82ac5b33b5b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Mon, 16 Mar 2026 15:59:31 +0100 Subject: [PATCH 04/48] Adapt fdm-app to changes --- ...lug.$calendar.balance.nitrogen._index.tsx} | 38 +++++++++++-------- 1 file changed, 22 insertions(+), 16 deletions(-) rename fdm-app/app/routes/{organization.$slug.$calendar.farms.balance.nitrogen._index.tsx => organization.$slug.$calendar.balance.nitrogen._index.tsx} (97%) diff --git a/fdm-app/app/routes/organization.$slug.$calendar.farms.balance.nitrogen._index.tsx b/fdm-app/app/routes/organization.$slug.$calendar.balance.nitrogen._index.tsx similarity index 97% rename from fdm-app/app/routes/organization.$slug.$calendar.farms.balance.nitrogen._index.tsx rename to fdm-app/app/routes/organization.$slug.$calendar.balance.nitrogen._index.tsx index 2cdfe26a8..b57bc224c 100644 --- a/fdm-app/app/routes/organization.$slug.$calendar.farms.balance.nitrogen._index.tsx +++ b/fdm-app/app/routes/organization.$slug.$calendar.balance.nitrogen._index.tsx @@ -1,4 +1,8 @@ -import type { NitrogenBalanceNumeric } from "@nmi-agro/fdm-calculator" +import { + calculateNitrogenBalanceForPrincipal, + collectInputForNitrogenBalanceForPrincipal, + type NitrogenBalanceNumeric, +} from "@nmi-agro/fdm-calculator" import { getFarms, getFields, listPrincipalsForFarm } from "@nmi-agro/fdm-core" import { ArrowDown, @@ -45,10 +49,6 @@ import { TooltipContent, TooltipTrigger, } from "~/components/ui/tooltip" -import { - getNitrogenBalanceForFarm, - getNitrogenBalanceForFarms, -} from "~/integrations/calculator" import { auth, getSession } from "~/lib/auth.server" import { getTimeframe } from "~/lib/calendar" import { clientConfig } from "~/lib/config" @@ -104,18 +104,22 @@ export async function loader({ request, params }: LoaderFunctionArgs) { farms.map((farm) => [farm.b_id_farm, farm]), ) - const asyncData = getNitrogenBalanceForFarms({ - fdm: fdm, - principal_id: organization.id, - farmIds: farms.map((farm) => farm.b_id_farm), - timeframe: timeframe, - }).then((results) => - Promise.all( + async function getAsyncData(principal_id: string) { + const inputs = await collectInputForNitrogenBalanceForPrincipal({ + fdm, + principal_id, + timeframe, + }) + const results = await calculateNitrogenBalanceForPrincipal( + fdm, + inputs, + ) + return Promise.all( results.map(async (nitrogenBalanceResult) => { const farm = farmsMap[nitrogenBalanceResult.b_id_farm] const farmPrincipals = await listPrincipalsForFarm( fdm, - organization.id, + principal_id, farm.b_id_farm, ) const owner = farmPrincipals.find( @@ -124,7 +128,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { const fields = await getFields( fdm, - organization.id, + principal_id, farm.b_id_farm, ) @@ -178,8 +182,10 @@ export async function loader({ request, params }: LoaderFunctionArgs) { } } }), - ), - ) + ) + } + + const asyncData = getAsyncData(organization.id) return { organization: organization, From d5bd97ac73b3d47cd89833eb11f3c2b3d66ff0f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Tue, 17 Mar 2026 10:34:59 +0100 Subject: [PATCH 05/48] Add testing WIP --- fdm-calculator/src/balance/nitrogen/index.ts | 5 +- .../src/balance/nitrogen/input.test.ts | 207 ++++++++++++++---- fdm-calculator/src/balance/nitrogen/input.ts | 130 ++++++----- fdm-calculator/src/index.ts | 2 +- fdm-core/src/cultivation.test.ts | 10 + fdm-core/src/cultivation.ts | 45 ++-- fdm-core/src/index.ts | 2 +- 7 files changed, 282 insertions(+), 119 deletions(-) diff --git a/fdm-calculator/src/balance/nitrogen/index.ts b/fdm-calculator/src/balance/nitrogen/index.ts index 7ac9d8d41..3feb58104 100644 --- a/fdm-calculator/src/balance/nitrogen/index.ts +++ b/fdm-calculator/src/balance/nitrogen/index.ts @@ -5,7 +5,6 @@ import { convertDecimalToNumberRecursive } from "../shared/conversion" import { combineSoilAnalyses } from "../shared/soil" import { calculateNitrogenEmission } from "./emission" import { calculateNitrogenEmissionViaNitrate } from "./emission/nitrate" -import type { collectInputForNitrogenBalanceForPrincipal } from "./input" import { calculateNitrogenRemoval } from "./removal" import { calculateNitrogenSupply } from "./supply" import { calculateTargetForNitrogenBalance } from "./target" @@ -101,9 +100,7 @@ export async function calculateNitrogenBalance( */ export async function calculateNitrogenBalanceForPrincipal( fdm: FdmType, - inputs: Awaited< - ReturnType - >, + inputs: (NitrogenBalanceFieldInput & { b_id_farm: string })[], ) { const batchSize = 50 const farmsWithBalanceResults: Record< diff --git a/fdm-calculator/src/balance/nitrogen/input.test.ts b/fdm-calculator/src/balance/nitrogen/input.test.ts index 7f4debaff..5ac726baa 100644 --- a/fdm-calculator/src/balance/nitrogen/input.test.ts +++ b/fdm-calculator/src/balance/nitrogen/input.test.ts @@ -13,6 +13,7 @@ import type { import { getCultivations, getCultivationsFromCatalogue, + getCultivationsOfFarmsFromCatalogue, getFertilizerApplications, getFertilizers, getFields, @@ -21,7 +22,10 @@ import { } 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" @@ -37,6 +41,7 @@ vi.mock("@nmi-agro/fdm-core", async () => { getFertilizerApplications: vi.fn(), getFertilizers: vi.fn(), getCultivationsFromCatalogue: vi.fn(), + getCultivationsOfFarmsFromCatalogue: vi.fn(), } }) @@ -58,28 +63,14 @@ const mockedGetCultivationsFromCatalogue = vi.mocked( const mockedCalculateAllFieldsNitrogenSupplyByDeposition = vi.mocked( calculateAllFieldsNitrogenSupplyByDeposition, ) +const mockedGetCultivationsOfFarmsFromCatalogue = vi.mocked( + getCultivationsOfFarmsFromCatalogue, +) -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 +99,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 +120,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_residues: 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 +152,8 @@ describe("collectInputForNitrogenBalance", () => { harvestable_analyses: [], }, }, - ] - const mockSoilAnalysesData = [ + ] as Harvest[], + mockSoilAnalysesData: [ { a_id: "sa-1", a_date: new Date(), @@ -157,8 +169,8 @@ 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", @@ -168,8 +180,8 @@ describe("collectInputForNitrogenBalance", () => { p_app_date: new Date(), p_id: "", }, - ] - const mockFertilizerDetailsData = [ + ] as FertilizerApplication[], + mockFertilizerDetailsData: [ { p_id: "fert-cat-1", p_n_rt: 5, @@ -179,8 +191,8 @@ describe("collectInputForNitrogenBalance", () => { p_s_rt: 0, p_ef_nh3: 0.1, }, - ] as unknown as Fertilizer[] - const mockCultivationDetailsData = [ + ] as Fertilizer[], + mockCultivationDetailsData: [ { b_lu_catalogue: "cat-cult-1", b_lu_croprotation: "maize", @@ -190,13 +202,54 @@ 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) }], - ]) + ]), + } +} +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 @@ -226,7 +279,9 @@ describe("collectInputForNitrogenBalance", () => { harvests: mockHarvestsData, soilAnalyses: mockSoilAnalysesData, fertilizerApplications: mockFertilizerApplicationsData, - depositionSupply: mockDepositionSupplyMap.get(fieldData.b_id)!, + depositionSupply: mockDepositionSupplyMap.get( + fieldData.b_id, + ) as { total: Decimal }, }), ) @@ -396,3 +451,81 @@ 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, + mockFertilizerDetailsData, + 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_farm) => + b_id_farm === "test-farm-id-2" + ? mockCultivationsData2 + : mockCultivationsData, + ) + mockedGetHarvests.mockResolvedValue(mockHarvestsData) // For simplicity, same harvests for all cultivations + mockedGetSoilAnalyses.mockResolvedValue(mockSoilAnalysesData) + mockedGetFertilizerApplications.mockResolvedValue( + mockFertilizerApplicationsData, + ) + mockedGetFertilizers.mockResolvedValue(mockFertilizerDetailsData) + mockedGetCultivationsOfFarmsFromCatalogue.mockResolvedValue( + mockCultivationDetailsData, + ) + mockedCalculateAllFieldsNitrogenSupplyByDeposition.mockResolvedValue( + mockDepositionSupplyMap, + ) + + await collectInputForNitrogenBalanceForFarms( + mockFdm, + principal_id, + ["test-farm-id", "test-farm-id-2"], + timeframe, + ) + + expect(mockedGetCultivationsOfFarmsFromCatalogue).toHaveBeenCalledWith( + mockFdm, + principal_id, + ["test-farm-id", "test-farm-id-2"], + ) + expect(mockedGetCultivationsOfFarmsFromCatalogue).toHaveBeenCalledTimes( + 1, + ) + expect(mockedGetCultivations).toHaveBeenCalledTimes(0) + }) +}) diff --git a/fdm-calculator/src/balance/nitrogen/input.ts b/fdm-calculator/src/balance/nitrogen/input.ts index 8efdf2068..acc8a616d 100644 --- a/fdm-calculator/src/balance/nitrogen/input.ts +++ b/fdm-calculator/src/balance/nitrogen/input.ts @@ -7,8 +7,7 @@ import type { import { getCultivations, getCultivationsFromCatalogue, - getCultivationsOfPrincipalFromCatalogue, - getFarms, + getCultivationsOfFarmsFromCatalogue, getFertilizerApplications, getFertilizers, getField, @@ -151,7 +150,8 @@ export async function collectOnlyFieldInputForNitrogenBalance( } /** - * Collects necessary input data from a FDM instance for calculating the nitrogen balance. + * 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 @@ -167,7 +167,7 @@ export async function collectOnlyFieldInputForNitrogenBalance( * * @alpha */ -export async function collectInputForNitrogenBalance( +export async function collectInputForNitrogenBalanceForFarms( fdm: FdmType, principal_id: PrincipalId, b_id_farm: fdmSchema.farmsTypeSelect["b_id_farm"], @@ -216,8 +216,7 @@ export async function collectInputForNitrogenBalance( } /** - * Collects necessary input data from a FDM instance for calculating the nitrogen balance while minimizing - * the data lookups. + * 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 @@ -226,66 +225,83 @@ export async function collectInputForNitrogenBalance( * * @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 a `NitrogenBalanceInput` object containing all the necessary data. * @throws {Error} - Throws an error if data collection or processing fails. * * @alpha */ -export async function collectInputForNitrogenBalanceForPrincipal({ - fdm, - principal_id, - timeframe, -}: { - fdm: FdmType - principal_id: PrincipalId - timeframe: Timeframe -}): Promise<(NitrogenBalanceFieldInput & { b_id_farm: string })[]> { - const farms = await getFarms(fdm, principal_id) - const [cultivationCatalogue, farmInputsFieldInputsOnly] = await Promise.all( - [ - getCultivationsOfPrincipalFromCatalogue(fdm, principal_id), - Promise.all( - farms.map(async ({ b_id_farm }) => ({ - b_id_farm: b_id_farm, - fertilizerDetails: await getFertilizers( - fdm, - principal_id, - b_id_farm, - ), - fieldInputs: await collectOnlyFieldInputForNitrogenBalance( - fdm, - principal_id, - b_id_farm, - timeframe, - ), - })), - ), - ], - ) +export async function collectInputForNitrogenBalance( + fdm: FdmType, + principal_id: PrincipalId, + farmIds: string[], + timeframe: Timeframe, +): Promise<(NitrogenBalanceFieldInput & { b_id_farm: string })[]> { + try { + if ( + timeframe.start === null || + typeof timeframe.start === "undefined" + ) { + throw new Error("Timeframe start is not defined") + } + if (timeframe.end === null || typeof timeframe.end === "undefined") { + throw new Error("Timeframe end is not defined") + } + const myTimeframe = timeframe as { start: Date; end: Date } - return farmInputsFieldInputsOnly.flatMap( - ({ fertilizerDetails, fieldInputs }, farmIndex) => { - const b_lu_catalogues = new Set( - fieldInputs.flatMap((input) => - input.cultivations.map( - (cultivation) => cultivation.b_lu_catalogue, - ), + const [cultivationCatalogue, farmInputsFieldInputsOnly] = + await Promise.all([ + getCultivationsOfFarmsFromCatalogue(fdm, principal_id, farmIds), + Promise.all( + farmIds.map(async (b_id_farm) => ({ + b_id_farm: b_id_farm, + fertilizerDetails: await getFertilizers( + fdm, + principal_id, + b_id_farm, + ), + fieldInputs: + await collectOnlyFieldInputForNitrogenBalance( + fdm, + principal_id, + b_id_farm, + timeframe, + ), + })), ), - ) - const cultivationDetails = cultivationCatalogue.filter( - (cultivation) => - b_lu_catalogues.has(cultivation.b_lu_catalogue), - ) - return fieldInputs.map((input) => { - return { - b_id_farm: farms[farmIndex].b_id_farm, + ] as const) + + return + + return farmInputsFieldInputsOnly.flatMap( + ({ b_id_farm, fertilizerDetails, fieldInputs }) => { + const b_lu_catalogues = new Set( + fieldInputs.flatMap((input) => + input.cultivations.map( + (cultivation) => cultivation.b_lu_catalogue, + ), + ), + ) + const cultivationDetails = cultivationCatalogue.filter( + (cultivation) => + b_lu_catalogues.has(cultivation.b_lu_catalogue), + ) + return fieldInputs.map((input) => ({ + b_id_farm: b_id_farm, fieldInput: input, fertilizerDetails: fertilizerDetails, cultivationDetails: cultivationDetails, - timeFrame: timeframe, - } - }) - }, - ) + timeFrame: myTimeframe, + })) + }, + ) + } catch (error) { + throw new Error( + `Failed to collect nitrogen balance input for principal ${principal_id}: ${ + error instanceof Error ? error.message : String(error) + }`, + { cause: error }, + ) + } } diff --git a/fdm-calculator/src/index.ts b/fdm-calculator/src/index.ts index 3cb6517e2..8ec8e2d46 100644 --- a/fdm-calculator/src/index.ts +++ b/fdm-calculator/src/index.ts @@ -8,7 +8,7 @@ export { } from "./balance/nitrogen/index" export { collectInputForNitrogenBalance, - collectInputForNitrogenBalanceForPrincipal, + collectInputForNitrogenBalanceForFarms, } from "./balance/nitrogen/input" export type { FieldInput, diff --git a/fdm-core/src/cultivation.test.ts b/fdm-core/src/cultivation.test.ts index a76d43f28..4640a9833 100644 --- a/fdm-core/src/cultivation.test.ts +++ b/fdm-core/src/cultivation.test.ts @@ -11,6 +11,7 @@ import { getCultivationPlan, getCultivations, getCultivationsFromCatalogue, + getCultivationsOfFarmsFromCatalogue, getDefaultDatesOfCultivation, removeCultivation, updateCultivation, @@ -144,6 +145,15 @@ describe("Cultivation Data Model", () => { expect(cultivations).toBeDefined() }) + it("should get all cultivations of farms from catalogue", async () => { + const cultivations = await getCultivationsOfFarmsFromCatalogue( + fdm, + principal_id, + [b_id_farm], + ) + expect(cultivations).toBeDefined() + }) + it("should add a new cultivation to the catalogue", async () => { const b_lu_catalogue = createId() const b_lu_source = "custom" diff --git a/fdm-core/src/cultivation.ts b/fdm-core/src/cultivation.ts index ee1165ea3..f348163cb 100644 --- a/fdm-core/src/cultivation.ts +++ b/fdm-core/src/cultivation.ts @@ -12,7 +12,7 @@ import { type SQL, sql, } from "drizzle-orm" -import { checkPermission, listResources } from "./authorization" +import { checkPermission } from "./authorization" import type { PrincipalId } from "./authorization.d" import type { Cultivation, @@ -38,7 +38,7 @@ import type { Timeframe } from "./timeframe" * * @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 b_id_farm The ID of the farm. + * @param farmIds The ID of the farms. * @returns A Promise that resolves with an array of cultivation catalogue entries. * @alpha */ @@ -93,27 +93,13 @@ export async function getCultivationsOfFarmsFromCatalogue( ) return cultivationsCatalogue - } catch (err) { - throw handleError(err, "Exception for getCultivationsFromCatalogue", { - principal_id, - farmIds, - }) - } -} - -export async function getCultivationsOfPrincipalFromCatalogue( - fdm: FdmType, - principal_id: PrincipalId, -) { - try { - const farmIds = await listResources(fdm, "farm", "read", principal_id) - return getCultivationsOfFarmsFromCatalogue(fdm, principal_id, farmIds) } catch (err) { throw handleError( err, - "Exception for getCultivationsOfPrincipalFromCatalogue", + "Exception for getCultivationsOfFarmsFromCatalogue", { principal_id, + farmIds, }, ) } @@ -124,7 +110,28 @@ export async function getCultivationsFromCatalogue( principal_id: PrincipalId, b_id_farm: string, ) { - return getCultivationsOfFarmsFromCatalogue(fdm, principal_id, [b_id_farm]) + try { + return await getCultivationsOfFarmsFromCatalogue(fdm, principal_id, [ + b_id_farm, + ]) + } catch (err) { + if ( + (err as Error)?.message?.startsWith( + "Exception for getCultivationsOfFarmsFromCatalogue", + ) + ) { + const handledError = err as Error + throw handleError( + handledError.cause, + "Exception for getCultivationsFromCatalogue", + { + principal_id, + b_id_farm, + }, + ) + } + throw err + } } /** diff --git a/fdm-core/src/index.ts b/fdm-core/src/index.ts index 4ec9cd382..f2a6cbff1 100644 --- a/fdm-core/src/index.ts +++ b/fdm-core/src/index.ts @@ -47,7 +47,7 @@ export { getCultivation, getCultivationPlan, getCultivations, - getCultivationsOfPrincipalFromCatalogue, + getCultivationsOfFarmsFromCatalogue, getCultivationsFromCatalogue, getDefaultDatesOfCultivation, removeCultivation, From 0160200b7e1ff0e8c374bfd6ff49d36e4f38e6ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Tue, 17 Mar 2026 11:48:34 +0100 Subject: [PATCH 06/48] Fix tests --- fdm-calculator/src/balance/nitrogen/index.ts | 18 +- .../src/balance/nitrogen/input.test.ts | 67 ++++++-- fdm-calculator/src/balance/nitrogen/input.ts | 158 +++++++----------- fdm-calculator/src/index.ts | 2 +- 4 files changed, 123 insertions(+), 122 deletions(-) diff --git a/fdm-calculator/src/balance/nitrogen/index.ts b/fdm-calculator/src/balance/nitrogen/index.ts index 3feb58104..076866b14 100644 --- a/fdm-calculator/src/balance/nitrogen/index.ts +++ b/fdm-calculator/src/balance/nitrogen/index.ts @@ -98,17 +98,27 @@ export async function calculateNitrogenBalance( * @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 calculateNitrogenBalanceForPrincipal( +export async function calculateNitrogenBalanceForFarms( fdm: FdmType, - inputs: (NitrogenBalanceFieldInput & { b_id_farm: string })[], + inputs: (NitrogenBalanceInput & { b_id_farm: string })[], ) { const batchSize = 50 const farmsWithBalanceResults: Record< string, NitrogenBalanceFieldResultNumeric[] > = {} - for (let i = 0; i < inputs.length; i += batchSize) { - const batch = inputs.slice(i, i + batchSize) + 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, + })), + ) + for (let i = 0; i < fieldInputs.length; i += batchSize) { + const batch = fieldInputs.slice(i, i + batchSize) const batchResults = await Promise.all( batch.map(async (input) => { const fieldInput = input.fieldInput diff --git a/fdm-calculator/src/balance/nitrogen/input.test.ts b/fdm-calculator/src/balance/nitrogen/input.test.ts index 5ac726baa..f4ffd4b5c 100644 --- a/fdm-calculator/src/balance/nitrogen/input.test.ts +++ b/fdm-calculator/src/balance/nitrogen/input.test.ts @@ -12,7 +12,6 @@ import type { } from "@nmi-agro/fdm-core" import { getCultivations, - getCultivationsFromCatalogue, getCultivationsOfFarmsFromCatalogue, getFertilizerApplications, getFertilizers, @@ -57,9 +56,6 @@ 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 mockedCalculateAllFieldsNitrogenSupplyByDeposition = vi.mocked( calculateAllFieldsNitrogenSupplyByDeposition, ) @@ -258,7 +254,7 @@ describe("collectInputForNitrogenBalance", () => { mockFertilizerApplicationsData, ) mockedGetFertilizers.mockResolvedValue(mockFertilizerDetailsData) - mockedGetCultivationsFromCatalogue.mockResolvedValue( + mockedGetCultivationsOfFarmsFromCatalogue.mockResolvedValue( mockCultivationDetailsData, ) mockedCalculateAllFieldsNitrogenSupplyByDeposition.mockResolvedValue( @@ -285,7 +281,8 @@ describe("collectInputForNitrogenBalance", () => { }), ) - const expectedResult: NitrogenBalanceInput = { + const expectedResult: NitrogenBalanceInput & { b_id_farm?: string } = { + b_id_farm: b_id_farm, fields: expectedFieldInputs, fertilizerDetails: mockFertilizerDetailsData, cultivationDetails: mockCultivationDetailsData, @@ -335,10 +332,10 @@ describe("collectInputForNitrogenBalance", () => { principal_id, b_id_farm, ) - expect(mockedGetCultivationsFromCatalogue).toHaveBeenCalledWith( + expect(mockedGetCultivationsOfFarmsFromCatalogue).toHaveBeenCalledWith( mockFdm, principal_id, - b_id_farm, + [b_id_farm], ) expect( mockedCalculateAllFieldsNitrogenSupplyByDeposition, @@ -411,7 +408,7 @@ describe("collectInputForNitrogenBalance", () => { it("should handle empty arrays from core functions correctly", async () => { mockedGetFields.mockResolvedValue([]) mockedGetFertilizers.mockResolvedValue([]) - mockedGetCultivationsFromCatalogue.mockResolvedValue([]) + mockedGetCultivationsOfFarmsFromCatalogue.mockResolvedValue([]) const result = await collectInputForNitrogenBalance( mockFdm, @@ -420,7 +417,8 @@ describe("collectInputForNitrogenBalance", () => { timeframe, ) - const expectedResult: NitrogenBalanceInput = { + const expectedResult: NitrogenBalanceInput & { b_id_farm?: string } = { + b_id_farm: "test-farm-id", fields: [], fertilizerDetails: [], cultivationDetails: [], @@ -439,10 +437,10 @@ describe("collectInputForNitrogenBalance", () => { principal_id, b_id_farm, ) - expect(mockedGetCultivationsFromCatalogue).toHaveBeenCalledWith( + expect(mockedGetCultivationsOfFarmsFromCatalogue).toHaveBeenCalledWith( mockFdm, principal_id, - b_id_farm, + [b_id_farm], ) // Ensure other calls that depend on fields are not made expect(mockedGetCultivations).not.toHaveBeenCalled() @@ -504,20 +502,60 @@ describe("collectInputForNitrogenBalanceForFarms", () => { mockFertilizerApplicationsData, ) mockedGetFertilizers.mockResolvedValue(mockFertilizerDetailsData) + const combinedCultivationDetails = [ + ...mockCultivationDetailsData, + ...mockCultivationDetailsData2, + ] mockedGetCultivationsOfFarmsFromCatalogue.mockResolvedValue( - mockCultivationDetailsData, + combinedCultivationDetails, ) mockedCalculateAllFieldsNitrogenSupplyByDeposition.mockResolvedValue( mockDepositionSupplyMap, ) - await collectInputForNitrogenBalanceForFarms( + const result = await collectInputForNitrogenBalanceForFarms( mockFdm, principal_id, ["test-farm-id", "test-farm-id-2"], timeframe, ) + const makeFieldInput = (fieldData: Field) => ({ + field: fieldData, + cultivations: mockCultivationsData, + harvests: mockHarvestsData, + soilAnalyses: mockSoilAnalysesData, + fertilizerApplications: mockFertilizerApplicationsData, + depositionSupply: mockDepositionSupplyMap.get(fieldData.b_id) as { + total: Decimal + }, + }) + const expectedFieldInputs: FieldInput[] = + mockFieldsData.map(makeFieldInput) + const expectedFieldInputs2: FieldInput[] = + mockFieldsData2.map(makeFieldInput) + + const expectedResult: (NitrogenBalanceInput & { + b_id_farm?: string + })[] = [ + { + b_id_farm: "test-farm-id", + fields: expectedFieldInputs, + fertilizerDetails: mockFertilizerDetailsData, + cultivationDetails: combinedCultivationDetails, + timeFrame: timeframe, + }, + { + b_id_farm: "test-farm-id-2", + fields: expectedFieldInputs2, + fertilizerDetails: mockFertilizerDetailsData, + cultivationDetails: combinedCultivationDetails, + timeFrame: timeframe, + }, + ] + + expect(result).toEqual(expectedResult) + expect(mockedGetCultivationsOfFarmsFromCatalogue).toHaveBeenCalledWith( mockFdm, principal_id, @@ -526,6 +564,5 @@ describe("collectInputForNitrogenBalanceForFarms", () => { expect(mockedGetCultivationsOfFarmsFromCatalogue).toHaveBeenCalledTimes( 1, ) - expect(mockedGetCultivations).toHaveBeenCalledTimes(0) }) }) diff --git a/fdm-calculator/src/balance/nitrogen/input.ts b/fdm-calculator/src/balance/nitrogen/input.ts index acc8a616d..e1a7605c6 100644 --- a/fdm-calculator/src/balance/nitrogen/input.ts +++ b/fdm-calculator/src/balance/nitrogen/input.ts @@ -6,7 +6,6 @@ import type { } from "@nmi-agro/fdm-core" import { getCultivations, - getCultivationsFromCatalogue, getCultivationsOfFarmsFromCatalogue, getFertilizerApplications, getFertilizers, @@ -17,11 +16,7 @@ import { } from "@nmi-agro/fdm-core" import { getFdmPublicDataUrl } from "../../shared/public-data-url" import { calculateAllFieldsNitrogenSupplyByDeposition } from "./supply/deposition" -import type { - FieldInput, - NitrogenBalanceFieldInput, - NitrogenBalanceInput, -} from "./types" +import type { FieldInput, NitrogenBalanceInput } from "./types" /** * Collects field-specific input data from a FDM instance for calculating the nitrogen balance. @@ -134,7 +129,6 @@ export async function collectOnlyFieldInputForNitrogenBalance( fertilizerApplications: fertilizerApplications, soilAnalyses: soilAnalyses, depositionSupply: depositionByField.get(field.b_id), - timeframe: timeframe, } }), ) @@ -162,7 +156,7 @@ export async function collectOnlyFieldInputForNitrogenBalance( * @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. + * @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 @@ -170,44 +164,62 @@ export async function collectOnlyFieldInputForNitrogenBalance( export async function collectInputForNitrogenBalanceForFarms( fdm: FdmType, principal_id: PrincipalId, - b_id_farm: fdmSchema.farmsTypeSelect["b_id_farm"], + farmIds: fdmSchema.farmsTypeSelect["b_id_farm"][], timeframe: Timeframe, b_id?: fdmSchema.fieldsTypeSelect["b_id"], -): Promise { +): Promise<(NitrogenBalanceInput & { b_id_farm: string })[]> { try { return await fdm.transaction(async (tx: FdmType) => { - const onlyFieldInput = - await collectOnlyFieldInputForNitrogenBalance( - fdm, + // Collect the details of the cultivations + const cultivationDetails = + await getCultivationsOfFarmsFromCatalogue( + tx, principal_id, - b_id_farm, - timeframe, - b_id, + farmIds, ) - // Collect the details of the fertilizers - const fertilizerDetails = await getFertilizers( - tx, - principal_id, - b_id_farm, - ) - // Collect the details of the cultivations - const cultivationDetails = await getCultivationsFromCatalogue( - tx, - principal_id, - b_id_farm, - ) + return await Promise.all( + farmIds.map(async (b_id_farm) => { + try { + const onlyFieldInput = + await collectOnlyFieldInputForNitrogenBalance( + fdm, + principal_id, + b_id_farm, + timeframe, + b_id, + ) - return { - fields: onlyFieldInput, - fertilizerDetails: fertilizerDetails, - cultivationDetails: cultivationDetails, - timeFrame: timeframe, - } + // Collect the details of the fertilizers + const fertilizerDetails = await getFertilizers( + tx, + principal_id, + b_id_farm, + ) + + return { + b_id_farm: b_id_farm, + fields: onlyFieldInput, + fertilizerDetails: fertilizerDetails, + cultivationDetails: 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 }, + ) + } + }), + ) }) } catch (error) { throw new Error( - `Failed to collect nitrogen balance input for farm ${b_id_farm}: ${ + `Failed to collect nitrogen balance input: ${ error instanceof Error ? error.message : String(error) }`, { cause: error }, @@ -235,73 +247,15 @@ export async function collectInputForNitrogenBalanceForFarms( export async function collectInputForNitrogenBalance( fdm: FdmType, principal_id: PrincipalId, - farmIds: string[], + b_id_farm: string, timeframe: Timeframe, -): Promise<(NitrogenBalanceFieldInput & { b_id_farm: string })[]> { - try { - if ( - timeframe.start === null || - typeof timeframe.start === "undefined" - ) { - throw new Error("Timeframe start is not defined") - } - if (timeframe.end === null || typeof timeframe.end === "undefined") { - throw new Error("Timeframe end is not defined") - } - const myTimeframe = timeframe as { start: Date; end: Date } - - const [cultivationCatalogue, farmInputsFieldInputsOnly] = - await Promise.all([ - getCultivationsOfFarmsFromCatalogue(fdm, principal_id, farmIds), - Promise.all( - farmIds.map(async (b_id_farm) => ({ - b_id_farm: b_id_farm, - fertilizerDetails: await getFertilizers( - fdm, - principal_id, - b_id_farm, - ), - fieldInputs: - await collectOnlyFieldInputForNitrogenBalance( - fdm, - principal_id, - b_id_farm, - timeframe, - ), - })), - ), - ] as const) - - return - - return farmInputsFieldInputsOnly.flatMap( - ({ b_id_farm, fertilizerDetails, fieldInputs }) => { - const b_lu_catalogues = new Set( - fieldInputs.flatMap((input) => - input.cultivations.map( - (cultivation) => cultivation.b_lu_catalogue, - ), - ), - ) - const cultivationDetails = cultivationCatalogue.filter( - (cultivation) => - b_lu_catalogues.has(cultivation.b_lu_catalogue), - ) - return fieldInputs.map((input) => ({ - b_id_farm: b_id_farm, - fieldInput: input, - fertilizerDetails: fertilizerDetails, - cultivationDetails: cultivationDetails, - timeFrame: myTimeframe, - })) - }, - ) - } catch (error) { - throw new Error( - `Failed to collect nitrogen balance input for principal ${principal_id}: ${ - error instanceof Error ? error.message : String(error) - }`, - { cause: error }, +): Promise { + return ( + await collectInputForNitrogenBalanceForFarms( + fdm, + principal_id, + [b_id_farm], + timeframe, ) - } + )[0] } diff --git a/fdm-calculator/src/index.ts b/fdm-calculator/src/index.ts index 8ec8e2d46..2e06b7805 100644 --- a/fdm-calculator/src/index.ts +++ b/fdm-calculator/src/index.ts @@ -3,7 +3,7 @@ export const fdmCalculator = pkg export { calculateNitrogenBalance, calculateNitrogenBalanceField, - calculateNitrogenBalanceForPrincipal, + calculateNitrogenBalanceForFarms, getNitrogenBalanceField, } from "./balance/nitrogen/index" export { From a5f057013208ef16022f42005e44364758aa4632 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Tue, 17 Mar 2026 11:49:04 +0100 Subject: [PATCH 07/48] Update organization nitrogen balance page --- ...ion.$slug.$calendar.balance.nitrogen._index.tsx | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) 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 index b57bc224c..6bf0a4cbd 100644 --- a/fdm-app/app/routes/organization.$slug.$calendar.balance.nitrogen._index.tsx +++ b/fdm-app/app/routes/organization.$slug.$calendar.balance.nitrogen._index.tsx @@ -1,6 +1,6 @@ import { - calculateNitrogenBalanceForPrincipal, - collectInputForNitrogenBalanceForPrincipal, + calculateNitrogenBalanceForFarms, + collectInputForNitrogenBalanceForFarms, type NitrogenBalanceNumeric, } from "@nmi-agro/fdm-calculator" import { getFarms, getFields, listPrincipalsForFarm } from "@nmi-agro/fdm-core" @@ -105,16 +105,14 @@ export async function loader({ request, params }: LoaderFunctionArgs) { ) async function getAsyncData(principal_id: string) { - const inputs = await collectInputForNitrogenBalanceForPrincipal({ + const inputs = await collectInputForNitrogenBalanceForFarms( fdm, principal_id, + farms.map((farm) => farm.b_id_farm), timeframe, - }) - const results = await calculateNitrogenBalanceForPrincipal( - fdm, - inputs, ) - return Promise.all( + const results = await calculateNitrogenBalanceForFarms(fdm, inputs) + return await Promise.all( results.map(async (nitrogenBalanceResult) => { const farm = farmsMap[nitrogenBalanceResult.b_id_farm] const farmPrincipals = await listPrincipalsForFarm( From 3f0429221b75a6a0dd66e243217afc50b5310bd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Wed, 18 Mar 2026 15:24:23 +0100 Subject: [PATCH 08/48] Add sidebar and adjust text in organization nitrogen balance chart --- .../blocks/organization/no-farms-message.tsx | 36 ++++++ .../blocks/sidebar/organization-apps.tsx | 118 ++++++++++++++++++ ...slug.$calendar.balance.nitrogen._index.tsx | 39 ++++-- ...anization.$slug.$calendar.farms._index.tsx | 38 ++---- .../app/routes/organization.$slug._index.tsx | 24 +--- fdm-app/app/routes/organization.tsx | 2 + 6 files changed, 198 insertions(+), 59 deletions(-) create mode 100644 fdm-app/app/components/blocks/organization/no-farms-message.tsx create mode 100644 fdm-app/app/components/blocks/sidebar/organization-apps.tsx 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/organization.$slug.$calendar.balance.nitrogen._index.tsx b/fdm-app/app/routes/organization.$slug.$calendar.balance.nitrogen._index.tsx index 6bf0a4cbd..daf9f4520 100644 --- a/fdm-app/app/routes/organization.$slug.$calendar.balance.nitrogen._index.tsx +++ b/fdm-app/app/routes/organization.$slug.$calendar.balance.nitrogen._index.tsx @@ -26,6 +26,8 @@ import { 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 { Button } from "~/components/ui/button" import { Card, CardContent, @@ -55,7 +57,6 @@ import { clientConfig } from "~/lib/config" import { handleLoaderError, reportError } from "~/lib/error" import { fdm } from "~/lib/fdm.server" import { useOrganizationFarmSelectionStore } from "~/store/organization-farm-selection" -import { Button } from "../components/ui/button" // Meta export const meta: MetaFunction = () => { @@ -100,6 +101,15 @@ export async function loader({ request, params }: LoaderFunctionArgs) { 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, + noFarms: true, + asyncData: Promise.resolve([]), + } + } + const farmsMap = Object.fromEntries( farms.map((farm) => [farm.b_id_farm, farm]), ) @@ -224,9 +234,9 @@ type FarmResult = Awaited< function OrganizationFarmBalanceNitrogenOverview({ organization, asyncData, + noFarms, }: Awaited>) { const farmResults = use(asyncData) - const farm = farmResults[0].farm const params = useParams() const { syncOrganization, farmIds, setFarmIds } = @@ -345,6 +355,19 @@ function OrganizationFarmBalanceNitrogenOverview({ } }, [allResults]) + if (noFarms) { + return ( +
+ +
+ ) + } + if (resolvedNitrogenBalanceResult.errorMessage) { return (
@@ -542,11 +565,13 @@ function OrganizationFarmBalanceNitrogenOverview({ Balans - De stikstofbalans voor alle percelen van{" "} - {farm.b_name_farm}. 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. + 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. diff --git a/fdm-app/app/routes/organization.$slug.$calendar.farms._index.tsx b/fdm-app/app/routes/organization.$slug.$calendar.farms._index.tsx index 8bbe95ade..e4c074f81 100644 --- a/fdm-app/app/routes/organization.$slug.$calendar.farms._index.tsx +++ b/fdm-app/app/routes/organization.$slug.$calendar.farms._index.tsx @@ -8,19 +8,12 @@ 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" @@ -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} /> + Date: Thu, 19 Mar 2026 10:50:18 +0100 Subject: [PATCH 09/48] Move aggregation of farm results to fdm-calculator --- ...slug.$calendar.balance.nitrogen._index.tsx | 407 ++++++++---------- fdm-calculator/src/balance/nitrogen/index.ts | 100 +++++ fdm-calculator/src/balance/nitrogen/input.ts | 2 + fdm-calculator/src/index.ts | 1 + 4 files changed, 285 insertions(+), 225 deletions(-) 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 index daf9f4520..6d30757b3 100644 --- a/fdm-app/app/routes/organization.$slug.$calendar.balance.nitrogen._index.tsx +++ b/fdm-app/app/routes/organization.$slug.$calendar.balance.nitrogen._index.tsx @@ -1,6 +1,7 @@ import { calculateNitrogenBalanceForFarms, collectInputForNitrogenBalanceForFarms, + combineFarmNitrogenBalanceResults, type NitrogenBalanceNumeric, } from "@nmi-agro/fdm-calculator" import { getFarms, getFields, listPrincipalsForFarm } from "@nmi-agro/fdm-core" @@ -14,7 +15,7 @@ import { CircleCheck, CircleX, } from "lucide-react" -import { Suspense, use, useEffect, useMemo } from "react" +import { Suspense, use, useRef } from "react" import { data, type LoaderFunctionArgs, @@ -22,6 +23,7 @@ import { NavLink, useLoaderData, useParams, + useSearchParams, } from "react-router" import { BufferStripInfo } from "~/components/blocks/balance/buffer-strip-info" import { NitrogenBalanceChart } from "~/components/blocks/balance/nitrogen-chart" @@ -38,7 +40,6 @@ import { import { Checkbox } from "~/components/ui/checkbox" import { Dialog, - DialogClose, DialogContent, DialogDescription, DialogFooter, @@ -56,8 +57,35 @@ import { getTimeframe } from "~/lib/calendar" import { clientConfig } from "~/lib/config" import { handleLoaderError, reportError } from "~/lib/error" import { fdm } from "~/lib/fdm.server" -import { useOrganizationFarmSelectionStore } from "~/store/organization-farm-selection" +type Farm = Awaited>[number] +type Organization = Awaited< + ReturnType +>[number] +type FarmResult = { + farm: Farm + owner: Awaited>[number] | undefined + fields: Awaited> + totalArea: number + nitrogenBalanceResult: NitrogenBalanceNumeric & { + errorMessage?: string + } +} +type AsyncData = { + farmResults: FarmResult[] + combinedResult: NitrogenBalanceNumeric +} +type LoaderData = + | { + organization: Organization + noFarms: true + } + | { + farms: Farm[] + organization: Organization + noFarms: false + asyncData: Promise + } // Meta export const meta: MetaFunction = () => { return [ @@ -71,7 +99,10 @@ export const meta: MetaFunction = () => { ] } -export async function loader({ request, params }: LoaderFunctionArgs) { +export async function loader({ + request, + params, +}: LoaderFunctionArgs): Promise { try { // Get the organization const slug = params.slug @@ -82,6 +113,22 @@ export async function loader({ request, params }: LoaderFunctionArgs) { }) } + 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) @@ -104,9 +151,8 @@ export async function loader({ request, params }: LoaderFunctionArgs) { // If the organization has no access to any farms, render the empty message if (farms.length === 0) { return { - organization, + organization: organization, noFarms: true, - asyncData: Promise.resolve([]), } } @@ -114,15 +160,20 @@ export async function loader({ request, params }: LoaderFunctionArgs) { farms.map((farm) => [farm.b_id_farm, farm]), ) + const farmIds = + searchParamFarmIds ?? farms.map((farm) => farm.b_id_farm) + async function getAsyncData(principal_id: string) { const inputs = await collectInputForNitrogenBalanceForFarms( fdm, principal_id, - farms.map((farm) => farm.b_id_farm), + farmIds, timeframe, ) + const results = await calculateNitrogenBalanceForFarms(fdm, inputs) - return await Promise.all( + const combinedResult = combineFarmNitrogenBalanceResults(results) + const farmResults = await Promise.all( results.map(async (nitrogenBalanceResult) => { const farm = farmsMap[nitrogenBalanceResult.b_id_farm] const farmPrincipals = await listPrincipalsForFarm( @@ -169,7 +220,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { totalArea: totalArea, nitrogenBalanceResult: nitrogenBalanceResult as NitrogenBalanceNumeric & { - errorMessage?: undefined + errorMessage?: string }, } } catch (error) { @@ -191,12 +242,19 @@ export async function loader({ request, params }: LoaderFunctionArgs) { } }), ) + + return { + farmResults: farmResults, + combinedResult: combinedResult, + } } const asyncData = getAsyncData(organization.id) return { + farms: farms, organization: organization, + noFarms: false, asyncData: asyncData, } } catch (error) { @@ -219,9 +277,6 @@ export default function FarmBalanceNitrogenOverviewBlock() { ) } -type FarmResult = Awaited< - Awaited>["asyncData"] ->[number] /** * Renders the page elements with asynchronously loaded data * @@ -231,183 +286,40 @@ type FarmResult = Awaited< * If `use(...)` was added to `FarmBalanceNitrogenOverviewBlock` instead, the Suspense * would not render until `asyncData` resolves and the fallback would never be shown. */ -function OrganizationFarmBalanceNitrogenOverview({ - organization, - asyncData, - noFarms, -}: Awaited>) { - const farmResults = use(asyncData) +function OrganizationFarmBalanceNitrogenOverview(loaderData: LoaderData) { + const [searchParams, setSearchParams] = useSearchParams() const params = useParams() + const formRef = useRef(null) - const { syncOrganization, farmIds, setFarmIds } = - useOrganizationFarmSelectionStore() - - useEffect(() => { - syncOrganization( - organization.id, - farmResults.map((result) => result.farm.b_id_farm), - ) - }, [organization.id, syncOrganization, farmResults]) - - const resultByFarmId = useMemo( - () => - Object.fromEntries( - farmResults.map((result) => [result.farm.b_id_farm, result]), - ), - [farmResults], - ) - - const allResults = farmIds - .map((b_id_farm) => resultByFarmId[b_id_farm]) - .filter(Boolean) - - const resolvedNitrogenBalanceResult = useMemo(() => { - const results = allResults.filter( - (result) => !result.nitrogenBalanceResult.hasErrors, - ) - - const totalArea = results.reduce( - (totalArea, result) => totalArea + result.totalArea, - 0, - ) - - const fertilizerResultKeys = [ - "total", - "mineral", - "manure", - "compost", - "other", - ] as const - type FertilizerResult = { - [k in (typeof fertilizerResultKeys)[number]]: number - } - function weightedAvg( - accessor: (result: NitrogenBalanceNumeric) => number, - ) { - return Math.round( - results.reduce( - (total, result) => - total + - accessor(result.nitrogenBalanceResult) * - result.totalArea, - 0, - ) / totalArea, - ) - } - - function weightedFertilizerAvg( - accessor: (result: NitrogenBalanceNumeric) => FertilizerResult, - ) { - return Object.fromEntries( - fertilizerResultKeys.map((key) => [ - key, - weightedAvg((result) => accessor(result)[key]), - ]), - ) as FertilizerResult - } - - return { - balance: weightedAvg((result) => result.balance), - target: weightedAvg((result) => result.target), - supply: { - total: weightedAvg((result) => result.supply.total), - deposition: weightedAvg((result) => result.supply.deposition), - fixation: weightedAvg((result) => result.supply.fixation), - mineralisation: weightedAvg( - (result) => result.supply.mineralisation, - ), - fertilizers: weightedFertilizerAvg( - (result) => result.supply.fertilizers, - ), - }, - removal: { - total: weightedAvg((result) => result.removal.total), - harvests: weightedAvg((result) => result.removal.harvests), - residues: weightedAvg((result) => result.removal.residues), - }, - emission: { - total: weightedAvg((result) => result.emission.total), - ammonia: { - total: weightedAvg( - (result) => result.emission.ammonia.total, - ), - fertilizers: weightedFertilizerAvg( - (result) => result.emission.ammonia.fertilizers, - ), - residues: weightedAvg( - (result) => result.emission.ammonia.residues, - ), - }, - nitrate: weightedAvg((result) => result.emission.nitrate), - }, - fields: allResults.flatMap( - (result) => result.nitrogenBalanceResult.fields, - ), - hasErrors: allResults.some( - (result) => result.nitrogenBalanceResult.hasErrors, - ), - fieldErrorMessages: allResults.flatMap( - (result) => result.nitrogenBalanceResult.fieldErrorMessages, - ), - errorMessage: allResults.find( - (result) => result.nitrogenBalanceResult.errorMessage, - )?.nitrogenBalanceResult.errorMessage as string | undefined, - } - }, [allResults]) - - if (noFarms) { + if (loaderData.noFarms) { return (
) } - if (resolvedNitrogenBalanceResult.errorMessage) { - return ( -
- - - - Helaas is het niet mogelijk om je balans uit te - rekenen - - - -
-

- Er is helaas wat misgegaan. Probeer opnieuw of - neem contact op met Ondersteuning en deel de - volgende foutmelding: -

-
-
-                                    {JSON.stringify(
-                                        {
-                                            message:
-                                                resolvedNitrogenBalanceResult.errorMessage,
-                                            timestamp: new Date(),
-                                        },
-                                        null,
-                                        2,
-                                    )}
-                                
-
-
-
-
-
- ) - } + const { farms, asyncData: asyncDataPromise } = loaderData + + // `use` is not a React hook, therefore we can call it conditionally + const asyncData = use(asyncDataPromise) - const { hasErrors } = resolvedNitrogenBalanceResult + const { combinedResult: resolvedNitrogenBalanceResult, farmResults } = + asyncData + const farmChartBalanceData = resolvedNitrogenBalanceResult as unknown as { + balance: number + removal: number + } & NitrogenBalanceNumeric + const hasErrors = farmResults.some( + ({ nitrogenBalanceResult }) => nitrogenBalanceResult.hasErrors, + ) - const createFarmRow = (farmResult: FarmResult) => { + const createFarmRow = (farmResult: (typeof farmResults)[number]) => { const balanceResult = farmResult.nitrogenBalanceResult return (
@@ -586,7 +499,7 @@ function OrganizationFarmBalanceNitrogenOverview({

Bedrijven

- + @@ -601,64 +514,108 @@ function OrganizationFarmBalanceNitrogenOverview({ uitgesloten in de berekening. -
- {farmResults.map((result) => { - const b_id_farm = - result.farm.b_id_farm +
+ {farms.map((farm) => { + const b_id_farm = farm.b_id_farm + const currentValue = + farmResults.find( + (result) => + result.farm + .b_id_farm === + b_id_farm, + ) return (
{ - if ( - value && - !farmIds.includes( - result.farm - .b_id_farm, - ) - ) { - setFarmIds([ - ...farmIds, - result.farm - .b_id_farm, - ]) - } else if ( - farmIds.includes( - result.farm - .b_id_farm, - ) - ) { - setFarmIds( - farmIds.filter( - ( - current_b_id_farm, - ) => - current_b_id_farm !== - b_id_farm, - ), - ) - } - }} + name={b_id_farm} + defaultChecked={ + !!currentValue + } /> - {createFarmRow(result)} + {farm.b_name_farm ?? + "Onbekend"}
) })} -
+ - - - +
@@ -668,7 +625,7 @@ function OrganizationFarmBalanceNitrogenOverview({
- {allResults.map(createFarmRow)} + {farmResults.map(createFarmRow)}
diff --git a/fdm-calculator/src/balance/nitrogen/index.ts b/fdm-calculator/src/balance/nitrogen/index.ts index 076866b14..15c4550ef 100644 --- a/fdm-calculator/src/balance/nitrogen/index.ts +++ b/fdm-calculator/src/balance/nitrogen/index.ts @@ -617,3 +617,103 @@ export function calculateNitrogenBalancesFieldToFarm( farmWithBalance, ) as NitrogenBalanceNumeric } + +/** + * Aggregates nitrogen balances from multiple farms and takes the average weighted by each farm area. + * + * This function takes an array of nitrogen balance results for individual farms and aggregates + * them to provide an overall nitrogen balance for all the farms. It calculates weighted + * averages of nitrogen supply, removal, and emission based on the area of each farm. + * + * The function returns a comprehensive mean nitrogen balance for all the farms, including total supply, + * removal, emission, and the overall balance. + * @param farmBalanceResults - An array of nitrogen balance results for individual farms, potentially including errors. + * @returns The aggregated nitrogen balance for all the farms. + */ +export function combineFarmNitrogenBalanceResults( + farmBalanceResults: NitrogenBalanceNumeric[], +): NitrogenBalanceNumeric { + const results = farmBalanceResults.filter((result) => !result.hasErrors) + + const resultAreas = results.map( + (result) => + new Decimal( + result.fields.reduce((total, field) => total + field.b_area, 0), + ), + ) + + const totalArea = resultAreas.reduce( + (totalArea, area) => totalArea.add(area), + new Decimal(0), + ) + + const fertilizerResultKeys = [ + "total", + "mineral", + "manure", + "compost", + "other", + ] as const + type FertilizerResult = { + [k in (typeof fertilizerResultKeys)[number]]: number + } + function weightedAvg(accessor: (result: NitrogenBalanceNumeric) => number) { + return results + .reduce( + (total, result, i) => + total.add(resultAreas[i].mul(accessor(result))), + new Decimal(0), + ) + .dividedBy(totalArea) + .toNumber() + } + + function weightedFertilizerAvg( + accessor: (result: NitrogenBalanceNumeric) => FertilizerResult, + ) { + return Object.fromEntries( + fertilizerResultKeys.map((key) => [ + key, + weightedAvg((result) => accessor(result)[key]), + ]), + ) as FertilizerResult + } + return { + balance: weightedAvg((result) => result.balance), + target: weightedAvg((result) => result.target), + supply: { + total: weightedAvg((result) => result.supply.total), + deposition: weightedAvg((result) => result.supply.deposition), + fixation: weightedAvg((result) => result.supply.fixation), + mineralisation: weightedAvg( + (result) => result.supply.mineralisation, + ), + fertilizers: weightedFertilizerAvg( + (result) => result.supply.fertilizers, + ), + }, + removal: { + total: weightedAvg((result) => result.removal.total), + harvests: weightedAvg((result) => result.removal.harvests), + residues: weightedAvg((result) => result.removal.residues), + }, + emission: { + total: weightedAvg((result) => result.emission.total), + ammonia: { + total: weightedAvg((result) => result.emission.ammonia.total), + fertilizers: weightedFertilizerAvg( + (result) => result.emission.ammonia.fertilizers, + ), + residues: weightedAvg( + (result) => result.emission.ammonia.residues, + ), + }, + nitrate: weightedAvg((result) => result.emission.nitrate), + }, + fields: farmBalanceResults.flatMap((result) => result.fields), + hasErrors: farmBalanceResults.some((result) => result.hasErrors), + fieldErrorMessages: farmBalanceResults.flatMap( + (result) => result.fieldErrorMessages, + ), + } +} diff --git a/fdm-calculator/src/balance/nitrogen/input.ts b/fdm-calculator/src/balance/nitrogen/input.ts index e1a7605c6..93e061292 100644 --- a/fdm-calculator/src/balance/nitrogen/input.ts +++ b/fdm-calculator/src/balance/nitrogen/input.ts @@ -249,6 +249,7 @@ export async function collectInputForNitrogenBalance( principal_id: PrincipalId, b_id_farm: string, timeframe: Timeframe, + b_id?: fdmSchema.fieldsTypeSelect["b_id"], ): Promise { return ( await collectInputForNitrogenBalanceForFarms( @@ -256,6 +257,7 @@ export async function collectInputForNitrogenBalance( principal_id, [b_id_farm], timeframe, + b_id, ) )[0] } diff --git a/fdm-calculator/src/index.ts b/fdm-calculator/src/index.ts index 2e06b7805..6a4cd471b 100644 --- a/fdm-calculator/src/index.ts +++ b/fdm-calculator/src/index.ts @@ -4,6 +4,7 @@ export { calculateNitrogenBalance, calculateNitrogenBalanceField, calculateNitrogenBalanceForFarms, + combineFarmNitrogenBalanceResults, getNitrogenBalanceField, } from "./balance/nitrogen/index" export { From 8a1053d02844a71bae02bf7a60106a8f8a9b9e30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Thu, 19 Mar 2026 12:39:39 +0100 Subject: [PATCH 10/48] Add tests --- ...slug.$calendar.balance.nitrogen._index.tsx | 4 +- .../src/balance/nitrogen/index.test.ts | 179 ++++++++++++++++++ fdm-calculator/src/balance/nitrogen/index.ts | 19 +- fdm-calculator/src/index.ts | 2 +- 4 files changed, 196 insertions(+), 8 deletions(-) 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 index 6d30757b3..dfaa7bbfa 100644 --- a/fdm-app/app/routes/organization.$slug.$calendar.balance.nitrogen._index.tsx +++ b/fdm-app/app/routes/organization.$slug.$calendar.balance.nitrogen._index.tsx @@ -1,7 +1,7 @@ import { calculateNitrogenBalanceForFarms, collectInputForNitrogenBalanceForFarms, - combineFarmNitrogenBalanceResults, + aggregateFarmNitrogenBalanceResults, type NitrogenBalanceNumeric, } from "@nmi-agro/fdm-calculator" import { getFarms, getFields, listPrincipalsForFarm } from "@nmi-agro/fdm-core" @@ -172,7 +172,7 @@ export async function loader({ ) const results = await calculateNitrogenBalanceForFarms(fdm, inputs) - const combinedResult = combineFarmNitrogenBalanceResults(results) + const combinedResult = aggregateFarmNitrogenBalanceResults(results) const farmResults = await Promise.all( results.map(async (nitrogenBalanceResult) => { const farm = farmsMap[nitrogenBalanceResult.b_id_farm] diff --git a/fdm-calculator/src/balance/nitrogen/index.test.ts b/fdm-calculator/src/balance/nitrogen/index.test.ts index 80a2daf4d..54a7cc6a5 100644 --- a/fdm-calculator/src/balance/nitrogen/index.test.ts +++ b/fdm-calculator/src/balance/nitrogen/index.test.ts @@ -2,12 +2,14 @@ import type { FdmType } from "@nmi-agro/fdm-core" import Decimal from "decimal.js" import { describe, expect, it } from "vitest" import { + aggregateFarmNitrogenBalanceResults, calculateNitrogenBalance, calculateNitrogenBalancesFieldToFarm, } from "." import type { NitrogenBalanceFieldResultNumeric, NitrogenBalanceInput, + NitrogenBalanceNumeric, } from "./types" // Mock FdmType @@ -330,3 +332,180 @@ describe("calculateNitrogenBalance", () => { expect(farmBalance.supply.total).toBe(100) }) }) + +describe("aggregateFarmNitrogenBalanceResults", () => { + const result1: NitrogenBalanceNumeric = { + balance: 100, + supply: { + total: 50, + deposition: 30, + fixation: 10, + mineralisation: 5, + fertilizers: { + total: 0, + mineral: 0, + manure: 5, + compost: 0, + other: 0, + }, + }, + removal: { + total: 20, + harvests: 15, + residues: 5, + }, + emission: { + total: 30, + ammonia: { + total: 20, + fertilizers: { + total: 5, + mineral: 5, + manure: 0, + compost: 0, + other: 0, + }, + residues: 10, + }, + nitrate: 10, + }, + target: 70, + fields: [ + { + b_id: "test-field-id-1", + b_area: 0.5, + b_bufferstrip: false, + }, + ], + hasErrors: false, + fieldErrorMessages: [], + } + const result2: NitrogenBalanceNumeric = { + balance: 145, + supply: { + total: 95, + deposition: 20, + fixation: 5, + mineralisation: 30, + fertilizers: { + total: 40, + mineral: 10, + manure: 0, + compost: 0, + other: 30, + }, + }, + removal: { + total: 10, + harvests: 3, + residues: 7, + }, + emission: { + total: 40, + ammonia: { + total: 20, + fertilizers: { + total: 18, + mineral: 5, + manure: 8, + compost: 5, + other: 0, + }, + residues: 2, + }, + nitrate: 20, + }, + target: 160, + fields: [ + { + b_id: "test-field-id-2-1", + b_area: 0.3, + b_bufferstrip: false, + }, + { + b_id: "test-field-id-2-2", + b_area: 0.7, + b_bufferstrip: true, + }, + ], + hasErrors: false, + fieldErrorMessages: [], + } + + it("aggregates one farm result correctly", () => { + expect(aggregateFarmNitrogenBalanceResults([result1])).toEqual(result1) + }) + + it("aggregates two farm results correctly", () => { + expect(aggregateFarmNitrogenBalanceResults([result1, result2])).toEqual( + { + balance: 130, + supply: { + total: 80, + deposition: 70 / 3, + fixation: 20 / 3, + mineralisation: 65 / 3, + fertilizers: { + total: 80 / 3, + mineral: 20 / 3, + manure: 5 / 3, + compost: 0, + other: 20, + }, + }, + removal: { + total: 40 / 3, + harvests: 7, + residues: 19 / 3, + }, + emission: { + total: 110 / 3, + ammonia: { + total: 20, + fertilizers: { + total: 41 / 3, + mineral: 5, + manure: 16 / 3, + compost: 10 / 3, + other: 0, + }, + residues: 14 / 3, + }, + nitrate: 50 / 3, + }, + target: 130, + fields: [...result1.fields, ...result2.fields], + hasErrors: false, + fieldErrorMessages: [], + } satisfies ReturnType, + ) + }) + + it("should skip results with errors", () => { + expect( + aggregateFarmNitrogenBalanceResults([ + result1, + { + fields: [] as unknown[], + hasErrors: true, + fieldErrorMessages: ["cultivation glowberries not found"], + } as NitrogenBalanceNumeric, + ]), + ).toEqual({ + ...result1, + hasErrors: true, + fieldErrorMessages: ["cultivation glowberries not found"], + }) + }) + + it("should handle no valid results", () => { + const combined = aggregateFarmNitrogenBalanceResults([ + { hasErrors: true } as NitrogenBalanceNumeric, + ]) + + expect(combined.hasErrors).toBeTruthy() + expect(combined.fieldErrorMessages).toContain( + "Geen geldige bedrijfsstikstofbalans resultäten gevonden.", + ) + }) +}) diff --git a/fdm-calculator/src/balance/nitrogen/index.ts b/fdm-calculator/src/balance/nitrogen/index.ts index 15c4550ef..b43d8655b 100644 --- a/fdm-calculator/src/balance/nitrogen/index.ts +++ b/fdm-calculator/src/balance/nitrogen/index.ts @@ -630,7 +630,7 @@ export function calculateNitrogenBalancesFieldToFarm( * @param farmBalanceResults - An array of nitrogen balance results for individual farms, potentially including errors. * @returns The aggregated nitrogen balance for all the farms. */ -export function combineFarmNitrogenBalanceResults( +export function aggregateFarmNitrogenBalanceResults( farmBalanceResults: NitrogenBalanceNumeric[], ): NitrogenBalanceNumeric { const results = farmBalanceResults.filter((result) => !result.hasErrors) @@ -658,6 +658,7 @@ export function combineFarmNitrogenBalanceResults( [k in (typeof fertilizerResultKeys)[number]]: number } function weightedAvg(accessor: (result: NitrogenBalanceNumeric) => number) { + if (results.length === 0) return 0 // Area will be 0 too so best to fall back to 0 return results .reduce( (total, result, i) => @@ -678,6 +679,7 @@ export function combineFarmNitrogenBalanceResults( ]), ) as FertilizerResult } + return { balance: weightedAvg((result) => result.balance), target: weightedAvg((result) => result.target), @@ -711,9 +713,16 @@ export function combineFarmNitrogenBalanceResults( nitrate: weightedAvg((result) => result.emission.nitrate), }, fields: farmBalanceResults.flatMap((result) => result.fields), - hasErrors: farmBalanceResults.some((result) => result.hasErrors), - fieldErrorMessages: farmBalanceResults.flatMap( - (result) => result.fieldErrorMessages, - ), + hasErrors: + results.length === 0 || + farmBalanceResults.some((result) => result.hasErrors), + fieldErrorMessages: [ + ...(results.length === 0 + ? ["Geen geldige bedrijfsstikstofbalans resultäten gevonden."] + : []), + ...farmBalanceResults.flatMap( + (result) => result.fieldErrorMessages, + ), + ], } } diff --git a/fdm-calculator/src/index.ts b/fdm-calculator/src/index.ts index 6a4cd471b..5105d99c0 100644 --- a/fdm-calculator/src/index.ts +++ b/fdm-calculator/src/index.ts @@ -1,10 +1,10 @@ import pkg from "./package" export const fdmCalculator = pkg export { + aggregateFarmNitrogenBalanceResults, calculateNitrogenBalance, calculateNitrogenBalanceField, calculateNitrogenBalanceForFarms, - combineFarmNitrogenBalanceResults, getNitrogenBalanceField, } from "./balance/nitrogen/index" export { From 81b658c993ab5d20356e520ee77c1b75c2cfb002 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Thu, 19 Mar 2026 16:19:02 +0100 Subject: [PATCH 11/48] Add tests for farm nitrogen balance input --- ...slug.$calendar.balance.nitrogen._index.tsx | 2 +- .../src/balance/nitrogen/input.test.ts | 118 ++++++++++++++---- fdm-calculator/src/balance/nitrogen/input.ts | 67 ++++++++-- fdm-core/src/fertilizer.ts | 67 ++++++++-- fdm-core/src/index.ts | 1 + 5 files changed, 205 insertions(+), 50 deletions(-) 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 index dfaa7bbfa..0e539f081 100644 --- a/fdm-app/app/routes/organization.$slug.$calendar.balance.nitrogen._index.tsx +++ b/fdm-app/app/routes/organization.$slug.$calendar.balance.nitrogen._index.tsx @@ -1,7 +1,7 @@ import { + aggregateFarmNitrogenBalanceResults, calculateNitrogenBalanceForFarms, collectInputForNitrogenBalanceForFarms, - aggregateFarmNitrogenBalanceResults, type NitrogenBalanceNumeric, } from "@nmi-agro/fdm-calculator" import { getFarms, getFields, listPrincipalsForFarm } from "@nmi-agro/fdm-core" diff --git a/fdm-calculator/src/balance/nitrogen/input.test.ts b/fdm-calculator/src/balance/nitrogen/input.test.ts index 2087853ce..68bba9bb6 100644 --- a/fdm-calculator/src/balance/nitrogen/input.test.ts +++ b/fdm-calculator/src/balance/nitrogen/input.test.ts @@ -14,7 +14,7 @@ import { getCultivations, getCultivationsOfFarmsFromCatalogue, getFertilizerApplications, - getFertilizers, + getFertilizersOfFarms, getFields, getHarvests, getSoilAnalyses, @@ -38,7 +38,7 @@ vi.mock("@nmi-agro/fdm-core", async () => { getHarvests: vi.fn(), getSoilAnalyses: vi.fn(), getFertilizerApplications: vi.fn(), - getFertilizers: vi.fn(), + getFertilizersOfFarms: vi.fn(), getCultivationsFromCatalogue: vi.fn(), getCultivationsOfFarmsFromCatalogue: vi.fn(), } @@ -55,7 +55,7 @@ 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 mockedGetFertilizersOfFarms = vi.mocked(getFertilizersOfFarms) const mockedCalculateAllFieldsNitrogenSupplyByDeposition = vi.mocked( calculateAllFieldsNitrogenSupplyByDeposition, ) @@ -131,7 +131,7 @@ function createMockData() { b_lu_hcat3_name: "Hcat3 Name", b_lu_croprotation: "maize", b_lu_eom: 1, - b_lu_eom_residues: 1, + b_lu_eom_residue: 1, b_lu_harvestcat: "HC010", b_lu_harvestable: "once", b_lu_variety: "variety", @@ -177,6 +177,17 @@ function createMockData() { p_id: "", }, ] as FertilizerApplication[], + mockFertilizerApplicationsData2: [ + { + p_app_id: "fa-2", + p_id_catalogue: "fert-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: "", + }, + ] as FertilizerApplication[], mockFertilizerDetailsData: [ { p_id: "fert-cat-1", @@ -188,6 +199,17 @@ function createMockData() { p_ef_nh3: 0.1, }, ] as Fertilizer[], + mockFertilizerDetailsData2: [ + { + p_id: "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 Fertilizer[], mockCultivationDetailsData: [ { b_lu_catalogue: "cat-cult-1", @@ -253,7 +275,11 @@ describe("collectInputForNitrogenBalance", () => { mockedGetFertilizerApplications.mockResolvedValue( mockFertilizerApplicationsData, ) - mockedGetFertilizers.mockResolvedValue(mockFertilizerDetailsData) + const allFertilizerDetails = mockFertilizerDetailsData.map((fert) => ({ + ...fert, + b_id_farm: "test-farm-id", + })) + mockedGetFertilizersOfFarms.mockResolvedValue(allFertilizerDetails) mockedGetCultivationsOfFarmsFromCatalogue.mockResolvedValue( mockCultivationDetailsData, ) @@ -284,7 +310,7 @@ describe("collectInputForNitrogenBalance", () => { const expectedResult: NitrogenBalanceInput & { b_id_farm?: string } = { b_id_farm: b_id_farm, fields: expectedFieldInputs, - fertilizerDetails: mockFertilizerDetailsData, + fertilizerDetails: allFertilizerDetails, cultivationDetails: mockCultivationDetailsData, timeFrame: timeframe, } @@ -327,10 +353,11 @@ describe("collectInputForNitrogenBalance", () => { timeframe, ) } - expect(mockedGetFertilizers).toHaveBeenCalledWith( + expect(mockedGetFertilizersOfFarms).toHaveBeenCalledWith( mockFdm, principal_id, - b_id_farm, + [b_id_farm], + true, ) expect(mockedGetCultivationsOfFarmsFromCatalogue).toHaveBeenCalledWith( mockFdm, @@ -407,7 +434,7 @@ describe("collectInputForNitrogenBalance", () => { it("should handle empty arrays from core functions correctly", async () => { mockedGetFields.mockResolvedValue([]) - mockedGetFertilizers.mockResolvedValue([]) + mockedGetFertilizersOfFarms.mockResolvedValue([]) mockedGetCultivationsOfFarmsFromCatalogue.mockResolvedValue([]) const result = await collectInputForNitrogenBalance( @@ -432,10 +459,11 @@ describe("collectInputForNitrogenBalance", () => { b_id_farm, timeframe, ) - expect(mockedGetFertilizers).toHaveBeenCalledWith( + expect(mockedGetFertilizersOfFarms).toHaveBeenCalledWith( mockFdm, principal_id, - b_id_farm, + [b_id_farm], + true, ) expect(mockedGetCultivationsOfFarmsFromCatalogue).toHaveBeenCalledWith( mockFdm, @@ -476,6 +504,7 @@ describe("collectInputForNitrogenBalanceForFarms", () => { mockHarvestsData, mockSoilAnalysesData, mockFertilizerApplicationsData, + mockFertilizerApplicationsData2, mockFertilizerDetailsData, mockCultivationDetailsData, mockCultivationDetailsData2, @@ -491,17 +520,29 @@ describe("collectInputForNitrogenBalanceForFarms", () => { mockedGetFields.mockImplementation(async (_1, _2, b_id_farm) => b_id_farm === "test-farm-id-2" ? mockFieldsData2 : mockFieldsData, ) - mockedGetCultivations.mockImplementation(async (_1, _2, b_id_farm) => - b_id_farm === "test-farm-id-2" + 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.mockResolvedValue( - mockFertilizerApplicationsData, + mockedGetFertilizerApplications.mockImplementation( + async (_1, _2, b_id) => + b_id.startsWith("2-") + ? mockFertilizerApplicationsData2 + : mockFertilizerApplicationsData, ) - mockedGetFertilizers.mockResolvedValue(mockFertilizerDetailsData) + const fertData1 = mockFertilizerDetailsData.map((fert) => ({ + ...fert, + b_id_farm: "test-farm-id", + })) + const fertData2 = mockFertilizerDetailsData.map((fert) => ({ + ...fert, + b_id_farm: "test-farm-id-2", + })) + const allFertilizerDetails = [...fertData1, ...fertData2] + mockedGetFertilizersOfFarms.mockResolvedValue(allFertilizerDetails) const combinedCultivationDetails = [ ...mockCultivationDetailsData, ...mockCultivationDetailsData2, @@ -520,20 +561,36 @@ describe("collectInputForNitrogenBalanceForFarms", () => { timeframe, ) - const makeFieldInput = (fieldData: Field) => ({ + const makeFieldInput = ( + fieldData: Field, + fertilizerApplications: FertilizerApplication[], + cultivations: Cultivation[], + ) => ({ field: fieldData, - cultivations: mockCultivationsData, + cultivations: cultivations, harvests: mockHarvestsData, soilAnalyses: mockSoilAnalysesData, - fertilizerApplications: mockFertilizerApplicationsData, + fertilizerApplications: fertilizerApplications, depositionSupply: mockDepositionSupplyMap.get(fieldData.b_id) as { total: Decimal }, }) - const expectedFieldInputs: FieldInput[] = - mockFieldsData.map(makeFieldInput) - const expectedFieldInputs2: FieldInput[] = - mockFieldsData2.map(makeFieldInput) + 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 @@ -541,15 +598,15 @@ describe("collectInputForNitrogenBalanceForFarms", () => { { b_id_farm: "test-farm-id", fields: expectedFieldInputs, - fertilizerDetails: mockFertilizerDetailsData, - cultivationDetails: combinedCultivationDetails, + fertilizerDetails: fertData1, + cultivationDetails: mockCultivationDetailsData, timeFrame: timeframe, }, { b_id_farm: "test-farm-id-2", fields: expectedFieldInputs2, - fertilizerDetails: mockFertilizerDetailsData, - cultivationDetails: combinedCultivationDetails, + fertilizerDetails: fertData2, + cultivationDetails: mockCultivationDetailsData2, timeFrame: timeframe, }, ] @@ -564,5 +621,12 @@ describe("collectInputForNitrogenBalanceForFarms", () => { expect(mockedGetCultivationsOfFarmsFromCatalogue).toHaveBeenCalledTimes( 1, ) + expect(mockedGetFertilizersOfFarms).toHaveBeenCalledWith( + mockFdm, + principal_id, + ["test-farm-id", "test-farm-id-2"], + true, + ) + expect(mockedGetFertilizersOfFarms).toHaveBeenCalledTimes(1) }) }) diff --git a/fdm-calculator/src/balance/nitrogen/input.ts b/fdm-calculator/src/balance/nitrogen/input.ts index 93e061292..ca98ee625 100644 --- a/fdm-calculator/src/balance/nitrogen/input.ts +++ b/fdm-calculator/src/balance/nitrogen/input.ts @@ -3,12 +3,13 @@ import type { fdmSchema, PrincipalId, Timeframe, + Fertilizer, } from "@nmi-agro/fdm-core" import { getCultivations, getCultivationsOfFarmsFromCatalogue, getFertilizerApplications, - getFertilizers, + getFertilizersOfFarms, getField, getFields, getHarvests, @@ -61,6 +62,7 @@ export async function collectOnlyFieldInputForNitrogenBalance( b_id_farm, timeframe, ) + console.error(new Error("I AM HERE")) } // Set the link to location of FDM public data @@ -176,7 +178,37 @@ export async function collectInputForNitrogenBalanceForFarms( tx, principal_id, farmIds, - ) + ) // sorted by b_lu_catalogue + const fertilizerDetails = await getFertilizersOfFarms( + tx, + principal_id, + farmIds, + true, + ) + + const fertilizerDetailsForFarms: Record = {} + if (fertilizerDetails && fertilizerDetails.length > 0) { + let fertilizerStart = 0 + let fertilizerEnd = 0 + while (fertilizerEnd < fertilizerDetails.length) { + const b_id_farm = fertilizerDetails[fertilizerStart] + .b_id_farm as string + for ( + ; + fertilizerEnd < fertilizerDetails.length; + fertilizerEnd++ + ) { + if ( + fertilizerDetails[fertilizerEnd].b_id_farm !== + b_id_farm + ) + break + } + fertilizerDetailsForFarms[b_id_farm] = + fertilizerDetails.slice(fertilizerStart, fertilizerEnd) + fertilizerStart = fertilizerEnd + } + } return await Promise.all( farmIds.map(async (b_id_farm) => { @@ -190,18 +222,32 @@ export async function collectInputForNitrogenBalanceForFarms( b_id, ) - // Collect the details of the fertilizers - const fertilizerDetails = await getFertilizers( - tx, - principal_id, - b_id_farm, - ) + let cultivationDetailsForThisFarm = cultivationDetails + const fertilizerDetailsForThisFarm = + fertilizerDetailsForFarms[b_id_farm] ?? [] + if (farmIds.length > 1) { + // Required cultivation and fertilizer details for this farm should be extracted to not break the cache + const cultivationIds = new Set( + onlyFieldInput.flatMap((input) => + input.cultivations.map( + (cultivation) => + cultivation.b_lu_catalogue, + ), + ), + ) + cultivationDetailsForThisFarm = + cultivationDetails.filter((cultivation) => + cultivationIds.has( + cultivation.b_lu_catalogue, + ), + ) + } return { b_id_farm: b_id_farm, fields: onlyFieldInput, - fertilizerDetails: fertilizerDetails, - cultivationDetails: cultivationDetails, + fertilizerDetails: fertilizerDetailsForThisFarm, + cultivationDetails: cultivationDetailsForThisFarm, timeFrame: timeframe, } } catch (error) { @@ -218,6 +264,7 @@ export async function collectInputForNitrogenBalanceForFarms( ) }) } catch (error) { + console.log("ERROR OCCURRED", error) throw new Error( `Failed to collect nitrogen balance input: ${ error instanceof Error ? error.message : String(error) diff --git a/fdm-core/src/fertilizer.ts b/fdm-core/src/fertilizer.ts index 426b4cc97..0652bc5f3 100644 --- a/fdm-core/src/fertilizer.ts +++ b/fdm-core/src/fertilizer.ts @@ -536,19 +536,59 @@ export async function getFertilizers( fdm: FdmType, principal_id: PrincipalId, b_id_farm: schema.fertilizerAcquiringTypeSelect["b_id_farm"], -): Promise { +) { try { - await checkPermission( - fdm, - "farm", - "read", - b_id_farm, - principal_id, - "getFertilizers", + await getFertilizersOfFarms(fdm, principal_id, [b_id_farm]) + } catch (err) { + if ((err as Error)?.message === "Exception for getFertilizersOfFarms") { + throw handleError(err, "Exception for getFertilizers", { + b_id_farm, + }) + } + + throw err + } +} + +/** + * Retrieves fertilizer details for a specified farm. + * + * This function verifies that the requesting principal has read access to the farm, + * then queries the database to return a list of fertilizers along with their catalogue + * and application details. + * + * @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 b_id_farm - The ID of the farm for which the fertilizers are retrieved. + * @returns A promise that resolves with an array of fertilizer detail objects. + * + * @alpha + */ +export async function getFertilizersOfFarms( + fdm: FdmType, + principal_id: PrincipalId, + farmIds: schema.fertilizerAcquiringTypeSelect["b_id_farm"][], + includeFarmIds = false, +): Promise<(Fertilizer & { b_id_farm?: string })[]> { + try { + await Promise.all( + farmIds.map((b_id_farm) => + checkPermission( + fdm, + "farm", + "read", + b_id_farm, + principal_id, + "getFertilizers", + ), + ), ) const fertilizers = await fdm .select({ + ...(includeFarmIds + ? { 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, @@ -625,8 +665,11 @@ export async function getFertilizers( schema.fertilizersCatalogue.p_id_catalogue, ), ) - .where(eq(schema.fertilizerAcquiring.b_id_farm, b_id_farm)) - .orderBy(asc(schema.fertilizersCatalogue.p_name_nl)) + .where(inArray(schema.fertilizerAcquiring.b_id_farm, farmIds)) + .orderBy( + asc(schema.fertilizerAcquiring.b_id_farm), + asc(schema.fertilizersCatalogue.p_name_nl), + ) return fertilizers.map((f: (typeof fertilizers)[number]) => { let p_type: "manure" | "mineral" | "compost" | null = null @@ -648,8 +691,8 @@ export async function getFertilizers( } }) } catch (err) { - throw handleError(err, "Exception for getFertilizers", { - b_id_farm, + throw handleError(err, "Exception for getFertilizersOfFarms", { + farmIds, }) } } diff --git a/fdm-core/src/index.ts b/fdm-core/src/index.ts index f2a6cbff1..f7b434928 100644 --- a/fdm-core/src/index.ts +++ b/fdm-core/src/index.ts @@ -97,6 +97,7 @@ export { getFertilizerApplications, getFertilizerParametersDescription, getFertilizers, + getFertilizersOfFarms, getFertilizersFromCatalogue, removeFertilizer, removeFertilizerApplication, From 74d060e91a5c2fa90ff4d830522a629304175021 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Thu, 19 Mar 2026 17:03:37 +0100 Subject: [PATCH 12/48] Reuse the batch logic for calculateNitrogenBalance --- fdm-app/app/lib/email.server.ts | 4 +- ...slug.$calendar.balance.nitrogen._index.tsx | 161 +++++++----- .../src/balance/nitrogen/index.test.ts | 179 ------------- fdm-calculator/src/balance/nitrogen/index.ts | 245 ++++-------------- fdm-calculator/src/balance/nitrogen/input.ts | 1 - fdm-calculator/src/index.ts | 2 +- 6 files changed, 149 insertions(+), 443 deletions(-) diff --git a/fdm-app/app/lib/email.server.ts b/fdm-app/app/lib/email.server.ts index 070796771..1d0a6c1b4 100644 --- a/fdm-app/app/lib/email.server.ts +++ b/fdm-app/app/lib/email.server.ts @@ -257,8 +257,10 @@ function getTimeZoneFromUrl(url: string): string | undefined { return undefined } +import fs from "node:fs/promises" export async function sendEmail(email: Email): Promise { - await client.sendEmail(email) + // await client.sendEmail(email) + await fs.writeFile("email.html", email.HtmlBody) } export function isInactiveRecipientError(e: any) { 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 index 0e539f081..03369c98b 100644 --- a/fdm-app/app/routes/organization.$slug.$calendar.balance.nitrogen._index.tsx +++ b/fdm-app/app/routes/organization.$slug.$calendar.balance.nitrogen._index.tsx @@ -1,7 +1,8 @@ import { - aggregateFarmNitrogenBalanceResults, calculateNitrogenBalanceForFarms, + calculateNitrogenBalancesFieldToFarm, collectInputForNitrogenBalanceForFarms, + type NitrogenBalanceFieldResultNumeric, type NitrogenBalanceNumeric, } from "@nmi-agro/fdm-calculator" import { getFarms, getFields, listPrincipalsForFarm } from "@nmi-agro/fdm-core" @@ -170,77 +171,109 @@ export async function loader({ 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 results = await calculateNitrogenBalanceForFarms(fdm, inputs) - const combinedResult = aggregateFarmNitrogenBalanceResults(results) + 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] as string + rawFarmResultsMap[b_id_farm] ??= [] + rawFarmResultsMap[b_id_farm].push(result) + } const farmResults = await Promise.all( - results.map(async (nitrogenBalanceResult) => { - const farm = farmsMap[nitrogenBalanceResult.b_id_farm] - const farmPrincipals = await listPrincipalsForFarm( - fdm, - principal_id, - farm.b_id_farm, - ) - const owner = farmPrincipals.find( - (p) => p.role === "owner" && p.type === "user", - ) - - const fields = await getFields( - fdm, - principal_id, - farm.b_id_farm, - ) - - const totalArea = fields.reduce( - (totalArea, field) => totalArea + (field.b_area ?? 0), - 0, - ) - try { - if (nitrogenBalanceResult.hasErrors) { - reportError( - nitrogenBalanceResult.fieldErrorMessages.join( - ",\n", + Object.entries(rawFarmResultsMap).map( + async ([b_id_map, fieldResults]) => { + const nitrogenBalanceResult = + calculateNitrogenBalancesFieldToFarm( + fieldResults, + fieldResults.some( + (result) => result.errorMessage, ), - { - page: "organization/{slug}/{calendar}/farms/balance/nitrogen/_index", - scope: "loader", - }, - { - b_id_farm: farm.b_id_farm, - timeframe, - userId: session.principal_id, - }, + fieldResults + .filter((result) => result.errorMessage) + .map( + (result) => result.errorMessage, + ) as string[], ) - } + const farm = farmsMap[b_id_map] + const farmPrincipals = await listPrincipalsForFarm( + fdm, + principal_id, + farm.b_id_farm, + ) + const owner = farmPrincipals.find( + (p) => p.role === "owner" && p.type === "user", + ) - return { - farm: farm, - owner: owner, - fields: fields, - totalArea: totalArea, - nitrogenBalanceResult: - nitrogenBalanceResult as NitrogenBalanceNumeric & { + const fields = await getFields( + fdm, + principal_id, + farm.b_id_farm, + ) + + const totalArea = fields.reduce( + (totalArea, field) => + totalArea + (field.b_area ?? 0), + 0, + ) + try { + if (nitrogenBalanceResult.hasErrors) { + reportError( + nitrogenBalanceResult.fieldErrorMessages.join( + ",\n", + ), + { + page: "organization/{slug}/{calendar}/farms/balance/nitrogen/_index", + scope: "loader", + }, + { + b_id_farm: farm.b_id_farm, + timeframe, + userId: session.principal_id, + }, + ) + } + + return { + farm: farm, + owner: owner, + fields: fields, + totalArea: totalArea, + nitrogenBalanceResult: + nitrogenBalanceResult as NitrogenBalanceNumeric & { + errorMessage?: string + }, + } + } catch (error) { + return { + farm: farm, + owner: owner, + fields: fields, + totalArea: totalArea, + nitrogenBalanceResult: { + hasErrors: true, + errorMessage: + error instanceof Error + ? error.message + : String(error), + } as NitrogenBalanceNumeric & { errorMessage?: string }, + } } - } catch (error) { - return { - farm: farm, - owner: owner, - fields: fields, - totalArea: totalArea, - nitrogenBalanceResult: { - hasErrors: true, - errorMessage: - error instanceof Error - ? error.message - : String(error), - } as NitrogenBalanceNumeric & { - errorMessage?: string - }, - } - } - }), + }, + ), ) return { diff --git a/fdm-calculator/src/balance/nitrogen/index.test.ts b/fdm-calculator/src/balance/nitrogen/index.test.ts index 54a7cc6a5..80a2daf4d 100644 --- a/fdm-calculator/src/balance/nitrogen/index.test.ts +++ b/fdm-calculator/src/balance/nitrogen/index.test.ts @@ -2,14 +2,12 @@ import type { FdmType } from "@nmi-agro/fdm-core" import Decimal from "decimal.js" import { describe, expect, it } from "vitest" import { - aggregateFarmNitrogenBalanceResults, calculateNitrogenBalance, calculateNitrogenBalancesFieldToFarm, } from "." import type { NitrogenBalanceFieldResultNumeric, NitrogenBalanceInput, - NitrogenBalanceNumeric, } from "./types" // Mock FdmType @@ -332,180 +330,3 @@ describe("calculateNitrogenBalance", () => { expect(farmBalance.supply.total).toBe(100) }) }) - -describe("aggregateFarmNitrogenBalanceResults", () => { - const result1: NitrogenBalanceNumeric = { - balance: 100, - supply: { - total: 50, - deposition: 30, - fixation: 10, - mineralisation: 5, - fertilizers: { - total: 0, - mineral: 0, - manure: 5, - compost: 0, - other: 0, - }, - }, - removal: { - total: 20, - harvests: 15, - residues: 5, - }, - emission: { - total: 30, - ammonia: { - total: 20, - fertilizers: { - total: 5, - mineral: 5, - manure: 0, - compost: 0, - other: 0, - }, - residues: 10, - }, - nitrate: 10, - }, - target: 70, - fields: [ - { - b_id: "test-field-id-1", - b_area: 0.5, - b_bufferstrip: false, - }, - ], - hasErrors: false, - fieldErrorMessages: [], - } - const result2: NitrogenBalanceNumeric = { - balance: 145, - supply: { - total: 95, - deposition: 20, - fixation: 5, - mineralisation: 30, - fertilizers: { - total: 40, - mineral: 10, - manure: 0, - compost: 0, - other: 30, - }, - }, - removal: { - total: 10, - harvests: 3, - residues: 7, - }, - emission: { - total: 40, - ammonia: { - total: 20, - fertilizers: { - total: 18, - mineral: 5, - manure: 8, - compost: 5, - other: 0, - }, - residues: 2, - }, - nitrate: 20, - }, - target: 160, - fields: [ - { - b_id: "test-field-id-2-1", - b_area: 0.3, - b_bufferstrip: false, - }, - { - b_id: "test-field-id-2-2", - b_area: 0.7, - b_bufferstrip: true, - }, - ], - hasErrors: false, - fieldErrorMessages: [], - } - - it("aggregates one farm result correctly", () => { - expect(aggregateFarmNitrogenBalanceResults([result1])).toEqual(result1) - }) - - it("aggregates two farm results correctly", () => { - expect(aggregateFarmNitrogenBalanceResults([result1, result2])).toEqual( - { - balance: 130, - supply: { - total: 80, - deposition: 70 / 3, - fixation: 20 / 3, - mineralisation: 65 / 3, - fertilizers: { - total: 80 / 3, - mineral: 20 / 3, - manure: 5 / 3, - compost: 0, - other: 20, - }, - }, - removal: { - total: 40 / 3, - harvests: 7, - residues: 19 / 3, - }, - emission: { - total: 110 / 3, - ammonia: { - total: 20, - fertilizers: { - total: 41 / 3, - mineral: 5, - manure: 16 / 3, - compost: 10 / 3, - other: 0, - }, - residues: 14 / 3, - }, - nitrate: 50 / 3, - }, - target: 130, - fields: [...result1.fields, ...result2.fields], - hasErrors: false, - fieldErrorMessages: [], - } satisfies ReturnType, - ) - }) - - it("should skip results with errors", () => { - expect( - aggregateFarmNitrogenBalanceResults([ - result1, - { - fields: [] as unknown[], - hasErrors: true, - fieldErrorMessages: ["cultivation glowberries not found"], - } as NitrogenBalanceNumeric, - ]), - ).toEqual({ - ...result1, - hasErrors: true, - fieldErrorMessages: ["cultivation glowberries not found"], - }) - }) - - it("should handle no valid results", () => { - const combined = aggregateFarmNitrogenBalanceResults([ - { hasErrors: true } as NitrogenBalanceNumeric, - ]) - - expect(combined.hasErrors).toBeTruthy() - expect(combined.fieldErrorMessages).toContain( - "Geen geldige bedrijfsstikstofbalans resultäten gevonden.", - ) - }) -}) diff --git a/fdm-calculator/src/balance/nitrogen/index.ts b/fdm-calculator/src/balance/nitrogen/index.ts index b43d8655b..65b89e13b 100644 --- a/fdm-calculator/src/balance/nitrogen/index.ts +++ b/fdm-calculator/src/balance/nitrogen/index.ts @@ -32,64 +32,22 @@ import type { export async function calculateNitrogenBalance( fdm: FdmType, nitrogenBalanceInput: NitrogenBalanceInput, -): Promise { - const { fields, fertilizerDetails, cultivationDetails, timeFrame } = - nitrogenBalanceInput - - const fieldsWithBalanceResults: NitrogenBalanceFieldResultNumeric[] = [] - const batchSize = 50 - - for (let i = 0; i < fields.length; i += batchSize) { - const batch = fields.slice(i, i + batchSize) - const batchResults = await Promise.all( - batch.map(async (fieldInput) => { - try { - 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, - balance, - } - } catch (error) { - return { - b_id: fieldInput.field.b_id, - b_area: fieldInput.field.b_area ?? 0, - b_bufferstrip: fieldInput.field.b_bufferstrip ?? false, - errorMessage: - error instanceof Error - ? error.message - : String(error), - } - } - }), - ) - fieldsWithBalanceResults.push(...batchResults) - } - - const hasErrors = fieldsWithBalanceResults.some( - (result) => result.errorMessage !== undefined, - ) - const fieldErrorMessages = fieldsWithBalanceResults - .filter((result) => result.errorMessage !== undefined) - .map((result) => result.errorMessage as string) - - return calculateNitrogenBalancesFieldToFarm( - fieldsWithBalanceResults, - hasErrors, - fieldErrorMessages, - ) +) { + const fieldInputs: NitrogenBalanceFieldInput[] = + nitrogenBalanceInput.fields.map((field) => ({ + fieldInput: field, + cultivationDetails: nitrogenBalanceInput.cultivationDetails, + fertilizerDetails: nitrogenBalanceInput.fertilizerDetails, + timeFrame: nitrogenBalanceInput.timeFrame, + })) + + return calculateNitrogenBalanceBatched(fdm, fieldInputs) } /** - * Calculates the nitrogen balance for all farms readable by a principal. + * Calculates the nitrogen balance for all the farms. * - * This function orchestrates the nitrogen balance calculation for all fields on a farm. + * 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`. * @@ -102,11 +60,6 @@ export async function calculateNitrogenBalanceForFarms( fdm: FdmType, inputs: (NitrogenBalanceInput & { b_id_farm: string })[], ) { - const batchSize = 50 - const farmsWithBalanceResults: Record< - string, - NitrogenBalanceFieldResultNumeric[] - > = {} const fieldInputs: (NitrogenBalanceFieldInput & { b_id_farm: string })[] = inputs.flatMap((input) => input.fields.map((field) => ({ @@ -117,26 +70,38 @@ export async function calculateNitrogenBalanceForFarms( 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 < fieldInputs.length; i += batchSize) { const batch = fieldInputs.slice(i, i + batchSize) const batchResults = await Promise.all( - batch.map(async (input) => { - const fieldInput = input.fieldInput + batch.map(async (fieldInput) => { try { - const balance = await getNitrogenBalanceField(fdm, input) + const balance = await getNitrogenBalanceField( + fdm, + fieldInput, + ) return { - b_id_farm: input.b_id_farm, - 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_farm: input.b_id_farm, - 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 @@ -145,26 +110,21 @@ export async function calculateNitrogenBalanceForFarms( } }), ) - batchResults.forEach((result) => { - farmsWithBalanceResults[result.b_id_farm] ??= [] - farmsWithBalanceResults[result.b_id_farm].push(result) - }) + fieldsWithBalanceResults.push(...batchResults) } - return inputs.map(({ b_id_farm }) => { - const fieldResults = farmsWithBalanceResults[b_id_farm] ?? [] - const fieldErrorMessages = fieldResults - .map((result) => result.errorMessage) - .filter((msg) => msg) as string[] - return { - b_id_farm: b_id_farm, - ...calculateNitrogenBalancesFieldToFarm( - fieldResults, - fieldErrorMessages.length > 0, - fieldErrorMessages, - ), - } - }) + const hasErrors = fieldsWithBalanceResults.some( + (result) => result.errorMessage !== undefined, + ) + const fieldErrorMessages = fieldsWithBalanceResults + .filter((result) => result.errorMessage !== undefined) + .map((result) => result.errorMessage as string) + + return calculateNitrogenBalancesFieldToFarm( + fieldsWithBalanceResults, + hasErrors, + fieldErrorMessages, + ) } /** @@ -617,112 +577,3 @@ export function calculateNitrogenBalancesFieldToFarm( farmWithBalance, ) as NitrogenBalanceNumeric } - -/** - * Aggregates nitrogen balances from multiple farms and takes the average weighted by each farm area. - * - * This function takes an array of nitrogen balance results for individual farms and aggregates - * them to provide an overall nitrogen balance for all the farms. It calculates weighted - * averages of nitrogen supply, removal, and emission based on the area of each farm. - * - * The function returns a comprehensive mean nitrogen balance for all the farms, including total supply, - * removal, emission, and the overall balance. - * @param farmBalanceResults - An array of nitrogen balance results for individual farms, potentially including errors. - * @returns The aggregated nitrogen balance for all the farms. - */ -export function aggregateFarmNitrogenBalanceResults( - farmBalanceResults: NitrogenBalanceNumeric[], -): NitrogenBalanceNumeric { - const results = farmBalanceResults.filter((result) => !result.hasErrors) - - const resultAreas = results.map( - (result) => - new Decimal( - result.fields.reduce((total, field) => total + field.b_area, 0), - ), - ) - - const totalArea = resultAreas.reduce( - (totalArea, area) => totalArea.add(area), - new Decimal(0), - ) - - const fertilizerResultKeys = [ - "total", - "mineral", - "manure", - "compost", - "other", - ] as const - type FertilizerResult = { - [k in (typeof fertilizerResultKeys)[number]]: number - } - function weightedAvg(accessor: (result: NitrogenBalanceNumeric) => number) { - if (results.length === 0) return 0 // Area will be 0 too so best to fall back to 0 - return results - .reduce( - (total, result, i) => - total.add(resultAreas[i].mul(accessor(result))), - new Decimal(0), - ) - .dividedBy(totalArea) - .toNumber() - } - - function weightedFertilizerAvg( - accessor: (result: NitrogenBalanceNumeric) => FertilizerResult, - ) { - return Object.fromEntries( - fertilizerResultKeys.map((key) => [ - key, - weightedAvg((result) => accessor(result)[key]), - ]), - ) as FertilizerResult - } - - return { - balance: weightedAvg((result) => result.balance), - target: weightedAvg((result) => result.target), - supply: { - total: weightedAvg((result) => result.supply.total), - deposition: weightedAvg((result) => result.supply.deposition), - fixation: weightedAvg((result) => result.supply.fixation), - mineralisation: weightedAvg( - (result) => result.supply.mineralisation, - ), - fertilizers: weightedFertilizerAvg( - (result) => result.supply.fertilizers, - ), - }, - removal: { - total: weightedAvg((result) => result.removal.total), - harvests: weightedAvg((result) => result.removal.harvests), - residues: weightedAvg((result) => result.removal.residues), - }, - emission: { - total: weightedAvg((result) => result.emission.total), - ammonia: { - total: weightedAvg((result) => result.emission.ammonia.total), - fertilizers: weightedFertilizerAvg( - (result) => result.emission.ammonia.fertilizers, - ), - residues: weightedAvg( - (result) => result.emission.ammonia.residues, - ), - }, - nitrate: weightedAvg((result) => result.emission.nitrate), - }, - fields: farmBalanceResults.flatMap((result) => result.fields), - hasErrors: - results.length === 0 || - farmBalanceResults.some((result) => result.hasErrors), - fieldErrorMessages: [ - ...(results.length === 0 - ? ["Geen geldige bedrijfsstikstofbalans resultäten gevonden."] - : []), - ...farmBalanceResults.flatMap( - (result) => result.fieldErrorMessages, - ), - ], - } -} diff --git a/fdm-calculator/src/balance/nitrogen/input.ts b/fdm-calculator/src/balance/nitrogen/input.ts index ca98ee625..6cb4cdec6 100644 --- a/fdm-calculator/src/balance/nitrogen/input.ts +++ b/fdm-calculator/src/balance/nitrogen/input.ts @@ -62,7 +62,6 @@ export async function collectOnlyFieldInputForNitrogenBalance( b_id_farm, timeframe, ) - console.error(new Error("I AM HERE")) } // Set the link to location of FDM public data diff --git a/fdm-calculator/src/index.ts b/fdm-calculator/src/index.ts index 5105d99c0..06c0d6123 100644 --- a/fdm-calculator/src/index.ts +++ b/fdm-calculator/src/index.ts @@ -1,10 +1,10 @@ import pkg from "./package" export const fdmCalculator = pkg export { - aggregateFarmNitrogenBalanceResults, calculateNitrogenBalance, calculateNitrogenBalanceField, calculateNitrogenBalanceForFarms, + calculateNitrogenBalancesFieldToFarm, getNitrogenBalanceField, } from "./balance/nitrogen/index" export { From 40afd8eadb516ceb29db6edd39785ad0000fe944 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Thu, 19 Mar 2026 17:08:28 +0100 Subject: [PATCH 13/48] Fix failing test --- fdm-core/src/fertilizer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fdm-core/src/fertilizer.ts b/fdm-core/src/fertilizer.ts index 0652bc5f3..1bb2890c8 100644 --- a/fdm-core/src/fertilizer.ts +++ b/fdm-core/src/fertilizer.ts @@ -538,7 +538,7 @@ export async function getFertilizers( b_id_farm: schema.fertilizerAcquiringTypeSelect["b_id_farm"], ) { try { - await getFertilizersOfFarms(fdm, principal_id, [b_id_farm]) + return await getFertilizersOfFarms(fdm, principal_id, [b_id_farm]) } catch (err) { if ((err as Error)?.message === "Exception for getFertilizersOfFarms") { throw handleError(err, "Exception for getFertilizers", { From 0e271a818497d86a4fe3b59f2e582011c06ba935 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Fri, 20 Mar 2026 14:10:18 +0100 Subject: [PATCH 14/48] Move fertilizer chunking logic to fdm-core and test it --- .../src/balance/nitrogen/input.test.ts | 14 ++- fdm-calculator/src/balance/nitrogen/input.ts | 28 +---- fdm-core/src/fertilizer.test.ts | 107 ++++++++++++++++++ fdm-core/src/fertilizer.ts | 37 ++++-- 4 files changed, 146 insertions(+), 40 deletions(-) diff --git a/fdm-calculator/src/balance/nitrogen/input.test.ts b/fdm-calculator/src/balance/nitrogen/input.test.ts index 68bba9bb6..11d2c15fa 100644 --- a/fdm-calculator/src/balance/nitrogen/input.test.ts +++ b/fdm-calculator/src/balance/nitrogen/input.test.ts @@ -279,7 +279,9 @@ describe("collectInputForNitrogenBalance", () => { ...fert, b_id_farm: "test-farm-id", })) - mockedGetFertilizersOfFarms.mockResolvedValue(allFertilizerDetails) + mockedGetFertilizersOfFarms.mockResolvedValue({ + [b_id_farm]: allFertilizerDetails, + }) mockedGetCultivationsOfFarmsFromCatalogue.mockResolvedValue( mockCultivationDetailsData, ) @@ -357,7 +359,6 @@ describe("collectInputForNitrogenBalance", () => { mockFdm, principal_id, [b_id_farm], - true, ) expect(mockedGetCultivationsOfFarmsFromCatalogue).toHaveBeenCalledWith( mockFdm, @@ -434,7 +435,7 @@ describe("collectInputForNitrogenBalance", () => { it("should handle empty arrays from core functions correctly", async () => { mockedGetFields.mockResolvedValue([]) - mockedGetFertilizersOfFarms.mockResolvedValue([]) + mockedGetFertilizersOfFarms.mockResolvedValue({}) mockedGetCultivationsOfFarmsFromCatalogue.mockResolvedValue([]) const result = await collectInputForNitrogenBalance( @@ -463,7 +464,6 @@ describe("collectInputForNitrogenBalance", () => { mockFdm, principal_id, [b_id_farm], - true, ) expect(mockedGetCultivationsOfFarmsFromCatalogue).toHaveBeenCalledWith( mockFdm, @@ -541,7 +541,10 @@ describe("collectInputForNitrogenBalanceForFarms", () => { ...fert, b_id_farm: "test-farm-id-2", })) - const allFertilizerDetails = [...fertData1, ...fertData2] + const allFertilizerDetails = { + "test-farm-id": fertData1, + "test-farm-id-2": fertData2, + } mockedGetFertilizersOfFarms.mockResolvedValue(allFertilizerDetails) const combinedCultivationDetails = [ ...mockCultivationDetailsData, @@ -625,7 +628,6 @@ describe("collectInputForNitrogenBalanceForFarms", () => { mockFdm, principal_id, ["test-farm-id", "test-farm-id-2"], - true, ) expect(mockedGetFertilizersOfFarms).toHaveBeenCalledTimes(1) }) diff --git a/fdm-calculator/src/balance/nitrogen/input.ts b/fdm-calculator/src/balance/nitrogen/input.ts index 6cb4cdec6..c997dab15 100644 --- a/fdm-calculator/src/balance/nitrogen/input.ts +++ b/fdm-calculator/src/balance/nitrogen/input.ts @@ -3,7 +3,6 @@ import type { fdmSchema, PrincipalId, Timeframe, - Fertilizer, } from "@nmi-agro/fdm-core" import { getCultivations, @@ -182,33 +181,8 @@ export async function collectInputForNitrogenBalanceForFarms( tx, principal_id, farmIds, - true, ) - const fertilizerDetailsForFarms: Record = {} - if (fertilizerDetails && fertilizerDetails.length > 0) { - let fertilizerStart = 0 - let fertilizerEnd = 0 - while (fertilizerEnd < fertilizerDetails.length) { - const b_id_farm = fertilizerDetails[fertilizerStart] - .b_id_farm as string - for ( - ; - fertilizerEnd < fertilizerDetails.length; - fertilizerEnd++ - ) { - if ( - fertilizerDetails[fertilizerEnd].b_id_farm !== - b_id_farm - ) - break - } - fertilizerDetailsForFarms[b_id_farm] = - fertilizerDetails.slice(fertilizerStart, fertilizerEnd) - fertilizerStart = fertilizerEnd - } - } - return await Promise.all( farmIds.map(async (b_id_farm) => { try { @@ -223,7 +197,7 @@ export async function collectInputForNitrogenBalanceForFarms( let cultivationDetailsForThisFarm = cultivationDetails const fertilizerDetailsForThisFarm = - fertilizerDetailsForFarms[b_id_farm] ?? [] + fertilizerDetails[b_id_farm] ?? [] if (farmIds.length > 1) { // Required cultivation and fertilizer details for this farm should be extracted to not break the cache const cultivationIds = new Set( diff --git a/fdm-core/src/fertilizer.test.ts b/fdm-core/src/fertilizer.test.ts index c3b495941..680ba3f0e 100644 --- a/fdm-core/src/fertilizer.test.ts +++ b/fdm-core/src/fertilizer.test.ts @@ -11,6 +11,7 @@ import { disableFertilizerCatalogue, enableFertilizerCatalogue, } from "./catalogues" +import { applicationMethodOptions, fertilizersCatalogue } from "./db/schema" import { addFarm } from "./farm" import { createFdmServer } from "./fdm-server" import type { FdmServerType } from "./fdm-server.d" @@ -24,6 +25,7 @@ import { getFertilizerParametersDescription, getFertilizers, getFertilizersFromCatalogue, + getFertilizersOfFarms, removeFertilizer, removeFertilizerApplication, updateFertilizerApplication, @@ -36,6 +38,7 @@ 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,6 +61,14 @@ 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) }) @@ -322,6 +333,102 @@ describe("Fertilizer Data Model", () => { expect(fertilizers.length).toBe(2) }) + it("should get fertilizers for multiple farm IDs", 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", "other"][ + 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 map = await getFertilizersOfFarms(fdm, principal_id, [ + b_id_farm, + b_id_farm_2, + ]) + expect(map[b_id_farm]).toBeDefined() + expect(map[b_id_farm].map((fert) => fert.p_name_nl)).toEqual([ + "Farm 1 Example Fertilizer 1", + "Farm 1 Example Fertilizer 2", + ]) + expect(map[b_id_farm_2]).toBeDefined() + expect(map[b_id_farm_2].map((fert) => fert.p_name_nl)).toEqual([ + "Farm 2 Example Fertilizer 1", + "Farm 2 Example Fertilizer 2", + ]) + }) + 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 1bb2890c8..9b32f58f7 100644 --- a/fdm-core/src/fertilizer.ts +++ b/fdm-core/src/fertilizer.ts @@ -538,7 +538,9 @@ export async function getFertilizers( b_id_farm: schema.fertilizerAcquiringTypeSelect["b_id_farm"], ) { try { - return await getFertilizersOfFarms(fdm, principal_id, [b_id_farm]) + return (await getFertilizersOfFarms(fdm, principal_id, [b_id_farm]))[ + b_id_farm + ] } catch (err) { if ((err as Error)?.message === "Exception for getFertilizersOfFarms") { throw handleError(err, "Exception for getFertilizers", { @@ -568,8 +570,7 @@ export async function getFertilizersOfFarms( fdm: FdmType, principal_id: PrincipalId, farmIds: schema.fertilizerAcquiringTypeSelect["b_id_farm"][], - includeFarmIds = false, -): Promise<(Fertilizer & { b_id_farm?: string })[]> { +): Promise> { try { await Promise.all( farmIds.map((b_id_farm) => @@ -586,9 +587,7 @@ export async function getFertilizersOfFarms( const fertilizers = await fdm .select({ - ...(includeFarmIds - ? { b_id_farm: schema.fertilizerAcquiring.b_id_farm } - : {}), + 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, @@ -671,7 +670,7 @@ export async function getFertilizersOfFarms( asc(schema.fertilizersCatalogue.p_name_nl), ) - return fertilizers.map((f: (typeof fertilizers)[number]) => { + const res = 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) @@ -690,6 +689,30 @@ export async function getFertilizersOfFarms( p_type: p_type, } }) + + console.log(res) + // Chunk the query result array up for each fertilizer. + const fertilizerDetailsForFarms: Record = {} + if (res && res.length > 0) { + let fertilizerStart = 0 + let fertilizerEnd = 0 + while (fertilizerEnd < res.length) { + const b_id_farm = res[fertilizerStart].b_id_farm as string + for (; fertilizerEnd < res.length; fertilizerEnd++) { + if (res[fertilizerEnd].b_id_farm !== b_id_farm) break + } + fertilizerDetailsForFarms[b_id_farm] = res.slice( + fertilizerStart, + fertilizerEnd, + ) + console.log( + `for farm ${b_id_farm} the start is ${fertilizerStart} and end is ${fertilizerEnd}`, + ) + fertilizerStart = fertilizerEnd + } + } + + return fertilizerDetailsForFarms } catch (err) { throw handleError(err, "Exception for getFertilizersOfFarms", { farmIds, From 08bdc11699aade54fcb4536d402a5487b87682e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Fri, 20 Mar 2026 15:02:17 +0100 Subject: [PATCH 15/48] Add more tests and fix some --- fdm-calculator/src/balance/nitrogen/index.ts | 20 ++++++----- fdm-calculator/src/balance/nitrogen/input.ts | 1 - fdm-core/src/fertilizer.test.ts | 8 +++++ fdm-core/src/fertilizer.ts | 36 ++++++++++++++------ 4 files changed, 44 insertions(+), 21 deletions(-) diff --git a/fdm-calculator/src/balance/nitrogen/index.ts b/fdm-calculator/src/balance/nitrogen/index.ts index 65b89e13b..b3c5b6891 100644 --- a/fdm-calculator/src/balance/nitrogen/index.ts +++ b/fdm-calculator/src/balance/nitrogen/index.ts @@ -33,15 +33,17 @@ export async function calculateNitrogenBalance( fdm: FdmType, nitrogenBalanceInput: NitrogenBalanceInput, ) { - const fieldInputs: NitrogenBalanceFieldInput[] = - nitrogenBalanceInput.fields.map((field) => ({ - fieldInput: field, - cultivationDetails: nitrogenBalanceInput.cultivationDetails, - fertilizerDetails: nitrogenBalanceInput.fertilizerDetails, - timeFrame: nitrogenBalanceInput.timeFrame, - })) - - return calculateNitrogenBalanceBatched(fdm, fieldInputs) + return calculateNitrogenBalanceForFarms(fdm, [ + { + ...nitrogenBalanceInput, + b_id_farm: + ( + nitrogenBalanceInput as NitrogenBalanceInput & { + b_id_farm?: string + } + ).b_id_farm ?? "farm", + }, + ]) } /** diff --git a/fdm-calculator/src/balance/nitrogen/input.ts b/fdm-calculator/src/balance/nitrogen/input.ts index c997dab15..476d514ab 100644 --- a/fdm-calculator/src/balance/nitrogen/input.ts +++ b/fdm-calculator/src/balance/nitrogen/input.ts @@ -237,7 +237,6 @@ export async function collectInputForNitrogenBalanceForFarms( ) }) } catch (error) { - console.log("ERROR OCCURRED", error) throw new Error( `Failed to collect nitrogen balance input: ${ error instanceof Error ? error.message : String(error) diff --git a/fdm-core/src/fertilizer.test.ts b/fdm-core/src/fertilizer.test.ts index 680ba3f0e..099c6ce57 100644 --- a/fdm-core/src/fertilizer.test.ts +++ b/fdm-core/src/fertilizer.test.ts @@ -429,6 +429,14 @@ describe("Fertilizer Data Model", () => { ]) }) + it("(getFertilizers) should rename the error if getFertilizersOfFarms throws an error", async () => { + const invalidFarmId = createId() + + expect( + getFertilizers(fdm, principal_id, invalidFarmId), + ).rejects.not.toThrowError("Exception for getFertilizersOfFarms") + }) + 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 9b32f58f7..d9636c0d2 100644 --- a/fdm-core/src/fertilizer.ts +++ b/fdm-core/src/fertilizer.ts @@ -538,14 +538,24 @@ export async function getFertilizers( b_id_farm: schema.fertilizerAcquiringTypeSelect["b_id_farm"], ) { try { - return (await getFertilizersOfFarms(fdm, principal_id, [b_id_farm]))[ - b_id_farm - ] + return ( + (await getFertilizersOfFarms(fdm, principal_id, [b_id_farm]))[ + b_id_farm + ] ?? [] + ) } catch (err) { - if ((err as Error)?.message === "Exception for getFertilizersOfFarms") { - throw handleError(err, "Exception for getFertilizers", { - b_id_farm, - }) + if ( + (err as Error)?.message?.startsWith( + "Exception for getFertilizersOfFarms", + ) + ) { + throw handleError( + (err as Error).cause, + "Exception for getFertilizers", + { + b_id_farm, + }, + ) } throw err @@ -690,9 +700,8 @@ export async function getFertilizersOfFarms( } }) - console.log(res) // Chunk the query result array up for each fertilizer. - const fertilizerDetailsForFarms: Record = {} + const fertilizersMap: Record = {} if (res && res.length > 0) { let fertilizerStart = 0 let fertilizerEnd = 0 @@ -701,7 +710,7 @@ export async function getFertilizersOfFarms( for (; fertilizerEnd < res.length; fertilizerEnd++) { if (res[fertilizerEnd].b_id_farm !== b_id_farm) break } - fertilizerDetailsForFarms[b_id_farm] = res.slice( + fertilizersMap[b_id_farm] = res.slice( fertilizerStart, fertilizerEnd, ) @@ -712,7 +721,12 @@ export async function getFertilizersOfFarms( } } - return fertilizerDetailsForFarms + // Add empty arrays for farms with no fertilizers to be consistent with the old `getFertilizers` implementation + for (const b_id_farm of farmIds) { + fertilizersMap[b_id_farm] ??= [] + } + + return fertilizersMap } catch (err) { throw handleError(err, "Exception for getFertilizersOfFarms", { farmIds, From 0d6e09b3829d25940b9d296d7943bdd7033f6aca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Fri, 20 Mar 2026 15:13:06 +0100 Subject: [PATCH 16/48] Rename some things and clean up --- fdm-calculator/src/balance/nitrogen/input.ts | 4 ++-- fdm-core/src/fertilizer.ts | 3 --- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/fdm-calculator/src/balance/nitrogen/input.ts b/fdm-calculator/src/balance/nitrogen/input.ts index 476d514ab..984158c69 100644 --- a/fdm-calculator/src/balance/nitrogen/input.ts +++ b/fdm-calculator/src/balance/nitrogen/input.ts @@ -37,7 +37,7 @@ import type { FieldInput, NitrogenBalanceInput } from "./types" * * @alpha */ -export async function collectOnlyFieldInputForNitrogenBalance( +async function collectInputForNitrogenBalanceForFarm( fdm: FdmType, principal_id: PrincipalId, b_id_farm: fdmSchema.farmsTypeSelect["b_id_farm"], @@ -187,7 +187,7 @@ export async function collectInputForNitrogenBalanceForFarms( farmIds.map(async (b_id_farm) => { try { const onlyFieldInput = - await collectOnlyFieldInputForNitrogenBalance( + await collectInputForNitrogenBalanceForFarm( fdm, principal_id, b_id_farm, diff --git a/fdm-core/src/fertilizer.ts b/fdm-core/src/fertilizer.ts index d9636c0d2..3ea4923bb 100644 --- a/fdm-core/src/fertilizer.ts +++ b/fdm-core/src/fertilizer.ts @@ -714,9 +714,6 @@ export async function getFertilizersOfFarms( fertilizerStart, fertilizerEnd, ) - console.log( - `for farm ${b_id_farm} the start is ${fertilizerStart} and end is ${fertilizerEnd}`, - ) fertilizerStart = fertilizerEnd } } From 35322ea9fd689910ddf131718a492735892e8eec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Mon, 23 Mar 2026 10:43:34 +0100 Subject: [PATCH 17/48] Add organic matter balance for organisations --- ...slug.$calendar.balance.nitrogen._index.tsx | 2 +- ...calendar.balance.organic-matter._index.tsx | 625 ++++++++++++++++++ fdm-calculator/src/balance/nitrogen/input.ts | 15 +- .../src/balance/organic-matter/index.ts | 79 ++- .../src/balance/organic-matter/input.test.ts | 361 +++++++++- .../src/balance/organic-matter/input.ts | 193 +++++- fdm-calculator/src/index.ts | 7 +- 7 files changed, 1221 insertions(+), 61 deletions(-) create mode 100644 fdm-app/app/routes/organization.$slug.$calendar.balance.organic-matter._index.tsx 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 index 03369c98b..afa4915da 100644 --- a/fdm-app/app/routes/organization.$slug.$calendar.balance.nitrogen._index.tsx +++ b/fdm-app/app/routes/organization.$slug.$calendar.balance.nitrogen._index.tsx @@ -91,7 +91,7 @@ type LoaderData = export const meta: MetaFunction = () => { return [ { - title: `Stikstof | Bedrijf | Nutriëntenbalans| ${clientConfig.name}`, + title: `Stikstof | Organisatie | Nutriëntenbalans| ${clientConfig.name}`, }, { name: "description", 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..b7f3a1df5 --- /dev/null +++ b/fdm-app/app/routes/organization.$slug.$calendar.balance.organic-matter._index.tsx @@ -0,0 +1,625 @@ +import { + calculateOrganicMatterBalanceForFarms, + calculateOrganicMatterBalancesFieldToFarm, + collectInputForOrganicMatterBalanceForFarms, + type OrganicMatterBalanceFieldResultNumeric, + type OrganicMatterBalanceNumeric, +} from "@nmi-agro/fdm-calculator" +import { getFarms, getFields, listPrincipalsForFarm } from "@nmi-agro/fdm-core" +import { + ArrowDownToLine, + ArrowRightLeft, + ArrowUpFromLine, + CircleAlert, + CircleCheck, + CircleX, +} from "lucide-react" +import { Suspense, use, useRef } from "react" +import { + data, + type LoaderFunctionArgs, + type MetaFunction, + NavLink, + useLoaderData, + useParams, + useSearchParams, +} 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 { Button } from "~/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "~/components/ui/card" +import { Checkbox } from "~/components/ui/checkbox" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "~/components/ui/dialog" +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" + +type Farm = Awaited>[number] +type Organization = Awaited< + ReturnType +>[number] +type FarmResult = { + farm: Farm + owner: Awaited>[number] | undefined + fields: Awaited> + totalArea: number + organicMatterBalanceResult: OrganicMatterBalanceNumeric & { + errorMessage?: string + } +} +type AsyncData = { + farmResults: FarmResult[] + combinedResult: OrganicMatterBalanceNumeric +} +type LoaderData = + | { + organization: Organization + noFarms: true + } + | { + farms: Farm[] + 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, + } + } + + const farmsMap = Object.fromEntries( + farms.map((farm) => [farm.b_id_farm, farm]), + ) + + const farmIds = + searchParamFarmIds ?? farms.map((farm) => farm.b_id_farm) + + 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] as string + rawFarmResultsMap[b_id_farm] ??= [] + rawFarmResultsMap[b_id_farm].push(result) + } + const farmResults = await Promise.all( + Object.entries(rawFarmResultsMap).map( + async ([b_id_map, fieldResults]) => { + const organicMatterBalanceResult = + calculateOrganicMatterBalancesFieldToFarm( + fieldResults, + fieldResults.some( + (result) => result.errorMessage, + ), + fieldResults + .filter((result) => result.errorMessage) + .map( + (result) => result.errorMessage, + ) as string[], + ) + const farm = farmsMap[b_id_map] + const farmPrincipals = await listPrincipalsForFarm( + fdm, + principal_id, + farm.b_id_farm, + ) + const owner = farmPrincipals.find( + (p) => p.role === "owner" && p.type === "user", + ) + + const fields = await getFields( + fdm, + principal_id, + farm.b_id_farm, + ) + + const totalArea = fields.reduce( + (totalArea, field) => + totalArea + (field.b_area ?? 0), + 0, + ) + try { + if (organicMatterBalanceResult.hasErrors) { + reportError( + organicMatterBalanceResult.fieldErrorMessages.join( + ",\n", + ), + { + page: "organization/{slug}/{calendar}/farms/balance/organic-matter/_index", + scope: "loader", + }, + { + b_id_farm: farm.b_id_farm, + timeframe, + userId: session.principal_id, + }, + ) + } + + return { + farm: farm, + owner: owner, + fields: fields, + totalArea: totalArea, + organicMatterBalanceResult: + organicMatterBalanceResult as OrganicMatterBalanceNumeric & { + errorMessage?: string + }, + } + } catch (error) { + return { + farm: farm, + owner: owner, + fields: fields, + totalArea: totalArea, + organicMatterBalanceResult: { + hasErrors: true, + errorMessage: + error instanceof Error + ? error.message + : String(error), + } as OrganicMatterBalanceNumeric & { + errorMessage?: string + }, + } + } + }, + ), + ) + + return { + farmResults: farmResults, + combinedResult: combinedResult, + } + } + + const asyncData = getAsyncData(organization.id) + + return { + farms: farms, + organization: organization, + noFarms: false, + asyncData: asyncData, + } + } catch (error) { + throw handleLoaderError(error) + } +} + +export default function FarmBalanceOrganicMatterOverviewBlock() { + const loaderData = useLoaderData() + + return ( +
+ } + > + + +
+ ) +} + +function OrganizationFarmBalanceOrganicMatterOverview( + loaderData: Awaited>, +) { + const [searchParams, setSearchParams] = useSearchParams() + const params = useParams() + const formRef = useRef(null) + + if (loaderData.noFarms) { + return ( +
+ +
+ ) + } + + const { farms, asyncData: asyncDataPromise } = loaderData + + // `use` is not a React hook, therefore we can call it conditionally + const asyncData = use(asyncDataPromise) + + const { combinedResult: resolvedOrganicMatterBalanceResult, farmResults } = + asyncData + const farmChartBalanceData = + resolvedOrganicMatterBalanceResult as unknown as { + supply: number + removal: number + } & OrganicMatterBalanceNumeric + const hasErrors = farmResults.some( + ({ organicMatterBalanceResult }) => + organicMatterBalanceResult.hasErrors, + ) + + const createFarmRow = (farmResult: (typeof farmResults)[number]) => { + const balanceResult = farmResult.organicMatterBalanceResult + return ( +
+ {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}` + ) : ( + +

+ {"Bekijk foutmelding"} +

+
+ )} +
+
+ ) + } + return ( +
+

+ Organische Stof +

+
+ + + + Balans (Bedrijf) + + + + +
+
+

+ {resolvedOrganicMatterBalanceResult.balance} +

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

+ 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

+ + + + + + + + Wijzig selectie van bedrijven + + + De geselecteerde bedrijven zijn + uitgesloten in de berekening. + + +
+ {farms.map((farm) => { + const b_id_farm = farm.b_id_farm + const currentValue = + farmResults.find( + (result) => + result.farm + .b_id_farm === + b_id_farm, + ) + return ( +
+ + {farm.b_name_farm ?? + "Onbekend"} +
+ ) + })} +
+ + + +
+
+ +
+ +
+ +
+ {farmResults.map(createFarmRow)} +
+
+
+
+
+ ) +} diff --git a/fdm-calculator/src/balance/nitrogen/input.ts b/fdm-calculator/src/balance/nitrogen/input.ts index 984158c69..3a43c6d0c 100644 --- a/fdm-calculator/src/balance/nitrogen/input.ts +++ b/fdm-calculator/src/balance/nitrogen/input.ts @@ -32,6 +32,7 @@ import type { FieldInput, NitrogenBalanceInput } from "./types" * @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 an array of `FieldInput` objects containing only the field-specific input data. * @throws {Error} - Throws an error if data collection or processing fails. * @@ -156,6 +157,8 @@ async function collectInputForNitrogenBalanceForFarm( * @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. + * **Do not** provide this if collecting input for multiple farms, it will yield an unusable input. * @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. * @@ -176,7 +179,7 @@ export async function collectInputForNitrogenBalanceForFarms( tx, principal_id, farmIds, - ) // sorted by b_lu_catalogue + ) const fertilizerDetails = await getFertilizersOfFarms( tx, principal_id, @@ -237,6 +240,14 @@ export async function collectInputForNitrogenBalanceForFarms( ) }) } catch (error) { + if ( + (error as Error).message?.startsWith( + "Failed to collect field nitrogen balance input for farm", + ) + ) { + throw error + } + // Wrap any errors in a more descriptive error message. throw new Error( `Failed to collect nitrogen balance input: ${ error instanceof Error ? error.message : String(error) @@ -266,7 +277,7 @@ export async function collectInputForNitrogenBalanceForFarms( export async function collectInputForNitrogenBalance( fdm: FdmType, principal_id: PrincipalId, - b_id_farm: string, + b_id_farm: fdmSchema.farmsTypeSelect["b_id_farm"], timeframe: Timeframe, b_id?: fdmSchema.fieldsTypeSelect["b_id"], ): Promise { 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..586dc41d8 100644 --- a/fdm-calculator/src/balance/organic-matter/input.test.ts +++ b/fdm-calculator/src/balance/organic-matter/input.test.ts @@ -1,22 +1,198 @@ +import type { + Cultivation, + CultivationCatalogue, + FdmType, + Fertilizer, + FertilizerApplication, + 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(), + getFertilizersOfFarms: vi.fn(), getCultivationsFromCatalogue: vi.fn(), + getCultivationsOfFarmsFromCatalogue: 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-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: "", + }, + ] as FertilizerApplication[], + mockFertilizerApplicationsData2: [ + { + p_app_id: "fa-2", + p_id_catalogue: "fert-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: "", + }, + ] as FertilizerApplication[], + mockFertilizerDetailsData: [ + { + p_id: "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 Fertilizer[], + mockFertilizerDetailsData2: [ + { + p_id: "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 Fertilizer[], + 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), @@ -35,7 +211,9 @@ 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, "getFertilizersOfFarms").mockResolvedValue({ + [b_id_farm]: [], + }) vi.spyOn(fdmCore, "getCultivationsFromCatalogue").mockResolvedValue([]) const result = await collectInputForOrganicMatterBalance( @@ -63,7 +241,10 @@ describe("collectInputForOrganicMatterBalance", () => { vi.spyOn(fdmCore, "getSoilAnalyses").mockResolvedValue([]) vi.spyOn(fdmCore, "getFertilizerApplications").mockResolvedValue([]) vi.spyOn(fdmCore, "getFertilizers").mockResolvedValue([]) - vi.spyOn(fdmCore, "getCultivationsFromCatalogue").mockResolvedValue([]) + vi.spyOn( + fdmCore, + "getCultivationsOfFarmsFromCatalogue", + ).mockResolvedValue([]) const result = await collectInputForOrganicMatterBalance( mockFdm, @@ -105,12 +286,13 @@ describe("collectInputForOrganicMatterBalance", () => { vi.spyOn(fdmCore, "getCultivations").mockResolvedValue([ mockCultivation, ] as any) - vi.spyOn(fdmCore, "getFertilizers").mockResolvedValue([ - mockFertilizer, - ] as any) - vi.spyOn(fdmCore, "getCultivationsFromCatalogue").mockResolvedValue([ - mockCultivation, - ] as any) + vi.spyOn(fdmCore, "getFertilizersOfFarms").mockResolvedValue({ + [b_id_farm]: [mockFertilizer], + } as any) + vi.spyOn( + fdmCore, + "getCultivationsOfFarmsFromCatalogue", + ).mockResolvedValue([mockCultivation] as any) vi.spyOn(fdmCore, "getHarvests").mockResolvedValue([]) vi.spyOn(fdmCore, "getSoilAnalyses").mockResolvedValue([]) vi.spyOn(fdmCore, "getFertilizerApplications").mockResolvedValue([]) @@ -129,3 +311,158 @@ describe("collectInputForOrganicMatterBalance", () => { expect(result.fertilizerDetails[0].p_id_catalogue).toBe("fert1") }) }) + +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, + 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 fertData1 = mockFertilizerDetailsData.map((fert) => ({ + ...fert, + b_id_farm: "test-farm-id", + })) + const fertData2 = mockFertilizerDetailsData.map((fert) => ({ + ...fert, + b_id_farm: "test-farm-id-2", + })) + const allFertilizerDetails = { + "test-farm-id": fertData1, + "test-farm-id-2": fertData2, + } + vi.spyOn(fdmCore, "getFertilizersOfFarms").mockResolvedValue( + allFertilizerDetails, + ) + const combinedCultivationDetails = [ + ...mockCultivationDetailsData, + ...mockCultivationDetailsData2, + ] + vi.spyOn( + fdmCore, + "getCultivationsOfFarmsFromCatalogue", + ).mockResolvedValue(combinedCultivationDetails) + + 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: mockCultivationDetailsData, + timeFrame: timeframe, + }, + { + b_id_farm: "test-farm-id-2", + fields: expectedFieldInputs2, + fertilizerDetails: fertData2, + cultivationDetails: mockCultivationDetailsData2, + timeFrame: timeframe, + }, + ] + + expect(result).toEqual(expectedResult) + + expect( + fdmCore.getCultivationsOfFarmsFromCatalogue, + ).toHaveBeenCalledWith(mockFdm, principal_id, [ + "test-farm-id", + "test-farm-id-2", + ]) + expect( + fdmCore.getCultivationsOfFarmsFromCatalogue, + ).toHaveBeenCalledTimes(1) + expect(fdmCore.getFertilizersOfFarms).toHaveBeenCalledWith( + mockFdm, + principal_id, + ["test-farm-id", "test-farm-id-2"], + ) + expect(fdmCore.getFertilizersOfFarms).toHaveBeenCalledTimes(1) + }) +}) diff --git a/fdm-calculator/src/balance/organic-matter/input.ts b/fdm-calculator/src/balance/organic-matter/input.ts index d04b4b120..02902c2ea 100644 --- a/fdm-calculator/src/balance/organic-matter/input.ts +++ b/fdm-calculator/src/balance/organic-matter/input.ts @@ -6,26 +6,26 @@ import type { } from "@nmi-agro/fdm-core" import { getCultivations, - getCultivationsFromCatalogue, + getCultivationsOfFarmsFromCatalogue, getFertilizerApplications, - getFertilizers, + getFertilizersOfFarms, getField, getFields, getSoilAnalyses, } from "@nmi-agro/fdm-core" -import type { OrganicMatterBalanceInput } from "./types" +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 +37,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 +67,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,35 +103,172 @@ export async function collectInputForOrganicMatterBalance( } }), ) - - // 3. Fetch farm-level catalogue data. - // These details are fetched once for the entire farm and reused for each field. - const fertilizerDetails = await getFertilizers( - tx, - principal_id, - b_id_farm, - ) - const cultivationDetails = await getCultivationsFromCatalogue( + }) + } catch (error) { + throw new Error( + `Failed to collect field organic matter balance input for farm ${b_id_farm}: ${ + error instanceof Error ? error.message : String(error) + }`, + { cause: error }, + ) + } +} +/** + * 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 + */ +export async function collectInputForOrganicMatterBalanceForFarms( + fdm: FdmType, + principal_id: PrincipalId, + farmIds: fdmSchema.farmsTypeSelect["b_id_farm"][], + timeframe: Timeframe, + b_id?: fdmSchema.fieldsTypeSelect["b_id"], +): Promise<(OrganicMatterBalanceInput & { b_id_farm: string })[]> { + try { + // All data fetching is wrapped in a single database transaction to ensure consistency. + return await fdm.transaction(async (tx: FdmType) => { + const cultivationDetails = + await getCultivationsOfFarmsFromCatalogue( + tx, + principal_id, + farmIds, + ) + const fertilizerDetails = await getFertilizersOfFarms( tx, principal_id, - b_id_farm, + farmIds, ) - // 4. Assemble the final input object. - return { - fields, - fertilizerDetails, - cultivationDetails, - timeFrame: timeframe, - } + return await Promise.all( + farmIds.map(async (b_id_farm) => { + try { + const onlyFieldInput = + await collectInputForOrganicMatterBalanceForFarm( + fdm, + principal_id, + b_id_farm, + timeframe, + b_id, + ) + + // 3. Fetch farm-level catalogue data. + // These details are fetched once for the entire farm and reused for each field. + let cultivationDetailsForThisFarm = cultivationDetails + const fertilizerDetailsForThisFarm = + fertilizerDetails[b_id_farm] ?? [] + if (farmIds.length > 1) { + // Required cultivation and fertilizer details for this farm should be extracted to not break the cache + const cultivationIds = new Set( + onlyFieldInput.flatMap((input) => + input.cultivations.map( + (cultivation) => + cultivation.b_lu_catalogue, + ), + ), + ) + cultivationDetailsForThisFarm = + cultivationDetails.filter((cultivation) => + cultivationIds.has( + cultivation.b_lu_catalogue, + ), + ) + } + + // 4. Assemble the final input object. + return { + b_id_farm: b_id_farm, + fields: onlyFieldInput, + fertilizerDetails: fertilizerDetailsForThisFarm, + cultivationDetails: cultivationDetailsForThisFarm, + timeFrame: timeframe, + } + } catch (error) { + throw new Error( + `Failed to collect organic matter balance input for farm ${b_id_farm}: ${ + error instanceof Error + ? error.message + : String(error) + }`, + { cause: error }, + ) + } + }), + ) }) } catch (error) { + if ( + (error as Error).message?.startsWith( + "Failed to collect field organic matter balance input for farm", + ) + ) { + throw 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}: ${ + `Failed to collect organic matter balance input: ${ error instanceof Error ? error.message : String(error) }`, { cause: 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"], +) { + return ( + await collectInputForOrganicMatterBalanceForFarms( + fdm, + principal_id, + [b_id_farm], + timeframe, + b_id, + ) + )[0] +} diff --git a/fdm-calculator/src/index.ts b/fdm-calculator/src/index.ts index 06c0d6123..766d9f4b9 100644 --- a/fdm-calculator/src/index.ts +++ b/fdm-calculator/src/index.ts @@ -33,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, From 5a040f2763b19b3e9c1cdb9caa0d18893582efaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Mon, 23 Mar 2026 10:47:30 +0100 Subject: [PATCH 18/48] Remove mock email sending --- fdm-app/app/lib/email.server.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/fdm-app/app/lib/email.server.ts b/fdm-app/app/lib/email.server.ts index 1d0a6c1b4..070796771 100644 --- a/fdm-app/app/lib/email.server.ts +++ b/fdm-app/app/lib/email.server.ts @@ -257,10 +257,8 @@ function getTimeZoneFromUrl(url: string): string | undefined { return undefined } -import fs from "node:fs/promises" export async function sendEmail(email: Email): Promise { - // await client.sendEmail(email) - await fs.writeFile("email.html", email.HtmlBody) + await client.sendEmail(email) } export function isInactiveRecipientError(e: any) { From bed80e526a13ec77092a9e384867404c1e4b6236 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Mon, 23 Mar 2026 11:40:02 +0100 Subject: [PATCH 19/48] Nitpicks --- .../components/blocks/header/organization.tsx | 8 +- ...slug.$calendar.balance.nitrogen._index.tsx | 80 +++++++------- ...calendar.balance.organic-matter._index.tsx | 101 ++++++++++-------- .../src/balance/nitrogen/input.test.ts | 2 + fdm-calculator/src/balance/nitrogen/input.ts | 2 +- fdm-core/src/fertilizer.test.ts | 8 +- 6 files changed, 114 insertions(+), 87 deletions(-) diff --git a/fdm-app/app/components/blocks/header/organization.tsx b/fdm-app/app/components/blocks/header/organization.tsx index 62cab90f9..358023a54 100644 --- a/fdm-app/app/components/blocks/header/organization.tsx +++ b/fdm-app/app/components/blocks/header/organization.tsx @@ -45,7 +45,7 @@ export function HeaderOrganization({ matches.find( (match) => match.id === - `routes/organization.$slug.$calendar.farms.balance.${type}._index`, + `routes/organization.$slug.$calendar.balance.${type}._index`, ), ) const params = useParams() @@ -137,7 +137,7 @@ export function HeaderOrganization({ Balans @@ -162,7 +162,7 @@ export function HeaderOrganization({ key={"nitrogen"} > Stikstof @@ -175,7 +175,7 @@ export function HeaderOrganization({ key={"organic-matter"} > Organische stof 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 index afa4915da..ac722e23b 100644 --- a/fdm-app/app/routes/organization.$slug.$calendar.balance.nitrogen._index.tsx +++ b/fdm-app/app/routes/organization.$slug.$calendar.balance.nitrogen._index.tsx @@ -187,54 +187,60 @@ export async function loader({ NitrogenBalanceFieldResultNumeric[] > = {} for (const result of combinedResult.fields) { - const b_id_farm = fieldToFarmMap[result.b_id] as string + 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) } const farmResults = await Promise.all( Object.entries(rawFarmResultsMap).map( - async ([b_id_map, fieldResults]) => { - const nitrogenBalanceResult = - calculateNitrogenBalancesFieldToFarm( - fieldResults, - fieldResults.some( - (result) => result.errorMessage, - ), - fieldResults - .filter((result) => result.errorMessage) - .map( + async ([b_id_farm, fieldResults]) => { + const farm = farmsMap[b_id_farm] + try { + const nitrogenBalanceResult = + calculateNitrogenBalancesFieldToFarm( + fieldResults, + fieldResults.some( (result) => result.errorMessage, - ) as string[], + ), + fieldResults + .filter((result) => result.errorMessage) + .map( + (result) => result.errorMessage, + ) as string[], + ) + const farmPrincipals = await listPrincipalsForFarm( + fdm, + principal_id, + farm.b_id_farm, + ) + const owner = farmPrincipals.find( + (p) => p.role === "owner" && p.type === "user", ) - const farm = farmsMap[b_id_map] - const farmPrincipals = await listPrincipalsForFarm( - fdm, - principal_id, - farm.b_id_farm, - ) - const owner = farmPrincipals.find( - (p) => p.role === "owner" && p.type === "user", - ) - const fields = await getFields( - fdm, - principal_id, - farm.b_id_farm, - ) + const fields = await getFields( + fdm, + principal_id, + farm.b_id_farm, + ) - const totalArea = fields.reduce( - (totalArea, field) => - totalArea + (field.b_area ?? 0), - 0, - ) - try { + const totalArea = fields.reduce( + (totalArea, field) => + totalArea + (field.b_area ?? 0), + 0, + ) if (nitrogenBalanceResult.hasErrors) { reportError( nitrogenBalanceResult.fieldErrorMessages.join( ",\n", ), { - page: "organization/{slug}/{calendar}/farms/balance/nitrogen/_index", + page: "organization/{slug}/{calendar}/balance/nitrogen/_index", scope: "loader", }, { @@ -258,9 +264,9 @@ export async function loader({ } catch (error) { return { farm: farm, - owner: owner, - fields: fields, - totalArea: totalArea, + owner: undefined, + fields: [], + totalArea: 0, nitrogenBalanceResult: { hasErrors: true, errorMessage: @@ -359,7 +365,7 @@ function OrganizationFarmBalanceNitrogenOverview(loaderData: LoaderData) { className="flex items-center grow" key={farmResult.farm.b_id_farm} > - {balanceResult.balance ? ( + {Number.isFinite(balanceResult.balance) ? ( balanceResult.balance <= balanceResult.target ? ( ) : ( 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 index b7f3a1df5..2a5f8a6a6 100644 --- 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 @@ -186,54 +186,60 @@ export async function loader({ OrganicMatterBalanceFieldResultNumeric[] > = {} for (const result of combinedResult.fields) { - const b_id_farm = fieldToFarmMap[result.b_id] as string + 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) } const farmResults = await Promise.all( Object.entries(rawFarmResultsMap).map( - async ([b_id_map, fieldResults]) => { - const organicMatterBalanceResult = - calculateOrganicMatterBalancesFieldToFarm( - fieldResults, - fieldResults.some( - (result) => result.errorMessage, - ), - fieldResults - .filter((result) => result.errorMessage) - .map( + async ([b_id_farm, fieldResults]) => { + const farm = farmsMap[b_id_farm] + try { + const organicMatterBalanceResult = + calculateOrganicMatterBalancesFieldToFarm( + fieldResults, + fieldResults.some( (result) => result.errorMessage, - ) as string[], + ), + fieldResults + .filter((result) => result.errorMessage) + .map( + (result) => result.errorMessage, + ) as string[], + ) + const farmPrincipals = await listPrincipalsForFarm( + fdm, + principal_id, + farm.b_id_farm, + ) + const owner = farmPrincipals.find( + (p) => p.role === "owner" && p.type === "user", ) - const farm = farmsMap[b_id_map] - const farmPrincipals = await listPrincipalsForFarm( - fdm, - principal_id, - farm.b_id_farm, - ) - const owner = farmPrincipals.find( - (p) => p.role === "owner" && p.type === "user", - ) - const fields = await getFields( - fdm, - principal_id, - farm.b_id_farm, - ) + const fields = await getFields( + fdm, + principal_id, + farm.b_id_farm, + ) - const totalArea = fields.reduce( - (totalArea, field) => - totalArea + (field.b_area ?? 0), - 0, - ) - try { + const totalArea = fields.reduce( + (totalArea, field) => + totalArea + (field.b_area ?? 0), + 0, + ) if (organicMatterBalanceResult.hasErrors) { reportError( organicMatterBalanceResult.fieldErrorMessages.join( ",\n", ), { - page: "organization/{slug}/{calendar}/farms/balance/organic-matter/_index", + page: "organization/{slug}/{calendar}/balance/organic-matter/_index", scope: "loader", }, { @@ -257,9 +263,9 @@ export async function loader({ } catch (error) { return { farm: farm, - owner: owner, - fields: fields, - totalArea: totalArea, + owner: undefined, + fields: [], + totalArea: 0, organicMatterBalanceResult: { hasErrors: true, errorMessage: @@ -309,9 +315,16 @@ export default function FarmBalanceOrganicMatterOverviewBlock() { ) } -function OrganizationFarmBalanceOrganicMatterOverview( - loaderData: Awaited>, -) { +/** + * 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 [searchParams, setSearchParams] = useSearchParams() const params = useParams() const formRef = useRef(null) @@ -353,8 +366,8 @@ function OrganizationFarmBalanceOrganicMatterOverview( className="flex items-center grow" key={farmResult.farm.b_id_farm} > - {balanceResult.balance ? ( - balanceResult.balance <= balanceResult.target ? ( + {Number.isFinite(balanceResult.balance) ? ( + balanceResult.balance > 0 ? ( ) : ( @@ -377,7 +390,7 @@ function OrganizationFarmBalanceOrganicMatterOverview(
{!balanceResult.hasErrors ? ( - `${balanceResult.balance} / ${balanceResult.target}` + balanceResult.balance ) : ( - ) : resolvedOrganicMatterBalanceResult.balance <= - resolvedOrganicMatterBalanceResult.target ? ( + ) : resolvedOrganicMatterBalanceResult.balance > + 0 ? ( ) : ( diff --git a/fdm-calculator/src/balance/nitrogen/input.test.ts b/fdm-calculator/src/balance/nitrogen/input.test.ts index 11d2c15fa..8621e5373 100644 --- a/fdm-calculator/src/balance/nitrogen/input.test.ts +++ b/fdm-calculator/src/balance/nitrogen/input.test.ts @@ -235,6 +235,8 @@ function createMockData() { 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) }], ]), } } diff --git a/fdm-calculator/src/balance/nitrogen/input.ts b/fdm-calculator/src/balance/nitrogen/input.ts index 3a43c6d0c..59c928102 100644 --- a/fdm-calculator/src/balance/nitrogen/input.ts +++ b/fdm-calculator/src/balance/nitrogen/input.ts @@ -267,7 +267,7 @@ export async function collectInputForNitrogenBalanceForFarms( * * @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 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. * @throws {Error} - Throws an error if data collection or processing fails. diff --git a/fdm-core/src/fertilizer.test.ts b/fdm-core/src/fertilizer.test.ts index 099c6ce57..868020235 100644 --- a/fdm-core/src/fertilizer.test.ts +++ b/fdm-core/src/fertilizer.test.ts @@ -71,6 +71,12 @@ describe("Fertilizer Data Model", () => { ) 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 () => {}) @@ -434,7 +440,7 @@ describe("Fertilizer Data Model", () => { expect( getFertilizers(fdm, principal_id, invalidFarmId), - ).rejects.not.toThrowError("Exception for getFertilizersOfFarms") + ).rejects.not.toThrowError("Exception for getFertilizers") }) it("should remove a fertilizer", async () => { From d53bc11e00a521879ef71c2b08098d17201d47bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Mon, 23 Mar 2026 12:03:33 +0100 Subject: [PATCH 20/48] Improve navigation --- .../components/blocks/header/organization.tsx | 8 ++--- ...slug.$calendar.balance.nitrogen._index.tsx | 30 ++++++------------- ...calendar.balance.organic-matter._index.tsx | 30 ++++++------------- 3 files changed, 22 insertions(+), 46 deletions(-) diff --git a/fdm-app/app/components/blocks/header/organization.tsx b/fdm-app/app/components/blocks/header/organization.tsx index 358023a54..592f4cf6b 100644 --- a/fdm-app/app/components/blocks/header/organization.tsx +++ b/fdm-app/app/components/blocks/header/organization.tsx @@ -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", ) @@ -48,7 +49,6 @@ export function HeaderOrganization({ `routes/organization.$slug.$calendar.balance.${type}._index`, ), ) - const params = useParams() return ( <> @@ -162,7 +162,7 @@ export function HeaderOrganization({ key={"nitrogen"} > Stikstof @@ -175,7 +175,7 @@ export function HeaderOrganization({ key={"organic-matter"} > Organische stof 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 index ac722e23b..cf3e9caa6 100644 --- a/fdm-app/app/routes/organization.$slug.$calendar.balance.nitrogen._index.tsx +++ b/fdm-app/app/routes/organization.$slug.$calendar.balance.nitrogen._index.tsx @@ -83,6 +83,7 @@ type LoaderData = } | { farms: Farm[] + farmIds: string[] organization: Organization noFarms: false asyncData: Promise @@ -292,6 +293,7 @@ export async function loader({ return { farms: farms, + farmIds: farmIds.sort(), organization: organization, noFarms: false, asyncData: asyncData, @@ -303,11 +305,11 @@ export async function loader({ export default function FarmBalanceNitrogenOverviewBlock() { const loaderData = useLoaderData() - + const farmIds = !loaderData.noFarms ? loaderData.farmIds : [] return (
} > @@ -326,7 +328,7 @@ export default function FarmBalanceNitrogenOverviewBlock() { * would not render until `asyncData` resolves and the fallback would never be shown. */ function OrganizationFarmBalanceNitrogenOverview(loaderData: LoaderData) { - const [searchParams, setSearchParams] = useSearchParams() + const [, setSearchParams] = useSearchParams() const params = useParams() const formRef = useRef(null) @@ -343,7 +345,7 @@ function OrganizationFarmBalanceNitrogenOverview(loaderData: LoaderData) { ) } - const { farms, asyncData: asyncDataPromise } = loaderData + const { farms, farmIds, asyncData: asyncDataPromise } = loaderData // `use` is not a React hook, therefore we can call it conditionally const asyncData = use(asyncDataPromise) @@ -602,28 +604,14 @@ function OrganizationFarmBalanceNitrogenOverview(loaderData: LoaderData) { } } } - - const farmIds = - searchParams - .get("farmIds") - ?.split(",") - .filter(Boolean) ?? - farms.map( - (farm) => - farm.b_id_farm, - ) - const sortedPrevFarmIds = - farmIds.sort() selectedFarmIds.sort() if ( - sortedPrevFarmIds.length !== + farmIds.length !== selectedFarmIds.length || selectedFarmIds.find( (selected_id, index) => selected_id !== - sortedPrevFarmIds[ - index - ], + farmIds[index], ) ) { setSearchParams( @@ -653,7 +641,7 @@ function OrganizationFarmBalanceNitrogenOverview(loaderData: LoaderData) { } }} > - Sluiten + Opslaan 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 index 2a5f8a6a6..9930a1dd7 100644 --- 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 @@ -81,6 +81,7 @@ type LoaderData = } | { farms: Farm[] + farmIds: string[] organization: Organization noFarms: false asyncData: Promise @@ -291,6 +292,7 @@ export async function loader({ return { farms: farms, + farmIds: farmIds.sort(), organization: organization, noFarms: false, asyncData: asyncData, @@ -302,11 +304,11 @@ export async function loader({ export default function FarmBalanceOrganicMatterOverviewBlock() { const loaderData = useLoaderData() - + const farmIds = !loaderData.noFarms ? loaderData.farmIds : [] return (
} > @@ -325,7 +327,7 @@ export default function FarmBalanceOrganicMatterOverviewBlock() { * would not render until `asyncData` resolves and the fallback would never be shown. */ function OrganizationFarmBalanceOrganicMatterOverview(loaderData: LoaderData) { - const [searchParams, setSearchParams] = useSearchParams() + const [, setSearchParams] = useSearchParams() const params = useParams() const formRef = useRef(null) @@ -342,7 +344,7 @@ function OrganizationFarmBalanceOrganicMatterOverview(loaderData: LoaderData) { ) } - const { farms, asyncData: asyncDataPromise } = loaderData + const { farms, farmIds, asyncData: asyncDataPromise } = loaderData // `use` is not a React hook, therefore we can call it conditionally const asyncData = use(asyncDataPromise) @@ -566,28 +568,14 @@ function OrganizationFarmBalanceOrganicMatterOverview(loaderData: LoaderData) { } } } - - const farmIds = - searchParams - .get("farmIds") - ?.split(",") - .filter(Boolean) ?? - farms.map( - (farm) => - farm.b_id_farm, - ) - const sortedPrevFarmIds = - farmIds.sort() selectedFarmIds.sort() if ( - sortedPrevFarmIds.length !== + farmIds.length !== selectedFarmIds.length || selectedFarmIds.find( (selected_id, index) => selected_id !== - sortedPrevFarmIds[ - index - ], + farmIds[index], ) ) { setSearchParams( @@ -617,7 +605,7 @@ function OrganizationFarmBalanceOrganicMatterOverview(loaderData: LoaderData) { } }} > - Sluiten + Opslaan From 430f01a4de3be2c5f32f4aba0b2f553a0c1f6fca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Mon, 23 Mar 2026 13:43:38 +0100 Subject: [PATCH 21/48] Move farm select dialog into a separate component --- .../blocks/balance/farm-select-dialog.tsx | 121 +++++++++++++++ ...slug.$calendar.balance.nitrogen._index.tsx | 138 ++--------------- ...calendar.balance.organic-matter._index.tsx | 142 ++---------------- 3 files changed, 145 insertions(+), 256 deletions(-) create mode 100644 fdm-app/app/components/blocks/balance/farm-select-dialog.tsx 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..5d2ed8db7 --- /dev/null +++ b/fdm-app/app/components/blocks/balance/farm-select-dialog.tsx @@ -0,0 +1,121 @@ +import type { getFarms } from "@nmi-agro/fdm-core" +import { useRef } from "react" +import { useSearchParams } from "react-router" +import { Button } from "~/components/ui/button" +import { Checkbox } from "~/components/ui/checkbox" +import { + Dialog, + 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: Awaited> + defaultSelectedFarmIds: string[] +}) { + const formRef = useRef(null) + const [, setSearchParams] = useSearchParams() + + return ( + + + + + + + Wijzig selectie van bedrijven + + De geselecteerde bedrijven zijn uitgesloten in de + berekening. + + +
+ {farms.map((farm) => { + const b_id_farm = farm.b_id_farm + const currentValue = defaultSelectedFarmIds.includes( + farm.b_id_farm, + ) + return ( +
+ + {farm.b_name_farm ?? "Onbekend"} +
+ ) + })} +
+ + + +
+
+ ) +} 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 index cf3e9caa6..afb27bc54 100644 --- a/fdm-app/app/routes/organization.$slug.$calendar.balance.nitrogen._index.tsx +++ b/fdm-app/app/routes/organization.$slug.$calendar.balance.nitrogen._index.tsx @@ -16,7 +16,7 @@ import { CircleCheck, CircleX, } from "lucide-react" -import { Suspense, use, useRef } from "react" +import { Suspense, use } from "react" import { data, type LoaderFunctionArgs, @@ -24,13 +24,11 @@ import { NavLink, useLoaderData, useParams, - useSearchParams, } 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 { Button } from "~/components/ui/button" import { Card, CardContent, @@ -38,16 +36,6 @@ import { CardHeader, CardTitle, } from "~/components/ui/card" -import { Checkbox } from "~/components/ui/checkbox" -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "~/components/ui/dialog" import { Tooltip, TooltipContent, @@ -58,6 +46,7 @@ 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< @@ -307,14 +296,15 @@ export default function FarmBalanceNitrogenOverviewBlock() { const loaderData = useLoaderData() const farmIds = !loaderData.noFarms ? loaderData.farmIds : [] return ( -
+
+

Stikstof

} > -
+ ) } @@ -328,9 +318,7 @@ export default function FarmBalanceNitrogenOverviewBlock() { * would not render until `asyncData` resolves and the fallback would never be shown. */ function OrganizationFarmBalanceNitrogenOverview(loaderData: LoaderData) { - const [, setSearchParams] = useSearchParams() const params = useParams() - const formRef = useRef(null) if (loaderData.noFarms) { return ( @@ -406,8 +394,7 @@ function OrganizationFarmBalanceNitrogenOverview(loaderData: LoaderData) { ) } return ( -
-

Stikstof

+ <>
@@ -539,113 +526,10 @@ function OrganizationFarmBalanceNitrogenOverview(loaderData: LoaderData) {

Bedrijven

- - - - - - - - Wijzig selectie van bedrijven - - - De geselecteerde bedrijven zijn - uitgesloten in de berekening. - - -
- {farms.map((farm) => { - const b_id_farm = farm.b_id_farm - const currentValue = - farmResults.find( - (result) => - result.farm - .b_id_farm === - b_id_farm, - ) - return ( -
- - {farm.b_name_farm ?? - "Onbekend"} -
- ) - })} -
- - - -
-
+
@@ -657,6 +541,6 @@ function OrganizationFarmBalanceNitrogenOverview(loaderData: LoaderData) {
-
+ ) } 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 index 9930a1dd7..921f7b152 100644 --- 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 @@ -14,7 +14,7 @@ import { CircleCheck, CircleX, } from "lucide-react" -import { Suspense, use, useRef } from "react" +import { Suspense, use } from "react" import { data, type LoaderFunctionArgs, @@ -22,13 +22,11 @@ import { NavLink, useLoaderData, useParams, - useSearchParams, } 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 { Button } from "~/components/ui/button" import { Card, CardContent, @@ -36,16 +34,6 @@ import { CardHeader, CardTitle, } from "~/components/ui/card" -import { Checkbox } from "~/components/ui/checkbox" -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "~/components/ui/dialog" import { Tooltip, TooltipContent, @@ -56,6 +44,7 @@ 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< @@ -306,14 +295,17 @@ export default function FarmBalanceOrganicMatterOverviewBlock() { const loaderData = useLoaderData() const farmIds = !loaderData.noFarms ? loaderData.farmIds : [] return ( -
+
+

+ Organische Stof +

} > -
+ ) } @@ -327,9 +319,7 @@ export default function FarmBalanceOrganicMatterOverviewBlock() { * would not render until `asyncData` resolves and the fallback would never be shown. */ function OrganizationFarmBalanceOrganicMatterOverview(loaderData: LoaderData) { - const [, setSearchParams] = useSearchParams() const params = useParams() - const formRef = useRef(null) if (loaderData.noFarms) { return ( @@ -407,10 +397,7 @@ function OrganizationFarmBalanceOrganicMatterOverview(loaderData: LoaderData) { ) } return ( -
-

- Organische Stof -

+ <>
@@ -503,113 +490,10 @@ function OrganizationFarmBalanceOrganicMatterOverview(loaderData: LoaderData) {

Bedrijven

- - - - - - - - Wijzig selectie van bedrijven - - - De geselecteerde bedrijven zijn - uitgesloten in de berekening. - - -
- {farms.map((farm) => { - const b_id_farm = farm.b_id_farm - const currentValue = - farmResults.find( - (result) => - result.farm - .b_id_farm === - b_id_farm, - ) - return ( -
- - {farm.b_name_farm ?? - "Onbekend"} -
- ) - })} -
- - - -
-
+
@@ -621,6 +505,6 @@ function OrganizationFarmBalanceOrganicMatterOverview(loaderData: LoaderData) {
-
+ ) } From dcb2e74a207d0cfd788bb44a5f9353e23fb19e63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Mon, 23 Mar 2026 16:01:29 +0100 Subject: [PATCH 22/48] fix: cache input inconsistency between one farm and multiple farms --- .../src/balance/nitrogen/input.test.ts | 7 +- fdm-calculator/src/balance/nitrogen/input.ts | 40 ++++---- .../src/balance/organic-matter/input.test.ts | 35 +++++-- .../src/balance/organic-matter/input.ts | 91 +++++++++---------- fdm-core/src/cultivation.ts | 2 +- 5 files changed, 93 insertions(+), 82 deletions(-) diff --git a/fdm-calculator/src/balance/nitrogen/input.test.ts b/fdm-calculator/src/balance/nitrogen/input.test.ts index 8621e5373..7763809b3 100644 --- a/fdm-calculator/src/balance/nitrogen/input.test.ts +++ b/fdm-calculator/src/balance/nitrogen/input.test.ts @@ -174,7 +174,7 @@ function createMockData() { p_app_amount: 100, p_app_method: "broadcasting", // match one of ApplicationMethods p_app_date: new Date(), - p_id: "", + p_id: "fert-cat-1", }, ] as FertilizerApplication[], mockFertilizerApplicationsData2: [ @@ -185,7 +185,7 @@ function createMockData() { p_app_amount: 100, p_app_method: "broadcasting", // match one of ApplicationMethods p_app_date: new Date(), - p_id: "", + p_id: "fert-cat-2", }, ] as FertilizerApplication[], mockFertilizerDetailsData: [ @@ -508,6 +508,7 @@ describe("collectInputForNitrogenBalanceForFarms", () => { mockFertilizerApplicationsData, mockFertilizerApplicationsData2, mockFertilizerDetailsData, + mockFertilizerDetailsData2, mockCultivationDetailsData, mockCultivationDetailsData2, mockDepositionSupplyMap, @@ -539,7 +540,7 @@ describe("collectInputForNitrogenBalanceForFarms", () => { ...fert, b_id_farm: "test-farm-id", })) - const fertData2 = mockFertilizerDetailsData.map((fert) => ({ + const fertData2 = mockFertilizerDetailsData2.map((fert) => ({ ...fert, b_id_farm: "test-farm-id-2", })) diff --git a/fdm-calculator/src/balance/nitrogen/input.ts b/fdm-calculator/src/balance/nitrogen/input.ts index 59c928102..8c1960ff2 100644 --- a/fdm-calculator/src/balance/nitrogen/input.ts +++ b/fdm-calculator/src/balance/nitrogen/input.ts @@ -198,26 +198,30 @@ export async function collectInputForNitrogenBalanceForFarms( b_id, ) - let cultivationDetailsForThisFarm = cultivationDetails - const fertilizerDetailsForThisFarm = - fertilizerDetails[b_id_farm] ?? [] - if (farmIds.length > 1) { - // Required cultivation and fertilizer details for this farm should be extracted to not break the cache - const cultivationIds = new Set( - onlyFieldInput.flatMap((input) => - input.cultivations.map( - (cultivation) => - cultivation.b_lu_catalogue, - ), + // Required cultivation and fertilizer details for this farm should be extracted to not break the cache + const cultivationIds = new Set( + onlyFieldInput.flatMap((input) => + input.cultivations.map( + (cultivation) => cultivation.b_lu_catalogue, ), + ), + ) + const cultivationDetailsForThisFarm = + cultivationDetails.filter((cultivation) => + cultivationIds.has(cultivation.b_lu_catalogue), ) - cultivationDetailsForThisFarm = - cultivationDetails.filter((cultivation) => - cultivationIds.has( - cultivation.b_lu_catalogue, - ), - ) - } + + const fertilizerIds = new Set( + onlyFieldInput.flatMap((input) => + input.fertilizerApplications.map( + (app) => app.p_id, + ), + ), + ) + const fertilizerDetailsForThisFarm = + fertilizerDetails[b_id_farm]?.filter((fert) => + fertilizerIds.has(fert.p_id), + ) ?? [] return { b_id_farm: b_id_farm, diff --git a/fdm-calculator/src/balance/organic-matter/input.test.ts b/fdm-calculator/src/balance/organic-matter/input.test.ts index 586dc41d8..caf112380 100644 --- a/fdm-calculator/src/balance/organic-matter/input.test.ts +++ b/fdm-calculator/src/balance/organic-matter/input.test.ts @@ -27,7 +27,6 @@ vi.mock("@nmi-agro/fdm-core", async () => { getSoilAnalyses: vi.fn(), getFertilizerApplications: vi.fn(), getFertilizersOfFarms: vi.fn(), - getCultivationsFromCatalogue: vi.fn(), getCultivationsOfFarmsFromCatalogue: vi.fn(), } }) @@ -132,7 +131,7 @@ function createMockData() { p_app_amount: 100, p_app_method: "broadcasting", // match one of ApplicationMethods p_app_date: new Date(), - p_id: "", + p_id: "fert-cat-1", }, ] as FertilizerApplication[], mockFertilizerApplicationsData2: [ @@ -143,7 +142,7 @@ function createMockData() { p_app_amount: 100, p_app_method: "broadcasting", // match one of ApplicationMethods p_app_date: new Date(), - p_id: "", + p_id: "fert-cat-2", }, ] as FertilizerApplication[], mockFertilizerDetailsData: [ @@ -206,15 +205,28 @@ 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, "getFertilizerApplications").mockResolvedValue( + mockFertilizerApplicationsData, + ) vi.spyOn(fdmCore, "getFertilizersOfFarms").mockResolvedValue({ - [b_id_farm]: [], + [b_id_farm]: mockFertilizerDetailsData, }) - vi.spyOn(fdmCore, "getCultivationsFromCatalogue").mockResolvedValue([]) + vi.spyOn( + fdmCore, + "getCultivationsOfFarmsFromCatalogue", + ).mockResolvedValue(mockCultivationDetailsData) const result = await collectInputForOrganicMatterBalance( mockFdm, @@ -281,7 +293,7 @@ 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: "fert1", p_id_catalogue: "fert1" } vi.spyOn(fdmCore, "getFields").mockResolvedValue([mockField] as any) vi.spyOn(fdmCore, "getCultivations").mockResolvedValue([ mockCultivation, @@ -295,7 +307,9 @@ describe("collectInputForOrganicMatterBalance", () => { ).mockResolvedValue([mockCultivation] as any) vi.spyOn(fdmCore, "getHarvests").mockResolvedValue([]) vi.spyOn(fdmCore, "getSoilAnalyses").mockResolvedValue([]) - vi.spyOn(fdmCore, "getFertilizerApplications").mockResolvedValue([]) + vi.spyOn(fdmCore, "getFertilizerApplications").mockResolvedValue([ + { p_id: "fert1" } as any, + ]) const result = await collectInputForOrganicMatterBalance( mockFdm, @@ -339,6 +353,7 @@ describe("collectInputForOrganicMatterBalanceForFarms", () => { mockFertilizerApplicationsData, mockFertilizerApplicationsData2, mockFertilizerDetailsData, + mockFertilizerDetailsData2, mockCultivationDetailsData, mockCultivationDetailsData2, } = createMockData() @@ -374,7 +389,7 @@ describe("collectInputForOrganicMatterBalanceForFarms", () => { ...fert, b_id_farm: "test-farm-id", })) - const fertData2 = mockFertilizerDetailsData.map((fert) => ({ + const fertData2 = mockFertilizerDetailsData2.map((fert) => ({ ...fert, b_id_farm: "test-farm-id-2", })) diff --git a/fdm-calculator/src/balance/organic-matter/input.ts b/fdm-calculator/src/balance/organic-matter/input.ts index 02902c2ea..abe9e6d89 100644 --- a/fdm-calculator/src/balance/organic-matter/input.ts +++ b/fdm-calculator/src/balance/organic-matter/input.ts @@ -106,7 +106,7 @@ async function collectInputForOrganicMatterBalanceForFarm( }) } catch (error) { throw new Error( - `Failed to collect field organic matter balance input for farm ${b_id_farm}: ${ + `Failed to collect organic matter balance input for farm ${b_id_farm}: ${ error instanceof Error ? error.message : String(error) }`, { cause: error }, @@ -161,56 +161,47 @@ export async function collectInputForOrganicMatterBalanceForFarms( return await Promise.all( farmIds.map(async (b_id_farm) => { - try { - const onlyFieldInput = - await collectInputForOrganicMatterBalanceForFarm( - fdm, - principal_id, - b_id_farm, - timeframe, - b_id, - ) - - // 3. Fetch farm-level catalogue data. - // These details are fetched once for the entire farm and reused for each field. - let cultivationDetailsForThisFarm = cultivationDetails - const fertilizerDetailsForThisFarm = - fertilizerDetails[b_id_farm] ?? [] - if (farmIds.length > 1) { - // Required cultivation and fertilizer details for this farm should be extracted to not break the cache - const cultivationIds = new Set( - onlyFieldInput.flatMap((input) => - input.cultivations.map( - (cultivation) => - cultivation.b_lu_catalogue, - ), - ), - ) - cultivationDetailsForThisFarm = - cultivationDetails.filter((cultivation) => - cultivationIds.has( - cultivation.b_lu_catalogue, - ), - ) - } + const onlyFieldInput = + await collectInputForOrganicMatterBalanceForFarm( + fdm, + principal_id, + b_id_farm, + timeframe, + b_id, + ) - // 4. Assemble the final input object. - return { - b_id_farm: b_id_farm, - fields: onlyFieldInput, - fertilizerDetails: fertilizerDetailsForThisFarm, - cultivationDetails: cultivationDetailsForThisFarm, - timeFrame: timeframe, - } - } catch (error) { - throw new Error( - `Failed to collect organic matter balance input for farm ${b_id_farm}: ${ - error instanceof Error - ? error.message - : String(error) - }`, - { cause: error }, + // 3. Fetch farm-level catalogue data. + // These details are fetched once for the entire farm and reused for each field. + // Required cultivation and fertilizer details for this farm should be extracted to not break the cache + const cultivationIds = new Set( + onlyFieldInput.flatMap((input) => + input.cultivations.map( + (cultivation) => cultivation.b_lu_catalogue, + ), + ), + ) + const cultivationDetailsForThisFarm = + cultivationDetails.filter((cultivation) => + cultivationIds.has(cultivation.b_lu_catalogue), ) + + const fertilizerIds = new Set( + onlyFieldInput.flatMap((input) => + input.fertilizerApplications.map((app) => app.p_id), + ), + ) + const fertilizerDetailsForThisFarm = + fertilizerDetails[b_id_farm]?.filter((fert) => + fertilizerIds.has(fert.p_id), + ) ?? [] + + // 4. Assemble the final input object. + return { + b_id_farm: b_id_farm, + fields: onlyFieldInput, + fertilizerDetails: fertilizerDetailsForThisFarm, + cultivationDetails: cultivationDetailsForThisFarm, + timeFrame: timeframe, } }), ) @@ -218,7 +209,7 @@ export async function collectInputForOrganicMatterBalanceForFarms( } catch (error) { if ( (error as Error).message?.startsWith( - "Failed to collect field organic matter balance input for farm", + "Failed to collect organic matter balance input for farm", ) ) { throw error diff --git a/fdm-core/src/cultivation.ts b/fdm-core/src/cultivation.ts index 83929c907..ad42a2852 100644 --- a/fdm-core/src/cultivation.ts +++ b/fdm-core/src/cultivation.ts @@ -56,7 +56,7 @@ export async function getCultivationsOfFarmsFromCatalogue( "read", b_id_farm, principal_id, - "getCultivationsFromCatalogue", + "getCultivationsOfFarmsFromCatalogue", ), ), ) From a8d4e4bbac5f4509764c27722de6aed2e8d7a592 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Mon, 23 Mar 2026 16:33:42 +0100 Subject: [PATCH 23/48] Test getCultivationsOfFarmsFromCatalogue a bit better --- fdm-core/src/cultivation.test.ts | 97 +++++++++++++++++++++++++++++--- 1 file changed, 89 insertions(+), 8 deletions(-) diff --git a/fdm-core/src/cultivation.test.ts b/fdm-core/src/cultivation.test.ts index 6afb79b8d..83e31c002 100644 --- a/fdm-core/src/cultivation.test.ts +++ b/fdm-core/src/cultivation.test.ts @@ -33,12 +33,17 @@ import { createId } from "./id" 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_2: 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") @@ -49,11 +54,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, @@ -93,6 +102,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 () => { @@ -102,10 +151,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", @@ -121,9 +167,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") @@ -134,6 +192,13 @@ describe("Cultivation Data Model", () => { b_id, b_lu_start, ) + b_lu_2 = await addCultivation( + fdm, + principal_id, + b_lu_catalogue_2, + b_id_2, + b_lu_start, + ) }) it("should get cultivations from catalogue", async () => { @@ -142,16 +207,32 @@ 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 of farms from catalogue", async () => { const cultivations = await getCultivationsOfFarmsFromCatalogue( fdm, principal_id, - [b_id_farm], + [b_id_farm, b_id_farm_2], ) - expect(cultivations).toBeDefined() + expect( + cultivations.find( + (cultivation) => + cultivation.b_lu_catalogue === b_lu_catalogue, + ), + ).toBeDefined() + expect( + cultivations.find( + (cultivation) => + cultivation.b_lu_catalogue === b_lu_catalogue_2, + ), + ).toBeDefined() }) it("should add a new cultivation to the catalogue", async () => { From e69e9df0059dc5c5e98a8b0bb05c6cf1e96aad9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Mon, 23 Mar 2026 16:37:09 +0100 Subject: [PATCH 24/48] Change the not technically-correct comment about the use(...) hook --- .../organization.$slug.$calendar.balance.nitrogen._index.tsx | 2 +- ...ganization.$slug.$calendar.balance.organic-matter._index.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 index afb27bc54..17fcdf07a 100644 --- a/fdm-app/app/routes/organization.$slug.$calendar.balance.nitrogen._index.tsx +++ b/fdm-app/app/routes/organization.$slug.$calendar.balance.nitrogen._index.tsx @@ -335,7 +335,7 @@ function OrganizationFarmBalanceNitrogenOverview(loaderData: LoaderData) { const { farms, farmIds, asyncData: asyncDataPromise } = loaderData - // `use` is not a React hook, therefore we can call it conditionally + // Unlike most React hooks `use` may be called conditionally const asyncData = use(asyncDataPromise) const { combinedResult: resolvedNitrogenBalanceResult, farmResults } = 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 index 921f7b152..2560d5f8f 100644 --- 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 @@ -336,7 +336,7 @@ function OrganizationFarmBalanceOrganicMatterOverview(loaderData: LoaderData) { const { farms, farmIds, asyncData: asyncDataPromise } = loaderData - // `use` is not a React hook, therefore we can call it conditionally + // Unlike most React hooks `use` may be called conditionally const asyncData = use(asyncDataPromise) const { combinedResult: resolvedOrganicMatterBalanceResult, farmResults } = From 261d0bedc08df5aa44339716611ba6e64f16ab03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Mon, 23 Mar 2026 16:46:19 +0100 Subject: [PATCH 25/48] fix: dialog does not close when there is no change in the selection of the farms --- .../components/blocks/balance/farm-select-dialog.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/fdm-app/app/components/blocks/balance/farm-select-dialog.tsx b/fdm-app/app/components/blocks/balance/farm-select-dialog.tsx index 5d2ed8db7..a050ee7e8 100644 --- a/fdm-app/app/components/blocks/balance/farm-select-dialog.tsx +++ b/fdm-app/app/components/blocks/balance/farm-select-dialog.tsx @@ -5,6 +5,7 @@ import { Button } from "~/components/ui/button" import { Checkbox } from "~/components/ui/checkbox" import { Dialog, + DialogClose, DialogContent, DialogDescription, DialogFooter, @@ -68,8 +69,8 @@ export function FarmSelectDialog({ })} - + + From 5fe82e014fd0e1f6074d1238b0219bdaf8a22964 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Mon, 23 Mar 2026 16:52:47 +0100 Subject: [PATCH 26/48] Typecheck fix --- fdm-core/src/cultivation.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/fdm-core/src/cultivation.test.ts b/fdm-core/src/cultivation.test.ts index 83e31c002..b5b0cff71 100644 --- a/fdm-core/src/cultivation.test.ts +++ b/fdm-core/src/cultivation.test.ts @@ -39,7 +39,6 @@ describe("Cultivation Data Model", () => { let b_id: string let b_id_2: string let b_lu: string - let b_lu_2: string let b_lu_start: Date let principal_id: string let b_lu_source: string @@ -192,7 +191,7 @@ describe("Cultivation Data Model", () => { b_id, b_lu_start, ) - b_lu_2 = await addCultivation( + await addCultivation( fdm, principal_id, b_lu_catalogue_2, From f4a59049a97812a62933756587e0734ec677a434 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Tue, 24 Mar 2026 10:33:00 +0100 Subject: [PATCH 27/48] Address nitpicks --- .../blocks/balance/farm-select-dialog.tsx | 7 +- ...slug.$calendar.balance.nitrogen._index.tsx | 2 +- ...calendar.balance.organic-matter._index.tsx | 2 +- fdm-calculator/src/balance/nitrogen/input.ts | 42 +++---- .../src/balance/organic-matter/input.ts | 110 +++++++++--------- .../src/balance/shared/errors.test.ts | 80 +++++++++++++ fdm-calculator/src/balance/shared/errors.ts | 31 +++++ fdm-core/src/cultivation.ts | 8 +- 8 files changed, 192 insertions(+), 90 deletions(-) create mode 100644 fdm-calculator/src/balance/shared/errors.test.ts create mode 100644 fdm-calculator/src/balance/shared/errors.ts diff --git a/fdm-app/app/components/blocks/balance/farm-select-dialog.tsx b/fdm-app/app/components/blocks/balance/farm-select-dialog.tsx index a050ee7e8..b5ad0383a 100644 --- a/fdm-app/app/components/blocks/balance/farm-select-dialog.tsx +++ b/fdm-app/app/components/blocks/balance/farm-select-dialog.tsx @@ -86,14 +86,17 @@ export function FarmSelectDialog({ } } } + const sortedDefaultSelectedFarmIds = [ + ...defaultSelectedFarmIds, + ].sort() newlySelectedFarmIds.sort() if ( - defaultSelectedFarmIds.length !== + sortedDefaultSelectedFarmIds.length !== newlySelectedFarmIds.length || newlySelectedFarmIds.some( (selected_id, index) => selected_id !== - defaultSelectedFarmIds[index], + sortedDefaultSelectedFarmIds[index], ) ) { setSearchParams((searchParams) => { 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 index 17fcdf07a..e97dea0cd 100644 --- a/fdm-app/app/routes/organization.$slug.$calendar.balance.nitrogen._index.tsx +++ b/fdm-app/app/routes/organization.$slug.$calendar.balance.nitrogen._index.tsx @@ -299,7 +299,7 @@ export default function FarmBalanceNitrogenOverviewBlock() {

Stikstof

} > 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 index 2560d5f8f..f450cd2df 100644 --- 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 @@ -300,7 +300,7 @@ export default function FarmBalanceOrganicMatterOverviewBlock() { Organische Stof } > diff --git a/fdm-calculator/src/balance/nitrogen/input.ts b/fdm-calculator/src/balance/nitrogen/input.ts index 8c1960ff2..14d9d771f 100644 --- a/fdm-calculator/src/balance/nitrogen/input.ts +++ b/fdm-calculator/src/balance/nitrogen/input.ts @@ -15,6 +15,7 @@ import { 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 { FieldInput, NitrogenBalanceInput } from "./types" @@ -135,12 +136,7 @@ async function collectInputForNitrogenBalanceForFarm( ) }) } catch (error) { - throw new Error( - `Failed to collect field nitrogen balance input for farm ${b_id_farm}: ${ - error instanceof Error ? error.message : String(error) - }`, - { cause: error }, - ) + throw handleNitrogenBalanceInputCollectionError(error, b_id_farm) } } @@ -191,7 +187,7 @@ export async function collectInputForNitrogenBalanceForFarms( try { const onlyFieldInput = await collectInputForNitrogenBalanceForFarm( - fdm, + tx, principal_id, b_id_farm, timeframe, @@ -231,33 +227,16 @@ export async function collectInputForNitrogenBalanceForFarms( 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, + b_id_farm, ) } }), ) }) } catch (error) { - if ( - (error as Error).message?.startsWith( - "Failed to collect field nitrogen balance input for farm", - ) - ) { - throw error - } - // Wrap any errors in a more descriptive error message. - throw new Error( - `Failed to collect nitrogen balance input: ${ - error instanceof Error ? error.message : String(error) - }`, - { cause: error }, - ) + throw handleNitrogenBalanceInputCollectionError(error) } } @@ -273,6 +252,7 @@ export async function collectInputForNitrogenBalanceForFarms( * @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. * @@ -295,3 +275,9 @@ export async function collectInputForNitrogenBalance( ) )[0] } + +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/input.ts b/fdm-calculator/src/balance/organic-matter/input.ts index abe9e6d89..ab57aed51 100644 --- a/fdm-calculator/src/balance/organic-matter/input.ts +++ b/fdm-calculator/src/balance/organic-matter/input.ts @@ -13,6 +13,7 @@ import { getFields, getSoilAnalyses, } from "@nmi-agro/fdm-core" +import { handleInputCollectionError } from "../shared/errors" import type { FieldInput, OrganicMatterBalanceInput } from "./types" /** @@ -105,12 +106,7 @@ async function collectInputForOrganicMatterBalanceForFarm( ) }) } catch (error) { - 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, b_id_farm) } } /** @@ -161,66 +157,62 @@ export async function collectInputForOrganicMatterBalanceForFarms( return await Promise.all( farmIds.map(async (b_id_farm) => { - const onlyFieldInput = - await collectInputForOrganicMatterBalanceForFarm( - fdm, - principal_id, - b_id_farm, - timeframe, - b_id, - ) + try { + const onlyFieldInput = + await collectInputForOrganicMatterBalanceForFarm( + tx, + principal_id, + b_id_farm, + timeframe, + b_id, + ) - // 3. Fetch farm-level catalogue data. - // These details are fetched once for the entire farm and reused for each field. - // Required cultivation and fertilizer details for this farm should be extracted to not break the cache - const cultivationIds = new Set( - onlyFieldInput.flatMap((input) => - input.cultivations.map( - (cultivation) => cultivation.b_lu_catalogue, + // 3. Fetch farm-level catalogue data. + // These details are fetched once for the entire farm and reused for each field. + // Required cultivation and fertilizer details for this farm should be extracted to not break the cache + const cultivationIds = new Set( + onlyFieldInput.flatMap((input) => + input.cultivations.map( + (cultivation) => cultivation.b_lu_catalogue, + ), ), - ), - ) - const cultivationDetailsForThisFarm = - cultivationDetails.filter((cultivation) => - cultivationIds.has(cultivation.b_lu_catalogue), ) + const cultivationDetailsForThisFarm = + cultivationDetails.filter((cultivation) => + cultivationIds.has(cultivation.b_lu_catalogue), + ) - const fertilizerIds = new Set( - onlyFieldInput.flatMap((input) => - input.fertilizerApplications.map((app) => app.p_id), - ), - ) - const fertilizerDetailsForThisFarm = - fertilizerDetails[b_id_farm]?.filter((fert) => - fertilizerIds.has(fert.p_id), - ) ?? [] + const fertilizerIds = new Set( + onlyFieldInput.flatMap((input) => + input.fertilizerApplications.map( + (app) => app.p_id, + ), + ), + ) + const fertilizerDetailsForThisFarm = + fertilizerDetails[b_id_farm]?.filter((fert) => + fertilizerIds.has(fert.p_id), + ) ?? [] - // 4. Assemble the final input object. - return { - b_id_farm: b_id_farm, - fields: onlyFieldInput, - fertilizerDetails: fertilizerDetailsForThisFarm, - cultivationDetails: cultivationDetailsForThisFarm, - timeFrame: timeframe, + // 4. Assemble the final input object. + return { + b_id_farm: b_id_farm, + fields: onlyFieldInput, + fertilizerDetails: fertilizerDetailsForThisFarm, + cultivationDetails: cultivationDetailsForThisFarm, + timeFrame: timeframe, + } + } catch (error) { + throw handleOrganicMatterBalanceInputCollectionError( + error, + b_id_farm, + ) } }), ) }) } catch (error) { - if ( - (error as Error).message?.startsWith( - "Failed to collect organic matter balance input for farm", - ) - ) { - throw error - } - // Wrap any errors in a more descriptive error message. - throw new Error( - `Failed to collect organic matter balance input: ${ - error instanceof Error ? error.message : String(error) - }`, - { cause: error }, - ) + throw handleOrganicMatterBalanceInputCollectionError(error) } } @@ -263,3 +255,9 @@ export async function collectInputForOrganicMatterBalance( ) )[0] } + +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..553f49134 --- /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-core/src/cultivation.ts b/fdm-core/src/cultivation.ts index ad42a2852..3875167fa 100644 --- a/fdm-core/src/cultivation.ts +++ b/fdm-core/src/cultivation.ts @@ -33,6 +33,10 @@ import { import { createId } from "./id" import type { Timeframe } from "./timeframe" +/** Error message which will be replaced if getCultivationsFromCatalogue is rethrowing the error. */ +export const exceptionForGetCultivationsOfFarmsFromCatalogue = + "Exception for getCultivationsOfFarmsFromCatalogue" + /** * Retrieves cultivations available in the enabled catalogues for a farm. * @@ -96,7 +100,7 @@ export async function getCultivationsOfFarmsFromCatalogue( } catch (err) { throw handleError( err, - "Exception for getCultivationsOfFarmsFromCatalogue", + exceptionForGetCultivationsOfFarmsFromCatalogue, { principal_id, farmIds, @@ -117,7 +121,7 @@ export async function getCultivationsFromCatalogue( } catch (err) { if ( (err as Error)?.message?.startsWith( - "Exception for getCultivationsOfFarmsFromCatalogue", + exceptionForGetCultivationsOfFarmsFromCatalogue, ) ) { const handledError = err as Error From 7f15a84ef3bfabca62fb32073f453bdd9d813f46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Tue, 24 Mar 2026 10:43:35 +0100 Subject: [PATCH 28/48] Sure --- fdm-calculator/src/balance/shared/errors.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fdm-calculator/src/balance/shared/errors.test.ts b/fdm-calculator/src/balance/shared/errors.test.ts index 553f49134..f5e9c17b7 100644 --- a/fdm-calculator/src/balance/shared/errors.test.ts +++ b/fdm-calculator/src/balance/shared/errors.test.ts @@ -72,7 +72,7 @@ describe("handleInputCollectionError", () => { } catch (error) { const wrapped = handleNutrientBalanceInputCollectionError(error) expect(wrapped.message).toEqual( - `Failed to collect nutrient balance input: ${null}`, + "Failed to collect nutrient balance input: null", ) expect(wrapped.cause).toEqual(null) } From 3ed182da3c87bcb376bbd9b2a0885bd3428793ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Thu, 26 Mar 2026 17:30:05 +0100 Subject: [PATCH 29/48] Replace getFertilizersOfFarms with getFertilizersFromCatalogueForFarms --- .../src/balance/nitrogen/input.test.ts | 45 +-- fdm-calculator/src/balance/nitrogen/input.ts | 9 +- .../src/balance/organic-matter/input.test.ts | 58 ++-- .../src/balance/organic-matter/input.ts | 9 +- fdm-core/src/bulk.ts | 24 ++ fdm-core/src/fertilizer.d.ts | 15 +- fdm-core/src/fertilizer.test.ts | 51 ++- fdm-core/src/fertilizer.ts | 300 +++++++++++------- fdm-core/src/index.ts | 3 +- 9 files changed, 338 insertions(+), 176 deletions(-) create mode 100644 fdm-core/src/bulk.ts diff --git a/fdm-calculator/src/balance/nitrogen/input.test.ts b/fdm-calculator/src/balance/nitrogen/input.test.ts index 7763809b3..d095385dc 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, @@ -14,7 +14,7 @@ import { getCultivations, getCultivationsOfFarmsFromCatalogue, getFertilizerApplications, - getFertilizersOfFarms, + getFertilizersFromCatalogueForFarms, getFields, getHarvests, getSoilAnalyses, @@ -38,7 +38,7 @@ vi.mock("@nmi-agro/fdm-core", async () => { getHarvests: vi.fn(), getSoilAnalyses: vi.fn(), getFertilizerApplications: vi.fn(), - getFertilizersOfFarms: vi.fn(), + getFertilizersFromCatalogueForFarms: vi.fn(), getCultivationsFromCatalogue: vi.fn(), getCultivationsOfFarmsFromCatalogue: vi.fn(), } @@ -55,7 +55,9 @@ const mockedGetCultivations = vi.mocked(getCultivations) const mockedGetHarvests = vi.mocked(getHarvests) const mockedGetSoilAnalyses = vi.mocked(getSoilAnalyses) const mockedGetFertilizerApplications = vi.mocked(getFertilizerApplications) -const mockedGetFertilizersOfFarms = vi.mocked(getFertilizersOfFarms) +const mockedGetFertilizersFromCatalogueForFarms = vi.mocked( + getFertilizersFromCatalogueForFarms, +) const mockedCalculateAllFieldsNitrogenSupplyByDeposition = vi.mocked( calculateAllFieldsNitrogenSupplyByDeposition, ) @@ -169,28 +171,28 @@ function createMockData() { 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: "fert-cat-1", + p_id: "fert-1", }, ] as FertilizerApplication[], mockFertilizerApplicationsData2: [ { p_app_id: "fa-2", - p_id_catalogue: "fert-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-cat-2", + 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, @@ -198,10 +200,10 @@ function createMockData() { p_s_rt: 0, p_ef_nh3: 0.1, }, - ] as Fertilizer[], + ] as FertilizerCatalogue[], mockFertilizerDetailsData2: [ { - p_id: "fert-cat-2", + p_id_catalogue: "fert-cat-2", p_n_rt: 5, p_type: "manure", p_no3_rt: 1, @@ -209,7 +211,7 @@ function createMockData() { p_s_rt: 0, p_ef_nh3: 0.1, }, - ] as Fertilizer[], + ] as FertilizerCatalogue[], mockCultivationDetailsData: [ { b_lu_catalogue: "cat-cult-1", @@ -279,9 +281,8 @@ describe("collectInputForNitrogenBalance", () => { ) const allFertilizerDetails = mockFertilizerDetailsData.map((fert) => ({ ...fert, - b_id_farm: "test-farm-id", })) - mockedGetFertilizersOfFarms.mockResolvedValue({ + mockedGetFertilizersFromCatalogueForFarms.mockResolvedValue({ [b_id_farm]: allFertilizerDetails, }) mockedGetCultivationsOfFarmsFromCatalogue.mockResolvedValue( @@ -357,7 +358,7 @@ describe("collectInputForNitrogenBalance", () => { timeframe, ) } - expect(mockedGetFertilizersOfFarms).toHaveBeenCalledWith( + expect(mockedGetFertilizersFromCatalogueForFarms).toHaveBeenCalledWith( mockFdm, principal_id, [b_id_farm], @@ -437,7 +438,7 @@ describe("collectInputForNitrogenBalance", () => { it("should handle empty arrays from core functions correctly", async () => { mockedGetFields.mockResolvedValue([]) - mockedGetFertilizersOfFarms.mockResolvedValue({}) + mockedGetFertilizersFromCatalogueForFarms.mockResolvedValue({}) mockedGetCultivationsOfFarmsFromCatalogue.mockResolvedValue([]) const result = await collectInputForNitrogenBalance( @@ -462,7 +463,7 @@ describe("collectInputForNitrogenBalance", () => { b_id_farm, timeframe, ) - expect(mockedGetFertilizersOfFarms).toHaveBeenCalledWith( + expect(mockedGetFertilizersFromCatalogueForFarms).toHaveBeenCalledWith( mockFdm, principal_id, [b_id_farm], @@ -548,7 +549,9 @@ describe("collectInputForNitrogenBalanceForFarms", () => { "test-farm-id": fertData1, "test-farm-id-2": fertData2, } - mockedGetFertilizersOfFarms.mockResolvedValue(allFertilizerDetails) + mockedGetFertilizersFromCatalogueForFarms.mockResolvedValue( + allFertilizerDetails, + ) const combinedCultivationDetails = [ ...mockCultivationDetailsData, ...mockCultivationDetailsData2, @@ -627,11 +630,13 @@ describe("collectInputForNitrogenBalanceForFarms", () => { expect(mockedGetCultivationsOfFarmsFromCatalogue).toHaveBeenCalledTimes( 1, ) - expect(mockedGetFertilizersOfFarms).toHaveBeenCalledWith( + expect(mockedGetFertilizersFromCatalogueForFarms).toHaveBeenCalledWith( mockFdm, principal_id, ["test-farm-id", "test-farm-id-2"], ) - expect(mockedGetFertilizersOfFarms).toHaveBeenCalledTimes(1) + expect(mockedGetFertilizersFromCatalogueForFarms).toHaveBeenCalledTimes( + 1, + ) }) }) diff --git a/fdm-calculator/src/balance/nitrogen/input.ts b/fdm-calculator/src/balance/nitrogen/input.ts index 14d9d771f..986acb04d 100644 --- a/fdm-calculator/src/balance/nitrogen/input.ts +++ b/fdm-calculator/src/balance/nitrogen/input.ts @@ -8,7 +8,7 @@ import { getCultivations, getCultivationsOfFarmsFromCatalogue, getFertilizerApplications, - getFertilizersOfFarms, + getFertilizersFromCatalogueForFarms, getField, getFields, getHarvests, @@ -176,7 +176,7 @@ export async function collectInputForNitrogenBalanceForFarms( principal_id, farmIds, ) - const fertilizerDetails = await getFertilizersOfFarms( + const fertilizerDetails = await getFertilizersFromCatalogueForFarms( tx, principal_id, farmIds, @@ -210,13 +210,14 @@ export async function collectInputForNitrogenBalanceForFarms( const fertilizerIds = new Set( onlyFieldInput.flatMap((input) => input.fertilizerApplications.map( - (app) => app.p_id, + (app) => app.p_id_catalogue, ), ), ) + const fertilizerDetailsForThisFarm = fertilizerDetails[b_id_farm]?.filter((fert) => - fertilizerIds.has(fert.p_id), + fertilizerIds.has(fert.p_id_catalogue), ) ?? [] return { diff --git a/fdm-calculator/src/balance/organic-matter/input.test.ts b/fdm-calculator/src/balance/organic-matter/input.test.ts index caf112380..da25ef4b2 100644 --- a/fdm-calculator/src/balance/organic-matter/input.test.ts +++ b/fdm-calculator/src/balance/organic-matter/input.test.ts @@ -2,8 +2,8 @@ import type { Cultivation, CultivationCatalogue, FdmType, - Fertilizer, FertilizerApplication, + FertilizerCatalogue, Field, PrincipalId, SoilAnalysis, @@ -26,7 +26,7 @@ vi.mock("@nmi-agro/fdm-core", async () => { getHarvests: vi.fn(), getSoilAnalyses: vi.fn(), getFertilizerApplications: vi.fn(), - getFertilizersOfFarms: vi.fn(), + getFertilizersFromCatalogueForFarms: vi.fn(), getCultivationsOfFarmsFromCatalogue: vi.fn(), } }) @@ -126,28 +126,28 @@ function createMockData() { 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: "fert-cat-1", + p_id: "fert-1", }, ] as FertilizerApplication[], mockFertilizerApplicationsData2: [ { p_app_id: "fa-2", - p_id_catalogue: "fert-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-cat-2", + 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, @@ -155,10 +155,10 @@ function createMockData() { p_s_rt: 0, p_ef_nh3: 0.1, }, - ] as Fertilizer[], + ] as FertilizerCatalogue[], mockFertilizerDetailsData2: [ { - p_id: "fert-cat-2", + p_id_catalogue: "fert-cat-2", p_n_rt: 5, p_type: "manure", p_no3_rt: 1, @@ -166,7 +166,7 @@ function createMockData() { p_s_rt: 0, p_ef_nh3: 0.1, }, - ] as Fertilizer[], + ] as FertilizerCatalogue[], mockCultivationDetailsData: [ { b_lu_catalogue: "cat-cult-1", @@ -220,7 +220,10 @@ describe("collectInputForOrganicMatterBalance", () => { vi.spyOn(fdmCore, "getFertilizerApplications").mockResolvedValue( mockFertilizerApplicationsData, ) - vi.spyOn(fdmCore, "getFertilizersOfFarms").mockResolvedValue({ + vi.spyOn( + fdmCore, + "getFertilizersFromCatalogueForFarms", + ).mockResolvedValue({ [b_id_farm]: mockFertilizerDetailsData, }) vi.spyOn( @@ -293,12 +296,15 @@ describe("collectInputForOrganicMatterBalance", () => { it("should correctly structure the output", async () => { const mockField = { b_id: "field1" } const mockCultivation = { b_lu: "cult1" } - const mockFertilizer = { p_id: "fert1", 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, "getFertilizersOfFarms").mockResolvedValue({ + vi.spyOn( + fdmCore, + "getFertilizersFromCatalogueForFarms", + ).mockResolvedValue({ [b_id_farm]: [mockFertilizer], } as any) vi.spyOn( @@ -308,7 +314,7 @@ describe("collectInputForOrganicMatterBalance", () => { vi.spyOn(fdmCore, "getHarvests").mockResolvedValue([]) vi.spyOn(fdmCore, "getSoilAnalyses").mockResolvedValue([]) vi.spyOn(fdmCore, "getFertilizerApplications").mockResolvedValue([ - { p_id: "fert1" } as any, + { p_id_catalogue: "fert-cat-1" } as any, ]) const result = await collectInputForOrganicMatterBalance( @@ -322,7 +328,7 @@ 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") }) }) @@ -397,9 +403,10 @@ describe("collectInputForOrganicMatterBalanceForFarms", () => { "test-farm-id": fertData1, "test-farm-id-2": fertData2, } - vi.spyOn(fdmCore, "getFertilizersOfFarms").mockResolvedValue( - allFertilizerDetails, - ) + vi.spyOn( + fdmCore, + "getFertilizersFromCatalogueForFarms", + ).mockResolvedValue(allFertilizerDetails) const combinedCultivationDetails = [ ...mockCultivationDetailsData, ...mockCultivationDetailsData2, @@ -473,11 +480,14 @@ describe("collectInputForOrganicMatterBalanceForFarms", () => { expect( fdmCore.getCultivationsOfFarmsFromCatalogue, ).toHaveBeenCalledTimes(1) - expect(fdmCore.getFertilizersOfFarms).toHaveBeenCalledWith( - mockFdm, - principal_id, - ["test-farm-id", "test-farm-id-2"], - ) - expect(fdmCore.getFertilizersOfFarms).toHaveBeenCalledTimes(1) + expect( + fdmCore.getFertilizersFromCatalogueForFarms, + ).toHaveBeenCalledWith(mockFdm, principal_id, [ + "test-farm-id", + "test-farm-id-2", + ]) + expect( + fdmCore.getFertilizersFromCatalogueForFarms, + ).toHaveBeenCalledTimes(1) }) }) diff --git a/fdm-calculator/src/balance/organic-matter/input.ts b/fdm-calculator/src/balance/organic-matter/input.ts index ab57aed51..86843add0 100644 --- a/fdm-calculator/src/balance/organic-matter/input.ts +++ b/fdm-calculator/src/balance/organic-matter/input.ts @@ -8,7 +8,7 @@ import { getCultivations, getCultivationsOfFarmsFromCatalogue, getFertilizerApplications, - getFertilizersOfFarms, + getFertilizersFromCatalogueForFarms, getField, getFields, getSoilAnalyses, @@ -149,7 +149,7 @@ export async function collectInputForOrganicMatterBalanceForFarms( principal_id, farmIds, ) - const fertilizerDetails = await getFertilizersOfFarms( + const fertilizerDetails = await getFertilizersFromCatalogueForFarms( tx, principal_id, farmIds, @@ -185,13 +185,14 @@ export async function collectInputForOrganicMatterBalanceForFarms( const fertilizerIds = new Set( onlyFieldInput.flatMap((input) => input.fertilizerApplications.map( - (app) => app.p_id, + (app) => app.p_id_catalogue, ), ), ) + const fertilizerDetailsForThisFarm = fertilizerDetails[b_id_farm]?.filter((fert) => - fertilizerIds.has(fert.p_id), + fertilizerIds.has(fert.p_id_catalogue), ) ?? [] // 4. Assemble the final input object. diff --git a/fdm-core/src/bulk.ts b/fdm-core/src/bulk.ts new file mode 100644 index 000000000..29188cc30 --- /dev/null +++ b/fdm-core/src/bulk.ts @@ -0,0 +1,24 @@ +export function splitBy( + items: T[], + fn: (obj: T) => string, +): Record { + const result: Record = {} + if (items && items.length > 0) { + let start = 0 + let end = 0 + while (end < items.length) { + const currentKey = fn(items[start]) + if (result[currentKey]) { + throw new Error( + `Key "${currentKey}" has been encountered twice`, + ) + } + for (; end < items.length; end++) { + if (fn(items[end]) !== currentKey) break + } + result[currentKey] = items.slice(start, end) + start = end + } + } + return result +} 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 868020235..f499ffa3a 100644 --- a/fdm-core/src/fertilizer.test.ts +++ b/fdm-core/src/fertilizer.test.ts @@ -11,6 +11,7 @@ import { disableFertilizerCatalogue, enableFertilizerCatalogue, } from "./catalogues" +import * as schema from "./db/schema" import { applicationMethodOptions, fertilizersCatalogue } from "./db/schema" import { addFarm } from "./farm" import { createFdmServer } from "./fdm-server" @@ -25,7 +26,7 @@ import { getFertilizerParametersDescription, getFertilizers, getFertilizersFromCatalogue, - getFertilizersOfFarms, + getFertilizersFromCatalogueForFarms, removeFertilizer, removeFertilizerApplication, updateFertilizerApplication, @@ -339,7 +340,7 @@ describe("Fertilizer Data Model", () => { expect(fertilizers.length).toBe(2) }) - it("should get fertilizers for multiple farm IDs", async () => { + it("should get fertilizers from a list of farms", async () => { function makeFertilizer(name: string) { const fert: Partial< Parameters[3] @@ -419,10 +420,11 @@ describe("Fertilizer Data Model", () => { makeFertilizer("Farm 2 Example Fertilizer 2"), ) await addTestFertilizer(b_id_farm_2, farm_2_fert_2) - const map = await getFertilizersOfFarms(fdm, principal_id, [ - b_id_farm, - b_id_farm_2, - ]) + const map = await getFertilizersFromCatalogueForFarms( + fdm, + principal_id, + [b_id_farm, b_id_farm_2], + ) expect(map[b_id_farm]).toBeDefined() expect(map[b_id_farm].map((fert) => fert.p_name_nl)).toEqual([ "Farm 1 Example Fertilizer 1", @@ -435,12 +437,41 @@ describe("Fertilizer Data Model", () => { ]) }) - it("(getFertilizers) should rename the error if getFertilizersOfFarms throws an error", async () => { - const invalidFarmId = createId() + function mockFdmThatThrowsOnSelectionFromFertilizersCatalogue() { + return { + ...fdm, + select(...args: []) { + return { + ...fdm.select(...args), + from(table: typeof schema.fertilizersCatalogue) { + if (table !== schema.fertilizersCatalogue) { + return fdm.select().from(table) + } + throw new Error("Error querying the database") + }, + } as unknown + }, + } as typeof fdm + } + + it("(getFertilizersFromCatalogue) should rename the error if getFertilizersFromCatalogues throws an error", async () => { + expect( + getFertilizersFromCatalogue( + mockFdmThatThrowsOnSelectionFromFertilizersCatalogue(), + principal_id, + b_id_farm, + ), + ).rejects.not.toThrow("Exception for getFertilizersFromCatalogues") + }) + it("(getFertilizersFromCatalogueForFarms) should rename the error if getFertilizersFromCatalogues throws an error", async () => { expect( - getFertilizers(fdm, principal_id, invalidFarmId), - ).rejects.not.toThrowError("Exception for getFertilizers") + getFertilizersFromCatalogueForFarms( + mockFdmThatThrowsOnSelectionFromFertilizersCatalogue(), + principal_id, + [b_id_farm], + ), + ).rejects.not.toThrow("Exception for getFertilizersFromCatalogues") }) it("should remove a fertilizer", async () => { diff --git a/fdm-core/src/fertilizer.ts b/fdm-core/src/fertilizer.ts index 3ea4923bb..6667e4e9c 100644 --- a/fdm-core/src/fertilizer.ts +++ b/fdm-core/src/fertilizer.ts @@ -1,16 +1,19 @@ import { + type ApplicationMethods, type CatalogueFertilizerItem, hashFertilizer, } from "@nmi-agro/fdm-data" import { and, asc, desc, eq, gte, inArray, lte } from "drizzle-orm" import { checkPermission } from "./authorization" import type { PrincipalId } from "./authorization.d" +import { splitBy } from "./bulk" import * as schema from "./db/schema" import { handleError } from "./error" import type { FdmType } from "./fdm" import type { Fertilizer, FertilizerApplication, + FertilizerCatalogue, FertilizerParameterDescription, } from "./fertilizer.d" import { createId } from "./id" @@ -29,7 +32,7 @@ export async function getFertilizersFromCatalogue( fdm: FdmType, principal_id: PrincipalId, b_id_farm: schema.farmsTypeSelect["b_id_farm"], -): Promise { +): Promise { try { await checkPermission( fdm, @@ -41,37 +44,194 @@ export async function getFertilizersFromCatalogue( ) // Get enabled catalogues for the farm - const enabledCatalogues = await fdm + const enabledCatalogues: Pick< + schema.fertilizerCatalogueEnablingTypeSelect, + "p_source" + >[] = await fdm .select({ p_source: schema.fertilizerCatalogueEnabling.p_source, }) .from(schema.fertilizerCatalogueEnabling) .where(eq(schema.fertilizerCatalogueEnabling.b_id_farm, b_id_farm)) + const result = await getFertilizersFromCatalogues( + fdm, + enabledCatalogues.map(({ p_source }) => p_source), + ) + + return result[b_id_farm] ?? [] + } catch (err) { + throw handleError( + err instanceof Error && + err.message === + "Exception for getFertilizersFromCatalogueForFarms" + ? err.cause + : err, + "Exception for getFertilizersFromCatalogue", + { + principal_id, + b_id_farm, + }, + ) + } +} + +/** + * Retrieves all fertilizers from the enabled 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 with a map from b_id_farm to an array of catalogue fertilizers. + * @alpha + */ +export async function getFertilizersFromCatalogueForFarms( + fdm: FdmType, + principal_id: PrincipalId, + farmIds: string[], +): Promise< + Record< + schema.fertilizerCatalogueEnablingTypeSelect["b_id_farm"], + FertilizerCatalogue[] + > +> { + try { + // Check read permission for all of the farms + await Promise.all( + farmIds.map((b_id_farm) => + checkPermission( + fdm, + "farm", + "read", + b_id_farm, + principal_id, + "getFertilizersFromCatalogue", + ), + ), + ) + + // Get enabled catalogues for the farms + const allEnabledCatalogues: schema.fertilizerCatalogueEnablingTypeSelect[] = + 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, + ), + ) + .orderBy( + asc(schema.fertilizerCatalogueEnabling.b_id_farm), + asc(schema.fertilizerCatalogueEnabling.p_source), + ) + + const enabledCatalogues = [ + ...new Set(allEnabledCatalogues.map(({ p_source }) => p_source)), + ] + + const enabledCataloguesByFarm = splitBy( + allEnabledCatalogues, + ({ b_id_farm }) => b_id_farm, + ) + + const catalogues = await getFertilizersFromCatalogues( + fdm, + enabledCatalogues, + ) + + // Join each farm's enabled catalogues together + return Object.fromEntries( + Object.entries(enabledCataloguesByFarm).map( + ([b_id_farm, enabledCatalogues]) => [ + b_id_farm, + enabledCatalogues.flatMap( + ({ p_source }) => catalogues[p_source] ?? [], + ), + ], + ), + ) + } catch (err) { + throw handleError( + err instanceof Error && + err.message === + "Exception for getFertilizersFromCatalogueForFarms" + ? err.cause + : err, + "Exception for getFertilizersFromCatalogueForFarms", + { + principal_id, + farmIds, + }, + ) + } +} + +/** + * Retrieves all fertilizers from the list catalogues whose IDs are given. + * + * No permission checks are performed. This can change in the future if a permission system specific to the catalogue is implemented. + * + * @param fdm The FDM instance providing the connection to the database. The instance can be created with {@link createFdmServer}. + * @param catalogueIds Catalogues IDs to retrieve. These can be, for example, catalogue IDs found in fdm-data such as "baat", or farm IDs. + */ +export async function getFertilizersFromCatalogues( + fdm: FdmType, + catalogueIds: schema.fertilizersCatalogueTypeSelect["p_source"][], +): Promise> { + try { // If no catalogues are enabled, return empty array - if (enabledCatalogues.length === 0) { - return [] + if (catalogueIds.length === 0) { + return {} } // Get fertilizers from enabled catalogues - const fertilizersCatalogue = await fdm - .select() - .from(schema.fertilizersCatalogue) - .where( - inArray( - schema.fertilizersCatalogue.p_source, - enabledCatalogues.map( - (c: { p_source: string }) => c.p_source, - ), - ), - ) - .orderBy(asc(schema.fertilizersCatalogue.p_name_nl)) + const fertilizersCatalogue: schema.fertilizersCatalogueTypeSelect[] = + await fdm + .select() + .from(schema.fertilizersCatalogue) + .where( + inArray(schema.fertilizersCatalogue.p_source, catalogueIds), + ) + .orderBy( + asc(schema.fertilizersCatalogue.p_source), + asc(schema.fertilizersCatalogue.p_name_nl), + ) + + const fertilizersCatalogueExtended = fertilizersCatalogue.map( + (result) => { + 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_app_method_options: result.p_app_method_options as + | ApplicationMethods[] + | null, + p_type: p_type, + } + }, + ) - return fertilizersCatalogue + return splitBy( + fertilizersCatalogueExtended, + (fertilizer) => fertilizer.p_source, + ) } catch (err) { - throw handleError(err, "Exception for getFertilizersFromCatalogue", { - principal_id, - b_id_farm, + throw handleError(err, "Exception for getFertilizersFromCatalogues", { + catalogueIds, }) } } @@ -538,61 +698,13 @@ export async function getFertilizers( b_id_farm: schema.fertilizerAcquiringTypeSelect["b_id_farm"], ) { try { - return ( - (await getFertilizersOfFarms(fdm, principal_id, [b_id_farm]))[ - b_id_farm - ] ?? [] - ) - } catch (err) { - if ( - (err as Error)?.message?.startsWith( - "Exception for getFertilizersOfFarms", - ) - ) { - throw handleError( - (err as Error).cause, - "Exception for getFertilizers", - { - b_id_farm, - }, - ) - } - - throw err - } -} - -/** - * Retrieves fertilizer details for a specified farm. - * - * This function verifies that the requesting principal has read access to the farm, - * then queries the database to return a list of fertilizers along with their catalogue - * and application details. - * - * @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 b_id_farm - The ID of the farm for which the fertilizers are retrieved. - * @returns A promise that resolves with an array of fertilizer detail objects. - * - * @alpha - */ -export async function getFertilizersOfFarms( - fdm: FdmType, - principal_id: PrincipalId, - farmIds: schema.fertilizerAcquiringTypeSelect["b_id_farm"][], -): Promise> { - try { - await Promise.all( - farmIds.map((b_id_farm) => - checkPermission( - fdm, - "farm", - "read", - b_id_farm, - principal_id, - "getFertilizers", - ), - ), + await checkPermission( + fdm, + "farm", + "read", + b_id_farm, + principal_id, + "getFertilizers", ) const fertilizers = await fdm @@ -674,13 +786,10 @@ export async function getFertilizersOfFarms( schema.fertilizersCatalogue.p_id_catalogue, ), ) - .where(inArray(schema.fertilizerAcquiring.b_id_farm, farmIds)) - .orderBy( - asc(schema.fertilizerAcquiring.b_id_farm), - asc(schema.fertilizersCatalogue.p_name_nl), - ) + .where(eq(schema.fertilizerAcquiring.b_id_farm, b_id_farm)) + .orderBy(asc(schema.fertilizersCatalogue.p_name_nl)) - const res = fertilizers.map((f: (typeof fertilizers)[number]) => { + 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) @@ -699,34 +808,9 @@ export async function getFertilizersOfFarms( p_type: p_type, } }) - - // Chunk the query result array up for each fertilizer. - const fertilizersMap: Record = {} - if (res && res.length > 0) { - let fertilizerStart = 0 - let fertilizerEnd = 0 - while (fertilizerEnd < res.length) { - const b_id_farm = res[fertilizerStart].b_id_farm as string - for (; fertilizerEnd < res.length; fertilizerEnd++) { - if (res[fertilizerEnd].b_id_farm !== b_id_farm) break - } - fertilizersMap[b_id_farm] = res.slice( - fertilizerStart, - fertilizerEnd, - ) - fertilizerStart = fertilizerEnd - } - } - - // Add empty arrays for farms with no fertilizers to be consistent with the old `getFertilizers` implementation - for (const b_id_farm of farmIds) { - fertilizersMap[b_id_farm] ??= [] - } - - return fertilizersMap } catch (err) { - throw handleError(err, "Exception for getFertilizersOfFarms", { - farmIds, + throw handleError(err, "Exception for getFertilizers", { + b_id_farm, }) } } diff --git a/fdm-core/src/index.ts b/fdm-core/src/index.ts index f7b434928..17856b99a 100644 --- a/fdm-core/src/index.ts +++ b/fdm-core/src/index.ts @@ -97,8 +97,8 @@ export { getFertilizerApplications, getFertilizerParametersDescription, getFertilizers, - getFertilizersOfFarms, getFertilizersFromCatalogue, + getFertilizersFromCatalogueForFarms, removeFertilizer, removeFertilizerApplication, updateFertilizerApplication, @@ -107,6 +107,7 @@ export { export type { Fertilizer, FertilizerApplication, + FertilizerCatalogue, FertilizerParameterDescription, FertilizerParameterDescriptionItem, FertilizerParameters, From 4e048750df7f59468ecb7bbeb6e44fa51b120ee2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Fri, 27 Mar 2026 08:41:15 +0100 Subject: [PATCH 30/48] Make fertilizer type derivation logic shared --- fdm-core/src/fertilizer.ts | 75 ++++++++++++++++---------------------- 1 file changed, 32 insertions(+), 43 deletions(-) diff --git a/fdm-core/src/fertilizer.ts b/fdm-core/src/fertilizer.ts index 6667e4e9c..0c2763cf4 100644 --- a/fdm-core/src/fertilizer.ts +++ b/fdm-core/src/fertilizer.ts @@ -54,12 +54,14 @@ export async function getFertilizersFromCatalogue( .from(schema.fertilizerCatalogueEnabling) .where(eq(schema.fertilizerCatalogueEnabling.b_id_farm, b_id_farm)) - const result = await getFertilizersFromCatalogues( + const catalogues = await getFertilizersFromCatalogues( fdm, enabledCatalogues.map(({ p_source }) => p_source), ) - return result[b_id_farm] ?? [] + return enabledCatalogues.flatMap( + ({ p_source }) => catalogues[p_source] ?? [], + ) } catch (err) { throw handleError( err instanceof Error && @@ -203,24 +205,12 @@ export async function getFertilizersFromCatalogues( const fertilizersCatalogueExtended = fertilizersCatalogue.map( (result) => { - 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_app_method_options: result.p_app_method_options as | ApplicationMethods[] | null, - p_type: p_type, + p_type: deriveFertilizerType(result), } }, ) @@ -516,22 +506,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", { @@ -790,22 +767,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) { @@ -1531,3 +1495,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 +} From 558a32febb0203dc6a34049dbcb8210f1f75f070 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Fri, 27 Mar 2026 08:42:38 +0100 Subject: [PATCH 31/48] Nitpicks --- fdm-calculator/src/balance/organic-matter/input.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/fdm-calculator/src/balance/organic-matter/input.test.ts b/fdm-calculator/src/balance/organic-matter/input.test.ts index da25ef4b2..51a3f54be 100644 --- a/fdm-calculator/src/balance/organic-matter/input.test.ts +++ b/fdm-calculator/src/balance/organic-matter/input.test.ts @@ -255,7 +255,10 @@ 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, + "getFertilizersFromCatalogueForFarms", + ).mockResolvedValue({}) vi.spyOn( fdmCore, "getCultivationsOfFarmsFromCatalogue", From 3fac2036549c58dbd84200c6535afe34496775f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Fri, 27 Mar 2026 10:12:50 +0100 Subject: [PATCH 32/48] Replace getCultivationsOfFarmsFromCatalogue with getCultivationsFromCatalogueForFarms --- .../src/balance/nitrogen/input.test.ts | 39 ++-- fdm-calculator/src/balance/nitrogen/input.ts | 13 +- .../src/balance/organic-matter/input.test.ts | 29 ++- .../src/balance/organic-matter/input.ts | 13 +- fdm-core/src/cultivation.test.ts | 49 ++++- fdm-core/src/cultivation.ts | 178 +++++++++++++----- fdm-core/src/index.ts | 2 +- 7 files changed, 224 insertions(+), 99 deletions(-) diff --git a/fdm-calculator/src/balance/nitrogen/input.test.ts b/fdm-calculator/src/balance/nitrogen/input.test.ts index d095385dc..60da4638f 100644 --- a/fdm-calculator/src/balance/nitrogen/input.test.ts +++ b/fdm-calculator/src/balance/nitrogen/input.test.ts @@ -12,7 +12,7 @@ import type { } from "@nmi-agro/fdm-core" import { getCultivations, - getCultivationsOfFarmsFromCatalogue, + getCultivationsFromCatalogueForFarms, getFertilizerApplications, getFertilizersFromCatalogueForFarms, getFields, @@ -40,7 +40,7 @@ vi.mock("@nmi-agro/fdm-core", async () => { getFertilizerApplications: vi.fn(), getFertilizersFromCatalogueForFarms: vi.fn(), getCultivationsFromCatalogue: vi.fn(), - getCultivationsOfFarmsFromCatalogue: vi.fn(), + getCultivationsFromCatalogueForFarms: vi.fn(), } }) @@ -61,8 +61,8 @@ const mockedGetFertilizersFromCatalogueForFarms = vi.mocked( const mockedCalculateAllFieldsNitrogenSupplyByDeposition = vi.mocked( calculateAllFieldsNitrogenSupplyByDeposition, ) -const mockedGetCultivationsOfFarmsFromCatalogue = vi.mocked( - getCultivationsOfFarmsFromCatalogue, +const mockedGetCultivationsFromCatalogueForFarms = vi.mocked( + getCultivationsFromCatalogueForFarms, ) function createMockData() { @@ -285,9 +285,9 @@ describe("collectInputForNitrogenBalance", () => { mockedGetFertilizersFromCatalogueForFarms.mockResolvedValue({ [b_id_farm]: allFertilizerDetails, }) - mockedGetCultivationsOfFarmsFromCatalogue.mockResolvedValue( - mockCultivationDetailsData, - ) + mockedGetCultivationsFromCatalogueForFarms.mockResolvedValue({ + [b_id_farm]: mockCultivationDetailsData, + }) mockedCalculateAllFieldsNitrogenSupplyByDeposition.mockResolvedValue( mockDepositionSupplyMap, ) @@ -363,7 +363,7 @@ describe("collectInputForNitrogenBalance", () => { principal_id, [b_id_farm], ) - expect(mockedGetCultivationsOfFarmsFromCatalogue).toHaveBeenCalledWith( + expect(mockedGetCultivationsFromCatalogueForFarms).toHaveBeenCalledWith( mockFdm, principal_id, [b_id_farm], @@ -439,7 +439,7 @@ describe("collectInputForNitrogenBalance", () => { it("should handle empty arrays from core functions correctly", async () => { mockedGetFields.mockResolvedValue([]) mockedGetFertilizersFromCatalogueForFarms.mockResolvedValue({}) - mockedGetCultivationsOfFarmsFromCatalogue.mockResolvedValue([]) + mockedGetCultivationsFromCatalogueForFarms.mockResolvedValue({}) const result = await collectInputForNitrogenBalance( mockFdm, @@ -468,7 +468,7 @@ describe("collectInputForNitrogenBalance", () => { principal_id, [b_id_farm], ) - expect(mockedGetCultivationsOfFarmsFromCatalogue).toHaveBeenCalledWith( + expect(mockedGetCultivationsFromCatalogueForFarms).toHaveBeenCalledWith( mockFdm, principal_id, [b_id_farm], @@ -552,13 +552,10 @@ describe("collectInputForNitrogenBalanceForFarms", () => { mockedGetFertilizersFromCatalogueForFarms.mockResolvedValue( allFertilizerDetails, ) - const combinedCultivationDetails = [ - ...mockCultivationDetailsData, - ...mockCultivationDetailsData2, - ] - mockedGetCultivationsOfFarmsFromCatalogue.mockResolvedValue( - combinedCultivationDetails, - ) + mockedGetCultivationsFromCatalogueForFarms.mockResolvedValue({ + "test-farm-id": mockCultivationDetailsData, + "test-farm-id-2": mockCultivationDetailsData2, + }) mockedCalculateAllFieldsNitrogenSupplyByDeposition.mockResolvedValue( mockDepositionSupplyMap, ) @@ -622,14 +619,14 @@ describe("collectInputForNitrogenBalanceForFarms", () => { expect(result).toEqual(expectedResult) - expect(mockedGetCultivationsOfFarmsFromCatalogue).toHaveBeenCalledWith( + expect(mockedGetCultivationsFromCatalogueForFarms).toHaveBeenCalledWith( mockFdm, principal_id, ["test-farm-id", "test-farm-id-2"], ) - expect(mockedGetCultivationsOfFarmsFromCatalogue).toHaveBeenCalledTimes( - 1, - ) + expect( + mockedGetCultivationsFromCatalogueForFarms, + ).toHaveBeenCalledTimes(1) expect(mockedGetFertilizersFromCatalogueForFarms).toHaveBeenCalledWith( mockFdm, principal_id, diff --git a/fdm-calculator/src/balance/nitrogen/input.ts b/fdm-calculator/src/balance/nitrogen/input.ts index 986acb04d..d37cc76c9 100644 --- a/fdm-calculator/src/balance/nitrogen/input.ts +++ b/fdm-calculator/src/balance/nitrogen/input.ts @@ -6,7 +6,7 @@ import type { } from "@nmi-agro/fdm-core" import { getCultivations, - getCultivationsOfFarmsFromCatalogue, + getCultivationsFromCatalogueForFarms, getFertilizerApplications, getFertilizersFromCatalogueForFarms, getField, @@ -171,7 +171,7 @@ export async function collectInputForNitrogenBalanceForFarms( return await fdm.transaction(async (tx: FdmType) => { // Collect the details of the cultivations const cultivationDetails = - await getCultivationsOfFarmsFromCatalogue( + await getCultivationsFromCatalogueForFarms( tx, principal_id, farmIds, @@ -203,9 +203,12 @@ export async function collectInputForNitrogenBalanceForFarms( ), ) const cultivationDetailsForThisFarm = - cultivationDetails.filter((cultivation) => - cultivationIds.has(cultivation.b_lu_catalogue), - ) + cultivationDetails[b_id_farm]?.filter( + (cultivation) => + cultivationIds.has( + cultivation.b_lu_catalogue, + ), + ) ?? [] const fertilizerIds = new Set( onlyFieldInput.flatMap((input) => diff --git a/fdm-calculator/src/balance/organic-matter/input.test.ts b/fdm-calculator/src/balance/organic-matter/input.test.ts index 51a3f54be..67cfb88bc 100644 --- a/fdm-calculator/src/balance/organic-matter/input.test.ts +++ b/fdm-calculator/src/balance/organic-matter/input.test.ts @@ -27,7 +27,7 @@ vi.mock("@nmi-agro/fdm-core", async () => { getSoilAnalyses: vi.fn(), getFertilizerApplications: vi.fn(), getFertilizersFromCatalogueForFarms: vi.fn(), - getCultivationsOfFarmsFromCatalogue: vi.fn(), + getCultivationsFromCatalogueForFarms: vi.fn(), } }) @@ -228,8 +228,8 @@ describe("collectInputForOrganicMatterBalance", () => { }) vi.spyOn( fdmCore, - "getCultivationsOfFarmsFromCatalogue", - ).mockResolvedValue(mockCultivationDetailsData) + "getCultivationsFromCatalogueForFarms", + ).mockResolvedValue({ [b_id_farm]: mockCultivationDetailsData }) const result = await collectInputForOrganicMatterBalance( mockFdm, @@ -261,8 +261,8 @@ describe("collectInputForOrganicMatterBalance", () => { ).mockResolvedValue({}) vi.spyOn( fdmCore, - "getCultivationsOfFarmsFromCatalogue", - ).mockResolvedValue([]) + "getCultivationsFromCatalogueForFarms", + ).mockResolvedValue({}) const result = await collectInputForOrganicMatterBalance( mockFdm, @@ -312,8 +312,8 @@ describe("collectInputForOrganicMatterBalance", () => { } as any) vi.spyOn( fdmCore, - "getCultivationsOfFarmsFromCatalogue", - ).mockResolvedValue([mockCultivation] as any) + "getCultivationsFromCatalogueForFarms", + ).mockResolvedValue({ [b_id_farm]: [mockCultivation] } as any) vi.spyOn(fdmCore, "getHarvests").mockResolvedValue([]) vi.spyOn(fdmCore, "getSoilAnalyses").mockResolvedValue([]) vi.spyOn(fdmCore, "getFertilizerApplications").mockResolvedValue([ @@ -410,14 +410,13 @@ describe("collectInputForOrganicMatterBalanceForFarms", () => { fdmCore, "getFertilizersFromCatalogueForFarms", ).mockResolvedValue(allFertilizerDetails) - const combinedCultivationDetails = [ - ...mockCultivationDetailsData, - ...mockCultivationDetailsData2, - ] vi.spyOn( fdmCore, - "getCultivationsOfFarmsFromCatalogue", - ).mockResolvedValue(combinedCultivationDetails) + "getCultivationsFromCatalogueForFarms", + ).mockResolvedValue({ + "test-farm-id": mockCultivationDetailsData, + "test-farm-id-2": mockCultivationDetailsData2, + }) const result = await collectInputForOrganicMatterBalanceForFarms( mockFdm, @@ -475,13 +474,13 @@ describe("collectInputForOrganicMatterBalanceForFarms", () => { expect(result).toEqual(expectedResult) expect( - fdmCore.getCultivationsOfFarmsFromCatalogue, + fdmCore.getCultivationsFromCatalogueForFarms, ).toHaveBeenCalledWith(mockFdm, principal_id, [ "test-farm-id", "test-farm-id-2", ]) expect( - fdmCore.getCultivationsOfFarmsFromCatalogue, + fdmCore.getCultivationsFromCatalogueForFarms, ).toHaveBeenCalledTimes(1) expect( fdmCore.getFertilizersFromCatalogueForFarms, diff --git a/fdm-calculator/src/balance/organic-matter/input.ts b/fdm-calculator/src/balance/organic-matter/input.ts index 86843add0..88b323f96 100644 --- a/fdm-calculator/src/balance/organic-matter/input.ts +++ b/fdm-calculator/src/balance/organic-matter/input.ts @@ -6,7 +6,7 @@ import type { } from "@nmi-agro/fdm-core" import { getCultivations, - getCultivationsOfFarmsFromCatalogue, + getCultivationsFromCatalogueForFarms, getFertilizerApplications, getFertilizersFromCatalogueForFarms, getField, @@ -144,7 +144,7 @@ export async function collectInputForOrganicMatterBalanceForFarms( // All data fetching is wrapped in a single database transaction to ensure consistency. return await fdm.transaction(async (tx: FdmType) => { const cultivationDetails = - await getCultivationsOfFarmsFromCatalogue( + await getCultivationsFromCatalogueForFarms( tx, principal_id, farmIds, @@ -178,9 +178,12 @@ export async function collectInputForOrganicMatterBalanceForFarms( ), ) const cultivationDetailsForThisFarm = - cultivationDetails.filter((cultivation) => - cultivationIds.has(cultivation.b_lu_catalogue), - ) + cultivationDetails[b_id_farm]?.filter( + (cultivation) => + cultivationIds.has( + cultivation.b_lu_catalogue, + ), + ) ?? [] const fertilizerIds = new Set( onlyFieldInput.flatMap((input) => diff --git a/fdm-core/src/cultivation.test.ts b/fdm-core/src/cultivation.test.ts index b5b0cff71..fda999783 100644 --- a/fdm-core/src/cultivation.test.ts +++ b/fdm-core/src/cultivation.test.ts @@ -11,7 +11,7 @@ import { getCultivationPlan, getCultivations, getCultivationsFromCatalogue, - getCultivationsOfFarmsFromCatalogue, + getCultivationsFromCatalogueForFarms, getDefaultDatesOfCultivation, removeCultivation, updateCultivation, @@ -214,26 +214,65 @@ describe("Cultivation Data Model", () => { ).toBeDefined() }) - it("should get all cultivations of farms from catalogue", async () => { - const cultivations = await getCultivationsOfFarmsFromCatalogue( + it("should get all cultivations for farms from catalogue", async () => { + const cultivations = await getCultivationsFromCatalogueForFarms( fdm, principal_id, [b_id_farm, b_id_farm_2], ) + expect(cultivations[b_id_farm]).toBeDefined() + expect(cultivations[b_id_farm_2]).toBeDefined() expect( - cultivations.find( + cultivations[b_id_farm].find( (cultivation) => cultivation.b_lu_catalogue === b_lu_catalogue, ), ).toBeDefined() expect( - cultivations.find( + cultivations[b_id_farm_2].find( (cultivation) => cultivation.b_lu_catalogue === b_lu_catalogue_2, ), ).toBeDefined() }) + function mockFdmThatThrowsOnSelectionFromCultivationsCatalogue() { + return { + ...fdm, + select(...args: []) { + return { + ...fdm.select(...args), + from(table: typeof schema.cultivationsCatalogue) { + if (table !== schema.cultivationsCatalogue) { + return fdm.select().from(table) + } + throw new Error("Error querying the database") + }, + } as unknown + }, + } as typeof fdm + } + + it("(getCultivationsFromCatalogue) should rename the error if getCultivationsFromCatalogues throws an error", async () => { + expect( + getCultivationsFromCatalogue( + mockFdmThatThrowsOnSelectionFromCultivationsCatalogue(), + principal_id, + b_id_farm, + ), + ).rejects.not.toThrow("Exception for getFertilizersFromCatalogues") + }) + + it("(getCultivationsFromCatalogueForFarms) should rename the error if getCultivationsFromCatalogues throws an error", async () => { + expect( + getCultivationsFromCatalogueForFarms( + mockFdmThatThrowsOnSelectionFromCultivationsCatalogue(), + principal_id, + [b_id_farm], + ), + ).rejects.not.toThrow("Exception for getFertilizersFromCatalogues") + }) + it("should add a new cultivation to the catalogue", async () => { const b_lu_catalogue = createId() const b_lu_source = "custom" diff --git a/fdm-core/src/cultivation.ts b/fdm-core/src/cultivation.ts index 3875167fa..896eacda0 100644 --- a/fdm-core/src/cultivation.ts +++ b/fdm-core/src/cultivation.ts @@ -32,10 +32,11 @@ import { } from "./harvest" import { createId } from "./id" import type { Timeframe } from "./timeframe" +import { splitBy } from "./bulk" -/** Error message which will be replaced if getCultivationsFromCatalogue is rethrowing the error. */ -export const exceptionForGetCultivationsOfFarmsFromCatalogue = - "Exception for getCultivationsOfFarmsFromCatalogue" +/** Error message which will be replaced if getCultivationsFromCatalogue or getCultivationsFromCatalogueForFarms is rethrowing the error. */ +export const exceptionForGetCultivationsFromCatalogues = + "Exception for getCultivationsFromCatalogues" /** * Retrieves cultivations available in the enabled catalogues for a farm. @@ -46,11 +47,70 @@ export const exceptionForGetCultivationsOfFarmsFromCatalogue = * @returns A Promise that resolves with an array of cultivation catalogue entries. * @alpha */ -export async function getCultivationsOfFarmsFromCatalogue( +export async function getCultivationsFromCatalogue( fdm: FdmType, principal_id: PrincipalId, - farmIds: schema.farmsTypeSelect["b_id_farm"][], + b_id_farm: schema.farmsTypeSelect["b_id_farm"], ): Promise { + try { + await checkPermission( + fdm, + "farm", + "read", + b_id_farm, + principal_id, + "getCultivationsOfFarmsFromCatalogue", + ) + + // Get enabled catalogues for the farm + const enabledCatalogues: Pick< + schema.cultivationCatalogueSelectingTypeSelect, + "b_lu_source" + >[] = await fdm + .selectDistinct({ + b_lu_source: schema.cultivationCatalogueSelecting.b_lu_source, + }) + .from(schema.cultivationCatalogueSelecting) + .where( + eq(schema.cultivationCatalogueSelecting.b_id_farm, b_id_farm), + ) + + const catalogueIds = enabledCatalogues.map((cat) => cat.b_lu_source) + + const catalogue = await getCultivationsFromCatalogues(fdm, catalogueIds) + + return catalogueIds.flatMap( + (b_lu_source) => catalogue[b_lu_source] ?? [], + ) + } catch (err) { + throw handleError( + err instanceof Error && + err.message === exceptionForGetCultivationsFromCatalogues + ? err.cause + : err, + "Exception for getCultivationsFromCatalogue", + { + principal_id, + b_id_farm, + }, + ) + } +} + +/** + * Retrieves cultivations available in the enabled 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 ID of the farms. + * @returns A Promise that resolves with a map from farm IDs to arrays of cultivation catalogues. + * @alpha + */ +export async function getCultivationsFromCatalogueForFarms( + fdm: FdmType, + principal_id: PrincipalId, + farmIds: schema.farmsTypeSelect["b_id_farm"][], +) { try { await Promise.all( farmIds.map((b_id_farm) => @@ -66,8 +126,12 @@ export async function getCultivationsOfFarmsFromCatalogue( ) // Get enabled catalogues for the farm - const enabledCatalogues = await fdm - .select({ + const enabledCatalogues: Pick< + schema.cultivationCatalogueSelectingTypeSelect, + "b_id_farm" | "b_lu_source" + >[] = await fdm + .selectDistinct({ + b_id_farm: schema.cultivationCatalogueSelecting.b_id_farm, b_lu_source: schema.cultivationCatalogueSelecting.b_lu_source, }) .from(schema.cultivationCatalogueSelecting) @@ -77,30 +141,36 @@ export async function getCultivationsOfFarmsFromCatalogue( farmIds, ), ) + .orderBy(asc(schema.cultivationCatalogueSelecting.b_id_farm)) - // If no catalogues are enabled, return empty array - if (enabledCatalogues.length === 0) { - return [] - } + // Catalogues enabled on each farm + const cataloguesByFarm = splitBy( + enabledCatalogues, + (cat) => cat.b_id_farm, + ) - // Get cultivations from enabled catalogues - const cultivationsCatalogue = await fdm - .select() - .from(schema.cultivationsCatalogue) - .where( - inArray( - schema.cultivationsCatalogue.b_lu_source, - enabledCatalogues.map( - (c: { b_lu_source: string }) => c.b_lu_source, - ), - ), - ) + const catalogue = await getCultivationsFromCatalogues(fdm, [ + ...new Set(enabledCatalogues.map((cat) => cat.b_lu_source)), + ]) - return cultivationsCatalogue + // Combine lists of cultivation catalogues enabled on each farm + return Object.fromEntries( + Object.entries(cataloguesByFarm).map( + ([b_id_farm, enabledCatalogues]) => [ + b_id_farm, + enabledCatalogues.flatMap( + (cat) => catalogue[cat.b_lu_source], + ), + ], + ), + ) } catch (err) { throw handleError( - err, - exceptionForGetCultivationsOfFarmsFromCatalogue, + err instanceof Error && + err.message === exceptionForGetCultivationsFromCatalogues + ? err.cause + : err, + "Exception for getCultivationsFromCatalogueForFarms", { principal_id, farmIds, @@ -109,32 +179,46 @@ export async function getCultivationsOfFarmsFromCatalogue( } } -export async function getCultivationsFromCatalogue( +/** + * 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 ID of the catalogues, such as "brp" or farm IDs. + * @returns A Promise that resolves with a map from catalogue IDs to arrays of cultivations. + * @alpha + */ +async function getCultivationsFromCatalogues( fdm: FdmType, - principal_id: PrincipalId, - b_id_farm: string, + catalogueIds: schema.cultivationCatalogueSelectingTypeSelect["b_lu_source"][], ) { try { - return await getCultivationsOfFarmsFromCatalogue(fdm, principal_id, [ - b_id_farm, - ]) - } catch (err) { - if ( - (err as Error)?.message?.startsWith( - exceptionForGetCultivationsOfFarmsFromCatalogue, + // If no catalogues are enabled, return empty array + if (catalogueIds.length === 0) { + return {} + } + + // Get cultivations from enabled catalogues + const cultivationsCatalogue: CultivationCatalogue[] = await fdm + .select() + .from(schema.cultivationsCatalogue) + .where( + inArray(schema.cultivationsCatalogue.b_lu_source, catalogueIds), ) - ) { - const handledError = err as Error - throw handleError( - handledError.cause, - "Exception for getCultivationsFromCatalogue", - { - principal_id, - b_id_farm, - }, + .orderBy( + asc(schema.cultivationsCatalogue.b_lu_source), + asc(schema.cultivationsCatalogue.b_lu_name), ) - } - throw err + + return splitBy( + cultivationsCatalogue, + (cultivation) => cultivation.b_lu_source, + ) + } catch (err) { + throw handleError(err, exceptionForGetCultivationsFromCatalogues, { + catalogueIds, + }) } } diff --git a/fdm-core/src/index.ts b/fdm-core/src/index.ts index 17856b99a..f7b3523fd 100644 --- a/fdm-core/src/index.ts +++ b/fdm-core/src/index.ts @@ -47,8 +47,8 @@ export { getCultivation, getCultivationPlan, getCultivations, - getCultivationsOfFarmsFromCatalogue, getCultivationsFromCatalogue, + getCultivationsFromCatalogueForFarms, getDefaultDatesOfCultivation, removeCultivation, updateCultivation, From a437b115d6f8309c6dc071b579c866699bc3c117 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Fri, 27 Mar 2026 10:17:22 +0100 Subject: [PATCH 33/48] Add internal directive to some JSDoc comments --- fdm-core/src/cultivation.ts | 1 + fdm-core/src/fertilizer.ts | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/fdm-core/src/cultivation.ts b/fdm-core/src/cultivation.ts index 896eacda0..ae19c6c6b 100644 --- a/fdm-core/src/cultivation.ts +++ b/fdm-core/src/cultivation.ts @@ -187,6 +187,7 @@ export async function getCultivationsFromCatalogueForFarms( * @param fdm The FDM instance providing the connection to the database. The instance can be created with {@link createFdmServer}. * @param catalogueIds The ID of the catalogues, such as "brp" or farm IDs. * @returns A Promise that resolves with a map from catalogue IDs to arrays of cultivations. + * @internal * @alpha */ async function getCultivationsFromCatalogues( diff --git a/fdm-core/src/fertilizer.ts b/fdm-core/src/fertilizer.ts index 0c2763cf4..eddcde677 100644 --- a/fdm-core/src/fertilizer.ts +++ b/fdm-core/src/fertilizer.ts @@ -179,8 +179,9 @@ export async function getFertilizersFromCatalogueForFarms( * * @param fdm The FDM instance providing the connection to the database. The instance can be created with {@link createFdmServer}. * @param catalogueIds Catalogues IDs to retrieve. These can be, for example, catalogue IDs found in fdm-data such as "baat", or farm IDs. + * @internal */ -export async function getFertilizersFromCatalogues( +async function getFertilizersFromCatalogues( fdm: FdmType, catalogueIds: schema.fertilizersCatalogueTypeSelect["p_source"][], ): Promise> { From 9d3a4ab5f02d770b814f14742f297b99f9d06de2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Fri, 27 Mar 2026 12:24:03 +0100 Subject: [PATCH 34/48] Address nitpicks --- .../src/balance/nitrogen/input.test.ts | 14 ++++ fdm-calculator/src/balance/nitrogen/input.ts | 5 ++ .../src/balance/organic-matter/input.test.ts | 14 ++++ .../src/balance/organic-matter/input.ts | 5 ++ fdm-core/src/cultivation.test.ts | 79 ++++++++++++++++--- fdm-core/src/cultivation.ts | 2 +- fdm-core/src/fertilizer.test.ts | 71 +++++++++++++++-- fdm-core/src/fertilizer.ts | 6 +- 8 files changed, 173 insertions(+), 23 deletions(-) diff --git a/fdm-calculator/src/balance/nitrogen/input.test.ts b/fdm-calculator/src/balance/nitrogen/input.test.ts index 60da4638f..315485979 100644 --- a/fdm-calculator/src/balance/nitrogen/input.test.ts +++ b/fdm-calculator/src/balance/nitrogen/input.test.ts @@ -373,6 +373,20 @@ describe("collectInputForNitrogenBalance", () => { ).toHaveBeenCalledWith(expect.anything(), timeframe, expect.any(String)) }) + it("should throw an error if there is a specified field but also multiple specified farms", async () => { + await expect( + collectInputForNitrogenBalanceForFarms( + mockFdm, + principal_id, + ["some-farm", "some-other-farm"], + timeframe, + "some-field", + ), + ).rejects.toThrow( + "b_id can only be used when collecting input for a single farm", + ) + }) + it("should throw an error if getFields fails", async () => { const errorMessage = "Failed to get fields" mockedGetFields.mockRejectedValue(new Error(errorMessage)) diff --git a/fdm-calculator/src/balance/nitrogen/input.ts b/fdm-calculator/src/balance/nitrogen/input.ts index d37cc76c9..730792bab 100644 --- a/fdm-calculator/src/balance/nitrogen/input.ts +++ b/fdm-calculator/src/balance/nitrogen/input.ts @@ -168,6 +168,11 @@ export async function collectInputForNitrogenBalanceForFarms( b_id?: fdmSchema.fieldsTypeSelect["b_id"], ): Promise<(NitrogenBalanceInput & { b_id_farm: string })[]> { try { + if (b_id && farmIds.length !== 1) { + throw new Error( + "b_id can only be used when collecting input for a single farm", + ) + } return await fdm.transaction(async (tx: FdmType) => { // Collect the details of the cultivations const cultivationDetails = diff --git a/fdm-calculator/src/balance/organic-matter/input.test.ts b/fdm-calculator/src/balance/organic-matter/input.test.ts index 67cfb88bc..296a9ff1e 100644 --- a/fdm-calculator/src/balance/organic-matter/input.test.ts +++ b/fdm-calculator/src/balance/organic-matter/input.test.ts @@ -296,6 +296,20 @@ describe("collectInputForOrganicMatterBalance", () => { ).rejects.toThrow("Field not found: non-existent-field") }) + it("should throw an error if there is a specified field but also multiple specified farms", async () => { + await expect( + collectInputForOrganicMatterBalanceForFarms( + mockFdm, + principal_id, + ["some-farm", "some-other-farm"], + timeframe, + "some-field", + ), + ).rejects.toThrow( + "b_id can only be used when collecting input for a single farm", + ) + }) + it("should correctly structure the output", async () => { const mockField = { b_id: "field1" } const mockCultivation = { b_lu: "cult1" } diff --git a/fdm-calculator/src/balance/organic-matter/input.ts b/fdm-calculator/src/balance/organic-matter/input.ts index 88b323f96..ba0fa6f1d 100644 --- a/fdm-calculator/src/balance/organic-matter/input.ts +++ b/fdm-calculator/src/balance/organic-matter/input.ts @@ -141,6 +141,11 @@ export async function collectInputForOrganicMatterBalanceForFarms( b_id?: fdmSchema.fieldsTypeSelect["b_id"], ): Promise<(OrganicMatterBalanceInput & { b_id_farm: string })[]> { try { + if (b_id && farmIds.length !== 1) { + throw new Error( + "b_id can only be used when collecting input for a single farm", + ) + } // All data fetching is wrapped in a single database transaction to ensure consistency. return await fdm.transaction(async (tx: FdmType) => { const cultivationDetails = diff --git a/fdm-core/src/cultivation.test.ts b/fdm-core/src/cultivation.test.ts index fda999783..935b40493 100644 --- a/fdm-core/src/cultivation.test.ts +++ b/fdm-core/src/cultivation.test.ts @@ -236,6 +236,37 @@ describe("Cultivation Data Model", () => { ).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, + ) + enableCultivationCatalogue( + fdm, + principal_id, + b_id_farm, + "invalid-catalogue", + ) + expect( + await getCultivationsFromCatalogue( + fdm, + principal_id, + b_id_farm, + ), + ).toEqual([]) + expect( + await getCultivationsFromCatalogueForFarms(fdm, principal_id, [ + b_id_farm, + ]), + ).toEqual({ + [b_id_farm]: [], + }) + }) + function mockFdmThatThrowsOnSelectionFromCultivationsCatalogue() { return { ...fdm, @@ -253,24 +284,52 @@ describe("Cultivation Data Model", () => { } as typeof fdm } - it("(getCultivationsFromCatalogue) should rename the error if getCultivationsFromCatalogues throws an error", async () => { - expect( - getCultivationsFromCatalogue( + it("(getCultivationsFromCatalogue) should rename the error if getFertilizersFromCatalogues throws an error", async () => { + const failError = new Error("Should have thrown.") + try { + await getCultivationsFromCatalogue( mockFdmThatThrowsOnSelectionFromCultivationsCatalogue(), principal_id, b_id_farm, - ), - ).rejects.not.toThrow("Exception for getFertilizersFromCatalogues") + ) + throw failError + } catch (e) { + expect(e).not.toBe(failError) + expect(e).toBeInstanceOf(Error) + expect((e as Error).message).toBe( + "Exception for getCultivationsFromCatalogue", + ) + const errorCause = (e as Error).cause + if (errorCause instanceof Error) { + expect(errorCause.message).not.toBe( + "Exception for getCultivationsFromCatalogues", + ) + } + } }) - it("(getCultivationsFromCatalogueForFarms) should rename the error if getCultivationsFromCatalogues throws an error", async () => { - expect( - getCultivationsFromCatalogueForFarms( + it("(getCultivationsFromCatalogueForFarms) should rename the error if getFertilizersFromCatalogues throws an error", async () => { + const failError = new Error("Should have thrown.") + try { + await getCultivationsFromCatalogueForFarms( mockFdmThatThrowsOnSelectionFromCultivationsCatalogue(), principal_id, [b_id_farm], - ), - ).rejects.not.toThrow("Exception for getFertilizersFromCatalogues") + ) + throw failError + } catch (err) { + expect(err).not.toBe(failError) + expect(err).toBeInstanceOf(Error) + expect((err as Error).message).toBe( + "Exception for getCultivationsFromCatalogueForFarms", + ) + const errorCause = (err as Error).cause + if (errorCause instanceof Error) { + expect(errorCause.message).not.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 ae19c6c6b..1f3a23bd5 100644 --- a/fdm-core/src/cultivation.ts +++ b/fdm-core/src/cultivation.ts @@ -159,7 +159,7 @@ export async function getCultivationsFromCatalogueForFarms( ([b_id_farm, enabledCatalogues]) => [ b_id_farm, enabledCatalogues.flatMap( - (cat) => catalogue[cat.b_lu_source], + (cat) => catalogue[cat.b_lu_source] ?? [], ), ], ), diff --git a/fdm-core/src/fertilizer.test.ts b/fdm-core/src/fertilizer.test.ts index f499ffa3a..0719cd0a9 100644 --- a/fdm-core/src/fertilizer.test.ts +++ b/fdm-core/src/fertilizer.test.ts @@ -437,6 +437,33 @@ describe("Fertilizer Data Model", () => { ]) }) + it("should handle empty catalogues", async () => { + const b_id_farm = await addFarm( + fdm, + principal_id, + "Test Farm No Cultivations In Catalogue", + undefined, + undefined, + undefined, + ) + enableFertilizerCatalogue( + fdm, + principal_id, + b_id_farm, + "invalid-catalogue", + ) + expect( + await getFertilizersFromCatalogue(fdm, principal_id, b_id_farm), + ).toEqual([]) + expect( + await getFertilizersFromCatalogueForFarms(fdm, principal_id, [ + b_id_farm, + ]), + ).toEqual({ + [b_id_farm]: [], + }) + }) + function mockFdmThatThrowsOnSelectionFromFertilizersCatalogue() { return { ...fdm, @@ -455,23 +482,51 @@ describe("Fertilizer Data Model", () => { } it("(getFertilizersFromCatalogue) should rename the error if getFertilizersFromCatalogues throws an error", async () => { - expect( - getFertilizersFromCatalogue( + const failError = new Error("Should have thrown.") + try { + await getFertilizersFromCatalogue( mockFdmThatThrowsOnSelectionFromFertilizersCatalogue(), principal_id, b_id_farm, - ), - ).rejects.not.toThrow("Exception for getFertilizersFromCatalogues") + ) + throw failError + } catch (e) { + expect(e).not.toBe(failError) + expect(e).toBeInstanceOf(Error) + expect((e as Error).message).toBe( + "Exception for getFertilizersFromCatalogue", + ) + const errorCause = (e as Error).cause + if (errorCause instanceof Error) { + expect(errorCause.message).not.toBe( + "Exception for getFertilizersFromCatalogues", + ) + } + } }) it("(getFertilizersFromCatalogueForFarms) should rename the error if getFertilizersFromCatalogues throws an error", async () => { - expect( - getFertilizersFromCatalogueForFarms( + const failError = new Error("Should have thrown.") + try { + await getFertilizersFromCatalogueForFarms( mockFdmThatThrowsOnSelectionFromFertilizersCatalogue(), principal_id, [b_id_farm], - ), - ).rejects.not.toThrow("Exception for getFertilizersFromCatalogues") + ) + throw failError + } catch (err) { + expect(err).not.toBe(failError) + expect(err).toBeInstanceOf(Error) + expect((err as Error).message).toBe( + "Exception for getFertilizersFromCatalogueForFarms", + ) + const errorCause = (err as Error).cause + if (errorCause instanceof Error) { + expect(errorCause.message).not.toBe( + "Exception for getFertilizersFromCatalogues", + ) + } + } }) it("should remove a fertilizer", async () => { diff --git a/fdm-core/src/fertilizer.ts b/fdm-core/src/fertilizer.ts index eddcde677..9cc827eda 100644 --- a/fdm-core/src/fertilizer.ts +++ b/fdm-core/src/fertilizer.ts @@ -65,8 +65,7 @@ export async function getFertilizersFromCatalogue( } catch (err) { throw handleError( err instanceof Error && - err.message === - "Exception for getFertilizersFromCatalogueForFarms" + err.message === "Exception for getFertilizersFromCatalogues" ? err.cause : err, "Exception for getFertilizersFromCatalogue", @@ -159,8 +158,7 @@ export async function getFertilizersFromCatalogueForFarms( } catch (err) { throw handleError( err instanceof Error && - err.message === - "Exception for getFertilizersFromCatalogueForFarms" + err.message === "Exception for getFertilizersFromCatalogues" ? err.cause : err, "Exception for getFertilizersFromCatalogueForFarms", From 1ca596f7f6be97c2de0a33297ebc86094d98d861 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Fri, 27 Mar 2026 13:07:29 +0100 Subject: [PATCH 35/48] Fix mock fdm --- fdm-core/src/cultivation.test.ts | 28 ++++++----------- fdm-core/src/fertilizer.test.ts | 28 ++++++----------- fdm-core/src/test-util.ts | 52 ++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 38 deletions(-) create mode 100644 fdm-core/src/test-util.ts diff --git a/fdm-core/src/cultivation.test.ts b/fdm-core/src/cultivation.test.ts index 935b40493..e95b3cc18 100644 --- a/fdm-core/src/cultivation.test.ts +++ b/fdm-core/src/cultivation.test.ts @@ -29,6 +29,7 @@ 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 @@ -267,28 +268,14 @@ describe("Cultivation Data Model", () => { }) }) - function mockFdmThatThrowsOnSelectionFromCultivationsCatalogue() { - return { - ...fdm, - select(...args: []) { - return { - ...fdm.select(...args), - from(table: typeof schema.cultivationsCatalogue) { - if (table !== schema.cultivationsCatalogue) { - return fdm.select().from(table) - } - throw new Error("Error querying the database") - }, - } as unknown - }, - } as typeof fdm - } - it("(getCultivationsFromCatalogue) should rename the error if getFertilizersFromCatalogues throws an error", async () => { const failError = new Error("Should have thrown.") try { await getCultivationsFromCatalogue( - mockFdmThatThrowsOnSelectionFromCultivationsCatalogue(), + mockFdmThatThrowsOnSelectFrom( + fdm, + schema.cultivationsCatalogue, + ), principal_id, b_id_farm, ) @@ -312,7 +299,10 @@ describe("Cultivation Data Model", () => { const failError = new Error("Should have thrown.") try { await getCultivationsFromCatalogueForFarms( - mockFdmThatThrowsOnSelectionFromCultivationsCatalogue(), + mockFdmThatThrowsOnSelectFrom( + fdm, + schema.cultivationsCatalogue, + ), principal_id, [b_id_farm], ) diff --git a/fdm-core/src/fertilizer.test.ts b/fdm-core/src/fertilizer.test.ts index 0719cd0a9..e3e203460 100644 --- a/fdm-core/src/fertilizer.test.ts +++ b/fdm-core/src/fertilizer.test.ts @@ -34,6 +34,7 @@ import { } from "./fertilizer" import { addField } from "./field" import { createId } from "./id" +import { mockFdmThatThrowsOnSelectFrom } from "./test-util" describe("Fertilizer Data Model", () => { let fdm: FdmServerType @@ -464,28 +465,14 @@ describe("Fertilizer Data Model", () => { }) }) - function mockFdmThatThrowsOnSelectionFromFertilizersCatalogue() { - return { - ...fdm, - select(...args: []) { - return { - ...fdm.select(...args), - from(table: typeof schema.fertilizersCatalogue) { - if (table !== schema.fertilizersCatalogue) { - return fdm.select().from(table) - } - throw new Error("Error querying the database") - }, - } as unknown - }, - } as typeof fdm - } - it("(getFertilizersFromCatalogue) should rename the error if getFertilizersFromCatalogues throws an error", async () => { const failError = new Error("Should have thrown.") try { await getFertilizersFromCatalogue( - mockFdmThatThrowsOnSelectionFromFertilizersCatalogue(), + mockFdmThatThrowsOnSelectFrom( + fdm, + schema.fertilizersCatalogue, + ), principal_id, b_id_farm, ) @@ -509,7 +496,10 @@ describe("Fertilizer Data Model", () => { const failError = new Error("Should have thrown.") try { await getFertilizersFromCatalogueForFarms( - mockFdmThatThrowsOnSelectionFromFertilizersCatalogue(), + mockFdmThatThrowsOnSelectFrom( + fdm, + schema.fertilizersCatalogue, + ), principal_id, [b_id_farm], ) 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 */ From 68c9f1a9ccc85bb3671037ced1f447c2f99fb3ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Fri, 27 Mar 2026 13:23:57 +0100 Subject: [PATCH 36/48] Include all farm IDs for getFertilizersFromCatalogueForFarms and getCultivationsFromCatalogueForFarms --- fdm-core/src/cultivation.test.ts | 27 ++++++++++++++++++++++++++- fdm-core/src/cultivation.ts | 16 +++++++--------- fdm-core/src/fertilizer.test.ts | 23 ++++++++++++++++++++++- fdm-core/src/fertilizer.ts | 14 ++++++-------- 4 files changed, 61 insertions(+), 19 deletions(-) diff --git a/fdm-core/src/cultivation.test.ts b/fdm-core/src/cultivation.test.ts index e95b3cc18..46d3570b9 100644 --- a/fdm-core/src/cultivation.test.ts +++ b/fdm-core/src/cultivation.test.ts @@ -246,7 +246,7 @@ describe("Cultivation Data Model", () => { undefined, undefined, ) - enableCultivationCatalogue( + await enableCultivationCatalogue( fdm, principal_id, b_id_farm, @@ -268,6 +268,31 @@ describe("Cultivation Data Model", () => { }) }) + 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([]) + expect( + await getCultivationsFromCatalogueForFarms(fdm, principal_id, [ + b_id_farm, + ]), + ).toEqual({ + [b_id_farm]: [], + }) + }) + it("(getCultivationsFromCatalogue) should rename the error if getFertilizersFromCatalogues throws an error", async () => { const failError = new Error("Should have thrown.") try { diff --git a/fdm-core/src/cultivation.ts b/fdm-core/src/cultivation.ts index 1f3a23bd5..9012adef4 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 { splitBy } from "./bulk" import type { Cultivation, CultivationCatalogue, @@ -32,7 +33,6 @@ import { } from "./harvest" import { createId } from "./id" import type { Timeframe } from "./timeframe" -import { splitBy } from "./bulk" /** Error message which will be replaced if getCultivationsFromCatalogue or getCultivationsFromCatalogueForFarms is rethrowing the error. */ export const exceptionForGetCultivationsFromCatalogues = @@ -155,14 +155,12 @@ export async function getCultivationsFromCatalogueForFarms( // Combine lists of cultivation catalogues enabled on each farm return Object.fromEntries( - Object.entries(cataloguesByFarm).map( - ([b_id_farm, enabledCatalogues]) => [ - b_id_farm, - enabledCatalogues.flatMap( - (cat) => catalogue[cat.b_lu_source] ?? [], - ), - ], - ), + farmIds.map((b_id_farm) => [ + b_id_farm, + (cataloguesByFarm[b_id_farm] ?? []).flatMap( + (cat) => catalogue[cat.b_lu_source] ?? [], + ), + ]), ) } catch (err) { throw handleError( diff --git a/fdm-core/src/fertilizer.test.ts b/fdm-core/src/fertilizer.test.ts index e3e203460..d304fd12c 100644 --- a/fdm-core/src/fertilizer.test.ts +++ b/fdm-core/src/fertilizer.test.ts @@ -447,7 +447,7 @@ describe("Fertilizer Data Model", () => { undefined, undefined, ) - enableFertilizerCatalogue( + await enableFertilizerCatalogue( fdm, principal_id, b_id_farm, @@ -465,6 +465,27 @@ describe("Fertilizer Data Model", () => { }) }) + 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([]) + expect( + await getFertilizersFromCatalogueForFarms(fdm, principal_id, [ + b_id_farm, + ]), + ).toEqual({ + [b_id_farm]: [], + }) + }) + it("(getFertilizersFromCatalogue) should rename the error if getFertilizersFromCatalogues throws an error", async () => { const failError = new Error("Should have thrown.") try { diff --git a/fdm-core/src/fertilizer.ts b/fdm-core/src/fertilizer.ts index 9cc827eda..e0e02f815 100644 --- a/fdm-core/src/fertilizer.ts +++ b/fdm-core/src/fertilizer.ts @@ -146,14 +146,12 @@ export async function getFertilizersFromCatalogueForFarms( // Join each farm's enabled catalogues together return Object.fromEntries( - Object.entries(enabledCataloguesByFarm).map( - ([b_id_farm, enabledCatalogues]) => [ - b_id_farm, - enabledCatalogues.flatMap( - ({ p_source }) => catalogues[p_source] ?? [], - ), - ], - ), + farmIds.map((b_id_farm) => [ + b_id_farm, + (enabledCataloguesByFarm[b_id_farm] ?? []).flatMap( + (cat) => catalogues[cat.p_source] ?? [], + ), + ]), ) } catch (err) { throw handleError( From 406c6380b80b95853cad48160ea9904e17cdc6d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Fri, 27 Mar 2026 15:21:51 +0100 Subject: [PATCH 37/48] Make sure results are sorted by area --- ...slug.$calendar.balance.nitrogen._index.tsx | 174 +++++++++--------- ...calendar.balance.organic-matter._index.tsx | 69 ++++--- 2 files changed, 131 insertions(+), 112 deletions(-) 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 index e97dea0cd..c72e5ab24 100644 --- a/fdm-app/app/routes/organization.$slug.$calendar.balance.nitrogen._index.tsx +++ b/fdm-app/app/routes/organization.$slug.$calendar.balance.nitrogen._index.tsx @@ -55,23 +55,24 @@ type Organization = Awaited< type FarmResult = { farm: Farm owner: Awaited>[number] | undefined - fields: Awaited> 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 } | { - farms: Farm[] farmIds: string[] organization: Organization noFarms: false @@ -142,15 +143,12 @@ export async function loader({ // If the organization has no access to any farms, render the empty message if (farms.length === 0) { return { + farmIds: [], organization: organization, noFarms: true, } } - const farmsMap = Object.fromEntries( - farms.map((farm) => [farm.b_id_farm, farm]), - ) - const farmIds = searchParamFarmIds ?? farms.map((farm) => farm.b_id_farm) @@ -187,92 +185,103 @@ export async function loader({ rawFarmResultsMap[b_id_farm] ??= [] rawFarmResultsMap[b_id_farm].push(result) } + + 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 farmResults = await Promise.all( - Object.entries(rawFarmResultsMap).map( - async ([b_id_farm, fieldResults]) => { - const farm = farmsMap[b_id_farm] - try { - const nitrogenBalanceResult = - calculateNitrogenBalancesFieldToFarm( - fieldResults, - fieldResults.some( + farmsExtended.map(async (farm) => { + try { + const fieldResults = rawFarmResultsMap[farm.b_id_farm] + const nitrogenBalanceResult = + calculateNitrogenBalancesFieldToFarm( + fieldResults, + fieldResults.some( + (result) => result.errorMessage, + ), + fieldResults + .filter((result) => result.errorMessage) + .map( (result) => result.errorMessage, - ), - fieldResults - .filter((result) => result.errorMessage) - .map( - (result) => result.errorMessage, - ) as string[], - ) - const farmPrincipals = await listPrincipalsForFarm( - fdm, - principal_id, - farm.b_id_farm, - ) - const owner = farmPrincipals.find( - (p) => p.role === "owner" && p.type === "user", - ) - - const fields = await getFields( - fdm, - principal_id, - farm.b_id_farm, + ) as string[], ) + const farmPrincipals = await listPrincipalsForFarm( + fdm, + principal_id, + farm.b_id_farm, + ) + const owner = farmPrincipals.find( + (p) => p.role === "owner" && p.type === "user", + ) - const totalArea = fields.reduce( - (totalArea, field) => - totalArea + (field.b_area ?? 0), - 0, + 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, + }, ) - 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, - owner: owner, - fields: fields, - totalArea: totalArea, - nitrogenBalanceResult: - nitrogenBalanceResult as NitrogenBalanceNumeric & { - errorMessage?: string - }, - } - } catch (error) { - return { - farm: farm, - owner: undefined, - fields: [], - totalArea: 0, - nitrogenBalanceResult: { - hasErrors: true, - errorMessage: - error instanceof Error - ? error.message - : String(error), - } as NitrogenBalanceNumeric & { + return { + farm: farm, + owner: owner, + totalArea: farm.b_area_farm, + nitrogenBalanceResult: + nitrogenBalanceResult as NitrogenBalanceNumeric & { errorMessage?: string }, - } } - }, - ), + } catch (error) { + return { + farm: farm, + owner: undefined, + fields: [], + 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, } @@ -281,7 +290,6 @@ export async function loader({ const asyncData = getAsyncData(organization.id) return { - farms: farms, farmIds: farmIds.sort(), organization: organization, noFarms: false, @@ -333,7 +341,7 @@ function OrganizationFarmBalanceNitrogenOverview(loaderData: LoaderData) { ) } - const { farms, farmIds, asyncData: asyncDataPromise } = loaderData + const { farmIds, asyncData: asyncDataPromise } = loaderData // Unlike most React hooks `use` may be called conditionally const asyncData = use(asyncDataPromise) @@ -527,7 +535,7 @@ function OrganizationFarmBalanceNitrogenOverview(loaderData: LoaderData) {

Bedrijven

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 index f450cd2df..622a1181f 100644 --- 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 @@ -53,23 +53,24 @@ type Organization = Awaited< type FarmResult = { farm: Farm owner: Awaited>[number] | undefined - fields: Awaited> 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 } | { - farms: Farm[] farmIds: string[] organization: Organization noFarms: false @@ -143,13 +144,10 @@ export async function loader({ return { organization: organization, noFarms: true, + farmIds: [], } } - const farmsMap = Object.fromEntries( - farms.map((farm) => [farm.b_id_farm, farm]), - ) - const farmIds = searchParamFarmIds ?? farms.map((farm) => farm.b_id_farm) @@ -186,11 +184,38 @@ export async function loader({ 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 farmResults = await Promise.all( - Object.entries(rawFarmResultsMap).map( - async ([b_id_farm, fieldResults]) => { - const farm = farmsMap[b_id_farm] + farmsExtended + .filter((farm) => rawFarmResultsMap[farm.b_id_farm]) + .map(async (farm) => { try { + const fieldResults = + rawFarmResultsMap[farm.b_id_farm] const organicMatterBalanceResult = calculateOrganicMatterBalancesFieldToFarm( fieldResults, @@ -212,17 +237,6 @@ export async function loader({ (p) => p.role === "owner" && p.type === "user", ) - const fields = await getFields( - fdm, - principal_id, - farm.b_id_farm, - ) - - const totalArea = fields.reduce( - (totalArea, field) => - totalArea + (field.b_area ?? 0), - 0, - ) if (organicMatterBalanceResult.hasErrors) { reportError( organicMatterBalanceResult.fieldErrorMessages.join( @@ -243,8 +257,7 @@ export async function loader({ return { farm: farm, owner: owner, - fields: fields, - totalArea: totalArea, + totalArea: farm.b_area_farm, organicMatterBalanceResult: organicMatterBalanceResult as OrganicMatterBalanceNumeric & { errorMessage?: string @@ -254,8 +267,7 @@ export async function loader({ return { farm: farm, owner: undefined, - fields: [], - totalArea: 0, + totalArea: farm.b_area_farm, organicMatterBalanceResult: { hasErrors: true, errorMessage: @@ -267,11 +279,11 @@ export async function loader({ }, } } - }, - ), + }), ) return { + farms: farmsExtended, farmResults: farmResults, combinedResult: combinedResult, } @@ -280,7 +292,6 @@ export async function loader({ const asyncData = getAsyncData(organization.id) return { - farms: farms, farmIds: farmIds.sort(), organization: organization, noFarms: false, @@ -334,7 +345,7 @@ function OrganizationFarmBalanceOrganicMatterOverview(loaderData: LoaderData) { ) } - const { farms, farmIds, asyncData: asyncDataPromise } = loaderData + const { farmIds, asyncData: asyncDataPromise } = loaderData // Unlike most React hooks `use` may be called conditionally const asyncData = use(asyncDataPromise) @@ -491,7 +502,7 @@ function OrganizationFarmBalanceOrganicMatterOverview(loaderData: LoaderData) {

Bedrijven

From 2fdadcc6c8e265a51dbc0222513d8ed0161ebdd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Fri, 27 Mar 2026 23:27:54 +0100 Subject: [PATCH 38/48] Nitpicks --- ...slug.$calendar.balance.nitrogen._index.tsx | 127 +++++++++--------- ...calendar.balance.organic-matter._index.tsx | 2 +- fdm-core/src/cultivation.test.ts | 4 +- 3 files changed, 68 insertions(+), 65 deletions(-) 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 index c72e5ab24..5d287d562 100644 --- a/fdm-app/app/routes/organization.$slug.$calendar.balance.nitrogen._index.tsx +++ b/fdm-app/app/routes/organization.$slug.$calendar.balance.nitrogen._index.tsx @@ -143,9 +143,9 @@ export async function loader({ // If the organization has no access to any farms, render the empty message if (farms.length === 0) { return { - farmIds: [], organization: organization, noFarms: true, + farmIds: [], } } @@ -186,6 +186,7 @@ export async function loader({ rawFarmResultsMap[b_id_farm].push(result) } + // Compute farms const farmsExtended = await Promise.all( farms.map(async (farm) => { const fields = await getFields( @@ -210,74 +211,76 @@ export async function loader({ farmsExtended.sort((f1, f2) => f2.b_area_farm - f1.b_area_farm) const farmResults = await Promise.all( - farmsExtended.map(async (farm) => { - try { - const fieldResults = rawFarmResultsMap[farm.b_id_farm] - const nitrogenBalanceResult = - calculateNitrogenBalancesFieldToFarm( - fieldResults, - fieldResults.some( - (result) => result.errorMessage, - ), - fieldResults - .filter((result) => result.errorMessage) - .map( + farmsExtended + .filter((farm) => rawFarmResultsMap[farm.b_id_farm]) + .map(async (farm) => { + try { + const fieldResults = + rawFarmResultsMap[farm.b_id_farm] + const nitrogenBalanceResult = + calculateNitrogenBalancesFieldToFarm( + fieldResults, + fieldResults.some( (result) => result.errorMessage, - ) as string[], + ), + fieldResults + .filter((result) => result.errorMessage) + .map( + (result) => result.errorMessage, + ) as string[], + ) + const farmPrincipals = await listPrincipalsForFarm( + fdm, + principal_id, + farm.b_id_farm, ) - const farmPrincipals = await listPrincipalsForFarm( - fdm, - principal_id, - farm.b_id_farm, - ) - const owner = farmPrincipals.find( - (p) => p.role === "owner" && p.type === "user", - ) - - 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, - }, + const owner = farmPrincipals.find( + (p) => p.role === "owner" && p.type === "user", ) - } - return { - farm: farm, - owner: owner, - totalArea: farm.b_area_farm, - nitrogenBalanceResult: - nitrogenBalanceResult as NitrogenBalanceNumeric & { + 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, + owner: owner, + totalArea: farm.b_area_farm, + nitrogenBalanceResult: + nitrogenBalanceResult as NitrogenBalanceNumeric & { + errorMessage?: string + }, + } + } catch (error) { + return { + farm: farm, + owner: undefined, + totalArea: farm.b_area_farm, + nitrogenBalanceResult: { + hasErrors: true, + errorMessage: + error instanceof Error + ? error.message + : String(error), + } as NitrogenBalanceNumeric & { errorMessage?: string }, + } } - } catch (error) { - return { - farm: farm, - owner: undefined, - fields: [], - totalArea: farm.b_area_farm, - nitrogenBalanceResult: { - hasErrors: true, - errorMessage: - error instanceof Error - ? error.message - : String(error), - } as NitrogenBalanceNumeric & { - errorMessage?: string - }, - } - } - }), + }), ) return { 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 index 622a1181f..98ecb894b 100644 --- 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 @@ -413,7 +413,7 @@ function OrganizationFarmBalanceOrganicMatterOverview(loaderData: LoaderData) { - Balans (Bedrijf) + Balans (Bedrijven) diff --git a/fdm-core/src/cultivation.test.ts b/fdm-core/src/cultivation.test.ts index 46d3570b9..fed5749a2 100644 --- a/fdm-core/src/cultivation.test.ts +++ b/fdm-core/src/cultivation.test.ts @@ -293,7 +293,7 @@ describe("Cultivation Data Model", () => { }) }) - it("(getCultivationsFromCatalogue) should rename the error if getFertilizersFromCatalogues throws an error", async () => { + it("(getCultivationsFromCatalogue) should rename the error if getCultivationsFromCatalogues throws an error", async () => { const failError = new Error("Should have thrown.") try { await getCultivationsFromCatalogue( @@ -320,7 +320,7 @@ describe("Cultivation Data Model", () => { } }) - it("(getCultivationsFromCatalogueForFarms) should rename the error if getFertilizersFromCatalogues throws an error", async () => { + it("(getCultivationsFromCatalogueForFarms) should rename the error if getCultivationsFromCatalogues throws an error", async () => { const failError = new Error("Should have thrown.") try { await getCultivationsFromCatalogueForFarms( From ad92a6e2192c7ab1722170397e93e63835d6ad63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Fri, 27 Mar 2026 23:37:51 +0100 Subject: [PATCH 39/48] Display farm areas on the farm select dialog --- .../blocks/balance/farm-select-dialog.tsx | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/fdm-app/app/components/blocks/balance/farm-select-dialog.tsx b/fdm-app/app/components/blocks/balance/farm-select-dialog.tsx index b5ad0383a..684799d50 100644 --- a/fdm-app/app/components/blocks/balance/farm-select-dialog.tsx +++ b/fdm-app/app/components/blocks/balance/farm-select-dialog.tsx @@ -1,4 +1,3 @@ -import type { getFarms } from "@nmi-agro/fdm-core" import { useRef } from "react" import { useSearchParams } from "react-router" import { Button } from "~/components/ui/button" @@ -29,7 +28,11 @@ export function FarmSelectDialog({ farms, defaultSelectedFarmIds, }: { - farms: Awaited> + farms: { + b_id_farm: string + b_name_farm: string | null + b_area_farm: number + }[] defaultSelectedFarmIds: string[] }) { const formRef = useRef(null) @@ -48,8 +51,11 @@ export function FarmSelectDialog({ berekening. -
- {farms.map((farm) => { + + {farms.flatMap((farm) => { const b_id_farm = farm.b_id_farm const currentValue = defaultSelectedFarmIds.includes( farm.b_id_farm, @@ -63,7 +69,12 @@ export function FarmSelectDialog({ name={b_id_farm} defaultChecked={!!currentValue} /> - {farm.b_name_farm ?? "Onbekend"} +
+ {farm.b_name_farm ?? "Onbekend"} +
+
+ {Math.round(farm.b_area_farm * 10) / 10} ha +
) })} From 6e4e2160106add63e63654bae9f6a87ced2d3641 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Fri, 27 Mar 2026 23:54:05 +0100 Subject: [PATCH 40/48] Validate farmIds --- ...zation.$slug.$calendar.balance.nitrogen._index.tsx | 11 +++++++++++ ....$slug.$calendar.balance.organic-matter._index.tsx | 11 +++++++++++ 2 files changed, 22 insertions(+) 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 index 5d287d562..989a418f6 100644 --- a/fdm-app/app/routes/organization.$slug.$calendar.balance.nitrogen._index.tsx +++ b/fdm-app/app/routes/organization.$slug.$calendar.balance.nitrogen._index.tsx @@ -152,6 +152,17 @@ export async function loader({ 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, 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 index 98ecb894b..b0747755d 100644 --- 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 @@ -151,6 +151,17 @@ export async function loader({ 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 organic matter balance for these farms" + throw data(statusText, { + status: 403, + statusText: statusText, + }) + } + async function getAsyncData(principal_id: string) { const inputs = await collectInputForOrganicMatterBalanceForFarms( fdm, From ae7d3c98be19fb2cd3abf8b5de37f0e5312fd557 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Bora=20=C4=B0nevi?= Date: Sat, 28 Mar 2026 00:00:58 +0100 Subject: [PATCH 41/48] Add changeset --- .changeset/breezy-spies-smoke.md | 5 +++++ .changeset/green-pumas-flash.md | 5 +++++ .changeset/thin-steaks-sneeze.md | 5 +++++ 3 files changed, 15 insertions(+) create mode 100644 .changeset/breezy-spies-smoke.md create mode 100644 .changeset/green-pumas-flash.md create mode 100644 .changeset/thin-steaks-sneeze.md diff --git a/.changeset/breezy-spies-smoke.md b/.changeset/breezy-spies-smoke.md new file mode 100644 index 000000000..5db9daaee --- /dev/null +++ b/.changeset/breezy-spies-smoke.md @@ -0,0 +1,5 @@ +--- +"@nmi-agro/fdm-core": minor +--- + +Added the the getFertilizersFromCatalogueForFarms and getCultivationsFromCatalogueForFarms methods which can retrieve all fertilizers or cultivations from catalogue respectively while avoiding fetching the same catalogue's items multiple times for different farms. 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..04ad31714 --- /dev/null +++ b/.changeset/thin-steaks-sneeze.md @@ -0,0 +1,5 @@ +--- +"@nmi-agro/fdm-calculator": minor +--- + +Added nitrogen and organic matter balance input collection for multiple farms which can reduce the number of database lookups when analyzing balance of farms in a region. From 83b397033a59c7c787f639706e73774de442134b Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Tue, 31 Mar 2026 10:25:22 +0200 Subject: [PATCH 42/48] fix: use "Organische stof" consistent --- fdm-app/app/routes/about.whats-new._index.tsx | 4 ++-- ...farm.$b_id_farm.$calendar.balance.organic-matter.$b_id.tsx | 2 +- ...arm.$b_id_farm.$calendar.balance.organic-matter._index.tsx | 2 +- .../farm.$b_id_farm.$calendar.balance.organic-matter.tsx | 2 +- ...nization.$slug.$calendar.balance.organic-matter._index.tsx | 4 ++-- 5 files changed, 7 insertions(+), 7 deletions(-) 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.organic-matter._index.tsx b/fdm-app/app/routes/organization.$slug.$calendar.balance.organic-matter._index.tsx index b0747755d..6f7a3354f 100644 --- 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 @@ -81,7 +81,7 @@ type LoaderData = export const meta: MetaFunction = () => { return [ { - title: `Organische Stof | Organisatie | Nutriëntenbalans| ${clientConfig.name}`, + title: `Organische stof | Organisatie | Nutriëntenbalans| ${clientConfig.name}`, }, { name: "description", @@ -319,7 +319,7 @@ export default function FarmBalanceOrganicMatterOverviewBlock() { return (

- Organische Stof + Organische stof

Date: Tue, 31 Mar 2026 13:31:57 +0200 Subject: [PATCH 43/48] refactor: improve the new backend functions --- .../src/balance/nitrogen/input.test.ts | 139 ++++++++------ fdm-calculator/src/balance/nitrogen/input.ts | 133 ++++++++----- .../src/balance/organic-matter/input.test.ts | 126 +++++++------ .../src/balance/organic-matter/input.ts | 146 ++++++++++----- fdm-core/src/bulk.ts | 24 --- fdm-core/src/catalogues.ts | 112 ++++++++++- fdm-core/src/cultivation.test.ts | 75 ++++---- fdm-core/src/cultivation.ts | 149 ++------------- fdm-core/src/fertilizer.test.ts | 71 ++++--- fdm-core/src/fertilizer.ts | 174 +++--------------- fdm-core/src/index.ts | 6 +- 11 files changed, 552 insertions(+), 603 deletions(-) delete mode 100644 fdm-core/src/bulk.ts diff --git a/fdm-calculator/src/balance/nitrogen/input.test.ts b/fdm-calculator/src/balance/nitrogen/input.test.ts index 315485979..294944814 100644 --- a/fdm-calculator/src/balance/nitrogen/input.test.ts +++ b/fdm-calculator/src/balance/nitrogen/input.test.ts @@ -12,9 +12,13 @@ import type { } from "@nmi-agro/fdm-core" import { getCultivations, - getCultivationsFromCatalogueForFarms, + getCultivationsFromCatalogues, + getCultivationsFromCatalogue, + getEnabledCultivationCataloguesForFarms, + getEnabledFertilizerCataloguesForFarms, getFertilizerApplications, - getFertilizersFromCatalogueForFarms, + getFertilizersFromCatalogues, + getFertilizersFromCatalogue, getFields, getHarvests, getSoilAnalyses, @@ -38,9 +42,12 @@ vi.mock("@nmi-agro/fdm-core", async () => { getHarvests: vi.fn(), getSoilAnalyses: vi.fn(), getFertilizerApplications: vi.fn(), - getFertilizersFromCatalogueForFarms: vi.fn(), getCultivationsFromCatalogue: vi.fn(), - getCultivationsFromCatalogueForFarms: vi.fn(), + getFertilizersFromCatalogue: vi.fn(), + getEnabledCultivationCataloguesForFarms: vi.fn(), + getEnabledFertilizerCataloguesForFarms: vi.fn(), + getCultivationsFromCatalogues: vi.fn(), + getFertilizersFromCatalogues: vi.fn(), } }) @@ -55,15 +62,19 @@ const mockedGetCultivations = vi.mocked(getCultivations) const mockedGetHarvests = vi.mocked(getHarvests) const mockedGetSoilAnalyses = vi.mocked(getSoilAnalyses) const mockedGetFertilizerApplications = vi.mocked(getFertilizerApplications) -const mockedGetFertilizersFromCatalogueForFarms = vi.mocked( - getFertilizersFromCatalogueForFarms, +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, ) -const mockedGetCultivationsFromCatalogueForFarms = vi.mocked( - getCultivationsFromCatalogueForFarms, -) function createMockData() { return { @@ -282,12 +293,12 @@ describe("collectInputForNitrogenBalance", () => { const allFertilizerDetails = mockFertilizerDetailsData.map((fert) => ({ ...fert, })) - mockedGetFertilizersFromCatalogueForFarms.mockResolvedValue({ - [b_id_farm]: allFertilizerDetails, - }) - mockedGetCultivationsFromCatalogueForFarms.mockResolvedValue({ - [b_id_farm]: mockCultivationDetailsData, - }) + mockedGetFertilizersFromCatalogue.mockResolvedValue( + allFertilizerDetails as any, + ) + mockedGetCultivationsFromCatalogue.mockResolvedValue( + mockCultivationDetailsData as any, + ) mockedCalculateAllFieldsNitrogenSupplyByDeposition.mockResolvedValue( mockDepositionSupplyMap, ) @@ -358,35 +369,21 @@ describe("collectInputForNitrogenBalance", () => { timeframe, ) } - expect(mockedGetFertilizersFromCatalogueForFarms).toHaveBeenCalledWith( + expect(mockedGetFertilizersFromCatalogue).toHaveBeenCalledWith( mockFdm, principal_id, - [b_id_farm], + b_id_farm, ) - expect(mockedGetCultivationsFromCatalogueForFarms).toHaveBeenCalledWith( + expect(mockedGetCultivationsFromCatalogue).toHaveBeenCalledWith( mockFdm, principal_id, - [b_id_farm], + b_id_farm, ) expect( mockedCalculateAllFieldsNitrogenSupplyByDeposition, ).toHaveBeenCalledWith(expect.anything(), timeframe, expect.any(String)) }) - it("should throw an error if there is a specified field but also multiple specified farms", async () => { - await expect( - collectInputForNitrogenBalanceForFarms( - mockFdm, - principal_id, - ["some-farm", "some-other-farm"], - timeframe, - "some-field", - ), - ).rejects.toThrow( - "b_id can only be used when collecting input for a single farm", - ) - }) - it("should throw an error if getFields fails", async () => { const errorMessage = "Failed to get fields" mockedGetFields.mockRejectedValue(new Error(errorMessage)) @@ -452,8 +449,8 @@ describe("collectInputForNitrogenBalance", () => { it("should handle empty arrays from core functions correctly", async () => { mockedGetFields.mockResolvedValue([]) - mockedGetFertilizersFromCatalogueForFarms.mockResolvedValue({}) - mockedGetCultivationsFromCatalogueForFarms.mockResolvedValue({}) + mockedGetFertilizersFromCatalogue.mockResolvedValue([]) + mockedGetCultivationsFromCatalogue.mockResolvedValue([]) const result = await collectInputForNitrogenBalance( mockFdm, @@ -477,15 +474,15 @@ describe("collectInputForNitrogenBalance", () => { b_id_farm, timeframe, ) - expect(mockedGetFertilizersFromCatalogueForFarms).toHaveBeenCalledWith( + expect(mockedGetFertilizersFromCatalogue).toHaveBeenCalledWith( mockFdm, principal_id, - [b_id_farm], + b_id_farm, ) - expect(mockedGetCultivationsFromCatalogueForFarms).toHaveBeenCalledWith( + expect(mockedGetCultivationsFromCatalogue).toHaveBeenCalledWith( mockFdm, principal_id, - [b_id_farm], + b_id_farm, ) // Ensure other calls that depend on fields are not made expect(mockedGetCultivations).not.toHaveBeenCalled() @@ -551,25 +548,41 @@ describe("collectInputForNitrogenBalanceForFarms", () => { ? 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, - b_id_farm: "test-farm-id", + p_source: "test-farm-id", })) const fertData2 = mockFertilizerDetailsData2.map((fert) => ({ ...fert, - b_id_farm: "test-farm-id-2", + p_source: "test-farm-id-2", })) - const allFertilizerDetails = { - "test-farm-id": fertData1, - "test-farm-id-2": fertData2, - } - mockedGetFertilizersFromCatalogueForFarms.mockResolvedValue( - allFertilizerDetails, - ) - mockedGetCultivationsFromCatalogueForFarms.mockResolvedValue({ - "test-farm-id": mockCultivationDetailsData, - "test-farm-id-2": mockCultivationDetailsData2, + 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, ) @@ -619,35 +632,41 @@ describe("collectInputForNitrogenBalanceForFarms", () => { b_id_farm: "test-farm-id", fields: expectedFieldInputs, fertilizerDetails: fertData1, - cultivationDetails: mockCultivationDetailsData, + cultivationDetails: cultDetailsWithSource1, timeFrame: timeframe, }, { b_id_farm: "test-farm-id-2", fields: expectedFieldInputs2, fertilizerDetails: fertData2, - cultivationDetails: mockCultivationDetailsData2, + cultivationDetails: cultDetailsWithSource2, timeFrame: timeframe, }, ] expect(result).toEqual(expectedResult) - expect(mockedGetCultivationsFromCatalogueForFarms).toHaveBeenCalledWith( + expect(mockedGetEnabledCultivationCataloguesForFarms).toHaveBeenCalledWith( mockFdm, principal_id, ["test-farm-id", "test-farm-id-2"], ) - expect( - mockedGetCultivationsFromCatalogueForFarms, - ).toHaveBeenCalledTimes(1) - expect(mockedGetFertilizersFromCatalogueForFarms).toHaveBeenCalledWith( + expect(mockedGetEnabledCultivationCataloguesForFarms).toHaveBeenCalledTimes(1) + expect(mockedGetEnabledFertilizerCataloguesForFarms).toHaveBeenCalledWith( mockFdm, principal_id, ["test-farm-id", "test-farm-id-2"], ) - expect(mockedGetFertilizersFromCatalogueForFarms).toHaveBeenCalledTimes( - 1, + expect(mockedGetEnabledFertilizerCataloguesForFarms).toHaveBeenCalledTimes(1) + expect(mockedgetCultivationsFromCatalogues).toHaveBeenCalledWith( + mockFdm, + ["brp"], + ) + expect(mockedgetCultivationsFromCatalogues).toHaveBeenCalledTimes(1) + expect(mockedgetFertilizersFromCatalogues).toHaveBeenCalledWith( + mockFdm, + 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 730792bab..01fd6eff5 100644 --- a/fdm-calculator/src/balance/nitrogen/input.ts +++ b/fdm-calculator/src/balance/nitrogen/input.ts @@ -6,9 +6,13 @@ import type { } from "@nmi-agro/fdm-core" import { getCultivations, - getCultivationsFromCatalogueForFarms, + getCultivationsFromCatalogues, + getCultivationsFromCatalogue, + getEnabledCultivationCataloguesForFarms, + getEnabledFertilizerCataloguesForFarms, getFertilizerApplications, - getFertilizersFromCatalogueForFarms, + getFertilizersFromCatalogues, + getFertilizersFromCatalogue, getField, getFields, getHarvests, @@ -146,15 +150,13 @@ async function collectInputForNitrogenBalanceForFarm( * * 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. + * 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 b_id_farm - The ID of the farm for which to collect the nitrogen balance input. + * @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. - * @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 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. * @@ -165,28 +167,41 @@ export async function collectInputForNitrogenBalanceForFarms( principal_id: PrincipalId, farmIds: fdmSchema.farmsTypeSelect["b_id_farm"][], timeframe: Timeframe, - b_id?: fdmSchema.fieldsTypeSelect["b_id"], ): Promise<(NitrogenBalanceInput & { b_id_farm: string })[]> { try { - if (b_id && farmIds.length !== 1) { - throw new Error( - "b_id can only be used when collecting input for a single farm", - ) - } return await fdm.transaction(async (tx: FdmType) => { - // Collect the details of the cultivations - const cultivationDetails = - await getCultivationsFromCatalogueForFarms( - tx, - principal_id, - farmIds, - ) - const fertilizerDetails = await getFertilizersFromCatalogueForFarms( - tx, - principal_id, - 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, + farmIds, + ), + getEnabledFertilizerCataloguesForFarms( + tx, + principal_id, + farmIds, + ), + ]) + + // 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, uniqueFertilizerSources), + ]) + // Step 3: Process each farm using the pre-fetched catalogue data return await Promise.all( farmIds.map(async (b_id_farm) => { try { @@ -196,10 +211,12 @@ export async function collectInputForNitrogenBalanceForFarms( principal_id, b_id_farm, timeframe, - b_id, ) - // Required cultivation and fertilizer details for this farm should be extracted to not break the cache + // 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( @@ -208,13 +225,15 @@ export async function collectInputForNitrogenBalanceForFarms( ), ) const cultivationDetailsForThisFarm = - cultivationDetails[b_id_farm]?.filter( - (cultivation) => - cultivationIds.has( - cultivation.b_lu_catalogue, - ), - ) ?? [] + 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( @@ -222,11 +241,12 @@ export async function collectInputForNitrogenBalanceForFarms( ), ), ) - const fertilizerDetailsForThisFarm = - fertilizerDetails[b_id_farm]?.filter((fert) => - fertilizerIds.has(fert.p_id_catalogue), - ) ?? [] + allFertilizers.filter( + (f) => + farmFertilizerSources.has(f.p_source) && + fertilizerIds.has(f.p_id_catalogue), + ) return { b_id_farm: b_id_farm, @@ -274,15 +294,36 @@ export async function collectInputForNitrogenBalance( timeframe: Timeframe, b_id?: fdmSchema.fieldsTypeSelect["b_id"], ): Promise { - return ( - await collectInputForNitrogenBalanceForFarms( - fdm, - principal_id, - [b_id_farm], - timeframe, - b_id, - ) - )[0] + try { + return await fdm.transaction(async (tx: FdmType) => { + const cultivationDetails = await getCultivationsFromCatalogue( + tx, + principal_id, + b_id_farm, + ) + 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, + cultivationDetails, + timeFrame: timeframe, + } + }) + } catch (error) { + throw handleNitrogenBalanceInputCollectionError(error) + } } export const handleNitrogenBalanceInputCollectionError = diff --git a/fdm-calculator/src/balance/organic-matter/input.test.ts b/fdm-calculator/src/balance/organic-matter/input.test.ts index 296a9ff1e..b9f1fb586 100644 --- a/fdm-calculator/src/balance/organic-matter/input.test.ts +++ b/fdm-calculator/src/balance/organic-matter/input.test.ts @@ -26,8 +26,12 @@ vi.mock("@nmi-agro/fdm-core", async () => { getHarvests: vi.fn(), getSoilAnalyses: vi.fn(), getFertilizerApplications: vi.fn(), - getFertilizersFromCatalogueForFarms: vi.fn(), - getCultivationsFromCatalogueForFarms: vi.fn(), + getCultivationsFromCatalogue: vi.fn(), + getFertilizersFromCatalogue: vi.fn(), + getEnabledCultivationCataloguesForFarms: vi.fn(), + getEnabledFertilizerCataloguesForFarms: vi.fn(), + getCultivationsFromCatalogues: vi.fn(), + getFertilizersFromCatalogues: vi.fn(), } }) @@ -220,16 +224,12 @@ describe("collectInputForOrganicMatterBalance", () => { vi.spyOn(fdmCore, "getFertilizerApplications").mockResolvedValue( mockFertilizerApplicationsData, ) - vi.spyOn( - fdmCore, - "getFertilizersFromCatalogueForFarms", - ).mockResolvedValue({ - [b_id_farm]: mockFertilizerDetailsData, - }) - vi.spyOn( - fdmCore, - "getCultivationsFromCatalogueForFarms", - ).mockResolvedValue({ [b_id_farm]: mockCultivationDetailsData }) + vi.spyOn(fdmCore, "getFertilizersFromCatalogue").mockResolvedValue( + mockFertilizerDetailsData as any, + ) + vi.spyOn(fdmCore, "getCultivationsFromCatalogue").mockResolvedValue( + mockCultivationDetailsData as any, + ) const result = await collectInputForOrganicMatterBalance( mockFdm, @@ -255,14 +255,8 @@ describe("collectInputForOrganicMatterBalance", () => { vi.spyOn(fdmCore, "getHarvests").mockResolvedValue([]) vi.spyOn(fdmCore, "getSoilAnalyses").mockResolvedValue([]) vi.spyOn(fdmCore, "getFertilizerApplications").mockResolvedValue([]) - vi.spyOn( - fdmCore, - "getFertilizersFromCatalogueForFarms", - ).mockResolvedValue({}) - vi.spyOn( - fdmCore, - "getCultivationsFromCatalogueForFarms", - ).mockResolvedValue({}) + vi.spyOn(fdmCore, "getFertilizersFromCatalogue").mockResolvedValue([]) + vi.spyOn(fdmCore, "getCultivationsFromCatalogue").mockResolvedValue([]) const result = await collectInputForOrganicMatterBalance( mockFdm, @@ -296,20 +290,6 @@ describe("collectInputForOrganicMatterBalance", () => { ).rejects.toThrow("Field not found: non-existent-field") }) - it("should throw an error if there is a specified field but also multiple specified farms", async () => { - await expect( - collectInputForOrganicMatterBalanceForFarms( - mockFdm, - principal_id, - ["some-farm", "some-other-farm"], - timeframe, - "some-field", - ), - ).rejects.toThrow( - "b_id can only be used when collecting input for a single farm", - ) - }) - it("should correctly structure the output", async () => { const mockField = { b_id: "field1" } const mockCultivation = { b_lu: "cult1" } @@ -318,16 +298,12 @@ describe("collectInputForOrganicMatterBalance", () => { vi.spyOn(fdmCore, "getCultivations").mockResolvedValue([ mockCultivation, ] as any) - vi.spyOn( - fdmCore, - "getFertilizersFromCatalogueForFarms", - ).mockResolvedValue({ - [b_id_farm]: [mockFertilizer], - } as any) - vi.spyOn( - fdmCore, - "getCultivationsFromCatalogueForFarms", - ).mockResolvedValue({ [b_id_farm]: [mockCultivation] } as any) + vi.spyOn(fdmCore, "getFertilizersFromCatalogue").mockResolvedValue([ + mockFertilizer, + ] as any) + vi.spyOn(fdmCore, "getCultivationsFromCatalogue").mockResolvedValue([ + mockCultivation, + ] as any) vi.spyOn(fdmCore, "getHarvests").mockResolvedValue([]) vi.spyOn(fdmCore, "getSoilAnalyses").mockResolvedValue([]) vi.spyOn(fdmCore, "getFertilizerApplications").mockResolvedValue([ @@ -408,29 +384,47 @@ describe("collectInputForOrganicMatterBalanceForFarms", () => { ? 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, - b_id_farm: "test-farm-id", + p_source: "test-farm-id", })) const fertData2 = mockFertilizerDetailsData2.map((fert) => ({ ...fert, - b_id_farm: "test-farm-id-2", + p_source: "test-farm-id-2", })) - const allFertilizerDetails = { - "test-farm-id": fertData1, - "test-farm-id-2": fertData2, - } + const allFertilizerDetails = [...fertData1, ...fertData2] vi.spyOn( fdmCore, - "getFertilizersFromCatalogueForFarms", - ).mockResolvedValue(allFertilizerDetails) + "getEnabledCultivationCataloguesForFarms", + ).mockResolvedValue({ + "test-farm-id": ["brp"], + "test-farm-id-2": ["brp"], + }) vi.spyOn( fdmCore, - "getCultivationsFromCatalogueForFarms", + "getEnabledFertilizerCataloguesForFarms", ).mockResolvedValue({ - "test-farm-id": mockCultivationDetailsData, - "test-farm-id-2": mockCultivationDetailsData2, + "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, @@ -473,14 +467,14 @@ describe("collectInputForOrganicMatterBalanceForFarms", () => { b_id_farm: "test-farm-id", fields: expectedFieldInputs, fertilizerDetails: fertData1, - cultivationDetails: mockCultivationDetailsData, + cultivationDetails: cultDetailsWithSource1, timeFrame: timeframe, }, { b_id_farm: "test-farm-id-2", fields: expectedFieldInputs2, fertilizerDetails: fertData2, - cultivationDetails: mockCultivationDetailsData2, + cultivationDetails: cultDetailsWithSource2, timeFrame: timeframe, }, ] @@ -488,22 +482,32 @@ describe("collectInputForOrganicMatterBalanceForFarms", () => { expect(result).toEqual(expectedResult) expect( - fdmCore.getCultivationsFromCatalogueForFarms, + fdmCore.getEnabledCultivationCataloguesForFarms, ).toHaveBeenCalledWith(mockFdm, principal_id, [ "test-farm-id", "test-farm-id-2", ]) expect( - fdmCore.getCultivationsFromCatalogueForFarms, + fdmCore.getEnabledCultivationCataloguesForFarms, ).toHaveBeenCalledTimes(1) expect( - fdmCore.getFertilizersFromCatalogueForFarms, + fdmCore.getEnabledFertilizerCataloguesForFarms, ).toHaveBeenCalledWith(mockFdm, principal_id, [ "test-farm-id", "test-farm-id-2", ]) expect( - fdmCore.getFertilizersFromCatalogueForFarms, + fdmCore.getEnabledFertilizerCataloguesForFarms, ).toHaveBeenCalledTimes(1) + expect(fdmCore.getCultivationsFromCatalogues).toHaveBeenCalledWith( + mockFdm, + ["brp"], + ) + expect(fdmCore.getCultivationsFromCatalogues).toHaveBeenCalledTimes(1) + expect(fdmCore.getFertilizersFromCatalogues).toHaveBeenCalledWith( + mockFdm, + 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 ba0fa6f1d..fbf61a98e 100644 --- a/fdm-calculator/src/balance/organic-matter/input.ts +++ b/fdm-calculator/src/balance/organic-matter/input.ts @@ -6,9 +6,13 @@ import type { } from "@nmi-agro/fdm-core" import { getCultivations, - getCultivationsFromCatalogueForFarms, + getCultivationsFromCatalogues, + getCultivationsFromCatalogue, + getEnabledCultivationCataloguesForFarms, + getEnabledFertilizerCataloguesForFarms, getFertilizerApplications, - getFertilizersFromCatalogueForFarms, + getFertilizersFromCatalogues, + getFertilizersFromCatalogue, getField, getFields, getSoilAnalyses, @@ -133,33 +137,64 @@ async function collectInputForOrganicMatterBalanceForFarm( * * @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, - b_id?: fdmSchema.fieldsTypeSelect["b_id"], ): Promise<(OrganicMatterBalanceInput & { b_id_farm: string })[]> { try { - if (b_id && farmIds.length !== 1) { - throw new Error( - "b_id can only be used when collecting input for a single farm", - ) - } - // All data fetching is wrapped in a single database transaction to ensure consistency. return await fdm.transaction(async (tx: FdmType) => { - const cultivationDetails = - await getCultivationsFromCatalogueForFarms( - tx, - principal_id, - farmIds, - ) - const fertilizerDetails = await getFertilizersFromCatalogueForFarms( - tx, - principal_id, - 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, + farmIds, + ), + getEnabledFertilizerCataloguesForFarms( + tx, + principal_id, + farmIds, + ), + ]) + + // 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, uniqueFertilizerSources), + ]) + // Step 3: Process each farm using the pre-fetched catalogue data return await Promise.all( farmIds.map(async (b_id_farm) => { try { @@ -169,12 +204,12 @@ export async function collectInputForOrganicMatterBalanceForFarms( principal_id, b_id_farm, timeframe, - b_id, ) - // 3. Fetch farm-level catalogue data. - // These details are fetched once for the entire farm and reused for each field. - // Required cultivation and fertilizer details for this farm should be extracted to not break the cache + // 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( @@ -183,13 +218,15 @@ export async function collectInputForOrganicMatterBalanceForFarms( ), ) const cultivationDetailsForThisFarm = - cultivationDetails[b_id_farm]?.filter( - (cultivation) => - cultivationIds.has( - cultivation.b_lu_catalogue, - ), - ) ?? [] + 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( @@ -197,13 +234,13 @@ export async function collectInputForOrganicMatterBalanceForFarms( ), ), ) - const fertilizerDetailsForThisFarm = - fertilizerDetails[b_id_farm]?.filter((fert) => - fertilizerIds.has(fert.p_id_catalogue), - ) ?? [] + allFertilizers.filter( + (f) => + farmFertilizerSources.has(f.p_source) && + fertilizerIds.has(f.p_id_catalogue), + ) - // 4. Assemble the final input object. return { b_id_farm: b_id_farm, fields: onlyFieldInput, @@ -254,15 +291,36 @@ export async function collectInputForOrganicMatterBalance( timeframe: Timeframe, b_id?: fdmSchema.fieldsTypeSelect["b_id"], ) { - return ( - await collectInputForOrganicMatterBalanceForFarms( - fdm, - principal_id, - [b_id_farm], - timeframe, - b_id, - ) - )[0] + try { + return await fdm.transaction(async (tx: FdmType) => { + const cultivationDetails = await getCultivationsFromCatalogue( + tx, + principal_id, + b_id_farm, + ) + const fertilizerDetails = await getFertilizersFromCatalogue( + tx, + principal_id, + b_id_farm, + ) + const fields = await collectInputForOrganicMatterBalanceForFarm( + tx, + principal_id, + b_id_farm, + timeframe, + b_id, + ) + return { + b_id_farm, + fields, + fertilizerDetails, + cultivationDetails, + timeFrame: timeframe, + } + }) + } catch (error) { + throw handleOrganicMatterBalanceInputCollectionError(error) + } } export const handleOrganicMatterBalanceInputCollectionError = diff --git a/fdm-core/src/bulk.ts b/fdm-core/src/bulk.ts deleted file mode 100644 index 29188cc30..000000000 --- a/fdm-core/src/bulk.ts +++ /dev/null @@ -1,24 +0,0 @@ -export function splitBy( - items: T[], - fn: (obj: T) => string, -): Record { - const result: Record = {} - if (items && items.length > 0) { - let start = 0 - let end = 0 - while (end < items.length) { - const currentKey = fn(items[start]) - if (result[currentKey]) { - throw new Error( - `Key "${currentKey}" has been encountered twice`, - ) - } - for (; end < items.length; end++) { - if (fn(items[end]) !== currentKey) break - } - result[currentKey] = items.slice(start, end) - start = end - } - } - return result -} 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 fed5749a2..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,8 +11,8 @@ import { getCultivation, getCultivationPlan, getCultivations, + getCultivationsFromCatalogues, getCultivationsFromCatalogue, - getCultivationsFromCatalogueForFarms, getDefaultDatesOfCultivation, removeCultivation, updateCultivation, @@ -215,24 +216,39 @@ describe("Cultivation Data Model", () => { ).toBeDefined() }) - it("should get all cultivations for farms from catalogue", async () => { - const cultivations = await getCultivationsFromCatalogueForFarms( + 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(cultivations[b_id_farm]).toBeDefined() - expect(cultivations[b_id_farm_2]).toBeDefined() + 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[b_id_farm].find( - (cultivation) => - cultivation.b_lu_catalogue === b_lu_catalogue, + 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[b_id_farm_2].find( - (cultivation) => - cultivation.b_lu_catalogue === b_lu_catalogue_2, + cultivations.find( + (c) => + c.b_lu_catalogue === b_lu_catalogue_2 && + farmSources2.has(c.b_lu_source), ), ).toBeDefined() }) @@ -259,13 +275,6 @@ describe("Cultivation Data Model", () => { b_id_farm, ), ).toEqual([]) - expect( - await getCultivationsFromCatalogueForFarms(fdm, principal_id, [ - b_id_farm, - ]), - ).toEqual({ - [b_id_farm]: [], - }) }) it("should handle no enabled catalogues", async () => { @@ -284,16 +293,9 @@ describe("Cultivation Data Model", () => { b_id_farm, ), ).toEqual([]) - expect( - await getCultivationsFromCatalogueForFarms(fdm, principal_id, [ - b_id_farm, - ]), - ).toEqual({ - [b_id_farm]: [], - }) }) - it("(getCultivationsFromCatalogue) should rename the error if getCultivationsFromCatalogues throws an error", async () => { + it("(getCultivationsFromCatalogue) should wrap errors with the correct message", async () => { const failError = new Error("Should have thrown.") try { await getCultivationsFromCatalogue( @@ -311,39 +313,26 @@ describe("Cultivation Data Model", () => { expect((e as Error).message).toBe( "Exception for getCultivationsFromCatalogue", ) - const errorCause = (e as Error).cause - if (errorCause instanceof Error) { - expect(errorCause.message).not.toBe( - "Exception for getCultivationsFromCatalogues", - ) - } } }) - it("(getCultivationsFromCatalogueForFarms) should rename the error if getCultivationsFromCatalogues throws an error", async () => { + it("(getCultivationsFromCatalogues) should wrap errors with the correct message", async () => { const failError = new Error("Should have thrown.") try { - await getCultivationsFromCatalogueForFarms( + await getCultivationsFromCatalogues( mockFdmThatThrowsOnSelectFrom( fdm, schema.cultivationsCatalogue, ), - principal_id, - [b_id_farm], + [b_lu_source], ) throw failError } catch (err) { expect(err).not.toBe(failError) expect(err).toBeInstanceOf(Error) expect((err as Error).message).toBe( - "Exception for getCultivationsFromCatalogueForFarms", + "Exception for getCultivationsFromCatalogues", ) - const errorCause = (err as Error).cause - if (errorCause instanceof Error) { - expect(errorCause.message).not.toBe( - "Exception for getCultivationsFromCatalogues", - ) - } } }) diff --git a/fdm-core/src/cultivation.ts b/fdm-core/src/cultivation.ts index 9012adef4..93a89fc83 100644 --- a/fdm-core/src/cultivation.ts +++ b/fdm-core/src/cultivation.ts @@ -14,7 +14,7 @@ import { } from "drizzle-orm" import { checkPermission } from "./authorization" import type { PrincipalId } from "./authorization.d" -import { splitBy } from "./bulk" +import { getEnabledCultivationCatalogues } from "./catalogues" import type { Cultivation, CultivationCatalogue, @@ -34,16 +34,12 @@ import { import { createId } from "./id" import type { Timeframe } from "./timeframe" -/** Error message which will be replaced if getCultivationsFromCatalogue or getCultivationsFromCatalogueForFarms is rethrowing the error. */ -export const exceptionForGetCultivationsFromCatalogues = - "Exception for getCultivationsFromCatalogues" - /** * Retrieves cultivations available in the enabled catalogues for a farm. * * @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 ID of the farms. + * @param b_id_farm The ID of the farm. * @returns A Promise that resolves with an array of cultivation catalogue entries. * @alpha */ @@ -53,126 +49,17 @@ 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, - "getCultivationsOfFarmsFromCatalogue", - ) - - // Get enabled catalogues for the farm - const enabledCatalogues: Pick< - schema.cultivationCatalogueSelectingTypeSelect, - "b_lu_source" - >[] = await fdm - .selectDistinct({ - b_lu_source: schema.cultivationCatalogueSelecting.b_lu_source, - }) - .from(schema.cultivationCatalogueSelecting) - .where( - eq(schema.cultivationCatalogueSelecting.b_id_farm, b_id_farm), - ) - - const catalogueIds = enabledCatalogues.map((cat) => cat.b_lu_source) - - const catalogue = await getCultivationsFromCatalogues(fdm, catalogueIds) - - return catalogueIds.flatMap( - (b_lu_source) => catalogue[b_lu_source] ?? [], + b_id_farm, ) + return await getCultivationsFromCatalogues(fdm, catalogueIds) } catch (err) { throw handleError( - err instanceof Error && - err.message === exceptionForGetCultivationsFromCatalogues - ? err.cause - : err, + err, "Exception for getCultivationsFromCatalogue", - { - principal_id, - b_id_farm, - }, - ) - } -} - -/** - * Retrieves cultivations available in the enabled 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 ID of the farms. - * @returns A Promise that resolves with a map from farm IDs to arrays of cultivation catalogues. - * @alpha - */ -export async function getCultivationsFromCatalogueForFarms( - fdm: FdmType, - principal_id: PrincipalId, - farmIds: schema.farmsTypeSelect["b_id_farm"][], -) { - try { - await Promise.all( - farmIds.map((b_id_farm) => - checkPermission( - fdm, - "farm", - "read", - b_id_farm, - principal_id, - "getCultivationsOfFarmsFromCatalogue", - ), - ), - ) - - // Get enabled catalogues for the farm - const enabledCatalogues: Pick< - schema.cultivationCatalogueSelectingTypeSelect, - "b_id_farm" | "b_lu_source" - >[] = await fdm - .selectDistinct({ - 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, - ), - ) - .orderBy(asc(schema.cultivationCatalogueSelecting.b_id_farm)) - - // Catalogues enabled on each farm - const cataloguesByFarm = splitBy( - enabledCatalogues, - (cat) => cat.b_id_farm, - ) - - const catalogue = await getCultivationsFromCatalogues(fdm, [ - ...new Set(enabledCatalogues.map((cat) => cat.b_lu_source)), - ]) - - // Combine lists of cultivation catalogues enabled on each farm - return Object.fromEntries( - farmIds.map((b_id_farm) => [ - b_id_farm, - (cataloguesByFarm[b_id_farm] ?? []).flatMap( - (cat) => catalogue[cat.b_lu_source] ?? [], - ), - ]), - ) - } catch (err) { - throw handleError( - err instanceof Error && - err.message === exceptionForGetCultivationsFromCatalogues - ? err.cause - : err, - "Exception for getCultivationsFromCatalogueForFarms", - { - principal_id, - farmIds, - }, + { principal_id, b_id_farm }, ) } } @@ -183,23 +70,20 @@ export async function getCultivationsFromCatalogueForFarms( * 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 ID of the catalogues, such as "brp" or farm IDs. - * @returns A Promise that resolves with a map from catalogue IDs to arrays of cultivations. - * @internal + * @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 */ -async function getCultivationsFromCatalogues( +export async function getCultivationsFromCatalogues( fdm: FdmType, catalogueIds: schema.cultivationCatalogueSelectingTypeSelect["b_lu_source"][], -) { +): Promise { try { - // If no catalogues are enabled, return empty array if (catalogueIds.length === 0) { - return {} + return [] } - // Get cultivations from enabled catalogues - const cultivationsCatalogue: CultivationCatalogue[] = await fdm + return fdm .select() .from(schema.cultivationsCatalogue) .where( @@ -209,13 +93,8 @@ async function getCultivationsFromCatalogues( asc(schema.cultivationsCatalogue.b_lu_source), asc(schema.cultivationsCatalogue.b_lu_name), ) - - return splitBy( - cultivationsCatalogue, - (cultivation) => cultivation.b_lu_source, - ) } catch (err) { - throw handleError(err, exceptionForGetCultivationsFromCatalogues, { + throw handleError(err, "Exception for getCultivationsFromCatalogues", { catalogueIds, }) } diff --git a/fdm-core/src/fertilizer.test.ts b/fdm-core/src/fertilizer.test.ts index d304fd12c..540dc33b0 100644 --- a/fdm-core/src/fertilizer.test.ts +++ b/fdm-core/src/fertilizer.test.ts @@ -10,6 +10,7 @@ import { import { disableFertilizerCatalogue, enableFertilizerCatalogue, + getEnabledFertilizerCataloguesForFarms, } from "./catalogues" import * as schema from "./db/schema" import { applicationMethodOptions, fertilizersCatalogue } from "./db/schema" @@ -25,8 +26,8 @@ import { getFertilizerApplications, getFertilizerParametersDescription, getFertilizers, + getFertilizersFromCatalogues, getFertilizersFromCatalogue, - getFertilizersFromCatalogueForFarms, removeFertilizer, removeFertilizerApplication, updateFertilizerApplication, @@ -421,18 +422,41 @@ describe("Fertilizer Data Model", () => { makeFertilizer("Farm 2 Example Fertilizer 2"), ) await addTestFertilizer(b_id_farm_2, farm_2_fert_2) - const map = await getFertilizersFromCatalogueForFarms( + const farmCatalogues = await getEnabledFertilizerCataloguesForFarms( fdm, principal_id, [b_id_farm, b_id_farm_2], ) - expect(map[b_id_farm]).toBeDefined() - expect(map[b_id_farm].map((fert) => fert.p_name_nl)).toEqual([ + 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, + 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", ]) - expect(map[b_id_farm_2]).toBeDefined() - expect(map[b_id_farm_2].map((fert) => fert.p_name_nl)).toEqual([ + 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", ]) @@ -456,13 +480,6 @@ describe("Fertilizer Data Model", () => { expect( await getFertilizersFromCatalogue(fdm, principal_id, b_id_farm), ).toEqual([]) - expect( - await getFertilizersFromCatalogueForFarms(fdm, principal_id, [ - b_id_farm, - ]), - ).toEqual({ - [b_id_farm]: [], - }) }) it("should handle no enabled catalogues", async () => { @@ -477,16 +494,9 @@ describe("Fertilizer Data Model", () => { expect( await getFertilizersFromCatalogue(fdm, principal_id, b_id_farm), ).toEqual([]) - expect( - await getFertilizersFromCatalogueForFarms(fdm, principal_id, [ - b_id_farm, - ]), - ).toEqual({ - [b_id_farm]: [], - }) }) - it("(getFertilizersFromCatalogue) should rename the error if getFertilizersFromCatalogues throws an error", async () => { + it("(getFertilizersFromCatalogue) should wrap errors with the correct message", async () => { const failError = new Error("Should have thrown.") try { await getFertilizersFromCatalogue( @@ -504,24 +514,17 @@ describe("Fertilizer Data Model", () => { expect((e as Error).message).toBe( "Exception for getFertilizersFromCatalogue", ) - const errorCause = (e as Error).cause - if (errorCause instanceof Error) { - expect(errorCause.message).not.toBe( - "Exception for getFertilizersFromCatalogues", - ) - } } }) - it("(getFertilizersFromCatalogueForFarms) should rename the error if getFertilizersFromCatalogues throws an error", async () => { + it("(getFertilizersFromCatalogues) should wrap errors with the correct message", async () => { const failError = new Error("Should have thrown.") try { - await getFertilizersFromCatalogueForFarms( + await getFertilizersFromCatalogues( mockFdmThatThrowsOnSelectFrom( fdm, schema.fertilizersCatalogue, ), - principal_id, [b_id_farm], ) throw failError @@ -529,14 +532,8 @@ describe("Fertilizer Data Model", () => { expect(err).not.toBe(failError) expect(err).toBeInstanceOf(Error) expect((err as Error).message).toBe( - "Exception for getFertilizersFromCatalogueForFarms", + "Exception for getFertilizersFromCatalogues", ) - const errorCause = (err as Error).cause - if (errorCause instanceof Error) { - expect(errorCause.message).not.toBe( - "Exception for getFertilizersFromCatalogues", - ) - } } }) diff --git a/fdm-core/src/fertilizer.ts b/fdm-core/src/fertilizer.ts index e0e02f815..8eeffe304 100644 --- a/fdm-core/src/fertilizer.ts +++ b/fdm-core/src/fertilizer.ts @@ -6,7 +6,7 @@ import { import { and, asc, desc, eq, gte, inArray, lte } from "drizzle-orm" import { checkPermission } from "./authorization" import type { PrincipalId } from "./authorization.d" -import { splitBy } from "./bulk" +import { getEnabledFertilizerCatalogues } from "./catalogues" import * as schema from "./db/schema" import { handleError } from "./error" import type { FdmType } from "./fdm" @@ -34,188 +34,62 @@ export async function getFertilizersFromCatalogue( b_id_farm: schema.farmsTypeSelect["b_id_farm"], ): Promise { try { - await checkPermission( + const catalogueIds = await getEnabledFertilizerCatalogues( fdm, - "farm", - "read", - b_id_farm, principal_id, - "getFertilizersFromCatalogue", - ) - - // Get enabled catalogues for the farm - const enabledCatalogues: Pick< - schema.fertilizerCatalogueEnablingTypeSelect, - "p_source" - >[] = await fdm - .select({ - p_source: schema.fertilizerCatalogueEnabling.p_source, - }) - .from(schema.fertilizerCatalogueEnabling) - .where(eq(schema.fertilizerCatalogueEnabling.b_id_farm, b_id_farm)) - - const catalogues = await getFertilizersFromCatalogues( - fdm, - enabledCatalogues.map(({ p_source }) => p_source), - ) - - return enabledCatalogues.flatMap( - ({ p_source }) => catalogues[p_source] ?? [], + b_id_farm, ) + return await getFertilizersFromCatalogues(fdm, catalogueIds) } catch (err) { throw handleError( - err instanceof Error && - err.message === "Exception for getFertilizersFromCatalogues" - ? err.cause - : err, + err, "Exception for getFertilizersFromCatalogue", - { - principal_id, - b_id_farm, - }, + { principal_id, b_id_farm }, ) } } /** - * Retrieves all fertilizers from the enabled 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 with a map from b_id_farm to an array of catalogue fertilizers. - * @alpha - */ -export async function getFertilizersFromCatalogueForFarms( - fdm: FdmType, - principal_id: PrincipalId, - farmIds: string[], -): Promise< - Record< - schema.fertilizerCatalogueEnablingTypeSelect["b_id_farm"], - FertilizerCatalogue[] - > -> { - try { - // Check read permission for all of the farms - await Promise.all( - farmIds.map((b_id_farm) => - checkPermission( - fdm, - "farm", - "read", - b_id_farm, - principal_id, - "getFertilizersFromCatalogue", - ), - ), - ) - - // Get enabled catalogues for the farms - const allEnabledCatalogues: schema.fertilizerCatalogueEnablingTypeSelect[] = - 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, - ), - ) - .orderBy( - asc(schema.fertilizerCatalogueEnabling.b_id_farm), - asc(schema.fertilizerCatalogueEnabling.p_source), - ) - - const enabledCatalogues = [ - ...new Set(allEnabledCatalogues.map(({ p_source }) => p_source)), - ] - - const enabledCataloguesByFarm = splitBy( - allEnabledCatalogues, - ({ b_id_farm }) => b_id_farm, - ) - - const catalogues = await getFertilizersFromCatalogues( - fdm, - enabledCatalogues, - ) - - // Join each farm's enabled catalogues together - return Object.fromEntries( - farmIds.map((b_id_farm) => [ - b_id_farm, - (enabledCataloguesByFarm[b_id_farm] ?? []).flatMap( - (cat) => catalogues[cat.p_source] ?? [], - ), - ]), - ) - } catch (err) { - throw handleError( - err instanceof Error && - err.message === "Exception for getFertilizersFromCatalogues" - ? err.cause - : err, - "Exception for getFertilizersFromCatalogueForFarms", - { - principal_id, - farmIds, - }, - ) - } -} - -/** - * Retrieves all fertilizers from the list catalogues whose IDs are given. + * Retrieves all fertilizers from the catalogues whose source IDs are given. * * No permission checks are performed. This can change in the future if a permission system specific to the catalogue is implemented. * * @param fdm The FDM instance providing the connection to the database. The instance can be created with {@link createFdmServer}. - * @param catalogueIds Catalogues IDs to retrieve. These can be, for example, catalogue IDs found in fdm-data such as "baat", or farm IDs. - * @internal + * @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 */ -async function getFertilizersFromCatalogues( +export async function getFertilizersFromCatalogues( fdm: FdmType, catalogueIds: schema.fertilizersCatalogueTypeSelect["p_source"][], -): Promise> { +): Promise { try { - // If no catalogues are enabled, return empty array if (catalogueIds.length === 0) { - return {} + return [] } - // Get fertilizers from enabled catalogues const fertilizersCatalogue: schema.fertilizersCatalogueTypeSelect[] = await fdm .select() .from(schema.fertilizersCatalogue) .where( - inArray(schema.fertilizersCatalogue.p_source, catalogueIds), + inArray( + schema.fertilizersCatalogue.p_source, + catalogueIds, + ), ) .orderBy( asc(schema.fertilizersCatalogue.p_source), asc(schema.fertilizersCatalogue.p_name_nl), ) - const fertilizersCatalogueExtended = fertilizersCatalogue.map( - (result) => { - return { - ...result, - p_app_method_options: result.p_app_method_options as - | ApplicationMethods[] - | null, - p_type: deriveFertilizerType(result), - } - }, - ) - - return splitBy( - fertilizersCatalogueExtended, - (fertilizer) => fertilizer.p_source, - ) + 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 getFertilizersFromCatalogues", { catalogueIds, diff --git a/fdm-core/src/index.ts b/fdm-core/src/index.ts index 9ffa52797..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,8 +49,8 @@ export { getCultivation, getCultivationPlan, getCultivations, + getCultivationsFromCatalogues, getCultivationsFromCatalogue, - getCultivationsFromCatalogueForFarms, getDefaultDatesOfCultivation, removeCultivation, updateCultivation, @@ -97,8 +99,8 @@ export { getFertilizerApplications, getFertilizerParametersDescription, getFertilizers, + getFertilizersFromCatalogues, getFertilizersFromCatalogue, - getFertilizersFromCatalogueForFarms, removeFertilizer, removeFertilizerApplication, updateFertilizerApplication, From 48ec6bbbb47b533b08c9eb5b7eda939460aeb316 Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Tue, 31 Mar 2026 13:34:06 +0200 Subject: [PATCH 44/48] chore: update the changesets --- .changeset/breezy-spies-smoke.md | 2 +- .changeset/thin-steaks-sneeze.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/breezy-spies-smoke.md b/.changeset/breezy-spies-smoke.md index 5db9daaee..985b11d66 100644 --- a/.changeset/breezy-spies-smoke.md +++ b/.changeset/breezy-spies-smoke.md @@ -2,4 +2,4 @@ "@nmi-agro/fdm-core": minor --- -Added the the getFertilizersFromCatalogueForFarms and getCultivationsFromCatalogueForFarms methods which can retrieve all fertilizers or cultivations from catalogue respectively while avoiding fetching the same catalogue's items multiple times for different farms. +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/thin-steaks-sneeze.md b/.changeset/thin-steaks-sneeze.md index 04ad31714..fcdbf04f4 100644 --- a/.changeset/thin-steaks-sneeze.md +++ b/.changeset/thin-steaks-sneeze.md @@ -2,4 +2,4 @@ "@nmi-agro/fdm-calculator": minor --- -Added nitrogen and organic matter balance input collection for multiple farms which can reduce the number of database lookups when analyzing balance of farms in a region. +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. From fa074dca5b301fe2c0ff07c35f226661f19660d6 Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Tue, 31 Mar 2026 15:31:58 +0200 Subject: [PATCH 45/48] fix: address code review findings in org OM balance route and calculator inputs --- ...slug.$calendar.balance.nitrogen._index.tsx | 60 +++++++-- ...calendar.balance.organic-matter._index.tsx | 62 ++++++++-- .../src/balance/nitrogen/input.test.ts | 1 + fdm-calculator/src/balance/nitrogen/input.ts | 116 ++++++++++-------- .../src/balance/organic-matter/input.test.ts | 1 + .../src/balance/organic-matter/input.ts | 116 ++++++++++-------- fdm-core/src/fertilizer.test.ts | 2 + fdm-core/src/fertilizer.ts | 48 +++++++- 8 files changed, 274 insertions(+), 132 deletions(-) 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 index 989a418f6..d5ba801c5 100644 --- a/fdm-app/app/routes/organization.$slug.$calendar.balance.nitrogen._index.tsx +++ b/fdm-app/app/routes/organization.$slug.$calendar.balance.nitrogen._index.tsx @@ -240,14 +240,19 @@ export async function loader({ (result) => result.errorMessage, ) as string[], ) - const farmPrincipals = await listPrincipalsForFarm( - fdm, - principal_id, - farm.b_id_farm, - ) - const owner = farmPrincipals.find( - (p) => p.role === "owner" && p.type === "user", - ) + let owner: Awaited>[number] | undefined + try { + const farmPrincipals = await listPrincipalsForFarm( + fdm, + principal_id, + farm.b_id_farm, + ) + owner = farmPrincipals.find( + (p) => p.role === "owner" && p.type === "user", + ) + } catch { + owner = undefined + } if (nitrogenBalanceResult.hasErrors) { reportError( @@ -370,8 +375,29 @@ function OrganizationFarmBalanceNitrogenOverview(loaderData: LoaderData) { ({ 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 ? ( - `${balanceResult.balance} / ${balanceResult.target}` + <> + {`${balanceResult.balance} / ${balanceResult.target}`} + {deltaFormatted !== undefined && ( + + + + {deltaFormatted} + + + + {`Verschil t.o.v. het organisatiegemiddelde (${(Math.round((orgAverage ?? 0) * 10) / 10).toFixed(1)} kg N / ha)`} + + + )} + ) : ( result.errorMessage, ) as string[], ) - const farmPrincipals = await listPrincipalsForFarm( - fdm, - principal_id, - farm.b_id_farm, - ) - const owner = farmPrincipals.find( - (p) => p.role === "owner" && p.type === "user", - ) + let owner: Awaited>[number] | undefined + try { + const farmPrincipals = await listPrincipalsForFarm( + fdm, + principal_id, + farm.b_id_farm, + ) + owner = farmPrincipals.find( + (p) => p.role === "owner" && p.type === "user", + ) + } catch { + owner = undefined + } if (organicMatterBalanceResult.hasErrors) { reportError( @@ -373,8 +378,31 @@ function OrganizationFarmBalanceOrganicMatterOverview(loaderData: LoaderData) { 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-green-600" + : "text-red-600" return (
-
+
{!balanceResult.hasErrors ? ( - balanceResult.balance + <> + {balanceResult.balance} + {deltaFormatted !== undefined && ( + + + + {deltaFormatted} + + + + {`Verschil t.o.v. het organisatiegemiddelde (${(Math.round((orgAverage ?? 0) * 10) / 10).toFixed(1)} kg OS / ha)`} + + + )} + ) : ( { 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 01fd6eff5..13b4abbff 100644 --- a/fdm-calculator/src/balance/nitrogen/input.ts +++ b/fdm-calculator/src/balance/nitrogen/input.ts @@ -170,18 +170,20 @@ export async function collectInputForNitrogenBalanceForFarms( ): 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, - farmIds, + uniqueFarmIds, ), getEnabledFertilizerCataloguesForFarms( tx, principal_id, - farmIds, + uniqueFarmIds, ), ]) @@ -198,71 +200,77 @@ export async function collectInputForNitrogenBalanceForFarms( ] const [allCultivations, allFertilizers] = await Promise.all([ getCultivationsFromCatalogues(tx, uniqueCultivationSources), - getFertilizersFromCatalogues(tx, uniqueFertilizerSources), + getFertilizersFromCatalogues(tx, principal_id, uniqueFertilizerSources), ]) // Step 3: Process each farm using the pre-fetched catalogue data - return await Promise.all( - farmIds.map(async (b_id_farm) => { - try { - 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 farmSettled = await Promise.allSettled( + uniqueFarmIds.map(async (b_id_farm) => { + const onlyFieldInput = + await collectInputForNitrogenBalanceForFarm( + tx, + principal_id, + b_id_farm, + timeframe, ) - const cultivationIds = new Set( - onlyFieldInput.flatMap((input) => - input.cultivations.map( - (cultivation) => cultivation.b_lu_catalogue, - ), + + // 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 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 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), ) - 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, - } - } catch (error) { - throw handleNitrogenBalanceInputCollectionError( - error, - b_id_farm, - ) + 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) diff --git a/fdm-calculator/src/balance/organic-matter/input.test.ts b/fdm-calculator/src/balance/organic-matter/input.test.ts index b9f1fb586..41ea91495 100644 --- a/fdm-calculator/src/balance/organic-matter/input.test.ts +++ b/fdm-calculator/src/balance/organic-matter/input.test.ts @@ -506,6 +506,7 @@ describe("collectInputForOrganicMatterBalanceForFarms", () => { 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 fbf61a98e..3d346e8d0 100644 --- a/fdm-calculator/src/balance/organic-matter/input.ts +++ b/fdm-calculator/src/balance/organic-matter/input.ts @@ -163,18 +163,20 @@ export async function collectInputForOrganicMatterBalanceForFarms( ): 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, - farmIds, + uniqueFarmIds, ), getEnabledFertilizerCataloguesForFarms( tx, principal_id, - farmIds, + uniqueFarmIds, ), ]) @@ -191,71 +193,77 @@ export async function collectInputForOrganicMatterBalanceForFarms( ] const [allCultivations, allFertilizers] = await Promise.all([ getCultivationsFromCatalogues(tx, uniqueCultivationSources), - getFertilizersFromCatalogues(tx, uniqueFertilizerSources), + getFertilizersFromCatalogues(tx, principal_id, uniqueFertilizerSources), ]) // Step 3: Process each farm using the pre-fetched catalogue data - return await Promise.all( - farmIds.map(async (b_id_farm) => { - try { - 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 farmSettled = await Promise.allSettled( + uniqueFarmIds.map(async (b_id_farm) => { + const onlyFieldInput = + await collectInputForOrganicMatterBalanceForFarm( + tx, + principal_id, + b_id_farm, + timeframe, ) - const cultivationIds = new Set( - onlyFieldInput.flatMap((input) => - input.cultivations.map( - (cultivation) => cultivation.b_lu_catalogue, - ), + + // 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 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 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), ) - 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, - } - } catch (error) { - throw handleOrganicMatterBalanceInputCollectionError( - error, - b_id_farm, - ) + 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) diff --git a/fdm-core/src/fertilizer.test.ts b/fdm-core/src/fertilizer.test.ts index 540dc33b0..dab2b8962 100644 --- a/fdm-core/src/fertilizer.test.ts +++ b/fdm-core/src/fertilizer.test.ts @@ -438,6 +438,7 @@ describe("Fertilizer Data Model", () => { ] const allFertilizers = await getFertilizersFromCatalogues( fdm, + principal_id, allSources, ) const farm1Sources = new Set(farmCatalogues[b_id_farm]) @@ -525,6 +526,7 @@ describe("Fertilizer Data Model", () => { fdm, schema.fertilizersCatalogue, ), + principal_id, [b_id_farm], ) throw failError diff --git a/fdm-core/src/fertilizer.ts b/fdm-core/src/fertilizer.ts index 8eeffe304..cbc25bee0 100644 --- a/fdm-core/src/fertilizer.ts +++ b/fdm-core/src/fertilizer.ts @@ -3,11 +3,12 @@ import { 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 { @@ -39,7 +40,7 @@ export async function getFertilizersFromCatalogue( principal_id, b_id_farm, ) - return await getFertilizersFromCatalogues(fdm, catalogueIds) + return await getFertilizersFromCatalogues(fdm, principal_id, catalogueIds) } catch (err) { throw handleError( err, @@ -52,15 +53,17 @@ export async function getFertilizersFromCatalogue( /** * Retrieves all fertilizers from the catalogues whose source IDs are given. * - * No permission checks are performed. This can change in the future if a permission system specific to the catalogue is implemented. + * 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 { @@ -68,6 +71,43 @@ export async function getFertilizersFromCatalogues( return [] } + // 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.fertilizerCatalogueEnabling.p_source, + catalogueIds, + ), + ) + 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 [] + } + const fertilizersCatalogue: schema.fertilizersCatalogueTypeSelect[] = await fdm .select() @@ -75,7 +115,7 @@ export async function getFertilizersFromCatalogues( .where( inArray( schema.fertilizersCatalogue.p_source, - catalogueIds, + filteredCatalogueIds, ), ) .orderBy( From 427728ed765f3d729609914ea9ee603c10a3519d Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Wed, 1 Apr 2026 09:58:29 +0200 Subject: [PATCH 46/48] fix: address code review findings --- .../blocks/balance/nitrogen-chart.tsx | 2 +- ...slug.$calendar.balance.nitrogen._index.tsx | 147 ++++++++---------- ...calendar.balance.organic-matter._index.tsx | 40 +++-- fdm-core/src/fertilizer.test.ts | 2 +- 4 files changed, 89 insertions(+), 102 deletions(-) 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/routes/organization.$slug.$calendar.balance.nitrogen._index.tsx b/fdm-app/app/routes/organization.$slug.$calendar.balance.nitrogen._index.tsx index d5ba801c5..86158d574 100644 --- a/fdm-app/app/routes/organization.$slug.$calendar.balance.nitrogen._index.tsx +++ b/fdm-app/app/routes/organization.$slug.$calendar.balance.nitrogen._index.tsx @@ -5,7 +5,7 @@ import { type NitrogenBalanceFieldResultNumeric, type NitrogenBalanceNumeric, } from "@nmi-agro/fdm-calculator" -import { getFarms, getFields, listPrincipalsForFarm } from "@nmi-agro/fdm-core" +import { getFarms, getFields } from "@nmi-agro/fdm-core" import { ArrowDown, ArrowRight, @@ -54,7 +54,6 @@ type Organization = Awaited< >[number] type FarmResult = { farm: Farm - owner: Awaited>[number] | undefined totalArea: number nitrogenBalanceResult: NitrogenBalanceNumeric & { errorMessage?: string @@ -222,81 +221,74 @@ export async function loader({ farmsExtended.sort((f1, f2) => f2.b_area_farm - f1.b_area_farm) const farmResults = await Promise.all( - farmsExtended - .filter((farm) => rawFarmResultsMap[farm.b_id_farm]) - .map(async (farm) => { - try { - const fieldResults = - rawFarmResultsMap[farm.b_id_farm] - const nitrogenBalanceResult = - calculateNitrogenBalancesFieldToFarm( - fieldResults, - fieldResults.some( + farmsExtended.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, - ), - fieldResults - .filter((result) => result.errorMessage) - .map( - (result) => result.errorMessage, - ) as string[], - ) - let owner: Awaited>[number] | undefined - try { - const farmPrincipals = await listPrincipalsForFarm( - fdm, - principal_id, - farm.b_id_farm, - ) - owner = farmPrincipals.find( - (p) => p.role === "owner" && p.type === "user", - ) - } catch { - owner = undefined - } - - 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, - }, - ) - } + ) 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, - owner: owner, - totalArea: farm.b_area_farm, - nitrogenBalanceResult: - nitrogenBalanceResult as NitrogenBalanceNumeric & { - errorMessage?: string - }, - } - } catch (error) { - return { - farm: farm, - owner: undefined, - totalArea: farm.b_area_farm, - nitrogenBalanceResult: { - hasErrors: true, - errorMessage: - error instanceof Error - ? error.message - : String(error), - } as NitrogenBalanceNumeric & { + 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 { @@ -367,10 +359,7 @@ function OrganizationFarmBalanceNitrogenOverview(loaderData: LoaderData) { const { combinedResult: resolvedNitrogenBalanceResult, farmResults } = asyncData - const farmChartBalanceData = resolvedNitrogenBalanceResult as unknown as { - balance: number - removal: number - } & NitrogenBalanceNumeric + const farmChartBalanceData = resolvedNitrogenBalanceResult const hasErrors = farmResults.some( ({ nitrogenBalanceResult }) => nitrogenBalanceResult.hasErrors, ) @@ -432,7 +421,9 @@ function OrganizationFarmBalanceNitrogenOverview(loaderData: LoaderData) { {deltaFormatted !== undefined && ( - + {deltaFormatted} @@ -461,7 +452,7 @@ function OrganizationFarmBalanceNitrogenOverview(loaderData: LoaderData) { - Overschot / Doel (Alle Bedrijven) + Overschot / Doel (Alle bedrijven) 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 index fb9427335..538730af7 100644 --- 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 @@ -5,7 +5,7 @@ import { type OrganicMatterBalanceFieldResultNumeric, type OrganicMatterBalanceNumeric, } from "@nmi-agro/fdm-calculator" -import { getFarms, getFields, listPrincipalsForFarm } from "@nmi-agro/fdm-core" +import { getFarms, getFields } from "@nmi-agro/fdm-core" import { ArrowDownToLine, ArrowRightLeft, @@ -52,7 +52,6 @@ type Organization = Awaited< >[number] type FarmResult = { farm: Farm - owner: Awaited>[number] | undefined totalArea: number organicMatterBalanceResult: OrganicMatterBalanceNumeric & { errorMessage?: string @@ -222,11 +221,23 @@ export async function loader({ const farmResults = await Promise.all( farmsExtended - .filter((farm) => rawFarmResultsMap[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 fieldResults = - rawFarmResultsMap[farm.b_id_farm] const organicMatterBalanceResult = calculateOrganicMatterBalancesFieldToFarm( fieldResults, @@ -239,19 +250,6 @@ export async function loader({ (result) => result.errorMessage, ) as string[], ) - let owner: Awaited>[number] | undefined - try { - const farmPrincipals = await listPrincipalsForFarm( - fdm, - principal_id, - farm.b_id_farm, - ) - owner = farmPrincipals.find( - (p) => p.role === "owner" && p.type === "user", - ) - } catch { - owner = undefined - } if (organicMatterBalanceResult.hasErrors) { reportError( @@ -272,7 +270,6 @@ export async function loader({ return { farm: farm, - owner: owner, totalArea: farm.b_area_farm, organicMatterBalanceResult: organicMatterBalanceResult as OrganicMatterBalanceNumeric & { @@ -282,7 +279,6 @@ export async function loader({ } catch (error) { return { farm: farm, - owner: undefined, totalArea: farm.b_area_farm, organicMatterBalanceResult: { hasErrors: true, @@ -401,8 +397,8 @@ function OrganizationFarmBalanceOrganicMatterOverview(loaderData: LoaderData) { delta === undefined ? "text-orange-500" : delta < 0 - ? "text-green-600" - : "text-red-600" + ? "text-red-600" + : "text-green-600" return (
{ p_name_nl: name, p_name_en: name, p_description: `This is ${name}`, - p_type: ["manure", "mineral", "compost", "other"][ + p_type: (["manure", "mineral", "compost", null] as const)[ Math.floor(Math.random() * 4) ], p_type_rvo: "10", From 548af95da644ff3cf0ddfb0460044525dabae356 Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Wed, 1 Apr 2026 10:12:35 +0200 Subject: [PATCH 47/48] fix: db insert in case of race condition --- fdm-core/src/calculator.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) 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() } /** From 8de9d8aae8f49a1d24cfe9172e48893bbeaa8f51 Mon Sep 17 00:00:00 2001 From: SvenVw <37927107+SvenVw@users.noreply.github.com> Date: Wed, 1 Apr 2026 10:31:27 +0200 Subject: [PATCH 48/48] refactor: address code review comments --- ...slug.$calendar.balance.nitrogen._index.tsx | 132 +++++++++--------- ...calendar.balance.organic-matter._index.tsx | 25 ++-- fdm-core/src/fertilizer.test.ts | 10 +- 3 files changed, 85 insertions(+), 82 deletions(-) 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 index 86158d574..e7cf7fc3a 100644 --- a/fdm-app/app/routes/organization.$slug.$calendar.balance.nitrogen._index.tsx +++ b/fdm-app/app/routes/organization.$slug.$calendar.balance.nitrogen._index.tsx @@ -220,75 +220,79 @@ export async function loader({ // 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.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, + 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 & { + 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 }, + } } - } 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 { @@ -392,7 +396,9 @@ function OrganizationFarmBalanceNitrogenOverview(loaderData: LoaderData) { className="flex items-center grow" key={farmResult.farm.b_id_farm} > - {Number.isFinite(balanceResult.balance) ? ( + {balanceResult.hasErrors ? ( + + ) : Number.isFinite(balanceResult.balance) ? ( balanceResult.balance <= balanceResult.target ? ( ) : ( 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 index 538730af7..7cd89ce57 100644 --- 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 @@ -147,8 +147,9 @@ export async function loader({ } } - const farmIds = - searchParamFarmIds ?? farms.map((farm) => farm.b_id_farm) + const farmIds = searchParamFarmIds + ? [...new Set(searchParamFarmIds)] + : farms.map((farm) => farm.b_id_farm) const allFarmIds = new Set(farms.map((farm) => farm.b_id_farm)) @@ -219,11 +220,12 @@ export async function loader({ // 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] + const fieldResults = rawFarmResultsMap[farm.b_id_farm] if (!fieldResults || fieldResults.length === 0) { return { farm: farm, @@ -364,11 +366,6 @@ function OrganizationFarmBalanceOrganicMatterOverview(loaderData: LoaderData) { const { combinedResult: resolvedOrganicMatterBalanceResult, farmResults } = asyncData - const farmChartBalanceData = - resolvedOrganicMatterBalanceResult as unknown as { - supply: number - removal: number - } & OrganicMatterBalanceNumeric const hasErrors = farmResults.some( ({ organicMatterBalanceResult }) => organicMatterBalanceResult.hasErrors, @@ -433,7 +430,9 @@ function OrganizationFarmBalanceOrganicMatterOverview(loaderData: LoaderData) { {deltaFormatted !== undefined && ( - + {deltaFormatted} @@ -541,8 +540,10 @@ function OrganizationFarmBalanceOrganicMatterOverview(loaderData: LoaderData) { diff --git a/fdm-core/src/fertilizer.test.ts b/fdm-core/src/fertilizer.test.ts index ba41a3c65..c15bc2bcd 100644 --- a/fdm-core/src/fertilizer.test.ts +++ b/fdm-core/src/fertilizer.test.ts @@ -445,9 +445,7 @@ describe("Fertilizer Data Model", () => { const farm1Fertilizers = allFertilizers.filter((f) => farm1Sources.has(f.p_source), ) - expect( - farm1Fertilizers.map((fert) => fert.p_name_nl), - ).toEqual([ + expect(farm1Fertilizers.map((fert) => fert.p_name_nl)).toEqual([ "Farm 1 Example Fertilizer 1", "Farm 1 Example Fertilizer 2", ]) @@ -455,15 +453,13 @@ describe("Fertilizer Data Model", () => { const farm2Fertilizers = allFertilizers.filter((f) => farm2Sources.has(f.p_source), ) - expect( - farm2Fertilizers.map((fert) => fert.p_name_nl), - ).toEqual([ + expect(farm2Fertilizers.map((fert) => fert.p_name_nl)).toEqual([ "Farm 2 Example Fertilizer 1", "Farm 2 Example Fertilizer 2", ]) }) - it("should handle empty catalogues", async () => { + it("should return empty array when enabled catalogue source has no entries", async () => { const b_id_farm = await addFarm( fdm, principal_id,