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..974b689 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,46 @@ 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="提交记录已变化,请刷新后重试")
+ 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..152dd55 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,131 @@ 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_any_submission_can_be_removed_and_restored_without_deletion(
+ intake_api_context: IntakeApiContext,
+) -> None:
+ client = intake_api_context.client
+ _, summary = submit_for_review(client)
+ 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..4c48ae4
--- /dev/null
+++ b/docs/tasks/P26_INTAKE_ORDER_CALENDAR_AND_MAP_PIN.md
@@ -0,0 +1,69 @@
+# 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 校验;订单管理不提供拖拽、上下移动或保存排程。
+- 订单管理通过现有 DayPlan 接口读取所选日期的全部有效任务,每次上门独立一条,并严格沿用 `sort_order`;取消任务不进入有效路线列表。
+- 订单详情在正常文档流中替换月历区域并内部滚动,关闭后恢复月历,下方当天上门列表始终保留。
+- 手动锚点只接受 GCJ-02;只改坐标,不改地址文字。客户主档、相同地址未取消/未完成订单及无执行历史任务同步更新,历史任务保持不变。
+- 临时锚点使用以坐标为中心的十字准星,文字标签不得承担落点表达;点击或拖动只更新现有 marker,不得销毁地图或重复 `setFitView()`。
+- 高德地图实例与覆盖物更新分离;坐标、选中任务和编辑状态变化通过控制器更新,只有组件卸载或底图能力变化才销毁实例。
+- 手动来源使用现有 `geocode_status/geocode_fingerprint/geocode_level` 字段表达;地址改变后手动坐标失效。
+- 公网 Relay/Gateway 的 `service_dates` 协议必须同步,但真实更新需另行确认。
+
+## 验收标准
+
+- 公网填写页不存在原生日期输入框,390×844 与 430×932 无横向越界。
+- 非连续日期经审核归档后只生成对应日期任务,旧每天两次订单不丢失语义。
+- 订单月历点击日期后完整展示当天有效上门,顺序与路线图已保存顺序一致;同一订单同日多次上门逐条展示。
+- 订单详情替换月历区域且不遮挡当天列表;订单管理不出现地图或排程编辑控件。
+- 点击/拖动地图准星后地图不闪烁、不跳回、不重新缩放,可信手动坐标仍按原规则同步;自动恢复失败时保留原手动位置。
+- 已作废提交移除/恢复不改变原始提交;链接列表默认三条并可展开。
+- 前后端完整测试、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..aaf876b 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,61 @@ 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("removes any record from the list with a direct x and can restore it", async () => {
+ const removedDetail = { ...detail, removed_at: "2031-05-02T08:00:00Z" };
+ apiMocks.updateIntakeSubmissionListState
+ .mockResolvedValueOnce(removedDetail)
+ .mockResolvedValueOnce(detail);
+ renderPage();
+
+ fireEvent.click(await screen.findByRole("button", { name: "从列表删除 P10 后台虚构客户" }));
+ await waitFor(() => expect(apiMocks.updateIntakeSubmissionListState).toHaveBeenCalledWith(
+ 9,
+ true,
+ revision,
+ ));
+ expect(within(screen.getByLabelText("提交记录列表")).getByText("还没有客户提交。")).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole("button", { name: "已移除记录" }));
+ expect(within(screen.getByLabelText("提交记录列表")).getByText("P10 后台虚构客户")).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole("button", { name: "恢复 P10 后台虚构客户" }));
+ await waitFor(() => expect(apiMocks.updateIntakeSubmissionListState).toHaveBeenLastCalledWith(
+ 9,
+ false,
+ revision,
+ ));
+ expect(within(screen.getByLabelText("提交记录列表")).getByText("还没有已移除记录。")).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole("button", { name: "当前记录" }));
+ expect(within(screen.getByLabelText("提交记录列表")).getByText("P10 后台虚构客户")).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..e1d902e 100644
--- a/frontend/src/features/intake/AdminIntakePage.tsx
+++ b/frontend/src/features/intake/AdminIntakePage.tsx
@@ -13,11 +13,14 @@ import {
ShieldCheck,
ShoppingCart,
Trash2,
+ X,
} from "lucide-react";
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 +30,7 @@ import {
listIntakeTokens,
saveIntakeReviewDraft,
updateIntakeToken,
+ updateIntakeSubmissionListState,
} from "./api";
import { emptyCat, serviceItemOptions } from "./constants";
import type {
@@ -103,9 +107,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 +121,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 +148,7 @@ function PayloadDetail({ payload }: { payload: IntakeDraftPayload }) {
服务计划
- labels.get(item) || item).join("、")} />
+ {dates.length ? null : } labels.get(item) || item).join("、")} />
);
@@ -198,6 +205,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 +250,7 @@ function ReviewEditor({ payload, sourceNote, unitPrice, disabled, onPayloadChang
)}
-
+ 服务计划
onPayloadChange({ ...payload, service: { ...payload.service, service_dates: dates, start_date: null, end_date: null, visits_per_day: null } })} />
{payload.service.service_dates === null && payload.service.visits_per_day ? 旧记录当前仍按每天 {payload.service.visits_per_day} 次处理;修改日历后将转换为每个选中日期一次。
: null}
;
}
@@ -258,9 +267,12 @@ export function IntakeWorkspace({ embedded = false }: { embedded?: boolean }) {
const [action, setAction] = useState(null);
const [confirmingDecision, setConfirmingDecision] = useState(null);
const [copiedId, setCopiedId] = useState(null);
+ const [linksExpanded, setLinksExpanded] = useState(false);
+ const [showRemoved, setShowRemoved] = useState(false);
const [error, setError] = useState(null);
const decisionKeys = useRef(new Map());
const selectedIdRef = useRef(null);
+ const showRemovedRef = useRef(false);
useEffect(() => { selectedIdRef.current = selectedId; }, [selectedId]);
@@ -275,9 +287,12 @@ export function IntakeWorkspace({ embedded = false }: { embedded?: boolean }) {
setTokens(tokenResponse.items);
setSubmissions(submissionResponse.items);
setLoadedOnce(true);
- setSelectedId((current) => current && submissionResponse.items.some((item) => item.id === current)
+ const matchingSubmissions = submissionResponse.items.filter(
+ (item) => Boolean(item.removed_at) === showRemovedRef.current,
+ );
+ setSelectedId((current) => current && matchingSubmissions.some((item) => item.id === current)
? current
- : submissionResponse.items[0]?.id ?? null);
+ : matchingSubmissions[0]?.id ?? null);
const activeId = selectedIdRef.current;
if (!silent && activeId && submissionResponse.items.some((item) => item.id === activeId)) {
const refreshedDetail = await getIntakeSubmission(activeId);
@@ -441,6 +456,40 @@ export function IntakeWorkspace({ embedded = false }: { embedded?: boolean }) {
}
}
+ async function handleListState(item: IntakeSubmissionSummary, removed: boolean) {
+ setAction(`list-state-${item.id}`);
+ setError(null);
+ try {
+ const updated = await updateIntakeSubmissionListState(item.id, removed, item.revision);
+ const nextSubmissions = replaceSubmission(submissions, updated);
+ setSubmissions(nextSubmissions);
+ if (selectedIdRef.current === item.id) {
+ const nextVisible = nextSubmissions.find(
+ (candidate) => Boolean(candidate.removed_at) === showRemovedRef.current,
+ );
+ setSelectedId(nextVisible?.id ?? null);
+ if (!nextVisible) {
+ setDetail(null);
+ setReviewDraft(null);
+ setUnitPrice("");
+ }
+ }
+ } catch (cause) {
+ setError(cause instanceof Error ? cause.message : "列表状态修改失败,请刷新后重试。");
+ } finally {
+ setAction(null);
+ }
+ }
+
+ function selectSubmissionList(removed: boolean) {
+ showRemovedRef.current = removed;
+ setShowRemoved(removed);
+ const first = submissions.find((item) => Boolean(item.removed_at) === removed);
+ setSelectedId(first?.id ?? null);
+ if (!first) setDetail(null);
+ setConfirmingDecision(null);
+ }
+
const reviewDirty = Boolean(
detail
&& reviewDraft
@@ -450,7 +499,11 @@ export function IntakeWorkspace({ embedded = false }: { embedded?: boolean }) {
),
);
const reviewSaved = Boolean(detail?.review_payload && !reviewDirty);
- const orderReady = Boolean(reviewSaved && detail?.review_unit_price !== null);
+ const hasServiceSchedule = Boolean(reviewDraft && (
+ (reviewDraft.service.service_dates?.length ?? 0) > 0
+ || (reviewDraft.service.start_date && reviewDraft.service.end_date && reviewDraft.service.visits_per_day)
+ ));
+ const orderReady = Boolean(reviewSaved && detail?.review_unit_price !== null && hasServiceSchedule);
const canEdit = detail?.status === "submitted" || detail?.status === "reviewed";
const canDecide = canEdit || detail?.status === "processing";
const terminal = Boolean(detail && [
@@ -460,6 +513,8 @@ export function IntakeWorkspace({ embedded = false }: { embedded?: boolean }) {
"voided",
"redacted",
].includes(detail.status));
+ const visibleSubmissions = submissions.filter((item) => Boolean(item.removed_at) === showRemoved);
+ const visibleTokens = linksExpanded ? tokens : tokens.slice(0, 3);
return (
@@ -482,14 +537,14 @@ export function IntakeWorkspace({ embedded = false }: { embedded?: boolean }) {
填写链接({tokens.length})
- {loading ? 正在加载…
: !loadedOnce && error ? 云端暂不可用,未把失败当作空列表。
: tokens.length === 0 ? 还没有填写链接。
: {tokens.map((token) => 链接 #{token.id}{tokenStatusLabels[token.status]}{token.submission_status ? {submissionStatusLabels[token.submission_status]} : null}
有效期至 {dateTime(token.expires_at)}
{token.fill_path ?
新链接仅本次可查看,请立即复制保存
:
链接原文未保存;需要时请重新生成
}
{token.fill_path ? <>
打开> : null}{!token.submitted_at && token.status !== "expired" ?
: null}
)}
}
+ {loading ? 正在加载…
: !loadedOnce && error ? 云端暂不可用,未把失败当作空列表。
: tokens.length === 0 ? 还没有填写链接。
: <>{visibleTokens.map((token) => 链接 #{token.id}{tokenStatusLabels[token.status]}{token.submission_status ? {submissionStatusLabels[token.submission_status]} : null}
有效期至 {dateTime(token.expires_at)}
{token.fill_path ?
新链接仅本次可查看,请立即复制保存
:
链接原文未保存;需要时请重新生成
}
{token.fill_path ? <>
打开> : null}{!token.submitted_at && token.status !== "expired" ?
: null}
)}
{tokens.length > 3 ? : null}>}
- 提交审核({submissions.length})
列表仅显示摘要;完整地址、门禁和钥匙只在右侧详情中读取。
+ 提交审核({visibleSubmissions.length})
列表仅显示摘要;完整地址、门禁和钥匙只在右侧详情中读取。
-
+
{selectedId !== null && detail?.id !== selectedId ?
正在读取敏感详情…
: !detail ?
:
@@ -503,7 +558,7 @@ export function IntakeWorkspace({ embedded = false }: { embedded?: boolean }) {
{detail.status === "processing" ?
云端记录正在处理。如上次因回写失败中断,请使用同一动作重试;本机唯一回执会阻止重复建档。
: null}
{confirmingDecision ?
{confirmingDecision === "customer" ? "确认新建客户档案(不会设置为已归档),并创建已补齐名称的猫咪?" : confirmingDecision === "order" ? "确认新建客户、猫咪、已确认订单和每日任务?" : "确认作废本次提交?"}
客户原稿保持只读;失败不会留下半套订单,重复重试不会重复建档。
: null}
- {terminal ?
{detail.status === "voided" ? "该提交已作废。" : detail.status === "redacted" ? "云端敏感资料已按期限清理。" : "已完成本机幂等归档。"}
{detail.converted_customer_id ? 查看客户 #{detail.converted_customer_id} : null}{detail.converted_order_id ? 查看订单 #{detail.converted_order_id} : null}
: null}
+ {terminal ?
{detail.status === "voided" ? "该提交已作废。" : detail.status === "redacted" ? "云端敏感资料已按期限清理。" : "已完成本机幂等归档。"}
{detail.converted_customer_id ? 查看客户 #{detail.converted_customer_id} : null}{detail.converted_order_id ? 查看订单 #{detail.converted_order_id} : null}
: null}
{terminal ?
: reviewDraft ? <>
查看客户原始提交(永久只读)
> : null}
}
diff --git a/frontend/src/features/intake/api.ts b/frontend/src/features/intake/api.ts
index 4e255af..944fdeb 100644
--- a/frontend/src/features/intake/api.ts
+++ b/frontend/src/features/intake/api.ts
@@ -97,6 +97,17 @@ export function getIntakeSubmission(submissionId: number): Promise
(`${adminPath}/submissions/${submissionId}`);
}
+export function updateIntakeSubmissionListState(
+ submissionId: number,
+ removed: boolean,
+ expectedRevision: string,
+): Promise {
+ return request(`${adminPath}/submissions/${submissionId}/list-state`, {
+ method: "PATCH",
+ body: JSON.stringify({ removed, expected_revision: expectedRevision }),
+ });
+}
+
export function reviewIntakeSubmission(
submissionId: number,
expectedRevision: string,
diff --git a/frontend/src/features/intake/constants.ts b/frontend/src/features/intake/constants.ts
index f830759..5979df3 100644
--- a/frontend/src/features/intake/constants.ts
+++ b/frontend/src/features/intake/constants.ts
@@ -56,9 +56,10 @@ export function editableDraft(draft: IntakeDraftPayload | null): IntakeDraftPayl
customer: { ...emptyCustomer },
cats: [emptyCat()],
service: {
+ service_dates: [],
start_date: null,
end_date: null,
- visits_per_day: 1,
+ visits_per_day: null,
service_items: ["feed", "water", "litter", "photo"],
},
notes: null,
@@ -70,7 +71,11 @@ export function editableDraft(draft: IntakeDraftPayload | null): IntakeDraftPayl
cats: draft.cats.length ? draft.cats : [emptyCat()],
service: {
...draft.service,
- visits_per_day: draft.service.visits_per_day ?? 1,
+ service_dates: draft.service.service_dates ?? null,
+ visits_per_day: draft.service.service_dates !== null
+ && draft.service.service_dates !== undefined
+ ? null
+ : draft.service.visits_per_day ?? 1,
service_items: draft.service.service_items.length
? draft.service.service_items
: ["feed", "water", "litter", "photo"],
@@ -86,7 +91,17 @@ export const emptyPublicCat = (): PublicIntakeCatDraft => ({
export function publicEditableDraft(
draft: PublicIntakeDraftPayload | null,
): PublicIntakeDraftPayload {
- if (draft) return draft;
+ if (draft) {
+ return {
+ ...draft,
+ service: {
+ service_dates: draft.service.service_dates ?? null,
+ start_date: draft.service.start_date ?? null,
+ end_date: draft.service.end_date ?? null,
+ visits_per_day: draft.service.visits_per_day ?? null,
+ },
+ };
+ }
return {
customer: {
name: null,
@@ -101,6 +116,7 @@ export function publicEditableDraft(
},
cats: [],
service: {
+ service_dates: [],
start_date: null,
end_date: null,
visits_per_day: null,
diff --git a/frontend/src/features/intake/types.ts b/frontend/src/features/intake/types.ts
index 16503ce..6669c00 100644
--- a/frontend/src/features/intake/types.ts
+++ b/frontend/src/features/intake/types.ts
@@ -55,6 +55,7 @@ export interface IntakeCatDraft {
}
export interface IntakeServiceDraft {
+ service_dates?: string[] | null;
start_date: string | null;
end_date: string | null;
visits_per_day: number | null;
@@ -98,6 +99,7 @@ export interface PublicIntakeCatDraft {
}
export interface PublicIntakeServiceDraft {
+ service_dates?: string[] | null;
start_date: string | null;
end_date: string | null;
visits_per_day: number | null;
@@ -142,9 +144,11 @@ export interface IntakeSubmissionSummary {
cat_count: number;
start_date: string | null;
end_date: string | null;
+ service_dates?: string[];
submitted_at: string | null;
updated_at: string;
revision: string;
+ removed_at?: string | null;
}
export interface IntakeSubmissionList {
diff --git a/frontend/src/features/orders/OrderForm.tsx b/frontend/src/features/orders/OrderForm.tsx
index ace0150..90f2066 100644
--- a/frontend/src/features/orders/OrderForm.tsx
+++ b/frontend/src/features/orders/OrderForm.tsx
@@ -18,6 +18,8 @@ import {
type KeyboardEvent as ReactKeyboardEvent,
} from "react";
+import { CalendarMonthGrid } from "../../components/ui/CalendarMonthGrid";
+import { localDateValue, parseLocalDate } from "../../components/ui/calendarDates";
import { serviceItemOptions } from "./constants";
import { parseOrderAddress } from "./addressParser";
import {
@@ -41,30 +43,6 @@ import type {
const inputClass = "mt-1.5 min-h-11 w-full rounded-xl border border-slate-300 bg-white px-3.5 py-2.5 text-sm text-slate-950 outline-none transition focus:border-orange-500 focus:ring-4 focus:ring-orange-100";
const labelClass = "block text-sm font-medium text-slate-700";
const defaultServices: ServiceItem[] = ["feed", "water", "litter", "photo"];
-const weekDays = ["一", "二", "三", "四", "五", "六", "日"];
-
-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}`;
-}
-
-function parseLocalDate(value: string): Date {
- const [year, month, day] = value.split("-").map(Number);
- return new Date(year, month - 1, day);
-}
-
-function monthGrid(month: Date): Array {
- const first = new Date(month.getFullYear(), month.getMonth(), 1);
- const offset = (first.getDay() + 6) % 7;
- const days = new Date(month.getFullYear(), month.getMonth() + 1, 0).getDate();
- return Array.from({ length: 42 }, (_, index) => {
- const day = index - offset + 1;
- return day >= 1 && day <= days ? new Date(month.getFullYear(), month.getMonth(), day) : null;
- });
-}
-
function optionalValue(value: string): string | null {
return value.trim() || null;
}
@@ -175,7 +153,6 @@ export function OrderForm({ options, initial, onCancel, onSave }: OrderFormProps
}, [onCancel, saving]);
const selectedSet = useMemo(() => new Set(selectedDates), [selectedDates]);
- const calendarDays = useMemo(() => monthGrid(visibleMonth), [visibleMonth]);
const adjustmentValue = adjustmentType === "none" ? 0 : Number(adjustmentAmount) || 0;
const initialVisitCounts = useMemo(
() => new Map(initial?.service_schedule.map((entry) => [entry.service_date, entry.visit_count]) ?? []),
@@ -449,7 +426,13 @@ export function OrderForm({ options, initial, onCancel, onSave }: OrderFormProps
服务日期
点击日期添加,再次点击取消;可以选择不连续日期。
已选 {selectedDates.length} 天
{visibleMonth.getFullYear()} 年 {visibleMonth.getMonth() + 1} 月
-
{weekDays.map((day) => {day})}{calendarDays.map((date, index) => date ? : )}
+
}
+ />
diff --git a/frontend/src/features/orders/OrderScheduleCalendar.tsx b/frontend/src/features/orders/OrderScheduleCalendar.tsx
new file mode 100644
index 0000000..d153639
--- /dev/null
+++ b/frontend/src/features/orders/OrderScheduleCalendar.tsx
@@ -0,0 +1,193 @@
+import {
+ CalendarDays,
+ ChevronLeft,
+ ChevronRight,
+ Clock3,
+ LoaderCircle,
+ MapPin,
+} from "lucide-react";
+import { useEffect, useMemo, useState } from "react";
+import { Link } from "react-router-dom";
+
+import { CalendarMonthGrid } from "../../components/ui/CalendarMonthGrid";
+import { localDateValue, parseLocalDate } from "../../components/ui/calendarDates";
+import { getDayPlan, getPlanDays } from "../plans/api";
+import { planTaskStatusLabels } from "../plans/constants";
+import type { DayPlan, PlanDaySummary, PlanTaskStatus } from "../plans/types";
+
+function monthBounds(month: Date): [string, string] {
+ return [
+ localDateValue(new Date(month.getFullYear(), month.getMonth(), 1)),
+ localDateValue(new Date(month.getFullYear(), month.getMonth() + 1, 0)),
+ ];
+}
+
+function displayDate(value: string): string {
+ const [year, month, day] = value.split("-");
+ return `${year} 年 ${Number(month)} 月 ${Number(day)} 日`;
+}
+
+function taskStatusStyle(status: PlanTaskStatus): string {
+ return {
+ pending: "bg-amber-50 text-amber-700",
+ confirmed: "bg-blue-50 text-blue-700",
+ ready: "bg-cyan-50 text-cyan-700",
+ in_progress: "bg-violet-50 text-violet-700",
+ completed: "bg-emerald-50 text-emerald-700",
+ exception: "bg-red-50 text-red-700",
+ cancelled: "bg-slate-200 text-slate-600",
+ }[status];
+}
+
+interface OrderScheduleCalendarProps {
+ selectedDate: string;
+ onSelectDate: (date: string) => void;
+ onSelectOrder: (orderId: number) => void;
+ refreshKey: number;
+}
+
+export function OrderScheduleCalendar({
+ selectedDate,
+ onSelectDate,
+ onSelectOrder,
+ refreshKey,
+}: OrderScheduleCalendarProps) {
+ const [visibleMonth, setVisibleMonth] = useState(() => parseLocalDate(selectedDate));
+ const [days, setDays] = useState([]);
+ const [error, setError] = useState(null);
+ const dayMap = useMemo(() => new Map(days.map((day) => [day.service_date, day])), [days]);
+
+ useEffect(() => {
+ let active = true;
+ const [from, to] = monthBounds(visibleMonth);
+ getPlanDays(from, to)
+ .then((response) => {
+ if (!active) return;
+ setError(null);
+ setDays(response.items);
+ })
+ .catch((cause: unknown) => {
+ if (active) setError(cause instanceof Error ? cause.message : "月历排班加载失败,请重试。");
+ });
+ return () => { active = false; };
+ }, [refreshKey, visibleMonth]);
+
+ function changeMonth(offset: number) {
+ setVisibleMonth((current) => new Date(current.getFullYear(), current.getMonth() + offset, 1));
+ }
+
+ function selectToday() {
+ const today = new Date();
+ setVisibleMonth(new Date(today.getFullYear(), today.getMonth(), 1));
+ onSelectDate(localDateValue(today));
+ }
+
+ return (
+
+
+
订单月历
点击日期查看当天全部上门;点击订单编号查看订单详情。
+
+
+ {visibleMonth.getFullYear()} 年 {visibleMonth.getMonth() + 1} 月
+
+
+
+
+ {error ? {error}
: null}
+ {
+ const value = localDateValue(date);
+ const day = dayMap.get(value);
+ return (
+ onSelectDate(value)}>
+
+
+ {(day?.orders ?? []).slice(0, 3).map((order) => )}
+ {(day?.orders?.length ?? 0) > 3 ? 还有 {day!.orders!.length - 3} 笔 : null}
+
+
+ );
+ }}
+ />
+
+ );
+}
+
+interface OrderDayVisitsProps {
+ selectedDate: string;
+ onSelectOrder: (orderId: number) => void;
+ refreshKey: number;
+}
+
+export function OrderDayVisits({ selectedDate, onSelectOrder, refreshKey }: OrderDayVisitsProps) {
+ const [retryKey, setRetryKey] = useState(0);
+ const requestKey = `${selectedDate}:${refreshKey}:${retryKey}`;
+ const [result, setResult] = useState<{
+ requestKey: string;
+ plan: DayPlan | null;
+ error: string | null;
+ }>({ requestKey: "", plan: null, error: null });
+
+ useEffect(() => {
+ let active = true;
+ getDayPlan(selectedDate)
+ .then((response) => {
+ if (active) setResult({ requestKey, plan: response, error: null });
+ })
+ .catch((cause: unknown) => {
+ if (!active) return;
+ setResult({
+ requestKey,
+ plan: null,
+ error: cause instanceof Error ? cause.message : "当天上门加载失败,请重试。",
+ });
+ });
+ return () => { active = false; };
+ }, [requestKey, selectedDate]);
+
+ const loading = result.requestKey !== requestKey;
+ const plan = loading ? null : result.plan;
+ const error = loading ? null : result.error;
+ const tasks = plan?.tasks.filter((task) => task.status !== "cancelled") ?? [];
+ const orderCount = new Set(tasks.map((task) => task.order_id)).size;
+
+ return (
+
+
+
+
{displayDate(selectedDate)} · 当天上门
+
{tasks.length} 次上门 · {orderCount} 笔订单;顺序来自路线图最后保存的排程。
+
+
去路线图调整顺序
+
+ {loading ? (
+ 正在加载当天上门…
+ ) : error ? (
+ {error}
+ ) : tasks.length === 0 ? (
+ 这一天没有有效上门任务
+ ) : (
+
+ {tasks.map((task, index) => (
+ -
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/features/orders/OrdersPage.test.tsx b/frontend/src/features/orders/OrdersPage.test.tsx
index 2b63041..5535983 100644
--- a/frontend/src/features/orders/OrdersPage.test.tsx
+++ b/frontend/src/features/orders/OrdersPage.test.tsx
@@ -2,6 +2,7 @@ import { fireEvent, render, screen, waitFor, within } from "@testing-library/rea
import { MemoryRouter } from "react-router-dom";
import { OrdersPage } from "./OrdersPage";
+import type { PlanTaskSummary } from "../plans/types";
import type {
OrderDetail,
OrderFormOptions,
@@ -21,8 +22,10 @@ const apiMocks = vi.hoisted(() => ({
previewDemoData: vi.fn(),
clearDemoData: vi.fn(),
}));
+const planApiMocks = vi.hoisted(() => ({ getPlanDays: vi.fn(), getDayPlan: vi.fn() }));
vi.mock("./api", () => apiMocks);
+vi.mock("../plans/api", () => planApiMocks);
const timestamp = "2030-09-01T08:00:00";
const tasks: OrderTask[] = Array.from({ length: 7 }, (_, index) => ({
@@ -127,9 +130,9 @@ const detail: OrderDetail = {
created_at: timestamp,
};
-function renderPage(initialCreate = false) {
+function renderPage(initialCreate = false, initialEntry = "/admin/orders?order_id=1&date=2030-10-01") {
return render(
-
+
,
);
@@ -176,6 +179,110 @@ beforeEach(() => {
apiMocks.updateOrder.mockResolvedValue(detail);
apiMocks.updateOrderStatus.mockResolvedValue(detail);
apiMocks.deleteOrder.mockResolvedValue(undefined);
+ planApiMocks.getPlanDays.mockResolvedValue({ items: [], total: 0 });
+ planApiMocks.getDayPlan.mockResolvedValue({
+ service_date: "2030-10-01",
+ task_count: 1,
+ order_count: 1,
+ cat_count: 2,
+ revision: "p".repeat(64),
+ schedule_locked: false,
+ tasks: [{
+ id: 1,
+ order_id: 1,
+ service_date: "2030-10-01",
+ planned_time: "09:30:00",
+ sort_order: 0,
+ status: "confirmed",
+ customer: summary.customer,
+ cat_count: 2,
+ cats: summary.cats.map((cat) => ({ id: cat.id, name: cat.name })),
+ items: tasks[0].items,
+ has_execution_history: false,
+ }],
+ });
+});
+
+it("uses the month calendar as the primary view and opens details on demand", async () => {
+ planApiMocks.getPlanDays.mockResolvedValue({
+ items: [{
+ service_date: "2030-10-01",
+ task_count: 2,
+ order_count: 1,
+ cat_count: 2,
+ customer_names: [summary.customer.name],
+ orders: [{ order_id: 1, customer_name: summary.customer.name, visit_count: 2, order_status: "confirmed" }],
+ }],
+ total: 1,
+ });
+ renderPage(false, "/admin/orders?date=2030-10-01");
+
+ expect(await screen.findByRole("heading", { name: "订单月历" })).toBeInTheDocument();
+ expect(screen.queryByRole("heading", { name: "订单 #1", level: 2 })).not.toBeInTheDocument();
+ const marker = await screen.findByRole("button", { name: /#1 ×2/ });
+ fireEvent.click(marker);
+ expect(await screen.findByRole("heading", { name: "订单 #1", level: 2 })).toBeInTheDocument();
+ expect(apiMocks.getOrder).toHaveBeenCalledWith(1);
+ expect(screen.queryByRole("heading", { name: "订单月历" })).not.toBeInTheDocument();
+ expect(screen.getByLabelText("2030-10-01 当天上门")).toBeInTheDocument();
+ expect(screen.queryByRole("heading", { name: "路线地图" })).not.toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "保存排程" })).not.toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole("button", { name: "关闭订单详情" }));
+ expect(screen.queryByRole("heading", { name: "订单 #1", level: 2 })).not.toBeInTheDocument();
+ expect(await screen.findByRole("heading", { name: "订单月历" })).toBeInTheDocument();
+ expect(screen.getByLabelText("2030-10-01 当天上门")).toBeInTheDocument();
+ const collapse = screen.getByRole("button", { name: "收起订单列表" });
+ fireEvent.click(collapse);
+ expect(screen.getByLabelText("订单列表")).toHaveClass("hidden");
+ expect(screen.getByRole("button", { name: "展开订单列表" })).toBeInTheDocument();
+});
+
+it("shows every active visit in the exact route-saved order", async () => {
+ const dayTask = (
+ id: number,
+ orderId: number,
+ customerName: string,
+ sortOrder: number,
+ status: PlanTaskSummary["status"] = "confirmed",
+ ): PlanTaskSummary => ({
+ id,
+ order_id: orderId,
+ service_date: "2030-10-01",
+ planned_time: sortOrder === 0 ? "08:30:00" : null,
+ sort_order: sortOrder,
+ status,
+ customer: { id: orderId, name: customerName, community: "虚构小区", address: `${customerName}地址` },
+ cat_count: 1,
+ cats: [{ id, name: `猫咪${id}` }],
+ items: [{ item_type: "feed", required: true, completed: false }],
+ has_execution_history: false,
+ });
+ planApiMocks.getDayPlan.mockResolvedValue({
+ service_date: "2030-10-01",
+ task_count: 4,
+ order_count: 3,
+ cat_count: 4,
+ revision: "r".repeat(64),
+ schedule_locked: false,
+ tasks: [
+ dayTask(8, 2, "路线第一位客户", 0),
+ dayTask(3, 1, "同单第一次上门", 1),
+ dayTask(4, 1, "同单第二次上门", 2),
+ dayTask(9, 3, "已取消客户", 3, "cancelled"),
+ ],
+ });
+ renderPage(false, "/admin/orders?date=2030-10-01");
+
+ const visitList = await screen.findByLabelText("2030-10-01 当天上门");
+ const visitButtons = within(visitList).getAllByRole("button", { name: /路线第/ });
+ expect(visitButtons.map((button) => button.getAttribute("aria-label"))).toEqual([
+ "路线第 1 站,路线第一位客户,订单 #2",
+ "路线第 2 站,同单第一次上门,订单 #1",
+ "路线第 3 站,同单第二次上门,订单 #1",
+ ]);
+ expect(within(visitList).queryByText("已取消客户")).not.toBeInTheDocument();
+ expect(within(visitList).getByRole("link", { name: "去路线图调整顺序" })).toHaveAttribute("href", "/admin/routes?date=2030-10-01");
});
it("shows an order, authoritative pricing, and seven generated tasks", async () => {
@@ -190,7 +297,7 @@ it("shows an order, authoritative pricing, and seven generated tasks", async ()
).toBeInTheDocument();
expect(screen.getByText("7 个日期 · 7 次")).toBeInTheDocument();
expect(screen.getAllByText("¥245.00").length).toBeGreaterThanOrEqual(2);
- expect(screen.getByText("共 7 个任务;具体时间与排序请在“按天计划”中设置。")).toBeInTheDocument();
+ expect(screen.getByText("共 7 个任务;具体时间与顺序由“路线图”维护。")).toBeInTheDocument();
expect(screen.getAllByText("2030-10-01").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("2030-10-07").length).toBeGreaterThanOrEqual(1);
expect(apiMocks.getOrder).toHaveBeenCalledWith(1);
diff --git a/frontend/src/features/orders/OrdersPage.tsx b/frontend/src/features/orders/OrdersPage.tsx
index 07f32d5..a492bb0 100644
--- a/frontend/src/features/orders/OrdersPage.tsx
+++ b/frontend/src/features/orders/OrdersPage.tsx
@@ -5,15 +5,19 @@ import {
ClipboardList,
LoaderCircle,
MapPin,
+ PanelLeftClose,
+ PanelLeftOpen,
Pencil,
Plus,
ReceiptText,
Trash2,
+ X,
} from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
-import { Link } from "react-router-dom";
+import { Link, useSearchParams } from "react-router-dom";
import { ConnectionErrorAlert } from "../../components/ui/ConnectionErrorAlert";
+import { localDateValue } from "../../components/ui/calendarDates";
import {
createOrder,
clearDemoData,
@@ -28,6 +32,7 @@ import {
} from "./api";
import { serviceItemOptions } from "./constants";
import { OrderForm } from "./OrderForm";
+import { OrderDayVisits, OrderScheduleCalendar } from "./OrderScheduleCalendar";
import type {
OrderDetail,
OrderCreateInput,
@@ -102,8 +107,13 @@ interface OrdersPageProps {
}
export function OrdersPage({ initialCreate = false }: OrdersPageProps) {
+ const [searchParams, setSearchParams] = useSearchParams();
+ const requestedOrderId = Number(searchParams.get("order_id"));
+ const selectedOrderId = Number.isInteger(requestedOrderId) && requestedOrderId > 0
+ ? requestedOrderId
+ : null;
+ const requestedDate = searchParams.get("date") || localDateValue();
const [orders, setOrders] = useState([]);
- const [selectedOrderId, setSelectedOrderId] = useState(null);
const [orderDetail, setOrderDetail] = useState(null);
const [formOptions, setFormOptions] = useState(null);
const [listLoading, setListLoading] = useState(true);
@@ -114,28 +124,24 @@ export function OrdersPage({ initialCreate = false }: OrdersPageProps) {
const [statusSaving, setStatusSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
const [geocoding, setGeocoding] = useState(false);
+ const [listCollapsed, setListCollapsed] = useState(false);
+ const [scheduleRefreshKey, setScheduleRefreshKey] = useState(0);
const listRequestId = useRef(0);
- const applyOrders = useCallback((items: OrderSummary[], preferredId?: number) => {
+ const applyOrders = useCallback((items: OrderSummary[]) => {
setOrders(items);
- setSelectedOrderId((current) => {
- if (preferredId && items.some((item) => item.id === preferredId)) return preferredId;
- if (current && items.some((item) => item.id === current)) return current;
- return items[0]?.id ?? null;
- });
}, []);
- const refreshOrders = useCallback(async (preferredId?: number) => {
+ const refreshOrders = useCallback(async () => {
const requestId = ++listRequestId.current;
setListLoading(true);
setPageError(null);
try {
const response = await listOrders();
- if (requestId === listRequestId.current) applyOrders(response.items, preferredId);
+ if (requestId === listRequestId.current) applyOrders(response.items);
} catch (cause) {
if (requestId === listRequestId.current) {
setOrders([]);
- setSelectedOrderId(null);
setOrderDetail(null);
setPageError(cause instanceof Error ? cause.message : "订单列表加载失败,请重试。");
}
@@ -190,9 +196,42 @@ export function OrdersPage({ initialCreate = false }: OrdersPageProps) {
? await updateOrder(orderDetail.id, payload as OrderPatchInput)
: await createOrder(payload as OrderCreateInput);
setOrderDetail(saved);
- setSelectedOrderId(saved.id);
+ setSearchParams((current) => {
+ const next = new URLSearchParams(current);
+ next.set("order_id", String(saved.id));
+ return next;
+ });
setFormMode(null);
- await refreshOrders(saved.id);
+ await refreshOrders();
+ setScheduleRefreshKey((current) => current + 1);
+ }
+
+ function selectOrder(orderId: number) {
+ setSearchParams((current) => {
+ const next = new URLSearchParams(current);
+ next.set("order_id", String(orderId));
+ return next;
+ });
+ }
+
+ function closeOrderDetail() {
+ setOrderDetail(null);
+ setSearchParams((current) => {
+ const next = new URLSearchParams(current);
+ next.delete("order_id");
+ return next;
+ });
+ }
+
+ function selectScheduleDate(date: string) {
+ setOrderDetail(null);
+ setSearchParams((current) => {
+ const next = new URLSearchParams(current);
+ next.set("date", date);
+ next.delete("task_id");
+ next.delete("order_id");
+ return next;
+ });
}
async function handleStatusChange(status: OrderStatus) {
@@ -204,7 +243,8 @@ export function OrdersPage({ initialCreate = false }: OrdersPageProps) {
try {
const updated = await updateOrderStatus(orderDetail.id, status);
setOrderDetail(updated);
- await refreshOrders(updated.id);
+ await refreshOrders();
+ setScheduleRefreshKey((current) => current + 1);
} catch (cause) {
setPageError(cause instanceof Error ? cause.message : "订单状态更新失败,请重试。");
} finally {
@@ -219,9 +259,9 @@ export function OrdersPage({ initialCreate = false }: OrdersPageProps) {
setPageError(null);
try {
await deleteOrder(orderDetail.id);
- setOrderDetail(null);
- setSelectedOrderId(null);
+ closeOrderDetail();
await refreshOrders();
+ setScheduleRefreshKey((current) => current + 1);
} catch (cause) {
setPageError(cause instanceof Error ? cause.message : "订单删除失败,请重试。");
} finally {
@@ -241,9 +281,9 @@ export function OrdersPage({ initialCreate = false }: OrdersPageProps) {
);
if (!confirmed) return;
await clearDemoData();
- setOrderDetail(null);
- setSelectedOrderId(null);
+ closeOrderDetail();
await refreshOrders();
+ setScheduleRefreshKey((current) => current + 1);
} catch (cause) {
setPageError(cause instanceof Error ? cause.message : "演示数据清理失败,请重试。");
} finally {
@@ -258,7 +298,8 @@ export function OrdersPage({ initialCreate = false }: OrdersPageProps) {
try {
const updated = await retryOrderGeocode(orderDetail.id);
setOrderDetail(updated);
- await refreshOrders(updated.id);
+ await refreshOrders();
+ setScheduleRefreshKey((current) => current + 1);
} catch (cause) {
setPageError(cause instanceof Error ? cause.message : "地址定位重试失败。");
} finally {
@@ -282,23 +323,29 @@ export function OrdersPage({ initialCreate = false }: OrdersPageProps) {
订单管理
录入服务订单,自动计算次数和费用,并生成每日任务。
-
+
+
+
+
{pageError ? (
void refreshOrders()} />
) : null}
-
-
) : (
+
{draftTasks.map((task, index) => (
- -
+
-
+
))}
+
)}
@@ -503,12 +619,16 @@ export function DailyPlansPage({ onDirtyChange }: DailyPlansPageProps) {
loading={routeLoading}
previewing={routePreviewing}
adopting={routeAdopting}
- dirty={dirty}
+ dirty={dirty || locationEditing}
error={routeError}
onSelectTask={selectTask}
onPreview={() => void handleRoutePreview()}
onRetry={() => void loadRoute(selectedDate, draftTasks.length > 0)}
onAdopt={() => void handleAdoptRecommendation()}
+ locationEditing={locationEditing}
+ draftPosition={draftLocation}
+ onDraftPositionChange={setDraftLocation}
+ onAmapReadyChange={setAmapReady}
/>