diff --git a/DESIGN.md b/DESIGN.md index 333f224..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,12 +193,15 @@ Success signals: 확인·경고·오류·성공 메시지가 같은 카드, 버 않는다. - 상태 전환: 다른 배차 상태 그룹으로 이동할 때 날짜 선택과 작업 화면을 비운다. - 기본: SellerOrderKey 기준 주문 행 표시 -- 순서 편집 진입: 현재 주문 순서를 편집 초안으로 복사 -- 핸들 탭: 이동 대상 주문을 선택 상태로 표시 -- 핸들 홀드·드래그: 카드가 다음 슬롯의 절반을 넘는 즉시 주문 1건의 초안 순서를 +- 순서 편집 진입: 현재 배송지 순서를 편집 초안으로 복사 +- 핸들 탭: 이동 대상 배송지를 선택 상태로 표시 +- 핸들 홀드·드래그: 카드가 다음 슬롯의 절반을 넘는 즉시 배송지 묶음 1곳의 초안 순서를 갱신하고 주변 주문을 애니메이션으로 이동 - 지도 이동: 지도 패닝만 허용하고 확대·회전·기울이기와 배송 편집은 허용하지 않음 -- 완료: 편집 초안을 화면 메모리에 반영하고 기본 주문 목록으로 복귀 +- 완료: 완료 배송지를 포함한 전체 stop 순열과 현재 `expectedVersion`을 서버에 + 저장하고, 서버가 반환한 새 버전과 정본 순서를 채택한 뒤 기본 목록으로 복귀 +- 순서 저장 중: 편집 화면을 유지하며 Android 뒤로가기, 환경설정, 로그아웃과 + 하단 탭 전환을 막고 성공 또는 실패 결과가 끝난 뒤에만 상위 이동을 허용 - 취소: 편집 초안을 버리고 기본 주문 목록으로 복귀 - 배송 반납: 확인 후 같은 배송지의 모든 주문을 공용 배송으로 이동 - 공용 배송 확보: 서버 선착순 결과를 적용하고 충돌 시 최신 목록으로 갱신 @@ -232,14 +237,17 @@ Success signals: 확인·경고·오류·성공 메시지가 같은 카드, 버 - fixture 필드는 DSV 배차 원본과 서버 `dsv_dispatch_import_rows`의 `destinationName`, `conditionCode`, `shippedBoxes`, `address`, `customerCode`, `sellerOrderKey`, `notes`, `destinationId` 구조를 따른다. -- 일반 목록과 순서 편집 모두 SellerOrderKey 주문 1건을 한 행·카드로 유지한다. +- 일반 목록과 순서 편집 모두 같은 `destinationId`의 주문을 배송지 한 행·카드로 + 묶고 주문 수와 총 박스 수를 함께 표시한다. - 배차 상태는 서버의 `companyGuidance.executionStatus`를 그대로 사용한다. 클라이언트가 주문 ID, 주문 상태, 배열 순서 또는 날짜로 배차 상태를 추측하지 않는다. -- 같은 `destinationId`의 주문도 각각 독립적인 방문 순서를 가진다. 단, 배정 변경은 - 기존 제품 계약대로 배송지 묶음 단위다. -- 서버 저장 계약이 정해지면 편집 결과는 `route_plan_stops.sequence`와 revision - 정책을 통해 저장하며 클라이언트는 예상 엔드포인트를 만들지 않는다. +- 같은 `destinationId`의 주문은 하나의 방문 순서로 함께 이동한다. 배정 변경도 + 배송지 묶음 단위지만, 순서 저장과 배정 변경은 서로 다른 서버 명령이다. +- 수동 순서는 승인된 `PATCH /driver/routes/:routePlanId/order` 계약으로 저장한다. + 클라이언트는 완료 배송지를 포함한 전체 `deliveryStopId` 순열, 서버에서 받은 + `expectedVersion`, 재시도에도 유지하는 `commandId`를 보내고 정본 응답만 채택한다. + `routeVersionId: null`인 레거시 배차에서는 순서 저장을 제공하지 않는다. - 클라이언트는 경로를 계산하지 않는다. DSV 서버가 OSRM/VWorld로 생성해 응답한 GeoJSON `LineString` geometry만 수정 없이 MapLibre source에 전달한다. - 서버 geometry가 없거나 stale/unavailable 상태면 경로 선을 그리지 않는다. @@ -247,7 +255,7 @@ Success signals: 확인·경고·오류·성공 메시지가 같은 카드, 버 앱 선택기는 커스텀 스타일을 적용하지 않으며 그 전후의 앱 안내만 `AppDialog`로 통일한다. - 앱 코드에서 Android 기본 `Alert.alert`를 사용하지 않는다. -- 주문 순서 변경은 배정이나 배송지 연결을 변경하지 않고 sequence만 바꾼다. +- 배송지 순서 변경은 배정이나 배송지 연결을 변경하지 않고 sequence만 바꾼다. - 시스템의 동작 줄이기 설정이 켜져 있으면 정착·레이아웃 애니메이션을 생략하고 순서만 즉시 반영한다. - 서버 계약 전 Preview는 배송지 정보를 화면 메모리에만 저장한다. Preview UI는 @@ -259,9 +267,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 75f471e..8ca4487 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": 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/api/dsvDriverRoute.test.ts b/src/api/dsvDriverRoute.test.ts index 686241b..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: [ { @@ -122,6 +125,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', }, @@ -152,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', + }, ], }, }, @@ -170,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'], { @@ -186,6 +219,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 +439,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 +456,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 +474,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', @@ -443,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 9d070ec..bb2d43a 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; }; @@ -82,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: { @@ -116,6 +127,7 @@ type AssignedRouteEnvelope = { id: string; name: string; routeGeometry: ServerDeliveryRouteGeometry | null; + routeVersionId?: string | null; stops: AssignedRouteStop[]; timezone?: string; }; @@ -139,6 +151,7 @@ export type DriverDeliveryRoute = { routePlanId: string; routeAccessToken: string; routeContext: string; + routeVersionId: string | null; serverRouteGeometry: ServerDeliveryRouteGeometry | null; timezone: string; }; @@ -220,6 +233,7 @@ export async function loadDriverDeliveryRoute( routeId: routeChoice.routePlanId, routeName: routeChoice.routeName, routePlanId: routeChoice.routePlanId, + routeVersionId: null, serverRouteGeometry: null, timezone: DSV_DEFAULT_TIMEZONE, }; @@ -258,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, @@ -294,6 +359,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 +390,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 ?? '', @@ -333,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 bbb8438..8fc7d22 100644 --- a/src/app/internalScreens.test.ts +++ b/src/app/internalScreens.test.ts @@ -65,12 +65,56 @@ 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=\{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, + ); + 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'), @@ -254,7 +298,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); @@ -266,6 +310,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 +337,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); @@ -378,6 +425,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); @@ -387,6 +437,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/config/projectConvention.test.ts b/src/config/projectConvention.test.ts index 33d2479..3c12f89 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, 23); 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); } 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/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 fdd829f..70db546 100644 --- a/src/ui/driver/DeliveryScreen.tsx +++ b/src/ui/driver/DeliveryScreen.tsx @@ -65,19 +65,21 @@ 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; + onSequenceSavingChange(isSaving: boolean): void; onSaveDestinationNotes( destinationId: string, previous: DestinationNotes, values: DestinationNoteValues, ): Promise; + onSaveDeliveryOrder(orders: DeliveryOrder[]): Promise; orders: DeliveryOrder[]; refreshing: boolean; serverRouteGeometry: ServerDeliveryRouteGeometry | null; @@ -91,15 +93,17 @@ export function DeliveryScreen({ historySummary, isEditing, isReadOnly, + isSequenceEditingSupported, lastUpdatedAt, nextDeliveryStopId, onAcknowledgeTimeConstraint, onEditingChange, onOpenDeliverySpace, - onOrdersChange, onReadDriverMessage, onRefresh, + onSequenceSavingChange, onSaveDestinationNotes, + onSaveDeliveryOrder, orders, refreshing, serverRouteGeometry, @@ -111,6 +115,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 +130,42 @@ 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); + onSequenceSavingChange(true); + try { + await onSaveDeliveryOrder(draftOrders); + onEditingChange(false); + } catch (error) { + showDialog({ + message: error instanceof Error ? error.message : '다시 시도해 주세요.', + title: '배송 순서를 저장하지 못했습니다', + tone: 'warning', + }); + } finally { + setIsSequenceSaving(false); + onSequenceSavingChange(false); + } } function handleDrop(destinationId: string, targetIndex: number) { @@ -190,14 +219,18 @@ export function DeliveryScreen({ if (isEditing && !isReadOnly) { return ( - + <> + + {dialog} + ); } @@ -273,13 +306,16 @@ export function DeliveryScreen({ [ styles.headerActionButton, styles.editButton, - orders.length === 0 && styles.editButtonDisabled, + (orders.length === 0 || !isSequenceEditingSupported) && + styles.editButtonDisabled, pressed && styles.buttonPressed, ]} > @@ -510,6 +546,7 @@ function DestinationGroupRow({ function OrderSequenceEditor({ currentDeliveryStopId, + isSaving, onCancel, onDone, onDrop, @@ -517,8 +554,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 +588,8 @@ function OrderSequenceEditor({ [ styles.headerAction, @@ -566,17 +606,22 @@ function OrderSequenceEditor({ [ styles.headerAction, pressed && styles.buttonPressed, ]} > - 완료 + + {isSaving ? '저장 중…' : '완료'} + 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} + ('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); const [routeChoices, setRouteChoices] = useState([]); const terminalRoutesRef = useRef>({}); @@ -84,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(); @@ -98,6 +106,10 @@ export function DriverWorkspace({ const isRouteReadOnly = route !== null && routeStatusGroup(route.executionStatus) === 'terminal'; + useEffect(() => { + routeRef.current = route; + }, [route]); + useEffect(() => { if (Platform.OS !== 'android') return; @@ -108,11 +120,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') { @@ -134,9 +149,23 @@ export function DriverWorkspace({ ); return () => backSubscription.remove(); - }, [isDeliverySpaceOpen, isSequenceEditing]); + }, [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 ( @@ -261,6 +290,7 @@ export function DriverWorkspace({ loadAttempt, refreshRequestKey, selectedRoutePlanId, + isSequenceSaving, ]); function retryRouteLoad() { @@ -277,7 +307,7 @@ export function DriverWorkspace({ } function selectRoute(routePlanId: string) { - if (deliveryExecution.isLocked) return; + if (deliveryExecution.isLocked || isSequenceEditing) return; if (routePlanId === selectedRoutePlanId) { return; } @@ -291,7 +321,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); @@ -312,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(); @@ -424,6 +465,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, @@ -466,6 +578,8 @@ export function DriverWorkspace({ { resetRootBackPress(); setIsSettingsOpen(true); @@ -483,6 +597,8 @@ export function DriverWorkspace({ [ styles.logoutButton, @@ -533,15 +649,17 @@ 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} + onSequenceSavingChange={changeSequenceSaving} onSaveDestinationNotes={saveDestinationNotes} + onSaveDeliveryOrder={saveDeliveryOrder} orders={orders} refreshing={isRefreshingRoute} serverRouteGeometry={route.serverRouteGeometry} @@ -589,6 +707,7 @@ export function DriverWorkspace({ > } + disabled={isSequenceSaving} isSelected={activeTab === 'delivery'} label="배송" onPress={() => { @@ -607,6 +726,7 @@ export function DriverWorkspace({ ⌖ } + disabled={isSequenceSaving} isSelected={activeTab === 'map'} label="지도" onPress={() => { @@ -658,6 +778,7 @@ function completedRouteFromHistory( routeId: summary.routePlanId, routeName: summary.routeName, routePlanId: summary.routePlanId, + routeVersionId: null, serverRouteGeometry: null, timezone: summary.timezone, }; @@ -963,11 +1084,13 @@ function RouteLoadState({ } function TabButton({ + disabled, icon, isSelected, label, onPress, }: { + disabled: boolean; icon: ReactNode; isSelected: boolean; label: string; @@ -976,7 +1099,8 @@ function TabButton({ return ( [ styles.tabButton,