Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions backend/app/api/customers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 2 additions & 0 deletions backend/app/api/orders.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
29 changes: 28 additions & 1 deletion backend/app/api/payments.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down Expand Up @@ -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)
3 changes: 2 additions & 1 deletion backend/app/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -34,6 +34,7 @@
"OrderSettlementMode",
"OrderStatus",
"Payment",
"PaymentRecordAuditEvent",
"PaymentMethod",
"PaymentRecordStatus",
"SystemFlag",
Expand Down
2 changes: 2 additions & 0 deletions backend/app/models/customer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
2 changes: 2 additions & 0 deletions backend/app/models/order.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
50 changes: 49 additions & 1 deletion backend/app/models/payment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"),
)
Expand Down Expand Up @@ -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")
13 changes: 13 additions & 0 deletions backend/app/schemas/customer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions backend/app/schemas/intake.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
13 changes: 13 additions & 0 deletions backend/app/schemas/order.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions backend/app/schemas/payment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}$")


Expand All @@ -89,6 +91,7 @@ class PaymentsOverview(BaseModel):
metrics: PaymentMetrics
receivables: list[PaymentReceivable]
records: list[PaymentRecordRead]
deleted_records: list[PaymentRecordRead]


class PaymentRegistration(BaseModel):
Expand All @@ -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
Expand All @@ -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
2 changes: 2 additions & 0 deletions backend/app/schemas/task.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 15 additions & 10 deletions backend/app/services/intake.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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": {
Expand Down
9 changes: 9 additions & 0 deletions backend/app/services/order_customers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions backend/app/services/orders.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down
Loading