mock_data.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333
  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=2, ge=1, le=5)
  15. customer_count: int = Field(default=1, ge=0, le=3)
  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. "runtime_mock": row.get("quote", "").startswith(str(MOCK)),
  94. })
  95. quote_rows.sort(
  96. key=lambda item: (
  97. item["route"], item["status"], item["supplier_cost_amount"]
  98. )
  99. )
  100. strategies = [
  101. {
  102. "name": row.get("strategyName", ""),
  103. "objective": row.get("strategyObjective", ""),
  104. "route": f"{row.get('origin', '')} → {row.get('destination', '')}",
  105. "adjustment_rate": _as_float(row.get("strategyAdjustmentRate")),
  106. "valid_to": row.get("validTo", ""),
  107. }
  108. for row in context_rows
  109. if row.get("kind") == "strategy"
  110. ]
  111. return {
  112. "summary": {
  113. "triple_count": self.repository.triple_count,
  114. "in_memory_triple_count": self.repository.in_memory_triple_count,
  115. "customer_count": len(customers),
  116. "supplier_count": len({row.get("supplier") for row in candidate_rows}),
  117. "product_count": len({row.get("product") for row in candidate_rows}),
  118. "quote_count": len({row.get("quote") for row in candidate_rows}),
  119. "route_count": len(route_rows),
  120. "strategy_count": len(strategies),
  121. },
  122. "routes": route_rows,
  123. "customers": customers,
  124. "quotes": quote_rows,
  125. "strategies": strategies,
  126. "loaded_files": self.repository.loaded_file_names,
  127. }
  128. def generate(self, request: MockBatchRequest) -> dict:
  129. route_context = next(
  130. (
  131. route
  132. for route in self.market_service.form_options()["routes"]
  133. if route["origin"] == request.origin
  134. and route["destination"] == request.destination
  135. ),
  136. None,
  137. )
  138. if not route_context:
  139. raise ValueError("只能为已经配置市场基准和策略的路线生成Mock数据")
  140. token = uuid4().hex[:8]
  141. benchmark = float(route_context["target_amount"])
  142. transport_mode = self._route_transport_mode(
  143. request.origin, request.destination
  144. )
  145. triples = []
  146. generated_suppliers = []
  147. generated_customers = []
  148. for index in range(request.supplier_count):
  149. suffix = f"{token}-{index + 1}"
  150. brand = MOCK[f"brand-{suffix}"]
  151. supplier = MOCK[f"supplier-{suffix}"]
  152. product = MOCK[f"product-{suffix}"]
  153. offering = MOCK[f"offering-{suffix}"]
  154. schedule = MOCK[f"schedule-{suffix}"]
  155. quote = MOCK[f"quote-{suffix}"]
  156. performance = MOCK[f"performance-{suffix}"]
  157. supplier_name = f"内存模拟供应商 {token[-3:].upper()}{index + 1}"
  158. product_name = (
  159. f"{request.origin}-{request.destination}动态Mock方案{index + 1}"
  160. )
  161. departure = request.evaluation_date + timedelta(days=8 + index)
  162. transit_days = self._transit_days(transport_mode, index)
  163. arrival = departure + timedelta(days=transit_days)
  164. cost = round(benchmark * (0.76 + index * 0.025), -2)
  165. success_rate = min(0.98, 0.87 + index * 0.035)
  166. product_class = (
  167. GESLI.AirFreightProduct
  168. if transport_mode == "空运"
  169. else GESLI.ContainerOceanProduct
  170. )
  171. route_type = "直达" if transport_mode == "空运" and index == 0 else (
  172. "直航" if index % 2 == 0 else "中转"
  173. )
  174. triples.extend([
  175. (brand, RDF.type, GESLI.ServiceBrand),
  176. (brand, GESLI.brandCode, Literal(f"MOCK-{token}-{index + 1}")),
  177. (brand, GESLI.brandName, Literal(f"Mock品牌 {index + 1}")),
  178. (supplier, RDF.type, GESLI.FreightForwarderSupplier),
  179. (supplier, RDFS.label, Literal(supplier_name, lang="zh")),
  180. (supplier, GESLI.platformSupplierType, Literal("内存Mock供应商")),
  181. (supplier, GESLI.isActivePartner, Literal(True)),
  182. (product, RDF.type, product_class),
  183. (product, GESLI.productCode, Literal(f"MOCK-PRODUCT-{suffix}")),
  184. (product, GESLI.productName, Literal(product_name)),
  185. (product, GESLI.hasServiceBrand, brand),
  186. (product, GESLI.hasProductPerformanceProfile, performance),
  187. (product, GESLI.transportMode, Literal(transport_mode)),
  188. (product, GESLI.serviceForm, Literal("Mock标准服务")),
  189. (product, GESLI.routeType, Literal(route_type)),
  190. (product, GESLI.originLocation, Literal(request.origin)),
  191. (product, GESLI.destinationLocation, Literal(request.destination)),
  192. (product, GESLI.estimatedTransitDays, Literal(transit_days, datatype=XSD.decimal)),
  193. (performance, RDF.type, GESLI.ProductPerformanceProfile),
  194. (performance, GESLI.etdAtdOnTimeRate, Literal(success_rate, datatype=XSD.decimal)),
  195. (performance, GESLI.etaAtaOnTimeRate, Literal(success_rate - 0.02, datatype=XSD.decimal)),
  196. (offering, RDF.type, GESLI.SupplierProductOffering),
  197. (offering, GESLI.offeringCode, Literal(f"MOCK-OFFER-{suffix}")),
  198. (offering, GESLI.offeredBySupplier, supplier),
  199. (offering, GESLI.offeringProduct, product),
  200. (offering, GESLI.offeringChannel, Literal("本地动态Mock")),
  201. (offering, GESLI.agencyLevel, Literal("一级模拟代理")),
  202. (offering, GESLI.capacityCommitment, Literal(f"模拟可用运力{10 + index * 5}单位")),
  203. (offering, GESLI.capacityAvailable, Literal(True)),
  204. (offering, GESLI.supportsDangerousGoods, Literal(index == 0)),
  205. (offering, GESLI.bookingSuccessRate, Literal(success_rate, datatype=XSD.decimal)),
  206. (offering, GESLI.spaceReleaseSuccessRate, Literal(success_rate - 0.01, datatype=XSD.decimal)),
  207. (schedule, RDF.type, GESLI.ScheduledServiceInstance),
  208. (schedule, GESLI.scheduleCode, Literal(f"MOCK-SCHEDULE-{suffix}")),
  209. (schedule, GESLI.scheduleOfProduct, product),
  210. (schedule, GESLI.documentCutoffTime, _date_time_literal(departure - timedelta(days=2))),
  211. (schedule, GESLI.estimatedDepartureTime, _date_time_literal(departure)),
  212. (schedule, GESLI.estimatedArrivalTime, _date_time_literal(arrival)),
  213. (schedule, GESLI.estimatedTransitDays, Literal(transit_days, datatype=XSD.decimal)),
  214. (quote, RDF.type, GESLI.SupplierQuote),
  215. (quote, GESLI.quoteNo, Literal(f"MOCK-QUOTE-{suffix}")),
  216. (quote, GESLI.quotedBySupplier, supplier),
  217. (quote, GESLI.quotedForOffering, offering),
  218. (quote, GESLI.quoteAppliesToSchedule, schedule),
  219. (quote, GESLI.supplierCostAmount, Literal(cost, datatype=XSD.decimal)),
  220. (quote, GESLI.currency, Literal("CNY")),
  221. (quote, GESLI.validFrom, Literal(request.evaluation_date, datatype=XSD.date)),
  222. (quote, GESLI.validTo, Literal(request.evaluation_date + timedelta(days=30), datatype=XSD.date)),
  223. ])
  224. generated_suppliers.append(supplier_name)
  225. for index in range(request.customer_count):
  226. suffix = f"{token}-{index + 1}"
  227. customer = MOCK[f"customer-{suffix}"]
  228. trade = MOCK[f"customer-trade-{suffix}"]
  229. credit = MOCK[f"customer-credit-{suffix}"]
  230. customer_name = f"内存模拟客户 {token[-3:].upper()}{index + 1}"
  231. triples.extend([
  232. (customer, RDF.type, GESLI.Customer),
  233. (customer, RDFS.label, Literal(customer_name, lang="zh")),
  234. (customer, GESLI.hasTradeProfile, trade),
  235. (customer, GESLI.hasCreditProfile, credit),
  236. (trade, RDF.type, GESLI.CustomerTradeProfile),
  237. (trade, GESLI.industry, Literal("动态Mock行业")),
  238. (trade, GESLI.routeName, Literal(f"{request.origin}-{request.destination}")),
  239. (trade, GESLI.repeatPurchaseCount, Literal(4 + index * 6)),
  240. (trade, GESLI.quoteWinRate, Literal(0.5 + index * 0.08, datatype=XSD.decimal)),
  241. (trade, GESLI.customerPriceSensitivity, Literal(0.45 + index * 0.15, datatype=XSD.decimal)),
  242. (trade, GESLI.customerTimeSensitivity, Literal(0.75 - index * 0.1, datatype=XSD.decimal)),
  243. (trade, GESLI.customerReliabilitySensitivity, Literal(0.7 + index * 0.1, datatype=XSD.decimal)),
  244. (credit, RDF.type, GESLI.CustomerCreditProfile),
  245. (credit, GESLI.overdueDays, Literal(index)),
  246. ])
  247. generated_customers.append(customer_name)
  248. added_triples = self.repository.add_triples(triples)
  249. self.market_service.reset_runtime()
  250. return {
  251. "batch_id": token,
  252. "added_triples": added_triples,
  253. "suppliers": generated_suppliers,
  254. "customers": generated_customers,
  255. "catalog": self.catalog(),
  256. }
  257. def reset(self) -> dict:
  258. self.repository.reload()
  259. self.market_service.reset_runtime()
  260. return self.catalog()
  261. def _route_transport_mode(self, origin: str, destination: str) -> str:
  262. rows = self.repository.query_file(
  263. self.root / "queries" / "market-candidates.rq"
  264. )["rows"]
  265. return next(
  266. (
  267. row.get("transportMode", "海运")
  268. for row in rows
  269. if row.get("originLocation") == origin
  270. and row.get("destinationLocation") == destination
  271. ),
  272. "海运",
  273. )
  274. @staticmethod
  275. def _transit_days(transport_mode: str, index: int) -> int:
  276. return (3 + index) if transport_mode == "空运" else (14 + index * 2)
  277. def _as_float(value: str | None, default: float = 0.0) -> float:
  278. try:
  279. return float(value) if value not in (None, "") else default
  280. except (TypeError, ValueError):
  281. return default
  282. def _as_date(value: str | None) -> date | None:
  283. if not value:
  284. return None
  285. try:
  286. return date.fromisoformat(value[:10])
  287. except ValueError:
  288. return None
  289. def _within(current: date, start: str | None, end: str | None) -> bool:
  290. start_date = _as_date(start)
  291. end_date = _as_date(end)
  292. return (not start_date or start_date <= current) and (
  293. not end_date or current <= end_date
  294. )
  295. def _date_time_literal(value: date) -> Literal:
  296. dt = datetime.combine(
  297. value,
  298. time(hour=12),
  299. tzinfo=timezone(timedelta(hours=8)),
  300. )
  301. return Literal(dt.isoformat(), datatype=XSD.dateTime)