feat(wanfa): 新增路线详情参考价格配置及展示功能
- 新增数据库迁移脚本,为详情表添加四个价格相关字段 - 完善后端schema校验、序列化逻辑,增加价格相关的数据清理与验证规则 - 新增价格配置校验逻辑,设置起售价时必须选择对应的计价单位 - 在Admin UI详情编辑器中新增价格配置模块,支持配置起售价、计价单位、适用人数范围和价格说明 - 更新小程序端详情页面,支持展示配置的参考价格信息 - 补充相关测试用例,更新API文档与类型定义
This commit is contained in:
1 parent
f18971a430
commit
feec6ab80f
20 files changed
+351
-17
No files matched your search
@@ -0,0 +1,64 @@
|
||||
"""Add configurable price presentation fields to detail records."""
|
||||
|
||||
from alembic import context, op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
from app.migration_compat import baseline_column_names
|
||||
|
||||
|
||||
revision = "0037_detail_price_config"
|
||||
down_revision = "0036_customer_browse_history"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
PRICE_COLUMNS = {
|
||||
"priceStartingValue",
|
||||
"priceUnit",
|
||||
"pricePeopleRange",
|
||||
"priceDescription",
|
||||
}
|
||||
|
||||
|
||||
def missing_price_columns(existing_columns: set[str]) -> set[str]:
|
||||
return PRICE_COLUMNS.difference(existing_columns)
|
||||
|
||||
|
||||
def is_offline_mode() -> bool:
|
||||
try:
|
||||
return context.is_offline_mode()
|
||||
except (NameError, AttributeError):
|
||||
return False
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if is_offline_mode():
|
||||
columns_to_add = missing_price_columns(baseline_column_names("DetailRecord"))
|
||||
else:
|
||||
existing_columns = {
|
||||
column["name"] for column in inspect(op.get_bind()).get_columns("DetailRecord")
|
||||
}
|
||||
columns_to_add = missing_price_columns(existing_columns)
|
||||
|
||||
definitions = {
|
||||
"priceStartingValue": sa.Column("priceStartingValue", sa.Float(), nullable=True),
|
||||
"priceUnit": sa.Column("priceUnit", sa.String(), nullable=True),
|
||||
"pricePeopleRange": sa.Column("pricePeopleRange", sa.String(), nullable=True),
|
||||
"priceDescription": sa.Column("priceDescription", sa.Text(), nullable=True),
|
||||
}
|
||||
for name, column in definitions.items():
|
||||
if name in columns_to_add:
|
||||
op.add_column("DetailRecord", column)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if is_offline_mode():
|
||||
columns_to_drop = PRICE_COLUMNS.intersection(baseline_column_names("DetailRecord"))
|
||||
else:
|
||||
columns_to_drop = {
|
||||
column["name"] for column in inspect(op.get_bind()).get_columns("DetailRecord")
|
||||
}.intersection(PRICE_COLUMNS)
|
||||
|
||||
for name in ("priceDescription", "pricePeopleRange", "priceUnit", "priceStartingValue"):
|
||||
if name in columns_to_drop:
|
||||
op.drop_column("DetailRecord", name)
|
||||
@@ -1,6 +1,6 @@
|
||||
from datetime import datetime, timezone
|
||||
from uuid import uuid4
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint
|
||||
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Index, Integer, String, Text, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from .database import Base
|
||||
@@ -273,6 +273,10 @@ class DetailRecord(AdminOwnedContent, Base):
|
||||
excluded: Mapped[list[str]] = mapped_column(JSONB, default=list, nullable=False)
|
||||
notes: Mapped[list[str]] = mapped_column(JSONB, default=list, nullable=False)
|
||||
gallery: Mapped[list[str]] = mapped_column(JSONB, default=list, nullable=False)
|
||||
priceStartingValue: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
priceUnit: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
pricePeopleRange: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
priceDescription: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
conciergeAdvisorId: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
isActive: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, index=True)
|
||||
sortOrder: Mapped[int] = mapped_column(Integer, default=0, nullable=False, index=True)
|
||||
|
||||
@@ -11,6 +11,7 @@ LeadType = Literal["general", "vehicle"]
|
||||
VehicleServiceType = Literal["charter", "transfer"]
|
||||
CharterDuration = Literal["halfDay", "fullDay"]
|
||||
BrowseHistoryType = Literal["wanfa-route", "team-building", "wild-archive"]
|
||||
PriceUnit = Literal["person", "day", "group"]
|
||||
|
||||
|
||||
class LoginIn(BaseModel):
|
||||
@@ -281,6 +282,13 @@ def normalize_detail_gallery(value: list[str]) -> list[str]:
|
||||
return normalized
|
||||
|
||||
|
||||
def normalize_optional_price_text(value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
normalized = normalize_optional_text(value)
|
||||
return normalized or None
|
||||
|
||||
|
||||
class DetailCreate(BaseModel):
|
||||
key: str = Field(min_length=1, max_length=120)
|
||||
eyebrow: str = Field(min_length=1, max_length=80)
|
||||
@@ -293,6 +301,10 @@ class DetailCreate(BaseModel):
|
||||
excluded: list[str] = Field(default_factory=list)
|
||||
notes: list[str] = Field(default_factory=list)
|
||||
gallery: list[str] = Field(default_factory=list)
|
||||
priceStartingValue: float | None = Field(default=None, ge=0)
|
||||
priceUnit: PriceUnit | None = None
|
||||
pricePeopleRange: str | None = Field(default=None, max_length=80)
|
||||
priceDescription: str | None = Field(default=None, max_length=500)
|
||||
conciergeAdvisorId: str | None = Field(default=None, max_length=120)
|
||||
isActive: bool = True
|
||||
sortOrder: int | None = Field(default=None, ge=0)
|
||||
@@ -312,6 +324,11 @@ class DetailCreate(BaseModel):
|
||||
def validate_detail_gallery(cls, value: list[str]) -> list[str]:
|
||||
return normalize_detail_gallery(value)
|
||||
|
||||
@field_validator("pricePeopleRange", "priceDescription", mode="before")
|
||||
@classmethod
|
||||
def normalize_price_text(cls, value: str | None) -> str | None:
|
||||
return normalize_optional_price_text(value)
|
||||
|
||||
@field_validator("conciergeAdvisorId", mode="before")
|
||||
@classmethod
|
||||
def normalize_concierge_advisor_id(cls, value: str | None) -> str | None:
|
||||
@@ -333,6 +350,10 @@ class DetailPatch(BaseModel):
|
||||
excluded: list[str] | None = None
|
||||
notes: list[str] | None = None
|
||||
gallery: list[str] | None = None
|
||||
priceStartingValue: float | None = Field(default=None, ge=0)
|
||||
priceUnit: PriceUnit | None = None
|
||||
pricePeopleRange: str | None = Field(default=None, max_length=80)
|
||||
priceDescription: str | None = Field(default=None, max_length=500)
|
||||
conciergeAdvisorId: str | None = Field(default=None, max_length=120)
|
||||
isActive: bool | None = None
|
||||
sortOrder: int | None = Field(default=None, ge=0)
|
||||
@@ -358,6 +379,11 @@ class DetailPatch(BaseModel):
|
||||
raise ValueError("字段不能为空")
|
||||
return normalize_detail_gallery(value)
|
||||
|
||||
@field_validator("pricePeopleRange", "priceDescription", mode="before")
|
||||
@classmethod
|
||||
def normalize_optional_price_text_fields(cls, value: str | None) -> str | None:
|
||||
return normalize_optional_price_text(value)
|
||||
|
||||
@field_validator("conciergeAdvisorId", mode="before")
|
||||
@classmethod
|
||||
def normalize_optional_concierge_advisor_id(cls, value: str | None) -> str | None:
|
||||
|
||||
@@ -80,6 +80,10 @@ def public_detail_dict(detail, concierge_advisor=None) -> dict:
|
||||
"excluded": detail.excluded or [],
|
||||
"notes": detail.notes or [],
|
||||
"gallery": [resolve_media_url(image) for image in (detail.gallery or [])],
|
||||
"priceStartingValue": detail.priceStartingValue,
|
||||
"priceUnit": detail.priceUnit,
|
||||
"pricePeopleRange": detail.pricePeopleRange,
|
||||
"priceDescription": detail.priceDescription,
|
||||
"conciergeAdvisor": public_concierge_advisor_dict(concierge_advisor) if concierge_advisor else None,
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
from importlib.util import module_from_spec, spec_from_file_location
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MIGRATION_PATH = Path(__file__).parents[1] / "alembic" / "versions" / "0037_detail_price_config.py"
|
||||
SPEC = spec_from_file_location("detail_price_config_migration", MIGRATION_PATH)
|
||||
assert SPEC and SPEC.loader
|
||||
MIGRATION = module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(MIGRATION)
|
||||
|
||||
|
||||
def test_price_columns_are_added_only_when_missing():
|
||||
assert MIGRATION.missing_price_columns(set()) == {
|
||||
"priceStartingValue",
|
||||
"priceUnit",
|
||||
"pricePeopleRange",
|
||||
"priceDescription",
|
||||
}
|
||||
assert MIGRATION.missing_price_columns({"priceStartingValue", "priceUnit"}) == {
|
||||
"pricePeopleRange",
|
||||
"priceDescription",
|
||||
}
|
||||
assert MIGRATION.missing_price_columns({
|
||||
"priceStartingValue",
|
||||
"priceUnit",
|
||||
"pricePeopleRange",
|
||||
"priceDescription",
|
||||
}) == set()
|
||||
@@ -77,6 +77,10 @@ def make_detail(**overrides):
|
||||
"excluded": ["往返大交通"],
|
||||
"notes": ["请准备轻便雨具。"],
|
||||
"gallery": ["https://example.test/family-water.jpg"],
|
||||
"priceStartingValue": 1.68,
|
||||
"priceUnit": "person",
|
||||
"pricePeopleRange": "2-4人",
|
||||
"priceDescription": "价格以最终确认方案为准。",
|
||||
"isActive": True,
|
||||
"sortOrder": 0,
|
||||
"conciergeAdvisorId": None,
|
||||
@@ -124,11 +128,33 @@ def test_detail_schema_trims_text_and_array_items():
|
||||
excluded=[" 往返大交通 "],
|
||||
notes=[" 请准备雨具。 "],
|
||||
gallery=[" https://example.test/family-water.jpg "],
|
||||
priceStartingValue=1.68,
|
||||
priceUnit="person",
|
||||
pricePeopleRange=" 2-4人 ",
|
||||
priceDescription=" 价格以最终确认方案为准。 ",
|
||||
)
|
||||
|
||||
assert detail.key == "family-water"
|
||||
assert detail.highlights == ["核心景观串联", "小团出行"]
|
||||
assert detail.gallery == ["https://example.test/family-water.jpg"]
|
||||
assert detail.priceStartingValue == 1.68
|
||||
assert detail.priceUnit == "person"
|
||||
assert detail.pricePeopleRange == "2-4人"
|
||||
assert detail.priceDescription == "价格以最终确认方案为准。"
|
||||
|
||||
|
||||
def test_detail_schema_rejects_unknown_price_unit():
|
||||
with pytest.raises(ValidationError):
|
||||
DetailCreate(
|
||||
key="family-water",
|
||||
eyebrow="玩法推荐",
|
||||
duration="5天4晚",
|
||||
title="亲子玩水",
|
||||
subtitle="贵州路线",
|
||||
intro="介绍",
|
||||
priceStartingValue=1.68,
|
||||
priceUnit="month",
|
||||
)
|
||||
|
||||
|
||||
def test_detail_schema_rejects_invalid_gallery_url():
|
||||
@@ -188,6 +214,10 @@ def test_public_detail_returns_frontend_contract_without_auth():
|
||||
"excluded": ["往返大交通"],
|
||||
"notes": ["请准备轻便雨具。"],
|
||||
"gallery": ["https://example.test/family-water.jpg"],
|
||||
"priceStartingValue": 1.68,
|
||||
"priceUnit": "person",
|
||||
"pricePeopleRange": "2-4人",
|
||||
"priceDescription": "价格以最终确认方案为准。",
|
||||
"conciergeAdvisor": None,
|
||||
},
|
||||
}
|
||||
@@ -294,6 +324,10 @@ def test_admin_detail_create_normalizes_key_and_appends():
|
||||
"excluded": [],
|
||||
"notes": [],
|
||||
"gallery": ["https://example.test/family-water.jpg"],
|
||||
"priceStartingValue": 1.68,
|
||||
"priceUnit": "person",
|
||||
"pricePeopleRange": "2-4人",
|
||||
"priceDescription": "价格以最终确认方案为准。",
|
||||
},
|
||||
)
|
||||
finally:
|
||||
@@ -301,6 +335,8 @@ def test_admin_detail_create_normalizes_key_and_appends():
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response.json()["data"]["key"] == "family-water"
|
||||
assert fake_db.added[0].priceStartingValue == 1.68
|
||||
assert fake_db.added[0].priceUnit == "person"
|
||||
assert fake_db.added[0].sortOrder == 3
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user