test_service_fulfillment.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578
  1. from __future__ import annotations
  2. import asyncio
  3. from datetime import timedelta
  4. from types import SimpleNamespace
  5. import pytest
  6. from aiohttp import CookieJar
  7. from aiohttp.test_utils import TestClient, TestServer
  8. def _user(user_id: int, *, username: str | None = None, name: str = "测试用户"):
  9. return SimpleNamespace(
  10. id=user_id,
  11. username=username,
  12. first_name=name,
  13. last_name=None,
  14. is_bot=False,
  15. )
  16. def _package(*, name: str = "基础护理", category: str = "护理"):
  17. return {
  18. "name": name,
  19. "category": category,
  20. "description": "标准线下服务套餐",
  21. "tags": ["线下", "护理"],
  22. "service_modes": ["at_store", "onsite"],
  23. "price_mode": "fixed",
  24. "currency": "CNY",
  25. "price_unit": "per_service",
  26. "min_price": "100",
  27. "max_price": "100",
  28. "duration_minutes": 60,
  29. "included_items": "基础服务",
  30. "excluded_items": "额外耗材",
  31. "addons": [
  32. {
  33. "name": "延长服务",
  34. "unit": "次",
  35. "price": "20",
  36. "extra_minutes": 15,
  37. }
  38. ],
  39. "travel_fee": {"mode": "quoted"},
  40. "service_radius_km": 20,
  41. "out_of_range_policy": "超区请重新询价",
  42. "preparation": "请提前沟通需求",
  43. }
  44. async def _technician(app_modules, service_db, *, user_id: int = 200, category: str = "护理"):
  45. directory_db = app_modules.load("wbb.utils.dbdirectory")
  46. await directory_db.upsert_directory_identity(
  47. user_id=user_id,
  48. username=f"technician{user_id}",
  49. first_name="认证",
  50. last_name="技师",
  51. )
  52. await directory_db.save_directory_location(
  53. user_id=user_id,
  54. longitude=116.4074,
  55. latitude=39.9042,
  56. source="test",
  57. actor_id=user_id,
  58. actor_name="认证技师",
  59. )
  60. await directory_db.upsert_directory_membership(
  61. bot_id="primary",
  62. chat_id=-100200,
  63. user_id=user_id,
  64. status="member",
  65. active=True,
  66. verified=True,
  67. )
  68. await directory_db.submit_teacher_application(
  69. user_id=user_id,
  70. source="test",
  71. bot_id="primary",
  72. chat_id=-100200,
  73. )
  74. await directory_db.decide_teacher_application(
  75. user_id=user_id,
  76. action="approve",
  77. actor_id="admin",
  78. actor_name="admin",
  79. source="test",
  80. )
  81. await directory_db.set_teacher_state(
  82. user_id=user_id,
  83. action="list",
  84. actor_id=user_id,
  85. actor_name="认证技师",
  86. source="test",
  87. )
  88. profile = await service_db.publish_technician_profile(
  89. user_id,
  90. {
  91. "headline": "专注线下护理",
  92. "bio": "提供到店和上门服务",
  93. "tags": ["护理"],
  94. "contact_hours": "10:00-20:00",
  95. "public_area_text": "北京朝阳区附近",
  96. "venue": {"name": "护理工作室", "address_hint": "测试路 100 号"},
  97. "onsite_policy": {"description": "20 公里内可上门"},
  98. "packages": [_package(category=category)],
  99. },
  100. actor_id=user_id,
  101. )
  102. return profile
  103. async def _accepted_customer(service_db, *, user_id: int = 300):
  104. customer = _user(user_id, username=None, name=f"顾客{user_id}")
  105. await service_db.observe_customer(customer, accepted_terms=True)
  106. return customer
  107. async def _order(service_db, customer, profile, *, service_mode: str = "onsite"):
  108. package = profile["service_profile"]["packages"][0]
  109. kwargs = {
  110. "customer": customer,
  111. "technician_id": profile["user_id"],
  112. "package_id": package["package_id"],
  113. "service_mode": service_mode,
  114. "requirements": "希望完成一次标准护理服务",
  115. }
  116. if service_mode == "onsite":
  117. kwargs.update(
  118. {
  119. "longitude": 116.4174,
  120. "latitude": 39.9042,
  121. "address_text": "测试街道 1 号 101 室",
  122. }
  123. )
  124. else:
  125. kwargs.update({"longitude": 116.4174, "latitude": 39.9042})
  126. return await service_db.create_service_order(**kwargs)
  127. async def test_public_customer_order_quote_address_and_completion_qr(app_modules):
  128. service_db = app_modules.load("wbb.utils.dbservice")
  129. profile = await _technician(app_modules, service_db)
  130. customer = await _accepted_customer(service_db)
  131. order = await _order(service_db, customer, profile)
  132. assert order["status"] == "requested"
  133. assert order["distance_band"] == "1 公里内"
  134. stored_address = await app_modules.wbb.control_db.service_order_addresses.find_one(
  135. {"order_id": order["order_id"]}
  136. )
  137. assert stored_address["redact_after"] is None
  138. assert "测试街道" not in stored_address["encrypted_payload"]
  139. hidden = await service_db.get_order_for_actor(
  140. order["order_id"], customer.id, reveal_address=True
  141. )
  142. assert "exact_address" not in hidden
  143. quoted_order, quote = await service_db.submit_service_quote(
  144. order["order_id"],
  145. profile["user_id"],
  146. {
  147. "base_amount": "100",
  148. "travel_fee": "10",
  149. "addons": [{"name": "延长服务", "quantity": 2, "unit_price": "20"}],
  150. "discount": "5",
  151. "currency": "CNY",
  152. "total_amount": "145",
  153. "note": "最终报价",
  154. },
  155. )
  156. assert quoted_order["status"] == "quoted"
  157. assert quote["total_amount"] == "145.00"
  158. confirmed = await service_db.confirm_service_quote(order["order_id"], customer.id)
  159. assert confirmed["status"] == "confirmed"
  160. revealed = await service_db.get_order_for_actor(
  161. order["order_id"], profile["user_id"], reveal_address=True
  162. )
  163. assert revealed["exact_address"]["address_text"] == "测试街道 1 号 101 室"
  164. started = await service_db.start_service_order(order["order_id"], profile["user_id"])
  165. assert started["status"] == "in_progress"
  166. qr, token = await service_db.issue_review_qr(order["order_id"], profile["user_id"])
  167. assert qr["status"] == "issued"
  168. assert len(token) >= 22
  169. assert token not in str(qr)
  170. with pytest.raises(service_db.ServiceDataError, match="不属于当前顾客"):
  171. await service_db.preview_review_qr(token, customer.id + 1)
  172. claim_results = await asyncio.gather(
  173. service_db.claim_review_qr(token, customer.id),
  174. service_db.claim_review_qr(token, customer.id),
  175. return_exceptions=True,
  176. )
  177. successful_claims = [item for item in claim_results if isinstance(item, tuple)]
  178. failed_claims = [item for item in claim_results if isinstance(item, Exception)]
  179. assert len(successful_claims) == 1
  180. assert len(failed_claims) == 1
  181. claimed, completed = successful_claims[0]
  182. assert claimed["status"] == "claimed"
  183. assert completed["status"] == "completed"
  184. assert "已使用" in str(failed_claims[0])
  185. resumable = await service_db.preview_review_qr(token, customer.id)
  186. assert resumable["status"] == "claimed"
  187. verified_review = await service_db.submit_review(
  188. customer=customer,
  189. technician_id=profile["user_id"],
  190. source="qr_verified",
  191. answers={
  192. "service_effect": 5,
  193. "professionalism": 5,
  194. "communication": 5,
  195. "comment": "已完成服务单评价",
  196. },
  197. anonymous=False,
  198. qr_id=claimed["qr_id"],
  199. )
  200. assert verified_review["source"] == "qr_verified"
  201. assert verified_review["order_id"] == order["order_id"]
  202. with pytest.raises(service_db.ServiceDataError, match="无效"):
  203. await service_db.preview_review_qr(token, customer.id)
  204. with pytest.raises(service_db.ServiceDataError, match="已经提交评价"):
  205. await service_db.submit_review(
  206. customer=customer,
  207. technician_id=profile["user_id"],
  208. source="qr_verified",
  209. answers={
  210. "service_effect": 5,
  211. "professionalism": 5,
  212. "communication": 5,
  213. },
  214. anonymous=True,
  215. qr_id=claimed["qr_id"],
  216. )
  217. address_after = await app_modules.wbb.control_db.service_order_addresses.find_one(
  218. {"order_id": order["order_id"]}
  219. )
  220. assert address_after["redact_after"] - completed["closed_at"] == timedelta(days=7)
  221. await app_modules.wbb.control_db.service_order_addresses.update_one(
  222. {"order_id": order["order_id"]},
  223. {"$set": {"redact_after": service_db.utc_now() - timedelta(seconds=1)}},
  224. )
  225. assert await service_db.redact_expired_addresses() == 1
  226. redacted_order = await app_modules.wbb.control_db.service_orders.find_one(
  227. {"order_id": order["order_id"]}
  228. )
  229. redacted_address = await app_modules.wbb.control_db.service_order_addresses.find_one(
  230. {"order_id": order["order_id"]}
  231. )
  232. assert redacted_order["address_redacted"] is True
  233. assert "distance_meters" not in redacted_order
  234. assert "encrypted_payload" not in redacted_address
  235. async def test_state_machine_cancellation_and_template_snapshot(app_modules):
  236. service_db = app_modules.load("wbb.utils.dbservice")
  237. profile = await _technician(app_modules, service_db)
  238. customer = await _accepted_customer(service_db)
  239. template = await service_db.create_package_template(
  240. {"template_name": "护理模板", "package": _package(name="模板初版")},
  241. actor_id="admin",
  242. )
  243. selected = await service_db.instantiate_package_template(template["template_id"])
  244. assert selected["source_template_version"] == 1
  245. await service_db.update_package_template(
  246. template["template_id"],
  247. {"template_name": "护理模板", "package": _package(name="模板新版")},
  248. actor_id="admin",
  249. )
  250. assert selected["name"] == "模板初版"
  251. order = await _order(service_db, customer, profile, service_mode="at_store")
  252. assert order["distance_meters"] > 0
  253. assert order["distance_band"] == "1 公里内"
  254. with pytest.raises(service_db.ServiceDataError, match="只有已确认"):
  255. await service_db.start_service_order(order["order_id"], profile["user_id"])
  256. canceled = await service_db.cancel_service_order(
  257. order["order_id"], customer.id, "顾客计划调整"
  258. )
  259. assert canceled["status"] == "canceled_customer"
  260. with pytest.raises(service_db.ServiceDataError, match="不能取消"):
  261. await service_db.cancel_service_order(
  262. order["order_id"], profile["user_id"], "重复取消"
  263. )
  264. pending_order = await _order(service_db, customer, profile)
  265. await service_db.submit_service_quote(
  266. pending_order["order_id"],
  267. profile["user_id"],
  268. {"base_amount": "100", "travel_fee": "0", "discount": "0"},
  269. )
  270. await service_db.confirm_service_quote(pending_order["order_id"], customer.id)
  271. await service_db.start_service_order(pending_order["order_id"], profile["user_id"])
  272. _, pending_token = await service_db.issue_review_qr(
  273. pending_order["order_id"], profile["user_id"]
  274. )
  275. canceled_pending = await service_db.cancel_service_order(
  276. pending_order["order_id"], profile["user_id"], "服务现场无法继续"
  277. )
  278. assert canceled_pending["status"] == "canceled_technician"
  279. with pytest.raises(service_db.ServiceDataError, match="无效"):
  280. await service_db.preview_review_qr(pending_token, customer.id)
  281. disputed_order = await _order(service_db, customer, profile)
  282. disputed = await service_db.dispute_service_order(
  283. disputed_order["order_id"], customer.id, "需要管理员协调"
  284. )
  285. assert disputed["status"] == "disputed"
  286. resumed = await service_db.resolve_service_dispute(
  287. disputed_order["order_id"],
  288. actor_id="admin",
  289. action="resume",
  290. reason="双方已达成一致",
  291. )
  292. assert resumed["status"] == "requested"
  293. async def test_order_requires_encryption_before_persisting(app_modules):
  294. service_db = app_modules.load("wbb.utils.dbservice")
  295. profile = await _technician(app_modules, service_db)
  296. customer = await _accepted_customer(service_db)
  297. original_key = app_modules.wbb.SERVICE_ADDRESS_ENCRYPTION_KEY
  298. app_modules.wbb.SERVICE_ADDRESS_ENCRYPTION_KEY = ""
  299. try:
  300. with pytest.raises(service_db.ServiceDataError, match="加密密钥"):
  301. await _order(service_db, customer, profile)
  302. finally:
  303. app_modules.wbb.SERVICE_ADDRESS_ENCRYPTION_KEY = original_key
  304. assert (
  305. await app_modules.wbb.control_db.service_orders.count_documents(
  306. {"customer_id": customer.id}
  307. )
  308. == 0
  309. )
  310. async def test_expired_review_qr_reopens_order_for_reissue(app_modules):
  311. service_db = app_modules.load("wbb.utils.dbservice")
  312. profile = await _technician(app_modules, service_db)
  313. customer = await _accepted_customer(service_db)
  314. order = await _order(service_db, customer, profile)
  315. await service_db.submit_service_quote(
  316. order["order_id"],
  317. profile["user_id"],
  318. {"base_amount": "100", "travel_fee": "0", "discount": "0"},
  319. )
  320. await service_db.confirm_service_quote(order["order_id"], customer.id)
  321. await service_db.start_service_order(order["order_id"], profile["user_id"])
  322. first_qr, first_token = await service_db.issue_review_qr(
  323. order["order_id"], profile["user_id"]
  324. )
  325. await app_modules.wbb.control_db.teacher_review_qr_records.update_one(
  326. {"qr_id": first_qr["qr_id"]},
  327. {"$set": {"expires_at": service_db.utc_now() - timedelta(seconds=1)}},
  328. )
  329. with pytest.raises(service_db.ServiceDataError, match="已过期"):
  330. await service_db.preview_review_qr(first_token, customer.id)
  331. reopened = await app_modules.wbb.control_db.service_orders.find_one(
  332. {"order_id": order["order_id"]}
  333. )
  334. assert reopened["status"] == "in_progress"
  335. assert "current_review_qr_id" not in reopened
  336. replacement, replacement_token = await service_db.issue_review_qr(
  337. order["order_id"], profile["user_id"]
  338. )
  339. assert replacement["qr_id"] != first_qr["qr_id"]
  340. assert replacement_token != first_token
  341. async def test_reviews_same_weight_anonymous_and_bayesian_leaderboard(app_modules):
  342. service_db = app_modules.load("wbb.utils.dbservice")
  343. profile = await _technician(app_modules, service_db)
  344. customer = await _accepted_customer(service_db)
  345. package_id = profile["service_profile"]["packages"][0]["package_id"]
  346. answers = {"service_effect": 5, "professionalism": 4, "communication": 5, "comment": "服务很专业"}
  347. template = await service_db.get_active_review_template()
  348. draft = await service_db.begin_review_draft(
  349. customer_id=customer.id,
  350. technician_id=profile["user_id"],
  351. source="student_initiated",
  352. package_id=package_id,
  353. template=template,
  354. )
  355. await service_db.save_review_draft_progress(
  356. customer.id,
  357. answers={"service_effect": 5},
  358. rating_index=1,
  359. text_index=0,
  360. )
  361. resumed_draft = await service_db.begin_review_draft(
  362. customer_id=customer.id,
  363. technician_id=profile["user_id"],
  364. source="student_initiated",
  365. package_id=package_id,
  366. template=template,
  367. )
  368. assert resumed_draft["draft_id"] == draft["draft_id"]
  369. assert resumed_draft["answers"] == {"service_effect": 5}
  370. await service_db.delete_review_draft(customer.id)
  371. review_ids = []
  372. for _ in range(3):
  373. review = await service_db.submit_review(
  374. customer=customer,
  375. technician_id=profile["user_id"],
  376. source="student_initiated",
  377. answers=answers,
  378. anonymous=True,
  379. package_id=package_id,
  380. )
  381. review_ids.append(review["review_id"])
  382. assert "qr_id" not in review
  383. assert len(set(review_ids)) == 3
  384. for review_id in review_ids:
  385. await service_db.moderate_review(
  386. review_id, "approve", actor_id="admin"
  387. )
  388. public, total = await service_db.list_public_reviews(profile["user_id"])
  389. assert total == 3
  390. assert all(item["customer_name"] == "匿名顾客" for item in public)
  391. assert all(item["source_label"] == "用户主动评价" for item in public)
  392. summary = await service_db.get_technician_review_summary(profile["user_id"])
  393. assert summary["review_count"] == 3
  394. pending, _ = await service_db.list_reviews(
  395. status="approved", source="student_initiated"
  396. )
  397. assert all(item["similar_content_count"] == 3 for item in pending)
  398. leaderboard, total = await service_db.list_leaderboard(category="护理")
  399. assert total == 1
  400. assert leaderboard[0]["review_count"] == 3
  401. assert leaderboard[0]["eligible"] is True
  402. assert leaderboard[0]["rank_score"] == pytest.approx(
  403. leaderboard[0]["average_score"]
  404. )
  405. async def test_review_moderation_only_allows_defined_state_transitions(app_modules):
  406. service_db = app_modules.load("wbb.utils.dbservice")
  407. profile = await _technician(app_modules, service_db)
  408. customer = await _accepted_customer(service_db)
  409. package_id = profile["service_profile"]["packages"][0]["package_id"]
  410. review = await service_db.submit_review(
  411. customer=customer,
  412. technician_id=profile["user_id"],
  413. source="student_initiated",
  414. answers={
  415. "service_effect": 5,
  416. "professionalism": 5,
  417. "communication": 5,
  418. },
  419. anonymous=True,
  420. package_id=package_id,
  421. )
  422. approved = await service_db.moderate_review(
  423. review["review_id"], "approve", actor_id="admin"
  424. )
  425. assert approved["status"] == "approved"
  426. with pytest.raises(service_db.ServiceDataError, match="不能执行"):
  427. await service_db.moderate_review(
  428. review["review_id"], "reject", actor_id="admin", reason="重复处理"
  429. )
  430. voided = await service_db.moderate_review(
  431. review["review_id"], "void", actor_id="admin", reason="争议作废"
  432. )
  433. assert voided["status"] == "voided"
  434. with pytest.raises(service_db.ServiceDataError, match="不能执行"):
  435. await service_db.moderate_review(
  436. review["review_id"], "approve", actor_id="admin"
  437. )
  438. async def test_technician_requires_active_managed_group_membership(app_modules):
  439. service_db = app_modules.load("wbb.utils.dbservice")
  440. profile = await _technician(app_modules, service_db)
  441. await app_modules.wbb.control_db.directory_memberships.update_many(
  442. {"user_id": profile["user_id"]}, {"$set": {"active": False}}
  443. )
  444. customer = await _accepted_customer(service_db)
  445. with pytest.raises(service_db.ServiceDataError, match="当前不满足接单资格"):
  446. await _order(service_db, customer, profile)
  447. async def test_service_admin_api_settings_templates_and_permissions(app_modules):
  448. admin_api = app_modules.load("wbb.admin.api")
  449. app_modules.wbb.BOT_PERMISSIONS = {"teacher_directory.manage"}
  450. application = admin_api.build_admin_application()
  451. await application["admin_api"].initialize()
  452. client = TestClient(TestServer(application), cookie_jar=CookieJar(unsafe=True))
  453. await client.start_server()
  454. try:
  455. login = await client.post(
  456. "/api/admin/v1/auth/login",
  457. json={"username": "admin", "password": "qwe0.123456"},
  458. )
  459. login_data = (await login.json())["data"]
  460. changed = await client.put(
  461. "/api/admin/v1/auth/password",
  462. headers={"X-CSRF-Token": login_data["csrf_token"]},
  463. json={
  464. "current_password": "qwe0.123456",
  465. "new_password": "changed-pass-123",
  466. },
  467. )
  468. headers = {"X-CSRF-Token": (await changed.json())["data"]["csrf_token"]}
  469. unconfirmed = await client.put(
  470. "/api/admin/v1/directory/service-settings",
  471. headers=headers,
  472. json={"customer_max_open_orders": 4},
  473. )
  474. assert unconfirmed.status == 409
  475. saved = await client.put(
  476. "/api/admin/v1/directory/service-settings",
  477. headers=headers,
  478. json={
  479. "customer_max_open_orders": 4,
  480. "customer_daily_request_limit": 12,
  481. "review_qr_expiry_hours": 48,
  482. "confirm": True,
  483. },
  484. )
  485. assert saved.status == 200
  486. assert (await saved.json())["data"]["review_qr_expiry_hours"] == 48
  487. created = await client.post(
  488. "/api/admin/v1/directory/package-templates",
  489. headers=headers,
  490. json={
  491. "template_name": "后台护理模板",
  492. "package": _package(),
  493. "confirm": True,
  494. },
  495. )
  496. assert created.status == 201
  497. created_data = (await created.json())["data"]
  498. assert created_data["version"] == 1
  499. duplicated = await client.post(
  500. f"/api/admin/v1/directory/package-templates/{created_data['template_id']}/actions",
  501. headers=headers,
  502. json={"action": "duplicate", "confirm": True},
  503. )
  504. assert duplicated.status == 200
  505. assert (await duplicated.json())["data"]["status"] == "disabled"
  506. review_template = await client.get(
  507. "/api/admin/v1/directory/review-template"
  508. )
  509. assert review_template.status == 200
  510. assert sum(
  511. item["weight"]
  512. for item in (await review_template.json())["data"]["rating_questions"]
  513. ) == 100
  514. metrics = await client.get(
  515. "/api/admin/v1/directory/fulfillment-metrics"
  516. )
  517. assert metrics.status == 200
  518. assert (await metrics.json())["data"]["scope_note"].startswith(
  519. "仅统计平台服务单"
  520. )
  521. app_modules.wbb.BOT_PERMISSIONS = set()
  522. denied = await client.get(
  523. "/api/admin/v1/directory/fulfillment-metrics"
  524. )
  525. assert denied.status == 403
  526. finally:
  527. await client.close()