Files

804 lines
31 KiB
Python

"""上传案例:会员提交的结构化记录,与案例库栏目文章分开。"""
import json
from datetime import date, datetime, timezone
from decimal import Decimal, InvalidOperation
from pathlib import Path
from sqlalchemy import Date, DateTime, Integer, String, Text, or_, select
from sqlalchemy.orm import Mapped, mapped_column
from app.db import Base
PHOTO_MAX_BYTES = 5 * 1024 * 1024
PAGE_SIZES = (10, 20, 100)
KIND_LABELS = {
"residential": "住宅",
"commercial": "商业",
"office": "办公",
"industrial": "工业",
}
OPEN_KINDS = frozenset({"residential", "commercial", "office", "industrial"})
CASE_TYPES = ("买卖成交", "拍卖成交", "月租金成交", "评估案例")
STRUCTURES = ("钢混", "砖混", "混合", "钢结构")
DECORATIONS = ("毛坯", "简易", "中档", "高档", "豪华")
TOTAL_FLOORS = tuple(str(i) for i in range(1, 61))
FLOOR_NOS = ("-4", "-3", "-2", "-1") + TOTAL_FLOORS
PURPOSES = (
"普通住宅",
"公寓",
"酒店式公寓",
"独立别墅",
"双拼别墅",
"联排别墅",
"停车位",
"地下室",
"其他",
)
LAYOUT_STRUCTURES = ("平面", "复式", "跃式", "错层", "LOFT")
OFFICE_LAYOUT_STRUCTURES = ("办公楼(中间过道两边办公室)", "写字楼(大开间)")
LAYOUTS = (
"单间",
"单身公寓",
"一室一厅",
"两室一厅",
"两室两厅",
"三室一厅",
"三室两厅",
"四室两厅",
"五室两厅",
"六室两厅",
"七室及以上",
)
COMMERCIAL_LAYOUTS = ("大开间", "隔档")
STREET_FRONTAGES = ("单面临街", "双面临街", "三面临街", "十字路口拐角", "不临街(商场或市场内铺面)")
FOOT_TRAFFICS = ("小", "较小", "一般", "较大", "大")
SHOP_TYPES = ("住宅底商", "商业街商铺", "临街门面", "购物中心/百货内铺", "酒店宾馆内铺", "其他")
BUSINESS_FORMATS = (
"空置",
"餐饮",
"服饰鞋帽箱包",
"休闲娱乐",
"美容美发",
"服务业",
"百货",
"酒店宾馆",
"家居建材",
"酒吧",
"汽车维修美容",
"其他",
)
ORIENTATIONS = ("东", "南", "西", "北", "东南", "西南", "东北", "西北", "南北", "东西")
VALUE_INCLUDES = ("装饰装修", "家具用具", "停车位", "花园", "地下室")
INDUSTRIAL_FLOORS = tuple(str(i) for i in range(1, 9))
INDUSTRIAL_LAYOUTS = ("开间",)
INDUSTRIAL_DIRECTIONS = ("南北", "东西")
ROAD_FRONTAGES = ("主干道", "次干道", "街巷")
INDUSTRY_TYPES = ("食品饮料加工", "机械制造", "水泥厂", "热力公司", "其他")
INDUSTRIAL_VALUE_INCLUDES = ("装饰装修", "家具用具")
_REGIONS: list | None = None
def _mysql_args() -> dict:
return {"mysql_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}
def _now() -> datetime:
return datetime.now(timezone.utc).replace(tzinfo=None)
class UploadedCase(Base):
__tablename__ = "uploaded_case"
__table_args__ = _mysql_args()
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
kind: Mapped[str] = mapped_column(String(32), nullable=False, index=True)
member_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
member_email: Mapped[str] = mapped_column(String(191), nullable=False, default="")
case_type: Mapped[str] = mapped_column(String(32), nullable=False)
province: Mapped[str] = mapped_column(String(64), nullable=False)
city: Mapped[str] = mapped_column(String(64), nullable=False)
district: Mapped[str] = mapped_column(String(64), nullable=False)
estate_name: Mapped[str] = mapped_column(String(200), nullable=False)
structure: Mapped[str] = mapped_column(String(32), nullable=False)
case_date: Mapped[date] = mapped_column(Date, nullable=False)
location: Mapped[str] = mapped_column(String(500), nullable=False)
built_year: Mapped[int] = mapped_column(Integer, nullable=False)
total_floors: Mapped[int] = mapped_column(Integer, nullable=False)
floor_no: Mapped[str] = mapped_column(String(8), nullable=False)
purpose: Mapped[str] = mapped_column(String(32), nullable=False)
layout_structure: Mapped[str] = mapped_column(String(32), nullable=False)
decoration: Mapped[str] = mapped_column(String(32), nullable=False)
value_includes: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
area: Mapped[str] = mapped_column(String(32), nullable=False)
layout: Mapped[str] = mapped_column(String(32), nullable=False)
orientation: Mapped[str] = mapped_column(String(16), nullable=False)
unit_price: Mapped[str] = mapped_column(String(32), nullable=False)
remark: Mapped[str] = mapped_column(Text, nullable=False, default="")
institution_name: Mapped[str] = mapped_column(String(200), nullable=False)
street_frontage: Mapped[str] = mapped_column(String(64), nullable=False, default="")
foot_traffic: Mapped[str] = mapped_column(String(32), nullable=False, default="")
shop_type: Mapped[str] = mapped_column(String(64), nullable=False, default="")
business_formats: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
heading: Mapped[str] = mapped_column(String(16), nullable=False, default="")
road_frontage: Mapped[str] = mapped_column(String(32), nullable=False, default="")
industry_type: Mapped[str] = mapped_column(String(32), nullable=False, default="")
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=_now, index=True)
class UploadedCasePhoto(Base):
__tablename__ = "uploaded_case_photo"
__table_args__ = _mysql_args()
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
case_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
file_id: Mapped[int] = mapped_column(Integer, nullable=False)
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
def regions() -> list:
global _REGIONS
if _REGIONS is None:
path = Path(__file__).resolve().parents[1] / "data" / "china_regions.json"
_REGIONS = json.loads(path.read_text(encoding="utf-8"))
return _REGIONS
def built_years(today: date | None = None) -> list[int]:
year = (today or date.today()).year
return list(range(1980, year + 1))
def ensure_uploaded_case_columns(db) -> None:
bind = db.get_bind()
if bind.dialect.name != "mysql":
return
from sqlalchemy import inspect, text
names = {column["name"] for column in inspect(bind).get_columns("uploaded_case")}
statements = []
if "street_frontage" not in names:
statements.append(
"ALTER TABLE uploaded_case ADD COLUMN street_frontage VARCHAR(64) NOT NULL DEFAULT ''"
)
if "foot_traffic" not in names:
statements.append(
"ALTER TABLE uploaded_case ADD COLUMN foot_traffic VARCHAR(32) NOT NULL DEFAULT ''"
)
if "shop_type" not in names:
statements.append("ALTER TABLE uploaded_case ADD COLUMN shop_type VARCHAR(64) NOT NULL DEFAULT ''")
if "business_formats" not in names:
statements.append("ALTER TABLE uploaded_case ADD COLUMN business_formats TEXT NULL")
if "heading" not in names:
statements.append("ALTER TABLE uploaded_case ADD COLUMN heading VARCHAR(16) NOT NULL DEFAULT ''")
if "road_frontage" not in names:
statements.append("ALTER TABLE uploaded_case ADD COLUMN road_frontage VARCHAR(32) NOT NULL DEFAULT ''")
if "industry_type" not in names:
statements.append("ALTER TABLE uploaded_case ADD COLUMN industry_type VARCHAR(32) NOT NULL DEFAULT ''")
for statement in statements:
db.execute(text(statement))
if "business_formats" not in names:
db.execute(text("UPDATE uploaded_case SET business_formats = '[]' WHERE business_formats IS NULL"))
if statements:
db.commit()
def commercial_options(today: date | None = None) -> dict:
return {
"caseTypes": list(CASE_TYPES),
"structures": list(STRUCTURES),
"builtYears": built_years(today),
"totalFloors": list(TOTAL_FLOORS),
"floorNos": list(FLOOR_NOS),
"layouts": list(COMMERCIAL_LAYOUTS),
"decorations": list(DECORATIONS),
"streetFrontages": list(STREET_FRONTAGES),
"orientations": list(ORIENTATIONS),
"footTraffics": list(FOOT_TRAFFICS),
"shopTypes": list(SHOP_TYPES),
"businessFormats": list(BUSINESS_FORMATS),
"valueIncludes": list(VALUE_INCLUDES),
}
def office_options(today: date | None = None) -> dict:
return {
"caseTypes": list(CASE_TYPES),
"structures": list(STRUCTURES),
"builtYears": built_years(today),
"totalFloors": list(TOTAL_FLOORS),
"floorNos": list(FLOOR_NOS),
"layoutStructures": list(OFFICE_LAYOUT_STRUCTURES),
"decorations": list(DECORATIONS),
"valueIncludes": list(VALUE_INCLUDES),
"orientations": list(ORIENTATIONS),
}
def residential_options(today: date | None = None) -> dict:
return {
"caseTypes": list(CASE_TYPES),
"structures": list(STRUCTURES),
"builtYears": built_years(today),
"totalFloors": list(TOTAL_FLOORS),
"floorNos": list(FLOOR_NOS),
"purposes": list(PURPOSES),
"layoutStructures": list(LAYOUT_STRUCTURES),
"decorations": list(DECORATIONS),
"valueIncludes": list(VALUE_INCLUDES),
"layouts": list(LAYOUTS),
"orientations": list(ORIENTATIONS),
}
def industrial_options(today: date | None = None) -> dict:
return {
"caseTypes": list(CASE_TYPES),
"structures": list(STRUCTURES),
"builtYears": built_years(today),
"totalFloors": list(INDUSTRIAL_FLOORS),
"layouts": list(INDUSTRIAL_LAYOUTS),
"headings": list(INDUSTRIAL_DIRECTIONS),
"decorations": list(DECORATIONS),
"roadFrontages": list(ROAD_FRONTAGES),
"orientations": list(INDUSTRIAL_DIRECTIONS),
"industryTypes": list(INDUSTRY_TYPES),
"valueIncludes": list(INDUSTRIAL_VALUE_INCLUDES),
}
def _choice(value, options: tuple[str, ...] | list, label: str) -> str:
text = str(value or "").strip()
if text not in options:
raise ValueError(f"{label}不正确")
return text
def _text(value, label: str, limit: int) -> str:
text = str(value or "").strip()
if not text:
raise ValueError(f"请填写{label}")
if len(text) > limit:
raise ValueError(f"{label}过长")
return text
def _number(value, label: str) -> str:
raw = str(value if value is not None else "").strip()
if not raw:
raise ValueError(f"请填写{label}")
try:
number = Decimal(raw)
except InvalidOperation as exc:
raise ValueError(f"{label}须为数字") from exc
if not number.is_finite():
raise ValueError(f"{label}须为数字")
return format(number, "f")
def _region(province: str, city: str, district: str) -> tuple[str, str, str]:
province = str(province or "").strip()
city = str(city or "").strip()
district = str(district or "").strip()
for item in regions():
if item.get("name") != province:
continue
for child in item.get("sub") or []:
if child.get("name") != city:
continue
names = [row.get("name") for row in child.get("sub") or []]
if district not in names:
raise ValueError("区县不正确")
return province, city, district
raise ValueError("城市不正确")
raise ValueError("省份不正确")
def _includes(value, options: tuple[str, ...] = VALUE_INCLUDES) -> list[str]:
if value is None or value == "" or value == []:
return []
if not isinstance(value, list):
raise ValueError("价值包含不正确")
chosen = []
for item in value:
text = str(item or "").strip()
if text not in options or text in chosen:
raise ValueError("价值包含不正确")
chosen.append(text)
if len(chosen) > 3:
raise ValueError("价值包含最多 3 项")
return chosen
def _formats(value) -> list[str]:
if value is None or value == "" or value == []:
raise ValueError("请选择经营业态")
if not isinstance(value, list):
raise ValueError("经营业态不正确")
chosen = []
for item in value:
text = str(item or "").strip()
if text not in BUSINESS_FORMATS or text in chosen:
raise ValueError("经营业态不正确")
chosen.append(text)
if not chosen:
raise ValueError("请选择经营业态")
if len(chosen) > 3:
raise ValueError("经营业态最多 3 项")
return chosen
def _photos(db, member_id: int, file_ids) -> list[int]:
from app.models.content import AdminFile
if not isinstance(file_ids, list) or not file_ids:
raise ValueError("请上传位置图及案例照片")
ids: list[int] = []
for raw in file_ids:
try:
file_id = int(raw)
except (TypeError, ValueError) as exc:
raise ValueError("位置图及案例照片不正确") from exc
row = db.get(AdminFile, file_id)
if row is None or row.bucket != "private" or row.created_by != member_id:
raise ValueError("位置图及案例照片不正确")
if row.size > PHOTO_MAX_BYTES:
raise ValueError("位置图及案例照片须小于 5MB")
if file_id not in ids:
ids.append(file_id)
return ids
def _residential(db, member_id: int, payload: dict, today: date | None = None) -> dict:
extra = set(payload) - {
"kind",
"caseType",
"province",
"city",
"district",
"estateName",
"structure",
"caseDate",
"location",
"builtYear",
"totalFloors",
"floorNo",
"purpose",
"layoutStructure",
"decoration",
"valueIncludes",
"area",
"layout",
"orientation",
"unitPrice",
"remark",
"institutionName",
"photoFileIds",
}
if extra:
raise ValueError("包含住宅没有的字段")
province, city, district = _region(payload.get("province"), payload.get("city"), payload.get("district"))
raw_date = str(payload.get("caseDate") or "").strip()
try:
case_date = datetime.strptime(raw_date, "%Y-%m-%d").date()
except ValueError as exc:
raise ValueError("案例时间不正确") from exc
year = _choice(str(payload.get("builtYear") or "").strip(), [str(item) for item in built_years(today)], "建筑年代")
remark = str(payload.get("remark") or "").strip()
if remark and not 1 <= len(remark) <= 1000:
raise ValueError("备注长度须在 1 到 1000")
return {
"case_type": _choice(payload.get("caseType"), CASE_TYPES, "案例类型"),
"province": province,
"city": city,
"district": district,
"estate_name": _text(payload.get("estateName"), "楼盘名称", 200),
"structure": _choice(payload.get("structure"), STRUCTURES, "建筑结构"),
"case_date": case_date,
"location": _text(payload.get("location"), "坐落", 500),
"built_year": int(year),
"total_floors": int(_choice(str(payload.get("totalFloors") or "").strip(), TOTAL_FLOORS, "总层数")),
"floor_no": _choice(str(payload.get("floorNo") or "").strip(), FLOOR_NOS, "所在层数"),
"purpose": _choice(payload.get("purpose"), PURPOSES, "用途"),
"layout_structure": _choice(payload.get("layoutStructure"), LAYOUT_STRUCTURES, "户型结构"),
"decoration": _choice(payload.get("decoration"), DECORATIONS, "装修档次"),
"value_includes": _includes(payload.get("valueIncludes")),
"area": _number(payload.get("area"), "建筑面积"),
"layout": _choice(payload.get("layout"), LAYOUTS, "户型"),
"orientation": _choice(payload.get("orientation"), ORIENTATIONS, "朝向"),
"unit_price": _number(payload.get("unitPrice"), "单价"),
"remark": remark,
"institution_name": _text(payload.get("institutionName"), "提交机构名称", 200),
"photo_ids": _photos(db, member_id, payload.get("photoFileIds")),
}
def _commercial(db, member_id: int, payload: dict, today: date | None = None) -> dict:
extra = set(payload) - {
"kind",
"caseType",
"province",
"city",
"district",
"estateName",
"structure",
"caseDate",
"location",
"builtYear",
"totalFloors",
"floorNo",
"layout",
"decoration",
"streetFrontage",
"area",
"orientation",
"footTraffic",
"shopType",
"businessFormats",
"unitPrice",
"valueIncludes",
"remark",
"institutionName",
"photoFileIds",
}
if extra:
raise ValueError("包含商业没有的字段")
province, city, district = _region(payload.get("province"), payload.get("city"), payload.get("district"))
raw_date = str(payload.get("caseDate") or "").strip()
try:
case_date = datetime.strptime(raw_date, "%Y-%m-%d").date()
except ValueError as exc:
raise ValueError("案例时间不正确") from exc
year = _choice(str(payload.get("builtYear") or "").strip(), [str(item) for item in built_years(today)], "建筑年代")
remark = str(payload.get("remark") or "").strip()
if remark and not 1 <= len(remark) <= 1000:
raise ValueError("备注长度须在 1 到 1000")
return {
"case_type": _choice(payload.get("caseType"), CASE_TYPES, "案例类型"),
"province": province,
"city": city,
"district": district,
"estate_name": _text(payload.get("estateName"), "楼盘名称", 200),
"structure": _choice(payload.get("structure"), STRUCTURES, "建筑结构"),
"case_date": case_date,
"location": _text(payload.get("location"), "坐落", 500),
"built_year": int(year),
"total_floors": int(_choice(str(payload.get("totalFloors") or "").strip(), TOTAL_FLOORS, "总层数")),
"floor_no": _choice(str(payload.get("floorNo") or "").strip(), FLOOR_NOS, "所在层数"),
"purpose": "",
"layout_structure": "",
"decoration": _choice(payload.get("decoration"), DECORATIONS, "装修档次"),
"value_includes": _includes(payload.get("valueIncludes")),
"area": _number(payload.get("area"), "建筑面积"),
"layout": _choice(payload.get("layout"), COMMERCIAL_LAYOUTS, "户型"),
"orientation": _choice(payload.get("orientation"), ORIENTATIONS, "朝向"),
"street_frontage": _choice(payload.get("streetFrontage"), STREET_FRONTAGES, "临街状况"),
"foot_traffic": _choice(payload.get("footTraffic"), FOOT_TRAFFICS, "人流量"),
"shop_type": _choice(payload.get("shopType"), SHOP_TYPES, "商铺类型"),
"business_formats": _formats(payload.get("businessFormats")),
"unit_price": _number(payload.get("unitPrice"), "单价"),
"remark": remark,
"institution_name": _text(payload.get("institutionName"), "提交机构名称", 200),
"photo_ids": _photos(db, member_id, payload.get("photoFileIds")),
}
def _office(db, member_id: int, payload: dict, today: date | None = None) -> dict:
extra = set(payload) - {
"kind",
"caseType",
"province",
"city",
"district",
"estateName",
"structure",
"caseDate",
"location",
"builtYear",
"totalFloors",
"floorNo",
"layoutStructure",
"decoration",
"valueIncludes",
"area",
"orientation",
"unitPrice",
"remark",
"institutionName",
"photoFileIds",
}
if extra:
raise ValueError("包含办公没有的字段")
province, city, district = _region(payload.get("province"), payload.get("city"), payload.get("district"))
raw_date = str(payload.get("caseDate") or "").strip()
try:
case_date = datetime.strptime(raw_date, "%Y-%m-%d").date()
except ValueError as exc:
raise ValueError("案例时间不正确") from exc
year = _choice(str(payload.get("builtYear") or "").strip(), [str(item) for item in built_years(today)], "建筑年代")
remark = str(payload.get("remark") or "").strip()
if remark and not 1 <= len(remark) <= 1000:
raise ValueError("备注长度须在 1 到 1000")
return {
"case_type": _choice(payload.get("caseType"), CASE_TYPES, "案例类型"),
"province": province,
"city": city,
"district": district,
"estate_name": _text(payload.get("estateName"), "楼盘名称", 200),
"structure": _choice(payload.get("structure"), STRUCTURES, "建筑结构"),
"case_date": case_date,
"location": _text(payload.get("location"), "坐落", 500),
"built_year": int(year),
"total_floors": int(_choice(str(payload.get("totalFloors") or "").strip(), TOTAL_FLOORS, "总层数")),
"floor_no": _choice(str(payload.get("floorNo") or "").strip(), FLOOR_NOS, "所在层数"),
"purpose": "",
"layout_structure": _choice(payload.get("layoutStructure"), OFFICE_LAYOUT_STRUCTURES, "户型结构"),
"decoration": _choice(payload.get("decoration"), DECORATIONS, "装修档次"),
"value_includes": _includes(payload.get("valueIncludes")),
"area": _number(payload.get("area"), "建筑面积"),
"layout": "",
"orientation": _choice(payload.get("orientation"), ORIENTATIONS, "朝向"),
"unit_price": _number(payload.get("unitPrice"), "单价"),
"remark": remark,
"institution_name": _text(payload.get("institutionName"), "提交机构名称", 200),
"photo_ids": _photos(db, member_id, payload.get("photoFileIds")),
}
def _industrial(db, member_id: int, payload: dict, today: date | None = None) -> dict:
extra = set(payload) - {
"kind",
"caseType",
"province",
"city",
"district",
"estateName",
"structure",
"caseDate",
"location",
"builtYear",
"totalFloors",
"layout",
"heading",
"decoration",
"roadFrontage",
"area",
"orientation",
"industryType",
"unitPrice",
"valueIncludes",
"remark",
"institutionName",
"photoFileIds",
}
if extra:
raise ValueError("包含工业没有的字段")
province, city, district = _region(payload.get("province"), payload.get("city"), payload.get("district"))
raw_date = str(payload.get("caseDate") or "").strip()
try:
case_date = datetime.strptime(raw_date, "%Y-%m-%d").date()
except ValueError as exc:
raise ValueError("案例时间不正确") from exc
year = _choice(str(payload.get("builtYear") or "").strip(), [str(item) for item in built_years(today)], "建筑年代")
remark = str(payload.get("remark") or "").strip()
if remark and not 1 <= len(remark) <= 1000:
raise ValueError("备注长度须在 1 到 1000")
return {
"case_type": _choice(payload.get("caseType"), CASE_TYPES, "案例类型"),
"province": province,
"city": city,
"district": district,
"estate_name": _text(payload.get("estateName"), "楼盘名称", 200),
"structure": _choice(payload.get("structure"), STRUCTURES, "建筑结构"),
"case_date": case_date,
"location": _text(payload.get("location"), "坐落", 500),
"built_year": int(year),
"total_floors": int(_choice(str(payload.get("totalFloors") or "").strip(), INDUSTRIAL_FLOORS, "总层数")),
"floor_no": "",
"purpose": "",
"layout_structure": "",
"decoration": _choice(payload.get("decoration"), DECORATIONS, "装修档次"),
"value_includes": _includes(payload.get("valueIncludes"), INDUSTRIAL_VALUE_INCLUDES),
"area": _number(payload.get("area"), "建筑面积"),
"layout": _choice(payload.get("layout"), INDUSTRIAL_LAYOUTS, "户型"),
"heading": _choice(payload.get("heading"), INDUSTRIAL_DIRECTIONS, "走向"),
"road_frontage": _choice(payload.get("roadFrontage"), ROAD_FRONTAGES, "临路状况"),
"orientation": _choice(payload.get("orientation"), INDUSTRIAL_DIRECTIONS, "朝向"),
"industry_type": _choice(payload.get("industryType"), INDUSTRY_TYPES, "工业类型"),
"unit_price": _number(payload.get("unitPrice"), "单价"),
"remark": remark,
"institution_name": _text(payload.get("institutionName"), "提交机构名称", 200),
"photo_ids": _photos(db, member_id, payload.get("photoFileIds")),
}
def create_uploaded_case(db, member, payload: dict, today: date | None = None) -> UploadedCase:
if not isinstance(payload, dict):
raise ValueError("提交内容不正确")
kind = str(payload.get("kind") or "").strip()
if kind not in KIND_LABELS:
raise ValueError("案例种类不正确")
if kind not in OPEN_KINDS:
raise ValueError("该类型尚未开放")
parsers = {
"residential": _residential,
"commercial": _commercial,
"office": _office,
"industrial": _industrial,
}
fields = parsers[kind](db, member.id, payload, today)
row = UploadedCase(
kind=kind,
member_id=member.id,
member_email=member.email,
case_type=fields["case_type"],
province=fields["province"],
city=fields["city"],
district=fields["district"],
estate_name=fields["estate_name"],
structure=fields["structure"],
case_date=fields["case_date"],
location=fields["location"],
built_year=fields["built_year"],
total_floors=fields["total_floors"],
floor_no=fields["floor_no"],
purpose=fields["purpose"],
layout_structure=fields["layout_structure"],
decoration=fields["decoration"],
value_includes=json.dumps(fields["value_includes"], ensure_ascii=False),
area=fields["area"],
layout=fields["layout"],
orientation=fields["orientation"],
unit_price=fields["unit_price"],
remark=fields["remark"],
institution_name=fields["institution_name"],
street_frontage=fields.get("street_frontage", ""),
foot_traffic=fields.get("foot_traffic", ""),
shop_type=fields.get("shop_type", ""),
business_formats=json.dumps(fields.get("business_formats", []), ensure_ascii=False),
heading=fields.get("heading", ""),
road_frontage=fields.get("road_frontage", ""),
industry_type=fields.get("industry_type", ""),
created_at=_now(),
)
db.add(row)
db.flush()
for index, file_id in enumerate(fields["photo_ids"]):
db.add(UploadedCasePhoto(case_id=row.id, file_id=file_id, position=index))
db.commit()
db.refresh(row)
return row
def _address(row: UploadedCase) -> str:
return f"{row.province} {row.city} {row.district}"
def _list_item(row: UploadedCase) -> dict:
return {
"id": row.id,
"kind": row.kind,
"kindLabel": KIND_LABELS.get(row.kind, row.kind),
"estateName": row.estate_name,
"address": _address(row),
"caseDate": row.case_date.isoformat() if row.case_date else "",
"institutionName": row.institution_name,
"memberEmail": row.member_email,
"createdAt": row.created_at.isoformat(timespec="seconds") if row.created_at else "",
}
def _photos_payload(db, case_id: int) -> list[dict]:
from app.models.content import AdminFile
from app.services.storage import private_object_url
links = db.scalars(
select(UploadedCasePhoto)
.where(UploadedCasePhoto.case_id == case_id)
.order_by(UploadedCasePhoto.position.asc(), UploadedCasePhoto.id.asc())
).all()
items = []
for link in links:
file_row = db.get(AdminFile, link.file_id)
if file_row is None or file_row.bucket != "private":
continue
url, expires_at = private_object_url(file_row.object_key)
items.append(
{
"id": file_row.id,
"filename": file_row.filename,
"contentType": file_row.content_type or "",
"url": url,
"expiresAt": expires_at.isoformat(),
}
)
return items
def _like_contains(value: str) -> str:
escaped = value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
return f"%{escaped}%"
def list_uploaded_cases(db, *, kind: str, current: int, size: int, q: str | None = None) -> dict | None:
if kind not in KIND_LABELS:
return None
page_size = size if size in PAGE_SIZES else 10
page = current if current >= 1 else 1
filters = [UploadedCase.kind == kind]
keyword = (q or "").strip()[:100]
if keyword:
needle = _like_contains(keyword)
filters.append(
or_(
UploadedCase.estate_name.like(needle, escape="\\"),
UploadedCase.province.like(needle, escape="\\"),
UploadedCase.city.like(needle, escape="\\"),
UploadedCase.district.like(needle, escape="\\"),
UploadedCase.institution_name.like(needle, escape="\\"),
UploadedCase.member_email.like(needle, escape="\\"),
)
)
total = len(db.scalars(select(UploadedCase.id).where(*filters)).all())
rows = db.scalars(
select(UploadedCase)
.where(*filters)
.order_by(UploadedCase.created_at.desc(), UploadedCase.id.desc())
.offset((page - 1) * page_size)
.limit(page_size)
).all()
return {
"kind": kind,
"kindLabel": KIND_LABELS[kind],
"items": [_list_item(row) for row in rows],
"total": total,
"current": page,
"size": page_size,
}
def uploaded_case_detail(db, case_id: int) -> dict | None:
row = db.get(UploadedCase, case_id)
if row is None:
return None
body = _list_item(row)
body.update(
{
"caseType": row.case_type,
"province": row.province,
"city": row.city,
"district": row.district,
"structure": row.structure,
"location": row.location,
"builtYear": row.built_year,
"totalFloors": row.total_floors,
"decoration": row.decoration,
"valueIncludes": json.loads(row.value_includes or "[]"),
"area": row.area,
"orientation": row.orientation,
"unitPrice": row.unit_price,
"remark": row.remark,
"photos": _photos_payload(db, row.id),
}
)
if row.kind != "industrial":
body["floorNo"] = row.floor_no
if row.kind in ("residential", "commercial", "industrial"):
body["layout"] = row.layout
if row.kind == "residential":
body["purpose"] = row.purpose
body["layoutStructure"] = row.layout_structure
if row.kind == "office":
body["layoutStructure"] = row.layout_structure
if row.kind == "commercial":
body["streetFrontage"] = row.street_frontage
body["footTraffic"] = row.foot_traffic
body["shopType"] = row.shop_type
body["businessFormats"] = json.loads(row.business_formats or "[]")
if row.kind == "industrial":
body["heading"] = row.heading
body["roadFrontage"] = row.road_frontage
body["industryType"] = row.industry_type
return body