service.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608
  1. """产品目录业务逻辑。
  2. 这一层不关心数据来自 MySQL 还是测试内存,只通过 CatalogRepository 接口取数据。
  3. """
  4. import re
  5. from collections.abc import Callable
  6. from dataclasses import replace
  7. from datetime import datetime, timedelta
  8. from typing import Any
  9. from app.core.errors import AppError
  10. from app.core.identifiers import new_ulid
  11. from app.domains.catalog.models import Plan, Product, ProductChangeLog, ProductVersion
  12. from app.domains.catalog.repository import CatalogRepository
  13. class ProductCatalogService:
  14. def __init__(
  15. self,
  16. repository: CatalogRepository,
  17. clock: Callable[[], datetime],
  18. ) -> None:
  19. self._repository = repository
  20. self._clock = clock
  21. def list_available(self, *, category: str | None = None) -> list[dict[str, Any]]:
  22. """返回当前时间有效的在售产品,可选按产品类别过滤。"""
  23. now = self._clock()
  24. items: list[dict[str, Any]] = []
  25. for product in self._repository.list_products():
  26. # continue 会跳过本轮循环,继续检查下一个产品。
  27. if product.status != "ACTIVE" or (category and product.category != category):
  28. continue
  29. # 列表推导式:从产品的所有版本中筛出当前生效的版本。
  30. versions = [
  31. version
  32. for version in self._repository.list_versions(product.id)
  33. if version.is_available_at(now)
  34. ]
  35. if not versions:
  36. continue
  37. # 若多个版本同时有效,选择生效时间最晚的版本作为当前版本。
  38. current = max(versions, key=lambda version: version.effective_from)
  39. items.append(
  40. {
  41. "product_id": product.id,
  42. "product_code": product.product_code,
  43. "name": product.name,
  44. "category": product.category,
  45. "summary": product.summary,
  46. "product_version_id": current.id,
  47. "version_no": current.version_no,
  48. "plans": [
  49. {
  50. "id": plan.id,
  51. "code": plan.code,
  52. "name": plan.name,
  53. "summary": plan.summary,
  54. "premium_cents": plan.premium_cents,
  55. "coverage_amount_cents": plan.coverage_amount_cents,
  56. "min_age": plan.min_age,
  57. "max_age": plan.max_age,
  58. }
  59. for plan in current.plans
  60. if plan.status == "ACTIVE"
  61. ],
  62. }
  63. )
  64. # lambda 是一个简短的匿名函数,这里告诉 sorted 按产品编码排序。
  65. return sorted(items, key=lambda item: str(item["product_code"]))
  66. def list_managed(self) -> dict[str, Any]:
  67. items = []
  68. for product in self._repository.list_products():
  69. versions = self._repository.list_versions(product.id)
  70. items.append(
  71. {
  72. "product_id": product.id,
  73. "product_code": product.product_code,
  74. "name": product.name,
  75. "category": product.category,
  76. "summary": product.summary,
  77. "status": product.status,
  78. "versions": [
  79. {
  80. "id": version.id,
  81. "version_no": version.version_no,
  82. "status": version.status,
  83. "effective_from": version.effective_from,
  84. "effective_to": version.effective_to,
  85. "rule_version": version.rule_version,
  86. "rate_version": version.rate_version,
  87. "terms_summary": version.terms_summary,
  88. "plan_count": len(version.plans),
  89. "plans": [
  90. {
  91. "id": plan.id,
  92. "code": plan.code,
  93. "name": plan.name,
  94. "summary": plan.summary,
  95. "status": plan.status,
  96. "premium_cents": plan.premium_cents,
  97. "coverage_amount_cents": plan.coverage_amount_cents,
  98. "min_age": plan.min_age,
  99. "max_age": plan.max_age,
  100. }
  101. for plan in version.plans
  102. ],
  103. }
  104. for version in sorted(
  105. versions,
  106. key=lambda item: item.effective_from,
  107. reverse=True,
  108. )
  109. ],
  110. "recent_logs": [
  111. self._serialize_log(log)
  112. for log in self._repository.list_change_logs(product.id)[:8]
  113. ],
  114. }
  115. )
  116. sorted_items = sorted(items, key=lambda item: str(item["product_code"]))
  117. return {"items": sorted_items, "total": len(sorted_items)}
  118. def update_product_status(
  119. self,
  120. product_id: str,
  121. status: str,
  122. *,
  123. actor_id: str = "SYSTEM",
  124. actor_name: str = "系统",
  125. ) -> dict[str, Any]:
  126. if status not in {"ACTIVE", "INACTIVE"}:
  127. raise AppError("PRODUCT_STATUS_INVALID", "产品状态无效", 422)
  128. product = next(
  129. (item for item in self._repository.list_products() if item.id == product_id),
  130. None,
  131. )
  132. if product is None:
  133. raise AppError("PRODUCT_NOT_FOUND", "未找到产品", 404)
  134. updated = replace(product, status=status)
  135. self._repository.save_product(updated)
  136. self._write_log(
  137. product_id,
  138. None,
  139. "PRODUCT_STATUS_CHANGED",
  140. actor_id,
  141. actor_name,
  142. {"from": product.status, "to": status},
  143. )
  144. return {
  145. "product_id": updated.id,
  146. "product_code": updated.product_code,
  147. "name": updated.name,
  148. "status": updated.status,
  149. }
  150. def update_product(
  151. self,
  152. product_id: str,
  153. *,
  154. name: str,
  155. category: str,
  156. summary: str,
  157. actor_id: str,
  158. actor_name: str,
  159. ) -> dict[str, Any]:
  160. product = self._get_product(product_id)
  161. normalized_name = name.strip()
  162. normalized_summary = summary.strip()
  163. if not normalized_name or not normalized_summary:
  164. raise AppError("PRODUCT_CONTENT_REQUIRED", "产品名称和产品简介不能为空", 422)
  165. if category not in {"MEDICAL", "ACCIDENT"}:
  166. raise AppError("PRODUCT_CATEGORY_INVALID", "产品分类无效", 422)
  167. updated = replace(
  168. product,
  169. name=normalized_name,
  170. category=category,
  171. summary=normalized_summary,
  172. )
  173. self._repository.save_product(updated)
  174. self._write_log(
  175. product_id,
  176. None,
  177. "PRODUCT_UPDATED",
  178. actor_id,
  179. actor_name,
  180. {"name": updated.name, "category": updated.category},
  181. )
  182. return self._serialize_product(updated)
  183. def create_product(
  184. self,
  185. *,
  186. product_code: str,
  187. name: str,
  188. category: str,
  189. summary: str,
  190. version_no: str,
  191. effective_from: datetime,
  192. rule_version: str,
  193. rate_version: str,
  194. terms_summary: str,
  195. plans: list[dict[str, Any]],
  196. actor_id: str,
  197. actor_name: str,
  198. ) -> dict[str, Any]:
  199. normalized_code = product_code.strip().upper()
  200. normalized_name = name.strip()
  201. normalized_summary = summary.strip()
  202. normalized_version = version_no.strip()
  203. if not re.fullmatch(r"[A-Z][A-Z0-9-]{2,31}", normalized_code):
  204. raise AppError(
  205. "PRODUCT_CODE_INVALID",
  206. "产品编码须以字母开头,仅包含大写字母、数字和连字符",
  207. 422,
  208. )
  209. if any(
  210. product.product_code.upper() == normalized_code
  211. for product in self._repository.list_products()
  212. ):
  213. raise AppError("PRODUCT_CODE_DUPLICATED", "产品编码已存在", 409)
  214. if not normalized_name or not normalized_summary:
  215. raise AppError("PRODUCT_CONTENT_REQUIRED", "产品名称和产品简介不能为空", 422)
  216. if category not in {"MEDICAL", "ACCIDENT"}:
  217. raise AppError("PRODUCT_CATEGORY_INVALID", "产品分类无效", 422)
  218. if not normalized_version:
  219. raise AppError("VERSION_NO_REQUIRED", "首个版本号不能为空", 422)
  220. product = Product(
  221. id=new_ulid(),
  222. product_code=normalized_code,
  223. name=normalized_name,
  224. category=category,
  225. summary=normalized_summary,
  226. status="INACTIVE",
  227. )
  228. initial_version = ProductVersion(
  229. id=new_ulid(),
  230. product_id=product.id,
  231. version_no=normalized_version,
  232. status="DRAFT",
  233. effective_from=self._require_aware(effective_from),
  234. effective_to=None,
  235. plans=self._build_plans(plans, ()),
  236. rule_version=rule_version.strip() or "eligibility-v1",
  237. rate_version=rate_version.strip() or "rate-v1",
  238. terms_summary=terms_summary.strip(),
  239. )
  240. self._repository.save_product(product)
  241. self._repository.save_version(initial_version)
  242. self._write_log(
  243. product.id,
  244. initial_version.id,
  245. "PRODUCT_CREATED",
  246. actor_id,
  247. actor_name,
  248. {
  249. "product_code": product.product_code,
  250. "version_no": initial_version.version_no,
  251. },
  252. )
  253. return {
  254. "product": self._serialize_product(product),
  255. "initial_version": self._serialize_version(initial_version),
  256. }
  257. def create_draft_version(
  258. self,
  259. product_id: str,
  260. *,
  261. version_no: str,
  262. effective_from: datetime,
  263. source_version_id: str | None,
  264. actor_id: str,
  265. actor_name: str,
  266. ) -> dict[str, Any]:
  267. self._get_product(product_id)
  268. normalized_version = version_no.strip()
  269. versions = self._repository.list_versions(product_id)
  270. if not normalized_version:
  271. raise AppError("VERSION_NO_REQUIRED", "版本号不能为空", 422)
  272. if any(version.version_no == normalized_version for version in versions):
  273. raise AppError("VERSION_NO_DUPLICATED", "该产品版本号已存在", 409)
  274. source = None
  275. if source_version_id:
  276. source = next(
  277. (version for version in versions if version.id == source_version_id),
  278. None,
  279. )
  280. if source is None:
  281. raise AppError("SOURCE_VERSION_NOT_FOUND", "未找到要复制的产品版本", 404)
  282. elif versions:
  283. source = max(versions, key=lambda version: version.effective_from)
  284. plans = tuple(
  285. replace(plan, id=new_ulid()) for plan in source.plans
  286. ) if source else ()
  287. draft = ProductVersion(
  288. id=new_ulid(),
  289. product_id=product_id,
  290. version_no=normalized_version,
  291. status="DRAFT",
  292. effective_from=self._require_aware(effective_from),
  293. effective_to=None,
  294. plans=plans,
  295. rule_version=source.rule_version if source else "eligibility-v1",
  296. rate_version=source.rate_version if source else "rate-v1",
  297. terms_summary=source.terms_summary if source else "",
  298. )
  299. self._repository.save_version(draft)
  300. self._write_log(
  301. product_id,
  302. draft.id,
  303. "VERSION_DRAFT_CREATED",
  304. actor_id,
  305. actor_name,
  306. {"version_no": draft.version_no, "copied_from": source_version_id},
  307. )
  308. return self._serialize_version(draft)
  309. def update_draft_version(
  310. self,
  311. product_id: str,
  312. version_id: str,
  313. *,
  314. effective_from: datetime,
  315. effective_to: datetime | None,
  316. rule_version: str,
  317. rate_version: str,
  318. terms_summary: str,
  319. plans: list[dict[str, Any]],
  320. actor_id: str,
  321. actor_name: str,
  322. ) -> dict[str, Any]:
  323. version = self._get_version(product_id, version_id)
  324. if version.status != "DRAFT":
  325. raise AppError("VERSION_IMMUTABLE", "只有草稿版本可以编辑", 409)
  326. start = self._require_aware(effective_from)
  327. end = self._require_aware(effective_to) if effective_to else None
  328. if end is not None and end <= start:
  329. raise AppError("VERSION_PERIOD_INVALID", "销售截止时间必须晚于生效时间", 422)
  330. updated = replace(
  331. version,
  332. effective_from=start,
  333. effective_to=end,
  334. rule_version=rule_version.strip() or "eligibility-v1",
  335. rate_version=rate_version.strip() or "rate-v1",
  336. terms_summary=terms_summary.strip(),
  337. plans=self._build_plans(plans, version.plans),
  338. )
  339. self._repository.save_version(updated)
  340. self._write_log(
  341. product_id,
  342. version_id,
  343. "VERSION_DRAFT_UPDATED",
  344. actor_id,
  345. actor_name,
  346. {"version_no": updated.version_no, "plan_count": len(updated.plans)},
  347. )
  348. return self._serialize_version(updated)
  349. def publish_version(
  350. self,
  351. product_id: str,
  352. version_id: str,
  353. *,
  354. actor_id: str,
  355. actor_name: str,
  356. ) -> dict[str, Any]:
  357. version = self._get_version(product_id, version_id)
  358. if version.status != "DRAFT":
  359. raise AppError("VERSION_NOT_DRAFT", "只有草稿版本可以发布", 409)
  360. if not version.terms_summary:
  361. raise AppError("VERSION_TERMS_REQUIRED", "发布前请填写投保须知与条款摘要", 422)
  362. active_plans = [plan for plan in version.plans if plan.status == "ACTIVE"]
  363. if not active_plans:
  364. raise AppError("VERSION_PLAN_REQUIRED", "发布前至少需要一个有效保障计划", 422)
  365. if any(plan.premium_cents <= 0 or plan.coverage_amount_cents <= 0 for plan in active_plans):
  366. raise AppError("VERSION_PLAN_INVALID", "有效计划的保费和保额必须大于零", 422)
  367. for existing in self._repository.list_versions(product_id):
  368. if (
  369. existing.status == "PUBLISHED"
  370. and existing.effective_from < version.effective_from
  371. and (
  372. existing.effective_to is None
  373. or existing.effective_to >= version.effective_from
  374. )
  375. ):
  376. self._repository.save_version(
  377. replace(
  378. existing,
  379. effective_to=version.effective_from - timedelta(microseconds=1),
  380. )
  381. )
  382. published = replace(version, status="PUBLISHED")
  383. self._repository.save_version(published)
  384. self._write_log(
  385. product_id,
  386. version_id,
  387. "VERSION_PUBLISHED",
  388. actor_id,
  389. actor_name,
  390. {"version_no": published.version_no},
  391. )
  392. return self._serialize_version(published)
  393. def retire_version(
  394. self,
  395. product_id: str,
  396. version_id: str,
  397. *,
  398. actor_id: str,
  399. actor_name: str,
  400. ) -> dict[str, Any]:
  401. version = self._get_version(product_id, version_id)
  402. if version.status != "PUBLISHED":
  403. raise AppError("VERSION_NOT_PUBLISHED", "只有已发布版本可以终止", 409)
  404. retired = replace(version, status="RETIRED", effective_to=self._clock())
  405. self._repository.save_version(retired)
  406. self._write_log(
  407. product_id,
  408. version_id,
  409. "VERSION_RETIRED",
  410. actor_id,
  411. actor_name,
  412. {"version_no": retired.version_no},
  413. )
  414. return self._serialize_version(retired)
  415. def delete_draft_version(
  416. self,
  417. product_id: str,
  418. version_id: str,
  419. *,
  420. actor_id: str,
  421. actor_name: str,
  422. ) -> dict[str, Any]:
  423. version = self._get_version(product_id, version_id)
  424. if version.status != "DRAFT":
  425. raise AppError("VERSION_DELETE_DENIED", "只有草稿版本可以删除", 409)
  426. self._repository.delete_version(version_id)
  427. self._write_log(
  428. product_id,
  429. version_id,
  430. "VERSION_DRAFT_DELETED",
  431. actor_id,
  432. actor_name,
  433. {"version_no": version.version_no},
  434. )
  435. return {"version_id": version_id, "deleted": True}
  436. def list_change_logs(self, product_id: str) -> dict[str, Any]:
  437. self._get_product(product_id)
  438. items = [
  439. self._serialize_log(log)
  440. for log in self._repository.list_change_logs(product_id)
  441. ]
  442. return {"items": items, "total": len(items)}
  443. def _get_product(self, product_id: str) -> Product:
  444. product = next(
  445. (item for item in self._repository.list_products() if item.id == product_id),
  446. None,
  447. )
  448. if product is None:
  449. raise AppError("PRODUCT_NOT_FOUND", "未找到产品", 404)
  450. return product
  451. def _get_version(self, product_id: str, version_id: str) -> ProductVersion:
  452. self._get_product(product_id)
  453. version = next(
  454. (
  455. item
  456. for item in self._repository.list_versions(product_id)
  457. if item.id == version_id
  458. ),
  459. None,
  460. )
  461. if version is None:
  462. raise AppError("VERSION_NOT_FOUND", "未找到产品版本", 404)
  463. return version
  464. @staticmethod
  465. def _require_aware(value: datetime) -> datetime:
  466. if value.tzinfo is None or value.utcoffset() is None:
  467. raise AppError("DATETIME_TIMEZONE_REQUIRED", "时间必须包含时区", 422)
  468. return value
  469. @staticmethod
  470. def _build_plans(
  471. items: list[dict[str, Any]],
  472. existing_plans: tuple[Plan, ...],
  473. ) -> tuple[Plan, ...]:
  474. if not items:
  475. raise AppError("VERSION_PLAN_REQUIRED", "至少需要一个保障计划", 422)
  476. existing_by_id = {plan.id: plan for plan in existing_plans}
  477. codes: set[str] = set()
  478. plans: list[Plan] = []
  479. for item in items:
  480. code = str(item.get("code", "")).strip().upper()
  481. name = str(item.get("name", "")).strip()
  482. if not code or not name:
  483. raise AppError("PLAN_CONTENT_REQUIRED", "计划编码和名称不能为空", 422)
  484. if code in codes:
  485. raise AppError("PLAN_CODE_DUPLICATED", "同一版本的计划编码不能重复", 422)
  486. codes.add(code)
  487. min_age = int(item.get("min_age", 0))
  488. max_age = int(item.get("max_age", 100))
  489. premium_cents = int(item.get("premium_cents", 0))
  490. coverage_amount_cents = int(item.get("coverage_amount_cents", 0))
  491. if min_age < 0 or max_age > 120 or min_age > max_age:
  492. raise AppError("PLAN_AGE_INVALID", "计划年龄范围无效", 422)
  493. if premium_cents < 0 or coverage_amount_cents < 0:
  494. raise AppError("PLAN_AMOUNT_INVALID", "计划保费和保额不能小于零", 422)
  495. requested_id = str(item.get("id", ""))
  496. plan_id = requested_id if requested_id in existing_by_id else new_ulid()
  497. plans.append(
  498. Plan(
  499. id=plan_id,
  500. code=code,
  501. name=name,
  502. summary=str(item.get("summary", "")).strip(),
  503. status=(
  504. str(item.get("status", "ACTIVE"))
  505. if str(item.get("status", "ACTIVE")) in {"ACTIVE", "INACTIVE"}
  506. else "ACTIVE"
  507. ),
  508. premium_cents=premium_cents,
  509. coverage_amount_cents=coverage_amount_cents,
  510. min_age=min_age,
  511. max_age=max_age,
  512. )
  513. )
  514. return tuple(plans)
  515. def _write_log(
  516. self,
  517. product_id: str,
  518. version_id: str | None,
  519. action: str,
  520. actor_id: str,
  521. actor_name: str,
  522. detail: dict[str, Any],
  523. ) -> None:
  524. self._repository.save_change_log(
  525. ProductChangeLog(
  526. id=new_ulid(),
  527. product_id=product_id,
  528. version_id=version_id,
  529. action=action,
  530. actor_id=actor_id,
  531. actor_name=actor_name,
  532. detail=detail,
  533. created_at=self._clock(),
  534. )
  535. )
  536. @staticmethod
  537. def _serialize_product(product: Any) -> dict[str, Any]:
  538. return {
  539. "product_id": product.id,
  540. "product_code": product.product_code,
  541. "name": product.name,
  542. "category": product.category,
  543. "summary": product.summary,
  544. "status": product.status,
  545. }
  546. @staticmethod
  547. def _serialize_version(version: ProductVersion) -> dict[str, Any]:
  548. return {
  549. "id": version.id,
  550. "version_no": version.version_no,
  551. "status": version.status,
  552. "effective_from": version.effective_from,
  553. "effective_to": version.effective_to,
  554. "rule_version": version.rule_version,
  555. "rate_version": version.rate_version,
  556. "terms_summary": version.terms_summary,
  557. "plan_count": len(version.plans),
  558. "plans": [
  559. {
  560. "id": plan.id,
  561. "code": plan.code,
  562. "name": plan.name,
  563. "summary": plan.summary,
  564. "status": plan.status,
  565. "premium_cents": plan.premium_cents,
  566. "coverage_amount_cents": plan.coverage_amount_cents,
  567. "min_age": plan.min_age,
  568. "max_age": plan.max_age,
  569. }
  570. for plan in version.plans
  571. ],
  572. }
  573. @staticmethod
  574. def _serialize_log(log: ProductChangeLog) -> dict[str, Any]:
  575. return {
  576. "id": log.id,
  577. "version_id": log.version_id,
  578. "action": log.action,
  579. "actor_id": log.actor_id,
  580. "actor_name": log.actor_name,
  581. "detail": log.detail,
  582. "created_at": log.created_at,
  583. }