| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333 |
- from __future__ import annotations
- from datetime import date, datetime, time, timedelta, timezone
- from pathlib import Path
- from uuid import uuid4
- from pydantic import BaseModel, Field
- from rdflib import Literal, Namespace, RDF, RDFS, XSD
- from app.market import MarketService
- from app.repository import RDFRepository
- GESLI = Namespace("https://gesli.example/ontology#")
- MOCK = Namespace("https://gesli.example/runtime-mock/")
- class MockBatchRequest(BaseModel):
- origin: str
- destination: str
- supplier_count: int = Field(default=2, ge=1, le=5)
- customer_count: int = Field(default=1, ge=0, le=3)
- evaluation_date: date = date(2026, 7, 20)
- class LocalMockDataService:
- def __init__(
- self,
- root: Path,
- repository: RDFRepository,
- market_service: MarketService,
- ):
- self.root = root
- self.repository = repository
- self.market_service = market_service
- def catalog(self) -> dict:
- candidate_rows = self.repository.query_file(
- self.root / "queries" / "market-candidates.rq"
- )["rows"]
- context_rows = self.repository.query_file(
- self.root / "queries" / "market-context.rq"
- )["rows"]
- customers = self.market_service.customers()
- evaluation_date = date(2026, 7, 20)
- routes: dict[tuple[str, str], dict] = {}
- for row in candidate_rows:
- origin = row.get("originLocation", "")
- destination = row.get("destinationLocation", "")
- if not origin or not destination:
- continue
- key = (origin, destination)
- route = routes.setdefault(key, {
- "origin": origin,
- "destination": destination,
- "products": set(),
- "suppliers": set(),
- "quotes": set(),
- "current_quotes": 0,
- "transport_modes": set(),
- })
- route["products"].add(row.get("product", ""))
- route["suppliers"].add(row.get("supplier", ""))
- route["quotes"].add(row.get("quote", ""))
- if row.get("transportMode"):
- route["transport_modes"].add(row["transportMode"])
- if _within(evaluation_date, row.get("validFrom"), row.get("validTo")):
- route["current_quotes"] += 1
- route_rows = [
- {
- "origin": route["origin"],
- "destination": route["destination"],
- "transport_modes": " / ".join(sorted(route["transport_modes"])),
- "product_count": len(route["products"]),
- "supplier_count": len(route["suppliers"]),
- "quote_count": len(route["quotes"]),
- "current_quote_count": route["current_quotes"],
- }
- for route in routes.values()
- ]
- route_rows.sort(key=lambda item: (item["origin"], item["destination"]))
- quote_rows = []
- for row in candidate_rows:
- valid_from = row.get("validFrom", "")
- valid_to = row.get("validTo", "")
- if _within(evaluation_date, valid_from, valid_to):
- status = "当前有效"
- elif valid_to and valid_to < evaluation_date.isoformat():
- status = "已过期"
- else:
- status = "尚未生效"
- quote_rows.append({
- "quote_no": row.get("quoteNo", ""),
- "route": f"{row.get('originLocation', '')} → {row.get('destinationLocation', '')}",
- "transport_mode": row.get("transportMode", ""),
- "product_name": row.get("productName", ""),
- "supplier_name": row.get("supplierName", ""),
- "supplier_cost_amount": _as_float(row.get("supplierCostAmount")),
- "currency": row.get("currency", ""),
- "valid_to": valid_to,
- "status": status,
- "dangerous_goods": row.get("supportsDangerousGoods", "false") == "true",
- "runtime_mock": row.get("quote", "").startswith(str(MOCK)),
- })
- quote_rows.sort(
- key=lambda item: (
- item["route"], item["status"], item["supplier_cost_amount"]
- )
- )
- strategies = [
- {
- "name": row.get("strategyName", ""),
- "objective": row.get("strategyObjective", ""),
- "route": f"{row.get('origin', '')} → {row.get('destination', '')}",
- "adjustment_rate": _as_float(row.get("strategyAdjustmentRate")),
- "valid_to": row.get("validTo", ""),
- }
- for row in context_rows
- if row.get("kind") == "strategy"
- ]
- return {
- "summary": {
- "triple_count": self.repository.triple_count,
- "in_memory_triple_count": self.repository.in_memory_triple_count,
- "customer_count": len(customers),
- "supplier_count": len({row.get("supplier") for row in candidate_rows}),
- "product_count": len({row.get("product") for row in candidate_rows}),
- "quote_count": len({row.get("quote") for row in candidate_rows}),
- "route_count": len(route_rows),
- "strategy_count": len(strategies),
- },
- "routes": route_rows,
- "customers": customers,
- "quotes": quote_rows,
- "strategies": strategies,
- "loaded_files": self.repository.loaded_file_names,
- }
- def generate(self, request: MockBatchRequest) -> dict:
- route_context = next(
- (
- route
- for route in self.market_service.form_options()["routes"]
- if route["origin"] == request.origin
- and route["destination"] == request.destination
- ),
- None,
- )
- if not route_context:
- raise ValueError("只能为已经配置市场基准和策略的路线生成Mock数据")
- token = uuid4().hex[:8]
- benchmark = float(route_context["target_amount"])
- transport_mode = self._route_transport_mode(
- request.origin, request.destination
- )
- triples = []
- generated_suppliers = []
- generated_customers = []
- for index in range(request.supplier_count):
- suffix = f"{token}-{index + 1}"
- brand = MOCK[f"brand-{suffix}"]
- supplier = MOCK[f"supplier-{suffix}"]
- product = MOCK[f"product-{suffix}"]
- offering = MOCK[f"offering-{suffix}"]
- schedule = MOCK[f"schedule-{suffix}"]
- quote = MOCK[f"quote-{suffix}"]
- performance = MOCK[f"performance-{suffix}"]
- supplier_name = f"内存模拟供应商 {token[-3:].upper()}{index + 1}"
- product_name = (
- f"{request.origin}-{request.destination}动态Mock方案{index + 1}"
- )
- departure = request.evaluation_date + timedelta(days=8 + index)
- transit_days = self._transit_days(transport_mode, index)
- arrival = departure + timedelta(days=transit_days)
- cost = round(benchmark * (0.76 + index * 0.025), -2)
- success_rate = min(0.98, 0.87 + index * 0.035)
- product_class = (
- GESLI.AirFreightProduct
- if transport_mode == "空运"
- else GESLI.ContainerOceanProduct
- )
- route_type = "直达" if transport_mode == "空运" and index == 0 else (
- "直航" if index % 2 == 0 else "中转"
- )
- triples.extend([
- (brand, RDF.type, GESLI.ServiceBrand),
- (brand, GESLI.brandCode, Literal(f"MOCK-{token}-{index + 1}")),
- (brand, GESLI.brandName, Literal(f"Mock品牌 {index + 1}")),
- (supplier, RDF.type, GESLI.FreightForwarderSupplier),
- (supplier, RDFS.label, Literal(supplier_name, lang="zh")),
- (supplier, GESLI.platformSupplierType, Literal("内存Mock供应商")),
- (supplier, GESLI.isActivePartner, Literal(True)),
- (product, RDF.type, product_class),
- (product, GESLI.productCode, Literal(f"MOCK-PRODUCT-{suffix}")),
- (product, GESLI.productName, Literal(product_name)),
- (product, GESLI.hasServiceBrand, brand),
- (product, GESLI.hasProductPerformanceProfile, performance),
- (product, GESLI.transportMode, Literal(transport_mode)),
- (product, GESLI.serviceForm, Literal("Mock标准服务")),
- (product, GESLI.routeType, Literal(route_type)),
- (product, GESLI.originLocation, Literal(request.origin)),
- (product, GESLI.destinationLocation, Literal(request.destination)),
- (product, GESLI.estimatedTransitDays, Literal(transit_days, datatype=XSD.decimal)),
- (performance, RDF.type, GESLI.ProductPerformanceProfile),
- (performance, GESLI.etdAtdOnTimeRate, Literal(success_rate, datatype=XSD.decimal)),
- (performance, GESLI.etaAtaOnTimeRate, Literal(success_rate - 0.02, datatype=XSD.decimal)),
- (offering, RDF.type, GESLI.SupplierProductOffering),
- (offering, GESLI.offeringCode, Literal(f"MOCK-OFFER-{suffix}")),
- (offering, GESLI.offeredBySupplier, supplier),
- (offering, GESLI.offeringProduct, product),
- (offering, GESLI.offeringChannel, Literal("本地动态Mock")),
- (offering, GESLI.agencyLevel, Literal("一级模拟代理")),
- (offering, GESLI.capacityCommitment, Literal(f"模拟可用运力{10 + index * 5}单位")),
- (offering, GESLI.capacityAvailable, Literal(True)),
- (offering, GESLI.supportsDangerousGoods, Literal(index == 0)),
- (offering, GESLI.bookingSuccessRate, Literal(success_rate, datatype=XSD.decimal)),
- (offering, GESLI.spaceReleaseSuccessRate, Literal(success_rate - 0.01, datatype=XSD.decimal)),
- (schedule, RDF.type, GESLI.ScheduledServiceInstance),
- (schedule, GESLI.scheduleCode, Literal(f"MOCK-SCHEDULE-{suffix}")),
- (schedule, GESLI.scheduleOfProduct, product),
- (schedule, GESLI.documentCutoffTime, _date_time_literal(departure - timedelta(days=2))),
- (schedule, GESLI.estimatedDepartureTime, _date_time_literal(departure)),
- (schedule, GESLI.estimatedArrivalTime, _date_time_literal(arrival)),
- (schedule, GESLI.estimatedTransitDays, Literal(transit_days, datatype=XSD.decimal)),
- (quote, RDF.type, GESLI.SupplierQuote),
- (quote, GESLI.quoteNo, Literal(f"MOCK-QUOTE-{suffix}")),
- (quote, GESLI.quotedBySupplier, supplier),
- (quote, GESLI.quotedForOffering, offering),
- (quote, GESLI.quoteAppliesToSchedule, schedule),
- (quote, GESLI.supplierCostAmount, Literal(cost, datatype=XSD.decimal)),
- (quote, GESLI.currency, Literal("CNY")),
- (quote, GESLI.validFrom, Literal(request.evaluation_date, datatype=XSD.date)),
- (quote, GESLI.validTo, Literal(request.evaluation_date + timedelta(days=30), datatype=XSD.date)),
- ])
- generated_suppliers.append(supplier_name)
- for index in range(request.customer_count):
- suffix = f"{token}-{index + 1}"
- customer = MOCK[f"customer-{suffix}"]
- trade = MOCK[f"customer-trade-{suffix}"]
- credit = MOCK[f"customer-credit-{suffix}"]
- customer_name = f"内存模拟客户 {token[-3:].upper()}{index + 1}"
- triples.extend([
- (customer, RDF.type, GESLI.Customer),
- (customer, RDFS.label, Literal(customer_name, lang="zh")),
- (customer, GESLI.hasTradeProfile, trade),
- (customer, GESLI.hasCreditProfile, credit),
- (trade, RDF.type, GESLI.CustomerTradeProfile),
- (trade, GESLI.industry, Literal("动态Mock行业")),
- (trade, GESLI.routeName, Literal(f"{request.origin}-{request.destination}")),
- (trade, GESLI.repeatPurchaseCount, Literal(4 + index * 6)),
- (trade, GESLI.quoteWinRate, Literal(0.5 + index * 0.08, datatype=XSD.decimal)),
- (trade, GESLI.customerPriceSensitivity, Literal(0.45 + index * 0.15, datatype=XSD.decimal)),
- (trade, GESLI.customerTimeSensitivity, Literal(0.75 - index * 0.1, datatype=XSD.decimal)),
- (trade, GESLI.customerReliabilitySensitivity, Literal(0.7 + index * 0.1, datatype=XSD.decimal)),
- (credit, RDF.type, GESLI.CustomerCreditProfile),
- (credit, GESLI.overdueDays, Literal(index)),
- ])
- generated_customers.append(customer_name)
- added_triples = self.repository.add_triples(triples)
- self.market_service.reset_runtime()
- return {
- "batch_id": token,
- "added_triples": added_triples,
- "suppliers": generated_suppliers,
- "customers": generated_customers,
- "catalog": self.catalog(),
- }
- def reset(self) -> dict:
- self.repository.reload()
- self.market_service.reset_runtime()
- return self.catalog()
- def _route_transport_mode(self, origin: str, destination: str) -> str:
- rows = self.repository.query_file(
- self.root / "queries" / "market-candidates.rq"
- )["rows"]
- return next(
- (
- row.get("transportMode", "海运")
- for row in rows
- if row.get("originLocation") == origin
- and row.get("destinationLocation") == destination
- ),
- "海运",
- )
- @staticmethod
- def _transit_days(transport_mode: str, index: int) -> int:
- return (3 + index) if transport_mode == "空运" else (14 + index * 2)
- def _as_float(value: str | None, default: float = 0.0) -> float:
- try:
- return float(value) if value not in (None, "") else default
- except (TypeError, ValueError):
- return default
- def _as_date(value: str | None) -> date | None:
- if not value:
- return None
- try:
- return date.fromisoformat(value[:10])
- except ValueError:
- return None
- def _within(current: date, start: str | None, end: str | None) -> bool:
- start_date = _as_date(start)
- end_date = _as_date(end)
- return (not start_date or start_date <= current) and (
- not end_date or current <= end_date
- )
- def _date_time_literal(value: date) -> Literal:
- dt = datetime.combine(
- value,
- time(hour=12),
- tzinfo=timezone(timedelta(hours=8)),
- )
- return Literal(dt.isoformat(), datatype=XSD.dateTime)
|