From 5864c62cd8ac3c0bc315bff0a04fb508d5b8049e Mon Sep 17 00:00:00 2001 From: OziinG Date: Mon, 7 Sep 2026 16:17:59 +0900 Subject: [PATCH 1/5] Preserve destination guidance as distinct driver-facing data Constraint: Additive app contract only; server deployment and sequence persistence remain externally owned. Rejected: Local-only sequence caching | it can overwrite newer server dispatch order without a route version contract. Confidence: high Scope-risk: narrow Directive: Keep openTime informational until route optimization semantics are separately approved. Tested: 39 targeted tests; TypeScript typecheck; Expo lint; expo install --check; git diff --check. Not-tested: Native or Expo bundle builds, APK/AAB, device behavior, server persistence, and web round-trip. --- src/api/dsvDriverRoute.test.ts | 11 +++++ src/api/dsvDriverRoute.ts | 9 ++++ src/app/internalScreens.test.ts | 3 ++ .../delivery/destinationNotesPreview.test.ts | 2 + .../delivery/destinationNotesPreview.ts | 6 +++ src/ui/driver/DestinationNotesSheet.tsx | 43 ++++++++++++++++++- 6 files changed, 73 insertions(+), 1 deletion(-) diff --git a/src/api/dsvDriverRoute.test.ts b/src/api/dsvDriverRoute.test.ts index 686241b..66ff42d 100644 --- a/src/api/dsvDriverRoute.test.ts +++ b/src/api/dsvDriverRoute.test.ts @@ -122,6 +122,8 @@ describe('DSV assigned route API client', () => { lunchTimeRangeUpdatedAt: '2026-07-30T22:00:00.000Z', memo: '후문으로 입장', memoUpdatedAt: '2026-07-30T21:00:00.000Z', + openTime: '09:00', + openTimeUpdatedAt: '2026-07-30T19:00:00.000Z', requiredArrivalTime: '10:30', requiredArrivalTimeUpdatedAt: '2026-07-30T20:00:00.000Z', }, @@ -186,6 +188,10 @@ describe('DSV assigned route API client', () => { updatedAt: '2026-07-30T21:00:00.000Z', value: '후문으로 입장', }, + openTime: { + updatedAt: '2026-07-30T19:00:00.000Z', + value: '09:00', + }, requiredArrivalTime: { updatedAt: '2026-07-30T20:00:00.000Z', value: '10:30', @@ -402,6 +408,8 @@ describe('DSV assigned route API client', () => { lunchTimeRangeUpdatedAt: '2026-08-17T03:00:00.000Z', memo: null, memoUpdatedAt: '2026-08-18T04:00:00.000Z', + openTime: '09:00', + openTimeUpdatedAt: '2026-08-18T03:00:00.000Z', requiredArrivalTime: '10:30', requiredArrivalTimeUpdatedAt: '2026-08-17T05:00:00.000Z', }, @@ -417,12 +425,14 @@ describe('DSV assigned route API client', () => { lunchAccess: { updatedAt: null, value: 'AVAILABLE' }, lunchTime: { updatedAt: '2026-08-17T03:00:00.000Z', value: '12:00~13:00' }, memo: { updatedAt: null, value: '기존 메모' }, + openTime: { updatedAt: null, value: '' }, requiredArrivalTime: { updatedAt: '2026-08-17T05:00:00.000Z', value: '10:30' }, }, { lunchAccess: 'UNKNOWN', lunchTime: '12:00~13:00', memo: '', + openTime: '09:00', requiredArrivalTime: '10:30', }, ); @@ -433,6 +443,7 @@ describe('DSV assigned route API client', () => { assert.deepEqual(JSON.parse(request?.init?.body as string), { lunchEntryStatus: null, memo: null, + openTime: '09:00', }); assert.deepEqual(notes.memo, { updatedAt: '2026-08-18T04:00:00.000Z', diff --git a/src/api/dsvDriverRoute.ts b/src/api/dsvDriverRoute.ts index 9d070ec..9a89879 100644 --- a/src/api/dsvDriverRoute.ts +++ b/src/api/dsvDriverRoute.ts @@ -73,6 +73,8 @@ type ServerDestinationNotes = { lunchTimeRangeUpdatedAt: string | null; memo: string | null; memoUpdatedAt: string | null; + openTime?: string | null; + openTimeUpdatedAt?: string | null; requiredArrivalTime: string | null; requiredArrivalTimeUpdatedAt: string | null; }; @@ -294,6 +296,9 @@ function buildDestinationNotesPatch( ): Record { const patch: Record = {}; if (previous.memo.value !== values.memo) patch.memo = values.memo || null; + if (previous.openTime.value !== values.openTime) { + patch.openTime = values.openTime || null; + } if (previous.lunchTime.value !== values.lunchTime) { patch.lunchTimeRange = values.lunchTime || null; } @@ -322,6 +327,10 @@ function mapServerDestinationNotes( value: notes.lunchTimeRange ?? '', }, memo: { updatedAt: notes.memoUpdatedAt, value: notes.memo ?? '' }, + openTime: { + updatedAt: notes.openTimeUpdatedAt ?? null, + value: notes.openTime ?? '', + }, requiredArrivalTime: { updatedAt: notes.requiredArrivalTimeUpdatedAt, value: notes.requiredArrivalTime ?? '', diff --git a/src/app/internalScreens.test.ts b/src/app/internalScreens.test.ts index bbb8438..211cbdb 100644 --- a/src/app/internalScreens.test.ts +++ b/src/app/internalScreens.test.ts @@ -266,6 +266,8 @@ describe('authenticated driver screens', () => { assert.match(destinationSheet, /orders\.map\(\(order, index\)/u); assert.match(destinationSheet, /order\.conditionCode/u); assert.match(destinationSheet, /order\.shippedBoxes/u); + assert.match(destinationSheet, /주문 메모/u); + assert.match(destinationSheet, /order\.notes/u); assert.match(destinationSheet, /일반 메모/u); assert.match(destinationSheet, /점심시간 시작/u); assert.match(destinationSheet, /점심시간 종료/u); @@ -291,6 +293,7 @@ describe('authenticated driver screens', () => { assert.doesNotMatch(destinationSheet, /label="미확인"/u); assert.doesNotMatch(destinationSheet, /numbers-and-punctuation/u); assert.match(destinationSheet, /필수 도착 시간/u); + assert.match(destinationSheet, /배송 가능 시작 시간/u); assert.match(destinationSheet, /마지막 수정/u); assert.doesNotMatch(destinationSheet, /UI Preview/u); assert.doesNotMatch(destinationSheet, /fetch\(/u); diff --git a/src/domain/delivery/destinationNotesPreview.test.ts b/src/domain/delivery/destinationNotesPreview.test.ts index 874412f..50f8439 100644 --- a/src/domain/delivery/destinationNotesPreview.test.ts +++ b/src/domain/delivery/destinationNotesPreview.test.ts @@ -18,6 +18,7 @@ describe('destination notes UI preview state', () => { lunchAccess: 'AVAILABLE', lunchTime: '12:00~13:00', memo: '', + openTime: '09:00', requiredArrivalTime: '13:30', }, updatedAt, @@ -26,6 +27,7 @@ describe('destination notes UI preview state', () => { assert.deepEqual(notes.memo, { updatedAt: null, value: '' }); assert.deepEqual(notes.lunchAccess, { updatedAt, value: 'AVAILABLE' }); assert.deepEqual(notes.lunchTime, { updatedAt, value: '12:00~13:00' }); + assert.deepEqual(notes.openTime, { updatedAt, value: '09:00' }); assert.deepEqual(notes.requiredArrivalTime, { updatedAt, value: '13:30' }); }); diff --git a/src/domain/delivery/destinationNotesPreview.ts b/src/domain/delivery/destinationNotesPreview.ts index 4890d58..8e31e74 100644 --- a/src/domain/delivery/destinationNotesPreview.ts +++ b/src/domain/delivery/destinationNotesPreview.ts @@ -9,6 +9,7 @@ export type DestinationNotes = { lunchAccess: DestinationNoteField; lunchTime: DestinationNoteField; memo: DestinationNoteField; + openTime: DestinationNoteField; requiredArrivalTime: DestinationNoteField; }; @@ -16,6 +17,7 @@ export type DestinationNoteValues = { lunchAccess: LunchAccess; lunchTime: string; memo: string; + openTime: string; requiredArrivalTime: string; }; @@ -23,6 +25,7 @@ export const EMPTY_DESTINATION_NOTES: DestinationNotes = { lunchAccess: { updatedAt: null, value: 'UNKNOWN' }, lunchTime: { updatedAt: null, value: '' }, memo: { updatedAt: null, value: '' }, + openTime: { updatedAt: null, value: '' }, requiredArrivalTime: { updatedAt: null, value: '' }, }; @@ -35,6 +38,7 @@ export function savePreviewDestinationNotes( lunchAccess: updateField(previous.lunchAccess, values.lunchAccess, updatedAt), lunchTime: updateField(previous.lunchTime, values.lunchTime, updatedAt), memo: updateField(previous.memo, values.memo, updatedAt), + openTime: updateField(previous.openTime, values.openTime, updatedAt), requiredArrivalTime: updateField( previous.requiredArrivalTime, values.requiredArrivalTime, @@ -47,6 +51,8 @@ export function isValidRequiredArrivalTime(value: string): boolean { return value === '' || isValidTime(value); } +export const isValidOpenTime = isValidRequiredArrivalTime; + export function isValidLunchTime(value: string): boolean { if (value === '') return true; const [startsAt, endsAt, extra] = value.split('~'); diff --git a/src/ui/driver/DestinationNotesSheet.tsx b/src/ui/driver/DestinationNotesSheet.tsx index 214999d..75c96e7 100644 --- a/src/ui/driver/DestinationNotesSheet.tsx +++ b/src/ui/driver/DestinationNotesSheet.tsx @@ -13,6 +13,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { formatTimeInput, isValidLunchTime, + isValidOpenTime, isValidRequiredArrivalTime, type DestinationNotes, type DestinationNoteValues, @@ -55,6 +56,7 @@ export function DestinationNotesSheet({ const [lunchStartsAt, setLunchStartsAt] = useState(initialLunchStartsAt); const [lunchEndsAt, setLunchEndsAt] = useState(initialLunchEndsAt); const [memo, setMemo] = useState(notes.memo.value); + const [openTime, setOpenTime] = useState(notes.openTime.value); const [requiredArrivalTime, setRequiredArrivalTime] = useState( notes.requiredArrivalTime.value, ); @@ -62,10 +64,15 @@ export function DestinationNotesSheet({ ? '' : `${lunchStartsAt}~${lunchEndsAt}`; const normalizedMemo = memo.trim(); + const normalizedOpenTime = openTime.trim(); const normalizedArrivalTime = requiredArrivalTime.trim(); const hasValidLunchTime = isValidLunchTime(normalizedLunchTime); + const hasValidOpenTime = isValidOpenTime(normalizedOpenTime); const hasValidArrivalTime = isValidRequiredArrivalTime(normalizedArrivalTime); - const canSave = hasValidLunchTime && hasValidArrivalTime && !isSaving; + const canSave = hasValidLunchTime + && hasValidOpenTime + && hasValidArrivalTime + && !isSaving; const totalBoxes = orders.reduce( (sum, order) => sum + order.shippedBoxes, 0, @@ -79,6 +86,7 @@ export function DestinationNotesSheet({ lunchAccess, lunchTime: normalizedLunchTime, memo: normalizedMemo, + openTime: normalizedOpenTime, requiredArrivalTime: normalizedArrivalTime, }); } finally { @@ -181,6 +189,13 @@ export function DestinationNotesSheet({ + {order.notes ? ( + + 주문 메모 + {order.notes} + + ) : null} + {order.driverMessages?.map((message) => ( 배송원 메모 @@ -301,6 +316,32 @@ export function DestinationNotesSheet({ ) : null} + + { + setOpenTime(formatTimeInput(value)); + }} + placeholder="09:00" + placeholderTextColor="#98a2b3" + style={[ + styles.textInput, + styles.arrivalTimeInput, + !hasValidOpenTime && styles.textInputInvalid, + ]} + value={openTime} + /> + {!hasValidOpenTime ? ( + + 0000부터 2359 사이의 숫자 4자리로 입력해 주세요. + + ) : null} + Date: Mon, 7 Sep 2026 17:35:51 +0900 Subject: [PATCH 2/5] Persist manual delivery order before route reconciliation Constraint: Use the versioned DSV route-order contract without APK, bundle, device, or deployment work. Rejected: Keep drag order only in component state | refresh and partial completion would restore the server sequence. Confidence: high Scope-risk: moderate Directive: Send the complete stop permutation, retain the command id across the one network retry, and adopt only the authoritative response version. Tested: npm run check:workspace (151 tests), npm run lint, npx expo install --check, git diff --check. Not-tested: Deployed server transaction, physical-device interaction, native build, APK, or production delivery flow. --- src/api/dsvDriverRoute.test.ts | 151 +++++++++++++++++++++++++++++- src/api/dsvDriverRoute.ts | 129 +++++++++++++++++++++++++ src/app/internalScreens.test.ts | 26 ++++- src/ui/driver/DeliveryScreen.tsx | 77 +++++++++++---- src/ui/driver/DriverWorkspace.tsx | 85 ++++++++++++++++- 5 files changed, 445 insertions(+), 23 deletions(-) diff --git a/src/api/dsvDriverRoute.test.ts b/src/api/dsvDriverRoute.test.ts index 66ff42d..3e2c7ee 100644 --- a/src/api/dsvDriverRoute.test.ts +++ b/src/api/dsvDriverRoute.test.ts @@ -5,8 +5,10 @@ import { loadDriverDeliveryRoute, loadDriverDeliveryRouteChoices, loadDriverCompletedRouteHistory, + updateDriverDeliveryOrder, updateDriverDestinationNotes, } from './dsvDriverRoute'; +import { PREVIEW_DELIVERY_ORDERS } from '../domain/delivery/deliveryPlan'; const ORIGINAL_BASE_URL = process.env.EXPO_PUBLIC_DSV_API_BASE_URL; @@ -49,7 +51,7 @@ describe('DSV assigned route API client', () => { assert.equal(history[0]?.stopCount, 8); }); - it('uses the account token to select a route and the scoped token to load actual stops', async () => { + it('loads the persisted route order including already completed stops', async () => { process.env.EXPO_PUBLIC_DSV_API_BASE_URL = 'https://dsv.example.test'; const calls: { input: string; init?: RequestInit }[] = []; const serverGeometry = { @@ -101,6 +103,7 @@ describe('DSV assigned route API client', () => { id: 'route-116', name: '#116', routeGeometry: serverGeometry, + routeVersionId: 'route-version-1', timezone: 'UTC', stops: [ { @@ -154,6 +157,25 @@ describe('DSV assigned route API client', () => { timeWindowEnd: '2026-07-31T03:00:00.000Z', timeWindowStart: '2026-07-31T02:00:00.000Z', }, + { + address: { + address1: '서울시 송파구 올림픽로 2', + address2: null, + city: '서울', + postalCode: '05540', + province: '서울', + }, + conditionCode: 'AMBIENT', + coordinates: { latitude: 37.52, longitude: 127.02 }, + deliveryStopId: 'stop-completed', + destinationId: 'destination-completed', + orderName: '#0525032087', + recipientName: '완료 배송지', + sellerOrderKey: '0525032087', + sequence: 2, + shippedBoxes: 1, + status: 'DELIVERED', + }, ], }, }, @@ -172,7 +194,16 @@ describe('DSV assigned route API client', () => { assert.equal(route?.executionStatus, 'IN_PROGRESS'); assert.equal(route?.routeContext, 'route-116'); assert.equal(route?.routePlanId, 'route-116'); + assert.equal(route?.routeVersionId, 'route-version-1'); assert.equal(route?.availableRoutes.length, 2); + assert.deepEqual(route?.orders.map(({ id, sequence, status }) => ({ + id, + sequence, + status, + })), [ + { id: 'stop-1', sequence: 1, status: 'PENDING' }, + { id: 'stop-completed', sequence: 2, status: 'DELIVERED' }, + ]); assert.equal(route?.orders[0]?.destinationName, '케이팜'); assert.equal(route?.orders[0]?.destinationId, 'stop-1'); assert.deepEqual(route?.destinationNotesById['stop-1'], { @@ -454,4 +485,122 @@ describe('DSV assigned route API client', () => { value: 'UNKNOWN', }); }); + + it('saves the complete stop order with one command id and adopts the authoritative response', async () => { + process.env.EXPO_PUBLIC_DSV_API_BASE_URL = 'https://dsv.example.test'; + const requests: { input: string; init?: RequestInit }[] = []; + const orders = PREVIEW_DELIVERY_ORDERS.slice(0, 2); + globalThis.fetch = async (input, init) => { + requests.push({ input: input.toString(), init }); + if (requests.length === 1) throw new TypeError('response lost'); + return new Response(JSON.stringify({ + data: { + routePlanId: 'route-116', + routeVersionId: 'route-version-2', + stops: [ + { deliveryStopId: orders[1]!.id, sequence: 1 }, + { deliveryStopId: orders[0]!.id, sequence: 2 }, + ], + }, + error: null, + })); + }; + + const saved = await updateDriverDeliveryOrder( + 'route-token', + 'route-116', + 'route-version-1', + orders, + ); + + assert.equal(requests.length, 2); + assert.equal(requests[0]?.input, 'https://dsv.example.test/driver/routes/route-116/order'); + assert.equal(requests[0]?.init?.method, 'PATCH'); + assert.equal((requests[0]?.init?.headers as Headers).get('Authorization'), 'Bearer route-token'); + const firstBody = JSON.parse(requests[0]?.init?.body as string) as Record; + const retryBody = JSON.parse(requests[1]?.init?.body as string) as Record; + assert.equal(typeof firstBody.commandId, 'string'); + assert.match(firstBody.commandId as string, /^[A-Za-z0-9._:-]{1,120}$/u); + assert.deepEqual(firstBody, retryBody); + assert.deepEqual(firstBody, { + commandId: firstBody.commandId, + expectedVersion: 'route-version-1', + orderedStopIds: orders.map(({ id }) => id), + }); + assert.equal(saved.routeVersionId, 'route-version-2'); + assert.deepEqual(saved.orders.map(({ id, sequence }) => ({ id, sequence })), [ + { id: orders[1]!.id, sequence: 1 }, + { id: orders[0]!.id, sequence: 2 }, + ]); + assert.equal(saved.orders.every(({ estimatedArrivalAt }) => ( + estimatedArrivalAt === null + )), true); + }); + + it('does not apply a stale manual order after the route version changes', async () => { + process.env.EXPO_PUBLIC_DSV_API_BASE_URL = 'https://dsv.example.test'; + globalThis.fetch = async () => new Response(JSON.stringify({ + data: null, + error: { code: 'VERSION_CONFLICT', message: 'VERSION_CONFLICT' }, + }), { status: 409 }); + + await assert.rejects( + updateDriverDeliveryOrder( + 'route-token', + 'route-116', + 'stale-route-version', + PREVIEW_DELIVERY_ORDERS.slice(0, 2), + ), + (error: unknown) => error instanceof Error + && error.name === 'DriverRouteApiError' + && error.message === '배송 경로가 변경됐습니다. 최신 순서를 확인한 뒤 다시 시도해 주세요.', + ); + }); + + it('rejects an incomplete authoritative stop order response', async () => { + process.env.EXPO_PUBLIC_DSV_API_BASE_URL = 'https://dsv.example.test'; + const orders = PREVIEW_DELIVERY_ORDERS.slice(0, 2); + globalThis.fetch = async () => new Response(JSON.stringify({ + data: { + routePlanId: 'route-116', + routeVersionId: 'route-version-2', + stops: [{ deliveryStopId: orders[0]!.id, sequence: 1 }], + }, + error: null, + })); + + await assert.rejects( + updateDriverDeliveryOrder( + 'route-token', + 'route-116', + 'route-version-1', + orders, + ), + (error: unknown) => error instanceof Error + && error.name === 'DriverRouteApiError' + && error.message === '저장된 배송 순서를 확인할 수 없습니다.', + ); + }); + + it('reports an actionable error after the idempotent network retry fails', async () => { + process.env.EXPO_PUBLIC_DSV_API_BASE_URL = 'https://dsv.example.test'; + let requestCount = 0; + globalThis.fetch = async () => { + requestCount += 1; + throw new TypeError('Network request failed'); + }; + + await assert.rejects( + updateDriverDeliveryOrder( + 'route-token', + 'route-116', + 'route-version-1', + PREVIEW_DELIVERY_ORDERS.slice(0, 2), + ), + (error: unknown) => error instanceof Error + && error.name === 'DriverRouteApiError' + && error.message === '네트워크 연결을 확인한 뒤 다시 시도해 주세요.', + ); + assert.equal(requestCount, 2); + }); }); diff --git a/src/api/dsvDriverRoute.ts b/src/api/dsvDriverRoute.ts index 9a89879..bb2d43a 100644 --- a/src/api/dsvDriverRoute.ts +++ b/src/api/dsvDriverRoute.ts @@ -84,6 +84,15 @@ type DestinationNotesEnvelope = { error?: { code: string; message: string } | null; }; +type DriverRouteOrderEnvelope = { + data: { + routePlanId: string; + routeVersionId: string; + stops: { deliveryStopId: string; sequence: number }[]; + } | null; + error?: { code: string; message: string } | null; +}; + type DriverRouteHistoryEnvelope = { data: { routes: { @@ -118,6 +127,7 @@ type AssignedRouteEnvelope = { id: string; name: string; routeGeometry: ServerDeliveryRouteGeometry | null; + routeVersionId?: string | null; stops: AssignedRouteStop[]; timezone?: string; }; @@ -141,6 +151,7 @@ export type DriverDeliveryRoute = { routePlanId: string; routeAccessToken: string; routeContext: string; + routeVersionId: string | null; serverRouteGeometry: ServerDeliveryRouteGeometry | null; timezone: string; }; @@ -222,6 +233,7 @@ export async function loadDriverDeliveryRoute( routeId: routeChoice.routePlanId, routeName: routeChoice.routeName, routePlanId: routeChoice.routePlanId, + routeVersionId: null, serverRouteGeometry: null, timezone: DSV_DEFAULT_TIMEZONE, }; @@ -260,12 +272,63 @@ export async function loadDriverDeliveryRoute( routePlanId: routeChoice.routePlanId, routeAccessToken: routeChoice.routeAccessToken, routeContext: routeChoice.routeContext, + routeVersionId: route.routeVersionId ?? null, serverRouteGeometry: readServerRouteGeometry(route.routeGeometry), timezone: normalizeDsvTimezone(route.timezone), availableRoutes: routeChoices, }; } +export async function updateDriverDeliveryOrder( + routeAccessToken: string, + routePlanId: string, + expectedVersion: string, + orders: DeliveryOrder[], +): Promise<{ orders: DeliveryOrder[]; routeVersionId: string }> { + const commandId = [ + routePlanId, + 'order', + Date.now(), + Math.random().toString(36).slice(2), + ].join(':'); + const body = JSON.stringify({ + commandId, + expectedVersion, + orderedStopIds: orders.map(({ id }) => id), + }); + let envelope: DriverRouteOrderEnvelope | undefined; + + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + envelope = await requestJson( + `/driver/routes/${encodeURIComponent(routePlanId)}/order`, + routeAccessToken, + { body, method: 'PATCH' }, + ); + break; + } catch (error) { + if (error instanceof DriverRouteApiError) throw error; + if (attempt === 1) { + throw new DriverRouteApiError( + 'ROUTE_ORDER_NETWORK_ERROR', + '네트워크 연결을 확인한 뒤 다시 시도해 주세요.', + ); + } + } + } + + if (envelope?.data === null || envelope === undefined) { + throwDriverRouteOrderError(envelope?.error); + } + if (!isValidDriverRouteOrderResult(envelope.data, routePlanId)) { + throwInvalidRouteOrderResponse(); + } + return { + orders: applyAuthoritativeStopOrder(orders, envelope.data), + routeVersionId: envelope.data.routeVersionId, + }; +} + export async function updateDriverDestinationNotes( routeAccessToken: string, destinationId: string, @@ -342,6 +405,72 @@ function readLunchAccess(value: ServerDestinationNotes['lunchEntryStatus']): Lun return value === 'AVAILABLE' || value === 'UNAVAILABLE' ? value : 'UNKNOWN'; } +function applyAuthoritativeStopOrder( + orders: DeliveryOrder[], + result: NonNullable, +): DeliveryOrder[] { + const orderById = new Map(orders.map((order) => [order.id, order])); + const sequences = result.stops.map(({ sequence }) => sequence).sort((a, b) => a - b); + if ( + result.stops.length !== orders.length || + new Set(result.stops.map(({ deliveryStopId }) => deliveryStopId)).size !== orders.length || + sequences.some((sequence, index) => sequence !== index + 1) + ) { + throwInvalidRouteOrderResponse(); + } + + return [...result.stops] + .sort((left, right) => left.sequence - right.sequence) + .map(({ deliveryStopId, sequence }) => { + const order = orderById.get(deliveryStopId); + if (order === undefined) { + throwInvalidRouteOrderResponse(); + } + return { ...order, estimatedArrivalAt: null, sequence }; + }); +} + +function isValidDriverRouteOrderResult( + result: NonNullable, + expectedRoutePlanId: string, +): boolean { + return result.routePlanId === expectedRoutePlanId && + typeof result.routeVersionId === 'string' && + result.routeVersionId.length > 0 && + Array.isArray(result.stops) && + result.stops.every((stop) => ( + stop !== null && + typeof stop === 'object' && + typeof stop.deliveryStopId === 'string' && + Number.isInteger(stop.sequence) + )); +} + +function throwInvalidRouteOrderResponse(): never { + throw new DriverRouteApiError( + 'INVALID_ROUTE_ORDER_RESPONSE', + '저장된 배송 순서를 확인할 수 없습니다.', + ); +} + +function throwDriverRouteOrderError( + error: { code: string; message: string } | null | undefined, +): never { + const code = error?.code ?? 'DSV_API_ERROR'; + const messages: Record = { + COMMAND_IN_PROGRESS: '배송 순서를 저장하고 있습니다. 잠시 후 최신 순서를 확인해 주세요.', + IDEMPOTENCY_PAYLOAD_MISMATCH: '배송 순서 저장 요청을 확인할 수 없습니다.', + INVALID_STOP_SET: '배송 경로가 변경됐습니다. 최신 순서를 확인한 뒤 다시 시도해 주세요.', + ROUTE_COMPLETED: '이미 종료된 배송 경로의 순서는 변경할 수 없습니다.', + ROUTE_SCOPE_REJECTED: '배송 경로가 변경됐습니다. 최신 순서를 확인한 뒤 다시 시도해 주세요.', + VERSION_CONFLICT: '배송 경로가 변경됐습니다. 최신 순서를 확인한 뒤 다시 시도해 주세요.', + }; + throw new DriverRouteApiError( + code, + messages[code] ?? error?.message ?? '배송 순서를 저장하지 못했습니다.', + ); +} + export async function loadDriverDeliveryRouteChoices( accountAccessToken: string, ): Promise { diff --git a/src/app/internalScreens.test.ts b/src/app/internalScreens.test.ts index 211cbdb..00c9195 100644 --- a/src/app/internalScreens.test.ts +++ b/src/app/internalScreens.test.ts @@ -254,7 +254,7 @@ describe('authenticated driver screens', () => { assert.doesNotMatch(deliveryScreen, /배송지 정보 UI Preview/u); assert.match( deliveryScreen, - /accessibilityState=\{\{ disabled: orders\.length === 0 \}\}/u, + /disabled: orders\.length === 0 \|\| !isSequenceEditingSupported/u, ); assert.match(deliveryScreen, /orders=\{selectedDestinationGroup\.orders\}/u); assert.match(destinationSheet, /useState\('orders'\)/u); @@ -381,6 +381,9 @@ describe('authenticated driver screens', () => { 'utf8', ); + assert.match(source, /async function finishEditing/u); + assert.match(source, /await onSaveDeliveryOrder\(draftOrders\)/u); + assert.match(source, /isSequenceSaving/u); assert.match(source, /Gesture\.Pan\(\)/u); assert.match(source, //u); assert.match(source, /accessibilityLabel=.*순서 이동 핸들/u); @@ -390,6 +393,27 @@ describe('authenticated driver screens', () => { assert.doesNotMatch(source, /destination\.orders\.length/u); }); + it('persists manual order with the assigned route version before refreshing', () => { + const workspace = readFileSync( + join(appDirectory, '../ui/driver/DriverWorkspace.tsx'), + 'utf8', + ); + const routeClient = readFileSync( + join(appDirectory, '../api/dsvDriverRoute.ts'), + 'utf8', + ); + + assert.match(routeClient, /routeVersionId: string \| null/u); + assert.match(routeClient, /updateDriverDeliveryOrder/u); + assert.match(routeClient, /orderedStopIds: orders\.map/u); + assert.match(workspace, /await updateDriverDeliveryOrder/u); + assert.match(workspace, /route\.routeVersionId/u); + assert.match(workspace, /serverRouteGeometry: null/u); + assert.match(workspace, /setLoadAttempt\(\(attempt\) => attempt \+ 1\)/u); + assert.match(workspace, /if \(!completesRoute\) \{\s+setLoadAttempt/u); + assert.doesNotMatch(workspace, /onOrdersChange=\{setOrders\}/u); + }); + it('reorders and animates neighboring rows while the handle remains held', () => { const source = readFileSync( join(appDirectory, '../ui/driver/DeliveryScreen.tsx'), diff --git a/src/ui/driver/DeliveryScreen.tsx b/src/ui/driver/DeliveryScreen.tsx index fdd829f..99dbf54 100644 --- a/src/ui/driver/DeliveryScreen.tsx +++ b/src/ui/driver/DeliveryScreen.tsx @@ -65,12 +65,12 @@ type DeliveryScreenProps = { historySummary?: DriverCompletedRouteHistory; isEditing: boolean; isReadOnly: boolean; + isSequenceEditingSupported: boolean; lastUpdatedAt: Date | null; nextDeliveryStopId: string | null; onAcknowledgeTimeConstraint(deliveryStopId: string): Promise; onEditingChange(isEditing: boolean): void; onOpenDeliverySpace(): void; - onOrdersChange(orders: DeliveryOrder[]): void; onReadDriverMessage(messageId: string): Promise; onRefresh(): void; onSaveDestinationNotes( @@ -78,6 +78,7 @@ type DeliveryScreenProps = { previous: DestinationNotes, values: DestinationNoteValues, ): Promise; + onSaveDeliveryOrder(orders: DeliveryOrder[]): Promise; orders: DeliveryOrder[]; refreshing: boolean; serverRouteGeometry: ServerDeliveryRouteGeometry | null; @@ -91,15 +92,16 @@ export function DeliveryScreen({ historySummary, isEditing, isReadOnly, + isSequenceEditingSupported, lastUpdatedAt, nextDeliveryStopId, onAcknowledgeTimeConstraint, onEditingChange, onOpenDeliverySpace, - onOrdersChange, onReadDriverMessage, onRefresh, onSaveDestinationNotes, + onSaveDeliveryOrder, orders, refreshing, serverRouteGeometry, @@ -111,6 +113,7 @@ export function DeliveryScreen({ const revealedDeliveryStopIdRef = useRef(nextDeliveryStopId); const [draftOrders, setDraftOrders] = useState(orders); const [isOrderActionPending, setIsOrderActionPending] = useState(false); + const [isSequenceSaving, setIsSequenceSaving] = useState(false); const [selectedDestinationId, setSelectedDestinationId] = useState(null); const totalBoxes = orders.reduce( @@ -125,18 +128,40 @@ export function DeliveryScreen({ ) ?? null; function startEditing() { + if (!isSequenceEditingSupported) return; setDraftOrders(orders); onEditingChange(true); } function cancelEditing() { + if (isSequenceSaving) return; setDraftOrders(orders); onEditingChange(false); } - function finishEditing() { - onOrdersChange(draftOrders); - onEditingChange(false); + async function finishEditing() { + if (isSequenceSaving) return; + const orderChanged = draftOrders.some((order, index) => ( + order.id !== orders[index]?.id + )); + if (!orderChanged) { + onEditingChange(false); + return; + } + + setIsSequenceSaving(true); + try { + await onSaveDeliveryOrder(draftOrders); + onEditingChange(false); + } catch (error) { + showDialog({ + message: error instanceof Error ? error.message : '다시 시도해 주세요.', + title: '배송 순서를 저장하지 못했습니다', + tone: 'warning', + }); + } finally { + setIsSequenceSaving(false); + } } function handleDrop(destinationId: string, targetIndex: number) { @@ -190,14 +215,18 @@ export function DeliveryScreen({ if (isEditing && !isReadOnly) { return ( - + <> + + {dialog} + ); } @@ -273,13 +302,16 @@ export function DeliveryScreen({ [ styles.headerActionButton, styles.editButton, - orders.length === 0 && styles.editButtonDisabled, + (orders.length === 0 || !isSequenceEditingSupported) && + styles.editButtonDisabled, pressed && styles.buttonPressed, ]} > @@ -510,6 +542,7 @@ function DestinationGroupRow({ function OrderSequenceEditor({ currentDeliveryStopId, + isSaving, onCancel, onDone, onDrop, @@ -517,8 +550,9 @@ function OrderSequenceEditor({ serverRouteGeometry, }: { currentDeliveryStopId: string | null; + isSaving: boolean; onCancel(): void; - onDone(): void; + onDone(): Promise | void; onDrop(destinationId: string, targetIndex: number): void; orders: DeliveryOrder[]; serverRouteGeometry: ServerDeliveryRouteGeometry | null; @@ -550,6 +584,8 @@ function OrderSequenceEditor({ [ styles.headerAction, @@ -566,17 +602,22 @@ function OrderSequenceEditor({ [ styles.headerAction, pressed && styles.buttonPressed, ]} > - 완료 + + {isSaving ? '저장 중…' : '완료'} + (null); + const routeRef = useRef(null); const [routeChoices, setRouteChoices] = useState([]); const terminalRoutesRef = useRef>({}); @@ -98,6 +100,10 @@ export function DriverWorkspace({ const isRouteReadOnly = route !== null && routeStatusGroup(route.executionStatus) === 'terminal'; + useEffect(() => { + routeRef.current = route; + }, [route]); + useEffect(() => { if (Platform.OS !== 'android') return; @@ -277,7 +283,7 @@ export function DriverWorkspace({ } function selectRoute(routePlanId: string) { - if (deliveryExecution.isLocked) return; + if (deliveryExecution.isLocked || isSequenceEditing) return; if (routePlanId === selectedRoutePlanId) { return; } @@ -291,7 +297,7 @@ export function DriverWorkspace({ } function selectRouteGroup(nextGroup: DriverRouteGroup) { - if (deliveryExecution.isLocked) return; + if (deliveryExecution.isLocked || isSequenceEditing) return; if (nextGroup === routeGroup) return; setIsSequenceEditing(false); setIsDeliverySpaceOpen(false); @@ -424,6 +430,77 @@ export function DriverWorkspace({ return notes; } + async function saveDeliveryOrder(nextOrders: DeliveryOrder[]) { + const savingRoute = routeRef.current; + if (savingRoute === null) { + throw new DriverRouteApiError( + 'ROUTE_NOT_AVAILABLE', + '배송 경로를 확인할 수 없습니다.', + ); + } + if (savingRoute.routeVersionId === null) { + throw new DriverRouteApiError( + 'ROUTE_ORDER_UNSUPPORTED', + '이 배송 경로는 수동 순서 저장을 지원하지 않습니다.', + ); + } + + const expectedVersion = savingRoute.routeVersionId; + let savedOrder: Awaited>; + try { + savedOrder = await updateDriverDeliveryOrder( + savingRoute.routeAccessToken, + savingRoute.routePlanId, + expectedVersion, + nextOrders, + ); + } catch (error) { + if ( + error instanceof DriverRouteApiError && + [ + 'COMMAND_IN_PROGRESS', + 'INVALID_STOP_SET', + 'ROUTE_COMPLETED', + 'ROUTE_SCOPE_REJECTED', + 'VERSION_CONFLICT', + ].includes(error.code) + ) { + setLoadAttempt((attempt) => attempt + 1); + } + throw error; + } + + const latestRoute = routeRef.current; + if ( + latestRoute === null || + latestRoute.routePlanId !== savingRoute.routePlanId || + ( + latestRoute.routeVersionId !== expectedVersion && + latestRoute.routeVersionId !== savedOrder.routeVersionId + ) + ) { + setLoadAttempt((attempt) => attempt + 1); + throw new DriverRouteApiError( + 'ROUTE_CHANGED_DURING_ORDER_SAVE', + '배송 경로가 변경됐습니다. 최신 순서를 확인해 주세요.', + ); + } + + const savedRoute: DriverDeliveryRoute = { + ...latestRoute, + etaStatus: latestRoute.pickupCompletedAt === null ? 'PRE_PICKUP' : 'FAILED', + nextDeliveryStopId: null, + orders: savedOrder.orders, + routeVersionId: savedOrder.routeVersionId, + serverRouteGeometry: null, + }; + routeRef.current = savedRoute; + setRoute(savedRoute); + setOrders(savedOrder.orders); + setLastRouteUpdatedAt(new Date()); + setLoadAttempt((attempt) => attempt + 1); + } + async function uploadDeliveryProof( deliveryStopId: string, photo: Omit, @@ -533,15 +610,16 @@ export function DriverWorkspace({ historySummary={route.historySummary} isEditing={isSequenceEditing} isReadOnly={isRouteReadOnly} + isSequenceEditingSupported={route.routeVersionId !== null} lastUpdatedAt={lastRouteUpdatedAt} nextDeliveryStopId={route.nextDeliveryStopId} onAcknowledgeTimeConstraint={acknowledgeTimeConstraint} onEditingChange={changeSequenceEditing} onOpenDeliverySpace={openDeliverySpace} - onOrdersChange={setOrders} onReadDriverMessage={readDriverMessage} onRefresh={refreshRoute} onSaveDestinationNotes={saveDestinationNotes} + onSaveDeliveryOrder={saveDeliveryOrder} orders={orders} refreshing={isRefreshingRoute} serverRouteGeometry={route.serverRouteGeometry} @@ -658,6 +736,7 @@ function completedRouteFromHistory( routeId: summary.routePlanId, routeName: summary.routeName, routePlanId: summary.routePlanId, + routeVersionId: null, serverRouteGeometry: null, timezone: summary.timezone, }; From b44f774adf5745e95470d9ae55ca263368282010 Mon Sep 17 00:00:00 2001 From: OziinG Date: Tue, 8 Sep 2026 16:02:13 +0900 Subject: [PATCH 3/5] Prevent Driver email validation from disagreeing with deployed auth Align the app-side acceptance boundary to the existing server authority while preserving legacy IDs and the conventional email path, and identify the next Play candidate as 0.1.14. Constraint: Build on candidate b443158 without modifying the deployed server or reusing the vc13 APK. Rejected: Broaden the server email contract | The deployed server remains the authentication authority. Confidence: high Scope-risk: narrow Directive: Keep EAS remote versioning authoritative and verify the production AAB resolves to versionCode 22 before any Play upload. Tested: 11 targeted tests; check:workspace 151 tests and typecheck; lint; Expo dependency check; Android and iOS exports; git diff check. Not-tested: Production EAS AAB, Play upload, Play-signed device update, and released app-server authentication E2E pending downstream release stages. --- app.json | 4 ++-- src/config/projectConvention.test.ts | 4 ++-- src/domain/auth/authForm.test.ts | 8 +++++++- src/domain/auth/authForm.ts | 6 ++---- 4 files changed, 13 insertions(+), 9 deletions(-) diff --git a/app.json b/app.json index 75f471e..383aa9f 100644 --- a/app.json +++ b/app.json @@ -3,7 +3,7 @@ "name": "CLEVER Driver", "owner": "evandsolution", "slug": "clever-driver-app", - "version": "0.1.13", + "version": "0.1.14", "icon": "./assets/branding/driver-app-icon.png", "orientation": "portrait", "userInterfaceStyle": "automatic", @@ -19,7 +19,7 @@ "android": { "package": "com.evnsolution.clever.driver", "predictiveBackGestureEnabled": true, - "versionCode": 13, + "versionCode": 22, "googleServicesFile": "./.private/google-services.json", "adaptiveIcon": { "foregroundImage": "./assets/branding/driver-app-icon-foreground.png", diff --git a/src/config/projectConvention.test.ts b/src/config/projectConvention.test.ts index 33d2479..c04dd93 100644 --- a/src/config/projectConvention.test.ts +++ b/src/config/projectConvention.test.ts @@ -33,8 +33,8 @@ test('keeps the CLEVER Driver app identity consistent', () => { assert.equal(appConfig.expo.name, 'CLEVER Driver'); assert.equal(appConfig.expo.owner, 'evandsolution'); assert.equal(appConfig.expo.slug, 'clever-driver-app'); - assert.equal(appConfig.expo.version, '0.1.13'); - assert.equal(appConfig.expo.android.versionCode, 13); + assert.equal(appConfig.expo.version, '0.1.14'); + assert.equal(appConfig.expo.android.versionCode, 22); assert.equal( appConfig.expo.ios.bundleIdentifier, 'com.evnsolution.clever.driver', diff --git a/src/domain/auth/authForm.test.ts b/src/domain/auth/authForm.test.ts index 3c965f9..88c087c 100644 --- a/src/domain/auth/authForm.test.ts +++ b/src/domain/auth/authForm.test.ts @@ -108,7 +108,13 @@ describe('DSV account form', () => { }); it('rejects malformed email identifiers', () => { - for (const loginId of ['driver@example', 'driver@@example.com', 'driver @example.com']) { + for (const loginId of [ + 'driver@example', + 'driver@@example.com', + 'driver @example.com', + 'driver..test@example.com', + 'driver@-example.com', + ]) { assert.ok(validateLoginForm({ loginId, password: 'password123' }).loginId); } }); diff --git a/src/domain/auth/authForm.ts b/src/domain/auth/authForm.ts index 2fa21d5..93e07cc 100644 --- a/src/domain/auth/authForm.ts +++ b/src/domain/auth/authForm.ts @@ -16,8 +16,7 @@ export type RegistrationFormErrors = Partial< const KOREAN_MOBILE_PATTERN = /^01\d{8,9}$/u; const LOGIN_ID_PATTERN = /^[a-z0-9._-]{4,40}$/u; -const EMAIL_LOGIN_ID_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/u; -const MAX_EMAIL_LOGIN_ID_LENGTH = 254; +const EMAIL_LOGIN_ID_PATTERN = /^(?=.{3,254}$)(?=[^@]{1,64}@)[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/u; const LOGIN_ID_ERROR_MESSAGE = '아이디는 영문 소문자, 숫자, 점, 밑줄, 하이픈 4~40자 또는 이메일 주소로 입력해 주세요.'; const MAX_NAME_LENGTH = 80; @@ -91,6 +90,5 @@ export function validateRegistrationForm( function isValidLoginId(value: string): boolean { return LOGIN_ID_PATTERN.test(value) - || (value.length <= MAX_EMAIL_LOGIN_ID_LENGTH - && EMAIL_LOGIN_ID_PATTERN.test(value)); + || EMAIL_LOGIN_ID_PATTERN.test(value); } From 9a265aef43a6340528d1139f1cdb6471f24fc24a Mon Sep 17 00:00:00 2001 From: OziinG Date: Tue, 8 Sep 2026 17:15:36 +0900 Subject: [PATCH 4/5] Keep manual-order save outcomes visible across navigation Lift the pending-save signal to the workspace so back, settings, logout, and tab actions cannot unmount the editor before the authoritative result is shown. Refresh the canonical order contract documentation and reserve the next Play version code because product bytes changed after review.\n\nConstraint: Preserve the existing versioned server-order contract and the already uploaded versionCode 22 artifact.\nRejected: Global mutation overlay | It adds unnecessary state machinery for one bounded editor lifecycle.\nConfidence: high\nScope-risk: narrow\nDirective: Do not allow workspace navigation to outlive or hide a pending manual-order save result.\nTested: 35 targeted tests; TypeScript typecheck and 153 tests; Expo lint; expo install --check; git diff --check.\nNot-tested: Replacement EAS AAB, Play upload, and Play-signed physical-device update pending downstream steps. --- DESIGN.md | 13 +++++--- README.md | 12 +++---- app.json | 2 +- docs/project-brief.md | 7 +++-- src/app/internalScreens.test.ts | 31 +++++++++++++++++++ src/config/projectConvention.test.ts | 2 +- .../navigation/androidBackNavigation.test.ts | 15 +++++++++ .../navigation/androidBackNavigation.ts | 4 +++ src/ui/driver/DeliveryScreen.tsx | 4 +++ src/ui/driver/DriverWorkspace.tsx | 20 ++++++++++-- 10 files changed, 92 insertions(+), 18 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 333f224..cf94e9c 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -196,7 +196,10 @@ Success signals: 확인·경고·오류·성공 메시지가 같은 카드, 버 - 핸들 홀드·드래그: 카드가 다음 슬롯의 절반을 넘는 즉시 주문 1건의 초안 순서를 갱신하고 주변 주문을 애니메이션으로 이동 - 지도 이동: 지도 패닝만 허용하고 확대·회전·기울이기와 배송 편집은 허용하지 않음 -- 완료: 편집 초안을 화면 메모리에 반영하고 기본 주문 목록으로 복귀 +- 완료: 완료 배송지를 포함한 전체 stop 순열과 현재 `expectedVersion`을 서버에 + 저장하고, 서버가 반환한 새 버전과 정본 순서를 채택한 뒤 기본 목록으로 복귀 +- 순서 저장 중: 편집 화면을 유지하며 Android 뒤로가기, 환경설정, 로그아웃과 + 하단 탭 전환을 막고 성공 또는 실패 결과가 끝난 뒤에만 상위 이동을 허용 - 취소: 편집 초안을 버리고 기본 주문 목록으로 복귀 - 배송 반납: 확인 후 같은 배송지의 모든 주문을 공용 배송으로 이동 - 공용 배송 확보: 서버 선착순 결과를 적용하고 충돌 시 최신 목록으로 갱신 @@ -238,8 +241,10 @@ Success signals: 확인·경고·오류·성공 메시지가 같은 카드, 버 않는다. - 같은 `destinationId`의 주문도 각각 독립적인 방문 순서를 가진다. 단, 배정 변경은 기존 제품 계약대로 배송지 묶음 단위다. -- 서버 저장 계약이 정해지면 편집 결과는 `route_plan_stops.sequence`와 revision - 정책을 통해 저장하며 클라이언트는 예상 엔드포인트를 만들지 않는다. +- 수동 순서는 승인된 `PATCH /driver/routes/:routePlanId/order` 계약으로 저장한다. + 클라이언트는 완료 배송지를 포함한 전체 `deliveryStopId` 순열, 서버에서 받은 + `expectedVersion`, 재시도에도 유지하는 `commandId`를 보내고 정본 응답만 채택한다. + `routeVersionId: null`인 레거시 배차에서는 순서 저장을 제공하지 않는다. - 클라이언트는 경로를 계산하지 않는다. DSV 서버가 OSRM/VWorld로 생성해 응답한 GeoJSON `LineString` geometry만 수정 없이 MapLibre source에 전달한다. - 서버 geometry가 없거나 stale/unavailable 상태면 경로 선을 그리지 않는다. @@ -259,9 +264,7 @@ Success signals: 확인·경고·오류·성공 메시지가 같은 카드, 버 - [ ] 완료·취소 배차 기록을 계정 인증으로 조회할 서버 계약 - [ ] 배송원별 배정 배송지와 주문을 제공할 DSV API 계약 -- [ ] 순서 확정의 저장 단위, 낙관적 잠금과 충돌 응답 계약 - [ ] 배송원 계정에 배정된 경로 geometry를 제공할 driver-scoped DSV API 계약 -- [ ] 서버 geometry의 fresh/stale/unavailable 상태와 순서 확정 revision 계약 - [ ] 배송 시작 이후 순서 재확정 허용 정책 - [ ] 배송원 route-access 범위에서 고정 배송지 정보를 읽고 수정하는 서버 계약 - [ ] 배송지 정보의 revision 충돌과 항목별 `updatedAt` 저장 계약 diff --git a/README.md b/README.md index 8279464..710fe17 100644 --- a/README.md +++ b/README.md @@ -33,9 +33,9 @@ npx expo install --check ## 현재 범위 -현재 DSV 계정 가입·로그인과 연결 상태 확인, 배송지 묶음 순서 변경·확정 -미리보기, MapLibre 지도 미리보기를 제공합니다. 인증은 승인된 DSV API를 -호출하며 배송지·주문과 순서 확정은 아직 로컬 예시 데이터입니다. 지도 경로는 -클라이언트가 만들지 않으며 서버가 OSRM/VWorld로 생성한 geometry가 있을 때만 -표시합니다. 배송 조회, 서버 경로, 순서 저장과 배정 변경은 후속 DSV API 계약이 -승인된 뒤 연결합니다. +현재 DSV 계정 가입·로그인과 연결 상태 확인, 서버 배차·배송 조회, 배송지별 +수동 순서 저장과 MapLibre 지도를 제공합니다. 수동 순서는 `routeVersionId`가 +있는 배차에서 전체 stop 순열과 현재 서버 버전을 승인된 DSV API에 보내고 정본 +응답을 채택합니다. 레거시 배차처럼 버전이 없으면 저장을 제공하지 않습니다. +지도 경로는 클라이언트가 만들지 않으며 서버가 OSRM/VWorld로 생성한 geometry가 +있을 때만 표시합니다. 배송지 묶음 반납·확보도 승인된 DSV API를 사용합니다. diff --git a/app.json b/app.json index 383aa9f..8ca4487 100644 --- a/app.json +++ b/app.json @@ -19,7 +19,7 @@ "android": { "package": "com.evnsolution.clever.driver", "predictiveBackGestureEnabled": true, - "versionCode": 22, + "versionCode": 23, "googleServicesFile": "./.private/google-services.json", "adaptiveIcon": { "foregroundImage": "./assets/branding/driver-app-icon-foreground.png", diff --git a/docs/project-brief.md b/docs/project-brief.md index 4491dd2..638269b 100644 --- a/docs/project-brief.md +++ b/docs/project-brief.md @@ -118,8 +118,11 @@ localhost 또는 `127.0.0.1` 개발을 위한 발급한 경로 범위 토큰으로 `/driver/assigned-route`를 조회해 표시한다. 화면의 배송일은 기기 날짜가 아니라 응답의 `deliveryDate`를 사용한다. 앱은 배송지 좌표를 임의로 연결하거나 경로를 계산하지 않으며, 서버의 `routeGeometry`가 -있을 때만 그 좌표를 수정 없이 표시한다. 순서 저장은 별도 API 계약이 승인된 -뒤 연결한다. +있을 때만 그 좌표를 수정 없이 표시한다. `routeVersionId`가 있는 배차의 수동 +순서는 경로 토큰으로 `PATCH /driver/routes/:routePlanId/order`에 완료 배송지를 +포함한 전체 `deliveryStopId` 순열, `expectedVersion`, `commandId`를 보내 저장한다. +앱은 서버가 반환한 새 버전과 순서만 채택하며 `routeVersionId: null`인 레거시 +배차에서는 수동 순서 저장을 제공하지 않는다. 주문 목록은 본 화면에서 선택한 배송일·배차 ID·배차명·경로 범위 토큰을 함께 받는다. 배차나 배송일이 바뀌면 목록 상태를 새로 만들고, 화면에는 선택한 배송일과 배차명을 표시한다. diff --git a/src/app/internalScreens.test.ts b/src/app/internalScreens.test.ts index 00c9195..0d3ba8e 100644 --- a/src/app/internalScreens.test.ts +++ b/src/app/internalScreens.test.ts @@ -65,12 +65,43 @@ describe('authenticated driver screens', () => { assert.match(workspace, /앱을 종료하려면 뒤로가기를 한 번 더 누르세요\./u); assert.match(workspace, /isDeliverySpaceOpen/u); assert.match(workspace, /isSequenceEditing/u); + assert.match(workspace, /isSequenceSaving/u); assert.match(workspace, /backSubscription\.remove\(\)/u); assert.match(deliveryScreen, /isEditing: boolean/u); assert.match(deliveryScreen, /onEditingChange\(isEditing: boolean\): void/u); + assert.match( + deliveryScreen, + /onSequenceSavingChange\(isSaving: boolean\): void/u, + ); assert.match(appConfig, /"predictiveBackGestureEnabled": true/u); }); + it('keeps parent navigation disabled while delivery order saving is pending', () => { + const workspace = readFileSync( + join(appDirectory, '../ui/driver/DriverWorkspace.tsx'), + 'utf8', + ); + const deliveryScreen = readFileSync( + join(appDirectory, '../ui/driver/DeliveryScreen.tsx'), + 'utf8', + ); + + assert.match(workspace, /onSequenceSavingChange=\{setIsSequenceSaving\}/u); + assert.equal(workspace.match(/disabled=\{isSequenceSaving\}/gu)?.length, 4); + assert.match( + workspace, + /accessibilityState=\{\{ disabled, selected: isSelected \}\}/u, + ); + assert.match( + deliveryScreen, + /setIsSequenceSaving\(true\);\s+onSequenceSavingChange\(true\);/u, + ); + assert.match( + deliveryScreen, + /setIsSequenceSaving\(false\);\s+onSequenceSavingChange\(false\);/u, + ); + }); + it('selects delivery dates from server route choices', () => { const source = readFileSync( join(appDirectory, '../ui/driver/DriverWorkspace.tsx'), diff --git a/src/config/projectConvention.test.ts b/src/config/projectConvention.test.ts index c04dd93..3c12f89 100644 --- a/src/config/projectConvention.test.ts +++ b/src/config/projectConvention.test.ts @@ -34,7 +34,7 @@ test('keeps the CLEVER Driver app identity consistent', () => { assert.equal(appConfig.expo.owner, 'evandsolution'); assert.equal(appConfig.expo.slug, 'clever-driver-app'); assert.equal(appConfig.expo.version, '0.1.14'); - assert.equal(appConfig.expo.android.versionCode, 22); + assert.equal(appConfig.expo.android.versionCode, 23); assert.equal( appConfig.expo.ios.bundleIdentifier, 'com.evnsolution.clever.driver', diff --git a/src/domain/navigation/androidBackNavigation.test.ts b/src/domain/navigation/androidBackNavigation.test.ts index 25d0f80..f7fcf54 100644 --- a/src/domain/navigation/androidBackNavigation.test.ts +++ b/src/domain/navigation/androidBackNavigation.test.ts @@ -11,33 +11,48 @@ describe('Android driver workspace back navigation', () => { assert.equal(resolveAndroidBackAction({ isDeliverySpaceOpen: true, isSequenceEditing: true, + isSequenceSaving: false, lastRootBackAt: 1_000, now: 1_500, }), 'close-delivery-space'); assert.equal(resolveAndroidBackAction({ isDeliverySpaceOpen: false, isSequenceEditing: true, + isSequenceSaving: false, lastRootBackAt: 1_000, now: 1_500, }), 'close-sequence-editor'); }); + it('keeps the sequence editor open while its save is pending', () => { + assert.equal(resolveAndroidBackAction({ + isDeliverySpaceOpen: false, + isSequenceEditing: true, + isSequenceSaving: true, + lastRootBackAt: 1_000, + now: 1_500, + }), 'keep-sequence-editor-open'); + }); + it('exits only on a second root back press within two seconds', () => { assert.equal(resolveAndroidBackAction({ isDeliverySpaceOpen: false, isSequenceEditing: false, + isSequenceSaving: false, lastRootBackAt: null, now: 1_000, }), 'show-exit-hint'); assert.equal(resolveAndroidBackAction({ isDeliverySpaceOpen: false, isSequenceEditing: false, + isSequenceSaving: false, lastRootBackAt: 1_000, now: 1_000 + ANDROID_BACK_EXIT_WINDOW_MS, }), 'exit-app'); assert.equal(resolveAndroidBackAction({ isDeliverySpaceOpen: false, isSequenceEditing: false, + isSequenceSaving: false, lastRootBackAt: 1_000, now: 1_001 + ANDROID_BACK_EXIT_WINDOW_MS, }), 'show-exit-hint'); diff --git a/src/domain/navigation/androidBackNavigation.ts b/src/domain/navigation/androidBackNavigation.ts index 89ec0e3..c16befe 100644 --- a/src/domain/navigation/androidBackNavigation.ts +++ b/src/domain/navigation/androidBackNavigation.ts @@ -3,20 +3,24 @@ export const ANDROID_BACK_EXIT_WINDOW_MS = 2_000; type AndroidBackAction = | 'close-delivery-space' | 'close-sequence-editor' + | 'keep-sequence-editor-open' | 'show-exit-hint' | 'exit-app'; export function resolveAndroidBackAction({ isDeliverySpaceOpen, isSequenceEditing, + isSequenceSaving, lastRootBackAt, now, }: { isDeliverySpaceOpen: boolean; isSequenceEditing: boolean; + isSequenceSaving: boolean; lastRootBackAt: number | null; now: number; }): AndroidBackAction { + if (isSequenceSaving) return 'keep-sequence-editor-open'; if (isDeliverySpaceOpen) return 'close-delivery-space'; if (isSequenceEditing) return 'close-sequence-editor'; diff --git a/src/ui/driver/DeliveryScreen.tsx b/src/ui/driver/DeliveryScreen.tsx index 99dbf54..70db546 100644 --- a/src/ui/driver/DeliveryScreen.tsx +++ b/src/ui/driver/DeliveryScreen.tsx @@ -73,6 +73,7 @@ type DeliveryScreenProps = { onOpenDeliverySpace(): void; onReadDriverMessage(messageId: string): Promise; onRefresh(): void; + onSequenceSavingChange(isSaving: boolean): void; onSaveDestinationNotes( destinationId: string, previous: DestinationNotes, @@ -100,6 +101,7 @@ export function DeliveryScreen({ onOpenDeliverySpace, onReadDriverMessage, onRefresh, + onSequenceSavingChange, onSaveDestinationNotes, onSaveDeliveryOrder, orders, @@ -150,6 +152,7 @@ export function DeliveryScreen({ } setIsSequenceSaving(true); + onSequenceSavingChange(true); try { await onSaveDeliveryOrder(draftOrders); onEditingChange(false); @@ -161,6 +164,7 @@ export function DeliveryScreen({ }); } finally { setIsSequenceSaving(false); + onSequenceSavingChange(false); } } diff --git a/src/ui/driver/DriverWorkspace.tsx b/src/ui/driver/DriverWorkspace.tsx index adc48a8..8af085b 100644 --- a/src/ui/driver/DriverWorkspace.tsx +++ b/src/ui/driver/DriverWorkspace.tsx @@ -75,6 +75,7 @@ export function DriverWorkspace({ const [activeTab, setActiveTab] = useState('delivery'); const [isDeliverySpaceOpen, setIsDeliverySpaceOpen] = useState(false); const [isSequenceEditing, setIsSequenceEditing] = useState(false); + const [isSequenceSaving, setIsSequenceSaving] = useState(false); const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [route, setRoute] = useState(null); const routeRef = useRef(null); @@ -114,11 +115,14 @@ export function DriverWorkspace({ const action = resolveAndroidBackAction({ isDeliverySpaceOpen, isSequenceEditing, + isSequenceSaving, lastRootBackAt: lastRootBackAtRef.current, now, }); - if (action === 'close-delivery-space') { + if (action === 'keep-sequence-editor-open') { + lastRootBackAtRef.current = null; + } else if (action === 'close-delivery-space') { lastRootBackAtRef.current = null; setIsDeliverySpaceOpen(false); } else if (action === 'close-sequence-editor') { @@ -140,7 +144,7 @@ export function DriverWorkspace({ ); return () => backSubscription.remove(); - }, [isDeliverySpaceOpen, isSequenceEditing]); + }, [isDeliverySpaceOpen, isSequenceEditing, isSequenceSaving]); useEffect(() => { let isActive = true; @@ -543,6 +547,8 @@ export function DriverWorkspace({ { resetRootBackPress(); setIsSettingsOpen(true); @@ -560,6 +566,8 @@ export function DriverWorkspace({ [ styles.logoutButton, @@ -618,6 +626,7 @@ export function DriverWorkspace({ onOpenDeliverySpace={openDeliverySpace} onReadDriverMessage={readDriverMessage} onRefresh={refreshRoute} + onSequenceSavingChange={setIsSequenceSaving} onSaveDestinationNotes={saveDestinationNotes} onSaveDeliveryOrder={saveDeliveryOrder} orders={orders} @@ -667,6 +676,7 @@ export function DriverWorkspace({ > } + disabled={isSequenceSaving} isSelected={activeTab === 'delivery'} label="배송" onPress={() => { @@ -685,6 +695,7 @@ export function DriverWorkspace({ ⌖ } + disabled={isSequenceSaving} isSelected={activeTab === 'map'} label="지도" onPress={() => { @@ -1042,11 +1053,13 @@ function RouteLoadState({ } function TabButton({ + disabled, icon, isSelected, label, onPress, }: { + disabled: boolean; icon: ReactNode; isSelected: boolean; label: string; @@ -1055,7 +1068,8 @@ function TabButton({ return ( [ styles.tabButton, From a981cc0d5d6d8e80bee656d67164c26967729b5c Mon Sep 17 00:00:00 2001 From: OziinG Date: Tue, 8 Sep 2026 17:35:38 +0900 Subject: [PATCH 5/5] Defer route refresh until manual-order save settles Keep foreground and push refreshes from closing the sequence editor during the pending server mutation, then apply the queued refresh once the save lifecycle ends. Remove the remaining stale order-unit descriptions from the design source of truth.\n\nConstraint: Finish only the review-reported save lifecycle defect and existing DESIGN.md corrections.\nRejected: Global refresh queue | The existing effect dependency can safely defer and replay the latest refresh key without a new state layer.\nConfidence: high\nScope-risk: narrow\nDirective: Route refreshes must not replace sequence-editor state while its save promise is pending.\nTested: 32 focused screen/back-navigation tests; TypeScript typecheck; git diff check; stale DESIGN wording scan.\nNot-tested: New EAS build, Play upload, physical-device update, merge, review submission, and rollout intentionally excluded. --- DESIGN.md | 51 ++++++++++++++++--------------- src/app/internalScreens.test.ts | 15 ++++++++- src/ui/driver/DriverWorkspace.tsx | 33 +++++++++++++++++++- 3 files changed, 73 insertions(+), 26 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index cf94e9c..63ad9d1 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -3,7 +3,7 @@ ## Source of truth - Status: Active -- Last refreshed: 2026-09-01 +- Last refreshed: 2026-09-08 - Primary product surfaces: DSV 배송원 인증, 배송, 지도 - Evidence reviewed: `docs/project-brief.md`, `docs/technology-stack.md`, `docs/code-organization.md`, `src/ui/auth/AuthEntryScreen.tsx`, @@ -29,11 +29,13 @@ Shopify, 상점 관리자 또는 Routes 전용 인증 개념은 화면에 노출 - 배송원이 로그인 직후 오늘의 배송지와 그 배송지에 속한 주문을 확인한다. - 일반 화면에서는 SellerOrderKey 기준 주문을 조밀한 행 목록으로 확인한다. -- 별도 순서 편집 모드에서는 SellerOrderKey 주문 1건씩 방문 순서를 결정한다. -- 배송지 묶음은 배정 변경의 단위이며 방문 순서 편집 단위와 구분한다. +- 별도 순서 편집 모드에서는 배송지 묶음별 방문 순서를 결정한다. +- 배송지 묶음은 배정 변경과 방문 순서 편집의 단위이며 두 동작의 서버 권위는 + 서로 구분한다. - 결정한 순서를 목록과 지도에서 같은 번호로 확인한다. - 경로 선은 서버가 OSRM/VWorld로 생성한 geometry가 있을 때만 그대로 표시한다. -- 서버 계약이 확정되기 전에는 미리보기 데이터와 화면 메모리 상태만 사용한다. +- 배차·배송·수동 순서는 승인된 서버 계약을 사용하고, 배송지 정보 UI Preview만 + 명시된 로컬 fixture와 화면 메모리 상태를 사용한다. - 플랫폼에 따라 앱 경고·확인 화면의 모양과 행동이 달라지지 않게 한다. - 배송원이 배송지명·주소 영역에서 고정 배송지 정보를 확인하고, 일반 메모, 점심시간, 점심시간 입장 가능 여부와 필수 도착 시간을 기록한다. @@ -56,7 +58,7 @@ Success signals: 확인·경고·오류·성공 메시지가 같은 카드, 버 연결된 배송원으로 인증되면 하단 탭은 두 개만 제공한다. -1. `배송`: 주문 목록 확인, `순서 편집` 액션으로 주문별 순서 편집 전환, +1. `배송`: 주문 목록 확인, `순서 편집` 액션으로 배송지별 순서 편집 전환, `주문 목록` 액션으로 배송지 묶음 반납·확보 페이지 진입 2. `지도`: 동일한 주문 순서와 서버 제공 경로 geometry를 MapLibre 지도에서 확인 @@ -80,11 +82,11 @@ Success signals: 확인·경고·오류·성공 메시지가 같은 카드, 버 - 주문 조회와 주문 순서 편집을 화면 상태로 분리한다. - 주문 목록은 배송처, SellerOrderKey, 운송조건, 박스 수, 고객 코드, 주소를 우선하고 수령인·전화번호·시간대·물품명처럼 배차 원본에 없는 값을 만들지 않는다. -- SellerOrderKey 주문 1건을 순서 변경의 최소 단위로 표현한다. +- 같은 `destinationId`의 주문을 묶은 배송지 1곳을 순서 변경의 최소 단위로 표현한다. - 배정 변경과 방문 순서 변경을 같은 개념으로 취급하지 않는다. - 미리보기 데이터와 서버 확정 데이터를 혼동하지 않도록 명확히 표시한다. -- 배송지 정보 UI Preview의 배송일은 `2026-08-26`으로 표시하고, 서로 다른 - 배송지 8곳의 주문 편집 결과는 화면 메모리에만 유지해 서버 상태로 전달하지 않는다. +- 배송지 정보 UI Preview의 배송일은 `2026-08-26`으로 표시하고, 메모·점심시간· + 필수 도착 시간 편집 결과는 화면 메모리에만 유지해 서버 상태로 전달하지 않는다. - 앱은 배송지 좌표를 임의로 직선 연결하거나 경로를 계산·보간하지 않는다. - 필수 도착 시간은 첫 버전에서 배송원이 참고하는 정보이며 서버 ETA, 시간창 또는 경로 최적화를 변경하지 않는다. @@ -110,10 +112,10 @@ Success signals: 확인·경고·오류·성공 메시지가 같은 카드, 버 - 현재 배송지의 `배송 중` Pill은 행 우측 상단에 두고, 운송조건과 박스 수는 같은 열의 하단에 정렬한다. - 순서 편집 지도: 화면 위쪽 전체 폭, 높이 고정, 지도 이동만 허용 -- 순서 편집 주문 카드: 흰색, 1px 회색 테두리, 12px 모서리, 고정 높이 -- 주문 순서 번호는 목록과 지도에서 같은 파란색 표식을 사용한다. 같은 배송지의 - 여러 주문은 좌표를 바꾸지 않고 한 표식 안에 순서들을 함께 표시한다. -- 모션: 핸들을 누른 채 주문 카드가 슬롯 경계를 넘는 순간 주변 주문만 220ms +- 순서 편집 배송지 카드: 흰색, 1px 회색 테두리, 12px 모서리, 고정 높이 +- 배송지 순서 번호는 목록과 지도에서 같은 파란색 표식을 사용한다. 같은 배송지의 + 여러 주문은 하나의 배송지 표식과 방문 순서를 공유한다. +- 모션: 핸들을 누른 채 배송지 카드가 슬롯 경계를 넘는 순간 주변 카드만 220ms cubic-out으로 자리를 비운다. 드래그 카드는 별도 레이아웃 애니메이션 없이 손가락을 따르고, 손을 놓을 때까지 재정렬을 미루지 않는다. - 앱 다이얼로그: 화면 중앙 흰색 카드, 24px 모서리, 어두운 반투명 배경, @@ -135,11 +137,11 @@ Success signals: 확인·경고·오류·성공 메시지가 같은 카드, 버 두 목록에 공통으로 적용되는 기준 정보로 표시한다. - 배송지 묶음 행: 배송처, 주소, 주문 수, 박스 수, 운송조건, 반납·가져오기 액션 - 주문 행: 배송처, SellerOrderKey, 운송조건, 박스 수, 고객 코드, 주소 -- 순서 편집 지도: MapLibre 주문 순서 미리보기, 서버 경로, 이동 전용 제스처 -- 순서 편집 주문 카드: 왼쪽 드래그 핸들, 순서 번호, 배송처, SellerOrderKey, +- 순서 편집 지도: MapLibre 배송지 순서 미리보기, 서버 경로, 이동 전용 제스처 +- 순서 편집 배송지 카드: 왼쪽 드래그 핸들, 순서 번호, 배송처, 주소, 주문 수, 운송조건, 박스 수 -- 순서 제어: 왼쪽 핸들을 홀드·드래그해 주문 1건 이동 -- 편집 완료/취소: 완료 시 화면 메모리 순서를 반영하고 취소 시 원래 순서 복원 +- 순서 제어: 왼쪽 핸들을 홀드·드래그해 배송지 묶음 1곳 이동 +- 편집 완료/취소: 완료 시 서버 정본 순서를 반영하고 취소 시 원래 순서 복원 - MapLibre 지도: 서버 제공 경로 선, 번호 표식, 서버 경로 대기 상태 안내 - 하단 탭: `배송`, `지도` - 새로고침 상태: 화면에 상시 영역이나 버튼을 두지 않고, 당기는 동안에만 @@ -179,7 +181,7 @@ Success signals: 확인·경고·오류·성공 메시지가 같은 카드, 버 - 휴대전화 세로 화면을 기준으로 한다. - 기본 주문 목록은 세로 스크롤한다. -- 순서 편집 화면은 상단 지도와 하단 목록을 분리해 지도는 고정하고 주문 카드 +- 순서 편집 화면은 상단 지도와 하단 목록을 분리해 지도는 고정하고 배송지 카드 영역만 세로 스크롤한다. - 지도는 남은 화면을 채우고 작은 화면에서도 요약 카드와 하단 탭이 보인다. - 태블릿 전용 레이아웃은 이번 범위에 포함하지 않는다. @@ -191,9 +193,9 @@ Success signals: 확인·경고·오류·성공 메시지가 같은 카드, 버 않는다. - 상태 전환: 다른 배차 상태 그룹으로 이동할 때 날짜 선택과 작업 화면을 비운다. - 기본: SellerOrderKey 기준 주문 행 표시 -- 순서 편집 진입: 현재 주문 순서를 편집 초안으로 복사 -- 핸들 탭: 이동 대상 주문을 선택 상태로 표시 -- 핸들 홀드·드래그: 카드가 다음 슬롯의 절반을 넘는 즉시 주문 1건의 초안 순서를 +- 순서 편집 진입: 현재 배송지 순서를 편집 초안으로 복사 +- 핸들 탭: 이동 대상 배송지를 선택 상태로 표시 +- 핸들 홀드·드래그: 카드가 다음 슬롯의 절반을 넘는 즉시 배송지 묶음 1곳의 초안 순서를 갱신하고 주변 주문을 애니메이션으로 이동 - 지도 이동: 지도 패닝만 허용하고 확대·회전·기울이기와 배송 편집은 허용하지 않음 - 완료: 완료 배송지를 포함한 전체 stop 순열과 현재 `expectedVersion`을 서버에 @@ -235,12 +237,13 @@ Success signals: 확인·경고·오류·성공 메시지가 같은 카드, 버 - fixture 필드는 DSV 배차 원본과 서버 `dsv_dispatch_import_rows`의 `destinationName`, `conditionCode`, `shippedBoxes`, `address`, `customerCode`, `sellerOrderKey`, `notes`, `destinationId` 구조를 따른다. -- 일반 목록과 순서 편집 모두 SellerOrderKey 주문 1건을 한 행·카드로 유지한다. +- 일반 목록과 순서 편집 모두 같은 `destinationId`의 주문을 배송지 한 행·카드로 + 묶고 주문 수와 총 박스 수를 함께 표시한다. - 배차 상태는 서버의 `companyGuidance.executionStatus`를 그대로 사용한다. 클라이언트가 주문 ID, 주문 상태, 배열 순서 또는 날짜로 배차 상태를 추측하지 않는다. -- 같은 `destinationId`의 주문도 각각 독립적인 방문 순서를 가진다. 단, 배정 변경은 - 기존 제품 계약대로 배송지 묶음 단위다. +- 같은 `destinationId`의 주문은 하나의 방문 순서로 함께 이동한다. 배정 변경도 + 배송지 묶음 단위지만, 순서 저장과 배정 변경은 서로 다른 서버 명령이다. - 수동 순서는 승인된 `PATCH /driver/routes/:routePlanId/order` 계약으로 저장한다. 클라이언트는 완료 배송지를 포함한 전체 `deliveryStopId` 순열, 서버에서 받은 `expectedVersion`, 재시도에도 유지하는 `commandId`를 보내고 정본 응답만 채택한다. @@ -252,7 +255,7 @@ Success signals: 확인·경고·오류·성공 메시지가 같은 카드, 버 앱 선택기는 커스텀 스타일을 적용하지 않으며 그 전후의 앱 안내만 `AppDialog`로 통일한다. - 앱 코드에서 Android 기본 `Alert.alert`를 사용하지 않는다. -- 주문 순서 변경은 배정이나 배송지 연결을 변경하지 않고 sequence만 바꾼다. +- 배송지 순서 변경은 배정이나 배송지 연결을 변경하지 않고 sequence만 바꾼다. - 시스템의 동작 줄이기 설정이 켜져 있으면 정착·레이아웃 애니메이션을 생략하고 순서만 즉시 반영한다. - 서버 계약 전 Preview는 배송지 정보를 화면 메모리에만 저장한다. Preview UI는 diff --git a/src/app/internalScreens.test.ts b/src/app/internalScreens.test.ts index 0d3ba8e..8fc7d22 100644 --- a/src/app/internalScreens.test.ts +++ b/src/app/internalScreens.test.ts @@ -86,8 +86,21 @@ describe('authenticated driver screens', () => { 'utf8', ); - assert.match(workspace, /onSequenceSavingChange=\{setIsSequenceSaving\}/u); + assert.match(workspace, /onSequenceSavingChange=\{changeSequenceSaving\}/u); assert.equal(workspace.match(/disabled=\{isSequenceSaving\}/gu)?.length, 4); + assert.match( + workspace, + /useEffect\(\(\) => \{\s+if \(isSequenceSaving\) return undefined;/u, + ); + assert.match(workspace, /sequenceSaveReloadBaselineRef/u); + assert.match( + workspace, + /sequenceSaveBaseline\.refreshRequestKey === refreshRequestKey/u, + ); + assert.match( + workspace, + /sequenceSaveBaseline\.loadAttempt === loadAttempt/u, + ); assert.match( workspace, /accessibilityState=\{\{ disabled, selected: isSelected \}\}/u, diff --git a/src/ui/driver/DriverWorkspace.tsx b/src/ui/driver/DriverWorkspace.tsx index 8af085b..a525fbf 100644 --- a/src/ui/driver/DriverWorkspace.tsx +++ b/src/ui/driver/DriverWorkspace.tsx @@ -87,6 +87,11 @@ export function DriverWorkspace({ const [loadAttempt, setLoadAttempt] = useState(0); const [isRefreshingRoute, setIsRefreshingRoute] = useState(false); const isPullRefreshingRouteRef = useRef(false); + const sequenceSaveReloadBaselineRef = useRef<{ + loadAttempt: number; + refreshRequestKey: number; + selectedRoutePlanId: string | undefined; + } | null>(null); const [lastRouteUpdatedAt, setLastRouteUpdatedAt] = useState(null); const [selectedRoutePlanId, setSelectedRoutePlanId] = useState(); const [loadErrorMessage, setLoadErrorMessage] = useState(); @@ -147,6 +152,20 @@ export function DriverWorkspace({ }, [isDeliverySpaceOpen, isSequenceEditing, isSequenceSaving]); useEffect(() => { + if (isSequenceSaving) return undefined; + + const sequenceSaveBaseline = sequenceSaveReloadBaselineRef.current; + if (sequenceSaveBaseline !== null) { + sequenceSaveReloadBaselineRef.current = null; + if ( + sequenceSaveBaseline.loadAttempt === loadAttempt && + sequenceSaveBaseline.refreshRequestKey === refreshRequestKey && + sequenceSaveBaseline.selectedRoutePlanId === selectedRoutePlanId + ) { + return undefined; + } + } + let isActive = true; if ( @@ -271,6 +290,7 @@ export function DriverWorkspace({ loadAttempt, refreshRequestKey, selectedRoutePlanId, + isSequenceSaving, ]); function retryRouteLoad() { @@ -322,6 +342,17 @@ export function DriverWorkspace({ setIsSequenceEditing(isEditing); } + function changeSequenceSaving(isSaving: boolean) { + if (isSaving) { + sequenceSaveReloadBaselineRef.current = { + loadAttempt, + refreshRequestKey, + selectedRoutePlanId, + }; + } + setIsSequenceSaving(isSaving); + } + function openDeliverySpace() { if (deliveryExecution.isLocked) return; resetRootBackPress(); @@ -626,7 +657,7 @@ export function DriverWorkspace({ onOpenDeliverySpace={openDeliverySpace} onReadDriverMessage={readDriverMessage} onRefresh={refreshRoute} - onSequenceSavingChange={setIsSequenceSaving} + onSequenceSavingChange={changeSequenceSaving} onSaveDestinationNotes={saveDestinationNotes} onSaveDeliveryOrder={saveDeliveryOrder} orders={orders}