From 0928d33fa34c8d556ee3cdea42534c89ef7f37fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lach=C3=A9=20Melvin?= <55115239+lache-melvin@users.noreply.github.com> Date: Wed, 23 Oct 2024 08:20:14 +1300 Subject: [PATCH 01/32] bump to RC1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f5b05a7922..772165978d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "open-msupply", "//": "Main version for the app, should be in semantic version format (any release candidate or test build should be separated by '-' i.e. 1.1.1-rc1 or 1.1.1-test", - "version": "2.3.01", + "version": "2.3.01-RC1", "private": true, "scripts": { "start": "cd ./server && cargo run & cd ./client && yarn start-local", From 8b82f43f6cb8a25a14a4a49274714bfb8ba71e5f Mon Sep 17 00:00:00 2001 From: Carl Smith <5456533+CarlosNZ@users.noreply.github.com> Date: Wed, 30 Oct 2024 20:00:28 -0400 Subject: [PATCH 02/32] Change dates that should be naive to Naive Date displaly --- .../common/src/intl/utils/DateUtils.test.ts | 9 +-- .../common/src/intl/utils/DateUtils.ts | 60 +++++++++---------- .../components/inputs/Filters/DateFilter.tsx | 11 ++-- .../src/JsonForms/common/components/Date.tsx | 3 +- .../src/JsonForms/components/DateOfBirth.tsx | 2 +- .../Patient/PatientView/PatientSummary.tsx | 2 +- .../src/Stock/Components/StockLineForm.tsx | 2 +- .../system/src/Stock/ListView/ListView.tsx | 2 +- 8 files changed, 44 insertions(+), 47 deletions(-) diff --git a/client/packages/common/src/intl/utils/DateUtils.test.ts b/client/packages/common/src/intl/utils/DateUtils.test.ts index baef4fbe33..89d781e192 100644 --- a/client/packages/common/src/intl/utils/DateUtils.test.ts +++ b/client/packages/common/src/intl/utils/DateUtils.test.ts @@ -1,15 +1,12 @@ -import { renderHookWithProvider } from '../../utils/testing'; -import { useFormatDateTime } from './DateUtils'; +import { DateUtils } from './DateUtils'; describe('useFormatDateTime', () => { - it('getLocalDateTime returns start of day for local timezone regardless of time zone', () => { - const { result } = renderHookWithProvider(useFormatDateTime); + it('getNaiveDate returns start of day for local timezone regardless of time zone', () => { const timeZone = new Intl.DateTimeFormat().resolvedOptions().timeZone; const date = '2024-02-07'; expect( - result.current - .getLocalDate(date, undefined, undefined, timeZone) + DateUtils.getNaiveDate(date, undefined, undefined, timeZone) ?.toString() .slice(0, 24) ).toBe('Wed Feb 07 2024 00:00:00'); diff --git a/client/packages/common/src/intl/utils/DateUtils.ts b/client/packages/common/src/intl/utils/DateUtils.ts index 6ce24bf0b1..0402a5c8f1 100644 --- a/client/packages/common/src/intl/utils/DateUtils.ts +++ b/client/packages/common/src/intl/utils/DateUtils.ts @@ -104,6 +104,35 @@ export const DateUtils = { : new Date(date); return isValid(maybeDate) ? maybeDate : null; }, + /** + * While getDateOrNull is naive to the timezone, the timezone will still + * change. When converting from the assumed naive zone of GMT to the local + * timezone, the dateTime will be wrong if the timezone is behind GMT. + * For example: for a user in -10 timezone, a date of 24-02-2024 will become + * 2024-02-23T13:00:00.000Z when rendered for mui datepicker. + * This function acts in the same way as getDateOrNull, but will create a + * datetime of start of day local time rather than start of day GMT by + * subtracting the local timezone offset. + * You can use this function anytime you need a datetime for mui date picker + * to be created from a date only string. This includes date of birth, date of + * death or any other date which is time and timezone agnostic. + */ + getNaiveDate: ( + date?: Date | string | null, + format?: string, + options?: ParseOptions, + timeZone?: string + ): Date | null => { + // tz passed as props options for testing purposes + const tz = timeZone ?? new Intl.DateTimeFormat().resolvedOptions().timeZone; + const UTCDateWithoutTime = DateUtils.getDateOrNull(date, format, options); + const offset = UTCDateWithoutTime + ? getTimezoneOffset(tz, UTCDateWithoutTime) + : 0; + return UTCDateWithoutTime + ? addMilliseconds(UTCDateWithoutTime, -offset) + : null; + }, minDate: (...dates: (Date | null)[]) => { const maybeDate = fromUnixTime( Math.min( @@ -221,36 +250,6 @@ export const useFormatDateTime = () => { : ''; }; - /** - * While getDateOrNull is naive to the timezone, the timezone will still change. - * When converting from the assumed naive zone of GMT to the local timezone, the - * dateTime will be wrong if the timezone is behind GMT. - * For example: for a user in -10 timezone, a date of 24-02-2024 will become - * 2024-02-23T13:00:00.000Z when rendered for mui datepicker. - * This function acts in the same way as getDateOrNull, but will create a datetime - * of start of day local time rather than start of day GMT by subtracting the local - * timezone offset. - * You can use this function anytime you need a datetime for mui date picker to - * be created from a date only string. This includes date of birth, date of death - * or any other date which is time and timezone agnostic. - */ - const getLocalDate = ( - date?: Date | string | null, - format?: string, - options?: ParseOptions, - timeZone?: string - ): Date | null => { - // tz passed as props options for testing purposes - const tz = timeZone ?? new Intl.DateTimeFormat().resolvedOptions().timeZone; - const UTCDateWithoutTime = DateUtils.getDateOrNull(date, format, options); - const offset = UTCDateWithoutTime - ? getTimezoneOffset(tz, UTCDateWithoutTime) - : 0; - return UTCDateWithoutTime - ? addMilliseconds(UTCDateWithoutTime, -offset) - : null; - }; - return { urlQueryDate, urlQueryDateTime, @@ -263,6 +262,5 @@ export const useFormatDateTime = () => { localisedDistanceToNow, localisedTime, relativeDateTime, - getLocalDate, }; }; diff --git a/client/packages/common/src/ui/components/inputs/Filters/DateFilter.tsx b/client/packages/common/src/ui/components/inputs/Filters/DateFilter.tsx index f55b5ea115..f784d96d4b 100644 --- a/client/packages/common/src/ui/components/inputs/Filters/DateFilter.tsx +++ b/client/packages/common/src/ui/components/inputs/Filters/DateFilter.tsx @@ -72,7 +72,7 @@ export const DateFilter: FC<{ filterDefinition: DateFilterDefinition }> = ({ const getDateFromUrl = (query: string, range: RangeOption | undefined) => { const value = typeof query !== 'object' || !range ? query : query[range]; - return DateUtils.getDateOrNull(value); + return DateUtils.getNaiveDate(value); }; const getRangeBoundary = ( @@ -86,10 +86,11 @@ const getRangeBoundary = ( if (range === 'from') return to - ? DateUtils.minDate(DateUtils.getDateOrNull(to), limitDate) ?? undefined - : limitDate ?? undefined; + ? (DateUtils.minDate(DateUtils.getDateOrNull(to), limitDate) ?? undefined) + : (limitDate ?? undefined); else return from - ? DateUtils.maxDate(DateUtils.getDateOrNull(from), limitDate) ?? undefined - : limitDate ?? undefined; + ? (DateUtils.maxDate(DateUtils.getDateOrNull(from), limitDate) ?? + undefined) + : (limitDate ?? undefined); }; diff --git a/client/packages/programs/src/JsonForms/common/components/Date.tsx b/client/packages/programs/src/JsonForms/common/components/Date.tsx index 29a2486a19..b0779015d8 100644 --- a/client/packages/programs/src/JsonForms/common/components/Date.tsx +++ b/client/packages/programs/src/JsonForms/common/components/Date.tsx @@ -5,6 +5,7 @@ import { DetailInputWithLabelRow, useFormatDateTime, BaseDatePickerInput, + DateUtils, } from '@openmsupply-client/common'; import { DefaultFormRowSx, FORM_LABEL_WIDTH } from '../styleConstants'; import { z } from 'zod'; @@ -52,7 +53,7 @@ const UIComponent = (props: ControlProps) => { { handleChange( path, diff --git a/client/packages/programs/src/JsonForms/components/DateOfBirth.tsx b/client/packages/programs/src/JsonForms/components/DateOfBirth.tsx index 809741e4cb..d0b167bad3 100644 --- a/client/packages/programs/src/JsonForms/components/DateOfBirth.tsx +++ b/client/packages/programs/src/JsonForms/components/DateOfBirth.tsx @@ -86,7 +86,7 @@ const UIComponent = (props: ControlProps) => { { const { setCustomBreadcrumbs } = useBreadcrumbs(); const t = useTranslation('dispensary'); const formatDateOfBirth = (dateOfBirth: string | null) => { - const dob = DateUtils.getDateOrNull(dateOfBirth); + const dob = DateUtils.getNaiveDate(dateOfBirth); return !dob ? '' diff --git a/client/packages/system/src/Stock/Components/StockLineForm.tsx b/client/packages/system/src/Stock/Components/StockLineForm.tsx index 412708558d..57af8df7ec 100644 --- a/client/packages/system/src/Stock/Components/StockLineForm.tsx +++ b/client/packages/system/src/Stock/Components/StockLineForm.tsx @@ -152,7 +152,7 @@ export const StockLineForm: FC = ({ label={t('label.expiry')} Input={ onUpdate({ expiryDate: Formatter.naiveDate(date) }) } diff --git a/client/packages/system/src/Stock/ListView/ListView.tsx b/client/packages/system/src/Stock/ListView/ListView.tsx index 2951c2b24e..252f8eaa0c 100644 --- a/client/packages/system/src/Stock/ListView/ListView.tsx +++ b/client/packages/system/src/Stock/ListView/ListView.tsx @@ -107,7 +107,7 @@ const StockListComponent: FC = () => { [ 'expiryDate', { - accessor: ({ rowData }) => DateUtils.getDateOrNull(rowData.expiryDate), + accessor: ({ rowData }) => DateUtils.getNaiveDate(rowData.expiryDate), width: 110, }, ], From 352bfe0ce265b03382a6b2b56cbbcc2b42be59c4 Mon Sep 17 00:00:00 2001 From: Carl Smith <5456533+CarlosNZ@users.noreply.github.com> Date: Thu, 31 Oct 2024 13:17:44 +1300 Subject: [PATCH 03/32] Change date range filter to naive date --- .../common/src/ui/components/inputs/Filters/DateFilter.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/packages/common/src/ui/components/inputs/Filters/DateFilter.tsx b/client/packages/common/src/ui/components/inputs/Filters/DateFilter.tsx index f784d96d4b..c18d175c8f 100644 --- a/client/packages/common/src/ui/components/inputs/Filters/DateFilter.tsx +++ b/client/packages/common/src/ui/components/inputs/Filters/DateFilter.tsx @@ -80,17 +80,17 @@ const getRangeBoundary = ( range: RangeOption | undefined, limit: Date | string | undefined ) => { - const limitDate = DateUtils.getDateOrNull(limit); + const limitDate = DateUtils.getNaiveDate(limit); if (typeof query !== 'object' || !range) return limitDate || undefined; const { from, to } = query as RangeObject; if (range === 'from') return to - ? (DateUtils.minDate(DateUtils.getDateOrNull(to), limitDate) ?? undefined) + ? (DateUtils.minDate(DateUtils.getNaiveDate(to), limitDate) ?? undefined) : (limitDate ?? undefined); else return from - ? (DateUtils.maxDate(DateUtils.getDateOrNull(from), limitDate) ?? + ? (DateUtils.maxDate(DateUtils.getNaiveDate(from), limitDate) ?? undefined) : (limitDate ?? undefined); }; From ca72899c68d58231f7a227c47a097aed7e3684b4 Mon Sep 17 00:00:00 2001 From: roxy-dao Date: Thu, 31 Oct 2024 14:34:27 +1300 Subject: [PATCH 04/32] Expose AMC and SOH in node --- server/graphql/types/src/types/requisition_line.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/server/graphql/types/src/types/requisition_line.rs b/server/graphql/types/src/types/requisition_line.rs index 741fa6d691..cd8d53f778 100644 --- a/server/graphql/types/src/types/requisition_line.rs +++ b/server/graphql/types/src/types/requisition_line.rs @@ -228,6 +228,14 @@ impl RequisitionLineNode { Ok(result_option.map(RequisitionLineNode::from_domain)) } + + pub async fn available_stock_on_hand(&self) -> &f64 { + &self.row().available_stock_on_hand + } + + pub async fn average_monthly_consumption(&self) -> &f64 { + &self.row().average_monthly_consumption + } } impl RequisitionLineNode { From fc0e6bba80830cd1674c8c003b300daaaa622dab Mon Sep 17 00:00:00 2001 From: roxy-dao Date: Thu, 31 Oct 2024 14:35:35 +1300 Subject: [PATCH 05/32] Use row AMC instead of linked req item stats --- client/packages/common/src/types/schema.ts | 2 ++ .../src/ResponseRequisition/DetailView/columns.ts | 6 ++---- .../src/ResponseRequisition/api/operations.generated.ts | 8 +++++--- .../src/ResponseRequisition/api/operations.graphql | 2 ++ 4 files changed, 11 insertions(+), 7 deletions(-) diff --git a/client/packages/common/src/types/schema.ts b/client/packages/common/src/types/schema.ts index af7960ec7a..3c51bd24c6 100644 --- a/client/packages/common/src/types/schema.ts +++ b/client/packages/common/src/types/schema.ts @@ -6197,6 +6197,8 @@ export type RequisitionLineNode = { alreadyIssued: Scalars['Float']['output']; approvalComment?: Maybe; approvedQuantity: Scalars['Float']['output']; + availableStockOnHand: Scalars['Float']['output']; + averageMonthlyConsumption: Scalars['Float']['output']; comment?: Maybe; id: Scalars['String']['output']; /** InboundShipment lines linked to requisitions line */ diff --git a/client/packages/requisitions/src/ResponseRequisition/DetailView/columns.ts b/client/packages/requisitions/src/ResponseRequisition/DetailView/columns.ts index 1466c7000c..652a5ae651 100644 --- a/client/packages/requisitions/src/ResponseRequisition/DetailView/columns.ts +++ b/client/packages/requisitions/src/ResponseRequisition/DetailView/columns.ts @@ -75,12 +75,10 @@ export const useResponseColumns = () => { description: 'description.customer-soh', width: 100, align: ColumnAlign.Right, - getSortValue: rowData => - rowData.linkedRequisitionLine?.itemStats?.availableStockOnHand ?? '', + getSortValue: rowData => rowData?.availableStockOnHand ?? '', Cell: PackVariantQuantityCell({ getItemId: row => row.itemId, - getQuantity: row => - row?.linkedRequisitionLine?.itemStats.availableStockOnHand ?? 0, + getQuantity: row => row?.availableStockOnHand ?? 0, }), }, [ diff --git a/client/packages/requisitions/src/ResponseRequisition/api/operations.generated.ts b/client/packages/requisitions/src/ResponseRequisition/api/operations.generated.ts index 199c4dbc8e..3a7c421f1d 100644 --- a/client/packages/requisitions/src/ResponseRequisition/api/operations.generated.ts +++ b/client/packages/requisitions/src/ResponseRequisition/api/operations.generated.ts @@ -11,9 +11,9 @@ export type UpdateResponseMutationVariables = Types.Exact<{ export type UpdateResponseMutation = { __typename: 'Mutations', updateResponseRequisition: { __typename: 'RequisitionNode', id: string } | { __typename: 'UpdateResponseRequisitionError' } }; -export type ResponseLineFragment = { __typename: 'RequisitionLineNode', id: string, itemId: string, requestedQuantity: number, supplyQuantity: number, remainingQuantityToSupply: number, alreadyIssued: number, comment?: string | null, approvedQuantity: number, approvalComment?: string | null, itemStats: { __typename: 'ItemStatsNode', availableStockOnHand: number, availableMonthsOfStockOnHand?: number | null, averageMonthlyConsumption: number }, item: { __typename: 'ItemNode', id: string, name: string, code: string, unitName?: string | null }, linkedRequisitionLine?: { __typename: 'RequisitionLineNode', itemStats: { __typename: 'ItemStatsNode', availableStockOnHand: number } } | null }; +export type ResponseLineFragment = { __typename: 'RequisitionLineNode', id: string, itemId: string, requestedQuantity: number, supplyQuantity: number, remainingQuantityToSupply: number, alreadyIssued: number, comment?: string | null, averageMonthlyConsumption: number, availableStockOnHand: number, approvedQuantity: number, approvalComment?: string | null, itemStats: { __typename: 'ItemStatsNode', availableStockOnHand: number, availableMonthsOfStockOnHand?: number | null, averageMonthlyConsumption: number }, item: { __typename: 'ItemNode', id: string, name: string, code: string, unitName?: string | null }, linkedRequisitionLine?: { __typename: 'RequisitionLineNode', itemStats: { __typename: 'ItemStatsNode', availableStockOnHand: number } } | null }; -export type ResponseFragment = { __typename: 'RequisitionNode', id: string, type: Types.RequisitionNodeType, status: Types.RequisitionNodeStatus, createdDatetime: string, sentDatetime?: string | null, finalisedDatetime?: string | null, requisitionNumber: number, colour?: string | null, theirReference?: string | null, comment?: string | null, otherPartyName: string, otherPartyId: string, maxMonthsOfStock: number, minMonthsOfStock: number, approvalStatus: Types.RequisitionNodeApprovalStatus, programName?: string | null, orderType?: string | null, user?: { __typename: 'UserNode', username: string, email?: string | null } | null, shipments: { __typename: 'InvoiceConnector', totalCount: number, nodes: Array<{ __typename: 'InvoiceNode', id: string, invoiceNumber: number, createdDatetime: string, user?: { __typename: 'UserNode', username: string } | null }> }, linesRemainingToSupply: { __typename: 'RequisitionLineConnector', totalCount: number }, lines: { __typename: 'RequisitionLineConnector', totalCount: number, nodes: Array<{ __typename: 'RequisitionLineNode', id: string, itemId: string, requestedQuantity: number, supplyQuantity: number, remainingQuantityToSupply: number, alreadyIssued: number, comment?: string | null, approvedQuantity: number, approvalComment?: string | null, itemStats: { __typename: 'ItemStatsNode', availableStockOnHand: number, availableMonthsOfStockOnHand?: number | null, averageMonthlyConsumption: number }, item: { __typename: 'ItemNode', id: string, name: string, code: string, unitName?: string | null }, linkedRequisitionLine?: { __typename: 'RequisitionLineNode', itemStats: { __typename: 'ItemStatsNode', availableStockOnHand: number } } | null }> }, otherParty: { __typename: 'NameNode', id: string, code: string, isCustomer: boolean, isSupplier: boolean, isOnHold: boolean, name: string, store?: { __typename: 'StoreNode', id: string, code: string } | null }, period?: { __typename: 'PeriodNode', name: string, startDate: string, endDate: string } | null }; +export type ResponseFragment = { __typename: 'RequisitionNode', id: string, type: Types.RequisitionNodeType, status: Types.RequisitionNodeStatus, createdDatetime: string, sentDatetime?: string | null, finalisedDatetime?: string | null, requisitionNumber: number, colour?: string | null, theirReference?: string | null, comment?: string | null, otherPartyName: string, otherPartyId: string, maxMonthsOfStock: number, minMonthsOfStock: number, approvalStatus: Types.RequisitionNodeApprovalStatus, programName?: string | null, orderType?: string | null, user?: { __typename: 'UserNode', username: string, email?: string | null } | null, shipments: { __typename: 'InvoiceConnector', totalCount: number, nodes: Array<{ __typename: 'InvoiceNode', id: string, invoiceNumber: number, createdDatetime: string, user?: { __typename: 'UserNode', username: string } | null }> }, linesRemainingToSupply: { __typename: 'RequisitionLineConnector', totalCount: number }, lines: { __typename: 'RequisitionLineConnector', totalCount: number, nodes: Array<{ __typename: 'RequisitionLineNode', id: string, itemId: string, requestedQuantity: number, supplyQuantity: number, remainingQuantityToSupply: number, alreadyIssued: number, comment?: string | null, averageMonthlyConsumption: number, availableStockOnHand: number, approvedQuantity: number, approvalComment?: string | null, itemStats: { __typename: 'ItemStatsNode', availableStockOnHand: number, availableMonthsOfStockOnHand?: number | null, averageMonthlyConsumption: number }, item: { __typename: 'ItemNode', id: string, name: string, code: string, unitName?: string | null }, linkedRequisitionLine?: { __typename: 'RequisitionLineNode', itemStats: { __typename: 'ItemStatsNode', availableStockOnHand: number } } | null }> }, otherParty: { __typename: 'NameNode', id: string, code: string, isCustomer: boolean, isSupplier: boolean, isOnHold: boolean, name: string, store?: { __typename: 'StoreNode', id: string, code: string } | null }, period?: { __typename: 'PeriodNode', name: string, startDate: string, endDate: string } | null }; export type ResponseByNumberQueryVariables = Types.Exact<{ storeId: Types.Scalars['String']['input']; @@ -21,7 +21,7 @@ export type ResponseByNumberQueryVariables = Types.Exact<{ }>; -export type ResponseByNumberQuery = { __typename: 'Queries', requisitionByNumber: { __typename: 'RecordNotFound' } | { __typename: 'RequisitionNode', id: string, type: Types.RequisitionNodeType, status: Types.RequisitionNodeStatus, createdDatetime: string, sentDatetime?: string | null, finalisedDatetime?: string | null, requisitionNumber: number, colour?: string | null, theirReference?: string | null, comment?: string | null, otherPartyName: string, otherPartyId: string, maxMonthsOfStock: number, minMonthsOfStock: number, approvalStatus: Types.RequisitionNodeApprovalStatus, programName?: string | null, orderType?: string | null, user?: { __typename: 'UserNode', username: string, email?: string | null } | null, shipments: { __typename: 'InvoiceConnector', totalCount: number, nodes: Array<{ __typename: 'InvoiceNode', id: string, invoiceNumber: number, createdDatetime: string, user?: { __typename: 'UserNode', username: string } | null }> }, linesRemainingToSupply: { __typename: 'RequisitionLineConnector', totalCount: number }, lines: { __typename: 'RequisitionLineConnector', totalCount: number, nodes: Array<{ __typename: 'RequisitionLineNode', id: string, itemId: string, requestedQuantity: number, supplyQuantity: number, remainingQuantityToSupply: number, alreadyIssued: number, comment?: string | null, approvedQuantity: number, approvalComment?: string | null, itemStats: { __typename: 'ItemStatsNode', availableStockOnHand: number, availableMonthsOfStockOnHand?: number | null, averageMonthlyConsumption: number }, item: { __typename: 'ItemNode', id: string, name: string, code: string, unitName?: string | null }, linkedRequisitionLine?: { __typename: 'RequisitionLineNode', itemStats: { __typename: 'ItemStatsNode', availableStockOnHand: number } } | null }> }, otherParty: { __typename: 'NameNode', id: string, code: string, isCustomer: boolean, isSupplier: boolean, isOnHold: boolean, name: string, store?: { __typename: 'StoreNode', id: string, code: string } | null }, period?: { __typename: 'PeriodNode', name: string, startDate: string, endDate: string } | null } }; +export type ResponseByNumberQuery = { __typename: 'Queries', requisitionByNumber: { __typename: 'RecordNotFound' } | { __typename: 'RequisitionNode', id: string, type: Types.RequisitionNodeType, status: Types.RequisitionNodeStatus, createdDatetime: string, sentDatetime?: string | null, finalisedDatetime?: string | null, requisitionNumber: number, colour?: string | null, theirReference?: string | null, comment?: string | null, otherPartyName: string, otherPartyId: string, maxMonthsOfStock: number, minMonthsOfStock: number, approvalStatus: Types.RequisitionNodeApprovalStatus, programName?: string | null, orderType?: string | null, user?: { __typename: 'UserNode', username: string, email?: string | null } | null, shipments: { __typename: 'InvoiceConnector', totalCount: number, nodes: Array<{ __typename: 'InvoiceNode', id: string, invoiceNumber: number, createdDatetime: string, user?: { __typename: 'UserNode', username: string } | null }> }, linesRemainingToSupply: { __typename: 'RequisitionLineConnector', totalCount: number }, lines: { __typename: 'RequisitionLineConnector', totalCount: number, nodes: Array<{ __typename: 'RequisitionLineNode', id: string, itemId: string, requestedQuantity: number, supplyQuantity: number, remainingQuantityToSupply: number, alreadyIssued: number, comment?: string | null, averageMonthlyConsumption: number, availableStockOnHand: number, approvedQuantity: number, approvalComment?: string | null, itemStats: { __typename: 'ItemStatsNode', availableStockOnHand: number, availableMonthsOfStockOnHand?: number | null, averageMonthlyConsumption: number }, item: { __typename: 'ItemNode', id: string, name: string, code: string, unitName?: string | null }, linkedRequisitionLine?: { __typename: 'RequisitionLineNode', itemStats: { __typename: 'ItemStatsNode', availableStockOnHand: number } } | null }> }, otherParty: { __typename: 'NameNode', id: string, code: string, isCustomer: boolean, isSupplier: boolean, isOnHold: boolean, name: string, store?: { __typename: 'StoreNode', id: string, code: string } | null }, period?: { __typename: 'PeriodNode', name: string, startDate: string, endDate: string } | null } }; export type ResponseRowFragment = { __typename: 'RequisitionNode', colour?: string | null, comment?: string | null, createdDatetime: string, finalisedDatetime?: string | null, id: string, otherPartyName: string, requisitionNumber: number, sentDatetime?: string | null, status: Types.RequisitionNodeStatus, theirReference?: string | null, type: Types.RequisitionNodeType, otherPartyId: string, approvalStatus: Types.RequisitionNodeApprovalStatus, programName?: string | null, orderType?: string | null, period?: { __typename: 'PeriodNode', name: string, startDate: string, endDate: string } | null, shipments: { __typename: 'InvoiceConnector', totalCount: number } }; @@ -76,6 +76,8 @@ export const ResponseLineFragmentDoc = gql` remainingQuantityToSupply alreadyIssued comment + averageMonthlyConsumption + availableStockOnHand itemStats { __typename availableStockOnHand diff --git a/client/packages/requisitions/src/ResponseRequisition/api/operations.graphql b/client/packages/requisitions/src/ResponseRequisition/api/operations.graphql index 699224b61c..bf7b00a62d 100644 --- a/client/packages/requisitions/src/ResponseRequisition/api/operations.graphql +++ b/client/packages/requisitions/src/ResponseRequisition/api/operations.graphql @@ -18,6 +18,8 @@ fragment ResponseLine on RequisitionLineNode { remainingQuantityToSupply alreadyIssued comment + averageMonthlyConsumption + availableStockOnHand itemStats { __typename From 3207a0a2d639c7a010402ba01f12f9725ed70d6d Mon Sep 17 00:00:00 2001 From: roxy-dao Date: Thu, 31 Oct 2024 14:46:57 +1300 Subject: [PATCH 06/32] Remove linked requisition line --- .../ResponseRequisition/api/operations.generated.ts | 11 +++-------- .../src/ResponseRequisition/api/operations.graphql | 5 ----- 2 files changed, 3 insertions(+), 13 deletions(-) diff --git a/client/packages/requisitions/src/ResponseRequisition/api/operations.generated.ts b/client/packages/requisitions/src/ResponseRequisition/api/operations.generated.ts index 3a7c421f1d..cb93ff2901 100644 --- a/client/packages/requisitions/src/ResponseRequisition/api/operations.generated.ts +++ b/client/packages/requisitions/src/ResponseRequisition/api/operations.generated.ts @@ -11,9 +11,9 @@ export type UpdateResponseMutationVariables = Types.Exact<{ export type UpdateResponseMutation = { __typename: 'Mutations', updateResponseRequisition: { __typename: 'RequisitionNode', id: string } | { __typename: 'UpdateResponseRequisitionError' } }; -export type ResponseLineFragment = { __typename: 'RequisitionLineNode', id: string, itemId: string, requestedQuantity: number, supplyQuantity: number, remainingQuantityToSupply: number, alreadyIssued: number, comment?: string | null, averageMonthlyConsumption: number, availableStockOnHand: number, approvedQuantity: number, approvalComment?: string | null, itemStats: { __typename: 'ItemStatsNode', availableStockOnHand: number, availableMonthsOfStockOnHand?: number | null, averageMonthlyConsumption: number }, item: { __typename: 'ItemNode', id: string, name: string, code: string, unitName?: string | null }, linkedRequisitionLine?: { __typename: 'RequisitionLineNode', itemStats: { __typename: 'ItemStatsNode', availableStockOnHand: number } } | null }; +export type ResponseLineFragment = { __typename: 'RequisitionLineNode', id: string, itemId: string, requestedQuantity: number, supplyQuantity: number, remainingQuantityToSupply: number, alreadyIssued: number, comment?: string | null, averageMonthlyConsumption: number, availableStockOnHand: number, approvedQuantity: number, approvalComment?: string | null, itemStats: { __typename: 'ItemStatsNode', availableStockOnHand: number, availableMonthsOfStockOnHand?: number | null, averageMonthlyConsumption: number }, item: { __typename: 'ItemNode', id: string, name: string, code: string, unitName?: string | null } }; -export type ResponseFragment = { __typename: 'RequisitionNode', id: string, type: Types.RequisitionNodeType, status: Types.RequisitionNodeStatus, createdDatetime: string, sentDatetime?: string | null, finalisedDatetime?: string | null, requisitionNumber: number, colour?: string | null, theirReference?: string | null, comment?: string | null, otherPartyName: string, otherPartyId: string, maxMonthsOfStock: number, minMonthsOfStock: number, approvalStatus: Types.RequisitionNodeApprovalStatus, programName?: string | null, orderType?: string | null, user?: { __typename: 'UserNode', username: string, email?: string | null } | null, shipments: { __typename: 'InvoiceConnector', totalCount: number, nodes: Array<{ __typename: 'InvoiceNode', id: string, invoiceNumber: number, createdDatetime: string, user?: { __typename: 'UserNode', username: string } | null }> }, linesRemainingToSupply: { __typename: 'RequisitionLineConnector', totalCount: number }, lines: { __typename: 'RequisitionLineConnector', totalCount: number, nodes: Array<{ __typename: 'RequisitionLineNode', id: string, itemId: string, requestedQuantity: number, supplyQuantity: number, remainingQuantityToSupply: number, alreadyIssued: number, comment?: string | null, averageMonthlyConsumption: number, availableStockOnHand: number, approvedQuantity: number, approvalComment?: string | null, itemStats: { __typename: 'ItemStatsNode', availableStockOnHand: number, availableMonthsOfStockOnHand?: number | null, averageMonthlyConsumption: number }, item: { __typename: 'ItemNode', id: string, name: string, code: string, unitName?: string | null }, linkedRequisitionLine?: { __typename: 'RequisitionLineNode', itemStats: { __typename: 'ItemStatsNode', availableStockOnHand: number } } | null }> }, otherParty: { __typename: 'NameNode', id: string, code: string, isCustomer: boolean, isSupplier: boolean, isOnHold: boolean, name: string, store?: { __typename: 'StoreNode', id: string, code: string } | null }, period?: { __typename: 'PeriodNode', name: string, startDate: string, endDate: string } | null }; +export type ResponseFragment = { __typename: 'RequisitionNode', id: string, type: Types.RequisitionNodeType, status: Types.RequisitionNodeStatus, createdDatetime: string, sentDatetime?: string | null, finalisedDatetime?: string | null, requisitionNumber: number, colour?: string | null, theirReference?: string | null, comment?: string | null, otherPartyName: string, otherPartyId: string, maxMonthsOfStock: number, minMonthsOfStock: number, approvalStatus: Types.RequisitionNodeApprovalStatus, programName?: string | null, orderType?: string | null, user?: { __typename: 'UserNode', username: string, email?: string | null } | null, shipments: { __typename: 'InvoiceConnector', totalCount: number, nodes: Array<{ __typename: 'InvoiceNode', id: string, invoiceNumber: number, createdDatetime: string, user?: { __typename: 'UserNode', username: string } | null }> }, linesRemainingToSupply: { __typename: 'RequisitionLineConnector', totalCount: number }, lines: { __typename: 'RequisitionLineConnector', totalCount: number, nodes: Array<{ __typename: 'RequisitionLineNode', id: string, itemId: string, requestedQuantity: number, supplyQuantity: number, remainingQuantityToSupply: number, alreadyIssued: number, comment?: string | null, averageMonthlyConsumption: number, availableStockOnHand: number, approvedQuantity: number, approvalComment?: string | null, itemStats: { __typename: 'ItemStatsNode', availableStockOnHand: number, availableMonthsOfStockOnHand?: number | null, averageMonthlyConsumption: number }, item: { __typename: 'ItemNode', id: string, name: string, code: string, unitName?: string | null } }> }, otherParty: { __typename: 'NameNode', id: string, code: string, isCustomer: boolean, isSupplier: boolean, isOnHold: boolean, name: string, store?: { __typename: 'StoreNode', id: string, code: string } | null }, period?: { __typename: 'PeriodNode', name: string, startDate: string, endDate: string } | null }; export type ResponseByNumberQueryVariables = Types.Exact<{ storeId: Types.Scalars['String']['input']; @@ -21,7 +21,7 @@ export type ResponseByNumberQueryVariables = Types.Exact<{ }>; -export type ResponseByNumberQuery = { __typename: 'Queries', requisitionByNumber: { __typename: 'RecordNotFound' } | { __typename: 'RequisitionNode', id: string, type: Types.RequisitionNodeType, status: Types.RequisitionNodeStatus, createdDatetime: string, sentDatetime?: string | null, finalisedDatetime?: string | null, requisitionNumber: number, colour?: string | null, theirReference?: string | null, comment?: string | null, otherPartyName: string, otherPartyId: string, maxMonthsOfStock: number, minMonthsOfStock: number, approvalStatus: Types.RequisitionNodeApprovalStatus, programName?: string | null, orderType?: string | null, user?: { __typename: 'UserNode', username: string, email?: string | null } | null, shipments: { __typename: 'InvoiceConnector', totalCount: number, nodes: Array<{ __typename: 'InvoiceNode', id: string, invoiceNumber: number, createdDatetime: string, user?: { __typename: 'UserNode', username: string } | null }> }, linesRemainingToSupply: { __typename: 'RequisitionLineConnector', totalCount: number }, lines: { __typename: 'RequisitionLineConnector', totalCount: number, nodes: Array<{ __typename: 'RequisitionLineNode', id: string, itemId: string, requestedQuantity: number, supplyQuantity: number, remainingQuantityToSupply: number, alreadyIssued: number, comment?: string | null, averageMonthlyConsumption: number, availableStockOnHand: number, approvedQuantity: number, approvalComment?: string | null, itemStats: { __typename: 'ItemStatsNode', availableStockOnHand: number, availableMonthsOfStockOnHand?: number | null, averageMonthlyConsumption: number }, item: { __typename: 'ItemNode', id: string, name: string, code: string, unitName?: string | null }, linkedRequisitionLine?: { __typename: 'RequisitionLineNode', itemStats: { __typename: 'ItemStatsNode', availableStockOnHand: number } } | null }> }, otherParty: { __typename: 'NameNode', id: string, code: string, isCustomer: boolean, isSupplier: boolean, isOnHold: boolean, name: string, store?: { __typename: 'StoreNode', id: string, code: string } | null }, period?: { __typename: 'PeriodNode', name: string, startDate: string, endDate: string } | null } }; +export type ResponseByNumberQuery = { __typename: 'Queries', requisitionByNumber: { __typename: 'RecordNotFound' } | { __typename: 'RequisitionNode', id: string, type: Types.RequisitionNodeType, status: Types.RequisitionNodeStatus, createdDatetime: string, sentDatetime?: string | null, finalisedDatetime?: string | null, requisitionNumber: number, colour?: string | null, theirReference?: string | null, comment?: string | null, otherPartyName: string, otherPartyId: string, maxMonthsOfStock: number, minMonthsOfStock: number, approvalStatus: Types.RequisitionNodeApprovalStatus, programName?: string | null, orderType?: string | null, user?: { __typename: 'UserNode', username: string, email?: string | null } | null, shipments: { __typename: 'InvoiceConnector', totalCount: number, nodes: Array<{ __typename: 'InvoiceNode', id: string, invoiceNumber: number, createdDatetime: string, user?: { __typename: 'UserNode', username: string } | null }> }, linesRemainingToSupply: { __typename: 'RequisitionLineConnector', totalCount: number }, lines: { __typename: 'RequisitionLineConnector', totalCount: number, nodes: Array<{ __typename: 'RequisitionLineNode', id: string, itemId: string, requestedQuantity: number, supplyQuantity: number, remainingQuantityToSupply: number, alreadyIssued: number, comment?: string | null, averageMonthlyConsumption: number, availableStockOnHand: number, approvedQuantity: number, approvalComment?: string | null, itemStats: { __typename: 'ItemStatsNode', availableStockOnHand: number, availableMonthsOfStockOnHand?: number | null, averageMonthlyConsumption: number }, item: { __typename: 'ItemNode', id: string, name: string, code: string, unitName?: string | null } }> }, otherParty: { __typename: 'NameNode', id: string, code: string, isCustomer: boolean, isSupplier: boolean, isOnHold: boolean, name: string, store?: { __typename: 'StoreNode', id: string, code: string } | null }, period?: { __typename: 'PeriodNode', name: string, startDate: string, endDate: string } | null } }; export type ResponseRowFragment = { __typename: 'RequisitionNode', colour?: string | null, comment?: string | null, createdDatetime: string, finalisedDatetime?: string | null, id: string, otherPartyName: string, requisitionNumber: number, sentDatetime?: string | null, status: Types.RequisitionNodeStatus, theirReference?: string | null, type: Types.RequisitionNodeType, otherPartyId: string, approvalStatus: Types.RequisitionNodeApprovalStatus, programName?: string | null, orderType?: string | null, period?: { __typename: 'PeriodNode', name: string, startDate: string, endDate: string } | null, shipments: { __typename: 'InvoiceConnector', totalCount: number } }; @@ -92,11 +92,6 @@ export const ResponseLineFragmentDoc = gql` } approvedQuantity approvalComment - linkedRequisitionLine { - itemStats { - availableStockOnHand - } - } } `; export const ResponseFragmentDoc = gql` diff --git a/client/packages/requisitions/src/ResponseRequisition/api/operations.graphql b/client/packages/requisitions/src/ResponseRequisition/api/operations.graphql index bf7b00a62d..9ee53104e7 100644 --- a/client/packages/requisitions/src/ResponseRequisition/api/operations.graphql +++ b/client/packages/requisitions/src/ResponseRequisition/api/operations.graphql @@ -35,11 +35,6 @@ fragment ResponseLine on RequisitionLineNode { } approvedQuantity approvalComment - linkedRequisitionLine { - itemStats { - availableStockOnHand - } - } } fragment Response on RequisitionNode { From 5b7d01b106c182ecc680127ab557a296a4b9a63e Mon Sep 17 00:00:00 2001 From: James Brunskill Date: Thu, 31 Oct 2024 15:21:04 +1300 Subject: [PATCH 07/32] RC2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 772165978d..15a0b34c0b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "open-msupply", "//": "Main version for the app, should be in semantic version format (any release candidate or test build should be separated by '-' i.e. 1.1.1-rc1 or 1.1.1-test", - "version": "2.3.01-RC1", + "version": "2.3.01-RC2", "private": true, "scripts": { "start": "cd ./server && cargo run & cd ./client && yarn start-local", From f537d1279e9336389a5f2e4245445e16c95ef0ba Mon Sep 17 00:00:00 2001 From: mark-prins Date: Thu, 31 Oct 2024 16:47:09 +1300 Subject: [PATCH 08/32] quick implementation of useBlocker --- .../useConfirmOnLeaving.ts | 23 +++++- client/packages/common/src/index.ts | 4 +- client/packages/host/src/Host.tsx | 75 ++++++++++--------- 3 files changed, 64 insertions(+), 38 deletions(-) diff --git a/client/packages/common/src/hooks/useConfirmOnLeaving/useConfirmOnLeaving.ts b/client/packages/common/src/hooks/useConfirmOnLeaving/useConfirmOnLeaving.ts index 8acbbbd6ff..e75c9a8fd5 100644 --- a/client/packages/common/src/hooks/useConfirmOnLeaving/useConfirmOnLeaving.ts +++ b/client/packages/common/src/hooks/useConfirmOnLeaving/useConfirmOnLeaving.ts @@ -1,5 +1,8 @@ import { useContext, useEffect, useRef } from 'react'; -import { UNSAFE_NavigationContext as NavigationContext } from 'react-router-dom'; +import { + UNSAFE_NavigationContext as NavigationContext, + useBlocker, +} from 'react-router-dom'; import { useTranslation } from '@common/intl'; import { useToggle } from '../useToggle'; @@ -31,6 +34,24 @@ export const useConfirmOnLeaving = (isUnsaved?: boolean) => { } }; + let blocker = useBlocker( + ({ currentLocation, nextLocation }) => + !!isUnsaved && currentLocation.pathname !== nextLocation.pathname + ); + + // TODO: the blocker hook should only be called once, wrapping in useCallback should do it? + // TODO: the .proceed() should navigate us, but it doesn't, you have to click 'back' again. possibly due to the hook being called multiple times? + // TODO: replace the confirm with a nice modal - requires a bit of refactoring here + // console.log('blocker', blocker.state); + if (blocker.state === 'blocked') { + if (confirm('time to leave?')) { + blocker.proceed(); + // console.log(blocker.location.pathname); + } else { + blocker.reset(); + } + } + useEffect(() => { // note that multiple calls to addEventListener don't result in multiple event listeners being added // since the method called is idempotent. However, I didn't want to rely on the implementation details diff --git a/client/packages/common/src/index.ts b/client/packages/common/src/index.ts index c34787b47e..1ecbe173bf 100644 --- a/client/packages/common/src/index.ts +++ b/client/packages/common/src/index.ts @@ -27,12 +27,14 @@ export { Link, useNavigate, useParams, - BrowserRouter, HashRouter, Routes, Route, Navigate, useMatch, + createBrowserRouter, + createRoutesFromElements, + RouterProvider, } from 'react-router-dom'; export * from './utils'; diff --git a/client/packages/host/src/Host.tsx b/client/packages/host/src/Host.tsx index d40defada2..97ab5a3bc8 100644 --- a/client/packages/host/src/Host.tsx +++ b/client/packages/host/src/Host.tsx @@ -1,7 +1,6 @@ import React from 'react'; import Bugsnag from '@bugsnag/js'; import { - BrowserRouter, Routes, Route, Box, @@ -21,6 +20,9 @@ import { EnvUtils, LocalStorage, AuthError, + createBrowserRouter, + createRoutesFromElements, + RouterProvider, } from '@openmsupply-client/common'; import { AppRoute, Environment } from '@openmsupply-client/config'; import { Initialise, Login, Viewport } from './components'; @@ -72,6 +74,41 @@ const Init = () => { return <>; }; +const router = createBrowserRouter( + createRoutesFromElements( + + + + + + } + /> + } + /> + } + /> + } + /> + } /> + + + + } + /> + ) +); + const Host = () => ( }> @@ -87,41 +124,7 @@ const Host = () => ( - - - - - - - } - /> - } - /> - } - /> - } - /> - } /> - - - - + From d66868d3f53916b07081fe8918880ee466ac18da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lach=C3=A9=20Melvin?= <55115239+lache-melvin@users.noreply.github.com> Date: Fri, 1 Nov 2024 07:46:18 +1300 Subject: [PATCH 09/32] get useBlocker working --- .../useConfirmOnLeaving.ts | 103 ++++-------------- 1 file changed, 24 insertions(+), 79 deletions(-) diff --git a/client/packages/common/src/hooks/useConfirmOnLeaving/useConfirmOnLeaving.ts b/client/packages/common/src/hooks/useConfirmOnLeaving/useConfirmOnLeaving.ts index e75c9a8fd5..37bad0fc3a 100644 --- a/client/packages/common/src/hooks/useConfirmOnLeaving/useConfirmOnLeaving.ts +++ b/client/packages/common/src/hooks/useConfirmOnLeaving/useConfirmOnLeaving.ts @@ -1,94 +1,39 @@ -import { useContext, useEffect, useRef } from 'react'; -import { - UNSAFE_NavigationContext as NavigationContext, - useBlocker, -} from 'react-router-dom'; +import { useCallback } from 'react'; +import { BlockerFunction, useBeforeUnload, useBlocker } from 'react-router-dom'; import { useTranslation } from '@common/intl'; -import { useToggle } from '../useToggle'; -const promptUser = (e: BeforeUnloadEvent) => { - // Cancel the event - e.preventDefault(); // If you prevent default behavior in Mozilla Firefox prompt will always be shown - // Chrome requires returnValue to be set - e.returnValue = ''; -}; - -// Ideally we'd use the `Prompt` component instead ( or usePrompt or useBlocker ) to prompt when navigating away using react-router -// however, these weren't implemented in react-router-dom v6 at the time of implementation /** useConfirmOnLeaving is a hook that will prompt the user if they try to navigate away from, - * or refresh the page, when there are unsaved changes. Be careful when using within a tab component though - * these are unloaded, but the event handler is at the window level, and so doesn't care + * or refresh the page, when there are unsaved changes. * */ export const useConfirmOnLeaving = (isUnsaved?: boolean) => { - const unblockRef = useRef(null); - const { navigator } = useContext(NavigationContext); const t = useTranslation(); - const { isOn, toggle } = useToggle(); - const showConfirmation = (onOk: () => void) => { - if ( - confirm( - `${t('heading.are-you-sure')}\n${t('messages.confirm-cancel-generic')}` - ) - ) { + const confirmMessage = `${t('heading.are-you-sure')}\n${t('messages.confirm-cancel-generic')}`; + + const customConfirm = (onOk: () => void) => { + if (confirm(confirmMessage)) { onOk(); } }; - let blocker = useBlocker( - ({ currentLocation, nextLocation }) => - !!isUnsaved && currentLocation.pathname !== nextLocation.pathname + useBlocker( + useCallback( + ({ currentLocation, nextLocation }) => { + if (!!isUnsaved && currentLocation.pathname !== nextLocation.pathname) { + return !confirm(confirmMessage); + } + return false; + }, + [isUnsaved] + ) ); - // TODO: the blocker hook should only be called once, wrapping in useCallback should do it? - // TODO: the .proceed() should navigate us, but it doesn't, you have to click 'back' again. possibly due to the hook being called multiple times? - // TODO: replace the confirm with a nice modal - requires a bit of refactoring here - // console.log('blocker', blocker.state); - if (blocker.state === 'blocked') { - if (confirm('time to leave?')) { - blocker.proceed(); - // console.log(blocker.location.pathname); - } else { - blocker.reset(); - } - } - - useEffect(() => { - // note that multiple calls to addEventListener don't result in multiple event listeners being added - // since the method called is idempotent. However, I didn't want to rely on the implementation details - // so have the toggle state to ensure we only add/remove the event listener once - if (isUnsaved && !isOn) { - window.addEventListener('beforeunload', promptUser, { capture: true }); - toggle(); - const push = navigator.push; - - navigator.push = (...args: Parameters) => { - showConfirmation(() => { - push(...args); - }); - }; - - return () => { - navigator.push = push; - }; - } - if (!isUnsaved && isOn) { - window.removeEventListener('beforeunload', promptUser, { capture: true }); - toggle(); - unblockRef.current?.(); - } - }, [isUnsaved]); - - // always remove the event listener on unmount, and don't check the toggle - // which would be trapped in a stale closure - useEffect( - () => () => { - window.removeEventListener('beforeunload', promptUser, { - capture: true, - }); - unblockRef.current?.(); - }, - [] + useBeforeUnload( + useCallback(event => { + // Cancel the event + event.preventDefault(); + }, []), + { capture: true } ); - return { showConfirmation }; + return { showConfirmation: customConfirm }; }; From a17703b97175289e0f4782da32205c9533ac1dd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lach=C3=A9=20Melvin?= <55115239+lache-melvin@users.noreply.github.com> Date: Fri, 1 Nov 2024 10:13:03 +1300 Subject: [PATCH 10/32] only prompt on refresh if dirty --- .../hooks/useConfirmOnLeaving/useConfirmOnLeaving.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/client/packages/common/src/hooks/useConfirmOnLeaving/useConfirmOnLeaving.ts b/client/packages/common/src/hooks/useConfirmOnLeaving/useConfirmOnLeaving.ts index 37bad0fc3a..f4e73a0525 100644 --- a/client/packages/common/src/hooks/useConfirmOnLeaving/useConfirmOnLeaving.ts +++ b/client/packages/common/src/hooks/useConfirmOnLeaving/useConfirmOnLeaving.ts @@ -28,10 +28,13 @@ export const useConfirmOnLeaving = (isUnsaved?: boolean) => { ); useBeforeUnload( - useCallback(event => { - // Cancel the event - event.preventDefault(); - }, []), + useCallback( + event => { + // Cancel the refresh + if (isUnsaved) event.preventDefault(); + }, + [isUnsaved] + ), { capture: true } ); From 306c9cf22597b5f4fc833d8ca6777a7a4a4808e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lach=C3=A9=20Melvin?= <55115239+lache-melvin@users.noreply.github.com> Date: Fri, 1 Nov 2024 11:26:51 +1300 Subject: [PATCH 11/32] bump to rc3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 15a0b34c0b..a9a57fae01 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "open-msupply", "//": "Main version for the app, should be in semantic version format (any release candidate or test build should be separated by '-' i.e. 1.1.1-rc1 or 1.1.1-test", - "version": "2.3.01-RC2", + "version": "2.3.01-RC3", "private": true, "scripts": { "start": "cd ./server && cargo run & cd ./client && yarn start-local", From 1ffec611af6a2ee5952d3aa42d8bf838fb4a0b26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lach=C3=A9=20Melvin?= <55115239+lache-melvin@users.noreply.github.com> Date: Fri, 1 Nov 2024 12:29:22 +1300 Subject: [PATCH 12/32] add repository layer --- .../src/db_diesel/changelog/changelog.rs | 2 + .../db_diesel/item_variant/bundled_item.rs | 193 ++++++++++++++++++ .../item_variant/bundled_item_row.rs | 101 +++++++++ .../src/db_diesel/item_variant/mod.rs | 2 + .../v2_04_00/add_bundled_item_table.rs | 26 +++ .../repository/src/migrations/v2_04_00/mod.rs | 2 + 6 files changed, 326 insertions(+) create mode 100644 server/repository/src/db_diesel/item_variant/bundled_item.rs create mode 100644 server/repository/src/db_diesel/item_variant/bundled_item_row.rs create mode 100644 server/repository/src/migrations/v2_04_00/add_bundled_item_table.rs diff --git a/server/repository/src/db_diesel/changelog/changelog.rs b/server/repository/src/db_diesel/changelog/changelog.rs index 71089081f5..b028799989 100644 --- a/server/repository/src/db_diesel/changelog/changelog.rs +++ b/server/repository/src/db_diesel/changelog/changelog.rs @@ -109,6 +109,7 @@ pub enum ChangelogTableName { Vaccination, ItemVariant, PackagingVariant, + BundledItem, } pub(crate) enum ChangeLogSyncStyle { @@ -173,6 +174,7 @@ impl ChangelogTableName { ChangelogTableName::ColdStorageType => ChangeLogSyncStyle::Legacy, ChangelogTableName::ItemVariant => ChangeLogSyncStyle::Central, ChangelogTableName::PackagingVariant => ChangeLogSyncStyle::Central, + ChangelogTableName::BundledItem => ChangeLogSyncStyle::Central, } } } diff --git a/server/repository/src/db_diesel/item_variant/bundled_item.rs b/server/repository/src/db_diesel/item_variant/bundled_item.rs new file mode 100644 index 0000000000..6fb3daa6e5 --- /dev/null +++ b/server/repository/src/db_diesel/item_variant/bundled_item.rs @@ -0,0 +1,193 @@ +use super::bundled_item_row::{bundled_item, BundledItemRow}; +use crate::{ + diesel_macros::apply_equal_filter, repository_error::RepositoryError, DBType, EqualFilter, + Pagination, StorageConnection, +}; +use diesel::{dsl::IntoBoxed, prelude::*}; + +#[derive(Clone, Default)] +pub struct BundledItemFilter { + pub id: Option>, + pub principal_item_variant_id: Option>, + pub bundled_item_variant_id: Option>, +} + +impl BundledItemFilter { + pub fn new() -> BundledItemFilter { + Self::default() + } + + pub fn id(mut self, filter: EqualFilter) -> Self { + self.id = Some(filter); + self + } + pub fn principal_item_variant_id(mut self, filter: EqualFilter) -> Self { + self.principal_item_variant_id = Some(filter); + self + } + pub fn bundled_item_variant_id(mut self, filter: EqualFilter) -> Self { + self.bundled_item_variant_id = Some(filter); + self + } +} + +pub struct BundledItemRepository<'a> { + connection: &'a StorageConnection, +} + +impl<'a> BundledItemRepository<'a> { + pub fn new(connection: &'a StorageConnection) -> Self { + BundledItemRepository { connection } + } + + pub fn count(&self, filter: Option) -> Result { + let query = create_filtered_query(filter); + + Ok(query + .count() + .get_result(self.connection.lock().connection())?) + } + + pub fn query_one( + &self, + filter: BundledItemFilter, + ) -> Result, RepositoryError> { + Ok(self.query_by_filter(filter)?.pop()) + } + + pub fn query_by_filter( + &self, + filter: BundledItemFilter, + ) -> Result, RepositoryError> { + self.query(Pagination::all(), Some(filter)) + } + + pub fn query( + &self, + pagination: Pagination, + filter: Option, + ) -> Result, RepositoryError> { + let query = create_filtered_query(filter); + + let final_query = query + .offset(pagination.offset as i64) + .limit(pagination.limit as i64); + + // Debug diesel query + // println!( + // "{}", + // diesel::debug_query::(&final_query).to_string() + // ); + + let result = final_query.load::(self.connection.lock().connection())?; + + Ok(result.into_iter().map(to_domain).collect()) + } +} + +fn to_domain(bundled_item_row: BundledItemRow) -> BundledItemRow { + bundled_item_row +} + +type BoxedBundledItemQuery = IntoBoxed<'static, bundled_item::table, DBType>; + +fn create_filtered_query(filter: Option) -> BoxedBundledItemQuery { + let mut query = bundled_item::table.into_boxed(); + // Exclude any deleted items + query = query.filter(bundled_item::deleted_datetime.is_null()); + + if let Some(f) = filter { + let BundledItemFilter { + id, + principal_item_variant_id, + bundled_item_variant_id, + } = f; + + apply_equal_filter!(query, id, bundled_item::id); + apply_equal_filter!( + query, + principal_item_variant_id, + bundled_item::principal_item_variant_id + ); + apply_equal_filter!( + query, + bundled_item_variant_id, + bundled_item::bundled_item_variant_id + ); + } + query +} + +#[cfg(test)] +mod tests { + use crate::{ + item_variant::{ + bundled_item::BundledItemRepository, + bundled_item_row::{BundledItemRow, BundledItemRowRepository}, + item_variant_row::{ItemVariantRow, ItemVariantRowRepository}, + }, + mock::{mock_item_a, mock_item_b, MockDataInserts}, + test_db, EqualFilter, + }; + + use super::BundledItemFilter; + + #[actix_rt::test] + async fn test_bundled_item_query_repository() { + // Prepare + let (_, storage_connection, _, _) = test_db::setup_all( + "test_bundled_item_query_repository", + MockDataInserts::none().items(), + ) + .await; + + let id = "test_id".to_string(); + let principal_id = "principal_id".to_string(); + let bundled_id = "bundled_id".to_string(); + + let item_variant_repo = ItemVariantRowRepository::new(&storage_connection); + + item_variant_repo + .upsert_one(&ItemVariantRow { + id: principal_id.clone(), + item_link_id: mock_item_a().id, + ..Default::default() + }) + .unwrap(); + item_variant_repo + .upsert_one(&ItemVariantRow { + id: bundled_id.clone(), + item_link_id: mock_item_b().id, + ..Default::default() + }) + .unwrap(); + + // Insert a row + let _bundled_item_row = BundledItemRowRepository::new(&storage_connection) + .upsert_one(&BundledItemRow { + id: id.clone(), + principal_item_variant_id: principal_id.clone(), + bundled_item_variant_id: bundled_id, + ratio: 1.0, + deleted_datetime: None, + }) + .unwrap(); + + // Query by id + let bundled_item_row = BundledItemRepository::new(&storage_connection) + .query_one(BundledItemFilter::new().id(EqualFilter::equal_to(&id))) + .unwrap() + .unwrap(); + assert_eq!(bundled_item_row.id, id); + + // Query by name + let bundled_item_row = BundledItemRepository::new(&storage_connection) + .query_one( + BundledItemFilter::new() + .principal_item_variant_id(EqualFilter::equal_to(&principal_id)), + ) + .unwrap() + .unwrap(); + assert_eq!(bundled_item_row.id, id); + } +} diff --git a/server/repository/src/db_diesel/item_variant/bundled_item_row.rs b/server/repository/src/db_diesel/item_variant/bundled_item_row.rs new file mode 100644 index 0000000000..2e32b0f54c --- /dev/null +++ b/server/repository/src/db_diesel/item_variant/bundled_item_row.rs @@ -0,0 +1,101 @@ +use crate::{ + ChangeLogInsertRow, ChangelogRepository, ChangelogTableName, RepositoryError, RowActionType, + StorageConnection, Upsert, +}; + +use chrono::NaiveDateTime; +use diesel::prelude::*; +use serde::{Deserialize, Serialize}; + +table! { + bundled_item(id) { + id -> Text, + principal_item_variant_id -> Text, + bundled_item_variant_id -> Text, + ratio -> Double, + deleted_datetime -> Nullable, + } +} + +#[derive( + Clone, Queryable, Insertable, AsChangeset, Debug, PartialEq, Default, Serialize, Deserialize, +)] +#[diesel(table_name = bundled_item)] +pub struct BundledItemRow { + pub id: String, + pub principal_item_variant_id: String, + pub bundled_item_variant_id: String, + pub ratio: f64, + pub deleted_datetime: Option, +} + +pub struct BundledItemRowRepository<'a> { + connection: &'a StorageConnection, +} + +impl<'a> BundledItemRowRepository<'a> { + pub fn new(connection: &'a StorageConnection) -> Self { + BundledItemRowRepository { connection } + } + + pub fn upsert_one(&self, row: &BundledItemRow) -> Result { + diesel::insert_into(bundled_item::table) + .values(row) + .on_conflict(bundled_item::id) + .do_update() + .set(row) + .execute(self.connection.lock().connection())?; + + self.insert_changelog(row.id.to_owned(), RowActionType::Upsert) + } + + fn insert_changelog( + &self, + row_id: String, + action: RowActionType, + ) -> Result { + let row = ChangeLogInsertRow { + table_name: ChangelogTableName::BundledItem, + record_id: row_id, + row_action: action, + store_id: None, + ..Default::default() + }; + ChangelogRepository::new(self.connection).insert(&row) + } + + pub fn find_one_by_id( + &self, + bundled_item_id: &str, + ) -> Result, RepositoryError> { + let result = bundled_item::table + .filter(bundled_item::id.eq(bundled_item_id)) + .first(self.connection.lock().connection()) + .optional()?; + Ok(result) + } + + pub fn mark_deleted(&self, bundled_item_id: &str) -> Result { + diesel::update(bundled_item::table.filter(bundled_item::id.eq(bundled_item_id))) + .set(bundled_item::deleted_datetime.eq(Some(chrono::Utc::now().naive_utc()))) + .execute(self.connection.lock().connection())?; + + // Upsert row action as this is a soft delete, not actual delete + self.insert_changelog(bundled_item_id.to_owned(), RowActionType::Upsert) + } +} + +impl Upsert for BundledItemRow { + fn upsert(&self, con: &StorageConnection) -> Result, RepositoryError> { + let cursor_id = BundledItemRowRepository::new(con).upsert_one(self)?; + Ok(Some(cursor_id)) + } + + // Test only + fn assert_upserted(&self, con: &StorageConnection) { + assert_eq!( + BundledItemRowRepository::new(con).find_one_by_id(&self.id), + Ok(Some(self.clone())) + ) + } +} diff --git a/server/repository/src/db_diesel/item_variant/mod.rs b/server/repository/src/db_diesel/item_variant/mod.rs index c7992108f7..4328481c86 100644 --- a/server/repository/src/db_diesel/item_variant/mod.rs +++ b/server/repository/src/db_diesel/item_variant/mod.rs @@ -1,3 +1,5 @@ +pub mod bundled_item; +pub mod bundled_item_row; pub mod item_variant; pub mod item_variant_row; pub mod packaging_variant; diff --git a/server/repository/src/migrations/v2_04_00/add_bundled_item_table.rs b/server/repository/src/migrations/v2_04_00/add_bundled_item_table.rs new file mode 100644 index 0000000000..5f0253aa02 --- /dev/null +++ b/server/repository/src/migrations/v2_04_00/add_bundled_item_table.rs @@ -0,0 +1,26 @@ +use crate::migrations::*; + +pub(crate) struct Migrate; + +impl MigrationFragment for Migrate { + fn identifier(&self) -> &'static str { + "add_bundled_item_table" + } + + fn migrate(&self, connection: &StorageConnection) -> anyhow::Result<()> { + sql!( + connection, + r#" + CREATE TABLE bundled_item ( + id TEXT NOT NULL PRIMARY KEY, + principal_item_variant_id TEXT NOT NULL REFERENCES item_variant(id), + bundled_item_variant_id TEXT NOT NULL REFERENCES item_variant(id), + ratio {DOUBLE} NOT NULL, + deleted_datetime {DATETIME} + ); + "# + )?; + + Ok(()) + } +} diff --git a/server/repository/src/migrations/v2_04_00/mod.rs b/server/repository/src/migrations/v2_04_00/mod.rs index d38c789314..d54b94f4d2 100644 --- a/server/repository/src/migrations/v2_04_00/mod.rs +++ b/server/repository/src/migrations/v2_04_00/mod.rs @@ -1,5 +1,6 @@ use super::{version::Version, Migration, MigrationFragment}; +mod add_bundled_item_table; mod add_cold_storage_type_table; mod add_expected_lifespan_to_assets; mod add_item_variant_id_to_stock_line_and_invoice_line; @@ -34,6 +35,7 @@ impl Migration for V2_04_00 { Box::new(item_variant::Migrate), Box::new(program_indicator_create_table::Migrate), Box::new(add_item_variant_id_to_stock_line_and_invoice_line::Migrate), + Box::new(add_bundled_item_table::Migrate), ] } } From 23ded2deac310fd0d700bad337950f7017b357b9 Mon Sep 17 00:00:00 2001 From: mark-prins Date: Fri, 1 Nov 2024 13:07:38 +1300 Subject: [PATCH 13/32] use modal --- .../useConfirmOnLeaving.ts | 56 +++++++++++++------ 1 file changed, 40 insertions(+), 16 deletions(-) diff --git a/client/packages/common/src/hooks/useConfirmOnLeaving/useConfirmOnLeaving.ts b/client/packages/common/src/hooks/useConfirmOnLeaving/useConfirmOnLeaving.ts index f4e73a0525..86fa38bd28 100644 --- a/client/packages/common/src/hooks/useConfirmOnLeaving/useConfirmOnLeaving.ts +++ b/client/packages/common/src/hooks/useConfirmOnLeaving/useConfirmOnLeaving.ts @@ -1,32 +1,42 @@ -import { useCallback } from 'react'; +import { useCallback, useContext, useEffect } from 'react'; import { BlockerFunction, useBeforeUnload, useBlocker } from 'react-router-dom'; import { useTranslation } from '@common/intl'; +import { ConfirmationModalContext } from '@openmsupply-client/common'; // '@common/components'; /** useConfirmOnLeaving is a hook that will prompt the user if they try to navigate away from, * or refresh the page, when there are unsaved changes. * */ export const useConfirmOnLeaving = (isUnsaved?: boolean) => { const t = useTranslation(); - const confirmMessage = `${t('heading.are-you-sure')}\n${t('messages.confirm-cancel-generic')}`; - const customConfirm = (onOk: () => void) => { - if (confirm(confirmMessage)) { - onOk(); - } + setOnConfirm(onOk); + showConfirmation(); }; - useBlocker( - useCallback( - ({ currentLocation, nextLocation }) => { - if (!!isUnsaved && currentLocation.pathname !== nextLocation.pathname) { - return !confirm(confirmMessage); - } - return false; - }, - [isUnsaved] - ) + const { setOpen, setMessage, setOnConfirm, setTitle } = useContext( + ConfirmationModalContext ); + const showConfirmation = useCallback(() => { + setMessage(t('heading.are-you-sure')); + setTitle(t('messages.confirm-cancel-generic')); + setOpen(true); + }, [setMessage, setTitle, setOpen]); + + const shouldBlock = useCallback( + ({ currentLocation, nextLocation }) => { + if (!!isUnsaved && currentLocation.pathname !== nextLocation.pathname) { + showConfirmation(); + return true; + } + return false; + }, + [isUnsaved, showConfirmation] + ); + + const blocker = useBlocker(shouldBlock); + + // handle page refresh events useBeforeUnload( useCallback( event => { @@ -38,5 +48,19 @@ export const useConfirmOnLeaving = (isUnsaved?: boolean) => { { capture: true } ); + // Reset the blocker if the dirty state changes + useEffect(() => { + if (blocker.state === 'blocked' && !isUnsaved) { + blocker.reset(); + } + }, [blocker, isUnsaved]); + + // update the onConfirm function when the blocker changes + useEffect(() => { + setOnConfirm(() => { + blocker?.proceed?.(); + }); + }, [blocker]); + return { showConfirmation: customConfirm }; }; From fe57561a1ad1be190b04daa23fa547a2465438ee Mon Sep 17 00:00:00 2001 From: James Brunskill Date: Fri, 1 Nov 2024 14:16:27 +1300 Subject: [PATCH 14/32] Validation for item_variant and packaging_variant --- .../common/src/intl/locales/en/common.json | 3 +- client/packages/common/src/types/schema.ts | 2 +- .../Tabs/ItemVariants/ItemVariantModal.tsx | 25 +++- .../hooks/useItemVariant/useItemVariant.ts | 5 + .../src/Item/api/operations.generated.ts | 13 +- .../system/src/Item/api/operations.graphql | 11 ++ .../item_variant/src/mutations/upsert.rs | 16 +- .../service/src/item/item_variant/test/mod.rs | 85 ++++++++++- .../service/src/item/item_variant/upsert.rs | 39 ++++- .../src/item/packaging_variant/test/mod.rs | 138 ++++++++++++++++-- .../src/item/packaging_variant/upsert.rs | 32 ++++ 11 files changed, 346 insertions(+), 23 deletions(-) diff --git a/client/packages/common/src/intl/locales/en/common.json b/client/packages/common/src/intl/locales/en/common.json index 2cd5abca2a..cd07566641 100644 --- a/client/packages/common/src/intl/locales/en/common.json +++ b/client/packages/common/src/intl/locales/en/common.json @@ -235,6 +235,7 @@ "error.failed-to-save-service-charges": "Failed to save service charges", "error.failed-to-save-vaccination": "Failed to save vaccination!", "error.failed-to-save-item-variant": "Failed to save item variant", + "error.duplicate-item-variant-name": "Item variant with the same name already exists for this item", "error.failed-to-save-vaccine-course": "Failed to save vaccine course", "error.fetch-notifications": "Unable to display cold chain notifications", "error.field-must-be-specified": "{{field}} must be specified", @@ -1633,4 +1634,4 @@ "warning.caps-lock": "Warning: Caps lock is on", "warning.field-not-parsed": "{{field}} not parsed", "warning.nothing-to-supply": "Nothing left to supply!" -} +} \ No newline at end of file diff --git a/client/packages/common/src/types/schema.ts b/client/packages/common/src/types/schema.ts index e29bd33bee..c1389124ba 100644 --- a/client/packages/common/src/types/schema.ts +++ b/client/packages/common/src/types/schema.ts @@ -7479,7 +7479,7 @@ export enum UniqueValueKey { Serial = 'serial' } -export type UniqueValueViolation = InsertAssetCatalogueItemErrorInterface & InsertAssetErrorInterface & InsertAssetLogErrorInterface & InsertAssetLogReasonErrorInterface & InsertDemographicIndicatorErrorInterface & InsertDemographicProjectionErrorInterface & InsertLocationErrorInterface & UpdateAssetErrorInterface & UpdateDemographicIndicatorErrorInterface & UpdateDemographicProjectionErrorInterface & UpdateLocationErrorInterface & UpdateSensorErrorInterface & { +export type UniqueValueViolation = InsertAssetCatalogueItemErrorInterface & InsertAssetErrorInterface & InsertAssetLogErrorInterface & InsertAssetLogReasonErrorInterface & InsertDemographicIndicatorErrorInterface & InsertDemographicProjectionErrorInterface & InsertLocationErrorInterface & UpdateAssetErrorInterface & UpdateDemographicIndicatorErrorInterface & UpdateDemographicProjectionErrorInterface & UpdateLocationErrorInterface & UpdateSensorErrorInterface & UpsertItemVariantErrorInterface & { __typename: 'UniqueValueViolation'; description: Scalars['String']['output']; field: UniqueValueKey; diff --git a/client/packages/system/src/Item/DetailView/Tabs/ItemVariants/ItemVariantModal.tsx b/client/packages/system/src/Item/DetailView/Tabs/ItemVariants/ItemVariantModal.tsx index 29bbc2e929..661b1de22d 100644 --- a/client/packages/system/src/Item/DetailView/Tabs/ItemVariants/ItemVariantModal.tsx +++ b/client/packages/system/src/Item/DetailView/Tabs/ItemVariants/ItemVariantModal.tsx @@ -35,7 +35,7 @@ export const ItemVariantModal = ({ const t = useTranslation(); const { Modal } = useDialog({ isOpen: true, onClose, disableBackdrop: true }); const height = useKeyboardHeightAdjustment(500); - const { success } = useNotification(); + const { success, error } = useNotification(); const { draft, isComplete, updateDraft, updatePackagingVariant, save } = useItemVariant({ @@ -52,9 +52,26 @@ export const ItemVariantModal = ({ disabled={!isComplete} variant="ok" onClick={() => { - save(draft); - success(t('messages.item-variant-saved'))(); - onClose(); + save(draft) + .then(() => { + success(t('messages.item-variant-saved'))(); + onClose(); + }) + .catch(e => { + // We create the same error message as we get from the default handler, but prevent duplicates + // This avoids the same error message being displayed multiple times, and the only appears once bug... + // https://github.com/msupply-foundation/open-msupply/issues/3984 + if ( + e instanceof Error && + e.message.includes(t('error.duplicate-item-variant-name')) + ) { + error(t('error.duplicate-item-variant-name'), { + preventDuplicate: true, + })(); + return; + } + error(t('error.failed-to-save-item-variant'))(); + }); }} /> } diff --git a/client/packages/system/src/Item/api/hooks/useItemVariant/useItemVariant.ts b/client/packages/system/src/Item/api/hooks/useItemVariant/useItemVariant.ts index 0c2ec3b8be..2c32489fe7 100644 --- a/client/packages/system/src/Item/api/hooks/useItemVariant/useItemVariant.ts +++ b/client/packages/system/src/Item/api/hooks/useItemVariant/useItemVariant.ts @@ -101,6 +101,11 @@ const useUpsert = ({ itemId }: { itemId: string }) => { if (result.__typename === 'ItemVariantNode') { return result; } + if (result.__typename === 'UpsertItemVariantError') { + if (result.error.__typename === 'UniqueValueViolation') { + throw new Error(t('error.duplicate-item-variant-name')); + } + } } throw new Error(t('error.failed-to-save-item-variant')); }; diff --git a/client/packages/system/src/Item/api/operations.generated.ts b/client/packages/system/src/Item/api/operations.generated.ts index f4d8bb7b7d..722c1d5d1c 100644 --- a/client/packages/system/src/Item/api/operations.generated.ts +++ b/client/packages/system/src/Item/api/operations.generated.ts @@ -107,7 +107,7 @@ export type UpsertItemVariantMutationVariables = Types.Exact<{ }>; -export type UpsertItemVariantMutation = { __typename: 'Mutations', centralServer: { __typename: 'CentralServerMutationNode', itemVariant: { __typename: 'ItemVariantMutations', upsertItemVariant: { __typename: 'ItemVariantNode', id: string, name: string, dosesPerUnit?: number | null, manufacturerId?: string | null, coldStorageTypeId?: string | null, manufacturer?: { __typename: 'NameNode', code: string, id: string, isCustomer: boolean, isSupplier: boolean, isOnHold: boolean, name: string, store?: { __typename: 'StoreNode', id: string, code: string } | null } | null, coldStorageType?: { __typename: 'ColdStorageTypeNode', id: string, name: string } | null, packagingVariants: Array<{ __typename: 'PackagingVariantNode', id: string, name: string, packagingLevel: number, packSize?: number | null, volumePerUnit?: number | null }> } | { __typename: 'UpsertItemVariantError' } } } }; +export type UpsertItemVariantMutation = { __typename: 'Mutations', centralServer: { __typename: 'CentralServerMutationNode', itemVariant: { __typename: 'ItemVariantMutations', upsertItemVariant: { __typename: 'ItemVariantNode', id: string, name: string, dosesPerUnit?: number | null, manufacturerId?: string | null, coldStorageTypeId?: string | null, manufacturer?: { __typename: 'NameNode', code: string, id: string, isCustomer: boolean, isSupplier: boolean, isOnHold: boolean, name: string, store?: { __typename: 'StoreNode', id: string, code: string } | null } | null, coldStorageType?: { __typename: 'ColdStorageTypeNode', id: string, name: string } | null, packagingVariants: Array<{ __typename: 'PackagingVariantNode', id: string, name: string, packagingLevel: number, packSize?: number | null, volumePerUnit?: number | null }> } | { __typename: 'UpsertItemVariantError', error: { __typename: 'DatabaseError', description: string } | { __typename: 'InternalError', description: string } | { __typename: 'UniqueValueViolation', description: string, field: Types.UniqueValueKey } } } } }; export type DeleteItemVariantMutationVariables = Types.Exact<{ storeId: Types.Scalars['String']['input']; @@ -434,6 +434,17 @@ export const UpsertItemVariantDocument = gql` ... on ItemVariantNode { ...ItemVariant } + ... on UpsertItemVariantError { + __typename + error { + __typename + description + ... on UniqueValueViolation { + description + field + } + } + } } } } diff --git a/client/packages/system/src/Item/api/operations.graphql b/client/packages/system/src/Item/api/operations.graphql index 61715cf928..ccef22dfc7 100644 --- a/client/packages/system/src/Item/api/operations.graphql +++ b/client/packages/system/src/Item/api/operations.graphql @@ -324,6 +324,17 @@ mutation upsertItemVariant($storeId: String!, $input: UpsertItemVariantInput!) { ... on ItemVariantNode { ...ItemVariant } + ... on UpsertItemVariantError { + __typename + error { + __typename + description + ... on UniqueValueViolation { + description + field + } + } + } } } } diff --git a/server/graphql/item_variant/src/mutations/upsert.rs b/server/graphql/item_variant/src/mutations/upsert.rs index d332b14538..a28bf0247d 100644 --- a/server/graphql/item_variant/src/mutations/upsert.rs +++ b/server/graphql/item_variant/src/mutations/upsert.rs @@ -1,6 +1,6 @@ use async_graphql::*; use graphql_core::{ - simple_generic_errors::{DatabaseError, InternalError}, + simple_generic_errors::{DatabaseError, InternalError, UniqueValueKey, UniqueValueViolation}, standard_graphql_error::{validate_auth, StandardGraphqlError}, ContextExt, }; @@ -49,6 +49,7 @@ pub enum UpsertItemVariantResponse { #[graphql(field(name = "description", ty = "String"))] pub enum UpsertItemVariantErrorInterface { InternalError(InternalError), + DuplicateName(UniqueValueViolation), DatabaseError(DatabaseError), } @@ -137,6 +138,13 @@ fn map_error(error: ServiceError) -> Result { let formatted_error = format!("{:#?}", error); let graphql_error = match error { + // Structured errors + ServiceError::DuplicateName => { + return Ok(UpsertItemVariantErrorInterface::DuplicateName( + UniqueValueViolation(UniqueValueKey::Name), + )) + } + // Generic errors ServiceError::CreatedRecordNotFound => InternalError(formatted_error), ServiceError::ItemDoesNotExist => InternalError(formatted_error), ServiceError::PackagingVariantError(upsert_packaging_variant_error) => { @@ -144,6 +152,9 @@ fn map_error(error: ServiceError) -> Result { UpsertPackagingVariantError::ItemVariantDoesNotExist => { BadUserInput(formatted_error) } + UpsertPackagingVariantError::CantChangeItemVariant => BadUserInput(formatted_error), + UpsertPackagingVariantError::LessThanZero(_field) => BadUserInput(formatted_error), + UpsertPackagingVariantError::DatabaseError(_repository_error) => { InternalError(formatted_error) } @@ -153,6 +164,9 @@ fn map_error(error: ServiceError) -> Result { } } ServiceError::DatabaseError(_repository_error) => InternalError(formatted_error), + ServiceError::CantChangeItem => BadUserInput(formatted_error), + + ServiceError::ColdStorageTypeDoesNotExist => BadUserInput(formatted_error), }; Err(graphql_error.extend()) diff --git a/server/service/src/item/item_variant/test/mod.rs b/server/service/src/item/item_variant/test/mod.rs index 07ef170a96..b922a3772e 100644 --- a/server/service/src/item/item_variant/test/mod.rs +++ b/server/service/src/item/item_variant/test/mod.rs @@ -6,7 +6,9 @@ mod query { use repository::{EqualFilter, StringFilter}; use util::uuid::uuid; - use crate::item::item_variant::{DeleteItemVariant, UpsertItemVariantWithPackaging}; + use crate::item::item_variant::{ + DeleteItemVariant, UpsertItemVariantError, UpsertItemVariantWithPackaging, + }; use crate::service_provider::ServiceProvider; #[actix_rt::test] @@ -139,16 +141,89 @@ mod query { #[actix_rt::test] async fn validate_item_variant() { - // TODO validation tests + let (_, _, connection_manager, _) = + setup_all("validate_item_variant", MockDataInserts::none().items()).await; - // Test that the item variant name is set? + let service_provider = ServiceProvider::new(connection_manager, "app_data"); + let context = service_provider.basic_context().unwrap(); + let service = service_provider.item_service; + + let test_item_a_variant_id = "test_item_variant_id"; // Test that we can't create a record with an item_id that doesn't exist + let result = service.upsert_item_variant( + &context, + UpsertItemVariantWithPackaging { + id: test_item_a_variant_id.to_string(), + item_id: uuid(), + name: "Variant 1".to_string(), + ..Default::default() + }, + ); + + assert_eq!( + result.unwrap_err(), + UpsertItemVariantError::ItemDoesNotExist + ); // Test that we can't change the item_id on an existing record??? - // Test that we can't create/update a record with an invalid cold_storage_id + // Create a new item variant for item_a + let _item_a_variant_a = service + .upsert_item_variant( + &context, + UpsertItemVariantWithPackaging { + id: test_item_a_variant_id.to_string(), + item_id: mock_item_a().id, + name: "item_a_variant_a".to_string(), + ..Default::default() + }, + ) + .unwrap(); + + // Try to change the item_id + let result = service.upsert_item_variant( + &context, + UpsertItemVariantWithPackaging { + id: test_item_a_variant_id.to_string(), + item_id: mock_item_b().id, + name: "Variant 1".to_string(), + ..Default::default() + }, + ); - // Test name should be unique for an item? + assert_eq!(result.unwrap_err(), UpsertItemVariantError::CantChangeItem); + + // Test that we can't create/update a record with an invalid cold_storage_id + let result = service.upsert_item_variant( + &context, + UpsertItemVariantWithPackaging { + id: test_item_a_variant_id.to_string(), + item_id: mock_item_a().id, + name: "Variant 1".to_string(), + cold_storage_type_id: Some(uuid()), + ..Default::default() + }, + ); + + assert_eq!( + result.unwrap_err(), + UpsertItemVariantError::ColdStorageTypeDoesNotExist + ); + + // Test: name should be unique for an item + + // Add another item variant for item_a with the same name + let result = service.upsert_item_variant( + &context, + UpsertItemVariantWithPackaging { + id: uuid(), + item_id: mock_item_a().id, + name: "item_a_variant_a".to_string(), + ..Default::default() + }, + ); + + assert_eq!(result.unwrap_err(), UpsertItemVariantError::DuplicateName); } } diff --git a/server/service/src/item/item_variant/upsert.rs b/server/service/src/item/item_variant/upsert.rs index ba7eabee01..ea1b8b96ca 100644 --- a/server/service/src/item/item_variant/upsert.rs +++ b/server/service/src/item/item_variant/upsert.rs @@ -1,10 +1,11 @@ use repository::{ item_variant::{ + item_variant::{ItemVariantFilter, ItemVariantRepository}, item_variant_row::{ItemVariantRow, ItemVariantRowRepository}, packaging_variant::{PackagingVariantFilter, PackagingVariantRepository}, packaging_variant_row::PackagingVariantRowRepository, }, - EqualFilter, RepositoryError, StorageConnection, + ColdStorageTypeRowRepository, EqualFilter, RepositoryError, StorageConnection, StringFilter, }; use crate::{ @@ -21,6 +22,9 @@ use crate::{ pub enum UpsertItemVariantError { CreatedRecordNotFound, ItemDoesNotExist, + CantChangeItem, + DuplicateName, + ColdStorageTypeDoesNotExist, PackagingVariantError(UpsertPackagingVariantError), DatabaseError(RepositoryError), } @@ -124,5 +128,38 @@ fn validate( return Err(UpsertItemVariantError::ItemDoesNotExist); } + let old_item_variant = ItemVariantRowRepository::new(connection).find_one_by_id(&input.id)?; + + if let Some(old_item_variant) = old_item_variant { + if old_item_variant.item_link_id != input.item_id { + return Err(UpsertItemVariantError::CantChangeItem); + } + } + + if let Some(cold_storage_type_id) = &input.cold_storage_type_id { + // Check if the cold storage type exists + let repo = ColdStorageTypeRowRepository::new(connection); + let cold_storage_type = repo.find_one_by_id(cold_storage_type_id)?; + if cold_storage_type.is_none() { + return Err(UpsertItemVariantError::ColdStorageTypeDoesNotExist); + } + } + + // Check for duplicate name under the same item + let item_variants_with_duplicate_name = ItemVariantRepository::new(connection) + .query_by_filter( + ItemVariantFilter::new() + .name(StringFilter::equal_to(&input.name.trim())) + .item_id(EqualFilter::equal_to(&input.item_id)), + )?; + + if item_variants_with_duplicate_name + .iter() + .find(|v| v.id != input.id) + .is_some() + { + return Err(UpsertItemVariantError::DuplicateName); + } + Ok(()) } diff --git a/server/service/src/item/packaging_variant/test/mod.rs b/server/service/src/item/packaging_variant/test/mod.rs index 698c30ee9e..2f11fd3764 100644 --- a/server/service/src/item/packaging_variant/test/mod.rs +++ b/server/service/src/item/packaging_variant/test/mod.rs @@ -4,9 +4,12 @@ mod query { use repository::mock::{mock_item_a, MockDataInserts}; use repository::test_db::setup_all; use repository::EqualFilter; + use util::uuid::uuid; use crate::item::item_variant::UpsertItemVariantWithPackaging; - use crate::item::packaging_variant::{DeletePackagingVariant, UpsertPackagingVariant}; + use crate::item::packaging_variant::{ + DeletePackagingVariant, UpsertPackagingVariant, UpsertPackagingVariantError, + }; use crate::service_provider::ServiceProvider; #[actix_rt::test] @@ -131,19 +134,136 @@ mod query { #[actix_rt::test] async fn validate_packaging_variant() { - // TODO validation tests + let (_, _, connection_manager, _) = setup_all( + "validate_packaging_variant", + MockDataInserts::none().items(), + ) + .await; + + let service_provider = ServiceProvider::new(connection_manager, "app_data"); + let context = service_provider.basic_context().unwrap(); + let service = service_provider.item_service; + + let test_item_variant_id = "test_item_variant_id"; + let test_item_variant2_id = "test_item_variant2_id"; + let test_packaging_variant_id = "test_packaging_variant_id"; + + // Create a 2 new item variants + let _item_variant = service + .upsert_item_variant( + &context, + UpsertItemVariantWithPackaging { + id: test_item_variant_id.to_string(), + item_id: mock_item_a().id, + name: "item_variant_a".to_string(), + ..Default::default() + }, + ) + .unwrap(); + + let _item_variant2 = service + .upsert_item_variant( + &context, + UpsertItemVariantWithPackaging { + id: test_item_variant2_id.to_string(), + item_id: mock_item_a().id, + name: "item_variant_b".to_string(), + ..Default::default() + }, + ) + .unwrap(); - // Test that the name is set? + // Create a new packaging variant + service + .upsert_packaging_variant( + &context, + UpsertPackagingVariant { + id: test_packaging_variant_id.to_string(), + item_variant_id: test_item_variant_id.to_string(), + name: "packaging_variant_a".to_string(), + ..Default::default() + }, + ) + .unwrap(); // Test that we can't create a record with an item_variant_id that doesn't exist + let result = service.upsert_packaging_variant( + &context, + UpsertPackagingVariant { + id: uuid(), + item_variant_id: "some_id_that_doesn't_exist".to_string(), + name: "packaging_variant_a".to_string(), + ..Default::default() + }, + ); + + assert_eq!( + result.unwrap_err(), + UpsertPackagingVariantError::ItemVariantDoesNotExist + ); // Test that we can't change the item_variant_id on an existing record??? + let result = service.upsert_packaging_variant( + &context, + UpsertPackagingVariant { + id: test_packaging_variant_id.to_string(), + item_variant_id: test_item_variant2_id.to_string(), + name: "packaging_variant_a".to_string(), + ..Default::default() + }, + ); + + assert_eq!( + result.unwrap_err(), + UpsertPackagingVariantError::CantChangeItemVariant + ); + + // Test that we can't create a record with a packaging_level < 0 + let result = service.upsert_packaging_variant( + &context, + UpsertPackagingVariant { + id: test_packaging_variant_id.to_string(), + item_variant_id: test_item_variant_id.to_string(), + name: "packaging_variant_a".to_string(), + packaging_level: -1, + ..Default::default() + }, + ); + assert_eq!( + result.unwrap_err(), + UpsertPackagingVariantError::LessThanZero("packaging_level".to_string()) + ); - // Test that the following fields are all > 0 if supplied... - /* - packaging_level: i32, - pack_size: Option, - volume_per_unit: Option, - */ + // Test that we can't create a record with a pack_size < 0 + let result = service.upsert_packaging_variant( + &context, + UpsertPackagingVariant { + id: test_packaging_variant_id.to_string(), + item_variant_id: test_item_variant_id.to_string(), + name: "packaging_variant_a".to_string(), + pack_size: Some(-1.0), + ..Default::default() + }, + ); + assert_eq!( + result.unwrap_err(), + UpsertPackagingVariantError::LessThanZero("pack_size".to_string()) + ); + + // Test that we can't create a record with a volume_per_unit < 0 + let result = service.upsert_packaging_variant( + &context, + UpsertPackagingVariant { + id: test_packaging_variant_id.to_string(), + item_variant_id: test_item_variant_id.to_string(), + name: "packaging_variant_a".to_string(), + volume_per_unit: Some(-1.0), + ..Default::default() + }, + ); + assert_eq!( + result.unwrap_err(), + UpsertPackagingVariantError::LessThanZero("volume_per_unit".to_string()) + ); } } diff --git a/server/service/src/item/packaging_variant/upsert.rs b/server/service/src/item/packaging_variant/upsert.rs index b35b9772ec..f66542e610 100644 --- a/server/service/src/item/packaging_variant/upsert.rs +++ b/server/service/src/item/packaging_variant/upsert.rs @@ -9,6 +9,8 @@ use crate::{check_item_variant_exists, service_provider::ServiceContext}; pub enum UpsertPackagingVariantError { CreatedRecordNotFound, ItemVariantDoesNotExist, + CantChangeItemVariant, + LessThanZero(String), DatabaseError(RepositoryError), } @@ -76,5 +78,35 @@ fn validate( return Err(UpsertPackagingVariantError::ItemVariantDoesNotExist); } + let old_packaging_variant = + PackagingVariantRowRepository::new(connection).find_one_by_id(&input.id)?; + if let Some(old_packaging_variant) = old_packaging_variant { + if old_packaging_variant.item_variant_id != input.item_variant_id { + return Err(UpsertPackagingVariantError::CantChangeItemVariant); + } + } + + if input.packaging_level < 0 { + return Err(UpsertPackagingVariantError::LessThanZero( + "packaging_level".to_string(), + )); + } + + if let Some(pack_size) = input.pack_size { + if pack_size < 0.0 { + return Err(UpsertPackagingVariantError::LessThanZero( + "pack_size".to_string(), + )); + } + } + + if let Some(volume_per_unit) = input.volume_per_unit { + if volume_per_unit < 0.0 { + return Err(UpsertPackagingVariantError::LessThanZero( + "volume_per_unit".to_string(), + )); + } + } + Ok(()) } From 65a5fd033515fae76d7b39b0816aba68517b93b7 Mon Sep 17 00:00:00 2001 From: James Brunskill Date: Fri, 1 Nov 2024 16:28:30 +1300 Subject: [PATCH 15/32] WIP Bundled Item Service --- .../db_diesel/item_variant/bundled_item.rs | 6 +- .../service/src/item/bundled_item/delete.rs | 33 ++++++++ server/service/src/item/bundled_item/mod.rs | 7 ++ server/service/src/item/bundled_item/query.rs | 25 ++++++ .../service/src/item/bundled_item/upsert.rs | 80 +++++++++++++++++++ server/service/src/item/item_variant/mod.rs | 1 + .../service/src/item/item_variant/validate.rs | 14 ++++ server/service/src/item/mod.rs | 32 ++++++++ 8 files changed, 193 insertions(+), 5 deletions(-) create mode 100644 server/service/src/item/bundled_item/delete.rs create mode 100644 server/service/src/item/bundled_item/mod.rs create mode 100644 server/service/src/item/bundled_item/query.rs create mode 100644 server/service/src/item/bundled_item/upsert.rs create mode 100644 server/service/src/item/item_variant/validate.rs diff --git a/server/repository/src/db_diesel/item_variant/bundled_item.rs b/server/repository/src/db_diesel/item_variant/bundled_item.rs index 6fb3daa6e5..bb221b31ba 100644 --- a/server/repository/src/db_diesel/item_variant/bundled_item.rs +++ b/server/repository/src/db_diesel/item_variant/bundled_item.rs @@ -81,14 +81,10 @@ impl<'a> BundledItemRepository<'a> { let result = final_query.load::(self.connection.lock().connection())?; - Ok(result.into_iter().map(to_domain).collect()) + Ok(result) } } -fn to_domain(bundled_item_row: BundledItemRow) -> BundledItemRow { - bundled_item_row -} - type BoxedBundledItemQuery = IntoBoxed<'static, bundled_item::table, DBType>; fn create_filtered_query(filter: Option) -> BoxedBundledItemQuery { diff --git a/server/service/src/item/bundled_item/delete.rs b/server/service/src/item/bundled_item/delete.rs new file mode 100644 index 0000000000..598009c760 --- /dev/null +++ b/server/service/src/item/bundled_item/delete.rs @@ -0,0 +1,33 @@ +use repository::{item_variant::bundled_item_row::BundledItemRowRepository, RepositoryError}; + +use crate::service_provider::ServiceContext; + +#[derive(PartialEq, Debug)] +pub enum DeleteBundledItemError { + DatabaseError(RepositoryError), +} + +pub struct DeleteBundledItem { + pub id: String, +} + +pub fn delete_bundled_item( + ctx: &ServiceContext, + input: DeleteBundledItem, +) -> Result { + ctx.connection + .transaction_sync(|connection| { + // No validation needed for delete, since we have a soft delete + // If it's already deleted, it's fine to delete again... + let repo = BundledItemRowRepository::new(connection); + repo.mark_deleted(&input.id) + }) + .map_err(|error| error.to_inner_error())?; + Ok(input.id) +} + +impl From for DeleteBundledItemError { + fn from(error: RepositoryError) -> Self { + DeleteBundledItemError::DatabaseError(error) + } +} diff --git a/server/service/src/item/bundled_item/mod.rs b/server/service/src/item/bundled_item/mod.rs new file mode 100644 index 0000000000..08c81e0cf9 --- /dev/null +++ b/server/service/src/item/bundled_item/mod.rs @@ -0,0 +1,7 @@ +mod delete; +mod query; +mod test; +mod upsert; +pub use delete::{delete_bundled_item, DeleteBundledItem, DeleteBundledItemError}; +pub use query::get_bundled_items; +pub use upsert::{upsert_bundled_item, UpsertBundledItem, UpsertBundledItemError}; diff --git a/server/service/src/item/bundled_item/query.rs b/server/service/src/item/bundled_item/query.rs new file mode 100644 index 0000000000..2983e8283a --- /dev/null +++ b/server/service/src/item/bundled_item/query.rs @@ -0,0 +1,25 @@ +use repository::{ + item_variant::{ + bundled_item::{BundledItemFilter, BundledItemRepository}, + bundled_item_row::BundledItemRow, + }, + PaginationOption, StorageConnection, +}; + +use crate::{get_default_pagination, i64_to_u32, ListError, ListResult}; + +pub const MAX_LIMIT: u32 = 1000; +pub const MIN_LIMIT: u32 = 1; + +pub fn get_bundled_items( + connection: &StorageConnection, + pagination: Option, + filter: Option, +) -> Result, ListError> { + let pagination = get_default_pagination(pagination, MAX_LIMIT, MIN_LIMIT)?; + let repository = BundledItemRepository::new(connection); + Ok(ListResult { + rows: repository.query(pagination, filter.clone())?, + count: i64_to_u32(repository.count(filter)?), + }) +} diff --git a/server/service/src/item/bundled_item/upsert.rs b/server/service/src/item/bundled_item/upsert.rs new file mode 100644 index 0000000000..e8af0b8a81 --- /dev/null +++ b/server/service/src/item/bundled_item/upsert.rs @@ -0,0 +1,80 @@ +use repository::{ + item_variant::bundled_item_row::{BundledItemRow, BundledItemRowRepository}, + RepositoryError, StorageConnection, +}; + +use crate::{check_item_variant_exists, service_provider::ServiceContext}; + +#[derive(PartialEq, Debug)] +pub enum UpsertBundledItemError { + CreatedRecordNotFound, + PrincipalItemDoesNotExist, + BundledItemDoesNotExist, + DatabaseError(RepositoryError), +} + +#[derive(Default, Clone)] +pub struct UpsertBundledItem { + pub id: String, + pub principal_item_variant_id: String, + pub bundled_item_variant_id: String, + pub ratio: f64, +} + +pub fn upsert_bundled_item( + ctx: &ServiceContext, + input: UpsertBundledItem, +) -> Result { + let bundled_item = ctx + .connection + .transaction_sync(|connection| { + validate(connection, &input)?; + let new_bundled_item = generate(input.clone()); + let repo = BundledItemRowRepository::new(connection); + + repo.upsert_one(&new_bundled_item)?; + + repo.find_one_by_id(&new_bundled_item.id)? + .ok_or(UpsertBundledItemError::CreatedRecordNotFound) + }) + .map_err(|error| error.to_inner_error())?; + Ok(bundled_item) +} + +impl From for UpsertBundledItemError { + fn from(error: RepositoryError) -> Self { + UpsertBundledItemError::DatabaseError(error) + } +} + +pub fn generate( + UpsertBundledItem { + id, + principal_item_variant_id, + bundled_item_variant_id, + ratio, + }: UpsertBundledItem, +) -> BundledItemRow { + BundledItemRow { + id, + principal_item_variant_id, + bundled_item_variant_id, + ratio, + deleted_datetime: None, + } +} + +fn validate( + connection: &StorageConnection, + input: &UpsertBundledItem, +) -> Result<(), UpsertBundledItemError> { + if !check_item_variant_exists(connection, &input.principal_item_variant_id)? { + return Err(UpsertBundledItemError::PrincipalItemDoesNotExist); + } + + if !check_item_variant_exists(connection, &input.bundled_item_variant_id)? { + return Err(UpsertBundledItemError::BundledItemDoesNotExist); + } + + Ok(()) +} diff --git a/server/service/src/item/item_variant/mod.rs b/server/service/src/item/item_variant/mod.rs index b085a0a298..28f5782b30 100644 --- a/server/service/src/item/item_variant/mod.rs +++ b/server/service/src/item/item_variant/mod.rs @@ -2,6 +2,7 @@ mod delete; mod query; mod test; mod upsert; +pub mod validate; pub use delete::{delete_item_variant, DeleteItemVariant, DeleteItemVariantError}; pub use query::get_item_variants; pub use upsert::{upsert_item_variant, UpsertItemVariantError, UpsertItemVariantWithPackaging}; diff --git a/server/service/src/item/item_variant/validate.rs b/server/service/src/item/item_variant/validate.rs new file mode 100644 index 0000000000..0345d53e10 --- /dev/null +++ b/server/service/src/item/item_variant/validate.rs @@ -0,0 +1,14 @@ +use repository::{ + item_variant::item_variant::{ItemVariantFilter, ItemVariantRepository}, + EqualFilter, RepositoryError, StorageConnection, +}; + +pub fn check_item_variant_exists( + connection: &StorageConnection, + item_variant_id: &str, +) -> Result { + let count = ItemVariantRepository::new(connection).count(Some( + ItemVariantFilter::new().id(EqualFilter::equal_to(item_variant_id)), + ))?; + Ok(count > 0) +} diff --git a/server/service/src/item/mod.rs b/server/service/src/item/mod.rs index 9458baa142..8c0cce3a3a 100644 --- a/server/service/src/item/mod.rs +++ b/server/service/src/item/mod.rs @@ -1,6 +1,11 @@ +pub mod bundled_item; pub mod item; pub mod item_variant; pub mod packaging_variant; +use bundled_item::{ + delete_bundled_item, get_bundled_items, upsert_bundled_item, DeleteBundledItem, + DeleteBundledItemError, UpsertBundledItem, UpsertBundledItemError, +}; pub use item::*; use item_variant::{ delete_item_variant, get_item_variants, upsert_item_variant, DeleteItemVariant, @@ -13,6 +18,8 @@ use packaging_variant::{ }; use repository::{ item_variant::{ + bundled_item::BundledItemFilter, + bundled_item_row::BundledItemRow, item_variant::{ItemVariantFilter, ItemVariantSort}, item_variant_row::ItemVariantRow, packaging_variant::{PackagingVariantFilter, PackagingVariantSort}, @@ -75,6 +82,31 @@ pub trait ItemServiceTrait: Sync + Send { ) -> Result { delete_packaging_variant(ctx, input) } + + fn get_bundled_items( + &self, + ctx: &ServiceContext, + pagination: Option, + filter: Option, + ) -> Result, ListError> { + get_bundled_items(&ctx.connection, pagination, filter) + } + + fn upsert_bundled_item( + &self, + ctx: &ServiceContext, + input: UpsertBundledItem, + ) -> Result { + upsert_bundled_item(ctx, input) + } + + fn delete_bundled_item( + &self, + ctx: &ServiceContext, + input: DeleteBundledItem, + ) -> Result { + delete_bundled_item(ctx, input) + } } pub struct ItemService {} From 716df4fdd026431f48b76caeb4c95f4f3efbfce5 Mon Sep 17 00:00:00 2001 From: Carl Smith <5456533+CarlosNZ@users.noreply.github.com> Date: Fri, 1 Nov 2024 16:37:58 +1300 Subject: [PATCH 16/32] Handle null selection when clearing --- .../DetailView/modals/InboundLineEdit/InboundLineEditForm.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/packages/invoices/src/InboundShipment/DetailView/modals/InboundLineEdit/InboundLineEditForm.tsx b/client/packages/invoices/src/InboundShipment/DetailView/modals/InboundLineEdit/InboundLineEditForm.tsx index 7e714a47ac..5632ad4e2f 100644 --- a/client/packages/invoices/src/InboundShipment/DetailView/modals/InboundLineEdit/InboundLineEditForm.tsx +++ b/client/packages/invoices/src/InboundShipment/DetailView/modals/InboundLineEdit/InboundLineEditForm.tsx @@ -17,7 +17,7 @@ type InboundLineItem = InboundLineFragment['item']; interface InboundLineEditProps { item: InboundLineItem | null; disabled: boolean; - onChangeItem: (item: ItemStockOnHandFragment) => void; + onChangeItem: (item: ItemStockOnHandFragment | null) => void; } export const InboundLineEditForm: FC = ({ @@ -41,7 +41,7 @@ export const InboundLineEditForm: FC = ({ openOnFocus={!item} disabled={disabled} currentItemId={item?.id} - onChange={newItem => newItem && onChangeItem(newItem)} + onChange={newItem => onChangeItem(newItem)} extraFilter={ disabled ? undefined From ee6427998126a956ffe88f48f48261398d29ef80 Mon Sep 17 00:00:00 2001 From: James Brunskill Date: Fri, 1 Nov 2024 16:54:29 +1300 Subject: [PATCH 17/32] RC4 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a9a57fae01..dd793723b8 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "open-msupply", "//": "Main version for the app, should be in semantic version format (any release candidate or test build should be separated by '-' i.e. 1.1.1-rc1 or 1.1.1-test", - "version": "2.3.01-RC3", + "version": "2.3.01-RC4", "private": true, "scripts": { "start": "cd ./server && cargo run & cd ./client && yarn start-local", From e9a05e2d4101331712d49184f54b04abae206cbf Mon Sep 17 00:00:00 2001 From: James Brunskill Date: Fri, 1 Nov 2024 17:36:52 +1300 Subject: [PATCH 18/32] Graphql & Service for bundled items --- server/Cargo.lock | 20 +++ server/graphql/Cargo.toml | 1 + .../graphql/core/src/loader/item_variant.rs | 31 ++++- .../core/src/loader/loader_registry.rs | 10 +- server/graphql/item_bundle/Cargo.toml | 35 ++++++ server/graphql/item_bundle/src/lib.rs | 28 +++++ .../item_bundle/src/mutations/delete.rs | 68 ++++++++++ .../graphql/item_bundle/src/mutations/mod.rs | 5 + .../item_bundle/src/mutations/upsert.rs | 103 +++++++++++++++ server/graphql/lib.rs | 4 + .../graphql/types/src/types/bundled_item.rs | 55 ++++++++ server/graphql/types/src/types/item.rs | 4 +- server/graphql/types/src/types/mod.rs | 3 + server/repository/src/mock/item_variant.rs | 58 +++++++++ server/repository/src/mock/mod.rs | 22 ++++ .../service/src/item/bundled_item/test/mod.rs | 118 ++++++++++++++++++ 16 files changed, 559 insertions(+), 6 deletions(-) create mode 100644 server/graphql/item_bundle/Cargo.toml create mode 100644 server/graphql/item_bundle/src/lib.rs create mode 100644 server/graphql/item_bundle/src/mutations/delete.rs create mode 100644 server/graphql/item_bundle/src/mutations/mod.rs create mode 100644 server/graphql/item_bundle/src/mutations/upsert.rs create mode 100644 server/graphql/types/src/types/bundled_item.rs create mode 100644 server/repository/src/mock/item_variant.rs create mode 100644 server/service/src/item/bundled_item/test/mod.rs diff --git a/server/Cargo.lock b/server/Cargo.lock index 4ee6dd02c4..6688311a13 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -3844,6 +3844,7 @@ dependencies = [ "graphql_inventory_adjustment", "graphql_invoice", "graphql_invoice_line", + "graphql_item_bundle", "graphql_item_variant", "graphql_location", "graphql_plugin", @@ -4118,6 +4119,25 @@ dependencies = [ "util", ] +[[package]] +name = "graphql_item_bundle" +version = "0.1.0" +dependencies = [ + "actix-rt", + "actix-web", + "assert-json-diff", + "async-graphql", + "async-graphql-actix-web", + "async-trait", + "graphql_core", + "graphql_types", + "repository", + "serde", + "serde_json", + "service", + "util", +] + [[package]] name = "graphql_item_variant" version = "0.1.0" diff --git a/server/graphql/Cargo.toml b/server/graphql/Cargo.toml index d9203a2893..493900b055 100644 --- a/server/graphql/Cargo.toml +++ b/server/graphql/Cargo.toml @@ -21,6 +21,7 @@ graphql_cold_chain = { path = "cold_chain" } graphql_asset_catalogue = { path = "asset_catalogue" } graphql_inventory_adjustment = { path = "inventory_adjustment" } graphql_item_variant = { path = "item_variant" } +graphql_item_bundle = { path = "item_bundle" } graphql_invoice = { path = "invoice" } graphql_invoice_line = { path = "invoice_line" } graphql_vaccine_course = { path = "vaccine_course" } diff --git a/server/graphql/core/src/loader/item_variant.rs b/server/graphql/core/src/loader/item_variant.rs index 4287d137b5..4923e06489 100644 --- a/server/graphql/core/src/loader/item_variant.rs +++ b/server/graphql/core/src/loader/item_variant.rs @@ -8,11 +8,11 @@ use async_graphql::dataloader::*; use service::service_provider::ServiceProvider; use std::collections::HashMap; -pub struct ItemVariantRowLoader { +pub struct ItemVariantsByItemIdLoader { pub service_provider: Data, } -impl Loader for ItemVariantRowLoader { +impl Loader for ItemVariantsByItemIdLoader { type Value = Vec; type Error = RepositoryError; @@ -32,3 +32,30 @@ impl Loader for ItemVariantRowLoader { Ok(map) } } + +pub struct ItemVariantByItemVariantIdLoader { + pub service_provider: Data, +} + +impl Loader for ItemVariantByItemVariantIdLoader { + type Value = ItemVariantRow; + type Error = RepositoryError; + + async fn load( + &self, + item_variant_ids: &[String], + ) -> Result, Self::Error> { + let service_context = self.service_provider.basic_context()?; + let repo = ItemVariantRepository::new(&service_context.connection); + + let item_variants = repo.query_by_filter( + ItemVariantFilter::new().id(EqualFilter::equal_any(item_variant_ids.to_vec())), + )?; + + let mut map: HashMap = HashMap::new(); + for variant in item_variants { + map.insert(variant.clone().id, variant); + } + Ok(map) + } +} diff --git a/server/graphql/core/src/loader/loader_registry.rs b/server/graphql/core/src/loader/loader_registry.rs index 610cdd828b..a192edde63 100644 --- a/server/graphql/core/src/loader/loader_registry.rs +++ b/server/graphql/core/src/loader/loader_registry.rs @@ -2,7 +2,7 @@ use crate::loader::*; use actix_web::web::Data; use anymap::{any::Any, Map}; use async_graphql::dataloader::DataLoader; -use item_variant::ItemVariantRowLoader; +use item_variant::{ItemVariantByItemVariantIdLoader, ItemVariantsByItemIdLoader}; use repository::StorageConnectionManager; use service::service_provider::ServiceProvider; @@ -399,7 +399,13 @@ pub async fn get_loaders( )); loaders.insert(DataLoader::new( - ItemVariantRowLoader { + ItemVariantsByItemIdLoader { + service_provider: service_provider.clone(), + }, + async_std::task::spawn, + )); + loaders.insert(DataLoader::new( + ItemVariantByItemVariantIdLoader { service_provider: service_provider.clone(), }, async_std::task::spawn, diff --git a/server/graphql/item_bundle/Cargo.toml b/server/graphql/item_bundle/Cargo.toml new file mode 100644 index 0000000000..b46904ab0d --- /dev/null +++ b/server/graphql/item_bundle/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "graphql_item_bundle" +version = "0.1.0" +edition = "2018" + +[lib] +path = "src/lib.rs" +doctest = false + +[dependencies] + +repository = { path = "../../repository" } +service = { path = "../../service" } +util = { path = "../../util" } +graphql_core = { path = "../core" } +graphql_types = { path = "../types" } + +actix-web = { workspace = true } +async-graphql = { workspace = true } +async-graphql-actix-web = { workspace = true } +async-trait = { workspace = true } + +[dev-dependencies] +actix-rt = { workspace = true } +assert-json-diff = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } + +[features] +default = ["sqlite"] +sqlite = ["repository/sqlite"] +postgres = ["repository/postgres"] + +[lints] +workspace = true diff --git a/server/graphql/item_bundle/src/lib.rs b/server/graphql/item_bundle/src/lib.rs new file mode 100644 index 0000000000..e31249fe86 --- /dev/null +++ b/server/graphql/item_bundle/src/lib.rs @@ -0,0 +1,28 @@ +use async_graphql::*; + +mod mutations; +use self::mutations::*; + +#[derive(Default, Clone)] +pub struct BundledItemMutations; + +#[Object] +impl BundledItemMutations { + async fn upsert_bundled_item( + &self, + ctx: &Context<'_>, + store_id: String, + input: UpsertBundledItemInput, + ) -> Result { + upsert_bundled_item(ctx, store_id, input) + } + + async fn delete_bundled_item( + &self, + ctx: &Context<'_>, + store_id: String, + input: DeleteBundledItemInput, + ) -> Result { + delete_bundled_item(ctx, store_id, input) + } +} diff --git a/server/graphql/item_bundle/src/mutations/delete.rs b/server/graphql/item_bundle/src/mutations/delete.rs new file mode 100644 index 0000000000..6331a2f527 --- /dev/null +++ b/server/graphql/item_bundle/src/mutations/delete.rs @@ -0,0 +1,68 @@ +use async_graphql::*; +use graphql_core::{ + standard_graphql_error::{validate_auth, StandardGraphqlError}, + ContextExt, +}; +use graphql_types::types::DeleteResponse; +use service::{ + auth::{Resource, ResourceAccessRequest}, + item::bundled_item::{DeleteBundledItem, DeleteBundledItemError}, +}; + +#[derive(InputObject)] +pub struct DeleteBundledItemInput { + pub id: String, +} + +#[derive(Union)] +pub enum DeleteBundledItemResponse { + Response(DeleteResponse), +} + +pub fn delete_bundled_item( + ctx: &Context<'_>, + store_id: String, + input: DeleteBundledItemInput, +) -> Result { + validate_auth( + ctx, + &ResourceAccessRequest { + resource: Resource::MutateItemNamesCodesAndUnits, + store_id: Some(store_id.to_string()), + }, + )?; + + let service_provider = ctx.service_provider(); + let service_context = service_provider.basic_context()?; + + let result = service_provider + .item_service + .delete_bundled_item(&service_context, input.to_domain()); + + map_response(result) +} + +impl DeleteBundledItemInput { + pub fn to_domain(self) -> DeleteBundledItem { + let DeleteBundledItemInput { id } = self; + + DeleteBundledItem { id } + } +} + +fn map_response(from: Result) -> Result { + match from { + Ok(result) => Ok(DeleteBundledItemResponse::Response(DeleteResponse(result))), + Err(error) => { + let formatted_error = format!("{:#?}", error); + + let graphql_error = match error { + DeleteBundledItemError::DatabaseError(_) => { + StandardGraphqlError::InternalError(formatted_error) + } + }; + + Err(graphql_error.extend()) + } + } +} diff --git a/server/graphql/item_bundle/src/mutations/mod.rs b/server/graphql/item_bundle/src/mutations/mod.rs new file mode 100644 index 0000000000..d866f43bdb --- /dev/null +++ b/server/graphql/item_bundle/src/mutations/mod.rs @@ -0,0 +1,5 @@ +mod upsert; +pub use upsert::*; + +mod delete; +pub use delete::*; diff --git a/server/graphql/item_bundle/src/mutations/upsert.rs b/server/graphql/item_bundle/src/mutations/upsert.rs new file mode 100644 index 0000000000..1dbb7c1fa1 --- /dev/null +++ b/server/graphql/item_bundle/src/mutations/upsert.rs @@ -0,0 +1,103 @@ +use async_graphql::*; +use graphql_core::{ + simple_generic_errors::{DatabaseError, InternalError}, + standard_graphql_error::{validate_auth, StandardGraphqlError}, + ContextExt, +}; +use graphql_types::types::BundledItemNode; +use repository::item_variant::bundled_item_row::BundledItemRow; +use service::{ + auth::{Resource, ResourceAccessRequest}, + item::bundled_item::{UpsertBundledItem, UpsertBundledItemError as ServiceError}, +}; + +#[derive(InputObject)] +pub struct UpsertBundledItemInput { + pub id: String, + pub principal_item_variant_id: String, + pub bundled_item_variant_id: String, + pub ratio: f64, +} + +#[derive(SimpleObject)] +pub struct UpsertBundledItemError { + pub error: UpsertBundledItemErrorInterface, +} +#[derive(Union)] +#[graphql(name = "UpsertBundledItemResponse")] +pub enum UpsertBundledItemResponse { + Error(UpsertBundledItemError), + Response(BundledItemNode), +} + +#[derive(Interface)] +#[graphql(field(name = "description", ty = "String"))] +pub enum UpsertBundledItemErrorInterface { + InternalError(InternalError), + DatabaseError(DatabaseError), +} + +pub fn upsert_bundled_item( + ctx: &Context<'_>, + store_id: String, + input: UpsertBundledItemInput, +) -> Result { + validate_auth( + ctx, + &ResourceAccessRequest { + resource: Resource::MutateItemNamesCodesAndUnits, + store_id: Some(store_id.to_string()), + }, + )?; + let service_provider = ctx.service_provider(); + let service_context = service_provider.basic_context()?; + + let result = service_provider + .item_service + .upsert_bundled_item(&service_context, input.to_domain()); + + map_response(result) +} + +impl UpsertBundledItemInput { + pub fn to_domain(self) -> UpsertBundledItem { + let UpsertBundledItemInput { + id, + principal_item_variant_id: item_id, + bundled_item_variant_id, + ratio, + } = self; + + UpsertBundledItem { + id: id.clone(), + principal_item_variant_id: item_id.clone(), + bundled_item_variant_id: bundled_item_variant_id.clone(), + ratio, + } + } +} + +fn map_response(from: Result) -> Result { + let result = match from { + Ok(variant) => UpsertBundledItemResponse::Response(BundledItemNode::from_domain(variant)), + Err(error) => UpsertBundledItemResponse::Error(UpsertBundledItemError { + error: map_error(error)?, + }), + }; + + Ok(result) +} + +fn map_error(error: ServiceError) -> Result { + use StandardGraphqlError::*; + let formatted_error = format!("{:#?}", error); + + let graphql_error = match error { + ServiceError::CreatedRecordNotFound => InternalError(formatted_error), + ServiceError::DatabaseError(_repository_error) => InternalError(formatted_error), + ServiceError::PrincipalItemDoesNotExist => BadUserInput(formatted_error), + ServiceError::BundledItemDoesNotExist => BadUserInput(formatted_error), + }; + + Err(graphql_error.extend()) +} diff --git a/server/graphql/lib.rs b/server/graphql/lib.rs index dac83533e0..ce1177cb0d 100644 --- a/server/graphql/lib.rs +++ b/server/graphql/lib.rs @@ -33,6 +33,7 @@ use graphql_demographic::{DemographicIndicatorQueries, DemographicMutations}; use graphql_inventory_adjustment::InventoryAdjustmentMutations; use graphql_invoice::{InvoiceMutations, InvoiceQueries}; use graphql_invoice_line::{InvoiceLineMutations, InvoiceLineQueries}; +use graphql_item_bundle::BundledItemMutations; use graphql_item_variant::ItemVariantMutations; use graphql_location::{LocationMutations, LocationQueries}; use graphql_plugin::{PluginMutations, PluginQueries}; @@ -68,6 +69,9 @@ impl CentralServerMutationNode { async fn item_variant(&self) -> ItemVariantMutations { ItemVariantMutations } + async fn bundled_item(&self) -> BundledItemMutations { + BundledItemMutations + } async fn asset_catalogue(&self) -> AssetCatalogueMutations { AssetCatalogueMutations } diff --git a/server/graphql/types/src/types/bundled_item.rs b/server/graphql/types/src/types/bundled_item.rs new file mode 100644 index 0000000000..55c2e63753 --- /dev/null +++ b/server/graphql/types/src/types/bundled_item.rs @@ -0,0 +1,55 @@ +use async_graphql::*; +use dataloader::DataLoader; +use graphql_core::{loader::ItemVariantByItemVariantIdLoader, ContextExt}; +use repository::item_variant::bundled_item_row::BundledItemRow; + +use super::ItemVariantNode; + +pub struct BundledItemNode { + pub bundled_item: BundledItemRow, +} + +#[Object] +impl BundledItemNode { + pub async fn id(&self) -> &String { + &self.bundled_item.id + } + + pub async fn principal_item_variant_id(&self) -> &String { + &self.bundled_item.principal_item_variant_id + } + + pub async fn principal_item_variant( + &self, + ctx: &Context<'_>, + ) -> Result> { + let principal_item_variant_id = &self.bundled_item.principal_item_variant_id; + let loader = ctx.get_loader::>(); + let result = loader.load_one(principal_item_variant_id.clone()).await?; + Ok(result.map(|item_variant| ItemVariantNode::from_domain(item_variant))) + } + + pub async fn bundled_item_variant_id(&self) -> &String { + &self.bundled_item.bundled_item_variant_id + } + + pub async fn bundled_item_variant(&self, ctx: &Context<'_>) -> Result> { + let bundled_item_variant_id = &self.bundled_item.bundled_item_variant_id; + let loader = ctx.get_loader::>(); + let result = loader.load_one(bundled_item_variant_id.clone()).await?; + Ok(result.map(|item_variant| ItemVariantNode::from_domain(item_variant))) + } +} + +impl BundledItemNode { + pub fn from_domain(bundled_item: BundledItemRow) -> BundledItemNode { + BundledItemNode { bundled_item } + } + + pub fn from_vec(variants: Vec) -> Vec { + variants + .into_iter() + .map(BundledItemNode::from_domain) + .collect() + } +} diff --git a/server/graphql/types/src/types/item.rs b/server/graphql/types/src/types/item.rs index 859ee7d2d2..b6d9362bac 100644 --- a/server/graphql/types/src/types/item.rs +++ b/server/graphql/types/src/types/item.rs @@ -3,7 +3,7 @@ use async_graphql::dataloader::DataLoader; use async_graphql::*; use graphql_core::{ loader::{ - ItemStatsLoaderInput, ItemVariantRowLoader, ItemsStatsForItemLoader, + ItemStatsLoaderInput, ItemVariantsByItemIdLoader, ItemsStatsForItemLoader, ItemsStockOnHandLoader, ItemsStockOnHandLoaderInput, StockLineByItemAndStoreIdLoader, StockLineByItemAndStoreIdLoaderInput, }, @@ -134,7 +134,7 @@ impl ItemNode { } pub async fn variants(&self, ctx: &Context<'_>) -> Result> { - let loader = ctx.get_loader::>(); + let loader = ctx.get_loader::>(); let result = loader .load_one(self.row().id.clone()) .await? diff --git a/server/graphql/types/src/types/mod.rs b/server/graphql/types/src/types/mod.rs index f5fa4709a1..7f97648d15 100644 --- a/server/graphql/types/src/types/mod.rs +++ b/server/graphql/types/src/types/mod.rs @@ -7,6 +7,9 @@ pub use self::item::*; pub mod item_variant; pub use self::item_variant::*; +pub mod bundled_item; +pub use self::bundled_item::*; + pub mod item_stats; pub use self::item_stats::*; diff --git a/server/repository/src/mock/item_variant.rs b/server/repository/src/mock/item_variant.rs new file mode 100644 index 0000000000..fa832c7a10 --- /dev/null +++ b/server/repository/src/mock/item_variant.rs @@ -0,0 +1,58 @@ +use crate::{item_variant::item_variant_row::ItemVariantRow, mock::item::*}; + +pub fn mock_item_a_variant_1() -> ItemVariantRow { + ItemVariantRow { + id: "item_a_variant_variant_1".to_string(), + name: "Item A Variant 1".to_string(), + item_link_id: mock_item_a().id, + cold_storage_type_id: None, + doses_per_unit: None, + manufacturer_link_id: None, + deleted_datetime: None, + } +} + +pub fn mock_item_a_variant_2() -> ItemVariantRow { + ItemVariantRow { + id: "item_a_variant_variant_1".to_string(), + name: "Item A Variant 1".to_string(), + item_link_id: mock_item_a().id, + cold_storage_type_id: None, + doses_per_unit: Some(10), + manufacturer_link_id: None, + deleted_datetime: None, + } +} + +pub fn mock_item_b_variant_1() -> ItemVariantRow { + ItemVariantRow { + id: "item_b_variant_variant_1".to_string(), + name: "Item B Variant 1".to_string(), + item_link_id: mock_item_b().id, + cold_storage_type_id: None, + doses_per_unit: None, + manufacturer_link_id: None, + deleted_datetime: None, + } +} + +pub fn mock_item_b_variant_2() -> ItemVariantRow { + ItemVariantRow { + id: "item_b_variant_variant_2".to_string(), + name: "Item B Variant 2".to_string(), + item_link_id: mock_item_b().id, + cold_storage_type_id: None, + doses_per_unit: Some(10), + manufacturer_link_id: None, + deleted_datetime: None, + } +} + +pub fn mock_item_variants() -> Vec { + vec![ + mock_item_a_variant_1(), + mock_item_a_variant_2(), + mock_item_b_variant_1(), + mock_item_b_variant_2(), + ] +} diff --git a/server/repository/src/mock/mod.rs b/server/repository/src/mock/mod.rs index b74770861d..137ce16689 100644 --- a/server/repository/src/mock/mod.rs +++ b/server/repository/src/mock/mod.rs @@ -18,6 +18,7 @@ mod full_master_list; mod invoice; mod invoice_line; mod item; +mod item_variant; mod location; mod name; mod name_store_join; @@ -78,6 +79,7 @@ pub use full_master_list::*; pub use invoice::*; pub use invoice_line::*; pub use item::*; +pub use item_variant::*; pub use location::*; pub use name::*; pub use name_store_join::*; @@ -123,6 +125,7 @@ use crate::{ asset_log_row::{AssetLogRow, AssetLogRowRepository}, asset_row::{AssetRow, AssetRowRepository}, }, + item_variant::item_variant_row::{ItemVariantRow, ItemVariantRowRepository}, vaccine_course::{ vaccine_course_dose_row::{VaccineCourseDoseRow, VaccineCourseDoseRowRepository}, vaccine_course_item_row::{VaccineCourseItemRow, VaccineCourseItemRowRepository}, @@ -175,6 +178,7 @@ pub struct MockData { pub units: Vec, pub currencies: Vec, pub items: Vec, + pub item_variants: Vec, pub locations: Vec, pub sensors: Vec, pub temperature_breaches: Vec, @@ -254,6 +258,7 @@ pub struct MockDataInserts { pub units: bool, pub currencies: bool, pub items: bool, + pub item_variants: bool, pub locations: bool, pub sensors: bool, pub temperature_breaches: bool, @@ -319,6 +324,7 @@ impl MockDataInserts { units: true, currencies: true, items: true, + item_variants: true, locations: true, sensors: true, temperature_breaches: true, @@ -442,6 +448,13 @@ impl MockDataInserts { self } + pub fn item_variants(mut self) -> Self { + self.units = true; + self.items = true; + self.item_variants = true; + self + } + pub fn locations(mut self) -> Self { self.locations = true; self @@ -910,6 +923,13 @@ pub fn insert_mock_data( } } + if inserts.item_variants { + let repo = ItemVariantRowRepository::new(connection); + for row in &mock_data.item_variants { + repo.upsert_one(row).unwrap(); + } + } + if inserts.locations { let repo = LocationRowRepository::new(connection); for row in &mock_data.locations { @@ -1255,6 +1275,7 @@ impl MockData { mut stores, mut units, mut items, + mut item_variants, mut locations, mut sensors, mut temperature_breaches, @@ -1319,6 +1340,7 @@ impl MockData { self.stores.append(&mut stores); self.units.append(&mut units); self.items.append(&mut items); + self.item_variants.append(&mut item_variants); self.locations.append(&mut locations); self.sensors.append(&mut sensors); self.temperature_logs.append(&mut temperature_logs); diff --git a/server/service/src/item/bundled_item/test/mod.rs b/server/service/src/item/bundled_item/test/mod.rs new file mode 100644 index 0000000000..4b14e1f6e1 --- /dev/null +++ b/server/service/src/item/bundled_item/test/mod.rs @@ -0,0 +1,118 @@ +#[cfg(test)] +mod query { + use repository::item_variant::bundled_item::BundledItemFilter; + use repository::mock::{ + mock_item_a, mock_item_a_variant_1, mock_item_b_variant_1, MockDataInserts, + }; + use repository::test_db::setup_all; + use repository::EqualFilter; + use util::uuid::uuid; + + use crate::item::bundled_item::{DeleteBundledItem, UpsertBundledItem}; + use crate::service_provider::ServiceProvider; + + #[actix_rt::test] + async fn create_edit_delete_bundled_item() { + let (_, _, connection_manager, _) = setup_all( + "create_edit_delete_bundled_item", + MockDataInserts::none().item_variants(), + ) + .await; + + let service_provider = ServiceProvider::new(connection_manager, "app_data"); + let context = service_provider.basic_context().unwrap(); + let service = service_provider.item_service; + + let test_bundled_item_record_id = "test_bundled_item_id"; + + // Create a new bundled item for item_a_variant_1 to bundle with item_b_variant_1 + service + .upsert_bundled_item( + &context, + UpsertBundledItem { + id: test_bundled_item_record_id.to_string(), + principal_item_variant_id: mock_item_a_variant_1().id, + bundled_item_variant_id: mock_item_b_variant_1().id, + ratio: 1.0, + }, + ) + .unwrap(); + + // Create a new bundled item for item_a_variant_2 to bundle with item_b_variant_2 + // This is just to make sure that we can have multiple bundled items in the db + service + .upsert_bundled_item( + &context, + UpsertBundledItem { + id: uuid(), + principal_item_variant_id: mock_item_a_variant_1().id, + bundled_item_variant_id: mock_item_b_variant_1().id, + ratio: 1.0, + }, + ) + .unwrap(); + + // Update the ratio + let _bundled_item = service + .upsert_bundled_item( + &context, + UpsertBundledItem { + id: test_bundled_item_record_id.to_string(), + principal_item_variant_id: mock_item_a().id, + bundled_item_variant_id: mock_item_a().id, + ratio: 2.0, + }, + ) + .unwrap(); + + // Query the bundled item by id + let bundled_item = service + .get_bundled_items( + &context, + None, + Some( + BundledItemFilter::new().id(EqualFilter::equal_to(test_bundled_item_record_id)), + ), + ) + .unwrap(); + + assert_eq!(bundled_item.count, 1); + assert_eq!( + bundled_item.rows[0].id, + test_bundled_item_record_id.to_string(), + ); + assert_eq!(bundled_item.rows[0].ratio, 2.0); + + // Delete the bundled item + service + .delete_bundled_item( + &context, + DeleteBundledItem { + id: test_bundled_item_record_id.to_string(), + }, + ) + .unwrap(); + + // Check that the delete worked + let bundled_item = service + .get_bundled_items( + &context, + None, + Some( + BundledItemFilter::new().id(EqualFilter::equal_to(test_bundled_item_record_id)), + ), + ) + .unwrap(); + + assert_eq!(bundled_item.count, 0); + } + + #[actix_rt::test] + async fn validate_bundled_item() { + // TODO validation tests + + // Check that the two item variants are not the same + + // Check that the two item variants are not from the same item (that would be bad) + } +} From ca3416f9ad7d9a8076faba73ef2da2982e7dcf58 Mon Sep 17 00:00:00 2001 From: mark-prins Date: Fri, 1 Nov 2024 18:06:07 +1300 Subject: [PATCH 19/32] simplify --- .../useConfirmOnLeaving.ts | 31 +++++-------------- 1 file changed, 8 insertions(+), 23 deletions(-) diff --git a/client/packages/common/src/hooks/useConfirmOnLeaving/useConfirmOnLeaving.ts b/client/packages/common/src/hooks/useConfirmOnLeaving/useConfirmOnLeaving.ts index 86fa38bd28..dd1dccd7c3 100644 --- a/client/packages/common/src/hooks/useConfirmOnLeaving/useConfirmOnLeaving.ts +++ b/client/packages/common/src/hooks/useConfirmOnLeaving/useConfirmOnLeaving.ts @@ -1,7 +1,7 @@ import { useCallback, useContext, useEffect } from 'react'; -import { BlockerFunction, useBeforeUnload, useBlocker } from 'react-router-dom'; +import { useBeforeUnload, useBlocker } from 'react-router-dom'; import { useTranslation } from '@common/intl'; -import { ConfirmationModalContext } from '@openmsupply-client/common'; // '@common/components'; +import { ConfirmationModalContext } from '@openmsupply-client/common'; /** useConfirmOnLeaving is a hook that will prompt the user if they try to navigate away from, * or refresh the page, when there are unsaved changes. @@ -23,19 +23,11 @@ export const useConfirmOnLeaving = (isUnsaved?: boolean) => { setOpen(true); }, [setMessage, setTitle, setOpen]); - const shouldBlock = useCallback( - ({ currentLocation, nextLocation }) => { - if (!!isUnsaved && currentLocation.pathname !== nextLocation.pathname) { - showConfirmation(); - return true; - } - return false; - }, - [isUnsaved, showConfirmation] + const blocker = useBlocker( + ({ currentLocation, nextLocation }) => + !!isUnsaved && currentLocation.pathname !== nextLocation.pathname ); - const blocker = useBlocker(shouldBlock); - // handle page refresh events useBeforeUnload( useCallback( @@ -48,18 +40,11 @@ export const useConfirmOnLeaving = (isUnsaved?: boolean) => { { capture: true } ); - // Reset the blocker if the dirty state changes useEffect(() => { - if (blocker.state === 'blocked' && !isUnsaved) { - blocker.reset(); + if (blocker.state === 'blocked') { + setOnConfirm(blocker.proceed); + showConfirmation(); } - }, [blocker, isUnsaved]); - - // update the onConfirm function when the blocker changes - useEffect(() => { - setOnConfirm(() => { - blocker?.proceed?.(); - }); }, [blocker]); return { showConfirmation: customConfirm }; From 29838150dc5de771d78a7345a301e4024b5ac96d Mon Sep 17 00:00:00 2001 From: James Brunskill Date: Mon, 4 Nov 2024 14:49:20 +1300 Subject: [PATCH 20/32] Release version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index dd793723b8..f5b05a7922 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "open-msupply", "//": "Main version for the app, should be in semantic version format (any release candidate or test build should be separated by '-' i.e. 1.1.1-rc1 or 1.1.1-test", - "version": "2.3.01-RC4", + "version": "2.3.01", "private": true, "scripts": { "start": "cd ./server && cargo run & cd ./client && yarn start-local", From bf1a7ca8141c050d5fed628185ecd4634289a509 Mon Sep 17 00:00:00 2001 From: James Brunskill Date: Mon, 4 Nov 2024 15:25:01 +1300 Subject: [PATCH 21/32] Allow defaults for date of death and item_name in sync --- server/service/src/sync/translations/activity_log.rs | 2 ++ server/service/src/sync/translations/name.rs | 1 + server/service/src/sync/translations/stocktake_line.rs | 1 + 3 files changed, 4 insertions(+) diff --git a/server/service/src/sync/translations/activity_log.rs b/server/service/src/sync/translations/activity_log.rs index 12237fc34e..c8f77391f3 100644 --- a/server/service/src/sync/translations/activity_log.rs +++ b/server/service/src/sync/translations/activity_log.rs @@ -24,8 +24,10 @@ pub struct LegacyActivityLogRow { pub record_id: String, pub datetime: NaiveDateTime, #[serde(deserialize_with = "empty_str_as_option_string")] + #[serde(default)] pub changed_to: Option, #[serde(deserialize_with = "empty_str_as_option_string")] + #[serde(default)] pub changed_from: Option, } diff --git a/server/service/src/sync/translations/name.rs b/server/service/src/sync/translations/name.rs index b3a7599a8c..86364cebea 100644 --- a/server/service/src/sync/translations/name.rs +++ b/server/service/src/sync/translations/name.rs @@ -122,6 +122,7 @@ pub struct LegacyNameRow { #[serde(deserialize_with = "empty_str_as_option")] pub gender: Option, #[serde(rename = "om_date_of_death")] + #[serde(default)] #[serde(deserialize_with = "zero_date_as_option")] #[serde(serialize_with = "date_option_to_isostring")] pub date_of_death: Option, diff --git a/server/service/src/sync/translations/stocktake_line.rs b/server/service/src/sync/translations/stocktake_line.rs index d05630a6af..9b503bd10e 100644 --- a/server/service/src/sync/translations/stocktake_line.rs +++ b/server/service/src/sync/translations/stocktake_line.rs @@ -34,6 +34,7 @@ pub struct LegacyStocktakeLineRow { #[serde(deserialize_with = "empty_str_as_option_string")] pub item_line_ID: Option, pub item_ID: String, + #[serde(default)] pub item_name: String, #[serde(deserialize_with = "empty_str_as_option_string")] pub Batch: Option, From 21c4fe076775b7770a36c1e5b5ef3af6323ce5e7 Mon Sep 17 00:00:00 2001 From: James Brunskill Date: Mon, 4 Nov 2024 16:14:52 +1300 Subject: [PATCH 22/32] Don't allow 0 packsize --- .../src/item/packaging_variant/test/mod.rs | 50 +++++++++++++++++++ .../src/item/packaging_variant/upsert.rs | 6 +-- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/server/service/src/item/packaging_variant/test/mod.rs b/server/service/src/item/packaging_variant/test/mod.rs index 2f11fd3764..4e19271b70 100644 --- a/server/service/src/item/packaging_variant/test/mod.rs +++ b/server/service/src/item/packaging_variant/test/mod.rs @@ -250,6 +250,39 @@ mod query { UpsertPackagingVariantError::LessThanZero("pack_size".to_string()) ); + + // Test that we can't create a record with a pack_size == 0 + let result = service.upsert_packaging_variant( + &context, + UpsertPackagingVariant { + id: test_packaging_variant_id.to_string(), + item_variant_id: test_item_variant_id.to_string(), + name: "packaging_variant_a".to_string(), + pack_size: Some(0.0), + ..Default::default() + }, + ); + assert_eq!( + result.unwrap_err(), + UpsertPackagingVariantError::LessThanZero("pack_size".to_string()) + ); + + // Test that we can't create a record with a pack_size == 0 + let result = service.upsert_packaging_variant( + &context, + UpsertPackagingVariant { + id: test_packaging_variant_id.to_string(), + item_variant_id: test_item_variant_id.to_string(), + name: "packaging_variant_a".to_string(), + pack_size: Some(0.0), + ..Default::default() + }, + ); + assert_eq!( + result.unwrap_err(), + UpsertPackagingVariantError::LessThanZero("pack_size".to_string()) + ); + // Test that we can't create a record with a volume_per_unit < 0 let result = service.upsert_packaging_variant( &context, @@ -265,5 +298,22 @@ mod query { result.unwrap_err(), UpsertPackagingVariantError::LessThanZero("volume_per_unit".to_string()) ); + + + // Test that we can't create a record with a volume_per_unit == 0 + let result = service.upsert_packaging_variant( + &context, + UpsertPackagingVariant { + id: test_packaging_variant_id.to_string(), + item_variant_id: test_item_variant_id.to_string(), + name: "packaging_variant_a".to_string(), + volume_per_unit: Some(0.0), + ..Default::default() + }, + ); + assert_eq!( + result.unwrap_err(), + UpsertPackagingVariantError::LessThanZero("volume_per_unit".to_string()) + ); } } diff --git a/server/service/src/item/packaging_variant/upsert.rs b/server/service/src/item/packaging_variant/upsert.rs index f66542e610..b2b13d006e 100644 --- a/server/service/src/item/packaging_variant/upsert.rs +++ b/server/service/src/item/packaging_variant/upsert.rs @@ -86,14 +86,14 @@ fn validate( } } - if input.packaging_level < 0 { + if input.packaging_level <= 0 { return Err(UpsertPackagingVariantError::LessThanZero( "packaging_level".to_string(), )); } if let Some(pack_size) = input.pack_size { - if pack_size < 0.0 { + if pack_size <= 0.0 { return Err(UpsertPackagingVariantError::LessThanZero( "pack_size".to_string(), )); @@ -101,7 +101,7 @@ fn validate( } if let Some(volume_per_unit) = input.volume_per_unit { - if volume_per_unit < 0.0 { + if volume_per_unit <= 0.0 { return Err(UpsertPackagingVariantError::LessThanZero( "volume_per_unit".to_string(), )); From 7e02128658448b57547955f04246bceff438a8d9 Mon Sep 17 00:00:00 2001 From: James Brunskill Date: Mon, 4 Nov 2024 17:40:12 +1300 Subject: [PATCH 23/32] Fix test --- .../src/item/packaging_variant/test/mod.rs | 40 ++++++++++++------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/server/service/src/item/packaging_variant/test/mod.rs b/server/service/src/item/packaging_variant/test/mod.rs index 4e19271b70..e23292bdcc 100644 --- a/server/service/src/item/packaging_variant/test/mod.rs +++ b/server/service/src/item/packaging_variant/test/mod.rs @@ -48,6 +48,7 @@ mod query { id: test_packaging_variant_id.to_string(), item_variant_id: item_variant.id, name: "packaging_variant_a".to_string(), + packaging_level: 1, ..Default::default() }, ) @@ -81,6 +82,7 @@ mod query { id: test_packaging_variant_id.to_string(), item_variant_id: test_item_variant_id.to_string(), name: "updated_name".to_string(), + packaging_level: 1, ..Default::default() }, ) @@ -181,6 +183,7 @@ mod query { id: test_packaging_variant_id.to_string(), item_variant_id: test_item_variant_id.to_string(), name: "packaging_variant_a".to_string(), + packaging_level: 1, ..Default::default() }, ) @@ -193,6 +196,7 @@ mod query { id: uuid(), item_variant_id: "some_id_that_doesn't_exist".to_string(), name: "packaging_variant_a".to_string(), + packaging_level: 1, ..Default::default() }, ); @@ -209,6 +213,7 @@ mod query { id: test_packaging_variant_id.to_string(), item_variant_id: test_item_variant2_id.to_string(), name: "packaging_variant_a".to_string(), + packaging_level: 1, ..Default::default() }, ); @@ -241,6 +246,7 @@ mod query { id: test_packaging_variant_id.to_string(), item_variant_id: test_item_variant_id.to_string(), name: "packaging_variant_a".to_string(), + packaging_level:1, pack_size: Some(-1.0), ..Default::default() }, @@ -258,6 +264,7 @@ mod query { id: test_packaging_variant_id.to_string(), item_variant_id: test_item_variant_id.to_string(), name: "packaging_variant_a".to_string(), + packaging_level:1, pack_size: Some(0.0), ..Default::default() }, @@ -274,6 +281,7 @@ mod query { id: test_packaging_variant_id.to_string(), item_variant_id: test_item_variant_id.to_string(), name: "packaging_variant_a".to_string(), + packaging_level:1, pack_size: Some(0.0), ..Default::default() }, @@ -290,6 +298,7 @@ mod query { id: test_packaging_variant_id.to_string(), item_variant_id: test_item_variant_id.to_string(), name: "packaging_variant_a".to_string(), + packaging_level:1, volume_per_unit: Some(-1.0), ..Default::default() }, @@ -300,20 +309,21 @@ mod query { ); - // Test that we can't create a record with a volume_per_unit == 0 - let result = service.upsert_packaging_variant( - &context, - UpsertPackagingVariant { - id: test_packaging_variant_id.to_string(), - item_variant_id: test_item_variant_id.to_string(), - name: "packaging_variant_a".to_string(), - volume_per_unit: Some(0.0), - ..Default::default() - }, - ); - assert_eq!( - result.unwrap_err(), - UpsertPackagingVariantError::LessThanZero("volume_per_unit".to_string()) - ); + // Test that we can't create a record with a volume_per_unit == 0 + let result = service.upsert_packaging_variant( + &context, + UpsertPackagingVariant { + id: test_packaging_variant_id.to_string(), + item_variant_id: test_item_variant_id.to_string(), + name: "packaging_variant_a".to_string(), + packaging_level:1, + volume_per_unit: Some(0.0), + ..Default::default() + }, + ); + assert_eq!( + result.unwrap_err(), + UpsertPackagingVariantError::LessThanZero("volume_per_unit".to_string()) + ); } } From 7aa62da25be439d59d3afdf2a62eaaae2dad2139 Mon Sep 17 00:00:00 2001 From: James Brunskill Date: Mon, 4 Nov 2024 18:55:22 +1300 Subject: [PATCH 24/32] Lookup item_link_id --- server/service/src/item/item_variant/upsert.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/server/service/src/item/item_variant/upsert.rs b/server/service/src/item/item_variant/upsert.rs index ea1b8b96ca..4fa86c9db9 100644 --- a/server/service/src/item/item_variant/upsert.rs +++ b/server/service/src/item/item_variant/upsert.rs @@ -5,7 +5,8 @@ use repository::{ packaging_variant::{PackagingVariantFilter, PackagingVariantRepository}, packaging_variant_row::PackagingVariantRowRepository, }, - ColdStorageTypeRowRepository, EqualFilter, RepositoryError, StorageConnection, StringFilter, + ColdStorageTypeRowRepository, EqualFilter, ItemLinkRowRepository, RepositoryError, + StorageConnection, StringFilter, }; use crate::{ @@ -131,7 +132,14 @@ fn validate( let old_item_variant = ItemVariantRowRepository::new(connection).find_one_by_id(&input.id)?; if let Some(old_item_variant) = old_item_variant { - if old_item_variant.item_link_id != input.item_id { + // Query Item Link to check if the item_id is the same + // If items have been merged, the item_id could be different, but we still want to update the row so we have the latest id + let old_item_id = ItemLinkRowRepository::new(connection) + .find_one_by_id(&old_item_variant.item_link_id)? + .map(|v| v.item_id) + .unwrap_or_else(|| old_item_variant.item_link_id.clone()); + + if old_item_id != input.item_id { return Err(UpsertItemVariantError::CantChangeItem); } } From 431c7cb1a61d68ab0b6269bd3618ebefd0cc781c Mon Sep 17 00:00:00 2001 From: James Brunskill Date: Mon, 4 Nov 2024 19:15:09 +1300 Subject: [PATCH 25/32] generate --- .../system/src/Item/api/operations.generated.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/client/packages/system/src/Item/api/operations.generated.ts b/client/packages/system/src/Item/api/operations.generated.ts index 722c1d5d1c..97ea97306f 100644 --- a/client/packages/system/src/Item/api/operations.generated.ts +++ b/client/packages/system/src/Item/api/operations.generated.ts @@ -16,13 +16,13 @@ export type ItemStockOnHandFragment = { __typename: 'ItemNode', availableStockOn export type ItemRowWithStatsFragment = { __typename: 'ItemNode', availableStockOnHand: number, defaultPackSize: number, id: string, code: string, name: string, unitName?: string | null, stats: { __typename: 'ItemStatsNode', averageMonthlyConsumption: number, availableStockOnHand: number, availableMonthsOfStockOnHand?: number | null, totalConsumption: number } }; -export type ColdStorageTypeFragment = { __typename: 'ColdStorageTypeNode', id: string, name: string }; +export type ColdStorageTypeFragment = { __typename: 'ColdStorageTypeNode', id: string, name: string, minTemperature: number, maxTemperature: number }; export type PackagingVariantFragment = { __typename: 'PackagingVariantNode', id: string, name: string, packagingLevel: number, packSize?: number | null, volumePerUnit?: number | null }; -export type ItemVariantFragment = { __typename: 'ItemVariantNode', id: string, name: string, dosesPerUnit?: number | null, manufacturerId?: string | null, coldStorageTypeId?: string | null, manufacturer?: { __typename: 'NameNode', code: string, id: string, isCustomer: boolean, isSupplier: boolean, isOnHold: boolean, name: string, store?: { __typename: 'StoreNode', id: string, code: string } | null } | null, coldStorageType?: { __typename: 'ColdStorageTypeNode', id: string, name: string } | null, packagingVariants: Array<{ __typename: 'PackagingVariantNode', id: string, name: string, packagingLevel: number, packSize?: number | null, volumePerUnit?: number | null }> }; +export type ItemVariantFragment = { __typename: 'ItemVariantNode', id: string, name: string, dosesPerUnit?: number | null, manufacturerId?: string | null, coldStorageTypeId?: string | null, manufacturer?: { __typename: 'NameNode', code: string, id: string, isCustomer: boolean, isSupplier: boolean, isOnHold: boolean, name: string, store?: { __typename: 'StoreNode', id: string, code: string } | null } | null, coldStorageType?: { __typename: 'ColdStorageTypeNode', id: string, name: string, minTemperature: number, maxTemperature: number } | null, packagingVariants: Array<{ __typename: 'PackagingVariantNode', id: string, name: string, packagingLevel: number, packSize?: number | null, volumePerUnit?: number | null }> }; -export type ItemFragment = { __typename: 'ItemNode', id: string, code: string, name: string, atcCategory: string, ddd: string, defaultPackSize: number, doses: number, isVaccine: boolean, margin: number, msupplyUniversalCode: string, msupplyUniversalName: string, outerPackSize: number, strength?: string | null, type: Types.ItemNodeType, unitName?: string | null, volumePerOuterPack: number, volumePerPack: number, weight: number, availableStockOnHand: number, availableBatches: { __typename: 'StockLineConnector', totalCount: number, nodes: Array<{ __typename: 'StockLineNode', availableNumberOfPacks: number, batch?: string | null, costPricePerPack: number, expiryDate?: string | null, id: string, itemId: string, note?: string | null, onHold: boolean, packSize: number, sellPricePerPack: number, storeId: string, totalNumberOfPacks: number, location?: { __typename: 'LocationNode', code: string, id: string, name: string, onHold: boolean } | null, item: { __typename: 'ItemNode', name: string, code: string, unitName?: string | null, doses: number } }> }, stats: { __typename: 'ItemStatsNode', averageMonthlyConsumption: number, availableStockOnHand: number, availableMonthsOfStockOnHand?: number | null, totalConsumption: number }, variants: Array<{ __typename: 'ItemVariantNode', id: string, name: string, dosesPerUnit?: number | null, manufacturerId?: string | null, coldStorageTypeId?: string | null, manufacturer?: { __typename: 'NameNode', code: string, id: string, isCustomer: boolean, isSupplier: boolean, isOnHold: boolean, name: string, store?: { __typename: 'StoreNode', id: string, code: string } | null } | null, coldStorageType?: { __typename: 'ColdStorageTypeNode', id: string, name: string } | null, packagingVariants: Array<{ __typename: 'PackagingVariantNode', id: string, name: string, packagingLevel: number, packSize?: number | null, volumePerUnit?: number | null }> }> }; +export type ItemFragment = { __typename: 'ItemNode', id: string, code: string, name: string, atcCategory: string, ddd: string, defaultPackSize: number, doses: number, isVaccine: boolean, margin: number, msupplyUniversalCode: string, msupplyUniversalName: string, outerPackSize: number, strength?: string | null, type: Types.ItemNodeType, unitName?: string | null, volumePerOuterPack: number, volumePerPack: number, weight: number, availableStockOnHand: number, availableBatches: { __typename: 'StockLineConnector', totalCount: number, nodes: Array<{ __typename: 'StockLineNode', availableNumberOfPacks: number, batch?: string | null, costPricePerPack: number, expiryDate?: string | null, id: string, itemId: string, note?: string | null, onHold: boolean, packSize: number, sellPricePerPack: number, storeId: string, totalNumberOfPacks: number, location?: { __typename: 'LocationNode', code: string, id: string, name: string, onHold: boolean } | null, item: { __typename: 'ItemNode', name: string, code: string, unitName?: string | null, doses: number } }> }, stats: { __typename: 'ItemStatsNode', averageMonthlyConsumption: number, availableStockOnHand: number, availableMonthsOfStockOnHand?: number | null, totalConsumption: number }, variants: Array<{ __typename: 'ItemVariantNode', id: string, name: string, dosesPerUnit?: number | null, manufacturerId?: string | null, coldStorageTypeId?: string | null, manufacturer?: { __typename: 'NameNode', code: string, id: string, isCustomer: boolean, isSupplier: boolean, isOnHold: boolean, name: string, store?: { __typename: 'StoreNode', id: string, code: string } | null } | null, coldStorageType?: { __typename: 'ColdStorageTypeNode', id: string, name: string, minTemperature: number, maxTemperature: number } | null, packagingVariants: Array<{ __typename: 'PackagingVariantNode', id: string, name: string, packagingLevel: number, packSize?: number | null, volumePerUnit?: number | null }> }> }; export type ItemsWithStockLinesQueryVariables = Types.Exact<{ first?: Types.InputMaybe; @@ -34,7 +34,7 @@ export type ItemsWithStockLinesQueryVariables = Types.Exact<{ }>; -export type ItemsWithStockLinesQuery = { __typename: 'Queries', items: { __typename: 'ItemConnector', totalCount: number, nodes: Array<{ __typename: 'ItemNode', id: string, code: string, name: string, atcCategory: string, ddd: string, defaultPackSize: number, doses: number, isVaccine: boolean, margin: number, msupplyUniversalCode: string, msupplyUniversalName: string, outerPackSize: number, strength?: string | null, type: Types.ItemNodeType, unitName?: string | null, volumePerOuterPack: number, volumePerPack: number, weight: number, availableStockOnHand: number, availableBatches: { __typename: 'StockLineConnector', totalCount: number, nodes: Array<{ __typename: 'StockLineNode', availableNumberOfPacks: number, batch?: string | null, costPricePerPack: number, expiryDate?: string | null, id: string, itemId: string, note?: string | null, onHold: boolean, packSize: number, sellPricePerPack: number, storeId: string, totalNumberOfPacks: number, location?: { __typename: 'LocationNode', code: string, id: string, name: string, onHold: boolean } | null, item: { __typename: 'ItemNode', name: string, code: string, unitName?: string | null, doses: number } }> }, stats: { __typename: 'ItemStatsNode', averageMonthlyConsumption: number, availableStockOnHand: number, availableMonthsOfStockOnHand?: number | null, totalConsumption: number }, variants: Array<{ __typename: 'ItemVariantNode', id: string, name: string, dosesPerUnit?: number | null, manufacturerId?: string | null, coldStorageTypeId?: string | null, manufacturer?: { __typename: 'NameNode', code: string, id: string, isCustomer: boolean, isSupplier: boolean, isOnHold: boolean, name: string, store?: { __typename: 'StoreNode', id: string, code: string } | null } | null, coldStorageType?: { __typename: 'ColdStorageTypeNode', id: string, name: string } | null, packagingVariants: Array<{ __typename: 'PackagingVariantNode', id: string, name: string, packagingLevel: number, packSize?: number | null, volumePerUnit?: number | null }> }> }> } }; +export type ItemsWithStockLinesQuery = { __typename: 'Queries', items: { __typename: 'ItemConnector', totalCount: number, nodes: Array<{ __typename: 'ItemNode', id: string, code: string, name: string, atcCategory: string, ddd: string, defaultPackSize: number, doses: number, isVaccine: boolean, margin: number, msupplyUniversalCode: string, msupplyUniversalName: string, outerPackSize: number, strength?: string | null, type: Types.ItemNodeType, unitName?: string | null, volumePerOuterPack: number, volumePerPack: number, weight: number, availableStockOnHand: number, availableBatches: { __typename: 'StockLineConnector', totalCount: number, nodes: Array<{ __typename: 'StockLineNode', availableNumberOfPacks: number, batch?: string | null, costPricePerPack: number, expiryDate?: string | null, id: string, itemId: string, note?: string | null, onHold: boolean, packSize: number, sellPricePerPack: number, storeId: string, totalNumberOfPacks: number, location?: { __typename: 'LocationNode', code: string, id: string, name: string, onHold: boolean } | null, item: { __typename: 'ItemNode', name: string, code: string, unitName?: string | null, doses: number } }> }, stats: { __typename: 'ItemStatsNode', averageMonthlyConsumption: number, availableStockOnHand: number, availableMonthsOfStockOnHand?: number | null, totalConsumption: number }, variants: Array<{ __typename: 'ItemVariantNode', id: string, name: string, dosesPerUnit?: number | null, manufacturerId?: string | null, coldStorageTypeId?: string | null, manufacturer?: { __typename: 'NameNode', code: string, id: string, isCustomer: boolean, isSupplier: boolean, isOnHold: boolean, name: string, store?: { __typename: 'StoreNode', id: string, code: string } | null } | null, coldStorageType?: { __typename: 'ColdStorageTypeNode', id: string, name: string, minTemperature: number, maxTemperature: number } | null, packagingVariants: Array<{ __typename: 'PackagingVariantNode', id: string, name: string, packagingLevel: number, packSize?: number | null, volumePerUnit?: number | null }> }> }> } }; export type ItemsQueryVariables = Types.Exact<{ first?: Types.InputMaybe; @@ -80,7 +80,7 @@ export type ItemByIdQueryVariables = Types.Exact<{ }>; -export type ItemByIdQuery = { __typename: 'Queries', items: { __typename: 'ItemConnector', totalCount: number, nodes: Array<{ __typename: 'ItemNode', id: string, code: string, name: string, atcCategory: string, ddd: string, defaultPackSize: number, doses: number, isVaccine: boolean, margin: number, msupplyUniversalCode: string, msupplyUniversalName: string, outerPackSize: number, strength?: string | null, type: Types.ItemNodeType, unitName?: string | null, volumePerOuterPack: number, volumePerPack: number, weight: number, availableStockOnHand: number, stats: { __typename: 'ItemStatsNode', averageMonthlyConsumption: number, availableStockOnHand: number, availableMonthsOfStockOnHand?: number | null, totalConsumption: number }, availableBatches: { __typename: 'StockLineConnector', totalCount: number, nodes: Array<{ __typename: 'StockLineNode', availableNumberOfPacks: number, batch?: string | null, costPricePerPack: number, expiryDate?: string | null, id: string, itemId: string, note?: string | null, onHold: boolean, packSize: number, sellPricePerPack: number, storeId: string, totalNumberOfPacks: number, location?: { __typename: 'LocationNode', code: string, id: string, name: string, onHold: boolean } | null, item: { __typename: 'ItemNode', name: string, code: string, unitName?: string | null, doses: number } }> }, variants: Array<{ __typename: 'ItemVariantNode', id: string, name: string, dosesPerUnit?: number | null, manufacturerId?: string | null, coldStorageTypeId?: string | null, manufacturer?: { __typename: 'NameNode', code: string, id: string, isCustomer: boolean, isSupplier: boolean, isOnHold: boolean, name: string, store?: { __typename: 'StoreNode', id: string, code: string } | null } | null, coldStorageType?: { __typename: 'ColdStorageTypeNode', id: string, name: string } | null, packagingVariants: Array<{ __typename: 'PackagingVariantNode', id: string, name: string, packagingLevel: number, packSize?: number | null, volumePerUnit?: number | null }> }> }> } }; +export type ItemByIdQuery = { __typename: 'Queries', items: { __typename: 'ItemConnector', totalCount: number, nodes: Array<{ __typename: 'ItemNode', id: string, code: string, name: string, atcCategory: string, ddd: string, defaultPackSize: number, doses: number, isVaccine: boolean, margin: number, msupplyUniversalCode: string, msupplyUniversalName: string, outerPackSize: number, strength?: string | null, type: Types.ItemNodeType, unitName?: string | null, volumePerOuterPack: number, volumePerPack: number, weight: number, availableStockOnHand: number, stats: { __typename: 'ItemStatsNode', averageMonthlyConsumption: number, availableStockOnHand: number, availableMonthsOfStockOnHand?: number | null, totalConsumption: number }, availableBatches: { __typename: 'StockLineConnector', totalCount: number, nodes: Array<{ __typename: 'StockLineNode', availableNumberOfPacks: number, batch?: string | null, costPricePerPack: number, expiryDate?: string | null, id: string, itemId: string, note?: string | null, onHold: boolean, packSize: number, sellPricePerPack: number, storeId: string, totalNumberOfPacks: number, location?: { __typename: 'LocationNode', code: string, id: string, name: string, onHold: boolean } | null, item: { __typename: 'ItemNode', name: string, code: string, unitName?: string | null, doses: number } }> }, variants: Array<{ __typename: 'ItemVariantNode', id: string, name: string, dosesPerUnit?: number | null, manufacturerId?: string | null, coldStorageTypeId?: string | null, manufacturer?: { __typename: 'NameNode', code: string, id: string, isCustomer: boolean, isSupplier: boolean, isOnHold: boolean, name: string, store?: { __typename: 'StoreNode', id: string, code: string } | null } | null, coldStorageType?: { __typename: 'ColdStorageTypeNode', id: string, name: string, minTemperature: number, maxTemperature: number } | null, packagingVariants: Array<{ __typename: 'PackagingVariantNode', id: string, name: string, packagingLevel: number, packSize?: number | null, volumePerUnit?: number | null }> }> }> } }; export type ItemVariantOptionFragment = { __typename: 'ItemVariantNode', id: string, label: string }; @@ -107,7 +107,7 @@ export type UpsertItemVariantMutationVariables = Types.Exact<{ }>; -export type UpsertItemVariantMutation = { __typename: 'Mutations', centralServer: { __typename: 'CentralServerMutationNode', itemVariant: { __typename: 'ItemVariantMutations', upsertItemVariant: { __typename: 'ItemVariantNode', id: string, name: string, dosesPerUnit?: number | null, manufacturerId?: string | null, coldStorageTypeId?: string | null, manufacturer?: { __typename: 'NameNode', code: string, id: string, isCustomer: boolean, isSupplier: boolean, isOnHold: boolean, name: string, store?: { __typename: 'StoreNode', id: string, code: string } | null } | null, coldStorageType?: { __typename: 'ColdStorageTypeNode', id: string, name: string } | null, packagingVariants: Array<{ __typename: 'PackagingVariantNode', id: string, name: string, packagingLevel: number, packSize?: number | null, volumePerUnit?: number | null }> } | { __typename: 'UpsertItemVariantError', error: { __typename: 'DatabaseError', description: string } | { __typename: 'InternalError', description: string } | { __typename: 'UniqueValueViolation', description: string, field: Types.UniqueValueKey } } } } }; +export type UpsertItemVariantMutation = { __typename: 'Mutations', centralServer: { __typename: 'CentralServerMutationNode', itemVariant: { __typename: 'ItemVariantMutations', upsertItemVariant: { __typename: 'ItemVariantNode', id: string, name: string, dosesPerUnit?: number | null, manufacturerId?: string | null, coldStorageTypeId?: string | null, manufacturer?: { __typename: 'NameNode', code: string, id: string, isCustomer: boolean, isSupplier: boolean, isOnHold: boolean, name: string, store?: { __typename: 'StoreNode', id: string, code: string } | null } | null, coldStorageType?: { __typename: 'ColdStorageTypeNode', id: string, name: string, minTemperature: number, maxTemperature: number } | null, packagingVariants: Array<{ __typename: 'PackagingVariantNode', id: string, name: string, packagingLevel: number, packSize?: number | null, volumePerUnit?: number | null }> } | { __typename: 'UpsertItemVariantError', error: { __typename: 'DatabaseError', description: string } | { __typename: 'InternalError', description: string } | { __typename: 'UniqueValueViolation', description: string, field: Types.UniqueValueKey } } } } }; export type DeleteItemVariantMutationVariables = Types.Exact<{ storeId: Types.Scalars['String']['input']; @@ -122,7 +122,7 @@ export type ColdStorageTypesQueryVariables = Types.Exact<{ }>; -export type ColdStorageTypesQuery = { __typename: 'Queries', coldStorageTypes: { __typename: 'ColdStorageTypeConnector', nodes: Array<{ __typename: 'ColdStorageTypeNode', id: string, name: string }> } }; +export type ColdStorageTypesQuery = { __typename: 'Queries', coldStorageTypes: { __typename: 'ColdStorageTypeConnector', nodes: Array<{ __typename: 'ColdStorageTypeNode', id: string, name: string, minTemperature: number, maxTemperature: number }> } }; export const ServiceItemRowFragmentDoc = gql` fragment ServiceItemRow on ItemNode { @@ -199,6 +199,8 @@ export const ColdStorageTypeFragmentDoc = gql` __typename id name + minTemperature + maxTemperature } `; export const PackagingVariantFragmentDoc = gql` From 9bc8c1f0f226d70d2f462280894afbcf3d6fe94c Mon Sep 17 00:00:00 2001 From: James Brunskill Date: Mon, 4 Nov 2024 19:26:52 +1300 Subject: [PATCH 26/32] Remove default from stocktake_item_line --- server/service/src/sync/translations/stocktake_line.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/server/service/src/sync/translations/stocktake_line.rs b/server/service/src/sync/translations/stocktake_line.rs index 9b503bd10e..d05630a6af 100644 --- a/server/service/src/sync/translations/stocktake_line.rs +++ b/server/service/src/sync/translations/stocktake_line.rs @@ -34,7 +34,6 @@ pub struct LegacyStocktakeLineRow { #[serde(deserialize_with = "empty_str_as_option_string")] pub item_line_ID: Option, pub item_ID: String, - #[serde(default)] pub item_name: String, #[serde(deserialize_with = "empty_str_as_option_string")] pub Batch: Option, From 5169f65a4c9ce7b1a1e77474d998f856ef7010d4 Mon Sep 17 00:00:00 2001 From: James Brunskill Date: Mon, 4 Nov 2024 19:45:32 +1300 Subject: [PATCH 27/32] Fix tests --- server/repository/src/mock/mod.rs | 1 + server/service/src/item/bundled_item/test/mod.rs | 12 +++++++----- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/server/repository/src/mock/mod.rs b/server/repository/src/mock/mod.rs index 137ce16689..a203ba5d24 100644 --- a/server/repository/src/mock/mod.rs +++ b/server/repository/src/mock/mod.rs @@ -730,6 +730,7 @@ pub(crate) fn all_mock_data() -> MockDataCollection { currencies: mock_currencies(), units: mock_units(), items: mock_items(), + item_variants: mock_item_variants(), locations: mock_locations(), sensors: mock_sensors(), temperature_logs: mock_temperature_logs(), diff --git a/server/service/src/item/bundled_item/test/mod.rs b/server/service/src/item/bundled_item/test/mod.rs index 4b14e1f6e1..7ad43e2d62 100644 --- a/server/service/src/item/bundled_item/test/mod.rs +++ b/server/service/src/item/bundled_item/test/mod.rs @@ -1,9 +1,7 @@ #[cfg(test)] mod query { use repository::item_variant::bundled_item::BundledItemFilter; - use repository::mock::{ - mock_item_a, mock_item_a_variant_1, mock_item_b_variant_1, MockDataInserts, - }; + use repository::mock::{mock_item_a_variant_1, mock_item_b_variant_1, MockDataInserts}; use repository::test_db::setup_all; use repository::EqualFilter; use util::uuid::uuid; @@ -58,8 +56,8 @@ mod query { &context, UpsertBundledItem { id: test_bundled_item_record_id.to_string(), - principal_item_variant_id: mock_item_a().id, - bundled_item_variant_id: mock_item_a().id, + principal_item_variant_id: mock_item_a_variant_1().id, + bundled_item_variant_id: mock_item_b_variant_1().id, ratio: 2.0, }, ) @@ -114,5 +112,9 @@ mod query { // Check that the two item variants are not the same // Check that the two item variants are not from the same item (that would be bad) + + // Can't bundle the same 2 variants multiple times (otherwise could configure same bundle with different ratios, which one should we pick?) + + // Prevent nested bundling - check the principal variant isn't the bundled variant in another bundle (and I guess vice versa?) } } From 2ed3629f227d29a49da47bda4b3f8760736c90ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lach=C3=A9=20Melvin?= <55115239+lache-melvin@users.noreply.github.com> Date: Tue, 5 Nov 2024 08:49:39 +1300 Subject: [PATCH 28/32] add postgres changelog table name --- .../src/migrations/v2_04_00/add_bundled_item_table.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/server/repository/src/migrations/v2_04_00/add_bundled_item_table.rs b/server/repository/src/migrations/v2_04_00/add_bundled_item_table.rs index 5f0253aa02..64f3c14c79 100644 --- a/server/repository/src/migrations/v2_04_00/add_bundled_item_table.rs +++ b/server/repository/src/migrations/v2_04_00/add_bundled_item_table.rs @@ -21,6 +21,16 @@ impl MigrationFragment for Migrate { "# )?; + if cfg!(feature = "postgres") { + // Postgres changelog variant + sql!( + connection, + r#" + ALTER TYPE changelog_table_name ADD VALUE IF NOT EXISTS 'bundled_item'; + "# + )?; + } + Ok(()) } } From b689dfa6c399606ab477ac89130c73c500d7e5d0 Mon Sep 17 00:00:00 2001 From: James Brunskill Date: Tue, 5 Nov 2024 09:50:12 +1300 Subject: [PATCH 29/32] Use better import path to fix tests --- .../Location/ListView/LocationEditModal/LocationEditModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/packages/system/src/Location/ListView/LocationEditModal/LocationEditModal.tsx b/client/packages/system/src/Location/ListView/LocationEditModal/LocationEditModal.tsx index 5e46e970a9..d916f93ce3 100644 --- a/client/packages/system/src/Location/ListView/LocationEditModal/LocationEditModal.tsx +++ b/client/packages/system/src/Location/ListView/LocationEditModal/LocationEditModal.tsx @@ -11,7 +11,7 @@ import { InlineSpinner, } from '@openmsupply-client/common'; import { LocationRowFragment, useLocation } from '../../api'; -import { ColdStorageTypeInput } from 'packages/system/src/Item/Components/ColdStorageTypeInput'; +import { ColdStorageTypeInput } from '@openmsupply-client/system/src/Item/Components/ColdStorageTypeInput'; interface LocationEditModalProps { mode: ModalMode | null; isOpen: boolean; From 8bf771501ddb6ccb98a48c2fbda18a1a21d7e89e Mon Sep 17 00:00:00 2001 From: James Brunskill Date: Tue, 5 Nov 2024 10:20:02 +1300 Subject: [PATCH 30/32] Don't allow 0 packsize --- .../DetailView/Tabs/ItemVariants/ItemPackagingVariantsTable.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/packages/system/src/Item/DetailView/Tabs/ItemVariants/ItemPackagingVariantsTable.tsx b/client/packages/system/src/Item/DetailView/Tabs/ItemVariants/ItemPackagingVariantsTable.tsx index 6484b88f0b..c50a09baa6 100644 --- a/client/packages/system/src/Item/DetailView/Tabs/ItemVariants/ItemPackagingVariantsTable.tsx +++ b/client/packages/system/src/Item/DetailView/Tabs/ItemVariants/ItemPackagingVariantsTable.tsx @@ -71,5 +71,5 @@ const VolumeInputCell = (props: CellProps) => ( // Input cells can't be defined inline, otherwise they lose focus on re-render const PackSizeInputCell = (props: CellProps) => ( - + ); From e5eda0cc3c400d609c50178c518de4d0b5f60971 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lach=C3=A9=20Melvin?= <55115239+lache-melvin@users.noreply.github.com> Date: Tue, 5 Nov 2024 12:34:19 +1300 Subject: [PATCH 31/32] non zero input --- .../Cells/NumberInputCell/NumberInputCell.tsx | 3 +++ .../ItemPackagingVariantsTable.tsx | 18 +++++++++--------- .../api/hooks/useItemVariant/useItemVariant.ts | 7 ++++++- 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/client/packages/common/src/ui/layout/tables/components/Cells/NumberInputCell/NumberInputCell.tsx b/client/packages/common/src/ui/layout/tables/components/Cells/NumberInputCell/NumberInputCell.tsx index 566cd165b4..840e8e7799 100644 --- a/client/packages/common/src/ui/layout/tables/components/Cells/NumberInputCell/NumberInputCell.tsx +++ b/client/packages/common/src/ui/layout/tables/components/Cells/NumberInputCell/NumberInputCell.tsx @@ -27,11 +27,13 @@ export const NumberInputCell = ({ TextInputProps, width, endAdornment, + error, }: CellProps & NumericInputProps & { id?: string; TextInputProps?: StandardTextFieldProps; endAdornment?: string; + error?: boolean; }): React.ReactElement> => { const [buffer, setBuffer] = useBufferState(column.accessor({ rowData })); const updater = useDebounceCallback(column.setter, [column.setter], 250); @@ -64,6 +66,7 @@ export const NumberInputCell = ({ value={buffer as number | undefined} width={width} endAdornment={endAdornment} + error={error} /> ); }; diff --git a/client/packages/system/src/Item/DetailView/Tabs/ItemVariants/ItemPackagingVariantsTable.tsx b/client/packages/system/src/Item/DetailView/Tabs/ItemVariants/ItemPackagingVariantsTable.tsx index c50a09baa6..fc2c6dd122 100644 --- a/client/packages/system/src/Item/DetailView/Tabs/ItemVariants/ItemPackagingVariantsTable.tsx +++ b/client/packages/system/src/Item/DetailView/Tabs/ItemVariants/ItemPackagingVariantsTable.tsx @@ -40,13 +40,13 @@ export const ItemPackagingVariantsTable = ({ }, { key: 'packSize', - Cell: update ? PackSizeInputCell : TooltipTextCell, + Cell: update ? NonZeroInputCell : TooltipTextCell, label: 'label.pack-size', setter: updatePackaging, }, { key: 'volumePerUnit', - Cell: update ? VolumeInputCell : TooltipTextCell, + Cell: update ? NonZeroInputCell : TooltipTextCell, label: 'label.volume-per-unit', setter: updatePackaging, }, @@ -65,11 +65,11 @@ export const ItemPackagingVariantsTable = ({ }; // Input cells can't be defined inline, otherwise they lose focus on re-render -const VolumeInputCell = (props: CellProps) => ( - -); - -// Input cells can't be defined inline, otherwise they lose focus on re-render -const PackSizeInputCell = (props: CellProps) => ( - +const NonZeroInputCell = (props: CellProps) => ( + ); diff --git a/client/packages/system/src/Item/api/hooks/useItemVariant/useItemVariant.ts b/client/packages/system/src/Item/api/hooks/useItemVariant/useItemVariant.ts index 2c32489fe7..13cb520ddd 100644 --- a/client/packages/system/src/Item/api/hooks/useItemVariant/useItemVariant.ts +++ b/client/packages/system/src/Item/api/hooks/useItemVariant/useItemVariant.ts @@ -119,5 +119,10 @@ const useUpsert = ({ itemId }: { itemId: string }) => { }; function getIsComplete(draft: ItemVariantFragment) { - return !!draft.name; + return ( + !!draft.name && + draft.packagingVariants.every( + pv => pv.packSize !== 0 && pv.volumePerUnit !== 0 + ) + ); } From bdaeea7d30db390ba786957843bf27328baeb235 Mon Sep 17 00:00:00 2001 From: Raja Deneche Date: Mon, 4 Nov 2024 22:57:40 +0000 Subject: [PATCH 32/32] Translated using Weblate (French) Currently translated at 92.6% (1523 of 1643 strings) Translation: Open mSupply/Common Translate-URL: https://translate.msupply.org/projects/open-msupply/common/fr/ --- .../common/src/intl/locales/fr/common.json | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/client/packages/common/src/intl/locales/fr/common.json b/client/packages/common/src/intl/locales/fr/common.json index cd52d7e1b8..743260bdc3 100644 --- a/client/packages/common/src/intl/locales/fr/common.json +++ b/client/packages/common/src/intl/locales/fr/common.json @@ -85,7 +85,7 @@ "button.save-and-confirm-status": "Confirmer {{status}}", "button.save-log": "Enregistrer le journal", "button.scan": "Scanner", - "button.select-a-color": "Choisir une couleur", + "button.select-a-color": "Sélectionner une couleur", "button.stop": "Stop", "button.supply-to-approved": "Fournir Qtés Autorisées", "button.supply-to-requested": "Quantité demandée", @@ -155,7 +155,7 @@ "description.last-reading-datetime": "Date et heure du dernier relevé", "description.max-min-temperature": "Relevé de temperature maximum ou minimum", "description.months-of-stock": "Mois de stock disponible", - "description.notification-preferences": "Par défaut, le système vous dira si vous avez plus de 3 ou 6 mois de stock sur la page d'accueil.", + "description.notification-preferences": "Par défaut, le système vous dira si vous avez moin de 3 ou plus de 6 mois de stock sur la page d'accueil.", "description.number-of-shipments": "Le nombre de bons de livraison créé depuis cette réquisition", "description.our-soh": "Notre Stock", "description.pack-quantity": "Quantité reçues en nombre de boites", @@ -1535,5 +1535,13 @@ "warning.cannot-create-placeholder-units": "Il y a un total de {{allocatedQuantity}} unités disponibles. Impossible d'allouer toutes les {{requestedQuantity}} unités demandées.", "warning.caps-lock": "Avertissement: Verrouillage majuscule activé", "warning.field-not-parsed": "{{field}} non analysé", - "warning.nothing-to-supply": "La quantité demandée à été fournie !" + "warning.nothing-to-supply": "La quantité demandée à été fournie !", + "button.new-requisition": "Nouvelle Réquisition", + "button.update-status": "Mettre à jour le statut", + "button.view-prescription": "Consulter la prescription", + "button.replenishment-return-lines": "Retourner les Lignes Sélectionnées", + "description.initial-stock-on-hand": "Stock disponible au premier jour de la période du programme", + "description.available-stock": "Stock initial du client + stock entrant +/- ajustements d'inventaire - stock sortant", + "description.rnr-adjustments": "Ajustements effectués pour cet article durant cette période (via les prises d'inventaire ou ajustements d'inventaire)", + "description.rnr-losses": "Enregistrer manuellement les pertes de cet article durant la période" }