From c42f774d67a2a7a86171964ca3b335554e5f71a0 Mon Sep 17 00:00:00 2001 From: adambalcerzak Date: Thu, 17 Sep 2026 14:44:04 +0200 Subject: [PATCH] OBLS-957 Validate staging location against delivery type --- src/apis/picking.ts | 3 + src/redux/actions/picking.ts | 14 +- src/redux/sagas/picking.ts | 15 +- src/screens/Picking/PickingContext.tsx | 41 ++--- .../PickingPickStagingLocationScreen.tsx | 166 ++++++++++-------- src/utils/ApiClient.ts | 5 +- 6 files changed, 138 insertions(+), 106 deletions(-) diff --git a/src/apis/picking.ts b/src/apis/picking.ts index 9927edb2..fc523e6c 100644 --- a/src/apis/picking.ts +++ b/src/apis/picking.ts @@ -31,6 +31,9 @@ export type PickTaskDropParams = { stagingLocationId: string; // User Id stagedById: string; + // Provided when the caller is overriding a previously reported staging location zone mismatch + overrideReasonCode?: string; + overrideComment?: string; }; export function getPickTasksApi(facilityId: string, params?: Partial) { diff --git a/src/redux/actions/picking.ts b/src/redux/actions/picking.ts index 90a79972..fccc37e5 100644 --- a/src/redux/actions/picking.ts +++ b/src/redux/actions/picking.ts @@ -158,11 +158,21 @@ export function shortPickTaskAction( export function dropPickTaskAction( outboundContainerId: string, stagingLocationId: string, - callback?: (response: { errorMessage?: string }) => void + callback?: (response: { + errorMessage?: string; + errorCode?: string; + expectedZones?: { id: string; name: string }[]; + }) => void, + override?: { reasonCode?: string; comment?: string } ) { return { type: DROP_PICK_TASK_REQUEST, - payload: { outboundContainerId, stagingLocationId }, + payload: { + outboundContainerId, + stagingLocationId, + overrideReasonCode: override?.reasonCode, + overrideComment: override?.comment + }, callback }; } diff --git a/src/redux/sagas/picking.ts b/src/redux/sagas/picking.ts index c32785de..01b9539f 100644 --- a/src/redux/sagas/picking.ts +++ b/src/redux/sagas/picking.ts @@ -228,17 +228,26 @@ function* dropPickTaskAction(action: any) { yield call(api.dropPickTaskApi, currentLocation.id, action.payload.outboundContainerId, { action: 'drop', stagingLocationId: action.payload.stagingLocationId, - stagedById: session.user.id + stagedById: session.user.id, + overrideReasonCode: action.payload.overrideReasonCode, + overrideComment: action.payload.overrideComment }); yield put({ type: DROP_PICK_TASK_REQUEST_SUCCESS }); yield action.callback({}); yield put(hideScreenLoading()); } catch (error) { + const errorMessage = (error as any)?.message || 'Error Dropping Pick Task'; + const errorData = (error as any)?.data; + const isZoneMismatch = errorData?.errorCode === 'STAGING_LOCATION_ZONE_MISMATCH'; yield put({ type: DROP_PICK_TASK_REQUEST_FAIL, - payload: (error as any)?.message || 'Error Dropping Pick Task' + payload: errorMessage + }); + yield action.callback({ + errorMessage, + errorCode: isZoneMismatch ? errorData.errorCode : undefined, + expectedZones: isZoneMismatch ? errorData.expectedZones : undefined }); - yield action.callback({ errorMessage: (error as any)?.message || 'Error Dropping Pick Task' }); yield put(hideScreenLoading()); } } diff --git a/src/screens/Picking/PickingContext.tsx b/src/screens/Picking/PickingContext.tsx index 83dafdce..956557eb 100644 --- a/src/screens/Picking/PickingContext.tsx +++ b/src/screens/Picking/PickingContext.tsx @@ -58,13 +58,16 @@ type PickingContextType = { revalidateTasksForRequisition: (requisitionId: string | undefined, callback?: () => void) => void; /** Start the pick task (API call) */ startPickTask: (callback: (response: { errorMessage?: string }) => void) => void; - /** Drop the current pick task at the system-suggested staging location */ - dropCurrentTask: (task: PickTask, callback?: (response: { errorMessage?: string }) => void) => void; /** Drop the current pick task at the given staging location */ dropCurrentTaskAtStagingLocation: ( task: PickTask, stagingLocationId: string, - callback?: (response: { errorMessage?: string }) => void + callback?: (response: { + errorMessage?: string; + errorCode?: string; + expectedZones?: { id: string; name: string }[]; + }) => void, + override?: { reasonCode?: string; comment?: string } ) => void; /** Revalidates the current pick task details from the server */ revalidateCurrentTask: (callback?: (task: PickTask | undefined) => void) => void; @@ -248,30 +251,17 @@ export function PickingProvider({ children }: { children: React.ReactNode }) { ); }; - const dropCurrentTask = (task: PickTask, callback?: (response: { errorMessage?: string }) => void) => { - if (!task) { - Alert.alert('Task Missing', 'No current task to drop.'); - return; - } - - if (!task.stagingLocation?.id) { - Alert.alert('Missing Input', 'Current task is missing a valid Staging Location.'); - return; - } - - if (!task.outboundContainer?.id) { - Alert.alert('Error', 'Current task does not have a valid Outbound Container.'); - return; - } - - dispatch(dropPickTaskAction(task.outboundContainer.id, task.stagingLocation.id, callback)); - }; - - // Like dropCurrentTask but drops at the given (scanned) staging location instead of the pick task's suggested one + // Drops the current task at the given (scanned) staging location; the server validates it + // against the task's delivery type/zone (see PickTaskService.validateStagingLocationZone). const dropCurrentTaskAtStagingLocation = ( task: PickTask, stagingLocationId: string, - callback?: (response: { errorMessage?: string }) => void + callback?: (response: { + errorMessage?: string; + errorCode?: string; + expectedZones?: { id: string; name: string }[]; + }) => void, + override?: { reasonCode?: string; comment?: string } ) => { if (!task) { Alert.alert('Task Missing', 'No current task to drop.'); @@ -288,7 +278,7 @@ export function PickingProvider({ children }: { children: React.ReactNode }) { return; } - dispatch(dropPickTaskAction(task.outboundContainer.id, stagingLocationId, callback)); + dispatch(dropPickTaskAction(task.outboundContainer.id, stagingLocationId, callback, override)); }; const resetSession = () => { @@ -314,7 +304,6 @@ export function PickingProvider({ children }: { children: React.ReactNode }) { resetSession, revalidateTasksForRequisition, startPickTask, - dropCurrentTask, dropCurrentTaskAtStagingLocation, revalidateCurrentTask, goToNextTask diff --git a/src/screens/Picking/PickingPickStagingLocationScreen.tsx b/src/screens/Picking/PickingPickStagingLocationScreen.tsx index ff790fa0..752676cd 100644 --- a/src/screens/Picking/PickingPickStagingLocationScreen.tsx +++ b/src/screens/Picking/PickingPickStagingLocationScreen.tsx @@ -1,29 +1,46 @@ import * as React from 'react'; import { Alert, ScrollView, View } from 'react-native'; import { Divider, Paragraph, Subheading } from 'react-native-paper'; +import { useDispatch } from 'react-redux'; +import PickingStagingLocationZoneMismatchModal from '../../components/PickingStagingLocationZoneMismatchModal'; import { ProductDetails } from '../../components/ProductDetails'; import { ScannerInput } from '../../components/ScannerInput'; import { SearchButton } from '../../components/SearchButton'; import { useSearchButton } from '../../components/SearchButton/useSearchButton'; import { EMPTY_STRING, HYPHEN } from '../../constants'; import { resetToRoutes } from '../../NavigationService'; +import { getReasonCodesAction } from '../../redux/actions/others'; +import { ReasonCode } from '../../types/picking'; import { parseFromISODateToLocaleString } from '../../utils/utils'; import { CustomerDetails } from './CustomerDetails'; import { usePickingContext } from './PickingContext'; import styles from './styles'; -// Lets the user stage at any scanned location. Set to false to require the scanned location to -// match the one suggested by the pick task. -const SKIP_STAGING_LOCATION_VALIDATION = true; - export default function PickingPickStagingLocationScreen() { - const { tasks, dropCurrentTask, dropCurrentTaskAtStagingLocation, resetSession, setCurrentTaskIndex, homeRoute } = - usePickingContext(); + const { tasks, dropCurrentTaskAtStagingLocation, resetSession, setCurrentTaskIndex, homeRoute } = usePickingContext(); + const dispatch = useDispatch(); const [stagingLocationNumber, setStagingLocationNumber] = React.useState(EMPTY_STRING); const [currentUniqueIndex, setCurrentUniqueIndex] = React.useState(0); const { isSearchOpen, searchButtonProps } = useSearchButton({ onSelect: setStagingLocationNumber }); + const [reasonCodes, setReasonCodes] = React.useState([]); + const [selectedReasonCode, setSelectedReasonCode] = React.useState(undefined); + const [overrideComment, setOverrideComment] = React.useState(EMPTY_STRING); + const [isOverrideModalVisible, setIsOverrideModalVisible] = React.useState(false); + const [pendingLocationId, setPendingLocationId] = React.useState(EMPTY_STRING); + const [expectedZoneNames, setExpectedZoneNames] = React.useState([]); + + React.useEffect(() => { + dispatch( + getReasonCodesAction('VALIDATE_STAGING_LOCATION_ZONE', (data: any) => { + if (!data?.error) { + setReasonCodes(data); + } + }) + ); + }, [dispatch]); + // Memoize unique tasks based on outbound container ID const uniqueTasks = React.useMemo(() => { const tasksWithContainers = tasks.filter((t) => t.outboundContainer?.id); @@ -41,90 +58,78 @@ export default function PickingPickStagingLocationScreen() { } }, [currentTask, tasks.length, setCurrentTaskIndex, uniqueTasks.length, tasks, homeRoute]); - // Requires the scanned location to match the one suggested by the task. Used when SKIP_STAGING_LOCATION_VALIDATION is false. - function handleScan(locationId: string) { - const expected = currentTask.stagingLocation?.locationNumber; - - if (!expected || locationId !== expected) { - Alert.alert( - 'Invalid Staging Location', - `Expected: ${expected ?? '-'}, but got: ${locationId}. Please try again.` - ); - setStagingLocationNumber(EMPTY_STRING); - return; - } - - dropCurrentTask(currentTask, (response) => { - if (response.errorMessage) { - Alert.alert('Error', response.errorMessage); - setStagingLocationNumber(EMPTY_STRING); - return; - } - - const nextIndex = currentUniqueIndex + 1; - if (nextIndex < uniqueTasks.length) { - Alert.alert('Success', 'Staging Location confirmed. Proceeding to the next container.', [ - { - text: 'OK', - onPress: () => { - setCurrentUniqueIndex(nextIndex); - setStagingLocationNumber(EMPTY_STRING); - } + function advanceOrComplete() { + const nextIndex = currentUniqueIndex + 1; + if (nextIndex < uniqueTasks.length) { + Alert.alert('Success', 'Staging Location confirmed. Proceeding to the next container.', [ + { + text: 'OK', + onPress: () => { + setCurrentUniqueIndex(nextIndex); + setStagingLocationNumber(EMPTY_STRING); } - ]); - } else { - Alert.alert('Picking Session Complete', 'You have completed all staging confirmations.', [ - { - text: 'OK', - onPress: () => { - resetSession(); - resetToRoutes([{ name: 'Drawer', params: { screen: 'Dashboard' } }, { name: homeRoute }]); - } + } + ]); + } else { + Alert.alert('Picking Session Complete', 'You have completed all staging confirmations.', [ + { + text: 'OK', + onPress: () => { + resetSession(); + resetToRoutes([{ name: 'Drawer', params: { screen: 'Dashboard' } }, { name: homeRoute }]); } - ]); - } - }); + } + ]); + } } - // Drops at whatever location the user scans, without checking it against the task's suggestion. - // Used when SKIP_STAGING_LOCATION_VALIDATION is true. - function handleScanWithoutValidation(locationId: string) { + // Always defers to the server: it's the authority on whether the scanned location is valid for + // this delivery type's zone (per-facility configurable). A mismatch surfaces the override modal + // rather than a plain error, since the mismatch is expected to be overridable. + function handleScan(locationId: string) { if (!locationId) { return; } dropCurrentTaskAtStagingLocation(currentTask, locationId, (response) => { + if (response.errorCode === 'STAGING_LOCATION_ZONE_MISMATCH') { + setPendingLocationId(locationId); + setExpectedZoneNames(response.expectedZones?.map((zone) => zone.name) ?? []); + setSelectedReasonCode(undefined); + setOverrideComment(EMPTY_STRING); + setIsOverrideModalVisible(true); + return; + } + if (response.errorMessage) { Alert.alert('Error', response.errorMessage); setStagingLocationNumber(EMPTY_STRING); return; } - const nextIndex = currentUniqueIndex + 1; - if (nextIndex < uniqueTasks.length) { - Alert.alert('Success', 'Staging Location confirmed. Proceeding to the next container.', [ - { - text: 'OK', - onPress: () => { - setCurrentUniqueIndex(nextIndex); - setStagingLocationNumber(EMPTY_STRING); - } - } - ]); - } else { - Alert.alert('Picking Session Complete', 'You have completed all staging confirmations.', [ - { - text: 'OK', - onPress: () => { - resetSession(); - resetToRoutes([{ name: 'Drawer', params: { screen: 'Dashboard' } }, { name: homeRoute }]); - } - } - ]); - } + advanceOrComplete(); }); } + function handleOverrideConfirm(reasonCode: ReasonCode | undefined, comment: string) { + setIsOverrideModalVisible(false); + + dropCurrentTaskAtStagingLocation( + currentTask, + pendingLocationId, + (response) => { + if (response.errorMessage) { + Alert.alert('Error', response.errorMessage); + setStagingLocationNumber(EMPTY_STRING); + return; + } + + advanceOrComplete(); + }, + { reasonCode: reasonCode?.id, comment } + ); + } + // If no task is selected yet, return null to avoid rendering // ProductDetails with undefined data while useEffect runs if (!currentTask) { @@ -200,12 +205,27 @@ export default function PickingPickStagingLocationScreen() { value={stagingLocationNumber} isEnabled={!isSearchOpen} onChange={setStagingLocationNumber} - onSubmit={SKIP_STAGING_LOCATION_VALIDATION ? handleScanWithoutValidation : handleScan} + onSubmit={handleScan} /> + + { + setIsOverrideModalVisible(false); + setStagingLocationNumber(EMPTY_STRING); + }} + onConfirm={handleOverrideConfirm} + /> ); } diff --git a/src/utils/ApiClient.ts b/src/utils/ApiClient.ts index 62732d14..4cb1197f 100644 --- a/src/utils/ApiClient.ts +++ b/src/utils/ApiClient.ts @@ -57,7 +57,7 @@ class _ApiClient { message = message ?? 'Not found'; break; case 409: - message = error.response?.data ?? 'Conflict: Resource Already Exists'; + message = message ?? 'Conflict: Resource Already Exists'; break; case 500: message = message ?? 'Internal Server Error'; @@ -68,7 +68,8 @@ class _ApiClient { } return Promise.reject({ message: message, - code: code + code: code, + data: error.response?.data }); }; }