service.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502
  1. from __future__ import annotations
  2. from datetime import date, datetime
  3. import json
  4. from pathlib import Path
  5. from threading import RLock
  6. from uuid import uuid4
  7. from zoneinfo import ZoneInfo
  8. from rdflib import Graph, Literal, Namespace, RDF, URIRef, XSD
  9. from app.market.filters import rejection_reasons
  10. from app.market.models import CandidateRecord, InquiryRequest
  11. from app.market.pricing import build_prices
  12. from app.market.scoring import CustomerSignals, score_candidates
  13. from app.repository import RDFRepository
  14. GESLI = Namespace("https://gesli.example/ontology#")
  15. RUN = Namespace("https://gesli.example/recommendation/")
  16. class MarketService:
  17. def __init__(self, root: Path, repository: RDFRepository):
  18. self.root = root
  19. self.repository = repository
  20. self.config = json.loads(
  21. (root / "config" / "market-strategies.json").read_text(encoding="utf-8")
  22. )
  23. self.runs: dict[str, dict] = {}
  24. self.audit_graph = Graph()
  25. self._lock = RLock()
  26. def customers(self) -> list[dict]:
  27. rows = self.repository.query_file(
  28. self.root / "queries" / "market-customers.rq"
  29. )["rows"]
  30. return [
  31. {
  32. "id": row["customer"],
  33. "name": row["customerName"],
  34. "industry": row.get("industry", ""),
  35. "preferred_route": row.get("routeName", ""),
  36. "repeat_purchase_count": _as_int(row.get("repeatPurchaseCount")),
  37. "price_sensitivity": _as_float(row.get("customerPriceSensitivity"), 0.5),
  38. "time_sensitivity": _as_float(row.get("customerTimeSensitivity"), 0.5),
  39. "reliability_sensitivity": _as_float(
  40. row.get("customerReliabilitySensitivity"), 0.5
  41. ),
  42. }
  43. for row in rows
  44. ]
  45. def form_options(self) -> dict:
  46. candidates = self._candidate_records()
  47. context_rows = self.repository.query_file(
  48. self.root / "queries" / "market-context.rq"
  49. )["rows"]
  50. configured_routes = {
  51. (row.get("origin", ""), row.get("destination", ""))
  52. for row in context_rows
  53. if row.get("kind") == "benchmark" and row.get("origin") and row.get("destination")
  54. }
  55. origins = sorted({origin for origin, _ in configured_routes})
  56. destinations = sorted({destination for _, destination in configured_routes})
  57. route_types = sorted({
  58. item.route_type
  59. for item in candidates
  60. if item.route_type and (item.origin, item.destination) in configured_routes
  61. })
  62. benchmark_by_route = {
  63. (row.get("origin", ""), row.get("destination", "")): _as_float(
  64. row.get("amount")
  65. )
  66. for row in context_rows
  67. if row.get("kind") == "benchmark"
  68. }
  69. routes = []
  70. for origin, destination in sorted(configured_routes):
  71. route_candidates = [
  72. item
  73. for item in candidates
  74. if item.origin == origin
  75. and item.destination == destination
  76. and item.departure_date
  77. and item.valid_to
  78. and item.valid_to >= date(2026, 7, 20)
  79. ]
  80. departures = [item.departure_date for item in route_candidates]
  81. transit_days = [item.transit_days for item in route_candidates]
  82. routes.append({
  83. "origin": origin,
  84. "destination": destination,
  85. "route_types": sorted({
  86. item.route_type for item in route_candidates if item.route_type
  87. }),
  88. "target_amount": benchmark_by_route.get((origin, destination), 0),
  89. "requested_departure_date": (
  90. min(departures).isoformat() if departures else "2026-07-27"
  91. ),
  92. "max_transit_days": max(transit_days) if transit_days else 30,
  93. })
  94. return {
  95. "customers": self.customers(),
  96. "origins": origins,
  97. "destinations": destinations,
  98. "route_types": route_types,
  99. "routes": routes,
  100. "defaults": InquiryRequest(customer="https://gesli.example/sample#Haier").model_dump(
  101. mode="json"
  102. ),
  103. }
  104. def recommend(self, inquiry: InquiryRequest) -> dict:
  105. customer_row = self._find_customer(inquiry.customer)
  106. customer = self._customer_signals(customer_row)
  107. component_cost, component_names = self._component_costs(
  108. inquiry.evaluation_date, inquiry.origin
  109. )
  110. component_product_codes = {
  111. code
  112. for codes in self.config["component_product_codes_by_origin"].values()
  113. for code in codes
  114. }
  115. route_candidates = [
  116. candidate
  117. for candidate in self._candidate_records()
  118. if candidate.origin == inquiry.origin
  119. and candidate.destination == inquiry.destination
  120. and candidate.currency == "CNY"
  121. and candidate.product_code not in component_product_codes
  122. ]
  123. feasible: list[CandidateRecord] = []
  124. rejected: list[dict] = []
  125. for candidate in route_candidates:
  126. reasons = rejection_reasons(candidate, inquiry)
  127. if reasons:
  128. rejected.append(self._rejected_payload(candidate, reasons))
  129. else:
  130. feasible.append(candidate)
  131. if not feasible:
  132. raise ValueError("没有满足当前硬条件的供应商产品和班期")
  133. benchmark, strategy = self._market_context(inquiry)
  134. scored, weights = score_candidates(feasible, inquiry, customer)
  135. candidates: list[dict] = []
  136. for item in scored:
  137. candidate: CandidateRecord = item.pop("candidate")
  138. total_supplier_cost = candidate.supplier_cost + component_cost
  139. prices = build_prices(
  140. benchmark_amount=benchmark["amount"],
  141. strategy_rate=strategy["adjustment_rate"],
  142. solution_type=item["solution_type"],
  143. customer=customer,
  144. salesperson_adjustment=inquiry.salesperson_adjustment,
  145. supplier_cost=total_supplier_cost,
  146. config=self.config,
  147. )
  148. payload = self._candidate_payload(candidate, item, prices, component_names)
  149. candidates.append(payload)
  150. top = candidates[0]
  151. score_gap = top["total_score"] - candidates[1]["total_score"] if len(candidates) > 1 else 10
  152. confidence = round(min(0.98, 0.78 + max(0, score_gap) / 100), 2)
  153. top["confidence_score"] = confidence
  154. run_id = f"rec-{uuid4().hex[:12]}"
  155. generated_at = datetime.now(ZoneInfo("Asia/Shanghai")).isoformat(timespec="seconds")
  156. result = {
  157. "run_id": run_id,
  158. "generated_at": generated_at,
  159. "normalized_inquiry": {
  160. **inquiry.model_dump(mode="json"),
  161. "customer_name": customer_row["customerName"],
  162. "route": f"{inquiry.origin} → {inquiry.destination}",
  163. "dangerous_goods_label": "危险品" if inquiry.dangerous_goods else "非危险品",
  164. },
  165. "pipeline": {
  166. "discovered": len(route_candidates),
  167. "rejected": len(rejected),
  168. "feasible": len(feasible),
  169. "generated_solutions": len(candidates),
  170. },
  171. "recommendation": {
  172. "candidate_id": top["id"],
  173. "solution_type": top["solution_type"],
  174. "product_name": top["product_name"],
  175. "supplier_name": top["supplier_name"],
  176. "final_quote_amount": top["pricing"]["final_quote_amount"],
  177. "currency": top["pricing"]["currency"],
  178. "confidence_score": confidence,
  179. "summary": self._recommendation_summary(top, strategy, customer_row),
  180. },
  181. "market_context": {
  182. "benchmark_amount": benchmark["amount"],
  183. "currency": benchmark["currency"],
  184. "strategy_name": strategy["name"],
  185. "strategy_objective": strategy["objective"],
  186. "strategy_adjustment_rate": strategy["adjustment_rate"],
  187. },
  188. "weights": weights,
  189. "candidates": candidates,
  190. "rejected_candidates": rejected,
  191. }
  192. self._store_run(run_id, result, inquiry, strategy)
  193. return result
  194. def get_run(self, run_id: str) -> dict | None:
  195. with self._lock:
  196. return self.runs.get(run_id)
  197. def reset_runtime(self) -> None:
  198. with self._lock:
  199. self.runs.clear()
  200. self.audit_graph = Graph()
  201. def get_audit(self, run_id: str) -> dict | None:
  202. run = self.get_run(run_id)
  203. if not run:
  204. return None
  205. run_uri = RUN[run_id]
  206. with self._lock:
  207. triples = list(self.audit_graph.triples((run_uri, None, None)))
  208. return {
  209. "run_id": run_id,
  210. "generated_at": run["generated_at"],
  211. "input": run["normalized_inquiry"],
  212. "weights": run["weights"],
  213. "market_context": run["market_context"],
  214. "candidate_count": len(run["candidates"]),
  215. "rejected_count": len(run["rejected_candidates"]),
  216. "rdf_assertions": len(triples),
  217. "sources": self.repository.loaded_file_names,
  218. }
  219. def _candidate_records(self) -> list[CandidateRecord]:
  220. rows = self.repository.query_file(
  221. self.root / "queries" / "market-candidates.rq"
  222. )["rows"]
  223. return [
  224. CandidateRecord(
  225. quote_uri=row["quote"],
  226. quote_no=row["quoteNo"],
  227. product_uri=row["product"],
  228. product_code=row.get("productCode", ""),
  229. product_name=row.get("productName", ""),
  230. brand_name=row.get("brandName", ""),
  231. transport_mode=row.get("transportMode", ""),
  232. offering_uri=row["offering"],
  233. offering_code=row.get("offeringCode", ""),
  234. supplier_uri=row["supplier"],
  235. supplier_name=row["supplierName"],
  236. schedule_uri=row.get("schedule", ""),
  237. schedule_code=row.get("scheduleCode", ""),
  238. route_type=row.get("routeType", ""),
  239. service_form=row.get("serviceForm", ""),
  240. origin=row.get("originLocation", ""),
  241. destination=row.get("destinationLocation", ""),
  242. currency=row["currency"],
  243. supplier_cost=_as_float(row.get("supplierCostAmount")),
  244. valid_from=_as_date(row.get("validFrom")),
  245. valid_to=_as_date(row.get("validTo")),
  246. departure_date=_as_date(row.get("estimatedDepartureTime")),
  247. arrival_date=_as_date(row.get("estimatedArrivalTime")),
  248. transit_days=_as_float(row.get("estimatedTransitDays")),
  249. booking_success_rate=_as_float(row.get("bookingSuccessRate"), 0.8),
  250. space_release_success_rate=_as_float(
  251. row.get("spaceReleaseSuccessRate"), 0.8
  252. ),
  253. etd_on_time_rate=_as_float(row.get("etdAtdOnTimeRate"), 0.85),
  254. eta_on_time_rate=_as_float(row.get("etaAtaOnTimeRate"), 0.85),
  255. supplier_active=_as_bool(row.get("supplierActive"), True),
  256. capacity_available=_as_bool(row.get("capacityAvailable"), True),
  257. supports_dangerous_goods=_as_bool(
  258. row.get("supportsDangerousGoods"), False
  259. ),
  260. dangerous_goods_only=_as_bool(row.get("dangerousGoodsOnly"), False),
  261. agency_level=row.get("agencyLevel", ""),
  262. offering_channel=row.get("offeringChannel", ""),
  263. capacity_commitment=row.get("capacityCommitment", ""),
  264. )
  265. for row in rows
  266. ]
  267. def _find_customer(self, customer_id: str) -> dict:
  268. rows = self.repository.query_file(
  269. self.root / "queries" / "market-customers.rq"
  270. )["rows"]
  271. for row in rows:
  272. if row["customer"] == customer_id or row["customerName"] == customer_id:
  273. return row
  274. raise ValueError("客户不存在或没有可用画像")
  275. @staticmethod
  276. def _customer_signals(row: dict) -> CustomerSignals:
  277. service_requirement = ";".join(
  278. value
  279. for value in (
  280. row.get("notificationRequirement", ""),
  281. row.get("documentRequirement", ""),
  282. row.get("exceptionResponseRequirement", ""),
  283. )
  284. if value
  285. )
  286. return CustomerSignals(
  287. price_sensitivity=_as_float(row.get("customerPriceSensitivity"), 0.5),
  288. time_sensitivity=_as_float(row.get("customerTimeSensitivity"), 0.5),
  289. reliability_sensitivity=_as_float(
  290. row.get("customerReliabilitySensitivity"), 0.5
  291. ),
  292. repeat_purchase_count=_as_int(row.get("repeatPurchaseCount")),
  293. overdue_days=_as_int(row.get("overdueDays")),
  294. service_requirement=service_requirement,
  295. )
  296. def _component_costs(
  297. self, evaluation_date: date, origin: str
  298. ) -> tuple[float, list[str]]:
  299. candidates = self._candidate_records()
  300. total = 0.0
  301. names: list[str] = []
  302. product_codes = self.config["component_product_codes_by_origin"].get(
  303. origin,
  304. self.config["component_product_codes_by_origin"].get("__default__", []),
  305. )
  306. for product_code in product_codes:
  307. valid = [
  308. item
  309. for item in candidates
  310. if item.product_code == product_code
  311. and (not item.valid_from or item.valid_from <= evaluation_date)
  312. and (not item.valid_to or evaluation_date <= item.valid_to)
  313. ]
  314. if valid:
  315. selected = min(valid, key=lambda item: item.supplier_cost)
  316. total += selected.supplier_cost
  317. names.append(selected.product_name)
  318. return total, names
  319. def _market_context(self, inquiry: InquiryRequest) -> tuple[dict, dict]:
  320. rows = self.repository.query_file(
  321. self.root / "queries" / "market-context.rq"
  322. )["rows"]
  323. applicable = [
  324. row
  325. for row in rows
  326. if row.get("origin") == inquiry.origin
  327. and row.get("destination") == inquiry.destination
  328. and _within(inquiry.evaluation_date, row.get("validFrom"), row.get("validTo"))
  329. ]
  330. benchmark_row = next((row for row in applicable if row["kind"] == "benchmark"), None)
  331. strategy_row = next((row for row in applicable if row["kind"] == "strategy"), None)
  332. if not benchmark_row or not strategy_row:
  333. raise ValueError("当前路线缺少有效的市场基准价格或启用策略")
  334. return (
  335. {
  336. "uri": benchmark_row["resource"],
  337. "amount": _as_float(benchmark_row["amount"]),
  338. "currency": benchmark_row["currency"],
  339. },
  340. {
  341. "uri": strategy_row["resource"],
  342. "name": strategy_row["strategyName"],
  343. "objective": strategy_row["strategyObjective"],
  344. "adjustment_rate": _as_float(strategy_row["strategyAdjustmentRate"]),
  345. },
  346. )
  347. @staticmethod
  348. def _candidate_payload(
  349. candidate: CandidateRecord,
  350. scores: dict,
  351. prices: dict,
  352. component_names: list[str],
  353. ) -> dict:
  354. reliability = round(
  355. (candidate.booking_success_rate + candidate.space_release_success_rate) / 2,
  356. 2,
  357. )
  358. reasons = [
  359. f"供应商订舱与放舱综合成功率{reliability:.0%}",
  360. f"预计运输时间{candidate.transit_days:g}天",
  361. f"{candidate.route_type or '当前'}路由,{candidate.capacity_commitment or '运力已确认'}",
  362. ]
  363. if scores["solution_type"] == "经济方案":
  364. reasons.insert(0, "当前可执行候选中采购价格最有竞争力")
  365. elif scores["solution_type"] == "时效方案":
  366. reasons.insert(0, "当前可执行候选中预计运输时间最短")
  367. else:
  368. reasons.insert(0, "客户偏好、供应商可靠性与价格综合表现最均衡")
  369. return {
  370. "id": candidate.quote_uri,
  371. "quote_no": candidate.quote_no,
  372. "solution_type": scores["solution_type"],
  373. "rank": scores["rank"],
  374. "brand_name": candidate.brand_name,
  375. "product_name": candidate.product_name,
  376. "supplier_name": candidate.supplier_name,
  377. "offering_channel": candidate.offering_channel,
  378. "schedule_code": candidate.schedule_code,
  379. "departure_date": candidate.departure_date.isoformat() if candidate.departure_date else "",
  380. "arrival_date": candidate.arrival_date.isoformat() if candidate.arrival_date else "",
  381. "transit_days": candidate.transit_days,
  382. "route_type": candidate.route_type,
  383. "capacity_commitment": candidate.capacity_commitment,
  384. "components": [*component_names, candidate.product_name],
  385. "reliability_rate": reliability,
  386. "price_score": scores["price_score"],
  387. "transit_score": scores["transit_score"],
  388. "supplier_execution_score": scores["supplier_execution_score"],
  389. "customer_fit_score": scores["customer_fit_score"],
  390. "product_strategy_score": scores["product_strategy_score"],
  391. "total_score": scores["total_score"],
  392. "confidence_score": None,
  393. "reasons": reasons,
  394. "pricing": prices,
  395. }
  396. @staticmethod
  397. def _rejected_payload(candidate: CandidateRecord, reasons: list[str]) -> dict:
  398. return {
  399. "id": candidate.quote_uri,
  400. "quote_no": candidate.quote_no,
  401. "product_name": candidate.product_name,
  402. "supplier_name": candidate.supplier_name,
  403. "schedule_code": candidate.schedule_code,
  404. "reasons": reasons,
  405. }
  406. @staticmethod
  407. def _recommendation_summary(
  408. candidate: dict, strategy: dict, customer_row: dict
  409. ) -> str:
  410. return (
  411. f"为{customer_row['customerName']}推荐{candidate['solution_type']}:"
  412. f"{candidate['supplier_name']}的{candidate['product_name']}。"
  413. f"综合得分{candidate['total_score']:.1f},采用“{strategy['name']}”,"
  414. f"在时效、履约可靠性和客户价格接受度之间取得当前最优平衡。"
  415. )
  416. def _store_run(
  417. self, run_id: str, result: dict, inquiry: InquiryRequest, strategy: dict
  418. ) -> None:
  419. run_uri = RUN[run_id]
  420. generated_at = Literal(result["generated_at"], datatype=XSD.dateTime)
  421. with self._lock:
  422. self.runs[run_id] = result
  423. self.audit_graph.add((run_uri, RDF.type, GESLI.RecommendationRun))
  424. self.audit_graph.add((run_uri, GESLI.generatedAt, generated_at))
  425. self.audit_graph.add((run_uri, GESLI.usesMarketStrategy, URIRef(strategy["uri"])))
  426. self.audit_graph.add((run_uri, GESLI.recommendationReason, Literal(result["recommendation"]["summary"])))
  427. customer_uri = URIRef(inquiry.customer)
  428. self.audit_graph.add((run_uri, GESLI.quoteForCustomer, customer_uri))
  429. for candidate in result["candidates"]:
  430. evaluation_uri = RUN[f"{run_id}/candidate/{candidate['rank']}"]
  431. self.audit_graph.add((evaluation_uri, RDF.type, GESLI.CandidateEvaluation))
  432. self.audit_graph.add((run_uri, GESLI.hasCandidateEvaluation, evaluation_uri))
  433. self.audit_graph.add((evaluation_uri, GESLI.evaluatedQuote, URIRef(candidate["id"])))
  434. self.audit_graph.add((evaluation_uri, GESLI.recommendationRank, Literal(candidate["rank"])))
  435. self.audit_graph.add((evaluation_uri, GESLI.totalScore, Literal(candidate["total_score"])))
  436. top_evaluation = RUN[f"{run_id}/candidate/1"]
  437. self.audit_graph.add((run_uri, GESLI.recommendedEvaluation, top_evaluation))
  438. def _as_float(value: str | None, default: float = 0.0) -> float:
  439. try:
  440. return float(value) if value not in (None, "") else default
  441. except (TypeError, ValueError):
  442. return default
  443. def _as_int(value: str | None, default: int = 0) -> int:
  444. try:
  445. return int(value) if value not in (None, "") else default
  446. except (TypeError, ValueError):
  447. return default
  448. def _as_bool(value: str | None, default: bool = False) -> bool:
  449. if value in (None, ""):
  450. return default
  451. return str(value).lower() in {"true", "1", "yes"}
  452. def _as_date(value: str | None) -> date | None:
  453. if not value:
  454. return None
  455. try:
  456. return date.fromisoformat(value[:10])
  457. except ValueError:
  458. return None
  459. def _within(current: date, start: str | None, end: str | None) -> bool:
  460. start_date = _as_date(start)
  461. end_date = _as_date(end)
  462. return (not start_date or start_date <= current) and (not end_date or current <= end_date)