From 705dd4202a317a3f9a3fe69751dbedd7e980d3c1 Mon Sep 17 00:00:00 2001 From: damingishere-coder Date: Sat, 29 Aug 2026 18:33:45 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=A4=9A?= =?UTF-8?q?=E9=80=89=E6=97=A5=E6=9C=9F=E8=AE=A2=E5=8D=95=E6=8E=92=E7=8F=AD?= =?UTF-8?q?=E4=B8=8E=E5=9C=B0=E5=9B=BE=E9=94=9A=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/customers.py | 62 ++++ backend/app/api/intake.py | 107 +++++- backend/app/api/orders.py | 56 +++ backend/app/api/plans.py | 133 ++++--- backend/app/schemas/intake.py | 55 ++- backend/app/schemas/location.py | 55 +++ backend/app/schemas/plan.py | 15 + backend/app/services/intake.py | 53 ++- backend/app/services/manual_locations.py | 324 ++++++++++++++++++ backend/app/services/order_locations.py | 76 ++-- backend/app/services/orders.py | 14 + backend/app/services/plans.py | 1 + backend/tests/test_intake_api.py | 134 ++++++++ backend/tests/test_plan_api.py | 41 +++ backend/tests/test_plan_route_api.py | 205 ++++++++++- .../P26_INTAKE_ORDER_CALENDAR_AND_MAP_PIN.md | 64 ++++ frontend/package-lock.json | 99 ++++++ frontend/package.json | 2 + .../src/components/ui/CalendarMonthGrid.tsx | 47 +++ .../src/components/ui/MultiDateCalendar.tsx | 90 +++++ frontend/src/components/ui/calendarDates.ts | 41 +++ .../features/intake/AdminIntakePage.test.tsx | 59 ++++ .../src/features/intake/AdminIntakePage.tsx | 65 +++- frontend/src/features/intake/api.ts | 11 + frontend/src/features/intake/constants.ts | 22 +- frontend/src/features/intake/types.ts | 4 + frontend/src/features/orders/OrderForm.tsx | 35 +- .../features/orders/OrderScheduleCalendar.tsx | 97 ++++++ .../src/features/orders/OrdersPage.test.tsx | 37 +- frontend/src/features/orders/OrdersPage.tsx | 112 ++++-- .../features/plans/DailyPlansPage.test.tsx | 1 + .../src/features/plans/DailyPlansPage.tsx | 141 +++++++- frontend/src/features/plans/RouteMap.tsx | 38 +- .../src/features/plans/RouteWorkspace.tsx | 17 +- frontend/src/features/plans/api.test.ts | 64 ++++ frontend/src/features/plans/api.ts | 66 +++- .../src/features/plans/mapProvider.test.ts | 106 ++++++ frontend/src/features/plans/mapProvider.ts | 51 ++- frontend/src/features/plans/types.ts | 26 ++ frontend/src/pages/FillPage.test.tsx | 9 +- frontend/src/pages/FillPage.tsx | 38 +- frontend/src/test/setup.ts | 8 + 42 files changed, 2461 insertions(+), 220 deletions(-) create mode 100644 backend/app/schemas/location.py create mode 100644 backend/app/services/manual_locations.py create mode 100644 docs/tasks/P26_INTAKE_ORDER_CALENDAR_AND_MAP_PIN.md create mode 100644 frontend/src/components/ui/CalendarMonthGrid.tsx create mode 100644 frontend/src/components/ui/MultiDateCalendar.tsx create mode 100644 frontend/src/components/ui/calendarDates.ts create mode 100644 frontend/src/features/orders/OrderScheduleCalendar.tsx create mode 100644 frontend/src/features/plans/mapProvider.test.ts diff --git a/backend/app/api/customers.py b/backend/app/api/customers.py index fa92b52..de268ea 100644 --- a/backend/app/api/customers.py +++ b/backend/app/api/customers.py @@ -6,6 +6,8 @@ from sqlalchemy.orm import Session, selectinload from app.db.session import get_db +from app.maps import MapServices +from app.maps.factory import get_map_services from app.models.customer import Cat, Customer from app.models.order import Order, OrderCat from app.models.payment import Payment @@ -22,11 +24,18 @@ CustomerSummary, CustomerUpdate, ) +from app.schemas.location import CustomerLocationRestore, CustomerLocationUpdate, LocationUpdateRead from app.services.customers import build_customer +from app.services.manual_locations import ( + check_customer_location_concurrency, + update_customer_location, + verified_geocode, +) router = APIRouter(prefix="/api/admin/customers", tags=["admin-customers"]) DatabaseSession = Annotated[Session, Depends(get_db)] +MapServicesDependency = Annotated[MapServices, Depends(get_map_services)] GEOCODE_ADDRESS_FIELDS = {"community", "address", "building", "unit", "room"} @@ -174,6 +183,59 @@ def get_customer(customer_id: int, session: DatabaseSession) -> CustomerDetail: return _customer_detail(session, _load_customer(session, customer_id)) +@router.patch("/{customer_id}/location", response_model=LocationUpdateRead) +def patch_customer_location( + customer_id: int, + payload: CustomerLocationUpdate, + session: DatabaseSession, +) -> LocationUpdateRead: + result = update_customer_location( + session, + customer_id=customer_id, + source_order_id=payload.source_order_id, + service_date=payload.service_date, + expected_customer_updated_at=payload.expected_customer_updated_at, + expected_day_revision=payload.expected_day_revision, + latitude=payload.latitude, + longitude=payload.longitude, + ) + session.commit() + return result + + +@router.post("/{customer_id}/location/restore-auto", response_model=LocationUpdateRead) +def restore_customer_location( + customer_id: int, + payload: CustomerLocationRestore, + session: DatabaseSession, + services: MapServicesDependency, +) -> LocationUpdateRead: + _, order, _ = check_customer_location_concurrency( + session, + customer_id=customer_id, + source_order_id=payload.source_order_id, + service_date=payload.service_date, + expected_customer_updated_at=payload.expected_customer_updated_at, + expected_day_revision=payload.expected_day_revision, + ) + result, provider_name = verified_geocode(order, services) + session.expire_all() + updated = update_customer_location( + session, + customer_id=customer_id, + source_order_id=payload.source_order_id, + service_date=payload.service_date, + expected_customer_updated_at=payload.expected_customer_updated_at, + expected_day_revision=payload.expected_day_revision, + latitude=result.point.latitude, + longitude=result.point.longitude, + automatic_result=result, + provider_name=provider_name, + ) + session.commit() + return updated + + @router.patch("/{customer_id}", response_model=CustomerDetail) def update_customer( customer_id: int, diff --git a/backend/app/api/intake.py b/backend/app/api/intake.py index 066fbcf..dc9cf6c 100644 --- a/backend/app/api/intake.py +++ b/backend/app/api/intake.py @@ -8,6 +8,7 @@ from app.db.session import get_db from app.models.enums import FormSubmissionStatus from app.models.intake import CustomerFormSubmission, CustomerFormToken +from app.models.system import SystemFlag from app.schemas.intake import ( IntakeClaimCommand, IntakeClaimRead, @@ -16,6 +17,7 @@ IntakeDecisionCommand, IntakeDecisionRead, IntakeDraftPayload, + IntakeListStateUpdate, IntakeRedactionRead, IntakeReviewDraftUpdate, IntakeSubmissionDetail, @@ -66,6 +68,7 @@ tags=["intake-relay-sync"], ) DatabaseSession = Annotated[Session, Depends(get_db)] +REMOVED_FLAG_PREFIX = "intake-list-removed:" def _token_options(): @@ -83,6 +86,49 @@ def _load_admin_token(session: Session, token_id: int) -> CustomerFormToken: return token +def _removed_key(submission_uuid: str) -> str: + return f"{REMOVED_FLAG_PREFIX}{submission_uuid}" + + +def _decorate_removed_state( + session: Session, + detail: IntakeSubmissionDetail, +) -> IntakeSubmissionDetail: + flag = session.get(SystemFlag, _removed_key(detail.submission_uuid)) + return detail.model_copy(update={"removed_at": flag.created_at if flag else None}) + + +def _decorate_removed_list( + session: Session, + response: IntakeSubmissionList, +) -> IntakeSubmissionList: + keys = [_removed_key(item.submission_uuid) for item in response.items] + flags = { + flag.key: flag + for flag in ( + session.scalars(select(SystemFlag).where(SystemFlag.key.in_(keys))).all() + if keys + else [] + ) + } + return response.model_copy( + update={ + "items": [ + item.model_copy( + update={ + "removed_at": ( + flags[_removed_key(item.submission_uuid)].created_at + if _removed_key(item.submission_uuid) in flags + else None + ) + } + ) + for item in response.items + ] + } + ) + + def _submission_options(): return ( selectinload(CustomerFormSubmission.token), @@ -235,9 +281,12 @@ def list_submissions( offset: int = Query(default=0, ge=0), ) -> IntakeSubmissionList: if remote_intake_enabled(): - return hydrate_remote_list( + return _decorate_removed_list( session, - RemoteIntakeClient().list_submissions(limit=limit, offset=offset), + hydrate_remote_list( + session, + RemoteIntakeClient().list_submissions(limit=limit, offset=offset), + ), ) status_filter = CustomerFormSubmission.status != FormSubmissionStatus.DRAFT total = session.scalar( @@ -254,9 +303,12 @@ def list_submissions( .limit(limit) .offset(offset) ).all() - return IntakeSubmissionList( - items=[to_submission_summary(item) for item in submissions], - total=total, + return _decorate_removed_list( + session, + IntakeSubmissionList( + items=[to_submission_summary(item) for item in submissions], + total=total, + ), ) @@ -273,11 +325,52 @@ def get_submission( session: DatabaseSession, ) -> IntakeSubmissionDetail: if remote_intake_enabled(): - return hydrate_remote_detail( + return _decorate_removed_state( + session, + hydrate_remote_detail( + session, + RemoteIntakeClient().get_submission(submission_id), + ), + ) + return _decorate_removed_state( + session, + to_submission_detail(_load_submission(session, submission_id)), + ) + + +@admin_router.patch( + "/submissions/{submission_id}/list-state", + response_model=IntakeSubmissionDetail, +) +def update_submission_list_state( + submission_id: int, + payload: IntakeListStateUpdate, + session: DatabaseSession, +) -> IntakeSubmissionDetail: + detail = ( + hydrate_remote_detail( session, RemoteIntakeClient().get_submission(submission_id), ) - return to_submission_detail(_load_submission(session, submission_id)) + if remote_intake_enabled() + else to_submission_detail(_load_submission(session, submission_id)) + ) + if detail.revision != payload.expected_revision: + raise HTTPException(status_code=409, detail="提交记录已变化,请刷新后重试") + voided = detail.status is FormSubmissionStatus.VOIDED or ( + detail.status is FormSubmissionStatus.REDACTED + and detail.decision_mode == "void" + ) + if payload.removed and not voided: + raise HTTPException(status_code=409, detail="只有已作废记录可以从列表移除") + key = _removed_key(detail.submission_uuid) + flag = session.get(SystemFlag, key) + if payload.removed and flag is None: + session.add(SystemFlag(key=key, payload={"removed": True})) + elif not payload.removed and flag is not None: + session.delete(flag) + session.commit() + return _decorate_removed_state(session, detail) @admin_router.put( diff --git a/backend/app/api/orders.py b/backend/app/api/orders.py index 02f373e..c8e5dcb 100644 --- a/backend/app/api/orders.py +++ b/backend/app/api/orders.py @@ -36,8 +36,14 @@ OrderWrite, OrderServiceContact, ) +from app.schemas.location import LocationUpdateRead, OrderLocationRestore, OrderLocationUpdate from app.services.order_customers import resolve_order_customer from app.services.order_locations import clear_order_location, geocode_order +from app.services.manual_locations import ( + check_order_location_concurrency, + update_order_location, + verified_geocode, +) from app.services.orders import ( DEFAULT_BASE_PRICE, EXTRA_CAT_UNIT_PRICE, @@ -553,6 +559,56 @@ def retry_order_geocode( return _order_detail(_load_order(session, order_id)) +@router.patch("/{order_id}/location", response_model=LocationUpdateRead) +def patch_order_location( + order_id: int, + payload: OrderLocationUpdate, + session: DatabaseSession, +) -> LocationUpdateRead: + result = update_order_location( + session, + order_id=order_id, + service_date=payload.service_date, + expected_order_updated_at=payload.expected_order_updated_at, + expected_day_revision=payload.expected_day_revision, + latitude=payload.latitude, + longitude=payload.longitude, + ) + session.commit() + return result + + +@router.post("/{order_id}/location/restore-auto", response_model=LocationUpdateRead) +def restore_order_location( + order_id: int, + payload: OrderLocationRestore, + session: DatabaseSession, + services: MapServicesDependency, +) -> LocationUpdateRead: + _, order = check_order_location_concurrency( + session, + order_id=order_id, + service_date=payload.service_date, + expected_order_updated_at=payload.expected_order_updated_at, + expected_day_revision=payload.expected_day_revision, + ) + result, provider_name = verified_geocode(order, services) + session.expire_all() + updated = update_order_location( + session, + order_id=order_id, + service_date=payload.service_date, + expected_order_updated_at=payload.expected_order_updated_at, + expected_day_revision=payload.expected_day_revision, + latitude=result.point.latitude, + longitude=result.point.longitude, + automatic_result=result, + provider_name=provider_name, + ) + session.commit() + return updated + + @router.put("/{order_id}", response_model=OrderDetail) def update_order( order_id: int, diff --git a/backend/app/api/plans.py b/backend/app/api/plans.py index 77552fb..f07162d 100644 --- a/backend/app/api/plans.py +++ b/backend/app/api/plans.py @@ -1,15 +1,15 @@ from datetime import date from typing import Annotated -from fastapi import APIRouter, Depends -from sqlalchemy import distinct, func, select -from sqlalchemy.orm import Session +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import select +from sqlalchemy.orm import Session, selectinload from app.db.session import get_db from app.maps import MapServices from app.maps.factory import get_map_services -from app.models.order import Order, OrderCat -from app.models.enums import TaskStatus +from app.models.order import Order +from app.models.enums import OrderStatus, TaskStatus from app.models.task import Task from app.schemas.plan import ( DayPlanResponse, @@ -18,6 +18,7 @@ PlanCustomerDetail, PlanCustomerSummary, PlanDaySummary, + PlanDayOrderMarker, PlanDaysResponse, PlanRoutePreviewRequest, PlanRouteWorkspace, @@ -28,6 +29,7 @@ PlanTaskSummary, ) from app.services.orders import order_display_address, task_has_execution_history +from app.services.manual_locations import location_impact from app.services.plan_routes import load_route_workspace, preview_day_route from app.services.plans import ( apply_day_schedule, @@ -101,8 +103,11 @@ def _day_plan(service_date: date, tasks: list[Task]) -> DayPlanResponse: ) -def _task_detail(task: Task, day_tasks: list[Task]) -> PlanTaskDetail: +def _task_detail(session: Session, task: Task, day_tasks: list[Task]) -> PlanTaskDetail: order = task.order + location_scope, sync_order_count, sync_task_count, customer_updated_at = ( + location_impact(session, order) + ) return PlanTaskDetail( task=_task_summary(task), day_revision=day_plan_revision(day_tasks), @@ -114,6 +119,7 @@ def _task_detail(task: Task, day_tasks: list[Task]) -> PlanTaskDetail: building=order.contact_building, unit=order.contact_unit, room=order.contact_room, + updated_at=customer_updated_at, ), cats=( [ @@ -148,66 +154,83 @@ def _task_detail(task: Task, day_tasks: list[Task]) -> PlanTaskDetail: task_notes=task.notes, estimated_arrival=task.estimated_arrival, photo_count=len(task.photos), + order_updated_at=order.updated_at, + route_geocode_status=order.route_geocode_status, + current_position=( + {"latitude": float(order.route_latitude), "longitude": float(order.route_longitude)} + if order.route_latitude is not None and order.route_longitude is not None + else None + ), + location_scope=location_scope, + location_sync_order_count=sync_order_count, + location_sync_task_count=sync_task_count, ) -@router.get("/days", response_model=PlanDaysResponse) -def list_plan_days(session: DatabaseSession) -> PlanDaysResponse: - rows = session.execute( - select( - Task.service_date, - func.count(distinct(Task.id)), - func.count(distinct(Task.order_id)), - func.count(distinct(OrderCat.cat_id)), - ) - .outerjoin(OrderCat, OrderCat.order_id == Task.order_id) - .where(Task.status != TaskStatus.CANCELLED) - .group_by(Task.service_date) - .order_by(Task.service_date) - ).all() - order_rows = session.execute( - select(Task.service_date, Task.order_id) - .where(Task.status != TaskStatus.CANCELLED) - .distinct() - ).all() - order_ids = {order_id for _, order_id in order_rows} - order_counts = { - order.id: order.cat_count - for order in session.scalars(select(Order).where(Order.id.in_(order_ids))).all() - } - cat_counts_by_date: dict[date, int] = {} - for service_date, order_id in order_rows: - cat_counts_by_date[service_date] = ( - cat_counts_by_date.get(service_date, 0) + order_counts.get(order_id, 0) - ) - customer_names_by_date: dict[date, list[str]] = {} - name_rows = session.execute( - select(Task.service_date, Order.contact_name) +@router.get("/days", response_model=PlanDaysResponse, response_model_exclude_none=True) +def list_plan_days( + session: DatabaseSession, + date_from: date | None = Query(default=None), + date_to: date | None = Query(default=None), +) -> PlanDaysResponse: + if (date_from is None) != (date_to is None): + raise HTTPException(status_code=422, detail="date_from 和 date_to 必须成对提供") + if date_from is not None and date_to is not None: + if date_to < date_from: + raise HTTPException(status_code=422, detail="date_to 不能早于 date_from") + if (date_to - date_from).days > 61: + raise HTTPException(status_code=422, detail="日期范围最多 62 天") + statement = ( + select(Task) .join(Order, Order.id == Task.order_id) - .where(Task.status != TaskStatus.CANCELLED) + .options(selectinload(Task.order)) + .where( + Task.status != TaskStatus.CANCELLED, + Order.order_status != OrderStatus.CANCELLED, + ) .order_by(Task.service_date, Task.sort_order, Task.id) - ).all() - for service_date, customer_name in name_rows: - names = customer_names_by_date.setdefault(service_date, []) - if customer_name not in names: - names.append(customer_name) - items = [ - PlanDaySummary( - service_date=service_date, - task_count=int(task_count), - order_count=int(order_count), - cat_count=cat_counts_by_date.get(service_date, int(cat_count)), - customer_names=customer_names_by_date.get(service_date, []), + ) + if date_from is not None and date_to is not None: + statement = statement.where(Task.service_date.between(date_from, date_to)) + tasks = list(session.scalars(statement).unique().all()) + tasks_by_date: dict[date, list[Task]] = {} + for task in tasks: + tasks_by_date.setdefault(task.service_date, []).append(task) + items: list[PlanDaySummary] = [] + for service_date, day_tasks in tasks_by_date.items(): + order_tasks: dict[int, list[Task]] = {} + for task in day_tasks: + order_tasks.setdefault(task.order_id, []).append(task) + orders = [entries[0].order for entries in order_tasks.values()] + items.append( + PlanDaySummary( + service_date=service_date, + task_count=len(day_tasks), + order_count=len(order_tasks), + cat_count=sum(order.cat_count for order in orders), + customer_names=list(dict.fromkeys(order.contact_name for order in orders)), + orders=( + [ + PlanDayOrderMarker( + order_id=order_id, + customer_name=entries[0].order.contact_name, + visit_count=len(entries), + order_status=entries[0].order.order_status, + ) + for order_id, entries in order_tasks.items() + ] + if date_from is not None + else None + ), + ) ) - for service_date, task_count, order_count, cat_count in rows - ] return PlanDaysResponse(items=items, total=len(items)) @router.get("/tasks/{task_id}", response_model=PlanTaskDetail) def get_plan_task(task_id: int, session: DatabaseSession) -> PlanTaskDetail: task = load_plan_task(session, task_id) - return _task_detail(task, load_day_tasks(session, task.service_date)) + return _task_detail(session, task, load_day_tasks(session, task.service_date)) @router.get("/{service_date}/route", response_model=PlanRouteWorkspace) @@ -251,7 +274,7 @@ def change_plan_task_status( expected_revision=payload.expected_revision, task_status=payload.task_status, ) - return _task_detail(task, load_day_tasks(session, task.service_date)) + return _task_detail(session, task, load_day_tasks(session, task.service_date)) @router.get("/{service_date}", response_model=DayPlanResponse) diff --git a/backend/app/schemas/intake.py b/backend/app/schemas/intake.py index 8faa68e..bf04bda 100644 --- a/backend/app/schemas/intake.py +++ b/backend/app/schemas/intake.py @@ -52,6 +52,7 @@ class IntakeCatDraft(IntakeModel): class IntakeServiceDraft(IntakeModel): + service_dates: list[date] | None = None start_date: date | None = None end_date: date | None = None visits_per_day: int | None = Field(default=None, ge=1, le=10) @@ -65,8 +66,26 @@ def deduplicate_service_items( ) -> list[TaskItemType]: return list(dict.fromkeys(service_items)) + @field_validator("service_dates") + @classmethod + def normalize_service_dates( + cls, + service_dates: list[date] | None, + ) -> list[date] | None: + if service_dates is None: + return None + normalized = sorted(set(service_dates)) + if len(normalized) > 366: + raise ValueError("服务日期最多 366 天") + return normalized + @model_validator(mode="after") def validate_optional_date_range(self) -> Self: + if self.service_dates is not None and any( + value is not None + for value in (self.start_date, self.end_date, self.visits_per_day) + ): + raise ValueError("服务日期列表不能与旧日期区间同时填写") if self.start_date and self.end_date: if self.end_date < self.start_date: raise ValueError("结束日期不能早于开始日期") @@ -114,12 +133,31 @@ class PublicIntakeCatDraft(IntakeModel): class PublicIntakeServiceDraft(IntakeModel): + service_dates: list[date] | None = None start_date: date | None = None end_date: date | None = None visits_per_day: int | None = Field(default=None, ge=1, le=10) + @field_validator("service_dates") + @classmethod + def normalize_service_dates( + cls, + service_dates: list[date] | None, + ) -> list[date] | None: + if service_dates is None: + return None + normalized = sorted(set(service_dates)) + if len(normalized) > 366: + raise ValueError("服务日期最多 366 天") + return normalized + @model_validator(mode="after") def validate_optional_date_range(self) -> Self: + if self.service_dates is not None and any( + value is not None + for value in (self.start_date, self.end_date, self.visits_per_day) + ): + raise ValueError("服务日期列表不能与旧日期区间同时填写") if self.start_date and self.end_date: if self.end_date < self.start_date: raise ValueError("结束日期不能早于开始日期") @@ -168,11 +206,16 @@ class IntakeOrderCustomer(IntakeCustomerSubmit): class IntakeServiceArchive(IntakeServiceDraft): - start_date: date - end_date: date - visits_per_day: int = Field(ge=1, le=10) service_items: list[TaskItemType] = Field(min_length=1, max_length=8) + @model_validator(mode="after") + def require_schedule(self) -> Self: + if self.service_dates: + return self + if self.start_date and self.end_date and self.visits_per_day: + return self + raise ValueError("请至少选择一个服务日期") + class IntakeSubmissionPayload(IntakeModel): customer: IntakeCustomerSubmit @@ -252,9 +295,11 @@ class IntakeSubmissionSummary(BaseModel): cat_count: int start_date: date | None end_date: date | None + service_dates: list[date] = Field(default_factory=list) submitted_at: datetime | None updated_at: datetime revision: str + removed_at: datetime | None = None class IntakeSubmissionList(BaseModel): @@ -310,6 +355,10 @@ class IntakeDecisionCommand(RevisionCommand): idempotency_key: str = Field(min_length=16, max_length=128) +class IntakeListStateUpdate(RevisionCommand): + removed: bool + + class IntakeDecisionRead(BaseModel): submission_id: int submission_uuid: str diff --git a/backend/app/schemas/location.py b/backend/app/schemas/location.py new file mode 100644 index 0000000..786099a --- /dev/null +++ b/backend/app/schemas/location.py @@ -0,0 +1,55 @@ +from datetime import date, datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field + +from app.schemas.plan import PlanGeoPoint + + +class LocationWriteModel(BaseModel): + model_config = ConfigDict(extra="forbid") + + +class CustomerLocationUpdate(LocationWriteModel): + latitude: float = Field(ge=-90, le=90) + longitude: float = Field(ge=-180, le=180) + coordinate_system: Literal["GCJ-02"] + source_order_id: int = Field(gt=0) + service_date: date + expected_customer_updated_at: datetime + expected_day_revision: str = Field(pattern=r"^[0-9a-f]{64}$") + + +class OrderLocationUpdate(LocationWriteModel): + latitude: float = Field(ge=-90, le=90) + longitude: float = Field(ge=-180, le=180) + coordinate_system: Literal["GCJ-02"] + service_date: date + expected_order_updated_at: datetime + expected_day_revision: str = Field(pattern=r"^[0-9a-f]{64}$") + + +class CustomerLocationRestore(LocationWriteModel): + source_order_id: int = Field(gt=0) + service_date: date + expected_customer_updated_at: datetime + expected_day_revision: str = Field(pattern=r"^[0-9a-f]{64}$") + + +class OrderLocationRestore(LocationWriteModel): + service_date: date + expected_order_updated_at: datetime + expected_day_revision: str = Field(pattern=r"^[0-9a-f]{64}$") + + +class LocationUpdateRead(BaseModel): + scope: Literal["customer", "order"] + customer_id: int | None + order_id: int + original_position: PlanGeoPoint | None + position: PlanGeoPoint + affected_orders: int = Field(ge=0) + affected_tasks: int = Field(ge=0) + customer_updated_at: datetime | None + order_updated_at: datetime + day_revision: str = Field(pattern=r"^[0-9a-f]{64}$") diff --git a/backend/app/schemas/plan.py b/backend/app/schemas/plan.py index af8e182..6636433 100644 --- a/backend/app/schemas/plan.py +++ b/backend/app/schemas/plan.py @@ -20,6 +20,7 @@ class PlanCustomerDetail(PlanCustomerSummary): building: str | None unit: str | None room: str | None + updated_at: datetime | None class PlanCatSummary(BaseModel): @@ -55,12 +56,20 @@ class PlanTaskSummary(BaseModel): has_execution_history: bool +class PlanDayOrderMarker(BaseModel): + order_id: int + customer_name: str + visit_count: int = Field(ge=1) + order_status: OrderStatus + + class PlanDaySummary(BaseModel): service_date: date task_count: int order_count: int cat_count: int customer_names: list[str] + orders: list[PlanDayOrderMarker] | None = None class PlanDaysResponse(BaseModel): @@ -89,6 +98,12 @@ class PlanTaskDetail(BaseModel): task_notes: str | None estimated_arrival: datetime | None photo_count: int + order_updated_at: datetime + route_geocode_status: str | None + current_position: "PlanGeoPoint | None" = None + location_scope: Literal["customer", "order"] + location_sync_order_count: int = Field(ge=0) + location_sync_task_count: int = Field(ge=0) class PlanScheduleItem(PlanWriteModel): diff --git a/backend/app/services/intake.py b/backend/app/services/intake.py index 02abfb7..ff95b2f 100644 --- a/backend/app/services/intake.py +++ b/backend/app/services/intake.py @@ -36,11 +36,11 @@ PublicIntakeRead, PublicIntakeSubmissionPayload, ) -from app.schemas.order import OrderWrite +from app.schemas.order import OrderCreate, OrderWrite from app.services.business_time import as_utc from app.services.credentials import token_digest from app.services.customers import build_customer -from app.services.orders import build_order, reprice_order +from app.services.orders import build_order, build_simple_order, reprice_order PUBLIC_TOKEN_BYTES = 16 @@ -316,6 +316,7 @@ def _public_payload_from_stored(payload: dict[str, object]) -> PublicIntakeDraft for cat in stored.cats ], "service": { + "service_dates": stored.service.service_dates, "start_date": stored.service.start_date, "end_date": stored.service.end_date, "visits_per_day": stored.service.visits_per_day, @@ -556,6 +557,7 @@ def to_submission_summary( cat_count=0, start_date=None, end_date=None, + service_dates=[], submitted_at=submission.token.submitted_at, updated_at=submission.updated_at, revision=submission_revision(submission), @@ -570,6 +572,7 @@ def to_submission_summary( cat_count=len(payload.cats), start_date=payload.service.start_date, end_date=payload.service.end_date, + service_dates=payload.service.service_dates or [], submitted_at=submission.token.submitted_at, updated_at=submission.updated_at, revision=submission_revision(submission), @@ -746,7 +749,7 @@ def _order_payload(submission: CustomerFormSubmission) -> IntakeOrderArchivePayl except ValidationError as exc: raise HTTPException( status_code=409, - detail="请补齐地址、猫咪、服务日期、每日次数和服务事项", + detail="请补齐地址、猫咪、服务日期和服务事项", ) from exc @@ -949,20 +952,36 @@ def archive_order_submission( changed_at=changed_at, ) customer, cats = _build_customer_cats(session, payload=payload) - order_payload = OrderWrite( - customer_id=customer.id, - cat_ids=[cat.id for cat in cats], - start_date=payload.service.start_date, - end_date=payload.service.end_date, - visits_per_day=payload.service.visits_per_day, - service_items=payload.service.service_items, - base_price=unit_price, - stairs_fee=Decimal("0.00"), - other_fee=Decimal("0.00"), - order_status=OrderStatus.CONFIRMED, - notes=payload.notes, - ) - order = build_order(order_payload, cats=cats, customer=customer) + if payload.service.service_dates: + order = build_simple_order( + OrderCreate( + source_customer_id=customer.id, + cat_count=len(cats), + service_dates=payload.service.service_dates, + service_items=payload.service.service_items, + unit_price=unit_price, + notes=payload.notes, + ), + source_customer=customer, + ) + else: + assert payload.service.start_date is not None + assert payload.service.end_date is not None + assert payload.service.visits_per_day is not None + order_payload = OrderWrite( + customer_id=customer.id, + cat_ids=[cat.id for cat in cats], + start_date=payload.service.start_date, + end_date=payload.service.end_date, + visits_per_day=payload.service.visits_per_day, + service_items=payload.service.service_items, + base_price=unit_price, + stairs_fee=Decimal("0.00"), + other_fee=Decimal("0.00"), + order_status=OrderStatus.CONFIRMED, + notes=payload.notes, + ) + order = build_order(order_payload, cats=cats, customer=customer) reprice_order(order, unit_price=unit_price) session.add(order) session.flush() diff --git a/backend/app/services/manual_locations.py b/backend/app/services/manual_locations.py new file mode 100644 index 0000000..9b04ecc --- /dev/null +++ b/backend/app/services/manual_locations.py @@ -0,0 +1,324 @@ +from datetime import date, datetime, timezone +from decimal import Decimal + +from fastapi import HTTPException +from sqlalchemy import select +from sqlalchemy.orm import Session, selectinload + +from app.maps import GeoPoint, GeocodeResult, MapProviderError, MapServices +from app.models.customer import Customer +from app.models.enums import OrderStatus +from app.models.order import Order +from app.models.task import Task +from app.schemas.location import LocationUpdateRead +from app.schemas.plan import PlanGeoPoint +from app.services.geocoding import ( + geocode_fingerprint, + geocode_result_matches_address, + normalized_geocode_address, +) +from app.services.orders import ( + customer_geocode_address, + order_geocode_address, + task_has_execution_history, +) +from app.services.plans import day_plan_revision, load_day_tasks, require_current_revision + + +def _order_options() -> tuple: + return ( + selectinload(Order.customer), + selectinload(Order.tasks).selectinload(Task.items), + selectinload(Order.tasks).selectinload(Task.photos), + ) + + +def load_location_order(session: Session, order_id: int) -> Order: + order = session.scalar( + select(Order).options(*_order_options()).where(Order.id == order_id) + ) + if order is None: + raise HTTPException(status_code=404, detail="订单不存在") + return order + + +def _same_moment(current: datetime, expected: datetime) -> bool: + def normalize(value: datetime) -> datetime: + if value.tzinfo is not None: + return value.astimezone(timezone.utc).replace(tzinfo=None) + return value + + return normalize(current) == normalize(expected) + + +def _check_day_revision(session: Session, service_date: date, expected: str) -> list[Task]: + tasks = load_day_tasks(session, service_date) + require_current_revision(tasks, expected) + return tasks + + +def _point( + latitude: float | Decimal | None, + longitude: float | Decimal | None, +) -> PlanGeoPoint | None: + if latitude is None or longitude is None: + return None + return PlanGeoPoint(latitude=float(latitude), longitude=float(longitude)) + + +def _eligible_order(order: Order, address: str) -> bool: + return ( + order.order_status not in {OrderStatus.CANCELLED, OrderStatus.COMPLETED} + and normalized_geocode_address(order_geocode_address(order) or "") + == normalized_geocode_address(address) + ) + + +def _apply_order_location( + order: Order, + *, + latitude: Decimal, + longitude: Decimal, + status: str, + fingerprint: str, + adcode: str | None, + level: str, +) -> int: + order.route_latitude = latitude + order.route_longitude = longitude + order.route_geocode_status = status + order.route_geocode_fingerprint = fingerprint + order.route_geocode_adcode = adcode + order.route_geocode_level = level + order.updated_at = datetime.now(timezone.utc) + affected_tasks = 0 + for task in order.tasks: + if not task_has_execution_history(task): + task.planned_lat = latitude + task.planned_lng = longitude + task.estimated_arrival = None + affected_tasks += 1 + return affected_tasks + + +def check_customer_location_concurrency( + session: Session, + *, + customer_id: int, + source_order_id: int, + service_date: date, + expected_customer_updated_at: datetime, + expected_day_revision: str, +) -> tuple[list[Task], Order, Customer]: + day_tasks = _check_day_revision(session, service_date, expected_day_revision) + source_order = load_location_order(session, source_order_id) + customer = session.scalar( + select(Customer) + .options(selectinload(Customer.orders)) + .where(Customer.id == customer_id) + ) + if customer is None: + raise HTTPException(status_code=404, detail="客户档案不存在") + if source_order.customer_id != customer.id: + raise HTTPException(status_code=409, detail="来源订单与客户档案不匹配") + source_address = order_geocode_address(source_order) + current_customer_address = customer_geocode_address(customer) + if ( + not source_address + or normalized_geocode_address(source_address) + != normalized_geocode_address(current_customer_address or "") + ): + raise HTTPException(status_code=409, detail="来源订单地址与客户当前地址不一致") + if not _same_moment(customer.updated_at, expected_customer_updated_at): + raise HTTPException(status_code=409, detail="客户档案已经变化,请刷新后再修改定位") + return day_tasks, source_order, customer + + +def check_order_location_concurrency( + session: Session, + *, + order_id: int, + service_date: date, + expected_order_updated_at: datetime, + expected_day_revision: str, +) -> tuple[list[Task], Order]: + day_tasks = _check_day_revision(session, service_date, expected_day_revision) + order = load_location_order(session, order_id) + if order.customer_id is not None: + raise HTTPException(status_code=409, detail="该订单已关联客户档案,请修改客户定位") + if not _same_moment(order.updated_at, expected_order_updated_at): + raise HTTPException(status_code=409, detail="订单已经变化,请刷新后再修改定位") + return day_tasks, order + + +def update_customer_location( + session: Session, + *, + customer_id: int, + source_order_id: int, + service_date: date, + expected_customer_updated_at: datetime, + expected_day_revision: str, + latitude: float, + longitude: float, + automatic_result: GeocodeResult | None = None, + provider_name: str = "manual", +) -> LocationUpdateRead: + day_tasks, source_order, customer = check_customer_location_concurrency( + session, + customer_id=customer_id, + source_order_id=source_order_id, + service_date=service_date, + expected_customer_updated_at=expected_customer_updated_at, + expected_day_revision=expected_day_revision, + ) + address = order_geocode_address(source_order) + if not address: + raise HTTPException(status_code=422, detail="订单缺少可定位地址") + original = _point(customer.latitude, customer.longitude) + lat = Decimal(str(latitude)) + lng = Decimal(str(longitude)) + status = "resolved" if automatic_result is not None else "manual" + fingerprint = geocode_fingerprint(provider_name, address) + adcode = automatic_result.adcode if automatic_result is not None else None + level = automatic_result.level if automatic_result is not None else "manual_pin" + customer.latitude = lat + customer.longitude = lng + customer.geocode_status = status + customer.geocode_fingerprint = fingerprint + customer.geocode_adcode = adcode + customer.geocode_level = level + customer.updated_at = datetime.now(timezone.utc) + + orders = list( + session.scalars( + select(Order) + .options(*_order_options()) + .where(Order.customer_id == customer.id) + ) + .unique() + .all() + ) + affected_orders = 0 + affected_tasks = 0 + for order in orders: + if not _eligible_order(order, address): + continue + affected_tasks += _apply_order_location( + order, + latitude=lat, + longitude=lng, + status=status, + fingerprint=fingerprint, + adcode=adcode, + level=level, + ) + affected_orders += 1 + session.flush() + return LocationUpdateRead( + scope="customer", + customer_id=customer.id, + order_id=source_order.id, + original_position=original, + position=PlanGeoPoint(latitude=latitude, longitude=longitude), + affected_orders=affected_orders, + affected_tasks=affected_tasks, + customer_updated_at=customer.updated_at, + order_updated_at=source_order.updated_at, + day_revision=day_plan_revision(day_tasks), + ) + + +def update_order_location( + session: Session, + *, + order_id: int, + service_date: date, + expected_order_updated_at: datetime, + expected_day_revision: str, + latitude: float, + longitude: float, + automatic_result: GeocodeResult | None = None, + provider_name: str = "manual", +) -> LocationUpdateRead: + day_tasks, order = check_order_location_concurrency( + session, + order_id=order_id, + service_date=service_date, + expected_order_updated_at=expected_order_updated_at, + expected_day_revision=expected_day_revision, + ) + address = order_geocode_address(order) + if not address: + raise HTTPException(status_code=422, detail="订单缺少可定位地址") + original = _point(order.route_latitude, order.route_longitude) + lat = Decimal(str(latitude)) + lng = Decimal(str(longitude)) + status = "resolved" if automatic_result is not None else "manual" + fingerprint = geocode_fingerprint(provider_name, address) + affected_tasks = _apply_order_location( + order, + latitude=lat, + longitude=lng, + status=status, + fingerprint=fingerprint, + adcode=automatic_result.adcode if automatic_result is not None else None, + level=automatic_result.level if automatic_result is not None else "manual_pin", + ) + session.flush() + return LocationUpdateRead( + scope="order", + customer_id=None, + order_id=order.id, + original_position=original, + position=PlanGeoPoint(latitude=latitude, longitude=longitude), + affected_orders=1, + affected_tasks=affected_tasks, + customer_updated_at=None, + order_updated_at=order.updated_at, + day_revision=day_plan_revision(day_tasks), + ) + + +def verified_geocode(order: Order, services: MapServices) -> tuple[GeocodeResult, str]: + address = order_geocode_address(order) + if not address: + raise HTTPException(status_code=422, detail="订单缺少可定位地址") + try: + result = services.geocode_provider.geocode(address) + except MapProviderError as exc: + raise HTTPException(status_code=409, detail="地址自动定位失败,已保留原手动锚点") from exc + if result is None or not geocode_result_matches_address(address, result): + raise HTTPException(status_code=409, detail="地址自动定位未通过验证,已保留原手动锚点") + return result, services.map_provider.provider_state().name + + +def location_impact(session: Session, order: Order) -> tuple[str, int, int, datetime | None]: + if order.customer_id is None: + return ( + "order", + 1, + sum(1 for task in order.tasks if not task_has_execution_history(task)), + None, + ) + customer = session.get(Customer, order.customer_id) + address = order_geocode_address(order) + if customer is None or not address: + return ("order", 1, 0, customer.updated_at if customer else None) + orders = list( + session.scalars( + select(Order).options(*_order_options()).where(Order.customer_id == customer.id) + ).unique().all() + ) + eligible = [candidate for candidate in orders if _eligible_order(candidate, address)] + return ( + "customer", + len(eligible), + sum( + 1 + for candidate in eligible + for task in candidate.tasks + if not task_has_execution_history(task) + ), + customer.updated_at, + ) diff --git a/backend/app/services/order_locations.py b/backend/app/services/order_locations.py index b4e3b6d..c885d46 100644 --- a/backend/app/services/order_locations.py +++ b/backend/app/services/order_locations.py @@ -6,35 +6,18 @@ from app.maps import GeoPoint, GeocodeResult, MapProviderError, MapServices from app.models.order import Order from app.models.task import Task -from app.services.geocoding import geocode_fingerprint, geocode_result_matches_address +from app.services.geocoding import ( + geocode_fingerprint, + geocode_result_matches_address, + normalized_geocode_address, +) from app.services.orders import ( - default_geocode_service_area, + customer_geocode_address, order_geocode_address, task_has_execution_history, ) -def _normalized(value: str | None) -> str: - return " ".join((value or "").strip().casefold().split()) - - -def _customer_address(order: Order) -> str | None: - if order.customer is None: - return None - parts = [ - order.customer.address, - order.customer.community, - order.customer.building, - ] - values: list[str] = [] - for part in parts: - value = " ".join((part or "").strip().split()) - if value and not any(_normalized(value) in _normalized(item) for item in values): - values = [item for item in values if _normalized(item) not in _normalized(value)] - values.append(value) - return " ".join(values) or None - - def clear_order_location(order: Order) -> None: order.route_latitude = None order.route_longitude = None @@ -50,6 +33,20 @@ def clear_order_location(order: Order) -> None: def trusted_order_point(order: Order, services: MapServices) -> GeoPoint | None: address = order_geocode_address(order) + if ( + address + and order.route_geocode_status == "manual" + and order.route_geocode_fingerprint == geocode_fingerprint("manual", address) + and order.route_latitude is not None + and order.route_longitude is not None + ): + try: + return GeoPoint( + latitude=float(order.route_latitude), + longitude=float(order.route_longitude), + ) + except (TypeError, ValueError): + return None state = services.map_provider.provider_state() expected_fingerprints = ( {geocode_fingerprint(state.name, address)} if address else set() @@ -112,8 +109,14 @@ def _apply_geocode_result( task.planned_lat = latitude task.planned_lng = longitude - customer_address = default_geocode_service_area(_customer_address(order)) - if order.customer is not None and _normalized(customer_address) == _normalized(address): + customer_address = ( + customer_geocode_address(order.customer) if order.customer is not None else None + ) + if ( + order.customer is not None + and normalized_geocode_address(customer_address or "") + == normalized_geocode_address(address) + ): order.customer.latitude = latitude order.customer.longitude = longitude order.customer.geocode_status = "resolved" @@ -128,6 +131,7 @@ def geocode_order( services: MapServices, *, raise_provider_errors: bool = False, + prefer_customer_manual: bool = True, ) -> str: """Best-effort address-only geocoding after the order transaction succeeds.""" @@ -144,6 +148,28 @@ def geocode_order( session.commit() return "missing" + customer = order.customer + if ( + prefer_customer_manual + and customer is not None + and customer.geocode_status == "manual" + and customer.geocode_fingerprint == geocode_fingerprint("manual", address) + and customer.latitude is not None + and customer.longitude is not None + ): + order.route_latitude = customer.latitude + order.route_longitude = customer.longitude + order.route_geocode_status = "manual" + order.route_geocode_fingerprint = customer.geocode_fingerprint + order.route_geocode_adcode = None + order.route_geocode_level = "manual_pin" + for task in order.tasks: + if not task_has_execution_history(task): + task.planned_lat = customer.latitude + task.planned_lng = customer.longitude + session.commit() + return "manual" + try: result = services.geocode_provider.geocode(address) except MapProviderError: diff --git a/backend/app/services/orders.py b/backend/app/services/orders.py index 73264d5..1134a75 100644 --- a/backend/app/services/orders.py +++ b/backend/app/services/orders.py @@ -361,6 +361,20 @@ def order_geocode_address(order: Order) -> str | None: ) +def customer_geocode_address(customer: Customer) -> str | None: + """Return the customer's current routable address using the order rules.""" + + return default_geocode_service_area( + _deduplicated_address( + [ + _routable_address_part(customer.address), + customer.community, + customer.building, + ] + ) + ) + + DEFAULT_MAP_CITY = "深圳市" DEFAULT_MAP_DISTRICT = "龙岗区" _CITY_PATTERN = re.compile(r"(?:^|省|\s)([^省区县乡镇街道路\s]{2,8}市)") diff --git a/backend/app/services/plans.py b/backend/app/services/plans.py index 972a055..4289620 100644 --- a/backend/app/services/plans.py +++ b/backend/app/services/plans.py @@ -155,6 +155,7 @@ def apply_day_schedule( task = tasks_by_id[item.task_id] task.sort_order = sort_order task.planned_time = item.planned_time + task.estimated_arrival = None session.commit() return load_day_tasks(session, service_date) diff --git a/backend/tests/test_intake_api.py b/backend/tests/test_intake_api.py index 9eb1557..839c8f4 100644 --- a/backend/tests/test_intake_api.py +++ b/backend/tests/test_intake_api.py @@ -11,6 +11,7 @@ from app.db.session import build_engine, get_db from app.main import app from app.models import Cat, Customer, CustomerFormSubmission, CustomerFormToken, Order, Task +from app.models.system import SystemFlag from app.models.enums import FormSubmissionStatus, FormTokenStatus from app.services.privacy_logging import FillTokenRedactionFilter from app.services.credentials import token_digest @@ -546,6 +547,139 @@ def test_submission_list_supports_pagination( assert len(page.json()["items"]) == 2 +def test_sparse_service_dates_are_canonical_and_create_one_task_per_selected_day( + intake_api_context: IntakeApiContext, +) -> None: + client = intake_api_context.client + token = create_token(client) + payload = complete_payload( + service={ + "service_dates": ["2031-04-07", "2031-04-01", "2031-04-07"], + } + ) + submitted = client.post( + f"/api/fill/{token_value(token)}/submit", + json={ + "expected_revision": token["revision"], + "idempotency_key": "sparse-service-dates-submit-0001", + "payload": payload, + }, + ) + assert submitted.status_code == 200 + summary = max( + client.get("/api/admin/intake/submissions").json()["items"], + key=lambda item: item["id"], + ) + assert summary["service_dates"] == ["2031-04-01", "2031-04-07"] + detail = client.get(f"/api/admin/intake/submissions/{summary['id']}").json() + review_payload = detail["payload"] + review_payload["service"]["service_items"] = ["feed", "photo"] + reviewed = client.put( + f"/api/admin/intake/submissions/{summary['id']}/review-draft", + json={ + "review_payload": review_payload, + "unit_price": "40.00", + "expected_revision": detail["revision"], + }, + ).json() + archived = client.post( + f"/api/admin/intake/submissions/{summary['id']}/archive-order", + json={ + "expected_revision": reviewed["revision"], + "idempotency_key": "sparse-service-dates-archive-0001", + }, + ) + assert archived.status_code == 200 + with intake_api_context.session_factory() as session: + order = session.get(Order, archived.json()["order_id"]) + assert order is not None + task_dates = sorted(task.service_date.isoformat() for task in order.tasks) + assert task_dates == ["2031-04-01", "2031-04-07"] + assert order.total_amount == 80 + + mixed_token = create_token(client) + mixed = client.post( + f"/api/fill/{token_value(mixed_token)}/submit", + json={ + "expected_revision": mixed_token["revision"], + "idempotency_key": "mixed-service-date-format-0001", + "payload": complete_payload( + service={ + "service_dates": ["2031-04-01"], + "start_date": "2031-04-01", + "end_date": "2031-04-01", + "visits_per_day": 1, + } + ), + }, + ) + assert mixed.status_code == 422 + + duplicate_token = create_token(client) + duplicate_dates = client.post( + f"/api/fill/{token_value(duplicate_token)}/submit", + json={ + "expected_revision": duplicate_token["revision"], + "idempotency_key": "dedupe-before-service-date-limit-0001", + "payload": complete_payload( + service={"service_dates": ["2031-04-01"] * 367} + ), + }, + ) + assert duplicate_dates.status_code == 200 + + too_many_token = create_token(client) + too_many_dates = [ + (datetime(2031, 1, 1) + timedelta(days=offset)).date().isoformat() + for offset in range(367) + ] + rejected = client.post( + f"/api/fill/{token_value(too_many_token)}/submit", + json={ + "expected_revision": too_many_token["revision"], + "idempotency_key": "too-many-service-dates-0001", + "payload": complete_payload(service={"service_dates": too_many_dates}), + }, + ) + assert rejected.status_code == 422 + + +def test_voided_submission_can_be_removed_and_restored_without_deletion( + intake_api_context: IntakeApiContext, +) -> None: + client = intake_api_context.client + _, summary = submit_for_review(client) + voided = client.post( + f"/api/admin/intake/submissions/{summary['id']}/void", + json={ + "expected_revision": summary["revision"], + "idempotency_key": "list-state-void-0001", + }, + ) + assert voided.status_code == 200 + detail = client.get(f"/api/admin/intake/submissions/{summary['id']}").json() + removed = client.patch( + f"/api/admin/intake/submissions/{summary['id']}/list-state", + json={"removed": True, "expected_revision": detail["revision"]}, + ) + assert removed.status_code == 200 + assert removed.json()["removed_at"] is not None + listed = client.get("/api/admin/intake/submissions").json()["items"] + assert next(item for item in listed if item["id"] == summary["id"])["removed_at"] + with intake_api_context.session_factory() as session: + assert session.get(CustomerFormSubmission, summary["id"]) is not None + flag = session.get(SystemFlag, f"intake-list-removed:{summary['submission_uuid']}") + assert flag is not None + assert flag.payload == {"removed": True} + + restored = client.patch( + f"/api/admin/intake/submissions/{summary['id']}/list-state", + json={"removed": False, "expected_revision": detail["revision"]}, + ) + assert restored.status_code == 200 + assert restored.json()["removed_at"] is None + + def test_customer_only_archive_and_void_are_idempotent( intake_api_context: IntakeApiContext, ) -> None: diff --git a/backend/tests/test_plan_api.py b/backend/tests/test_plan_api.py index 768fdcc..bc3fb5b 100644 --- a/backend/tests/test_plan_api.py +++ b/backend/tests/test_plan_api.py @@ -201,11 +201,16 @@ def test_plan_days_and_task_detail_follow_privacy_boundaries( "building", "unit", "room", + "updated_at", } assert detail["customer"]["address"] == "虚构路 100 号" assert detail["cats"][0]["service_notes"] == "虚构服务注意事项" assert detail["order_notes"] == "虚构 P4 订单备注" assert detail["photo_count"] == 0 + assert detail["location_scope"] == "customer" + assert detail["location_sync_order_count"] == 1 + assert detail["location_sync_task_count"] == 4 + assert detail["order_updated_at"] for forbidden_key in ( "phone", "wechat_name", @@ -226,6 +231,42 @@ def test_plan_days_and_task_detail_follow_privacy_boundaries( assert client.get("/api/admin/plans/tasks/999999").status_code == 404 +def test_plan_days_range_returns_order_markers_and_validates_bounds( + plan_api_context: PlanApiContext, +) -> None: + client = plan_api_context.client + first_order, second_order = create_three_task_plan(client) + + response = client.get( + "/api/admin/plans/days", + params={"date_from": "2033-10-01", "date_to": "2033-10-02"}, + ) + assert response.status_code == 200 + days = response.json()["items"] + assert days[0]["orders"] == [ + { + "order_id": first_order["id"], + "customer_name": "P4 第一位虚构客户", + "visit_count": 2, + "order_status": "confirmed", + }, + { + "order_id": second_order["id"], + "customer_name": "P4 第二位虚构客户", + "visit_count": 1, + "order_status": "confirmed", + }, + ] + assert days[1]["orders"][0]["visit_count"] == 2 + assert client.get( + "/api/admin/plans/days", params={"date_from": "2033-10-01"} + ).status_code == 422 + assert client.get( + "/api/admin/plans/days", + params={"date_from": "2033-01-01", "date_to": "2033-03-04"}, + ).status_code == 422 + + def test_schedule_save_is_atomic_persistent_and_revision_protected( plan_api_context: PlanApiContext, ) -> None: diff --git a/backend/tests/test_plan_route_api.py b/backend/tests/test_plan_route_api.py index 65380c3..c49f80d 100644 --- a/backend/tests/test_plan_route_api.py +++ b/backend/tests/test_plan_route_api.py @@ -21,7 +21,9 @@ ) from app.maps.factory import UnavailableMapProvider, get_map_services from app.models import Customer, Order, Task -from tests.test_plan_api import PlanApiContext, create_three_task_plan +from app.services.geocoding import geocode_fingerprint +from app.services.orders import order_geocode_address +from tests.test_plan_api import PlanApiContext, create_order, create_three_task_plan @pytest.fixture @@ -219,6 +221,207 @@ def test_order_save_geocodes_before_explicit_round_trip_preview( task["id"] for task in adopted_response.json()["tasks"] ] == optimized_task_ids + +def test_manual_customer_pin_syncs_only_unexecuted_tasks_and_restore_is_fail_safe( + plan_api_context: PlanApiContext, + fake_map_provider: FakeMapProvider, +) -> None: + client = plan_api_context.client + first_order, _ = create_three_task_plan(client) + fake_map_provider.geocode_results.extend( + [ + GeocodeResult(GeoPoint(30.11, 120.11), "深圳市", "龙岗区", "440307", "门牌号"), + GeocodeResult(GeoPoint(30.12, 120.12), "深圳市", "龙岗区", "440307", "门牌号"), + ] + ) + cat_ids = [cat["id"] for cat in first_order["cats"]] + same_address_order = create_order( + client, + customer_id=first_order["customer"]["id"], + cat_ids=cat_ids, + start_date="2033-10-03", + end_date="2033-10-03", + visits_per_day=1, + ) + different_address_order = create_order( + client, + customer_id=first_order["customer"]["id"], + cat_ids=cat_ids, + start_date="2033-10-04", + end_date="2033-10-04", + visits_per_day=1, + ) + with plan_api_context.session_factory.begin() as session: + different = session.get(Order, different_address_order["id"]) + assert different is not None + different.contact_address = "深圳市龙岗区虚构路 999 号" + different.route_latitude = Decimal("22.700001") + different.route_longitude = Decimal("114.700002") + for task in different.tasks: + task.planned_lat = different.route_latitude + task.planned_lng = different.route_longitude + day_one = client.get("/api/admin/plans/2033-10-01").json() + detail = client.get( + f"/api/admin/plans/tasks/{day_one['tasks'][0]['id']}" + ).json() + assert detail["location_scope"] == "customer" + + with plan_api_context.session_factory.begin() as session: + historical = session.scalar( + select(Task).where( + Task.order_id == first_order["id"], + Task.service_date == datetime(2033, 10, 2).date(), + ) + ) + assert historical is not None + historical.started_at = datetime(2033, 10, 2, 9, 0, tzinfo=timezone.utc) + historical_original = (historical.planned_lat, historical.planned_lng) + + day_one = client.get("/api/admin/plans/2033-10-01").json() + detail = client.get( + f"/api/admin/plans/tasks/{day_one['tasks'][0]['id']}" + ).json() + endpoint = f"/api/admin/customers/{detail['customer']['id']}/location" + payload = { + "latitude": 22.610001, + "longitude": 114.050002, + "coordinate_system": "GCJ-02", + "source_order_id": first_order["id"], + "service_date": "2033-10-01", + "expected_customer_updated_at": detail["customer"]["updated_at"], + "expected_day_revision": day_one["revision"], + } + wrong_coordinate_system = client.patch( + endpoint, + json={**payload, "coordinate_system": "WGS-84"}, + ) + assert wrong_coordinate_system.status_code == 422 + response = client.patch(endpoint, json=payload) + assert response.status_code == 200 + result = response.json() + assert result["scope"] == "customer" + assert result["affected_orders"] == 2 + assert result["affected_tasks"] == 4 + assert result["position"] == {"latitude": 22.610001, "longitude": 114.050002} + + with plan_api_context.session_factory() as session: + order = session.get(Order, first_order["id"]) + assert order is not None + address = order_geocode_address(order) + assert address + assert order.route_geocode_status == "manual" + assert order.route_geocode_fingerprint == geocode_fingerprint( + "manual", address + ) + current_historical = session.get(Task, historical.id) + assert current_historical is not None + assert ( + current_historical.planned_lat, + current_historical.planned_lng, + ) == historical_original + changed_tasks = session.scalars( + select(Task).where( + Task.order_id == first_order["id"], + Task.id != historical.id, + ) + ).all() + assert all( + float(task.planned_lat or 0) == pytest.approx(22.610001) + for task in changed_tasks + ) + assert all( + float(task.planned_lng or 0) == pytest.approx(114.050002) + for task in changed_tasks + ) + same_address = session.get(Order, same_address_order["id"]) + assert same_address is not None + assert float(same_address.route_latitude or 0) == pytest.approx(22.610001) + different_address = session.get(Order, different_address_order["id"]) + assert different_address is not None + assert float(different_address.route_latitude or 0) == pytest.approx(22.700001) + assert all( + float(task.planned_lng or 0) == pytest.approx(114.700002) + for task in different_address.tasks + ) + + route = client.get("/api/admin/plans/2033-10-01/route").json() + first_order_markers = [ + marker for marker in route["markers"] if marker["task_id"] in { + task["id"] for task in day_one["tasks"] if task["order_id"] == first_order["id"] + } + ] + assert first_order_markers + assert all(marker["position"] == result["position"] for marker in first_order_markers) + assert client.patch(endpoint, json=payload).status_code == 409 + + geocode_calls_before_inherited_order = len(fake_map_provider.geocode_calls) + inherited_order = create_order( + client, + customer_id=first_order["customer"]["id"], + cat_ids=cat_ids, + start_date="2033-10-05", + end_date="2033-10-05", + visits_per_day=1, + ) + assert len(fake_map_provider.geocode_calls) == geocode_calls_before_inherited_order + with plan_api_context.session_factory() as session: + inherited = session.get(Order, inherited_order["id"]) + assert inherited is not None + assert inherited.route_geocode_status == "manual" + assert float(inherited.route_latitude or 0) == pytest.approx(22.610001) + + latest_day = client.get("/api/admin/plans/2033-10-01").json() + latest_detail = client.get( + f"/api/admin/plans/tasks/{latest_day['tasks'][0]['id']}" + ).json() + geocode_calls_before_stale_restore = len(fake_map_provider.geocode_calls) + stale_restore = client.post( + f"/api/admin/customers/{latest_detail['customer']['id']}/location/restore-auto", + json={ + "source_order_id": first_order["id"], + "service_date": "2033-10-01", + "expected_customer_updated_at": detail["customer"]["updated_at"], + "expected_day_revision": latest_day["revision"], + }, + ) + assert stale_restore.status_code == 409 + assert len(fake_map_provider.geocode_calls) == geocode_calls_before_stale_restore + + next_geocode_index = len(fake_map_provider.geocode_calls) + fake_map_provider.geocode_results[next_geocode_index] = None + restore = client.post( + f"/api/admin/customers/{latest_detail['customer']['id']}/location/restore-auto", + json={ + "source_order_id": first_order["id"], + "service_date": "2033-10-01", + "expected_customer_updated_at": latest_detail["customer"]["updated_at"], + "expected_day_revision": latest_day["revision"], + }, + ) + assert restore.status_code == 409 + with plan_api_context.session_factory() as session: + order = session.get(Order, first_order["id"]) + assert order is not None + assert order.route_geocode_status == "manual" + assert float(order.route_latitude or 0) == pytest.approx(22.610001) + + changed_customer = client.patch( + f"/api/admin/customers/{latest_detail['customer']['id']}", + json={"address": "深圳市龙岗区另一条虚构路 1 号"}, + ) + assert changed_customer.status_code == 200 + with plan_api_context.session_factory() as session: + customer = session.get(Customer, latest_detail["customer"]["id"]) + assert customer is not None + assert customer.geocode_status == "pending" + assert customer.latitude is None + assert customer.geocode_fingerprint is None + historical_order = session.get(Order, first_order["id"]) + assert historical_order is not None + assert historical_order.route_geocode_status == "manual" + assert float(historical_order.route_latitude or 0) == pytest.approx(22.610001) + + def test_route_preview_caches_order_coordinates_and_ignores_later_profile_changes( plan_api_context: PlanApiContext, fake_map_provider: FakeMapProvider, diff --git a/docs/tasks/P26_INTAKE_ORDER_CALENDAR_AND_MAP_PIN.md b/docs/tasks/P26_INTAKE_ORDER_CALENDAR_AND_MAP_PIN.md new file mode 100644 index 0000000..8cdff14 --- /dev/null +++ b/docs/tasks/P26_INTAKE_ORDER_CALENDAR_AND_MAP_PIN.md @@ -0,0 +1,64 @@ +# P26:客户多选日期、订单日历排班与地图锚点 + +## 背景 + +公网客户填写页的原生日期区间控件在微信/iPhone 中会越界,也无法表达隔天或每周数次等不连续日期。后台订单详情占据主区域,缺少按月查看订单和按地理路线拖拽排班的统一工作区;自动地理编码发生偏差时也没有手动修正入口。客户填写链接和已作废提交的列表还需要收敛展示。 + +## 目标 + +- 公网客户可用自绘日历多选不连续上门日期,也可暂不选择日期提交。 +- 后台审核兼容旧日期区间,新订单仅按选中日期生成一次上门任务。 +- 订单管理以月历和当天排班为主,订单详情改为次级面板。 +- 当天任务支持拖拽排序并与路线地图编号联动。 +- 管理员可在高德地图上点击或拖动锚点,修正客户及相同地址未执行任务的定位。 +- 已作废提交可从当前列表移除并恢复;生成链接默认仅展示最新三条。 + +## 允许修改范围 + +- `frontend/src/components/`、`frontend/src/features/`、`frontend/src/pages/`、前端测试与依赖锁文件。 +- `backend/app/api/`、`backend/app/schemas/`、`backend/app/services/`、相关模型读取逻辑与后端测试。 +- 本任务文档和必要的部署/接口说明。 + +## 禁止修改范围 + +- 不新增 Alembic 迁移,不修改现有数据库表结构。 +- 不修改公网访问白名单,不开放 `/admin` 或管理 API。 +- 不修改 `.env`、密钥、Cookie、真实客户数据、运行数据库或 NAS 文件。 +- 不重启 Alter 管理的服务,不执行 NAS 更新、发布或数据库迁移。 +- 不暂存或修改 `.codemap/`、`PROJECT_AUDIT.md`。 + +## 已确定实现要求 + +- 新 intake 日程使用排序去重后的 `service_dates`,最多 366 天;旧区间数据只读兼容,日历发生编辑后才切换为新语义。 +- 新日程每个日期一次;没有日期可以提交,但不能归档为订单。 +- 月历标记使用订单号,同一订单同日多次显示 `#订单号 ×N`,取消订单不显示。 +- 拖拽仅调整所选日期内的任务顺序,保存复用现有 schedule API 和 revision 校验。 +- 手动锚点只接受 GCJ-02;只改坐标,不改地址文字。客户主档、相同地址未取消/未完成订单及无执行历史任务同步更新,历史任务保持不变。 +- 手动来源使用现有 `geocode_status/geocode_fingerprint/geocode_level` 字段表达;地址改变后手动坐标失效。 +- 公网 Relay/Gateway 的 `service_dates` 协议必须同步,但真实更新需另行确认。 + +## 验收标准 + +- 公网填写页不存在原生日期输入框,390×844 与 430×932 无横向越界。 +- 非连续日期经审核归档后只生成对应日期任务,旧每天两次订单不丢失语义。 +- 订单月历、详情面板、拖拽列表和路线编号联动正确;锁定日与 revision 冲突不会被覆盖。 +- 点击/拖动地图锚点后,可信手动坐标按规则同步;自动恢复失败时保留原手动位置。 +- 已作废提交移除/恢复不改变原始提交;链接列表默认三条并可展开。 +- 前后端完整测试、lint、typecheck、普通构建和 public 构建通过。 + +## 测试命令 + +```powershell +npm --prefix frontend test -- --run +npm --prefix frontend run typecheck +npm --prefix frontend run lint +npm --prefix frontend run build +npm --prefix frontend run build:public +Set-Location backend +..\.venv\Scripts\python.exe -m pytest -q +``` + +## 返回格式 + +- 报告实际改动、测试命令和结果、浏览器验收结果、风险与未执行的部署动作。 +- 报告 Git 初始状态、任务分支、提交/远端 SHA、Push、PR 与 CI 状态。 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 13fdf1f..63d3fc9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,8 @@ "name": "catcare-hub-frontend", "version": "0.1.0", "dependencies": { + "@dnd-kit/helpers": "^0.5.0", + "@dnd-kit/react": "^0.5.0", "lucide-react": "1.33.0", "react": "19.2.8", "react-dom": "19.2.8", @@ -500,6 +502,87 @@ "node": ">=20.19.0" } }, + "node_modules/@dnd-kit/abstract": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/abstract/-/abstract-0.5.0.tgz", + "integrity": "sha512-hi13iMJgjPX/KDYVKg5VeDIhmYiV6buc9bAX+tCLYf4QdyYjPbsXjn2sPo6m7fQ6SGJBEFgHJ2PemeKDUbwBaA==", + "license": "MIT", + "dependencies": { + "@dnd-kit/geometry": "^0.5.0", + "@dnd-kit/state": "^0.5.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@dnd-kit/collision": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/collision/-/collision-0.5.0.tgz", + "integrity": "sha512-xUqRn3lS7oqLkT0AnnHS/STh/Czvwe1UapZFYiLbsUGxopMsQd4teaPCzPouOThoMdGEe+dHWjfqJl6t9iG4mQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/abstract": "^0.5.0", + "@dnd-kit/geometry": "^0.5.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@dnd-kit/dom": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/dom/-/dom-0.5.0.tgz", + "integrity": "sha512-f2xFJp5SYQ8EW/Fbtaa8iBb66hpkWc7qa8vU826KW11/tb44sH+AisZnGtwOOTWTQ0GraqBDr5ixTErww+eKXw==", + "license": "MIT", + "dependencies": { + "@dnd-kit/abstract": "^0.5.0", + "@dnd-kit/collision": "^0.5.0", + "@dnd-kit/geometry": "^0.5.0", + "@dnd-kit/state": "^0.5.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@dnd-kit/geometry": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/geometry/-/geometry-0.5.0.tgz", + "integrity": "sha512-ubHQS1CiSDH8ssYH2xG5BnpwPSFP1tStXXjug7/Ba6qnQdu/EUH47l6QXKIksQnnanfVfDf0aGeevRxgZlj28A==", + "license": "MIT", + "dependencies": { + "@dnd-kit/state": "^0.5.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@dnd-kit/helpers": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/helpers/-/helpers-0.5.0.tgz", + "integrity": "sha512-i4y+51/icSw+OHMr/su19qhnmNhAzh8PnBwXvapFYTd+64oodIyJRiRkB+hhfxAfnur7RYSW8qacDTrXjg2XOg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/abstract": "^0.5.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@dnd-kit/react": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/react/-/react-0.5.0.tgz", + "integrity": "sha512-abQPLI8lmfVE+v/n+pqy5WFxrw6T2Yg0UQZsL78dp5DKci7dKTVDjvLWqvass+XTFtzJmsZEjk1NdqE6xG8Jiw==", + "license": "MIT", + "dependencies": { + "@dnd-kit/abstract": "^0.5.0", + "@dnd-kit/dom": "^0.5.0", + "@dnd-kit/state": "^0.5.0", + "tslib": "^2.6.2" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@dnd-kit/state": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/state/-/state-0.5.0.tgz", + "integrity": "sha512-y7XbabQqjF58Lk8YmDQuR8l6QjN+Kh4qlGEjUvHuIeasLk1QP+9L5diXS98VMxQIivyMmUtX2//f+3N7qPJX4w==", + "license": "MIT", + "dependencies": { + "@preact/signals-core": "^1.10.0", + "tslib": "^2.6.2" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.10.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", @@ -772,6 +855,16 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@preact/signals-core": { + "version": "1.14.4", + "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.14.4.tgz", + "integrity": "sha512-HNB6HYeYKhQbJ1aKl+YRjrS4+QWHLKX6qKoUsfS/m0vqzsVaEBiZiaKbG/e+NKk2ch5ALQr/ihWaMHxiCuuWHA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/@rolldown/binding-android-arm-eabi": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.5.tgz", @@ -3910,6 +4003,12 @@ "typescript": ">=4.8.4" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 84ffb9e..0e063e6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,8 @@ "test": "vitest" }, "dependencies": { + "@dnd-kit/helpers": "^0.5.0", + "@dnd-kit/react": "^0.5.0", "lucide-react": "1.33.0", "react": "19.2.8", "react-dom": "19.2.8", diff --git a/frontend/src/components/ui/CalendarMonthGrid.tsx b/frontend/src/components/ui/CalendarMonthGrid.tsx new file mode 100644 index 0000000..26220ad --- /dev/null +++ b/frontend/src/components/ui/CalendarMonthGrid.tsx @@ -0,0 +1,47 @@ +import { useMemo, type ReactNode } from "react"; + +import { localDateValue, monthGrid } from "./calendarDates"; + +const sundayFirst = ["日", "一", "二", "三", "四", "五", "六"]; +const mondayFirst = ["一", "二", "三", "四", "五", "六", "日"]; + +interface CalendarMonthGridProps { + month: Date; + renderDay: (date: Date) => ReactNode; + weekStartsOn?: 0 | 1; + fixedWeeks?: boolean; + weekdayPrefix?: string; + className?: string; + weekdayClassName?: string; + emptyClassName?: string; +} + +export function CalendarMonthGrid({ + month, + renderDay, + weekStartsOn = 0, + fixedWeeks = false, + weekdayPrefix = "", + className = "grid grid-cols-7 gap-1 text-center", + weekdayClassName = "py-2 text-xs font-medium text-slate-400", + emptyClassName = "", +}: CalendarMonthGridProps) { + const cells = useMemo( + () => monthGrid(month, weekStartsOn, fixedWeeks), + [fixedWeeks, month, weekStartsOn], + ); + const weekdays = weekStartsOn === 1 ? mondayFirst : sundayFirst; + + return ( +
+ {weekdays.map((day) => ( +
{weekdayPrefix}{day}
+ ))} + {cells.map((date, index) => ( + date + ?
{renderDay(date)}
+ :
+ ))} +
+ ); +} diff --git a/frontend/src/components/ui/MultiDateCalendar.tsx b/frontend/src/components/ui/MultiDateCalendar.tsx new file mode 100644 index 0000000..19b0457 --- /dev/null +++ b/frontend/src/components/ui/MultiDateCalendar.tsx @@ -0,0 +1,90 @@ +import { CalendarDays, ChevronLeft, ChevronRight, RotateCcw } from "lucide-react"; +import { useMemo, useState } from "react"; + +import { CalendarMonthGrid } from "./CalendarMonthGrid"; +import { localDateValue, parseLocalDate } from "./calendarDates"; + +function dateSummary(values: string[]): string { + if (!values.length) return "暂不确定,可以留空"; + const formatted = values.slice(0, 3).map((value) => { + const date = parseLocalDate(value); + return `${date.getMonth() + 1}月${date.getDate()}日`; + }); + return `${formatted.join("、")}${values.length > 3 ? ` 等 ${values.length} 天` : ""}`; +} + +interface MultiDateCalendarProps { + values: string[]; + onChange: (values: string[]) => void; + title?: string; + disabled?: boolean; +} + +export function MultiDateCalendar({ + values, + onChange, + title = "选择预计上门日期", + disabled = false, +}: MultiDateCalendarProps) { + const [open, setOpen] = useState(false); + const [visibleMonth, setVisibleMonth] = useState(() => ( + values[0] ? parseLocalDate(values[0]) : new Date() + )); + const selected = useMemo(() => new Set(values), [values]); + + function toggle(date: Date) { + const value = localDateValue(date); + const next = new Set(selected); + if (next.has(value)) next.delete(value); + else next.add(value); + onChange([...next].sort()); + } + + return ( +
+ + + {open ? ( +
+
+ +

{visibleMonth.getFullYear()} 年 {visibleMonth.getMonth() + 1} 月

+ +
+ ( + + )} + /> +
+ + +
+
+ ) : null} +
+ ); +} diff --git a/frontend/src/components/ui/calendarDates.ts b/frontend/src/components/ui/calendarDates.ts new file mode 100644 index 0000000..4f9d330 --- /dev/null +++ b/frontend/src/components/ui/calendarDates.ts @@ -0,0 +1,41 @@ +export function localDateValue(date = new Date()): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +export function parseLocalDate(value: string): Date { + const [year, month, day] = value.split("-").map(Number); + return new Date(year, month - 1, day); +} + +export function monthGrid( + month: Date, + weekStartsOn: 0 | 1 = 0, + fixedWeeks = false, +): Array { + const first = new Date(month.getFullYear(), month.getMonth(), 1); + const offset = (first.getDay() - weekStartsOn + 7) % 7; + const lastDay = new Date(month.getFullYear(), month.getMonth() + 1, 0).getDate(); + const length = fixedWeeks ? 42 : offset + lastDay; + return Array.from({ length }, (_, index) => { + const day = index - offset + 1; + return day >= 1 && day <= lastDay + ? new Date(month.getFullYear(), month.getMonth(), day) + : null; + }); +} + +export function expandDateRange(start: string | null, end: string | null): string[] { + if (!start || !end) return []; + const current = parseLocalDate(start); + const last = parseLocalDate(end); + if (Number.isNaN(current.getTime()) || Number.isNaN(last.getTime()) || last < current) return []; + const values: string[] = []; + while (current <= last && values.length < 366) { + values.push(localDateValue(current)); + current.setDate(current.getDate() + 1); + } + return values; +} diff --git a/frontend/src/features/intake/AdminIntakePage.test.tsx b/frontend/src/features/intake/AdminIntakePage.test.tsx index b4a4b41..78f3da0 100644 --- a/frontend/src/features/intake/AdminIntakePage.test.tsx +++ b/frontend/src/features/intake/AdminIntakePage.test.tsx @@ -13,6 +13,7 @@ const apiMocks = vi.hoisted(() => ({ getIntakeSubmission: vi.fn(), saveIntakeReviewDraft: vi.fn(), decideIntakeSubmission: vi.fn(), + updateIntakeSubmissionListState: vi.fn(), })); vi.mock("./api", () => apiMocks); @@ -100,12 +101,70 @@ beforeEach(() => { apiMocks.updateIntakeToken.mockResolvedValue({ ...token, status: "disabled", submitted_at: null, submission_status: null }); apiMocks.saveIntakeReviewDraft.mockResolvedValue({ ...detail, status: "reviewed", review_payload: payload, review_unit_price: "30.00", reviewed_at: timestamp, revision: "b".repeat(64) }); apiMocks.decideIntakeSubmission.mockResolvedValue({ submission_id: 9, submission_uuid: summary.submission_uuid, status: "archived_order", decision_mode: "order", customer_id: 3, order_id: 4, revision: "c".repeat(64) }); + apiMocks.updateIntakeSubmissionListState.mockResolvedValue(detail); Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText: vi.fn().mockResolvedValue(undefined) }, }); }); +it("shows only the newest three links until expanded", async () => { + const links = Array.from({ length: 5 }, (_, index) => ({ + ...token, + id: 20 - index, + created_at: `2031-05-0${5 - index}T08:00:00Z`, + })); + apiMocks.listIntakeTokens.mockResolvedValue({ items: links, total: links.length }); + renderPage(); + + expect(await screen.findByText("链接 #20")).toBeInTheDocument(); + expect(screen.getByText("链接 #18")).toBeInTheDocument(); + expect(screen.queryByText("链接 #17")).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "展开全部(5)" })); + expect(screen.getByText("链接 #16")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "收起" })); + expect(screen.queryByText("链接 #17")).not.toBeInTheDocument(); +}); + +it("moves a voided record out of the current list and can restore it", async () => { + const voidedSummary: IntakeSubmissionSummary = { + ...summary, + status: "voided", + removed_at: null, + }; + const voidedDetail: IntakeSubmissionDetail = { + ...detail, + ...voidedSummary, + status: "voided", + voided_at: timestamp, + decision_mode: "void", + }; + const removedDetail = { ...voidedDetail, removed_at: "2031-05-02T08:00:00Z" }; + apiMocks.listIntakeSubmissions.mockResolvedValue({ items: [voidedSummary], total: 1 }); + apiMocks.getIntakeSubmission.mockResolvedValue(voidedDetail); + apiMocks.updateIntakeSubmissionListState + .mockResolvedValueOnce(removedDetail) + .mockResolvedValueOnce(voidedDetail); + renderPage(); + + fireEvent.click(await screen.findByRole("button", { name: "从列表移除" })); + await waitFor(() => expect(apiMocks.updateIntakeSubmissionListState).toHaveBeenCalledWith( + 9, + true, + revision, + )); + expect(await screen.findByRole("button", { name: "恢复到当前列表" })).toBeInTheDocument(); + expect(within(screen.getByLabelText("提交记录列表")).getByText("P10 后台虚构客户")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "恢复到当前列表" })); + await waitFor(() => expect(apiMocks.updateIntakeSubmissionListState).toHaveBeenLastCalledWith( + 9, + false, + revision, + )); + expect(await screen.findByRole("button", { name: "从列表移除" })).toBeInTheDocument(); +}); + it("shows the full editable review while keeping the submission list privacy-minimized", async () => { renderPage(); diff --git a/frontend/src/features/intake/AdminIntakePage.tsx b/frontend/src/features/intake/AdminIntakePage.tsx index 63c24c3..69a26cf 100644 --- a/frontend/src/features/intake/AdminIntakePage.tsx +++ b/frontend/src/features/intake/AdminIntakePage.tsx @@ -18,6 +18,8 @@ import { type FormEvent, useCallback, useEffect, useRef, useState } from "react" import { Link } from "react-router-dom"; import { PageHeader } from "../../components/ui/PageHeader"; +import { MultiDateCalendar } from "../../components/ui/MultiDateCalendar"; +import { expandDateRange } from "../../components/ui/calendarDates"; import { customerAddress } from "../../lib/customerDisplay"; import { createIntakeToken, @@ -27,6 +29,7 @@ import { listIntakeTokens, saveIntakeReviewDraft, updateIntakeToken, + updateIntakeSubmissionListState, } from "./api"; import { emptyCat, serviceItemOptions } from "./constants"; import type { @@ -103,9 +106,11 @@ function replaceSubmission( cat_count: detail.cat_count, start_date: detail.start_date, end_date: detail.end_date, + service_dates: detail.service_dates, submitted_at: detail.submitted_at, updated_at: detail.updated_at, revision: detail.revision, + removed_at: detail.removed_at, } : item); } @@ -115,6 +120,7 @@ function DetailRow({ label, value }: { label: string; value: string | null | und function PayloadDetail({ payload }: { payload: IntakeDraftPayload }) { const labels = new Map(serviceItemOptions.map((item) => [item.value, item.label])); + const dates = payload.service.service_dates ?? []; return (
@@ -141,7 +147,7 @@ function PayloadDetail({ payload }: { payload: IntakeDraftPayload }) {

服务计划

-
labels.get(item) || item).join("、")} />
+
{dates.length ? null : } labels.get(item) || item).join("、")} />
); @@ -198,6 +204,8 @@ function ReviewEditor({ payload, sourceNote, unitPrice, disabled, onPayloadChang onPayloadChange({ ...payload, notes: mergeNote(payload.notes, sourceNote) }); } const optional = (value: string) => value.trim() || null; + const serviceDates = payload.service.service_dates + ?? expandDateRange(payload.service.start_date, payload.service.end_date); return

后台审核稿

客户原始提交不会被覆盖;这里的修改只用于最终落档和生成订单。

@@ -241,7 +249,7 @@ function ReviewEditor({ payload, sourceNote, unitPrice, disabled, onPayloadChang