dbservice.py 90 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052
  1. from __future__ import annotations
  2. import hashlib
  3. import json
  4. import math
  5. import re
  6. from collections import defaultdict
  7. from datetime import UTC, datetime, timedelta
  8. from decimal import Decimal, InvalidOperation
  9. from secrets import token_urlsafe
  10. from typing import Any
  11. from uuid import uuid4
  12. from cryptography.fernet import Fernet, InvalidToken
  13. from pymongo import ASCENDING, DESCENDING, ReturnDocument
  14. from pymongo.errors import DuplicateKeyError
  15. import wbb
  16. from wbb import control_db
  17. templatesdb = control_db.service_package_templates
  18. ordersdb = control_db.service_orders
  19. quotesdb = control_db.service_order_quotes
  20. addressesdb = control_db.service_order_addresses
  21. eventsdb = control_db.service_order_events
  22. customersdb = control_db.customer_profiles
  23. blocksdb = control_db.customer_blocks
  24. reportsdb = control_db.service_reports
  25. qrdb = control_db.teacher_review_qr_records
  26. reviewtemplatesdb = control_db.teacher_review_templates
  27. reviewdraftsdb = control_db.teacher_review_drafts
  28. reviewsdb = control_db.teacher_reviews
  29. statsdb = control_db.teacher_review_stats
  30. profilesdb = control_db.directory_profiles
  31. locationsdb = control_db.directory_locations
  32. membershipsdb = control_db.directory_memberships
  33. settingsdb = control_db.directory_settings
  34. SERVICE_MODES = {"at_store", "onsite"}
  35. PRICE_MODES = {"fixed", "starting_at", "range", "negotiable"}
  36. PRICE_UNITS = {"per_service", "per_hour", "per_item", "per_visit"}
  37. TRAVEL_FEE_MODES = {"included", "fixed", "per_km", "quoted"}
  38. ORDER_ACTIVE_STATUSES = {
  39. "requested",
  40. "quoted",
  41. "confirmed",
  42. "in_progress",
  43. "completion_pending",
  44. "disputed",
  45. }
  46. ORDER_TERMINAL_STATUSES = {
  47. "completed",
  48. "rejected",
  49. "canceled_customer",
  50. "canceled_technician",
  51. "voided",
  52. }
  53. ORDER_STATUSES = ORDER_ACTIVE_STATUSES | ORDER_TERMINAL_STATUSES
  54. REVIEW_SOURCES = {"qr_verified", "student_initiated"}
  55. REVIEW_STATUSES = {"pending", "approved", "rejected", "withdrawn", "voided"}
  56. DEFAULT_REVIEW_TEMPLATE = {
  57. "rating_questions": [
  58. {
  59. "question_id": "service_effect",
  60. "label": "服务效果",
  61. "description": "本次服务是否达到预期。",
  62. "required": True,
  63. "weight": 40,
  64. },
  65. {
  66. "question_id": "professionalism",
  67. "label": "专业程度",
  68. "description": "技师的专业能力与服务规范。",
  69. "required": True,
  70. "weight": 30,
  71. },
  72. {
  73. "question_id": "communication",
  74. "label": "沟通体验",
  75. "description": "沟通是否清晰、友好。",
  76. "required": True,
  77. "weight": 30,
  78. },
  79. ],
  80. "text_questions": [
  81. {
  82. "question_id": "comment",
  83. "label": "评价内容",
  84. "description": "分享真实体验,避免包含隐私信息。",
  85. "required": False,
  86. "max_length": 500,
  87. }
  88. ],
  89. }
  90. BUILTIN_PACKAGE_TEMPLATES = (
  91. {
  92. "template_id": "builtin_quick_at_store",
  93. "name": "快速到店服务",
  94. "sort_order": -300,
  95. "package": {
  96. "package_id": "builtin_package_at_store",
  97. "name": "标准到店服务",
  98. "category": "通用服务",
  99. "description": "顾客到店完成一次标准服务,具体内容可在接单后沟通确认。",
  100. "tags": ["到店", "标准服务"],
  101. "service_modes": ["at_store"],
  102. "price_mode": "fixed",
  103. "currency": "CNY",
  104. "price_unit": "per_service",
  105. "min_price": "200",
  106. "max_price": "200",
  107. "duration_minutes": 60,
  108. "included_items": "一次标准服务",
  109. "excluded_items": "额外耗材和临时加项",
  110. "preparation": "请提前说明具体需求",
  111. "addons": [],
  112. "travel_fee": {"mode": "included", "amount": "0", "per_km": "0"},
  113. "service_radius_km": None,
  114. "out_of_range_policy": "",
  115. },
  116. },
  117. {
  118. "template_id": "builtin_quick_onsite",
  119. "name": "快速上门服务",
  120. "sort_order": -200,
  121. "package": {
  122. "package_id": "builtin_package_onsite",
  123. "name": "标准上门服务",
  124. "category": "通用服务",
  125. "description": "技师上门完成一次标准服务,最终费用以确认报价为准。",
  126. "tags": ["上门", "标准服务"],
  127. "service_modes": ["onsite"],
  128. "price_mode": "starting_at",
  129. "currency": "CNY",
  130. "price_unit": "per_visit",
  131. "min_price": "300",
  132. "max_price": "300",
  133. "duration_minutes": 90,
  134. "included_items": "一次标准上门服务",
  135. "excluded_items": "交通费、额外耗材和临时加项",
  136. "preparation": "请提前发送位置并说明具体需求",
  137. "addons": [],
  138. "travel_fee": {"mode": "quoted", "amount": "0", "per_km": "0"},
  139. "service_radius_km": 20,
  140. "out_of_range_policy": "超出服务半径时,请先沟通交通费和是否可以接单。",
  141. },
  142. },
  143. {
  144. "template_id": "builtin_quick_hourly",
  145. "name": "快速按小时服务",
  146. "sort_order": -100,
  147. "package": {
  148. "package_id": "builtin_package_hourly",
  149. "name": "按小时专业服务",
  150. "category": "通用服务",
  151. "description": "按服务时长计价,支持到店或上门,具体工作范围提前确认。",
  152. "tags": ["按小时", "到店", "上门"],
  153. "service_modes": ["at_store", "onsite"],
  154. "price_mode": "starting_at",
  155. "currency": "CNY",
  156. "price_unit": "per_hour",
  157. "min_price": "200",
  158. "max_price": "200",
  159. "duration_minutes": 60,
  160. "included_items": "一小时专业服务",
  161. "excluded_items": "交通费、额外耗材和超时服务",
  162. "preparation": "请提前说明服务内容和预计时长",
  163. "addons": [],
  164. "travel_fee": {"mode": "quoted", "amount": "0", "per_km": "0"},
  165. "service_radius_km": 20,
  166. "out_of_range_policy": "超出服务半径时,请先沟通交通费和是否可以接单。",
  167. },
  168. },
  169. )
  170. _indexes_ready = False
  171. class ServiceDataError(ValueError):
  172. def __init__(self, code: str, message: str):
  173. super().__init__(message)
  174. self.code = code
  175. def utc_now() -> datetime:
  176. return datetime.now(UTC)
  177. def _as_utc(value: datetime) -> datetime:
  178. return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)
  179. def _clean_text(value: Any, *, max_length: int, required: bool = False) -> str:
  180. text = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]", "", str(value or ""))
  181. text = " ".join(text.strip().split())
  182. if required and not text:
  183. raise ServiceDataError("required_field", "必填内容不能为空。")
  184. if len(text) > max_length:
  185. raise ServiceDataError("text_too_long", f"内容不能超过 {max_length} 个字符。")
  186. return text
  187. def _positive_decimal(value: Any, field: str, *, allow_zero: bool = True) -> str:
  188. try:
  189. number = Decimal(str(value or 0)).quantize(Decimal("0.01"))
  190. except (InvalidOperation, ValueError) as exc:
  191. raise ServiceDataError("invalid_price", f"{field} 必须是有效金额。") from exc
  192. if not number.is_finite():
  193. raise ServiceDataError("invalid_price", f"{field} 必须是有效金额。")
  194. if number < 0 or (not allow_zero and number == 0):
  195. raise ServiceDataError("invalid_price", f"{field} 不能小于 0。")
  196. return format(number, "f")
  197. def _normalize_tags(values: Any) -> list[str]:
  198. if isinstance(values, str):
  199. values = values.replace(",", ",").split(",")
  200. tags = []
  201. for value in values or []:
  202. tag = _clean_text(value, max_length=20)
  203. if tag and tag.casefold() not in {item.casefold() for item in tags}:
  204. tags.append(tag)
  205. if len(tags) > 8:
  206. raise ServiceDataError("too_many_tags", "服务标签最多 8 个。")
  207. return tags
  208. def normalize_package(value: dict[str, Any], *, package_id: str | None = None) -> dict[str, Any]:
  209. modes = list(dict.fromkeys(str(item) for item in value.get("service_modes", [])))
  210. if not modes or set(modes) - SERVICE_MODES:
  211. raise ServiceDataError("invalid_service_mode", "套餐必须选择到店或上门服务方式。")
  212. price_mode = str(value.get("price_mode") or "negotiable")
  213. if price_mode not in PRICE_MODES:
  214. raise ServiceDataError("invalid_price_mode", "不支持该价格模式。")
  215. price_unit = str(value.get("price_unit") or "per_service")
  216. if price_unit not in PRICE_UNITS:
  217. raise ServiceDataError("invalid_price_unit", "不支持该计价单位。")
  218. currency = str(value.get("currency") or "CNY").upper()
  219. if not re.fullmatch(r"[A-Z]{3}", currency):
  220. raise ServiceDataError("invalid_currency", "币种必须使用三位 ISO 代码。")
  221. min_price = _positive_decimal(value.get("min_price"), "最低价格")
  222. max_price = _positive_decimal(value.get("max_price"), "最高价格")
  223. if Decimal(max_price) and Decimal(max_price) < Decimal(min_price):
  224. raise ServiceDataError("invalid_price_range", "最高价格不能低于最低价格。")
  225. if price_mode == "fixed" and Decimal(min_price) != Decimal(max_price):
  226. raise ServiceDataError("invalid_fixed_price", "固定价的最低价格和最高价格必须一致。")
  227. duration = value.get("duration_minutes")
  228. if duration in (None, "", 0, "0"):
  229. duration = None
  230. else:
  231. try:
  232. duration = int(duration)
  233. except (TypeError, ValueError) as exc:
  234. raise ServiceDataError("invalid_duration", "服务时长必须是分钟数。") from exc
  235. if not 15 <= duration <= 480:
  236. raise ServiceDataError("invalid_duration", "服务时长必须在 15 到 480 分钟之间。")
  237. travel = value.get("travel_fee") if isinstance(value.get("travel_fee"), dict) else {}
  238. travel_mode = str(travel.get("mode") or "quoted")
  239. if travel_mode not in TRAVEL_FEE_MODES:
  240. raise ServiceDataError("invalid_travel_fee", "不支持该交通费模式。")
  241. addons = []
  242. for item in value.get("addons", []) or []:
  243. if len(addons) >= 10:
  244. raise ServiceDataError("too_many_addons", "标准加项最多 10 个。")
  245. addons.append(
  246. {
  247. "addon_id": str(item.get("addon_id") or uuid4().hex),
  248. "name": _clean_text(item.get("name"), max_length=40, required=True),
  249. "unit": _clean_text(item.get("unit"), max_length=20) or "项",
  250. "price": _positive_decimal(item.get("price"), "加项价格"),
  251. "extra_minutes": max(0, min(480, int(item.get("extra_minutes") or 0))),
  252. }
  253. )
  254. radius = value.get("service_radius_km")
  255. if radius in (None, ""):
  256. radius = None
  257. else:
  258. radius = float(radius)
  259. if not 0.5 <= radius <= 200:
  260. raise ServiceDataError("invalid_service_radius", "上门半径必须在 0.5 到 200 公里之间。")
  261. if "onsite" in modes and radius is None:
  262. raise ServiceDataError("service_radius_required", "上门套餐必须填写服务半径。")
  263. out_of_range_policy = _clean_text(
  264. value.get("out_of_range_policy"),
  265. max_length=200,
  266. required="onsite" in modes,
  267. )
  268. source_type = str(value.get("source_type") or "custom")
  269. if source_type not in {"template", "custom"}:
  270. raise ServiceDataError("invalid_package_source", "套餐来源无效。")
  271. return {
  272. "package_id": str(package_id or value.get("package_id") or uuid4().hex),
  273. "name": _clean_text(value.get("name"), max_length=50, required=True),
  274. "category": _clean_text(value.get("category"), max_length=30, required=True),
  275. "description": _clean_text(value.get("description"), max_length=800, required=True),
  276. "tags": _normalize_tags(value.get("tags", [])),
  277. "service_modes": modes,
  278. "price_mode": price_mode,
  279. "currency": currency,
  280. "price_unit": price_unit,
  281. "min_price": min_price,
  282. "max_price": max_price,
  283. "price_display": _clean_text(value.get("price_display"), max_length=80),
  284. "duration_minutes": duration,
  285. "included_items": _clean_text(value.get("included_items"), max_length=500),
  286. "excluded_items": _clean_text(value.get("excluded_items"), max_length=500),
  287. "preparation": _clean_text(value.get("preparation"), max_length=500),
  288. "addons": addons,
  289. "travel_fee": {
  290. "mode": travel_mode,
  291. "amount": _positive_decimal(travel.get("amount"), "交通费"),
  292. "per_km": _positive_decimal(travel.get("per_km"), "每公里交通费"),
  293. "description": _clean_text(travel.get("description"), max_length=200),
  294. },
  295. "service_radius_km": radius,
  296. "out_of_range_policy": out_of_range_policy,
  297. "source_type": source_type,
  298. "source_template_id": value.get("source_template_id"),
  299. "source_template_version": value.get("source_template_version"),
  300. "customized_from_template": bool(value.get("customized_from_template")),
  301. }
  302. def _normalize_profile(value: dict[str, Any]) -> dict[str, Any]:
  303. packages = [normalize_package(item) for item in value.get("packages", [])]
  304. if not 1 <= len(packages) <= 5:
  305. raise ServiceDataError("invalid_package_count", "技师必须发布 1 到 5 个套餐。")
  306. modes = sorted({mode for item in packages for mode in item["service_modes"]})
  307. venue = value.get("venue") if isinstance(value.get("venue"), dict) else {}
  308. onsite = value.get("onsite_policy") if isinstance(value.get("onsite_policy"), dict) else {}
  309. public_area_text = _clean_text(
  310. value.get("public_area_text"), max_length=80, required=True
  311. )
  312. venue_name = _clean_text(
  313. venue.get("name"), max_length=80, required="at_store" in modes
  314. )
  315. venue_address = _clean_text(
  316. venue.get("address_hint"), max_length=120, required="at_store" in modes
  317. )
  318. onsite_description = _clean_text(
  319. onsite.get("description"), max_length=300, required="onsite" in modes
  320. )
  321. return {
  322. "headline": _clean_text(value.get("headline"), max_length=80, required=True),
  323. "bio": _clean_text(value.get("bio"), max_length=800, required=True),
  324. "tags": _normalize_tags(value.get("tags", [])),
  325. "contact_hours": _clean_text(value.get("contact_hours"), max_length=120, required=True),
  326. "service_modes": modes,
  327. "accepting_requests": bool(value.get("accepting_requests", True)),
  328. "public_area_text": public_area_text,
  329. "venue": {
  330. "name": venue_name,
  331. "address_hint": venue_address,
  332. },
  333. "onsite_policy": {
  334. "description": onsite_description,
  335. },
  336. "packages": packages,
  337. "is_complete": True,
  338. }
  339. async def ensure_service_indexes() -> None:
  340. global _indexes_ready
  341. if _indexes_ready:
  342. return
  343. await templatesdb.create_index([("template_id", ASCENDING)], unique=True)
  344. await templatesdb.create_index([("status", ASCENDING), ("sort_order", ASCENDING)])
  345. await ordersdb.create_index([("order_id", ASCENDING)], unique=True)
  346. await ordersdb.create_index([("customer_id", ASCENDING), ("created_at", DESCENDING)])
  347. await ordersdb.create_index([("technician_id", ASCENDING), ("created_at", DESCENDING)])
  348. await ordersdb.create_index([("status", ASCENDING), ("updated_at", DESCENDING)])
  349. await quotesdb.create_index([("quote_id", ASCENDING)], unique=True)
  350. await quotesdb.create_index([("order_id", ASCENDING), ("version", DESCENDING)], unique=True)
  351. await addressesdb.create_index([("order_id", ASCENDING)], unique=True)
  352. await addressesdb.create_index([("redact_after", ASCENDING)])
  353. await eventsdb.create_index([("event_id", ASCENDING)], unique=True)
  354. await eventsdb.create_index([("order_id", ASCENDING), ("created_at", ASCENDING)])
  355. await customersdb.create_index([("user_id", ASCENDING)], unique=True)
  356. await blocksdb.create_index([("scope", ASCENDING), ("technician_id", ASCENDING), ("customer_id", ASCENDING)], unique=True)
  357. await reportsdb.create_index([("report_id", ASCENDING)], unique=True)
  358. await qrdb.create_index([("qr_id", ASCENDING)], unique=True)
  359. await qrdb.create_index([("token_hash", ASCENDING)], unique=True)
  360. await qrdb.create_index([("order_id", ASCENDING), ("created_at", DESCENDING)])
  361. await reviewtemplatesdb.create_index([("version", DESCENDING)], unique=True)
  362. await reviewdraftsdb.create_index([("customer_id", ASCENDING)], unique=True)
  363. await reviewdraftsdb.create_index([("expires_at", ASCENDING)], expireAfterSeconds=0)
  364. await reviewsdb.create_index([("review_id", ASCENDING)], unique=True)
  365. await reviewsdb.create_index([("qr_id", ASCENDING)], unique=True, sparse=True)
  366. await reviewsdb.create_index([("technician_id", ASCENDING), ("status", ASCENDING), ("created_at", DESCENDING)])
  367. await statsdb.create_index([("technician_id", ASCENDING), ("category", ASCENDING)], unique=True)
  368. _indexes_ready = True
  369. async def record_service_event(
  370. order_id: str,
  371. event_type: str,
  372. *,
  373. actor_id: int | str,
  374. reason: str = "",
  375. metadata: dict[str, Any] | None = None,
  376. ) -> dict[str, Any]:
  377. await ensure_service_indexes()
  378. document = {
  379. "event_id": uuid4().hex,
  380. "order_id": str(order_id),
  381. "event_type": str(event_type),
  382. "actor_id": actor_id,
  383. "reason": _clean_text(reason, max_length=500),
  384. "metadata": metadata or {},
  385. "created_at": utc_now(),
  386. }
  387. await eventsdb.insert_one(document)
  388. return document
  389. async def record_service_funnel_event(user_id: int, event_type: str) -> dict[str, Any]:
  390. if event_type not in {"directory_viewed", "technician_viewed", "request_started"}:
  391. raise ServiceDataError("invalid_funnel_event", "不支持该履约漏斗事件。")
  392. return await record_service_event(
  393. "",
  394. event_type,
  395. actor_id=int(user_id),
  396. metadata={"funnel": True},
  397. )
  398. async def observe_customer(user: Any, *, accepted_terms: bool = False) -> dict[str, Any]:
  399. await ensure_service_indexes()
  400. now = utc_now()
  401. display_name = " ".join(
  402. value for value in (str(getattr(user, "first_name", "") or "").strip(), str(getattr(user, "last_name", "") or "").strip()) if value
  403. ) or f"用户 {user.id}"
  404. updates: dict[str, Any] = {
  405. "username": getattr(user, "username", None),
  406. "display_name": display_name,
  407. "updated_at": now,
  408. }
  409. if accepted_terms:
  410. updates["terms_accepted_at"] = now
  411. await customersdb.update_one(
  412. {"user_id": int(user.id)},
  413. {"$set": updates, "$setOnInsert": {"created_at": now, "blocked": False}},
  414. upsert=True,
  415. )
  416. return await customersdb.find_one({"user_id": int(user.id)}) or {}
  417. async def require_customer_allowed(user_id: int) -> dict[str, Any]:
  418. await ensure_service_indexes()
  419. customer = await customersdb.find_one({"user_id": int(user_id)})
  420. if not customer or not customer.get("terms_accepted_at"):
  421. raise ServiceDataError("terms_required", "请先接受服务规则和隐私说明。")
  422. if customer.get("blocked"):
  423. raise ServiceDataError("customer_blocked", "你的服务功能已被暂停。")
  424. global_block = await blocksdb.find_one({"scope": "global", "customer_id": int(user_id), "active": True})
  425. if global_block:
  426. raise ServiceDataError("customer_blocked", "你的服务功能已被暂停。")
  427. return customer
  428. async def get_service_settings() -> dict[str, int]:
  429. stored = await settingsdb.find_one({"settings_id": "global"}) or {}
  430. return {
  431. "customer_max_open_orders": max(
  432. 1,
  433. min(
  434. 20,
  435. int(
  436. stored.get("service_customer_max_open_orders")
  437. or getattr(wbb, "SERVICE_CUSTOMER_MAX_OPEN_ORDERS", 3)
  438. or 3
  439. ),
  440. ),
  441. ),
  442. "customer_daily_request_limit": max(
  443. 1,
  444. min(
  445. 100,
  446. int(
  447. stored.get("service_customer_daily_request_limit")
  448. or getattr(wbb, "SERVICE_CUSTOMER_DAILY_REQUEST_LIMIT", 10)
  449. or 10
  450. ),
  451. ),
  452. ),
  453. "review_qr_expiry_hours": max(
  454. 1,
  455. min(
  456. 168,
  457. int(
  458. stored.get("service_review_qr_expiry_hours")
  459. or getattr(wbb, "SERVICE_REVIEW_QR_EXPIRY_HOURS", 24)
  460. or 24
  461. ),
  462. ),
  463. ),
  464. }
  465. async def set_service_settings(values: dict[str, Any]) -> dict[str, int]:
  466. current = await get_service_settings()
  467. try:
  468. normalized = {
  469. "customer_max_open_orders": max(
  470. 1,
  471. min(20, int(values.get("customer_max_open_orders", current["customer_max_open_orders"]))),
  472. ),
  473. "customer_daily_request_limit": max(
  474. 1,
  475. min(
  476. 100,
  477. int(
  478. values.get(
  479. "customer_daily_request_limit",
  480. current["customer_daily_request_limit"],
  481. )
  482. ),
  483. ),
  484. ),
  485. "review_qr_expiry_hours": max(
  486. 1,
  487. min(168, int(values.get("review_qr_expiry_hours", current["review_qr_expiry_hours"]))),
  488. ),
  489. }
  490. except (TypeError, ValueError) as exc:
  491. raise ServiceDataError("invalid_service_settings", "履约设置必须是有效整数。") from exc
  492. await settingsdb.update_one(
  493. {"settings_id": "global"},
  494. {
  495. "$set": {
  496. "service_customer_max_open_orders": normalized["customer_max_open_orders"],
  497. "service_customer_daily_request_limit": normalized["customer_daily_request_limit"],
  498. "service_review_qr_expiry_hours": normalized["review_qr_expiry_hours"],
  499. "updated_at": utc_now(),
  500. },
  501. "$setOnInsert": {"created_at": utc_now()},
  502. },
  503. upsert=True,
  504. )
  505. return normalized
  506. async def list_package_templates(*, include_archived: bool = False) -> list[dict[str, Any]]:
  507. await ensure_service_indexes()
  508. filters = {} if include_archived else {"status": {"$ne": "archived"}}
  509. return await templatesdb.find(filters).sort([("sort_order", ASCENDING), ("created_at", ASCENDING)]).to_list(length=500)
  510. async def ensure_builtin_package_templates() -> list[dict[str, Any]]:
  511. """Seed editable starter templates once without overwriting administrator changes."""
  512. await ensure_service_indexes()
  513. now = utc_now()
  514. template_ids = []
  515. for item in BUILTIN_PACKAGE_TEMPLATES:
  516. template_id = str(item["template_id"])
  517. template_ids.append(template_id)
  518. package = normalize_package(
  519. item["package"],
  520. package_id=str(item["package"]["package_id"]),
  521. )
  522. package["source_type"] = "template"
  523. await templatesdb.update_one(
  524. {"template_id": template_id},
  525. {
  526. "$setOnInsert": {
  527. "template_id": template_id,
  528. "version": 1,
  529. "name": item["name"],
  530. "admin_note": "系统内置快速模板,可在后台编辑、停用或归档。",
  531. "package": package,
  532. "status": "enabled",
  533. "sort_order": int(item["sort_order"]),
  534. "builtin": True,
  535. "created_by": "system",
  536. "created_at": now,
  537. "updated_at": now,
  538. }
  539. },
  540. upsert=True,
  541. )
  542. return await templatesdb.find({"template_id": {"$in": template_ids}}).sort(
  543. [("sort_order", ASCENDING)]
  544. ).to_list(length=len(template_ids))
  545. async def create_package_template(values: dict[str, Any], *, actor_id: str) -> dict[str, Any]:
  546. await ensure_service_indexes()
  547. now = utc_now()
  548. package = normalize_package(values.get("package") or values)
  549. package["source_type"] = "template"
  550. document = {
  551. "template_id": uuid4().hex,
  552. "version": 1,
  553. "name": _clean_text(values.get("template_name") or package["name"], max_length=80, required=True),
  554. "admin_note": _clean_text(values.get("admin_note"), max_length=500),
  555. "package": package,
  556. "status": "enabled" if values.get("enabled", True) else "disabled",
  557. "sort_order": int(values.get("sort_order") or 0),
  558. "created_by": str(actor_id),
  559. "created_at": now,
  560. "updated_at": now,
  561. }
  562. await templatesdb.insert_one(document)
  563. return document
  564. async def update_package_template(template_id: str, values: dict[str, Any], *, actor_id: str) -> dict[str, Any]:
  565. current = await templatesdb.find_one({"template_id": str(template_id)})
  566. if not current:
  567. raise ServiceDataError("template_not_found", "未找到套餐模板。")
  568. package = normalize_package(values.get("package") or values, package_id=current["package"]["package_id"])
  569. package["source_type"] = "template"
  570. updated = await templatesdb.find_one_and_update(
  571. {"template_id": str(template_id)},
  572. {
  573. "$set": {
  574. "name": _clean_text(values.get("template_name") or package["name"], max_length=80, required=True),
  575. "admin_note": _clean_text(values.get("admin_note"), max_length=500),
  576. "package": package,
  577. "sort_order": int(values.get("sort_order") or 0),
  578. "updated_by": str(actor_id),
  579. "updated_at": utc_now(),
  580. },
  581. "$inc": {"version": 1},
  582. },
  583. return_document=ReturnDocument.AFTER,
  584. )
  585. return updated or {}
  586. async def set_package_template_status(template_id: str, action: str, *, actor_id: str) -> dict[str, Any]:
  587. status_map = {"enable": "enabled", "disable": "disabled", "archive": "archived"}
  588. if action not in status_map:
  589. raise ServiceDataError("invalid_template_action", "不支持该模板操作。")
  590. updated = await templatesdb.find_one_and_update(
  591. {"template_id": str(template_id)},
  592. {"$set": {"status": status_map[action], "updated_by": str(actor_id), "updated_at": utc_now()}},
  593. return_document=ReturnDocument.AFTER,
  594. )
  595. if not updated:
  596. raise ServiceDataError("template_not_found", "未找到套餐模板。")
  597. return updated
  598. async def duplicate_package_template(template_id: str, *, actor_id: str) -> dict[str, Any]:
  599. current = await templatesdb.find_one({"template_id": str(template_id)})
  600. if not current:
  601. raise ServiceDataError("template_not_found", "未找到套餐模板。")
  602. values = {
  603. "template_name": f"{current['name']}(副本)",
  604. "admin_note": current.get("admin_note", ""),
  605. "sort_order": current.get("sort_order", 0),
  606. "enabled": False,
  607. "package": current["package"],
  608. }
  609. return await create_package_template(values, actor_id=actor_id)
  610. async def publish_technician_profile(user_id: int, values: dict[str, Any], *, actor_id: int | str) -> dict[str, Any]:
  611. await ensure_service_indexes()
  612. profile = await profilesdb.find_one({"user_id": int(user_id)})
  613. if not profile or profile.get("application_status") != "approved":
  614. raise ServiceDataError("technician_required", "只有已认证技师可以发布服务资料。")
  615. if not profile.get("username"):
  616. raise ServiceDataError("username_required", "技师必须设置有效的 Telegram 用户名。")
  617. if not await locationsdb.find_one({"user_id": int(user_id)}):
  618. raise ServiceDataError("technician_location_required", "技师必须设置有效服务位置。")
  619. if not await membershipsdb.find_one({"user_id": int(user_id), "active": True}):
  620. raise ServiceDataError("technician_membership_required", "技师必须仍是受管群当前成员。")
  621. normalized = _normalize_profile(values)
  622. now = utc_now()
  623. normalized.update({"updated_at": now, "updated_by": actor_id, "version": int((profile.get("service_profile") or {}).get("version") or 0) + 1})
  624. await profilesdb.update_one(
  625. {"user_id": int(user_id)},
  626. {"$set": {"service_profile": normalized, "updated_at": now}},
  627. )
  628. return await profilesdb.find_one({"user_id": int(user_id)}) or {}
  629. async def get_technician_service_profile(user_id: int) -> dict[str, Any]:
  630. profile = await profilesdb.find_one({"user_id": int(user_id)})
  631. if not profile:
  632. raise ServiceDataError("technician_not_found", "未找到技师资料。")
  633. return profile
  634. async def get_technician_self_service_context(user_id: int) -> dict[str, Any]:
  635. profile = await profilesdb.find_one({"user_id": int(user_id)})
  636. if not profile:
  637. raise ServiceDataError("technician_not_found", "未找到技师申请资料。")
  638. location_configured = bool(
  639. await locationsdb.find_one({"user_id": int(user_id)})
  640. )
  641. membership_active = bool(
  642. await membershipsdb.find_one({"user_id": int(user_id), "active": True})
  643. )
  644. issues = []
  645. if profile.get("application_status") != "approved":
  646. issues.append({"code": "approval_required", "message": "技师认证尚未通过。"})
  647. if not profile.get("username"):
  648. issues.append(
  649. {
  650. "code": "username_required",
  651. "message": "请先在 Telegram 设置用户名。",
  652. }
  653. )
  654. if not location_configured:
  655. issues.append(
  656. {
  657. "code": "location_required",
  658. "message": "请先在 Bot 中设置服务基准位置。",
  659. }
  660. )
  661. if not membership_active:
  662. issues.append(
  663. {
  664. "code": "membership_required",
  665. "message": "当前不在受管群成员名单中。",
  666. }
  667. )
  668. return {
  669. "user_id": int(user_id),
  670. "display_name": profile.get("display_name") or f"技师 {user_id}",
  671. "username": profile.get("username"),
  672. "eligibility": {"ready": not issues, "issues": issues},
  673. "service_profile": profile.get("service_profile") or None,
  674. }
  675. async def set_technician_accepting_requests(
  676. user_id: int,
  677. accepting_requests: bool,
  678. ) -> dict[str, Any]:
  679. profile = await get_technician_service_profile(user_id)
  680. values = dict(profile.get("service_profile") or {})
  681. if not values.get("is_complete"):
  682. raise ServiceDataError("technician_profile_required", "请先发布至少一个服务套餐。")
  683. values["accepting_requests"] = bool(accepting_requests)
  684. return await publish_technician_profile(
  685. user_id,
  686. values,
  687. actor_id=user_id,
  688. )
  689. async def instantiate_package_template(template_id: str) -> dict[str, Any]:
  690. template = await templatesdb.find_one(
  691. {"template_id": str(template_id), "status": "enabled"}
  692. )
  693. if not template:
  694. raise ServiceDataError("template_not_found", "套餐模板不存在或已停用。")
  695. package = dict(template["package"])
  696. package.update(
  697. {
  698. "package_id": uuid4().hex,
  699. "source_type": "template",
  700. "source_template_id": template["template_id"],
  701. "source_template_version": template["version"],
  702. "customized_from_template": False,
  703. }
  704. )
  705. return package
  706. def _fernet() -> Fernet:
  707. raw = str(getattr(wbb, "SERVICE_ADDRESS_ENCRYPTION_KEY", "") or "").strip()
  708. if not raw:
  709. raise ServiceDataError("address_encryption_required", "服务地址加密密钥尚未配置。")
  710. try:
  711. return Fernet(raw.encode())
  712. except (ValueError, TypeError) as exc:
  713. raise ServiceDataError("invalid_address_encryption_key", "服务地址加密密钥无效。") from exc
  714. def _encrypt_address(payload: dict[str, Any]) -> str:
  715. return _fernet().encrypt(json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode()).decode()
  716. def _decrypt_address(token: str) -> dict[str, Any]:
  717. try:
  718. return json.loads(_fernet().decrypt(str(token).encode()).decode())
  719. except (InvalidToken, ValueError, json.JSONDecodeError) as exc:
  720. raise ServiceDataError("address_unavailable", "精确地址无法解密。") from exc
  721. async def _schedule_address_redaction(order_id: str, closed_at: datetime) -> None:
  722. retention = max(
  723. 1,
  724. min(365, int(getattr(wbb, "SERVICE_ADDRESS_RETENTION_DAYS", 7) or 7)),
  725. )
  726. await addressesdb.update_one(
  727. {"order_id": str(order_id), "redacted": False},
  728. {
  729. "$set": {
  730. "redact_after": closed_at + timedelta(days=retention),
  731. "updated_at": closed_at,
  732. }
  733. },
  734. )
  735. def _distance_meters(lon1: float, lat1: float, lon2: float, lat2: float) -> float:
  736. radius = 6_371_000.0
  737. phi1, phi2 = math.radians(lat1), math.radians(lat2)
  738. delta_phi = math.radians(lat2 - lat1)
  739. delta_lambda = math.radians(lon2 - lon1)
  740. a = math.sin(delta_phi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(delta_lambda / 2) ** 2
  741. return radius * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
  742. def distance_band(distance_meters: float) -> str:
  743. km = max(0.0, distance_meters / 1000)
  744. for threshold in (1, 3, 5, 10, 20, 50):
  745. if km <= threshold:
  746. return f"{threshold} 公里内"
  747. return "50 公里以上"
  748. async def _get_package(technician_id: int, package_id: str) -> tuple[dict[str, Any], dict[str, Any]]:
  749. profile = await profilesdb.find_one({"user_id": int(technician_id)})
  750. if not profile or profile.get("application_status") != "approved":
  751. raise ServiceDataError("technician_not_found", "未找到已认证技师。")
  752. if not profile.get("listed") or not profile.get("username"):
  753. raise ServiceDataError("technician_unavailable", "该技师暂未公开接单。")
  754. if not await membershipsdb.find_one({"user_id": int(technician_id), "active": True}):
  755. raise ServiceDataError("technician_unavailable", "该技师当前不满足接单资格。")
  756. service_profile = profile.get("service_profile") or {}
  757. if not service_profile.get("is_complete") or not service_profile.get("accepting_requests", True):
  758. raise ServiceDataError("technician_unavailable", "该技师暂未接单。")
  759. package = next((item for item in service_profile.get("packages", []) if str(item.get("package_id")) == str(package_id)), None)
  760. if not package:
  761. raise ServiceDataError("package_not_found", "未找到该服务套餐。")
  762. return profile, package
  763. async def list_available_technicians(
  764. *,
  765. longitude: float,
  766. latitude: float,
  767. max_distance_meters: float | None = None,
  768. query: str = "",
  769. page: int = 1,
  770. page_size: int = 10,
  771. ) -> tuple[list[dict[str, Any]], int]:
  772. lon, lat = float(longitude), float(latitude)
  773. if not -180 <= lon <= 180 or not -90 <= lat <= 90:
  774. raise ServiceDataError("invalid_coordinates", "位置坐标无效。")
  775. filters: dict[str, Any] = {
  776. "application_status": "approved",
  777. "listed": True,
  778. "username": {"$nin": [None, ""]},
  779. "service_profile.is_complete": True,
  780. "service_profile.accepting_requests": True,
  781. }
  782. if query:
  783. pattern = re.compile(re.escape(query.lstrip("@")), re.IGNORECASE)
  784. filters["$or"] = [
  785. {"username": pattern},
  786. {"display_name": pattern},
  787. {"service_profile.tags": pattern},
  788. {"service_profile.packages.category": pattern},
  789. ]
  790. active_members = {
  791. int(item["user_id"])
  792. async for item in membershipsdb.find({"active": True}, {"user_id": 1})
  793. }
  794. profiles = {
  795. int(item["user_id"]): item
  796. async for item in profilesdb.find(filters)
  797. if int(item["user_id"]) in active_members
  798. }
  799. values: list[dict[str, Any]] = []
  800. if profiles:
  801. async for location in locationsdb.find({"user_id": {"$in": list(profiles)}}):
  802. point = location.get("point", {}).get("coordinates") or [
  803. location.get("longitude"),
  804. location.get("latitude"),
  805. ]
  806. if len(point) != 2 or point[0] is None or point[1] is None:
  807. continue
  808. distance = _distance_meters(lon, lat, float(point[0]), float(point[1]))
  809. if max_distance_meters is not None and distance > float(max_distance_meters):
  810. continue
  811. profile = profiles[int(location["user_id"])]
  812. service_profile = profile.get("service_profile") or {}
  813. values.append(
  814. {
  815. "user_id": int(profile["user_id"]),
  816. "username": profile.get("username"),
  817. "display_name": profile.get("display_name"),
  818. "headline": service_profile.get("headline"),
  819. "bio": service_profile.get("bio"),
  820. "tags": service_profile.get("tags", []),
  821. "public_area_text": service_profile.get("public_area_text"),
  822. "service_modes": service_profile.get("service_modes", []),
  823. "packages": service_profile.get("packages", []),
  824. "distance_meters": round(distance, 2),
  825. "distance_band": distance_band(distance),
  826. }
  827. )
  828. values.sort(key=lambda item: (item["distance_meters"], item["user_id"]))
  829. total = len(values)
  830. start = (max(1, page) - 1) * max(1, page_size)
  831. return values[start : start + max(1, page_size)], total
  832. async def create_service_order(
  833. *,
  834. customer: Any,
  835. technician_id: int,
  836. package_id: str,
  837. service_mode: str,
  838. requirements: str,
  839. longitude: float | None = None,
  840. latitude: float | None = None,
  841. address_text: str = "",
  842. ) -> dict[str, Any]:
  843. await ensure_service_indexes()
  844. await observe_customer(customer)
  845. await require_customer_allowed(int(customer.id))
  846. if int(customer.id) == int(technician_id):
  847. raise ServiceDataError("self_service_not_allowed", "不能向自己发起服务请求。")
  848. personal_block = await blocksdb.find_one({"scope": "technician", "technician_id": int(technician_id), "customer_id": int(customer.id), "active": True})
  849. if personal_block:
  850. raise ServiceDataError("customer_blocked", "该技师暂不接受你的服务请求。")
  851. service_settings = await get_service_settings()
  852. max_open = service_settings["customer_max_open_orders"]
  853. open_count = await ordersdb.count_documents({"customer_id": int(customer.id), "status": {"$in": list(ORDER_ACTIVE_STATUSES)}})
  854. if open_count >= max_open:
  855. raise ServiceDataError("too_many_open_orders", f"最多同时保留 {max_open} 个未关闭服务单。")
  856. day_start = utc_now().replace(hour=0, minute=0, second=0, microsecond=0)
  857. daily_limit = service_settings["customer_daily_request_limit"]
  858. if await ordersdb.count_documents({"customer_id": int(customer.id), "created_at": {"$gte": day_start}}) >= daily_limit:
  859. raise ServiceDataError("daily_request_limit", "今天发起的服务请求已达到上限。")
  860. profile, package = await _get_package(int(technician_id), str(package_id))
  861. if service_mode not in package["service_modes"]:
  862. raise ServiceDataError("service_mode_unavailable", "该套餐不支持所选服务方式。")
  863. technician_location = await locationsdb.find_one({"user_id": int(technician_id)})
  864. if not technician_location:
  865. raise ServiceDataError("technician_location_required", "技师尚未配置服务位置。")
  866. address_payload: dict[str, Any] | None = None
  867. distance = 0.0
  868. if service_mode == "onsite":
  869. if longitude is None or latitude is None:
  870. raise ServiceDataError("customer_location_required", "上门服务必须提供位置。")
  871. lon, lat = float(longitude), float(latitude)
  872. if not -180 <= lon <= 180 or not -90 <= lat <= 90:
  873. raise ServiceDataError("invalid_coordinates", "位置坐标无效。")
  874. address = _clean_text(address_text, max_length=300, required=True)
  875. point = technician_location.get("point", {}).get("coordinates") or [technician_location.get("longitude"), technician_location.get("latitude")]
  876. distance = _distance_meters(float(point[0]), float(point[1]), lon, lat)
  877. radius = float(package.get("service_radius_km") or 0)
  878. if radius and distance > radius * 1000:
  879. raise ServiceDataError("outside_service_radius", "顾客位置超出该套餐的上门服务范围。")
  880. address_payload = {"kind": "customer", "longitude": lon, "latitude": lat, "address_text": address}
  881. else:
  882. if longitude is None or latitude is None:
  883. raise ServiceDataError(
  884. "customer_location_required", "到店服务必须先通过附近查找提供位置。"
  885. )
  886. lon, lat = float(longitude), float(latitude)
  887. if not -180 <= lon <= 180 or not -90 <= lat <= 90:
  888. raise ServiceDataError("invalid_coordinates", "位置坐标无效。")
  889. point = technician_location.get("point", {}).get("coordinates") or [
  890. technician_location.get("longitude"),
  891. technician_location.get("latitude"),
  892. ]
  893. distance = _distance_meters(float(point[0]), float(point[1]), lon, lat)
  894. venue = (profile.get("service_profile") or {}).get("venue") or {}
  895. address_payload = {
  896. "kind": "venue",
  897. "longitude": technician_location.get("longitude"),
  898. "latitude": technician_location.get("latitude"),
  899. "address_text": _clean_text(venue.get("address_hint"), max_length=300, required=True),
  900. }
  901. now = utc_now()
  902. order_id = uuid4().hex
  903. order = {
  904. "order_id": order_id,
  905. "customer_id": int(customer.id),
  906. "customer_name": _clean_text(getattr(customer, "first_name", ""), max_length=80) or f"用户 {customer.id}",
  907. "technician_id": int(technician_id),
  908. "technician_name": profile.get("display_name") or f"技师 {technician_id}",
  909. "package_id": str(package_id),
  910. "package_snapshot": package,
  911. "category": package["category"],
  912. "service_mode": service_mode,
  913. "requirements": _clean_text(requirements, max_length=800, required=True),
  914. "distance_meters": round(distance, 2),
  915. "distance_band": distance_band(distance),
  916. "status": "requested",
  917. "version": 1,
  918. "created_at": now,
  919. "updated_at": now,
  920. }
  921. encrypted_address = _encrypt_address(address_payload)
  922. await ordersdb.insert_one(order)
  923. try:
  924. await addressesdb.insert_one(
  925. {
  926. "order_id": order_id,
  927. "encrypted_payload": encrypted_address,
  928. "redacted": False,
  929. "redact_after": None,
  930. "created_at": now,
  931. "updated_at": now,
  932. }
  933. )
  934. except Exception:
  935. await ordersdb.delete_one({"order_id": order_id, "status": "requested"})
  936. raise
  937. await record_service_event(order_id, "order_requested", actor_id=int(customer.id))
  938. return order
  939. def normalize_quote(value: dict[str, Any], *, package: dict[str, Any]) -> dict[str, Any]:
  940. currency = str(value.get("currency") or package.get("currency") or "CNY").upper()
  941. if currency != str(package.get("currency") or currency):
  942. raise ServiceDataError("currency_mismatch", "最终报价币种必须与套餐一致。")
  943. base = _positive_decimal(value.get("base_amount"), "服务金额")
  944. travel = _positive_decimal(value.get("travel_fee"), "交通费")
  945. discount = _positive_decimal(value.get("discount"), "优惠金额")
  946. addons = []
  947. addon_total = Decimal("0")
  948. for item in value.get("addons", []) or []:
  949. quantity = max(1, min(999, int(item.get("quantity") or 1)))
  950. unit_price = Decimal(_positive_decimal(item.get("unit_price"), "加项单价"))
  951. addons.append({"name": _clean_text(item.get("name"), max_length=60, required=True), "quantity": quantity, "unit_price": format(unit_price, "f")})
  952. addon_total += unit_price * quantity
  953. total = Decimal(base) + Decimal(travel) + addon_total - Decimal(discount)
  954. if total < 0:
  955. raise ServiceDataError("invalid_total", "最终报价总额不能小于 0。")
  956. provided = value.get("total_amount")
  957. if provided not in (None, "") and Decimal(_positive_decimal(provided, "总价")) != total:
  958. raise ServiceDataError("quote_total_mismatch", "报价明细与总价不一致。")
  959. return {
  960. "currency": currency,
  961. "base_amount": base,
  962. "travel_fee": travel,
  963. "addons": addons,
  964. "discount": discount,
  965. "total_amount": format(total.quantize(Decimal("0.01")), "f"),
  966. "note": _clean_text(value.get("note"), max_length=500),
  967. }
  968. async def submit_service_quote(order_id: str, technician_id: int, values: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
  969. await ensure_service_indexes()
  970. order = await ordersdb.find_one({"order_id": str(order_id)})
  971. if not order or int(order["technician_id"]) != int(technician_id):
  972. raise ServiceDataError("order_not_found", "未找到可报价服务单。")
  973. if order["status"] not in {"requested", "quoted"}:
  974. raise ServiceDataError("invalid_order_state", "当前服务单不能修改报价。")
  975. quote = normalize_quote(values, package=order["package_snapshot"])
  976. version = int(order.get("quote_version") or 0) + 1
  977. quote.update({"quote_id": uuid4().hex, "order_id": str(order_id), "version": version, "created_by": int(technician_id), "created_at": utc_now()})
  978. try:
  979. await quotesdb.insert_one(quote)
  980. except DuplicateKeyError as exc:
  981. raise ServiceDataError(
  982. "concurrent_order_update", "服务单状态已变化,请刷新后重试。"
  983. ) from exc
  984. updated = await ordersdb.find_one_and_update(
  985. {
  986. "order_id": str(order_id),
  987. "status": {"$in": ["requested", "quoted"]},
  988. "version": order["version"],
  989. },
  990. {
  991. "$set": {
  992. "status": "quoted",
  993. "current_quote_id": quote["quote_id"],
  994. "quote_snapshot": quote,
  995. "quote_version": version,
  996. "updated_at": utc_now(),
  997. },
  998. "$inc": {"version": 1},
  999. },
  1000. return_document=ReturnDocument.AFTER,
  1001. )
  1002. if not updated:
  1003. await quotesdb.delete_one({"quote_id": quote["quote_id"]})
  1004. raise ServiceDataError("concurrent_order_update", "服务单状态已变化,请刷新后重试。")
  1005. await record_service_event(order_id, "quote_submitted", actor_id=int(technician_id), metadata={"quote_id": quote["quote_id"], "version": version})
  1006. return updated, quote
  1007. async def confirm_service_quote(order_id: str, customer_id: int) -> dict[str, Any]:
  1008. order = await ordersdb.find_one_and_update(
  1009. {"order_id": str(order_id), "customer_id": int(customer_id), "status": "quoted"},
  1010. {"$set": {"status": "confirmed", "confirmed_at": utc_now(), "updated_at": utc_now()}, "$inc": {"version": 1}},
  1011. return_document=ReturnDocument.AFTER,
  1012. )
  1013. if not order:
  1014. raise ServiceDataError("invalid_order_state", "当前报价无法确认。")
  1015. await record_service_event(order_id, "quote_confirmed", actor_id=int(customer_id))
  1016. return order
  1017. async def reject_service_order(order_id: str, technician_id: int, reason: str) -> dict[str, Any]:
  1018. now = utc_now()
  1019. order = await ordersdb.find_one_and_update(
  1020. {"order_id": str(order_id), "technician_id": int(technician_id), "status": {"$in": ["requested", "quoted"]}},
  1021. {"$set": {"status": "rejected", "closed_at": now, "updated_at": now, "close_reason": _clean_text(reason, max_length=500, required=True)}, "$inc": {"version": 1}},
  1022. return_document=ReturnDocument.AFTER,
  1023. )
  1024. if not order:
  1025. raise ServiceDataError("invalid_order_state", "当前服务单无法拒绝。")
  1026. await _schedule_address_redaction(order_id, now)
  1027. await record_service_event(order_id, "order_rejected", actor_id=int(technician_id), reason=reason)
  1028. return order
  1029. async def start_service_order(order_id: str, technician_id: int) -> dict[str, Any]:
  1030. order = await ordersdb.find_one_and_update(
  1031. {"order_id": str(order_id), "technician_id": int(technician_id), "status": "confirmed"},
  1032. {"$set": {"status": "in_progress", "started_at": utc_now(), "updated_at": utc_now()}, "$inc": {"version": 1}},
  1033. return_document=ReturnDocument.AFTER,
  1034. )
  1035. if not order:
  1036. raise ServiceDataError("invalid_order_state", "只有已确认服务单可以开始。")
  1037. await record_service_event(order_id, "service_started", actor_id=int(technician_id))
  1038. return order
  1039. async def cancel_service_order(order_id: str, actor_id: int, reason: str) -> dict[str, Any]:
  1040. order = await ordersdb.find_one({"order_id": str(order_id)})
  1041. if not order or int(actor_id) not in {int(order["customer_id"]), int(order["technician_id"])}:
  1042. raise ServiceDataError("order_not_found", "未找到该服务单。")
  1043. if order["status"] not in ORDER_ACTIVE_STATUSES - {"disputed"}:
  1044. raise ServiceDataError("invalid_order_state", "当前服务单不能取消。")
  1045. status = "canceled_customer" if int(actor_id) == int(order["customer_id"]) else "canceled_technician"
  1046. now = utc_now()
  1047. updated = await ordersdb.find_one_and_update(
  1048. {"order_id": str(order_id), "status": order["status"], "version": order["version"]},
  1049. {"$set": {"status": status, "closed_at": now, "updated_at": now, "close_reason": _clean_text(reason, max_length=500, required=True)}, "$inc": {"version": 1}},
  1050. return_document=ReturnDocument.AFTER,
  1051. )
  1052. if not updated:
  1053. raise ServiceDataError("concurrent_order_update", "服务单状态已变化,请刷新后重试。")
  1054. await _schedule_address_redaction(order_id, now)
  1055. await qrdb.update_many({"order_id": str(order_id), "status": "issued"}, {"$set": {"status": "revoked", "revoked_at": now, "revoke_reason": "服务单已取消"}})
  1056. await record_service_event(order_id, "order_canceled", actor_id=int(actor_id), reason=reason, metadata={"status": status})
  1057. return updated
  1058. async def dispute_service_order(order_id: str, actor_id: int, reason: str) -> dict[str, Any]:
  1059. current = await ordersdb.find_one(
  1060. {
  1061. "order_id": str(order_id),
  1062. "status": {"$in": list(ORDER_ACTIVE_STATUSES - {"disputed"})},
  1063. "$or": [{"customer_id": int(actor_id)}, {"technician_id": int(actor_id)}],
  1064. }
  1065. )
  1066. if not current:
  1067. raise ServiceDataError("invalid_order_state", "当前服务单不能发起争议。")
  1068. order = await ordersdb.find_one_and_update(
  1069. {"order_id": str(order_id), "status": current["status"], "version": current["version"]},
  1070. {"$set": {"status": "disputed", "status_before_dispute": current["status"], "dispute_reason": _clean_text(reason, max_length=500, required=True), "updated_at": utc_now()}, "$inc": {"version": 1}},
  1071. return_document=ReturnDocument.AFTER,
  1072. )
  1073. if not order:
  1074. raise ServiceDataError("invalid_order_state", "当前服务单不能发起争议。")
  1075. await qrdb.update_many({"order_id": str(order_id), "status": "issued"}, {"$set": {"status": "revoked", "revoked_at": utc_now(), "revoke_reason": "服务单争议"}})
  1076. await record_service_event(order_id, "order_disputed", actor_id=int(actor_id), reason=reason)
  1077. return order
  1078. async def resolve_service_dispute(
  1079. order_id: str,
  1080. *,
  1081. actor_id: str,
  1082. action: str,
  1083. reason: str,
  1084. ) -> dict[str, Any]:
  1085. if action == "void":
  1086. return await admin_void_service_order(order_id, actor_id=actor_id, reason=reason)
  1087. if action != "resume":
  1088. raise ServiceDataError("invalid_dispute_action", "不支持该争议处理操作。")
  1089. current = await ordersdb.find_one({"order_id": str(order_id), "status": "disputed"})
  1090. if not current:
  1091. raise ServiceDataError("invalid_order_state", "该服务单当前不在争议中。")
  1092. resume_status = str(current.get("status_before_dispute") or "confirmed")
  1093. if resume_status == "completion_pending":
  1094. resume_status = "in_progress"
  1095. if resume_status not in {"requested", "quoted", "confirmed", "in_progress"}:
  1096. resume_status = "confirmed"
  1097. updated = await ordersdb.find_one_and_update(
  1098. {"order_id": str(order_id), "status": "disputed", "version": current["version"]},
  1099. {
  1100. "$set": {
  1101. "status": resume_status,
  1102. "dispute_resolved_at": utc_now(),
  1103. "dispute_resolution_reason": _clean_text(reason, max_length=500, required=True),
  1104. "updated_at": utc_now(),
  1105. },
  1106. "$unset": {"status_before_dispute": ""},
  1107. "$inc": {"version": 1},
  1108. },
  1109. return_document=ReturnDocument.AFTER,
  1110. )
  1111. if not updated:
  1112. raise ServiceDataError("concurrent_order_update", "服务单状态已变化,请刷新后重试。")
  1113. await record_service_event(order_id, "dispute_resolved", actor_id=actor_id, reason=reason, metadata={"status": resume_status})
  1114. return updated
  1115. async def admin_void_service_order(order_id: str, *, actor_id: str, reason: str) -> dict[str, Any]:
  1116. now = utc_now()
  1117. order = await ordersdb.find_one_and_update(
  1118. {"order_id": str(order_id), "status": {"$ne": "voided"}},
  1119. {"$set": {"status": "voided", "closed_at": now, "updated_at": now, "close_reason": _clean_text(reason, max_length=500, required=True)}, "$inc": {"version": 1}},
  1120. return_document=ReturnDocument.AFTER,
  1121. )
  1122. if not order:
  1123. raise ServiceDataError("order_not_found", "未找到可作废服务单。")
  1124. await _schedule_address_redaction(order_id, now)
  1125. await qrdb.update_many({"order_id": str(order_id), "status": {"$ne": "voided"}}, {"$set": {"status": "voided", "revoked_at": now}})
  1126. await reviewsdb.update_many({"order_id": str(order_id), "status": "approved"}, {"$set": {"status": "voided", "moderated_at": now, "moderation_reason": reason, "updated_at": now}})
  1127. await recompute_review_stats()
  1128. await record_service_event(order_id, "order_voided", actor_id=actor_id, reason=reason)
  1129. return order
  1130. async def issue_review_qr(order_id: str, technician_id: int) -> tuple[dict[str, Any], str]:
  1131. await ensure_service_indexes()
  1132. order = await ordersdb.find_one({"order_id": str(order_id), "technician_id": int(technician_id)})
  1133. if order and order.get("status") == "completion_pending":
  1134. previous = await qrdb.find_one(
  1135. {"order_id": str(order_id), "status": "issued"},
  1136. sort=[("created_at", DESCENDING)],
  1137. )
  1138. if previous and _as_utc(previous["expires_at"]) <= utc_now():
  1139. now = utc_now()
  1140. expired = await qrdb.find_one_and_update(
  1141. {"qr_id": previous["qr_id"], "status": "issued"},
  1142. {"$set": {"status": "expired", "expired_at": now}},
  1143. return_document=ReturnDocument.AFTER,
  1144. )
  1145. if expired:
  1146. await ordersdb.update_one(
  1147. {
  1148. "order_id": str(order_id),
  1149. "status": "completion_pending",
  1150. "current_review_qr_id": previous["qr_id"],
  1151. },
  1152. {
  1153. "$set": {"status": "in_progress", "updated_at": now},
  1154. "$unset": {"current_review_qr_id": ""},
  1155. "$inc": {"version": 1},
  1156. },
  1157. )
  1158. order = await ordersdb.find_one(
  1159. {"order_id": str(order_id), "technician_id": int(technician_id)}
  1160. )
  1161. if not order or order.get("status") != "in_progress":
  1162. raise ServiceDataError("invalid_order_state", "只有服务中的订单可以生成评价二维码。")
  1163. if await qrdb.find_one({"order_id": str(order_id), "status": {"$in": ["issued", "claimed"]}}):
  1164. raise ServiceDataError("review_qr_exists", "该服务单已经生成评价二维码。")
  1165. token = token_urlsafe(18)
  1166. now = utc_now()
  1167. hours = (await get_service_settings())["review_qr_expiry_hours"]
  1168. document = {
  1169. "qr_id": uuid4().hex,
  1170. "order_id": str(order_id),
  1171. "technician_id": int(technician_id),
  1172. "customer_id": int(order["customer_id"]),
  1173. "package_snapshot": order["package_snapshot"],
  1174. "quote_snapshot": order.get("quote_snapshot"),
  1175. "token_hash": hashlib.sha256(token.encode()).hexdigest(),
  1176. "status": "issued",
  1177. "expires_at": now + timedelta(hours=hours),
  1178. "created_at": now,
  1179. }
  1180. try:
  1181. await qrdb.insert_one(document)
  1182. except DuplicateKeyError as exc:
  1183. raise ServiceDataError("review_qr_exists", "该服务单已经生成评价二维码。") from exc
  1184. updated = await ordersdb.find_one_and_update(
  1185. {
  1186. "order_id": str(order_id),
  1187. "status": "in_progress",
  1188. "version": order["version"],
  1189. },
  1190. {
  1191. "$set": {
  1192. "status": "completion_pending",
  1193. "current_review_qr_id": document["qr_id"],
  1194. "completion_requested_at": now,
  1195. "updated_at": now,
  1196. },
  1197. "$inc": {"version": 1},
  1198. },
  1199. return_document=ReturnDocument.AFTER,
  1200. )
  1201. if not updated:
  1202. await qrdb.update_one(
  1203. {"qr_id": document["qr_id"], "status": "issued"},
  1204. {
  1205. "$set": {
  1206. "status": "voided",
  1207. "revoked_at": utc_now(),
  1208. "revoke_reason": "服务单状态已变化",
  1209. }
  1210. },
  1211. )
  1212. raise ServiceDataError("concurrent_order_update", "服务单状态已变化,请刷新后重试。")
  1213. await record_service_event(order_id, "review_qr_issued", actor_id=int(technician_id), metadata={"qr_id": document["qr_id"]})
  1214. return document, token
  1215. async def preview_review_qr(token: str, customer_id: int) -> dict[str, Any]:
  1216. document = await qrdb.find_one({"token_hash": hashlib.sha256(str(token).encode()).hexdigest()})
  1217. if not document:
  1218. raise ServiceDataError("review_qr_unavailable", "评价二维码无效、已使用或已过期。")
  1219. if int(document["customer_id"]) != int(customer_id):
  1220. raise ServiceDataError("review_qr_customer_mismatch", "该二维码不属于当前顾客。")
  1221. if document.get("status") == "claimed":
  1222. if await reviewsdb.find_one({"qr_id": document["qr_id"]}):
  1223. raise ServiceDataError("review_qr_unavailable", "评价二维码无效、已使用或已过期。")
  1224. return document
  1225. if document.get("status") != "issued":
  1226. raise ServiceDataError("review_qr_unavailable", "评价二维码无效、已使用或已过期。")
  1227. if _as_utc(document["expires_at"]) <= utc_now():
  1228. now = utc_now()
  1229. expired = await qrdb.find_one_and_update(
  1230. {"qr_id": document["qr_id"], "status": "issued"},
  1231. {"$set": {"status": "expired", "expired_at": now}},
  1232. return_document=ReturnDocument.AFTER,
  1233. )
  1234. if expired:
  1235. await ordersdb.update_one(
  1236. {
  1237. "order_id": document["order_id"],
  1238. "status": "completion_pending",
  1239. "current_review_qr_id": document["qr_id"],
  1240. },
  1241. {
  1242. "$set": {"status": "in_progress", "updated_at": now},
  1243. "$unset": {"current_review_qr_id": ""},
  1244. "$inc": {"version": 1},
  1245. },
  1246. )
  1247. raise ServiceDataError("review_qr_unavailable", "评价二维码无效、已使用或已过期。")
  1248. return document
  1249. async def claim_review_qr(token: str, customer_id: int) -> tuple[dict[str, Any], dict[str, Any]]:
  1250. token_hash = hashlib.sha256(str(token).encode()).hexdigest()
  1251. now = utc_now()
  1252. await preview_review_qr(token, customer_id)
  1253. document = await qrdb.find_one_and_update(
  1254. {"token_hash": token_hash, "customer_id": int(customer_id), "status": "issued"},
  1255. {"$set": {"status": "claimed", "claimed_at": now}},
  1256. return_document=ReturnDocument.AFTER,
  1257. )
  1258. if not document:
  1259. raise ServiceDataError("review_qr_unavailable", "评价二维码无效、已使用或已过期。")
  1260. order = await ordersdb.find_one_and_update(
  1261. {"order_id": document["order_id"], "status": "completion_pending", "customer_id": int(customer_id)},
  1262. {"$set": {"status": "completed", "completed_at": now, "closed_at": now, "updated_at": now}, "$inc": {"version": 1}},
  1263. return_document=ReturnDocument.AFTER,
  1264. )
  1265. if not order:
  1266. await qrdb.update_one({"qr_id": document["qr_id"]}, {"$set": {"status": "voided"}})
  1267. raise ServiceDataError("invalid_order_state", "服务单状态已变化,无法完成评价。")
  1268. await _schedule_address_redaction(order["order_id"], now)
  1269. await record_service_event(order["order_id"], "service_completed", actor_id=int(customer_id), metadata={"qr_id": document["qr_id"]})
  1270. return document, order
  1271. def normalize_review_template(values: dict[str, Any]) -> dict[str, Any]:
  1272. ratings = []
  1273. for item in values.get("rating_questions", []):
  1274. if len(ratings) >= 5:
  1275. raise ServiceDataError("too_many_review_questions", "评分维度最多 5 个。")
  1276. weight = int(item.get("weight") or 0)
  1277. if weight <= 0:
  1278. raise ServiceDataError("invalid_review_weight", "评分权重必须大于 0。")
  1279. ratings.append({"question_id": str(item.get("question_id") or uuid4().hex[:12]), "label": _clean_text(item.get("label"), max_length=40, required=True), "description": _clean_text(item.get("description"), max_length=160), "required": bool(item.get("required", True)), "weight": weight})
  1280. if not ratings or sum(item["weight"] for item in ratings) != 100:
  1281. raise ServiceDataError("invalid_review_weight", "评分维度权重合计必须为 100%。")
  1282. texts = []
  1283. for item in values.get("text_questions", []):
  1284. if len(texts) >= 2:
  1285. raise ServiceDataError("too_many_review_questions", "文字问题最多 2 个。")
  1286. texts.append({"question_id": str(item.get("question_id") or uuid4().hex[:12]), "label": _clean_text(item.get("label"), max_length=40, required=True), "description": _clean_text(item.get("description"), max_length=160), "required": bool(item.get("required")), "max_length": max(50, min(1000, int(item.get("max_length") or 500)))})
  1287. return {"rating_questions": ratings, "text_questions": texts}
  1288. async def get_active_review_template() -> dict[str, Any]:
  1289. await ensure_service_indexes()
  1290. current = await reviewtemplatesdb.find_one({"status": "active"}, sort=[("version", DESCENDING)])
  1291. if current:
  1292. return current
  1293. now = utc_now()
  1294. document = {"template_id": uuid4().hex, "version": 1, "status": "active", **normalize_review_template(DEFAULT_REVIEW_TEMPLATE), "created_at": now, "published_at": now}
  1295. try:
  1296. await reviewtemplatesdb.insert_one(document)
  1297. except DuplicateKeyError:
  1298. return await reviewtemplatesdb.find_one({"status": "active"}, sort=[("version", DESCENDING)]) or document
  1299. return document
  1300. async def publish_review_template(values: dict[str, Any], *, actor_id: str) -> dict[str, Any]:
  1301. normalized = normalize_review_template(values)
  1302. await ensure_service_indexes()
  1303. while True:
  1304. current = await reviewtemplatesdb.find_one(sort=[("version", DESCENDING)])
  1305. version = int((current or {}).get("version") or 0) + 1
  1306. now = utc_now()
  1307. document = {
  1308. "template_id": uuid4().hex,
  1309. "version": version,
  1310. "status": "active",
  1311. **normalized,
  1312. "published_by": str(actor_id),
  1313. "created_at": now,
  1314. "published_at": now,
  1315. }
  1316. try:
  1317. await reviewtemplatesdb.insert_one(document)
  1318. break
  1319. except DuplicateKeyError:
  1320. continue
  1321. await reviewtemplatesdb.update_many(
  1322. {"status": "active", "template_id": {"$ne": document["template_id"]}},
  1323. {"$set": {"status": "retired", "retired_at": now}},
  1324. )
  1325. return document
  1326. async def begin_review_draft(
  1327. *,
  1328. customer_id: int,
  1329. technician_id: int,
  1330. source: str,
  1331. template: dict[str, Any],
  1332. package_id: str | None = None,
  1333. qr_id: str | None = None,
  1334. ) -> dict[str, Any]:
  1335. await ensure_service_indexes()
  1336. now = utc_now()
  1337. current = await reviewdraftsdb.find_one({"customer_id": int(customer_id)})
  1338. identity = {
  1339. "technician_id": int(technician_id),
  1340. "source": str(source),
  1341. "package_id": str(package_id) if package_id else None,
  1342. "qr_id": str(qr_id) if qr_id else None,
  1343. }
  1344. if (
  1345. current
  1346. and all(current.get(key) == value for key, value in identity.items())
  1347. and _as_utc(current["expires_at"]) > now
  1348. ):
  1349. return current
  1350. document = {
  1351. "draft_id": uuid4().hex,
  1352. "customer_id": int(customer_id),
  1353. **identity,
  1354. "template_snapshot": {
  1355. key: template[key]
  1356. for key in (
  1357. "template_id",
  1358. "version",
  1359. "rating_questions",
  1360. "text_questions",
  1361. )
  1362. },
  1363. "rating_index": 0,
  1364. "text_index": 0,
  1365. "answers": {},
  1366. "created_at": now,
  1367. "updated_at": now,
  1368. "expires_at": now + timedelta(days=30),
  1369. }
  1370. await reviewdraftsdb.update_one(
  1371. {"customer_id": int(customer_id)}, {"$set": document}, upsert=True
  1372. )
  1373. return document
  1374. async def save_review_draft_progress(
  1375. customer_id: int,
  1376. *,
  1377. answers: dict[str, Any],
  1378. rating_index: int,
  1379. text_index: int,
  1380. ) -> None:
  1381. await reviewdraftsdb.update_one(
  1382. {"customer_id": int(customer_id)},
  1383. {
  1384. "$set": {
  1385. "answers": dict(answers),
  1386. "rating_index": max(0, int(rating_index)),
  1387. "text_index": max(0, int(text_index)),
  1388. "updated_at": utc_now(),
  1389. }
  1390. },
  1391. )
  1392. async def delete_review_draft(customer_id: int) -> None:
  1393. await reviewdraftsdb.delete_one({"customer_id": int(customer_id)})
  1394. def _score_review(template: dict[str, Any], answers: dict[str, Any]) -> tuple[float, dict[str, int], dict[str, str]]:
  1395. ratings: dict[str, int] = {}
  1396. texts: dict[str, str] = {}
  1397. total = 0.0
  1398. for question in template["rating_questions"]:
  1399. raw = answers.get(question["question_id"])
  1400. if raw in (None, "") and not question["required"]:
  1401. continue
  1402. try:
  1403. score = int(raw)
  1404. except (TypeError, ValueError) as exc:
  1405. raise ServiceDataError("invalid_review_answer", "评分必须是 1 到 5 的整数。") from exc
  1406. if not 1 <= score <= 5:
  1407. raise ServiceDataError("invalid_review_answer", "评分必须在 1 到 5 之间。")
  1408. ratings[question["question_id"]] = score
  1409. total += score * question["weight"] / 100
  1410. for question in template["text_questions"]:
  1411. text = _clean_text(answers.get(question["question_id"]), max_length=question["max_length"], required=question["required"])
  1412. if text:
  1413. texts[question["question_id"]] = text
  1414. return round(total, 4), ratings, texts
  1415. async def submit_review(
  1416. *,
  1417. customer: Any,
  1418. technician_id: int,
  1419. source: str,
  1420. answers: dict[str, Any],
  1421. anonymous: bool,
  1422. package_id: str | None = None,
  1423. qr_id: str | None = None,
  1424. template_snapshot: dict[str, Any] | None = None,
  1425. ) -> dict[str, Any]:
  1426. await observe_customer(customer)
  1427. await require_customer_allowed(int(customer.id))
  1428. if source not in REVIEW_SOURCES:
  1429. raise ServiceDataError("invalid_review_source", "评价来源无效。")
  1430. profile = await profilesdb.find_one({"user_id": int(technician_id), "application_status": "approved"})
  1431. if not profile:
  1432. raise ServiceDataError("technician_not_found", "未找到已认证技师。")
  1433. if int(customer.id) == int(technician_id):
  1434. raise ServiceDataError("self_review_not_allowed", "不能评价自己。")
  1435. order_id = None
  1436. package_snapshot = None
  1437. if source == "qr_verified":
  1438. qr = await qrdb.find_one({"qr_id": str(qr_id), "status": "claimed", "customer_id": int(customer.id), "technician_id": int(technician_id)})
  1439. if not qr:
  1440. raise ServiceDataError("review_qr_required", "缺少已完成服务单的评价资格。")
  1441. if await reviewsdb.find_one({"qr_id": str(qr_id)}):
  1442. raise ServiceDataError("review_already_submitted", "该服务单已经提交评价。")
  1443. order_id = qr["order_id"]
  1444. package_snapshot = qr["package_snapshot"]
  1445. else:
  1446. service_profile = profile.get("service_profile") or {}
  1447. package_snapshot = next((item for item in service_profile.get("packages", []) if str(item.get("package_id")) == str(package_id)), None)
  1448. if package_id and not package_snapshot:
  1449. raise ServiceDataError("package_not_found", "未找到所选套餐。")
  1450. package_snapshot = package_snapshot or {"package_id": None, "name": "其他服务", "category": "其他"}
  1451. template = template_snapshot or await get_active_review_template()
  1452. score, rating_answers, text_answers = _score_review(template, answers)
  1453. normalized_content = "\n".join(
  1454. value.casefold() for _, value in sorted(text_answers.items()) if value
  1455. )
  1456. now = utc_now()
  1457. document = {
  1458. "review_id": uuid4().hex,
  1459. "technician_id": int(technician_id),
  1460. "technician_name": profile.get("display_name") or f"技师 {technician_id}",
  1461. "customer_id": int(customer.id),
  1462. "customer_name": _clean_text(getattr(customer, "first_name", ""), max_length=80) or f"用户 {customer.id}",
  1463. "source": source,
  1464. "package_snapshot": package_snapshot,
  1465. "category": package_snapshot.get("category") or "其他",
  1466. "template_snapshot": {key: template[key] for key in ("template_id", "version", "rating_questions", "text_questions")},
  1467. "rating_answers": rating_answers,
  1468. "text_answers": text_answers,
  1469. "content_fingerprint": (
  1470. hashlib.sha256(normalized_content.encode()).hexdigest()
  1471. if normalized_content
  1472. else None
  1473. ),
  1474. "score": score,
  1475. "anonymous": bool(anonymous),
  1476. "status": "pending",
  1477. "created_at": now,
  1478. "updated_at": now,
  1479. }
  1480. if qr_id:
  1481. document["qr_id"] = str(qr_id)
  1482. document["order_id"] = order_id
  1483. try:
  1484. await reviewsdb.insert_one(document)
  1485. except DuplicateKeyError as exc:
  1486. if qr_id:
  1487. raise ServiceDataError(
  1488. "review_already_submitted", "该服务单已经提交评价。"
  1489. ) from exc
  1490. raise
  1491. return document
  1492. async def moderate_review(review_id: str, action: str, *, actor_id: str, reason: str = "") -> dict[str, Any]:
  1493. status_map = {"approve": "approved", "reject": "rejected", "void": "voided"}
  1494. source_status = {"approve": "pending", "reject": "pending", "void": "approved"}
  1495. if action not in status_map:
  1496. raise ServiceDataError("invalid_review_action", "不支持该评价操作。")
  1497. current = await reviewsdb.find_one({"review_id": str(review_id)})
  1498. if not current:
  1499. raise ServiceDataError("review_not_found", "未找到评价。")
  1500. if current["status"] != source_status[action]:
  1501. raise ServiceDataError("invalid_review_state", "当前评价状态不能执行该审核操作。")
  1502. if action in {"reject", "void"} and not _clean_text(reason, max_length=500):
  1503. raise ServiceDataError("reason_required", "拒绝或作废必须填写原因。")
  1504. updated = await reviewsdb.find_one_and_update(
  1505. {"review_id": str(review_id), "status": source_status[action]},
  1506. {"$set": {"status": status_map[action], "moderated_by": str(actor_id), "moderated_at": utc_now(), "moderation_reason": _clean_text(reason, max_length=500), "updated_at": utc_now()}},
  1507. return_document=ReturnDocument.AFTER,
  1508. )
  1509. if not updated:
  1510. raise ServiceDataError("concurrent_review_update", "评价状态已变化,请刷新后重试。")
  1511. await recompute_review_stats()
  1512. return updated
  1513. async def recompute_review_stats() -> None:
  1514. await ensure_service_indexes()
  1515. approved = await reviewsdb.find({"status": "approved"}).to_list(length=100000)
  1516. grouped: dict[tuple[int, str], list[dict[str, Any]]] = defaultdict(list)
  1517. category_scores: dict[str, list[float]] = defaultdict(list)
  1518. for review in approved:
  1519. key = (int(review["technician_id"]), str(review.get("category") or "其他"))
  1520. grouped[key].append(review)
  1521. category_scores[key[1]].append(float(review["score"]))
  1522. now = utc_now()
  1523. await statsdb.delete_many({})
  1524. documents = []
  1525. for (technician_id, category), values in grouped.items():
  1526. count = len(values)
  1527. average = sum(float(item["score"]) for item in values) / count
  1528. category_average = sum(category_scores[category]) / len(category_scores[category])
  1529. rank_score = count / (count + 5) * average + 5 / (count + 5) * category_average
  1530. dimensions: dict[str, list[int]] = defaultdict(list)
  1531. for review in values:
  1532. for key, score in review.get("rating_answers", {}).items():
  1533. dimensions[key].append(int(score))
  1534. documents.append(
  1535. {
  1536. "technician_id": technician_id,
  1537. "category": category,
  1538. "review_count": count,
  1539. "average_score": round(average, 4),
  1540. "category_average": round(category_average, 4),
  1541. "rank_score": round(rank_score, 6),
  1542. "eligible": count >= 3,
  1543. "dimension_averages": {key: round(sum(items) / len(items), 4) for key, items in dimensions.items()},
  1544. "last_review_at": max(item.get("moderated_at") or item["created_at"] for item in values),
  1545. "updated_at": now,
  1546. }
  1547. )
  1548. if documents:
  1549. await statsdb.insert_many(documents)
  1550. async def list_leaderboard(*, category: str = "", page: int = 1, page_size: int = 20) -> tuple[list[dict[str, Any]], int]:
  1551. await ensure_service_indexes()
  1552. filters: dict[str, Any] = {"eligible": True}
  1553. if category:
  1554. filters["category"] = str(category)
  1555. visible_ids = [
  1556. int(item["user_id"])
  1557. async for item in profilesdb.find(
  1558. {
  1559. "application_status": "approved",
  1560. "listed": True,
  1561. "username": {"$nin": [None, ""]},
  1562. },
  1563. {"user_id": 1},
  1564. )
  1565. ]
  1566. filters["technician_id"] = {"$in": visible_ids}
  1567. total = await statsdb.count_documents(filters)
  1568. items = await statsdb.find(filters).sort([("rank_score", DESCENDING), ("review_count", DESCENDING), ("last_review_at", DESCENDING)]).skip((max(1, page) - 1) * page_size).limit(page_size).to_list(length=page_size)
  1569. profiles = {int(item["user_id"]): item async for item in profilesdb.find({"user_id": {"$in": [item["technician_id"] for item in items]}, "application_status": "approved", "listed": True})}
  1570. visible = [{**item, "display_name": profiles[item["technician_id"]].get("display_name"), "username": profiles[item["technician_id"]].get("username")} for item in items if item["technician_id"] in profiles]
  1571. return visible, total
  1572. async def list_public_reviews(
  1573. technician_id: int,
  1574. *,
  1575. page: int = 1,
  1576. page_size: int = 10,
  1577. ) -> tuple[list[dict[str, Any]], int]:
  1578. filters = {"technician_id": int(technician_id), "status": "approved"}
  1579. total = await reviewsdb.count_documents(filters)
  1580. items = await reviewsdb.find(filters).sort("moderated_at", DESCENDING).skip(
  1581. (max(1, page) - 1) * page_size
  1582. ).limit(page_size).to_list(length=page_size)
  1583. public_items = []
  1584. for item in items:
  1585. public_items.append(
  1586. {
  1587. "review_id": item["review_id"],
  1588. "source": item["source"],
  1589. "source_label": (
  1590. "完成服务单评价"
  1591. if item["source"] == "qr_verified"
  1592. else "用户主动评价"
  1593. ),
  1594. "customer_name": "匿名顾客" if item.get("anonymous", True) else item.get("customer_name"),
  1595. "package_name": (item.get("package_snapshot") or {}).get("name"),
  1596. "category": item.get("category"),
  1597. "score": item.get("score"),
  1598. "rating_answers": item.get("rating_answers", {}),
  1599. "text_answers": item.get("text_answers", {}),
  1600. "approved_at": item.get("moderated_at"),
  1601. }
  1602. )
  1603. return public_items, total
  1604. async def get_technician_review_summary(technician_id: int) -> dict[str, Any]:
  1605. items = await reviewsdb.find(
  1606. {"technician_id": int(technician_id), "status": "approved"},
  1607. {"score": 1, "source": 1},
  1608. ).to_list(length=100000)
  1609. count = len(items)
  1610. return {
  1611. "review_count": count,
  1612. "average_score": round(
  1613. sum(float(item["score"]) for item in items) / count,
  1614. 4,
  1615. )
  1616. if count
  1617. else 0,
  1618. "source_counts": {
  1619. source: sum(1 for item in items if item.get("source") == source)
  1620. for source in REVIEW_SOURCES
  1621. },
  1622. "ranking_title": "审核评价排行",
  1623. }
  1624. async def list_service_orders(*, query: str = "", status: str = "", page: int = 1, page_size: int = 20) -> tuple[list[dict[str, Any]], int]:
  1625. await ensure_service_indexes()
  1626. filters: dict[str, Any] = {}
  1627. if status:
  1628. filters["status"] = status
  1629. if query:
  1630. if query.isdigit():
  1631. filters["$or"] = [{"customer_id": int(query)}, {"technician_id": int(query)}]
  1632. else:
  1633. pattern = re.compile(re.escape(query), re.IGNORECASE)
  1634. filters["$or"] = [{"order_id": pattern}, {"customer_name": pattern}, {"technician_name": pattern}]
  1635. total = await ordersdb.count_documents(filters)
  1636. items = await ordersdb.find(filters).sort("created_at", DESCENDING).skip((max(1, page) - 1) * page_size).limit(page_size).to_list(length=page_size)
  1637. return items, total
  1638. async def list_actor_service_orders(
  1639. actor_id: int,
  1640. *,
  1641. role: str = "all",
  1642. page: int = 1,
  1643. page_size: int = 20,
  1644. ) -> tuple[list[dict[str, Any]], int]:
  1645. if role == "customer":
  1646. filters: dict[str, Any] = {"customer_id": int(actor_id)}
  1647. elif role == "technician":
  1648. filters = {"technician_id": int(actor_id)}
  1649. else:
  1650. filters = {
  1651. "$or": [
  1652. {"customer_id": int(actor_id)},
  1653. {"technician_id": int(actor_id)},
  1654. ]
  1655. }
  1656. total = await ordersdb.count_documents(filters)
  1657. items = await ordersdb.find(filters).sort("created_at", DESCENDING).skip(
  1658. (max(1, page) - 1) * page_size
  1659. ).limit(page_size).to_list(length=page_size)
  1660. return items, total
  1661. async def get_admin_service_order(order_id: str) -> dict[str, Any]:
  1662. order = await ordersdb.find_one({"order_id": str(order_id)})
  1663. if not order:
  1664. raise ServiceDataError("order_not_found", "未找到服务单。")
  1665. result = dict(order)
  1666. result["quotes"] = await quotesdb.find({"order_id": str(order_id)}).sort(
  1667. "version", ASCENDING
  1668. ).to_list(length=100)
  1669. result["events"] = await eventsdb.find({"order_id": str(order_id)}).sort(
  1670. "created_at", ASCENDING
  1671. ).to_list(length=500)
  1672. result["qr_records"] = await qrdb.find({"order_id": str(order_id)}).sort(
  1673. "created_at", DESCENDING
  1674. ).to_list(length=100)
  1675. return result
  1676. async def list_review_qr_records(
  1677. *,
  1678. status: str = "",
  1679. query: str = "",
  1680. page: int = 1,
  1681. page_size: int = 20,
  1682. ) -> tuple[list[dict[str, Any]], int]:
  1683. filters: dict[str, Any] = {}
  1684. if status:
  1685. filters["status"] = status
  1686. if query:
  1687. filters["$or"] = [
  1688. {"order_id": re.compile(re.escape(query), re.IGNORECASE)},
  1689. {"qr_id": re.compile(re.escape(query), re.IGNORECASE)},
  1690. {"customer_id": int(query) if query.isdigit() else -1},
  1691. {"technician_id": int(query) if query.isdigit() else -1},
  1692. ]
  1693. total = await qrdb.count_documents(filters)
  1694. items = await qrdb.find(filters).sort("created_at", DESCENDING).skip(
  1695. (max(1, page) - 1) * page_size
  1696. ).limit(page_size).to_list(length=page_size)
  1697. for item in items:
  1698. item.pop("token_hash", None)
  1699. return items, total
  1700. async def list_reviews(*, status: str = "", source: str = "", query: str = "", page: int = 1, page_size: int = 20) -> tuple[list[dict[str, Any]], int]:
  1701. await ensure_service_indexes()
  1702. filters: dict[str, Any] = {}
  1703. if status:
  1704. filters["status"] = status
  1705. if source:
  1706. filters["source"] = source
  1707. if query:
  1708. pattern = re.compile(re.escape(query), re.IGNORECASE)
  1709. filters["$or"] = [{"technician_name": pattern}, {"customer_name": pattern}, {"review_id": pattern}]
  1710. total = await reviewsdb.count_documents(filters)
  1711. items = await reviewsdb.find(filters).sort("created_at", DESCENDING).skip((max(1, page) - 1) * page_size).limit(page_size).to_list(length=page_size)
  1712. for item in items:
  1713. item["customer_review_count"] = await reviewsdb.count_documents({"customer_id": item["customer_id"], "technician_id": item["technician_id"]})
  1714. item["customer_review_count_30d"] = await reviewsdb.count_documents({"customer_id": item["customer_id"], "technician_id": item["technician_id"], "created_at": {"$gte": utc_now() - timedelta(days=30)}})
  1715. item["customer_review_count_90d"] = await reviewsdb.count_documents({"customer_id": item["customer_id"], "technician_id": item["technician_id"], "created_at": {"$gte": utc_now() - timedelta(days=90)}})
  1716. item["similar_content_count"] = (
  1717. await reviewsdb.count_documents(
  1718. {
  1719. "customer_id": item["customer_id"],
  1720. "technician_id": item["technician_id"],
  1721. "content_fingerprint": item["content_fingerprint"],
  1722. }
  1723. )
  1724. if item.get("content_fingerprint")
  1725. else 0
  1726. )
  1727. return items, total
  1728. async def get_order_for_actor(order_id: str, actor_id: int, *, reveal_address: bool = False) -> dict[str, Any]:
  1729. order = await ordersdb.find_one({"order_id": str(order_id)})
  1730. if not order or int(actor_id) not in {int(order["customer_id"]), int(order["technician_id"])}:
  1731. raise ServiceDataError("order_not_found", "未找到服务单。")
  1732. result = dict(order)
  1733. can_reveal = order["status"] in {"confirmed", "in_progress", "completion_pending", "completed", "disputed"}
  1734. if reveal_address and can_reveal:
  1735. address = await addressesdb.find_one({"order_id": str(order_id), "redacted": False})
  1736. if address:
  1737. result["exact_address"] = _decrypt_address(address["encrypted_payload"])
  1738. return result
  1739. async def admin_reveal_order_address(order_id: str, *, actor_id: str, reason: str) -> dict[str, Any]:
  1740. reason = _clean_text(reason, max_length=500, required=True)
  1741. order = await ordersdb.find_one({"order_id": str(order_id)})
  1742. address = await addressesdb.find_one({"order_id": str(order_id), "redacted": False})
  1743. if not order or not address:
  1744. raise ServiceDataError("address_unavailable", "精确地址已脱敏或不存在。")
  1745. await record_service_event(order_id, "admin_address_revealed", actor_id=actor_id, reason=reason)
  1746. return _decrypt_address(address["encrypted_payload"])
  1747. async def redact_expired_addresses() -> int:
  1748. now = utc_now()
  1749. candidates = await addressesdb.find({"redacted": False, "redact_after": {"$lte": now}}).to_list(length=1000)
  1750. count = 0
  1751. for item in candidates:
  1752. order = await ordersdb.find_one({"order_id": item["order_id"]})
  1753. if not order or order.get("status") not in ORDER_TERMINAL_STATUSES:
  1754. continue
  1755. result = await addressesdb.update_one({"_id": item["_id"], "redacted": False}, {"$set": {"redacted": True, "redacted_at": now, "updated_at": now}, "$unset": {"encrypted_payload": ""}})
  1756. if result.modified_count:
  1757. await ordersdb.update_one(
  1758. {"order_id": item["order_id"]},
  1759. {
  1760. "$set": {"address_redacted": True, "updated_at": now},
  1761. "$unset": {"distance_meters": ""},
  1762. },
  1763. )
  1764. count += result.modified_count
  1765. return count
  1766. async def set_customer_block(*, customer_id: int, scope: str, technician_id: int | None, active: bool, actor_id: int | str, reason: str) -> dict[str, Any]:
  1767. if scope not in {"global", "technician"}:
  1768. raise ServiceDataError("invalid_block_scope", "拉黑范围无效。")
  1769. if scope == "technician" and technician_id is None:
  1770. raise ServiceDataError("technician_required", "技师拉黑必须指定技师。")
  1771. now = utc_now()
  1772. await blocksdb.update_one(
  1773. {"scope": scope, "technician_id": int(technician_id or 0), "customer_id": int(customer_id)},
  1774. {"$set": {"active": bool(active), "reason": _clean_text(reason, max_length=500, required=active), "actor_id": actor_id, "updated_at": now}, "$setOnInsert": {"created_at": now}},
  1775. upsert=True,
  1776. )
  1777. return await blocksdb.find_one({"scope": scope, "technician_id": int(technician_id or 0), "customer_id": int(customer_id)}) or {}
  1778. async def set_technician_customer_block(
  1779. *,
  1780. technician_id: int,
  1781. customer_id: int,
  1782. active: bool,
  1783. reason: str,
  1784. ) -> dict[str, Any]:
  1785. profile = await profilesdb.find_one(
  1786. {"user_id": int(technician_id), "application_status": "approved"}
  1787. )
  1788. if not profile:
  1789. raise ServiceDataError("technician_required", "只有已认证技师可以管理个人拉黑。")
  1790. if not await ordersdb.find_one(
  1791. {"technician_id": int(technician_id), "customer_id": int(customer_id)}
  1792. ):
  1793. raise ServiceDataError("customer_relationship_required", "只能拉黑曾向你发起服务请求的顾客。")
  1794. return await set_customer_block(
  1795. customer_id=int(customer_id),
  1796. scope="technician",
  1797. technician_id=int(technician_id),
  1798. active=active,
  1799. actor_id=int(technician_id),
  1800. reason=reason,
  1801. )
  1802. async def list_customer_blocks(
  1803. *,
  1804. scope: str = "global",
  1805. active: bool | None = None,
  1806. page: int = 1,
  1807. page_size: int = 20,
  1808. ) -> tuple[list[dict[str, Any]], int]:
  1809. filters: dict[str, Any] = {"scope": scope}
  1810. if active is not None:
  1811. filters["active"] = active
  1812. total = await blocksdb.count_documents(filters)
  1813. items = await blocksdb.find(filters).sort("updated_at", DESCENDING).skip(
  1814. (max(1, page) - 1) * page_size
  1815. ).limit(page_size).to_list(length=page_size)
  1816. return items, total
  1817. async def create_service_report(*, reporter_id: int, target_type: str, target_id: str, reason: str) -> dict[str, Any]:
  1818. if target_type not in {"technician", "customer", "order", "review"}:
  1819. raise ServiceDataError("invalid_report_target", "举报对象无效。")
  1820. document = {"report_id": uuid4().hex, "reporter_id": int(reporter_id), "target_type": target_type, "target_id": str(target_id), "reason": _clean_text(reason, max_length=800, required=True), "status": "open", "created_at": utc_now()}
  1821. await reportsdb.insert_one(document)
  1822. return document
  1823. async def list_service_reports(
  1824. *,
  1825. status: str = "",
  1826. page: int = 1,
  1827. page_size: int = 20,
  1828. ) -> tuple[list[dict[str, Any]], int]:
  1829. filters = {"status": status} if status else {}
  1830. total = await reportsdb.count_documents(filters)
  1831. items = await reportsdb.find(filters).sort("created_at", DESCENDING).skip(
  1832. (max(1, page) - 1) * page_size
  1833. ).limit(page_size).to_list(length=page_size)
  1834. return items, total
  1835. async def moderate_service_report(
  1836. report_id: str,
  1837. *,
  1838. action: str,
  1839. actor_id: str,
  1840. reason: str,
  1841. ) -> dict[str, Any]:
  1842. status_map = {"resolve": "resolved", "dismiss": "dismissed"}
  1843. if action not in status_map:
  1844. raise ServiceDataError("invalid_report_action", "不支持该举报操作。")
  1845. updated = await reportsdb.find_one_and_update(
  1846. {"report_id": str(report_id), "status": "open"},
  1847. {
  1848. "$set": {
  1849. "status": status_map[action],
  1850. "handled_by": str(actor_id),
  1851. "handled_at": utc_now(),
  1852. "handling_reason": _clean_text(reason, max_length=500, required=True),
  1853. }
  1854. },
  1855. return_document=ReturnDocument.AFTER,
  1856. )
  1857. if not updated:
  1858. raise ServiceDataError("report_not_found", "举报不存在或已处理。")
  1859. return updated
  1860. async def fulfillment_metrics() -> dict[str, Any]:
  1861. await ensure_service_indexes()
  1862. counts = {status: await ordersdb.count_documents({"status": status}) for status in ORDER_STATUSES}
  1863. valid_requests = sum(counts.values()) - counts["voided"]
  1864. quoted = await ordersdb.count_documents(
  1865. {"status": {"$ne": "voided"}, "current_quote_id": {"$exists": True}}
  1866. )
  1867. confirmed = await ordersdb.count_documents(
  1868. {"status": {"$ne": "voided"}, "confirmed_at": {"$exists": True}}
  1869. )
  1870. completed = counts["completed"]
  1871. canceled = counts["canceled_customer"] + counts["canceled_technician"]
  1872. directory_views = await eventsdb.count_documents({"event_type": "directory_viewed"})
  1873. technician_views = await eventsdb.count_documents({"event_type": "technician_viewed"})
  1874. request_starts = await eventsdb.count_documents({"event_type": "request_started"})
  1875. return {
  1876. "counts": counts,
  1877. "valid_requests": valid_requests,
  1878. "quoted_requests": quoted,
  1879. "confirmed_requests": confirmed,
  1880. "directory_views": directory_views,
  1881. "technician_views": technician_views,
  1882. "request_starts": request_starts,
  1883. "request_submission_rate": min(1.0, round(valid_requests / request_starts, 4))
  1884. if request_starts
  1885. else 0,
  1886. "quote_rate": round(quoted / valid_requests, 4) if valid_requests else 0,
  1887. "customer_confirmation_rate": round(confirmed / quoted, 4) if quoted else 0,
  1888. "match_success_rate": round(confirmed / valid_requests, 4) if valid_requests else 0,
  1889. "fulfillment_rate": round(completed / confirmed, 4) if confirmed else 0,
  1890. "cancellation_rate": round(canceled / valid_requests, 4) if valid_requests else 0,
  1891. "cancellation_distribution": {
  1892. "customer": counts["canceled_customer"],
  1893. "technician": counts["canceled_technician"],
  1894. },
  1895. "scope_note": "仅统计平台服务单;直接 Telegram 私聊成交不在统计范围。",
  1896. }