mock_data.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477
  1. from __future__ import annotations
  2. from datetime import date, datetime, time, timedelta, timezone
  3. from pathlib import Path
  4. from uuid import uuid4
  5. from pydantic import BaseModel, Field
  6. from rdflib import Literal, Namespace, RDF, RDFS, XSD
  7. from app.market import MarketService
  8. from app.repository import RDFRepository
  9. GESLI = Namespace("https://gesli.example/ontology#")
  10. MOCK = Namespace("https://gesli.example/runtime-mock/")
  11. class MockBatchRequest(BaseModel):
  12. origin: str
  13. destination: str
  14. supplier_count: int = Field(default=4, ge=1, le=8)
  15. customer_count: int = Field(default=2, ge=0, le=5)
  16. evaluation_date: date = date(2026, 7, 20)
  17. class LocalMockDataService:
  18. def __init__(
  19. self,
  20. root: Path,
  21. repository: RDFRepository,
  22. market_service: MarketService,
  23. ):
  24. self.root = root
  25. self.repository = repository
  26. self.market_service = market_service
  27. def catalog(self) -> dict:
  28. candidate_rows = self.repository.query_file(
  29. self.root / "queries" / "market-candidates.rq"
  30. )["rows"]
  31. context_rows = self.repository.query_file(
  32. self.root / "queries" / "market-context.rq"
  33. )["rows"]
  34. customers = self.market_service.customers()
  35. evaluation_date = date(2026, 7, 20)
  36. routes: dict[tuple[str, str], dict] = {}
  37. for row in candidate_rows:
  38. origin = row.get("originLocation", "")
  39. destination = row.get("destinationLocation", "")
  40. if not origin or not destination:
  41. continue
  42. key = (origin, destination)
  43. route = routes.setdefault(key, {
  44. "origin": origin,
  45. "destination": destination,
  46. "products": set(),
  47. "suppliers": set(),
  48. "quotes": set(),
  49. "current_quotes": 0,
  50. "transport_modes": set(),
  51. })
  52. route["products"].add(row.get("product", ""))
  53. route["suppliers"].add(row.get("supplier", ""))
  54. route["quotes"].add(row.get("quote", ""))
  55. if row.get("transportMode"):
  56. route["transport_modes"].add(row["transportMode"])
  57. if _within(evaluation_date, row.get("validFrom"), row.get("validTo")):
  58. route["current_quotes"] += 1
  59. route_rows = [
  60. {
  61. "origin": route["origin"],
  62. "destination": route["destination"],
  63. "transport_modes": " / ".join(sorted(route["transport_modes"])),
  64. "product_count": len(route["products"]),
  65. "supplier_count": len(route["suppliers"]),
  66. "quote_count": len(route["quotes"]),
  67. "current_quote_count": route["current_quotes"],
  68. }
  69. for route in routes.values()
  70. ]
  71. route_rows.sort(key=lambda item: (item["origin"], item["destination"]))
  72. quote_rows = []
  73. for row in candidate_rows:
  74. valid_from = row.get("validFrom", "")
  75. valid_to = row.get("validTo", "")
  76. if _within(evaluation_date, valid_from, valid_to):
  77. status = "当前有效"
  78. elif valid_to and valid_to < evaluation_date.isoformat():
  79. status = "已过期"
  80. else:
  81. status = "尚未生效"
  82. quote_rows.append({
  83. "quote_no": row.get("quoteNo", ""),
  84. "route": f"{row.get('originLocation', '')} → {row.get('destinationLocation', '')}",
  85. "transport_mode": row.get("transportMode", ""),
  86. "product_name": row.get("productName", ""),
  87. "supplier_name": row.get("supplierName", ""),
  88. "supplier_cost_amount": _as_float(row.get("supplierCostAmount")),
  89. "currency": row.get("currency", ""),
  90. "valid_to": valid_to,
  91. "status": status,
  92. "dangerous_goods": row.get("supportsDangerousGoods", "false") == "true",
  93. "relationship_tier": row.get("relationshipTier") or "基础合作",
  94. "relationship_strength": _as_float(row.get("relationshipStrength"), 0.60),
  95. "low_price_access_probability": _as_float(
  96. row.get("lowPriceAccessProbability"), 0.50
  97. ),
  98. "priority_space_probability": _as_float(
  99. row.get("prioritySpaceProbability"), 0.60
  100. ),
  101. "relationship_evidence": (
  102. row.get("relationshipEvidence") or "按标准合作机制执行"
  103. ),
  104. "spot_space_status": row.get("spotSpaceStatus") or "需确认",
  105. "spot_space_teu": _as_float(row.get("spotSpaceTeu")),
  106. "runtime_mock": row.get("quote", "").startswith(str(MOCK)),
  107. })
  108. quote_rows.sort(
  109. key=lambda item: (
  110. item["route"], item["status"], item["supplier_cost_amount"]
  111. )
  112. )
  113. strategies = [
  114. {
  115. "name": row.get("strategyName", ""),
  116. "objective": row.get("strategyObjective", ""),
  117. "route": f"{row.get('origin', '')} → {row.get('destination', '')}",
  118. "adjustment_rate": _as_float(row.get("strategyAdjustmentRate")),
  119. "valid_to": row.get("validTo", ""),
  120. }
  121. for row in context_rows
  122. if row.get("kind") == "strategy"
  123. ]
  124. return {
  125. "summary": {
  126. "triple_count": self.repository.triple_count,
  127. "in_memory_triple_count": self.repository.in_memory_triple_count,
  128. "customer_count": len(customers),
  129. "supplier_count": len({row.get("supplier") for row in candidate_rows}),
  130. "product_count": len({row.get("product") for row in candidate_rows}),
  131. "quote_count": len({row.get("quote") for row in candidate_rows}),
  132. "route_count": len(route_rows),
  133. "strategy_count": len(strategies),
  134. },
  135. "routes": route_rows,
  136. "customers": customers,
  137. "quotes": quote_rows,
  138. "strategies": strategies,
  139. "loaded_files": self.repository.loaded_file_names,
  140. }
  141. def generate(self, request: MockBatchRequest) -> dict:
  142. route_context = next(
  143. (
  144. route
  145. for route in self.market_service.form_options()["routes"]
  146. if route["origin"] == request.origin
  147. and route["destination"] == request.destination
  148. ),
  149. None,
  150. )
  151. if not route_context:
  152. raise ValueError("只能为已经配置市场基准和策略的路线生成Mock数据")
  153. token = uuid4().hex[:8]
  154. benchmark = float(route_context["target_amount"])
  155. transport_mode = self._route_transport_mode(
  156. request.origin, request.destination
  157. )
  158. triples = []
  159. generated_suppliers = []
  160. generated_customers = []
  161. for index in range(request.supplier_count):
  162. suffix = f"{token}-{index + 1}"
  163. brand = MOCK[f"brand-{suffix}"]
  164. supplier = MOCK[f"supplier-{suffix}"]
  165. product = MOCK[f"product-{suffix}"]
  166. offering = MOCK[f"offering-{suffix}"]
  167. schedule = MOCK[f"schedule-{suffix}"]
  168. quote = MOCK[f"quote-{suffix}"]
  169. performance = MOCK[f"performance-{suffix}"]
  170. profile = self._supplier_profile(index)
  171. supplier_name = (
  172. f"印度航线Mock{profile['supplier_label']} {token[-3:].upper()}{index + 1}"
  173. )
  174. product_name = (
  175. f"{request.origin}-{request.destination}{profile['product_label']}方案{index + 1}"
  176. )
  177. departure = request.evaluation_date + timedelta(days=8 + index)
  178. transit_days = self._transit_days(transport_mode, index)
  179. arrival = departure + timedelta(days=transit_days)
  180. cost = round(benchmark * profile["cost_ratio"], -2)
  181. success_rate = profile["booking_success_rate"]
  182. product_class = (
  183. GESLI.AirFreightProduct
  184. if transport_mode == "空运"
  185. else GESLI.ContainerOceanProduct
  186. )
  187. route_type = "直达" if transport_mode == "空运" and index < 2 else profile["route_type"]
  188. triples.extend([
  189. (brand, RDF.type, GESLI.ServiceBrand),
  190. (brand, GESLI.brandCode, Literal(f"MOCK-{token}-{index + 1}")),
  191. (brand, GESLI.brandName, Literal(f"Mock品牌 {index + 1}")),
  192. (supplier, RDF.type, profile["supplier_class"]),
  193. (supplier, RDFS.label, Literal(supplier_name, lang="zh")),
  194. (supplier, GESLI.platformSupplierType, Literal(profile["supplier_type"])),
  195. (supplier, GESLI.isActivePartner, Literal(True)),
  196. (product, RDF.type, product_class),
  197. (product, GESLI.productCode, Literal(f"MOCK-PRODUCT-{suffix}")),
  198. (product, GESLI.productName, Literal(product_name)),
  199. (product, GESLI.hasServiceBrand, brand),
  200. (product, GESLI.hasProductPerformanceProfile, performance),
  201. (product, GESLI.transportMode, Literal(transport_mode)),
  202. (product, GESLI.serviceForm, Literal(profile["service_form"])),
  203. (product, GESLI.routeType, Literal(route_type)),
  204. (product, GESLI.originLocation, Literal(request.origin)),
  205. (product, GESLI.destinationLocation, Literal(request.destination)),
  206. (product, GESLI.estimatedTransitDays, Literal(transit_days, datatype=XSD.decimal)),
  207. (performance, RDF.type, GESLI.ProductPerformanceProfile),
  208. (performance, GESLI.etdAtdOnTimeRate, Literal(success_rate, datatype=XSD.decimal)),
  209. (performance, GESLI.etaAtaOnTimeRate, Literal(success_rate - 0.02, datatype=XSD.decimal)),
  210. (offering, RDF.type, GESLI.SupplierProductOffering),
  211. (offering, GESLI.offeringCode, Literal(f"MOCK-OFFER-{suffix}")),
  212. (offering, GESLI.offeredBySupplier, supplier),
  213. (offering, GESLI.offeringProduct, product),
  214. (offering, GESLI.offeringChannel, Literal(profile["offering_channel"])),
  215. (offering, GESLI.agencyLevel, Literal(profile["agency_level"])),
  216. (offering, GESLI.capacityCommitment, Literal(profile["capacity_commitment"])),
  217. (offering, GESLI.capacityAvailable, Literal(True)),
  218. (offering, GESLI.spotSpaceStatus, Literal(profile["spot_space_status"])),
  219. (offering, GESLI.spotSpaceTeu, Literal(profile["spot_space_teu"], datatype=XSD.decimal)),
  220. (offering, GESLI.supportsDangerousGoods, Literal(index == 0)),
  221. (offering, GESLI.solutionAssuranceScore, Literal(profile["solution_assurance"], datatype=XSD.decimal)),
  222. (offering, GESLI.serviceSupportScore, Literal(profile["service_support_score"], datatype=XSD.decimal)),
  223. (offering, GESLI.relationshipTier, Literal(profile["relationship_tier"])),
  224. (offering, GESLI.relationshipStrength, Literal(profile["relationship_strength"], datatype=XSD.decimal)),
  225. (offering, GESLI.lowPriceAccessProbability, Literal(profile["low_price_access_probability"], datatype=XSD.decimal)),
  226. (offering, GESLI.prioritySpaceProbability, Literal(profile["priority_space_probability"], datatype=XSD.decimal)),
  227. (offering, GESLI.relationshipEvidence, Literal(profile["relationship_evidence"])),
  228. (offering, GESLI.bookingSuccessRate, Literal(success_rate, datatype=XSD.decimal)),
  229. (offering, GESLI.spaceReleaseSuccessRate, Literal(profile["space_release_success_rate"], datatype=XSD.decimal)),
  230. (schedule, RDF.type, GESLI.ScheduledServiceInstance),
  231. (schedule, GESLI.scheduleCode, Literal(f"MOCK-SCHEDULE-{suffix}")),
  232. (schedule, GESLI.scheduleOfProduct, product),
  233. (schedule, GESLI.documentCutoffTime, _date_time_literal(departure - timedelta(days=2))),
  234. (schedule, GESLI.estimatedDepartureTime, _date_time_literal(departure)),
  235. (schedule, GESLI.estimatedArrivalTime, _date_time_literal(arrival)),
  236. (schedule, GESLI.estimatedTransitDays, Literal(transit_days, datatype=XSD.decimal)),
  237. (quote, RDF.type, GESLI.SupplierQuote),
  238. (quote, GESLI.quoteNo, Literal(f"MOCK-QUOTE-{suffix}")),
  239. (quote, GESLI.quotedBySupplier, supplier),
  240. (quote, GESLI.quotedForOffering, offering),
  241. (quote, GESLI.quoteAppliesToSchedule, schedule),
  242. (quote, GESLI.supplierCostAmount, Literal(cost, datatype=XSD.decimal)),
  243. (quote, GESLI.currency, Literal("CNY")),
  244. (quote, GESLI.validFrom, Literal(request.evaluation_date, datatype=XSD.date)),
  245. (quote, GESLI.validTo, Literal(request.evaluation_date + timedelta(days=30), datatype=XSD.date)),
  246. ])
  247. generated_suppliers.append(supplier_name)
  248. for index in range(request.customer_count):
  249. suffix = f"{token}-{index + 1}"
  250. customer = MOCK[f"customer-{suffix}"]
  251. trade = MOCK[f"customer-trade-{suffix}"]
  252. credit = MOCK[f"customer-credit-{suffix}"]
  253. customer_name = f"印度目的港模拟客户 {token[-3:].upper()}{index + 1}"
  254. triples.extend([
  255. (customer, RDF.type, GESLI.Customer),
  256. (customer, RDFS.label, Literal(customer_name, lang="zh")),
  257. (customer, GESLI.hasTradeProfile, trade),
  258. (customer, GESLI.hasCreditProfile, credit),
  259. (trade, RDF.type, GESLI.CustomerTradeProfile),
  260. (trade, GESLI.industry, Literal("印度市场Mock行业")),
  261. (trade, GESLI.routeName, Literal(f"{request.origin}-{request.destination}")),
  262. (trade, GESLI.repeatPurchaseCount, Literal(4 + index * 6)),
  263. (trade, GESLI.quoteWinRate, Literal(0.5 + index * 0.08, datatype=XSD.decimal)),
  264. (trade, GESLI.customerPriceSensitivity, Literal(0.45 + index * 0.15, datatype=XSD.decimal)),
  265. (trade, GESLI.customerTimeSensitivity, Literal(0.75 - index * 0.1, datatype=XSD.decimal)),
  266. (trade, GESLI.customerReliabilitySensitivity, Literal(0.7 + index * 0.1, datatype=XSD.decimal)),
  267. (credit, RDF.type, GESLI.CustomerCreditProfile),
  268. (credit, GESLI.overdueDays, Literal(index)),
  269. ])
  270. generated_customers.append(customer_name)
  271. added_triples = self.repository.add_triples(triples)
  272. self.market_service.reset_runtime()
  273. return {
  274. "batch_id": token,
  275. "added_triples": added_triples,
  276. "suppliers": generated_suppliers,
  277. "customers": generated_customers,
  278. "catalog": self.catalog(),
  279. }
  280. def reset(self) -> dict:
  281. self.repository.reload()
  282. self.market_service.reset_runtime()
  283. return self.catalog()
  284. def _route_transport_mode(self, origin: str, destination: str) -> str:
  285. rows = self.repository.query_file(
  286. self.root / "queries" / "market-candidates.rq"
  287. )["rows"]
  288. return next(
  289. (
  290. row.get("transportMode", "海运")
  291. for row in rows
  292. if row.get("originLocation") == origin
  293. and row.get("destinationLocation") == destination
  294. ),
  295. "海运",
  296. )
  297. @staticmethod
  298. def _transit_days(transport_mode: str, index: int) -> int:
  299. return (3 + index) if transport_mode == "空运" else (16 + index * 2)
  300. @staticmethod
  301. def _supplier_profile(index: int) -> dict:
  302. profiles = [
  303. {
  304. "supplier_label": "船公司直营",
  305. "supplier_class": GESLI.Carrier,
  306. "supplier_type": "船公司",
  307. "product_label": "优先直航",
  308. "route_type": "直航",
  309. "service_form": "优先舱位包柜",
  310. "offering_channel": "船公司直营",
  311. "agency_level": "产品品牌本地分支机构",
  312. "capacity_commitment": "年度协议优先保舱20TEU",
  313. "spot_space_status": "可立即订舱",
  314. "spot_space_teu": 18,
  315. "relationship_tier": "战略合作",
  316. "relationship_strength": 0.96,
  317. "low_price_access_probability": 0.90,
  318. "priority_space_probability": 0.95,
  319. "relationship_evidence": "年度协议、价格窗口直连与优先放舱额度",
  320. "solution_assurance": 0.98,
  321. "service_support_score": 0.94,
  322. "booking_success_rate": 0.97,
  323. "space_release_success_rate": 0.96,
  324. "cost_ratio": 0.91,
  325. },
  326. {
  327. "supplier_label": "一级订舱代理",
  328. "supplier_class": GESLI.ShippingAgencyCompany,
  329. "supplier_type": "订舱代理",
  330. "product_label": "稳定直航",
  331. "route_type": "直航",
  332. "service_form": "稳定舱位包柜",
  333. "offering_channel": "一级订舱代理",
  334. "agency_level": "一级代理",
  335. "capacity_commitment": "月度保舱12TEU",
  336. "spot_space_status": "现舱有限",
  337. "spot_space_teu": 8,
  338. "relationship_tier": "核心合作",
  339. "relationship_strength": 0.87,
  340. "low_price_access_probability": 0.82,
  341. "priority_space_probability": 0.86,
  342. "relationship_evidence": "核心代理授权与固定订舱窗口",
  343. "solution_assurance": 0.90,
  344. "service_support_score": 0.90,
  345. "booking_success_rate": 0.94,
  346. "space_release_success_rate": 0.92,
  347. "cost_ratio": 0.86,
  348. },
  349. {
  350. "supplier_label": "稳定合作代理",
  351. "supplier_class": GESLI.FreightForwarderSupplier,
  352. "supplier_type": "货代公司",
  353. "product_label": "常规直航",
  354. "route_type": "直航",
  355. "service_form": "标准包柜",
  356. "offering_channel": "长期合作货代",
  357. "agency_level": "稳定合作代理",
  358. "capacity_commitment": "周度滚动确认8TEU",
  359. "spot_space_status": "现舱有限",
  360. "spot_space_teu": 4,
  361. "relationship_tier": "稳定合作",
  362. "relationship_strength": 0.74,
  363. "low_price_access_probability": 0.70,
  364. "priority_space_probability": 0.72,
  365. "relationship_evidence": "长期出货记录与周度保舱协同",
  366. "solution_assurance": 0.83,
  367. "service_support_score": 0.82,
  368. "booking_success_rate": 0.90,
  369. "space_release_success_rate": 0.88,
  370. "cost_ratio": 0.79,
  371. },
  372. {
  373. "supplier_label": "市场订舱代理",
  374. "supplier_class": GESLI.ShippingAgencyCompany,
  375. "supplier_type": "订舱代理",
  376. "product_label": "中转经济",
  377. "route_type": "中转",
  378. "service_form": "经济包柜",
  379. "offering_channel": "市场订舱代理",
  380. "agency_level": "二级代理",
  381. "capacity_commitment": "按单票确认舱位",
  382. "spot_space_status": "需确认",
  383. "spot_space_teu": 0,
  384. "relationship_tier": "常规合作",
  385. "relationship_strength": 0.52,
  386. "low_price_access_probability": 0.46,
  387. "priority_space_probability": 0.48,
  388. "relationship_evidence": "按市场舱位和临时报价执行",
  389. "solution_assurance": 0.67,
  390. "service_support_score": 0.68,
  391. "booking_success_rate": 0.84,
  392. "space_release_success_rate": 0.82,
  393. "cost_ratio": 0.72,
  394. },
  395. {
  396. "supplier_label": "区域货代",
  397. "supplier_class": GESLI.FreightForwarderSupplier,
  398. "supplier_type": "货代公司",
  399. "product_label": "加班船直航",
  400. "route_type": "直航",
  401. "service_form": "加班船包柜",
  402. "offering_channel": "区域货代",
  403. "agency_level": "合作货代",
  404. "capacity_commitment": "加班船窗口优先确认6TEU",
  405. "spot_space_status": "现舱有限",
  406. "spot_space_teu": 3,
  407. "relationship_tier": "稳定合作",
  408. "relationship_strength": 0.69,
  409. "low_price_access_probability": 0.63,
  410. "priority_space_probability": 0.70,
  411. "relationship_evidence": "区域市场协同与加班船资源预留",
  412. "solution_assurance": 0.80,
  413. "service_support_score": 0.79,
  414. "booking_success_rate": 0.89,
  415. "space_release_success_rate": 0.88,
  416. "cost_ratio": 0.82,
  417. },
  418. ]
  419. return profiles[index % len(profiles)]
  420. def _as_float(value: str | None, default: float = 0.0) -> float:
  421. try:
  422. return float(value) if value not in (None, "") else default
  423. except (TypeError, ValueError):
  424. return default
  425. def _as_date(value: str | None) -> date | None:
  426. if not value:
  427. return None
  428. try:
  429. return date.fromisoformat(value[:10])
  430. except ValueError:
  431. return None
  432. def _within(current: date, start: str | None, end: str | None) -> bool:
  433. start_date = _as_date(start)
  434. end_date = _as_date(end)
  435. return (not start_date or start_date <= current) and (
  436. not end_date or current <= end_date
  437. )
  438. def _date_time_literal(value: date) -> Literal:
  439. dt = datetime.combine(
  440. value,
  441. time(hour=12),
  442. tzinfo=timezone(timedelta(hours=8)),
  443. )
  444. return Literal(dt.isoformat(), datatype=XSD.dateTime)