From 24807a3d8bd24dd32db9a5dffc7ab03bbf91cfc4 Mon Sep 17 00:00:00 2001 From: damingishere-coder Date: Mon, 31 Aug 2026 15:34:58 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E8=AE=A2=E5=8D=95=E4=B8=8E=E6=94=B6=E6=AC=BE=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/customers.py | 4 + backend/app/api/orders.py | 2 + backend/app/api/payments.py | 29 +- backend/app/models/__init__.py | 3 +- backend/app/models/customer.py | 2 + backend/app/models/order.py | 2 + backend/app/models/payment.py | 50 ++- backend/app/schemas/customer.py | 13 + backend/app/schemas/intake.py | 2 + backend/app/schemas/order.py | 13 + backend/app/schemas/payment.py | 17 ++ backend/app/schemas/task.py | 2 + backend/app/services/intake.py | 25 +- backend/app/services/order_customers.py | 9 + backend/app/services/orders.py | 6 + backend/app/services/payments.py | 285 +++++++++++++++--- backend/app/services/task_execution.py | 2 + .../versions/0015_customer_access_split.py | 164 ++++++++++ .../versions/0016_payment_soft_delete.py | 98 ++++++ backend/tests/test_customer_api.py | 15 +- backend/tests/test_intake_api.py | 6 +- backend/tests/test_migrations.py | 101 +++++++ backend/tests/test_order_api.py | 11 +- backend/tests/test_p18_workflow.py | 13 +- backend/tests/test_payment_api.py | 122 +++++++- backend/tests/test_task_api.py | 2 + .../src/features/customers/CustomerForms.tsx | 28 +- .../features/customers/CustomersPage.test.tsx | 13 +- .../src/features/customers/CustomersPage.tsx | 17 +- frontend/src/features/customers/types.ts | 4 + .../features/intake/AdminIntakePage.test.tsx | 3 + .../src/features/intake/AdminIntakePage.tsx | 12 +- frontend/src/features/intake/constants.ts | 2 + frontend/src/features/intake/types.ts | 2 + .../features/mobile/MobileTaskPage.test.tsx | 10 +- .../src/features/mobile/MobileTaskPage.tsx | 4 +- frontend/src/features/orders/OrderForm.tsx | 13 +- .../features/orders/OrderScheduleCalendar.tsx | 4 +- .../src/features/orders/OrdersPage.test.tsx | 18 +- frontend/src/features/orders/OrdersPage.tsx | 16 +- frontend/src/features/orders/api.test.ts | 2 + frontend/src/features/orders/types.ts | 4 + .../features/payments/PaymentDeleteDialog.tsx | 83 +++++ .../src/features/payments/PaymentForm.tsx | 2 +- .../features/payments/PaymentsPage.test.tsx | 77 ++++- .../src/features/payments/PaymentsPage.tsx | 56 +++- frontend/src/features/payments/api.ts | 23 ++ frontend/src/features/payments/types.ts | 11 + .../features/plans/DailyPlansPage.test.tsx | 2 +- frontend/src/features/plans/RouteMap.test.tsx | 33 +- frontend/src/features/plans/RouteMap.tsx | 9 +- .../src/features/plans/RouteWorkspace.tsx | 7 +- .../src/features/plans/mapProvider.test.ts | 5 +- frontend/src/features/plans/mapProvider.ts | 29 +- frontend/src/features/plans/types.ts | 1 + .../features/tasks/TaskExecutionPage.test.tsx | 8 +- .../src/features/tasks/TaskExecutionPage.tsx | 5 +- frontend/src/features/tasks/types.ts | 2 + frontend/src/styles.css | 9 + 59 files changed, 1386 insertions(+), 126 deletions(-) create mode 100644 backend/migrations/versions/0015_customer_access_split.py create mode 100644 backend/migrations/versions/0016_payment_soft_delete.py create mode 100644 frontend/src/features/payments/PaymentDeleteDialog.tsx diff --git a/backend/app/api/customers.py b/backend/app/api/customers.py index de268ea..2965f0f 100644 --- a/backend/app/api/customers.py +++ b/backend/app/api/customers.py @@ -244,6 +244,10 @@ def update_customer( ) -> CustomerDetail: customer = _load_customer(session, customer_id) updates = payload.model_dump(exclude_unset=True) + if updates.get("community_access_method") is not None or updates.get( + "building_access_method" + ) is not None: + updates["access_method"] = None address_changed = any( field in GEOCODE_ADDRESS_FIELDS and getattr(customer, field) != value for field, value in updates.items() diff --git a/backend/app/api/orders.py b/backend/app/api/orders.py index 5ec9e8c..03a380d 100644 --- a/backend/app/api/orders.py +++ b/backend/app/api/orders.py @@ -352,6 +352,8 @@ def get_order_form_options(session: DatabaseSession) -> OrderFormOptions: unit=customer.unit, room=customer.room, access_method=customer.access_method, + community_access_method=customer.community_access_method, + building_access_method=customer.building_access_method, access_info=customer.access_info, key_status=customer.key_status, key_code=customer.key_code, diff --git a/backend/app/api/payments.py b/backend/app/api/payments.py index 3528e73..e766c8d 100644 --- a/backend/app/api/payments.py +++ b/backend/app/api/payments.py @@ -7,12 +7,21 @@ from app.db.session import get_db from app.schemas.payment import ( PaymentCreate, + PaymentDeleteRequest, + PaymentMutationResult, PaymentRegistration, + PaymentRestoreRequest, PaymentVoidRequest, PaymentVoidResult, PaymentsOverview, ) -from app.services.payments import get_payments_overview, register_payment, void_payment +from app.services.payments import ( + delete_payment, + get_payments_overview, + register_payment, + restore_payment, + void_payment, +) router = APIRouter(prefix="/api/admin/payments", tags=["admin-payments"]) @@ -43,3 +52,21 @@ def void_payment_record( session: DatabaseSession, ) -> PaymentVoidResult: return void_payment(session, payment_id, payload) + + +@router.post("/{payment_id}/delete", response_model=PaymentMutationResult) +def delete_payment_record( + payment_id: int, + payload: PaymentDeleteRequest, + session: DatabaseSession, +) -> PaymentMutationResult: + return delete_payment(session, payment_id, payload) + + +@router.post("/{payment_id}/restore", response_model=PaymentMutationResult) +def restore_payment_record( + payment_id: int, + payload: PaymentRestoreRequest, + session: DatabaseSession, +) -> PaymentMutationResult: + return restore_payment(session, payment_id, payload) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 8cf4e42..76c3c4a 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -13,7 +13,7 @@ ) from app.models.intake import CustomerFormSubmission, CustomerFormToken, IntakeAuditEvent from app.models.order import Order, OrderCat, OrderServiceDate -from app.models.payment import Payment +from app.models.payment import Payment, PaymentRecordAuditEvent from app.models.system import SystemFlag from app.models.task import Task, TaskItem, TaskPhoto @@ -34,6 +34,7 @@ "OrderSettlementMode", "OrderStatus", "Payment", + "PaymentRecordAuditEvent", "PaymentMethod", "PaymentRecordStatus", "SystemFlag", diff --git a/backend/app/models/customer.py b/backend/app/models/customer.py index 80b8637..a258093 100644 --- a/backend/app/models/customer.py +++ b/backend/app/models/customer.py @@ -40,6 +40,8 @@ class Customer(TimestampMixin, Base): unit: Mapped[str | None] = mapped_column(String(50)) room: Mapped[str | None] = mapped_column(String(50)) access_method: Mapped[str | None] = mapped_column(String(100)) + community_access_method: Mapped[str | None] = mapped_column(String(100)) + building_access_method: Mapped[str | None] = mapped_column(String(100)) access_info: Mapped[str | None] = mapped_column(Text) key_status: Mapped[str | None] = mapped_column(String(50)) key_code: Mapped[str | None] = mapped_column(String(100)) diff --git a/backend/app/models/order.py b/backend/app/models/order.py index bed2064..b3aa45e 100644 --- a/backend/app/models/order.py +++ b/backend/app/models/order.py @@ -76,6 +76,8 @@ class Order(TimestampMixin, Base): contact_unit: Mapped[str | None] = mapped_column(String(50)) contact_room: Mapped[str | None] = mapped_column(String(50)) contact_access_method: Mapped[str | None] = mapped_column(String(100)) + contact_community_access_method: Mapped[str | None] = mapped_column(String(100)) + contact_building_access_method: Mapped[str | None] = mapped_column(String(100)) contact_access_info: Mapped[str | None] = mapped_column(Text) contact_key_status: Mapped[str | None] = mapped_column(String(50)) contact_key_code: Mapped[str | None] = mapped_column(String(100)) diff --git a/backend/app/models/payment.py b/backend/app/models/payment.py index 76959c1..673aa1d 100644 --- a/backend/app/models/payment.py +++ b/backend/app/models/payment.py @@ -2,7 +2,7 @@ from decimal import Decimal from typing import TYPE_CHECKING -from sqlalchemy import CheckConstraint, Date, DateTime, ForeignKey, Index, Numeric, String, Text +from sqlalchemy import CheckConstraint, Date, DateTime, ForeignKey, Index, Numeric, String, Text, func from sqlalchemy.orm import Mapped, mapped_column, relationship from app.db.base import Base @@ -33,6 +33,12 @@ class Payment(TimestampMixin, Base): "OR (payment_status != 'voided' AND voided_at IS NULL AND voided_reason IS NULL)", name="payment_void_audit", ), + CheckConstraint( + "(deleted_at IS NULL AND deleted_reason IS NULL) OR " + "(deleted_at IS NOT NULL AND deleted_reason IS NOT NULL " + "AND length(trim(deleted_reason)) BETWEEN 1 AND 500)", + name="payment_delete_audit", + ), Index("ix_payments_customer_paid_at", "customer_id", "paid_at"), Index("ix_payments_order_service_date", "order_id", "service_date"), ) @@ -66,6 +72,48 @@ class Payment(TimestampMixin, Base): notes: Mapped[str | None] = mapped_column(Text) voided_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) voided_reason: Mapped[str | None] = mapped_column(Text) + deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True) + deleted_reason: Mapped[str | None] = mapped_column(Text) order: Mapped["Order"] = relationship(back_populates="payments") customer: Mapped["Customer | None"] = relationship(back_populates="payments") + audit_events: Mapped[list["PaymentRecordAuditEvent"]] = relationship( + back_populates="payment", + order_by="PaymentRecordAuditEvent.created_at", + ) + + +class PaymentRecordAuditEvent(Base): + __tablename__ = "payment_record_audit_events" + __table_args__ = ( + CheckConstraint( + "action IN ('deleted', 'restored')", + name="payment_record_audit_action_values", + ), + CheckConstraint( + "(action = 'deleted' AND reason IS NOT NULL " + "AND length(trim(reason)) BETWEEN 1 AND 500) OR " + "(action = 'restored' AND reason IS NULL)", + name="payment_record_audit_reason", + ), + Index( + "ix_payment_record_audit_events_payment_created", + "payment_id", + "created_at", + ), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + payment_id: Mapped[int] = mapped_column( + ForeignKey("payments.id", ondelete="RESTRICT"), + nullable=False, + ) + action: Mapped[str] = mapped_column(String(16), nullable=False) + reason: Mapped[str | None] = mapped_column(Text) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + server_default=func.now(), + ) + + payment: Mapped[Payment] = relationship(back_populates="audit_events") diff --git a/backend/app/schemas/customer.py b/backend/app/schemas/customer.py index 79e3620..85babb5 100644 --- a/backend/app/schemas/customer.py +++ b/backend/app/schemas/customer.py @@ -26,12 +26,23 @@ class CustomerFields(NormalizedModel): unit: str | None = Field(default=None, max_length=50) room: str | None = Field(default=None, max_length=50) access_method: str | None = Field(default=None, max_length=100) + community_access_method: str | None = Field(default=None, max_length=100) + building_access_method: str | None = Field(default=None, max_length=100) access_info: str | None = Field(default=None, max_length=4000) key_status: str | None = Field(default=None, max_length=50) key_code: str | None = Field(default=None, max_length=100) notes: str | None = Field(default=None, max_length=4000) is_repeat_customer: bool = False + @model_validator(mode="after") + def clear_classified_legacy_access(self) -> Self: + if ( + self.community_access_method is not None + or self.building_access_method is not None + ): + self.access_method = None + return self + class CustomerCreate(CustomerFields): name: str = Field(min_length=1, max_length=100) @@ -47,6 +58,8 @@ class CustomerUpdate(NormalizedModel): unit: str | None = Field(default=None, max_length=50) room: str | None = Field(default=None, max_length=50) access_method: str | None = Field(default=None, max_length=100) + community_access_method: str | None = Field(default=None, max_length=100) + building_access_method: str | None = Field(default=None, max_length=100) access_info: str | None = Field(default=None, max_length=4000) key_status: str | None = Field(default=None, max_length=50) key_code: str | None = Field(default=None, max_length=100) diff --git a/backend/app/schemas/intake.py b/backend/app/schemas/intake.py index bf04bda..a9e3732 100644 --- a/backend/app/schemas/intake.py +++ b/backend/app/schemas/intake.py @@ -29,6 +29,8 @@ class IntakeCustomerDraft(IntakeModel): unit: str | None = Field(default=None, max_length=50) room: str | None = Field(default=None, max_length=50) access_method: str | None = Field(default=None, max_length=100) + community_access_method: str | None = Field(default=None, max_length=100) + building_access_method: str | None = Field(default=None, max_length=100) access_info: str | None = Field(default=None, max_length=4000) key_status: str | None = Field(default=None, max_length=50) key_code: str | None = Field(default=None, max_length=100) diff --git a/backend/app/schemas/order.py b/backend/app/schemas/order.py index 342c3dc..16f0ffd 100644 --- a/backend/app/schemas/order.py +++ b/backend/app/schemas/order.py @@ -132,6 +132,8 @@ class OrderServiceContact(NormalizedOrderModel): unit: str | None = Field(default=None, max_length=50) room: str | None = Field(default=None, max_length=50) access_method: str | None = Field(default=None, max_length=100) + community_access_method: str | None = Field(default=None, max_length=100) + building_access_method: str | None = Field(default=None, max_length=100) access_info: str | None = Field(default=None, max_length=4000) key_status: str | None = Field(default=None, max_length=50) key_code: str | None = Field(default=None, max_length=100) @@ -141,6 +143,15 @@ class OrderServiceContact(NormalizedOrderModel): longitude: Decimal | None = Field(default=None, ge=-180, le=180) geocode_status: str | None = Field(default=None, max_length=32) + @model_validator(mode="after") + def clear_classified_legacy_access(self) -> Self: + if ( + self.community_access_method is not None + or self.building_access_method is not None + ): + self.access_method = None + return self + class OrderCatSnapshot(NormalizedOrderModel): source_cat_id: int | None = Field(default=None, gt=0) @@ -411,6 +422,8 @@ class OrderCustomerOption(BaseModel): unit: str | None room: str | None access_method: str | None + community_access_method: str | None + building_access_method: str | None access_info: str | None key_status: str | None key_code: str | None diff --git a/backend/app/schemas/payment.py b/backend/app/schemas/payment.py index 59b557b..f4f946b 100644 --- a/backend/app/schemas/payment.py +++ b/backend/app/schemas/payment.py @@ -80,6 +80,8 @@ class PaymentRecordRead(BaseModel): paid_at: datetime | None voided_at: datetime | None voided_reason: str | None + deleted_at: datetime | None + deleted_reason: str | None revision: str = Field(pattern=r"^[0-9a-f]{64}$") @@ -89,6 +91,7 @@ class PaymentsOverview(BaseModel): metrics: PaymentMetrics receivables: list[PaymentReceivable] records: list[PaymentRecordRead] + deleted_records: list[PaymentRecordRead] class PaymentRegistration(BaseModel): @@ -103,6 +106,16 @@ class PaymentVoidRequest(BaseModel): reason: str = Field(min_length=1, max_length=500) +class PaymentDeleteRequest(PaymentVoidRequest): + pass + + +class PaymentRestoreRequest(BaseModel): + model_config = ConfigDict(str_strip_whitespace=True, extra="forbid") + + expected_revision: str = Field(pattern=r"^[0-9a-f]{64}$") + + class PaymentVoidResult(BaseModel): payment: PaymentRecordRead order_id: int @@ -111,3 +124,7 @@ class PaymentVoidResult(BaseModel): overpaid_amount: Decimal = Field(ge=0) payment_status: OrderPaymentStatus revision: str = Field(pattern=r"^[0-9a-f]{64}$") + + +class PaymentMutationResult(PaymentVoidResult): + pass diff --git a/backend/app/schemas/task.py b/backend/app/schemas/task.py index e0fda2a..bea7c8c 100644 --- a/backend/app/schemas/task.py +++ b/backend/app/schemas/task.py @@ -27,6 +27,8 @@ class TaskExecutionCustomer(BaseModel): unit: str | None room: str | None access_method: str | None + community_access_method: str | None + building_access_method: str | None access_info: str | None key_status: str | None key_code: str | None diff --git a/backend/app/services/intake.py b/backend/app/services/intake.py index 3b6bb38..687bbb5 100644 --- a/backend/app/services/intake.py +++ b/backend/app/services/intake.py @@ -237,13 +237,11 @@ def _stored_payload_from_public( ) -> IntakeDraftPayload: public_data = payload.model_dump(mode="json") customer = dict(public_data["customer"]) - community_access = customer.pop("community_access_method", None) - building_access = customer.pop("building_access_method", None) - if community_access is not None or building_access is not None: - customer["access_method"] = ( - f"小区门禁:{community_access or '待确认'};" - f"楼下门禁:{building_access or '待确认'}" - ) + if ( + customer.get("community_access_method") is not None + or customer.get("building_access_method") is not None + ): + customer["access_method"] = None return IntakeDraftPayload.model_validate( { "customer": customer, @@ -280,9 +278,16 @@ def _split_public_access_method( def _public_payload_from_stored(payload: dict[str, object]) -> PublicIntakeDraftPayload: stored = IntakeDraftPayload.model_validate(payload or {}) - community_access, building_access, legacy_access = _split_public_access_method( - stored.customer.access_method - ) + community_access = stored.customer.community_access_method + building_access = stored.customer.building_access_method + legacy_access = stored.customer.access_method + if community_access is None and building_access is None: + parsed_community, parsed_building, parsed_legacy = _split_public_access_method( + legacy_access + ) + community_access = parsed_community + building_access = parsed_building + legacy_access = parsed_legacy return PublicIntakeDraftPayload.model_validate( { "customer": { diff --git a/backend/app/services/order_customers.py b/backend/app/services/order_customers.py index 806a61a..46bb5d8 100644 --- a/backend/app/services/order_customers.py +++ b/backend/app/services/order_customers.py @@ -46,6 +46,11 @@ def _ambiguous(stage: str, customers: list[Customer]) -> None: def _fill_empty_fields(customer: Customer, contact: OrderServiceContact) -> None: + if ( + contact.community_access_method is not None + or contact.building_access_method is not None + ): + customer.access_method = None for field in ( "wechat_name", "phone", @@ -55,6 +60,8 @@ def _fill_empty_fields(customer: Customer, contact: OrderServiceContact) -> None "unit", "room", "access_method", + "community_access_method", + "building_access_method", "access_info", "key_status", "key_code", @@ -148,6 +155,8 @@ def resolve_order_customer( unit=contact.unit, room=contact.room, access_method=contact.access_method, + community_access_method=contact.community_access_method, + building_access_method=contact.building_access_method, access_info=contact.access_info, key_status=contact.key_status, key_code=contact.key_code, diff --git a/backend/app/services/orders.py b/backend/app/services/orders.py index 0c6241b..8bd3b60 100644 --- a/backend/app/services/orders.py +++ b/backend/app/services/orders.py @@ -234,6 +234,8 @@ def customer_service_contact(customer: Customer) -> OrderServiceContact: unit=customer.unit, room=customer.room, access_method=customer.access_method, + community_access_method=customer.community_access_method, + building_access_method=customer.building_access_method, access_info=customer.access_info, key_status=customer.key_status, key_code=customer.key_code, @@ -277,6 +279,8 @@ def apply_service_contact(order: Order, contact: OrderServiceContact) -> None: order.contact_unit = contact.unit order.contact_room = contact.room order.contact_access_method = contact.access_method + order.contact_community_access_method = contact.community_access_method + order.contact_building_access_method = contact.building_access_method order.contact_access_info = contact.access_info order.contact_key_status = contact.key_status order.contact_key_code = contact.key_code @@ -295,6 +299,8 @@ def order_service_contact(order: Order) -> OrderServiceContact: unit=order.contact_unit, room=order.contact_room, access_method=order.contact_access_method, + community_access_method=order.contact_community_access_method, + building_access_method=order.contact_building_access_method, access_info=order.contact_access_info, key_status=order.contact_key_status, key_code=order.contact_key_code, diff --git a/backend/app/services/payments.py b/backend/app/services/payments.py index c3d00f2..255577e 100644 --- a/backend/app/services/payments.py +++ b/backend/app/services/payments.py @@ -14,13 +14,16 @@ PaymentRecordStatus, ) from app.models.order import Order, OrderCat -from app.models.payment import Payment +from app.models.payment import Payment, PaymentRecordAuditEvent from app.schemas.payment import ( PaymentCreate, + PaymentDeleteRequest, PaymentMetrics, + PaymentMutationResult, PaymentReceivable, PaymentRecordRead, PaymentRegistration, + PaymentRestoreRequest, PaymentVoidRequest, PaymentVoidResult, PaymentsOverview, @@ -110,6 +113,8 @@ def payment_revision(order: Order) -> str: "payment_method": payment.payment_method.value, "voided_at": _datetime_value(payment.voided_at), "voided_reason": payment.voided_reason, + "deleted_at": _datetime_value(payment.deleted_at), + "deleted_reason": payment.deleted_reason, } for payment in sorted(order.payments, key=lambda entry: entry.id) ], @@ -210,6 +215,8 @@ def _payment_record(payment: Payment) -> PaymentRecordRead: paid_at=as_utc(payment.paid_at), voided_at=as_utc(payment.voided_at), voided_reason=payment.voided_reason, + deleted_at=as_utc(payment.deleted_at), + deleted_reason=payment.deleted_reason, revision=payment_revision(order), ) @@ -250,6 +257,7 @@ def _completed_income( total = session.scalar( select(func.sum(Payment.amount)).where( Payment.payment_status == PaymentRecordStatus.COMPLETED, + Payment.deleted_at.is_(None), Payment.paid_at.is_not(None), Payment.paid_at >= start_utc, Payment.paid_at < end_utc, @@ -270,6 +278,7 @@ def get_payments_overview( select(func.count(Order.id)).where(Order.order_status == OrderStatus.COMPLETED) ) receivables = [item for order in receivable_orders for item in _receivables(order)] + payment_records = _load_payment_records(session) return PaymentsOverview( business_date=target_date, month_start=target_date.replace(day=1), @@ -280,7 +289,16 @@ def get_payments_overview( completed_order_count=completed_order_count or 0, ), receivables=receivables, - records=[_payment_record(payment) for payment in _load_payment_records(session)], + records=[ + _payment_record(payment) + for payment in payment_records + if payment.deleted_at is None + ], + deleted_records=[ + _payment_record(payment) + for payment in payment_records + if payment.deleted_at is not None + ], ) @@ -412,6 +430,80 @@ def register_payment( ) +def _payment_status_after_completed_amount( + order: Order, + next_paid: Decimal, +) -> OrderPaymentStatus: + if next_paid <= 0: + return OrderPaymentStatus.UNPAID + if ( + order.settlement_mode is OrderSettlementMode.DAILY + and any(item.due_amount > 0 for item in order_daily_receivables(order)) + ): + return OrderPaymentStatus.PARTIAL + return payment_status_for_amounts( + total_amount=order.total_amount, + paid_amount=next_paid, + ) + + +def _update_order_payment_state( + session: Session, + *, + order: Order, + previous_paid: Decimal, + previous_status: OrderPaymentStatus, + next_paid: Decimal, + next_status: OrderPaymentStatus, +) -> None: + order_result = session.execute( + update(Order) + .where( + Order.id == order.id, + Order.write_revision_number == order.write_revision_number, + Order.total_amount == order.total_amount, + Order.paid_amount == previous_paid, + Order.payment_status == previous_status, + Order.order_status == order.order_status, + Order.settlement_mode == order.settlement_mode, + Order.adjustment_type == order.adjustment_type, + Order.adjustment_amount == order.adjustment_amount, + Order.adjustment_service_date == order.adjustment_service_date, + ) + .values( + paid_amount=next_paid, + payment_status=next_status, + write_revision_number=order.write_revision_number + 1, + ) + .execution_options(synchronize_session=False) + ) + if order_result.rowcount != 1: + session.rollback() + raise HTTPException(status_code=409, detail="订单收款信息已变化,请刷新后重试") + + +def _payment_mutation_result( + session: Session, + *, + order_id: int, + payment_id: int, +) -> PaymentMutationResult: + session.expire_all() + refreshed_order = _load_payment_order(session, order_id) + refreshed_payment = next( + entry for entry in refreshed_order.payments if entry.id == payment_id + ) + return PaymentMutationResult( + payment=_payment_record(refreshed_payment), + order_id=refreshed_order.id, + paid_amount=money(refreshed_order.paid_amount), + due_amount=due_amount(refreshed_order), + overpaid_amount=overpaid_amount(refreshed_order), + payment_status=refreshed_order.payment_status, + revision=payment_revision(refreshed_order), + ) + + def void_payment( session: Session, payment_id: int, @@ -423,6 +515,8 @@ def void_payment( order = _load_payment_order(session, payment.order_id) payment = next(entry for entry in order.payments if entry.id == payment_id) + if payment.deleted_at is not None: + raise HTTPException(status_code=409, detail="已删除的收款流水不能撤销") if payment.payment_status is not PaymentRecordStatus.COMPLETED: raise HTTPException(status_code=409, detail="只有已完成的收款流水可以撤销") if order.payment_status is OrderPaymentStatus.REFUNDED: @@ -438,6 +532,7 @@ def void_payment( Payment.id == payment.id, Payment.order_id == order.id, Payment.payment_status == PaymentRecordStatus.COMPLETED, + Payment.deleted_at.is_(None), ) .values( payment_status=PaymentRecordStatus.VOIDED, @@ -460,43 +555,15 @@ def void_payment( Decimal("0.00"), ) ) - if next_paid <= 0: - next_status = OrderPaymentStatus.UNPAID - elif ( - order.settlement_mode is OrderSettlementMode.DAILY - and any(item.due_amount > 0 for item in order_daily_receivables(order)) - ): - next_status = OrderPaymentStatus.PARTIAL - else: - next_status = payment_status_for_amounts( - total_amount=order.total_amount, - paid_amount=next_paid, - ) - - order_result = session.execute( - update(Order) - .where( - Order.id == order.id, - Order.write_revision_number == order.write_revision_number, - Order.total_amount == order.total_amount, - Order.paid_amount == previous_paid, - Order.payment_status == previous_status, - Order.order_status == order.order_status, - Order.settlement_mode == order.settlement_mode, - Order.adjustment_type == order.adjustment_type, - Order.adjustment_amount == order.adjustment_amount, - Order.adjustment_service_date == order.adjustment_service_date, - ) - .values( - paid_amount=next_paid, - payment_status=next_status, - write_revision_number=order.write_revision_number + 1, - ) - .execution_options(synchronize_session=False) + next_status = _payment_status_after_completed_amount(order, next_paid) + _update_order_payment_state( + session, + order=order, + previous_paid=previous_paid, + previous_status=previous_status, + next_paid=next_paid, + next_status=next_status, ) - if order_result.rowcount != 1: - session.rollback() - raise HTTPException(status_code=409, detail="订单收款信息已变化,请刷新后重试") session.commit() session.expire_all() @@ -513,3 +580,145 @@ def void_payment( payment_status=refreshed_order.payment_status, revision=payment_revision(refreshed_order), ) + + +def delete_payment( + session: Session, + payment_id: int, + payload: PaymentDeleteRequest, +) -> PaymentMutationResult: + payment = session.get(Payment, payment_id) + if payment is None: + raise HTTPException(status_code=404, detail="收款流水不存在") + + order = _load_payment_order(session, payment.order_id) + payment = next(entry for entry in order.payments if entry.id == payment_id) + require_payment_revision(order, payload.expected_revision) + if payment.deleted_at is not None: + raise HTTPException(status_code=409, detail="收款流水已经删除") + if ( + payment.payment_status is PaymentRecordStatus.COMPLETED + and order.payment_status is OrderPaymentStatus.REFUNDED + ): + raise HTTPException(status_code=409, detail="已退款订单的完成流水不能自动撤销删除") + + previous_paid = money(order.paid_amount) + previous_status = order.payment_status + previous_payment_status = payment.payment_status + deleted_at = datetime.now(timezone.utc) + values: dict[str, object] = { + "deleted_at": deleted_at, + "deleted_reason": payload.reason, + } + if previous_payment_status is PaymentRecordStatus.COMPLETED: + values.update( + payment_status=PaymentRecordStatus.VOIDED, + voided_at=deleted_at, + voided_reason=payload.reason, + ) + payment_result = session.execute( + update(Payment) + .where( + Payment.id == payment.id, + Payment.order_id == order.id, + Payment.payment_status == previous_payment_status, + Payment.deleted_at.is_(None), + ) + .values(**values) + .execution_options(synchronize_session="fetch") + ) + if payment_result.rowcount != 1: + session.rollback() + raise HTTPException(status_code=409, detail="收款流水状态已变化,请刷新后重试") + + next_paid = previous_paid + next_status = previous_status + if previous_payment_status is PaymentRecordStatus.COMPLETED: + next_paid = money( + sum( + ( + entry.amount + for entry in order.payments + if entry.payment_status is PaymentRecordStatus.COMPLETED + ), + Decimal("0.00"), + ) + ) + next_status = _payment_status_after_completed_amount(order, next_paid) + _update_order_payment_state( + session, + order=order, + previous_paid=previous_paid, + previous_status=previous_status, + next_paid=next_paid, + next_status=next_status, + ) + session.add( + PaymentRecordAuditEvent( + payment_id=payment.id, + action="deleted", + reason=payload.reason, + ) + ) + session.commit() + return _payment_mutation_result( + session, + order_id=order.id, + payment_id=payment.id, + ) + + +def restore_payment( + session: Session, + payment_id: int, + payload: PaymentRestoreRequest, +) -> PaymentMutationResult: + payment = session.get(Payment, payment_id) + if payment is None: + raise HTTPException(status_code=404, detail="收款流水不存在") + + order = _load_payment_order(session, payment.order_id) + payment = next(entry for entry in order.payments if entry.id == payment_id) + require_payment_revision(order, payload.expected_revision) + if payment.deleted_at is None: + raise HTTPException(status_code=409, detail="收款流水当前未删除") + + previous_paid = money(order.paid_amount) + previous_status = order.payment_status + payment_result = session.execute( + update(Payment) + .where( + Payment.id == payment.id, + Payment.order_id == order.id, + Payment.payment_status == payment.payment_status, + Payment.deleted_at == payment.deleted_at, + Payment.deleted_reason == payment.deleted_reason, + ) + .values(deleted_at=None, deleted_reason=None) + .execution_options(synchronize_session="fetch") + ) + if payment_result.rowcount != 1: + session.rollback() + raise HTTPException(status_code=409, detail="收款流水状态已变化,请刷新后重试") + + _update_order_payment_state( + session, + order=order, + previous_paid=previous_paid, + previous_status=previous_status, + next_paid=previous_paid, + next_status=previous_status, + ) + session.add( + PaymentRecordAuditEvent( + payment_id=payment.id, + action="restored", + reason=None, + ) + ) + session.commit() + return _payment_mutation_result( + session, + order_id=order.id, + payment_id=payment.id, + ) diff --git a/backend/app/services/task_execution.py b/backend/app/services/task_execution.py index 9d60617..471843e 100644 --- a/backend/app/services/task_execution.py +++ b/backend/app/services/task_execution.py @@ -179,6 +179,8 @@ def task_execution_detail(task: Task) -> TaskExecutionDetail: unit=order.contact_unit, room=order.contact_room, access_method=order.contact_access_method, + community_access_method=order.contact_community_access_method, + building_access_method=order.contact_building_access_method, access_info=order.contact_access_info, key_status=order.contact_key_status, key_code=order.contact_key_code, diff --git a/backend/migrations/versions/0015_customer_access_split.py b/backend/migrations/versions/0015_customer_access_split.py new file mode 100644 index 0000000..74e4c7e --- /dev/null +++ b/backend/migrations/versions/0015_customer_access_split.py @@ -0,0 +1,164 @@ +"""Split community and building access methods while preserving legacy values. + +Revision ID: 0015_customer_access_split +Revises: 0014_consistency_guards +""" + +from collections.abc import Sequence +import re + +from alembic import op +import sqlalchemy as sa + + +revision: str = "0015_customer_access_split" +down_revision: str | None = "0014_consistency_guards" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +ACCESS_PATTERN = re.compile( + r"^小区门禁:(?P[^;]+);楼下门禁:(?P[^;]+)$" +) + + +def _set_sqlite_foreign_keys(*, enabled: bool) -> None: + connection = op.get_bind() + if connection.dialect.name != "sqlite": + return + with op.get_context().autocommit_block(): + op.execute(sa.text(f"PRAGMA foreign_keys={'ON' if enabled else 'OFF'}")) + + +def _split(value: str | None) -> tuple[str | None, str | None] | None: + if not value: + return None + match = ACCESS_PATTERN.fullmatch(value) + if match is None: + return None + community = match.group("community") + building = match.group("building") + return ( + None if community == "待确认" else community, + None if building == "待确认" else building, + ) + + +def _backfill_split_fields() -> None: + connection = op.get_bind() + customer_rows = connection.execute( + sa.text("SELECT id, access_method FROM customers WHERE access_method IS NOT NULL") + ).mappings().all() + for row in customer_rows: + parsed = _split(row["access_method"]) + if parsed is None: + continue + connection.execute( + sa.text( + "UPDATE customers SET access_method = NULL, " + "community_access_method = :community, " + "building_access_method = :building WHERE id = :id" + ), + {"id": row["id"], "community": parsed[0], "building": parsed[1]}, + ) + + order_rows = connection.execute( + sa.text( + "SELECT id, contact_access_method FROM orders " + "WHERE contact_access_method IS NOT NULL" + ) + ).mappings().all() + for row in order_rows: + parsed = _split(row["contact_access_method"]) + if parsed is None: + continue + connection.execute( + sa.text( + "UPDATE orders SET contact_access_method = NULL, " + "contact_community_access_method = :community, " + "contact_building_access_method = :building WHERE id = :id" + ), + {"id": row["id"], "community": parsed[0], "building": parsed[1]}, + ) + + +def _merge_for_downgrade( + *, + legacy: str | None, + community: str | None, + building: str | None, +) -> str | None: + if legacy: + if community is not None or building is not None: + raise RuntimeError("历史门禁与已分类门禁同时存在,无法无损降级到 0014") + return legacy + if community is None and building is None: + return None + merged = f"小区门禁:{community or '待确认'};楼下门禁:{building or '待确认'}" + if len(merged) > 100: + raise RuntimeError("门禁字段合并后超过 100 字符,无法无损降级到 0014") + return merged + + +def _restore_legacy_fields() -> None: + connection = op.get_bind() + customer_rows = connection.execute( + sa.text( + "SELECT id, access_method, community_access_method, " + "building_access_method FROM customers" + ) + ).mappings().all() + for row in customer_rows: + merged = _merge_for_downgrade( + legacy=row["access_method"], + community=row["community_access_method"], + building=row["building_access_method"], + ) + connection.execute( + sa.text("UPDATE customers SET access_method = :value WHERE id = :id"), + {"id": row["id"], "value": merged}, + ) + + order_rows = connection.execute( + sa.text( + "SELECT id, contact_access_method, contact_community_access_method, " + "contact_building_access_method FROM orders" + ) + ).mappings().all() + for row in order_rows: + merged = _merge_for_downgrade( + legacy=row["contact_access_method"], + community=row["contact_community_access_method"], + building=row["contact_building_access_method"], + ) + connection.execute( + sa.text("UPDATE orders SET contact_access_method = :value WHERE id = :id"), + {"id": row["id"], "value": merged}, + ) + + +def upgrade() -> None: + _set_sqlite_foreign_keys(enabled=False) + with op.batch_alter_table("customers") as batch_op: + batch_op.add_column(sa.Column("community_access_method", sa.String(length=100))) + batch_op.add_column(sa.Column("building_access_method", sa.String(length=100))) + with op.batch_alter_table("orders") as batch_op: + batch_op.add_column( + sa.Column("contact_community_access_method", sa.String(length=100)) + ) + batch_op.add_column( + sa.Column("contact_building_access_method", sa.String(length=100)) + ) + _set_sqlite_foreign_keys(enabled=True) + _backfill_split_fields() + + +def downgrade() -> None: + _restore_legacy_fields() + _set_sqlite_foreign_keys(enabled=False) + with op.batch_alter_table("orders") as batch_op: + batch_op.drop_column("contact_building_access_method") + batch_op.drop_column("contact_community_access_method") + with op.batch_alter_table("customers") as batch_op: + batch_op.drop_column("building_access_method") + batch_op.drop_column("community_access_method") + _set_sqlite_foreign_keys(enabled=True) diff --git a/backend/migrations/versions/0016_payment_soft_delete.py b/backend/migrations/versions/0016_payment_soft_delete.py new file mode 100644 index 0000000..ccdfdae --- /dev/null +++ b/backend/migrations/versions/0016_payment_soft_delete.py @@ -0,0 +1,98 @@ +"""Add auditable soft deletion and visibility restore for payment records. + +Revision ID: 0016_payment_soft_delete +Revises: 0015_customer_access_split +""" + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + + +revision: str = "0016_payment_soft_delete" +down_revision: str | None = "0015_customer_access_split" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def _set_sqlite_foreign_keys(*, enabled: bool) -> None: + connection = op.get_bind() + if connection.dialect.name != "sqlite": + return + with op.get_context().autocommit_block(): + op.execute(sa.text(f"PRAGMA foreign_keys={'ON' if enabled else 'OFF'}")) + + +def upgrade() -> None: + _set_sqlite_foreign_keys(enabled=False) + with op.batch_alter_table("payments") as batch_op: + batch_op.add_column(sa.Column("deleted_at", sa.DateTime(timezone=True))) + batch_op.add_column(sa.Column("deleted_reason", sa.Text())) + batch_op.create_index("ix_payments_deleted_at", ["deleted_at"]) + batch_op.create_check_constraint( + "payment_delete_audit", + "(deleted_at IS NULL AND deleted_reason IS NULL) OR " + "(deleted_at IS NOT NULL AND deleted_reason IS NOT NULL " + "AND length(trim(deleted_reason)) BETWEEN 1 AND 500)", + ) + + op.create_table( + "payment_record_audit_events", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column( + "payment_id", + sa.Integer(), + sa.ForeignKey("payments.id", ondelete="RESTRICT"), + nullable=False, + ), + sa.Column("action", sa.String(length=16), nullable=False), + sa.Column("reason", sa.Text()), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.CheckConstraint( + "action IN ('deleted', 'restored')", + name="payment_record_audit_action_values", + ), + sa.CheckConstraint( + "(action = 'deleted' AND reason IS NOT NULL " + "AND length(trim(reason)) BETWEEN 1 AND 500) OR " + "(action = 'restored' AND reason IS NULL)", + name="payment_record_audit_reason", + ), + ) + op.create_index( + "ix_payment_record_audit_events_payment_created", + "payment_record_audit_events", + ["payment_id", "created_at"], + ) + _set_sqlite_foreign_keys(enabled=True) + + +def downgrade() -> None: + connection = op.get_bind() + event_count = connection.scalar( + sa.text("SELECT count(*) FROM payment_record_audit_events") + ) + deleted_count = connection.scalar( + sa.text("SELECT count(*) FROM payments WHERE deleted_at IS NOT NULL") + ) + if event_count or deleted_count: + raise RuntimeError("存在收款删除或恢复审计记录,无法无损降级到 0015") + + _set_sqlite_foreign_keys(enabled=False) + op.drop_index( + "ix_payment_record_audit_events_payment_created", + table_name="payment_record_audit_events", + ) + op.drop_table("payment_record_audit_events") + with op.batch_alter_table("payments") as batch_op: + batch_op.drop_constraint("payment_delete_audit", type_="check") + batch_op.drop_index("ix_payments_deleted_at") + batch_op.drop_column("deleted_reason") + batch_op.drop_column("deleted_at") + _set_sqlite_foreign_keys(enabled=True) diff --git a/backend/tests/test_customer_api.py b/backend/tests/test_customer_api.py index 5c0939a..d43759e 100644 --- a/backend/tests/test_customer_api.py +++ b/backend/tests/test_customer_api.py @@ -49,7 +49,9 @@ def test_customer_and_multiple_cats_complete_workflow( "building": "测试楼", "unit": "测试单元", "room": "测试房号", - "access_method": "虚构门禁方式", + "access_method": "历史待分类门禁", + "community_access_method": "门卡", + "building_access_method": "密码", "access_info": "虚构入户说明", "key_status": "未提供", "key_code": "TEST-KEY-NOT-REAL", @@ -61,6 +63,9 @@ def test_customer_and_multiple_cats_complete_workflow( customer = create_response.json() customer_id = customer["id"] assert customer["name"] == "演示客户甲(虚构)" + assert customer["access_method"] is None + assert customer["community_access_method"] == "门卡" + assert customer["building_access_method"] == "密码" assert customer["access_info"] == "虚构入户说明" assert customer["cats"] == [] @@ -257,6 +262,14 @@ def test_simplified_customer_patch_preserves_hidden_legacy_fields( assert updated["room"] == "旧房号" assert updated["access_info"] == "旧入户信息" + classified = client.patch( + f"/api/admin/customers/{created['id']}", + json={"community_access_method": "门卡"}, + ) + assert classified.status_code == 200 + assert classified.json()["access_method"] is None + assert classified.json()["community_access_method"] == "门卡" + def test_customer_api_validation_not_found_and_cat_ownership( customer_api_client: TestClient, diff --git a/backend/tests/test_intake_api.py b/backend/tests/test_intake_api.py index 152dd55..9c8fb4a 100644 --- a/backend/tests/test_intake_api.py +++ b/backend/tests/test_intake_api.py @@ -245,9 +245,9 @@ def test_public_access_classification_and_hidden_legacy_cat_fields_round_trip( with intake_api_context.session_factory() as session: submission = session.scalar(select(CustomerFormSubmission)) assert submission is not None - assert submission.payload["customer"]["access_method"] == ( - "小区门禁:无;楼下门禁:门卡" - ) + assert submission.payload["customer"]["access_method"] is None + assert submission.payload["customer"]["community_access_method"] == "无" + assert submission.payload["customer"]["building_access_method"] == "门卡" assert submission.payload["cats"][0]["food"] == "旧草稿饮食" invalid = client.put( diff --git a/backend/tests/test_migrations.py b/backend/tests/test_migrations.py index 0ed9fed..df1e393 100644 --- a/backend/tests/test_migrations.py +++ b/backend/tests/test_migrations.py @@ -22,6 +22,7 @@ "order_service_dates", "orders", "payments", + "payment_record_audit_events", "system_flags", "task_items", "task_photos", @@ -589,6 +590,106 @@ def test_consistency_guard_migration_adds_versions_markers_and_unique_key( engine.dispose() +def test_customer_access_split_migration_preserves_legacy_and_round_trips(tmp_path) -> None: + database_path = tmp_path / "customer-access-split.db" + database_url = f"sqlite:///{database_path.as_posix()}" + config = alembic_config(database_url) + command.upgrade(config, "0014_consistency_guards") + engine = build_engine(database_url) + try: + with engine.begin() as connection: + connection.execute( + text( + "INSERT INTO customers (id, name, access_method, is_repeat_customer) VALUES " + "(1, '可拆分客户', '小区门禁:联系管家;楼下门禁:钥匙', 0), " + "(2, '历史客户', '旧门禁备注', 0), " + "(3, '单侧待确认客户', '小区门禁:待确认;楼下门禁:门卡', 0)" + ) + ) + connection.execute( + text( + "INSERT INTO orders " + "(id, customer_id, contact_name, contact_access_method, cat_snapshot, " + "start_date, end_date, visits_per_day, cat_count, service_items, " + "pricing_mode, settlement_mode, adjustment_type, adjustment_amount, " + "base_price, extra_cat_fee, stairs_fee, other_fee, total_amount, " + "paid_amount, payment_status, order_status) VALUES " + "(1, 1, '可拆分客户', '小区门禁:无;楼下门禁:门卡', '[]', " + "'2035-10-06', '2035-10-06', 1, 1, '[]', 'per_visit', " + "'order_total', 'none', 0, 30, 0, 0, 0, 30, 0, 'unpaid', 'confirmed')" + ) + ) + + command.upgrade(config, "0015_customer_access_split") + with engine.connect() as connection: + customers = connection.execute( + text( + "SELECT id, access_method, community_access_method, " + "building_access_method FROM customers ORDER BY id" + ) + ).all() + order = connection.execute( + text( + "SELECT contact_access_method, contact_community_access_method, " + "contact_building_access_method FROM orders WHERE id = 1" + ) + ).one() + assert connection.execute(text("PRAGMA integrity_check")).scalar_one() == "ok" + assert connection.execute(text("PRAGMA foreign_key_check")).all() == [] + assert tuple(customers[0]) == (1, None, "联系管家", "钥匙") + assert tuple(customers[1]) == (2, "旧门禁备注", None, None) + assert tuple(customers[2]) == (3, None, None, "门卡") + assert tuple(order) == (None, "无", "门卡") + + command.downgrade(config, "0014_consistency_guards") + with engine.connect() as connection: + restored = connection.execute( + text("SELECT access_method FROM customers ORDER BY id") + ).scalars().all() + assert connection.execute(text("PRAGMA integrity_check")).scalar_one() == "ok" + assert connection.execute(text("PRAGMA foreign_key_check")).all() == [] + assert restored == [ + "小区门禁:联系管家;楼下门禁:钥匙", + "旧门禁备注", + "小区门禁:待确认;楼下门禁:门卡", + ] + command.upgrade(config, "head") + finally: + engine.dispose() + + +def test_payment_soft_delete_migration_upgrades_and_downgrades_0015(tmp_path) -> None: + database_path = tmp_path / "payment-soft-delete.db" + database_url = f"sqlite:///{database_path.as_posix()}" + config = alembic_config(database_url) + command.upgrade(config, "0015_customer_access_split") + engine = build_engine(database_url) + try: + before = inspect(engine) + assert "deleted_at" not in {item["name"] for item in before.get_columns("payments")} + assert "payment_record_audit_events" not in before.get_table_names() + + command.upgrade(config, "0016_payment_soft_delete") + after = inspect(engine) + assert {"deleted_at", "deleted_reason"}.issubset( + item["name"] for item in after.get_columns("payments") + ) + assert "payment_record_audit_events" in after.get_table_names() + with engine.connect() as connection: + assert connection.execute(text("PRAGMA integrity_check")).scalar_one() == "ok" + assert connection.execute(text("PRAGMA foreign_key_check")).all() == [] + + command.downgrade(config, "0015_customer_access_split") + downgraded = inspect(engine) + assert "deleted_at" not in { + item["name"] for item in downgraded.get_columns("payments") + } + assert "payment_record_audit_events" not in downgraded.get_table_names() + command.upgrade(config, "head") + finally: + engine.dispose() + + def test_consistency_guard_migration_backfills_only_exact_historical_demo_seed( tmp_path, ) -> None: diff --git a/backend/tests/test_order_api.py b/backend/tests/test_order_api.py index d5c05d8..a7ca210 100644 --- a/backend/tests/test_order_api.py +++ b/backend/tests/test_order_api.py @@ -57,7 +57,14 @@ def create_customer_with_cats( name: str = "订单测试客户(虚构)", cat_names: tuple[str, ...] = ("订单测试猫甲", "订单测试猫乙"), ) -> tuple[dict, list[dict]]: - customer_response = client.post("/api/admin/customers", json={"name": name}) + customer_response = client.post( + "/api/admin/customers", + json={ + "name": name, + "community_access_method": "门卡", + "building_access_method": "密码", + }, + ) assert customer_response.status_code == 201 customer = customer_response.json() cats = [] @@ -129,6 +136,8 @@ def test_create_seven_day_order_generates_tasks_and_private_summary( assert len(order["tasks"]) == 7 assert all(len(task["items"]) == 4 for task in order["tasks"]) assert all(task["status"] == "pending" for task in order["tasks"]) + assert order["service_contact"]["community_access_method"] == "门卡" + assert order["service_contact"]["building_access_method"] == "密码" list_response = client.get("/api/admin/orders") assert list_response.status_code == 200 diff --git a/backend/tests/test_p18_workflow.py b/backend/tests/test_p18_workflow.py index 51abc11..be7329e 100644 --- a/backend/tests/test_p18_workflow.py +++ b/backend/tests/test_p18_workflow.py @@ -102,7 +102,7 @@ def direct_order_payload(**overrides: object) -> dict[str, object]: return payload -def test_direct_order_matches_restores_and_only_fills_empty_customer_fields( +def test_direct_order_matches_restores_and_classifies_legacy_customer_access( p18_context: P18Context, ) -> None: client = p18_context.client @@ -119,7 +119,12 @@ def test_direct_order_matches_restores_and_only_fills_empty_customer_fields( f"/api/admin/customers/{created['id']}/archive", json={"archived": True} ).status_code == 200 - response = client.post("/api/admin/orders", json=direct_order_payload()) + payload = direct_order_payload() + service_contact = payload["service_contact"] + assert isinstance(service_contact, dict) + service_contact["community_access_method"] = "门卡" + service_contact["building_access_method"] = "钥匙" + response = client.post("/api/admin/orders", json=payload) assert response.status_code == 201 order = response.json() assert order["source_customer_id"] == created["id"] @@ -132,7 +137,9 @@ def test_direct_order_matches_restores_and_only_fills_empty_customer_fields( profile = client.get(f"/api/admin/customers/{created['id']}").json() assert profile["archived_at"] is None assert profile["wechat_name"] == "P18-wechat" - assert profile["access_method"] == "门卡" + assert profile["access_method"] is None + assert profile["community_access_method"] == "门卡" + assert profile["building_access_method"] == "钥匙" assert profile["pending_cat_profile_count"] == 2 assert profile["cats"] == [] with p18_context.session_factory() as session: diff --git a/backend/tests/test_payment_api.py b/backend/tests/test_payment_api.py index d57bab2..a6ae375 100644 --- a/backend/tests/test_payment_api.py +++ b/backend/tests/test_payment_api.py @@ -10,7 +10,7 @@ from app.db.session import build_engine, get_db from app.main import app -from app.models import Order, Payment +from app.models import Order, Payment, PaymentRecordAuditEvent from app.models.enums import ( OrderPaymentStatus, OrderStatus, @@ -119,6 +119,7 @@ def test_payments_empty_state_and_date_validation( }, "receivables": [], "records": [], + "deleted_records": [], } assert payment_api_context.client.get( "/api/admin/payments", @@ -409,6 +410,102 @@ def test_completed_payment_can_be_voided_with_audit_and_reopens_receivable( assert stored.voided_reason == "重复录入,保留本地审计记录" +def test_completed_payment_delete_is_audited_reopens_receivable_and_restore_is_visibility_only( + payment_api_context: PaymentApiContext, +) -> None: + client = payment_api_context.client + _, order = create_payment_order(client, name="P26 删除流水客户(虚构)") + with payment_api_context.session_factory.begin() as session: + model = session.get(Order, order["id"]) + assert model is not None + model.order_status = OrderStatus.COMPLETED + + overview = client.get("/api/admin/payments", params={"date": "2035-10-06"}).json() + receivable = next(item for item in overview["receivables"] if item["order_id"] == order["id"]) + registered = client.post( + "/api/admin/payments", + json={ + "order_id": order["id"], + "amount": "30.00", + "payment_method": "wechat", + "paid_at": "2035-10-06T09:15:00+08:00", + "expected_revision": receivable["revision"], + }, + ).json() + record = registered["payment"] + + stale = client.post( + f"/api/admin/payments/{record['id']}/delete", + json={"expected_revision": "0" * 64, "reason": "过期版本删除"}, + ) + assert stale.status_code == 409 + + deleted = client.post( + f"/api/admin/payments/{record['id']}/delete", + json={ + "expected_revision": record["revision"], + "reason": "重复登记,移入已删除", + }, + ) + assert deleted.status_code == 200 + deleted_payload = deleted.json() + assert deleted_payload["payment"]["payment_status"] == "voided" + assert deleted_payload["payment"]["voided_reason"] == "重复登记,移入已删除" + assert deleted_payload["payment"]["deleted_at"] is not None + assert deleted_payload["payment"]["deleted_reason"] == "重复登记,移入已删除" + assert deleted_payload["paid_amount"] == "0.00" + assert deleted_payload["due_amount"] == "30.00" + assert deleted_payload["payment_status"] == "unpaid" + + after_delete = client.get("/api/admin/payments", params={"date": "2035-10-06"}).json() + assert after_delete["records"] == [] + assert [item["id"] for item in after_delete["deleted_records"]] == [record["id"]] + assert after_delete["metrics"]["today_income"] == "0.00" + assert after_delete["metrics"]["pending_order_count"] == 1 + + repeated_delete = client.post( + f"/api/admin/payments/{record['id']}/delete", + json={ + "expected_revision": deleted_payload["revision"], + "reason": "重复删除", + }, + ) + assert repeated_delete.status_code == 409 + + restored = client.post( + f"/api/admin/payments/{record['id']}/restore", + json={"expected_revision": deleted_payload["revision"]}, + ) + assert restored.status_code == 200 + restored_payload = restored.json() + assert restored_payload["payment"]["deleted_at"] is None + assert restored_payload["payment"]["deleted_reason"] is None + assert restored_payload["payment"]["payment_status"] == "voided" + assert restored_payload["paid_amount"] == "0.00" + assert restored_payload["due_amount"] == "30.00" + + repeated = client.post( + f"/api/admin/payments/{record['id']}/restore", + json={"expected_revision": restored_payload["revision"]}, + ) + assert repeated.status_code == 409 + + after_restore = client.get("/api/admin/payments", params={"date": "2035-10-06"}).json() + assert after_restore["deleted_records"] == [] + assert after_restore["records"][0]["payment_status"] == "voided" + with payment_api_context.session_factory() as session: + events = list( + session.scalars( + select(PaymentRecordAuditEvent) + .where(PaymentRecordAuditEvent.payment_id == record["id"]) + .order_by(PaymentRecordAuditEvent.id) + ) + ) + assert [event.action for event in events] == ["deleted", "restored"] + assert [event.reason for event in events] == ["重复登记,移入已删除", None] + assert all(event.created_at is not None for event in events) + + def test_cancelled_order_payment_can_be_voided_but_other_payment_states_cannot( payment_api_context: PaymentApiContext, ) -> None: @@ -481,6 +578,29 @@ def test_cancelled_order_payment_can_be_voided_but_other_payment_states_cannot( ) assert response.status_code == 409 + refreshed_records = client.get( + "/api/admin/payments", params={"date": "2035-10-06"} + ).json()["records"] + pending_record = next(item for item in refreshed_records if item["id"] == pending_id) + deleted_pending = client.post( + f"/api/admin/payments/{pending_id}/delete", + json={ + "expected_revision": pending_record["revision"], + "reason": "待处理流水重复创建", + }, + ) + assert deleted_pending.status_code == 200 + deleted_payload = deleted_pending.json() + assert deleted_payload["payment"]["payment_status"] == "pending" + assert deleted_payload["paid_amount"] == "0.00" + restored_pending = client.post( + f"/api/admin/payments/{pending_id}/restore", + json={"expected_revision": deleted_payload["revision"]}, + ) + assert restored_pending.status_code == 200 + assert restored_pending.json()["payment"]["payment_status"] == "pending" + assert restored_pending.json()["paid_amount"] == "0.00" + def test_refunded_order_context_rejects_payment_void( payment_api_context: PaymentApiContext, diff --git a/backend/tests/test_task_api.py b/backend/tests/test_task_api.py index 4e26657..53ecb2d 100644 --- a/backend/tests/test_task_api.py +++ b/backend/tests/test_task_api.py @@ -211,6 +211,8 @@ def test_execution_detail_start_text_and_revision_protection( "unit": "6 单元", "room": "606", "access_method": "虚构门禁方式", + "community_access_method": None, + "building_access_method": None, "access_info": "虚构门禁说明", "key_status": "虚构钥匙状态", "key_code": "FAKE-P6-KEY", diff --git a/frontend/src/features/customers/CustomerForms.tsx b/frontend/src/features/customers/CustomerForms.tsx index 0b4c97e..ecd008b 100644 --- a/frontend/src/features/customers/CustomerForms.tsx +++ b/frontend/src/features/customers/CustomerForms.tsx @@ -41,10 +41,16 @@ export function CustomerFormDialog({ initial, onCancel, onSave }: CustomerFormDi return; } + const communityAccessMethod = optionalValue(formData, "community_access_method"); + const buildingAccessMethod = optionalValue(formData, "building_access_method"); const payload: CustomerInput = { name, address: optionalValue(formData, "address"), - access_method: optionalValue(formData, "access_method"), + access_method: communityAccessMethod || buildingAccessMethod + ? null + : initial?.access_method ?? null, + community_access_method: communityAccessMethod, + building_access_method: buildingAccessMethod, key_status: optionalValue(formData, "key_status"), key_code: optionalValue(formData, "key_code"), notes: optionalValue(formData, "notes"), @@ -102,10 +108,19 @@ export function CustomerFormDialog({ initial, onCancel, onSave }: CustomerFormDi
+ + {initial?.access_method ? ( +

+ 历史门禁方式(待分类):{initial.access_method}。选择任一新门禁字段后会完成分类。 +

+ ) : null}
diff --git a/frontend/src/features/customers/CustomersPage.test.tsx b/frontend/src/features/customers/CustomersPage.test.tsx index bfacfaf..9e9cc21 100644 --- a/frontend/src/features/customers/CustomersPage.test.tsx +++ b/frontend/src/features/customers/CustomersPage.test.tsx @@ -62,6 +62,8 @@ const detail: CustomerDetail = { unit: "测试单元", room: "测试房号", access_method: "虚构门禁", + community_access_method: null, + building_access_method: null, access_info: "虚构入户说明", key_status: "未提供", key_code: "TEST-KEY", @@ -94,7 +96,9 @@ it("loads the customer list with one address and only the visible access fields" expect( await screen.findByRole("heading", { name: "测试客户(虚构)", level: 2 }, { timeout: 5_000 }), ).toBeInTheDocument(); - expect(screen.getByText("敏感信息,仅本地后台可见")).toBeInTheDocument(); + expect(screen.queryByText("敏感信息,仅本地后台可见")).not.toBeInTheDocument(); + expect(screen.getByText("历史门禁方式(待分类)")).toBeInTheDocument(); + expect(screen.getByText("虚构门禁")).toBeInTheDocument(); expect(screen.getByText("不对应真实地点")).toBeInTheDocument(); expect(screen.getByText("未提供 · TEST-KEY")).toBeInTheDocument(); expect(screen.queryByText("虚构入户说明")).not.toBeInTheDocument(); @@ -122,7 +126,8 @@ it("creates a simplified customer without submitting hidden legacy fields", asyn target: { value: "测试客户(虚构)" }, }); fireEvent.change(within(dialog).getByRole("textbox", { name: "地址" }), { target: { value: "虚构完整地址" } }); - fireEvent.change(within(dialog).getByRole("combobox", { name: "门禁方式" }), { target: { value: "密码" } }); + fireEvent.change(within(dialog).getByRole("combobox", { name: "小区门禁" }), { target: { value: "密码" } }); + fireEvent.change(within(dialog).getByRole("combobox", { name: "楼下门禁" }), { target: { value: "门卡" } }); fireEvent.change(within(dialog).getByRole("combobox", { name: "钥匙状态" }), { target: { value: "待取" } }); fireEvent.change(within(dialog).getByRole("textbox", { name: "钥匙编号" }), { target: { value: "TEST-KEY" }, @@ -135,7 +140,9 @@ it("creates a simplified customer without submitting hidden legacy fields", asyn expect.objectContaining({ name: "测试客户(虚构)", address: "虚构完整地址", - access_method: "密码", + access_method: null, + community_access_method: "密码", + building_access_method: "门卡", key_status: "待取", key_code: "TEST-KEY", is_repeat_customer: true, diff --git a/frontend/src/features/customers/CustomersPage.tsx b/frontend/src/features/customers/CustomersPage.tsx index 7be58a4..ac14951 100644 --- a/frontend/src/features/customers/CustomersPage.tsx +++ b/frontend/src/features/customers/CustomersPage.tsx @@ -9,7 +9,6 @@ import { Plus, RefreshCw, Search, - ShieldCheck, Trash2, UserRound, } from "lucide-react"; @@ -481,21 +480,19 @@ export function CustomersPage({ initialCreate = false }: CustomersPageProps) { -
+
-

- +

+ 门禁与钥匙

- 敏感信息,仅本地后台可见

+ + + {customerDetail.access_method ? : null}
- - -
-
- +
diff --git a/frontend/src/features/customers/types.ts b/frontend/src/features/customers/types.ts index e9305c7..5659c6b 100644 --- a/frontend/src/features/customers/types.ts +++ b/frontend/src/features/customers/types.ts @@ -44,6 +44,8 @@ export interface CustomerDetail { unit: string | null; room: string | null; access_method: string | null; + community_access_method: string | null; + building_access_method: string | null; access_info: string | null; key_status: string | null; key_code: string | null; @@ -65,6 +67,8 @@ export interface CustomerInput { name: string; address: string | null; access_method: string | null; + community_access_method: string | null; + building_access_method: string | null; key_status: string | null; key_code: string | null; notes: string | null; diff --git a/frontend/src/features/intake/AdminIntakePage.test.tsx b/frontend/src/features/intake/AdminIntakePage.test.tsx index aaf876b..4123d5a 100644 --- a/frontend/src/features/intake/AdminIntakePage.test.tsx +++ b/frontend/src/features/intake/AdminIntakePage.test.tsx @@ -160,6 +160,9 @@ it("shows the full editable review while keeping the submission list privacy-min renderPage(); expect(await screen.findByLabelText("详细地址", {}, { timeout: 5_000 })).toHaveValue("虚构后台测试地址"); + expect(screen.getByLabelText("小区门禁")).toHaveValue(""); + expect(screen.getByLabelText("楼下门禁")).toHaveValue(""); + expect(screen.queryByText("敏感信息,仅本地后台可见")).not.toBeInTheDocument(); expect(screen.getByLabelText("门禁说明")).toHaveValue("虚构敏感入户说明"); expect(screen.getByLabelText("钥匙编号")).toHaveValue("TEST-KEY"); expect(screen.getByText("查看客户原始提交(永久只读)")).toBeInTheDocument(); diff --git a/frontend/src/features/intake/AdminIntakePage.tsx b/frontend/src/features/intake/AdminIntakePage.tsx index e1d902e..00895aa 100644 --- a/frontend/src/features/intake/AdminIntakePage.tsx +++ b/frontend/src/features/intake/AdminIntakePage.tsx @@ -134,10 +134,12 @@ function PayloadDetail({ payload }: { payload: IntakeDraftPayload }) {
-
-

门禁与钥匙

敏感信息,仅本地后台可见
+
+

门禁与钥匙

- + + + {payload.customer.access_method ? : null}
@@ -226,7 +228,9 @@ function ReviewEditor({ payload, sourceNote, unitPrice, disabled, onPayloadChang - + + + {payload.customer.access_method ?

历史门禁方式(待分类):{payload.customer.access_method}

: null}