| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502 |
- from __future__ import annotations
- from datetime import date, datetime
- import json
- from pathlib import Path
- from threading import RLock
- from uuid import uuid4
- from zoneinfo import ZoneInfo
- from rdflib import Graph, Literal, Namespace, RDF, URIRef, XSD
- from app.market.filters import rejection_reasons
- from app.market.models import CandidateRecord, InquiryRequest
- from app.market.pricing import build_prices
- from app.market.scoring import CustomerSignals, score_candidates
- from app.repository import RDFRepository
- GESLI = Namespace("https://gesli.example/ontology#")
- RUN = Namespace("https://gesli.example/recommendation/")
- class MarketService:
- def __init__(self, root: Path, repository: RDFRepository):
- self.root = root
- self.repository = repository
- self.config = json.loads(
- (root / "config" / "market-strategies.json").read_text(encoding="utf-8")
- )
- self.runs: dict[str, dict] = {}
- self.audit_graph = Graph()
- self._lock = RLock()
- def customers(self) -> list[dict]:
- rows = self.repository.query_file(
- self.root / "queries" / "market-customers.rq"
- )["rows"]
- return [
- {
- "id": row["customer"],
- "name": row["customerName"],
- "industry": row.get("industry", ""),
- "preferred_route": row.get("routeName", ""),
- "repeat_purchase_count": _as_int(row.get("repeatPurchaseCount")),
- "price_sensitivity": _as_float(row.get("customerPriceSensitivity"), 0.5),
- "time_sensitivity": _as_float(row.get("customerTimeSensitivity"), 0.5),
- "reliability_sensitivity": _as_float(
- row.get("customerReliabilitySensitivity"), 0.5
- ),
- }
- for row in rows
- ]
- def form_options(self) -> dict:
- candidates = self._candidate_records()
- context_rows = self.repository.query_file(
- self.root / "queries" / "market-context.rq"
- )["rows"]
- configured_routes = {
- (row.get("origin", ""), row.get("destination", ""))
- for row in context_rows
- if row.get("kind") == "benchmark" and row.get("origin") and row.get("destination")
- }
- origins = sorted({origin for origin, _ in configured_routes})
- destinations = sorted({destination for _, destination in configured_routes})
- route_types = sorted({
- item.route_type
- for item in candidates
- if item.route_type and (item.origin, item.destination) in configured_routes
- })
- benchmark_by_route = {
- (row.get("origin", ""), row.get("destination", "")): _as_float(
- row.get("amount")
- )
- for row in context_rows
- if row.get("kind") == "benchmark"
- }
- routes = []
- for origin, destination in sorted(configured_routes):
- route_candidates = [
- item
- for item in candidates
- if item.origin == origin
- and item.destination == destination
- and item.departure_date
- and item.valid_to
- and item.valid_to >= date(2026, 7, 20)
- ]
- departures = [item.departure_date for item in route_candidates]
- transit_days = [item.transit_days for item in route_candidates]
- routes.append({
- "origin": origin,
- "destination": destination,
- "route_types": sorted({
- item.route_type for item in route_candidates if item.route_type
- }),
- "target_amount": benchmark_by_route.get((origin, destination), 0),
- "requested_departure_date": (
- min(departures).isoformat() if departures else "2026-07-27"
- ),
- "max_transit_days": max(transit_days) if transit_days else 30,
- })
- return {
- "customers": self.customers(),
- "origins": origins,
- "destinations": destinations,
- "route_types": route_types,
- "routes": routes,
- "defaults": InquiryRequest(customer="https://gesli.example/sample#Haier").model_dump(
- mode="json"
- ),
- }
- def recommend(self, inquiry: InquiryRequest) -> dict:
- customer_row = self._find_customer(inquiry.customer)
- customer = self._customer_signals(customer_row)
- component_cost, component_names = self._component_costs(
- inquiry.evaluation_date, inquiry.origin
- )
- component_product_codes = {
- code
- for codes in self.config["component_product_codes_by_origin"].values()
- for code in codes
- }
- route_candidates = [
- candidate
- for candidate in self._candidate_records()
- if candidate.origin == inquiry.origin
- and candidate.destination == inquiry.destination
- and candidate.currency == "CNY"
- and candidate.product_code not in component_product_codes
- ]
- feasible: list[CandidateRecord] = []
- rejected: list[dict] = []
- for candidate in route_candidates:
- reasons = rejection_reasons(candidate, inquiry)
- if reasons:
- rejected.append(self._rejected_payload(candidate, reasons))
- else:
- feasible.append(candidate)
- if not feasible:
- raise ValueError("没有满足当前硬条件的供应商产品和班期")
- benchmark, strategy = self._market_context(inquiry)
- scored, weights = score_candidates(feasible, inquiry, customer)
- candidates: list[dict] = []
- for item in scored:
- candidate: CandidateRecord = item.pop("candidate")
- total_supplier_cost = candidate.supplier_cost + component_cost
- prices = build_prices(
- benchmark_amount=benchmark["amount"],
- strategy_rate=strategy["adjustment_rate"],
- solution_type=item["solution_type"],
- customer=customer,
- salesperson_adjustment=inquiry.salesperson_adjustment,
- supplier_cost=total_supplier_cost,
- config=self.config,
- )
- payload = self._candidate_payload(candidate, item, prices, component_names)
- candidates.append(payload)
- top = candidates[0]
- score_gap = top["total_score"] - candidates[1]["total_score"] if len(candidates) > 1 else 10
- confidence = round(min(0.98, 0.78 + max(0, score_gap) / 100), 2)
- top["confidence_score"] = confidence
- run_id = f"rec-{uuid4().hex[:12]}"
- generated_at = datetime.now(ZoneInfo("Asia/Shanghai")).isoformat(timespec="seconds")
- result = {
- "run_id": run_id,
- "generated_at": generated_at,
- "normalized_inquiry": {
- **inquiry.model_dump(mode="json"),
- "customer_name": customer_row["customerName"],
- "route": f"{inquiry.origin} → {inquiry.destination}",
- "dangerous_goods_label": "危险品" if inquiry.dangerous_goods else "非危险品",
- },
- "pipeline": {
- "discovered": len(route_candidates),
- "rejected": len(rejected),
- "feasible": len(feasible),
- "generated_solutions": len(candidates),
- },
- "recommendation": {
- "candidate_id": top["id"],
- "solution_type": top["solution_type"],
- "product_name": top["product_name"],
- "supplier_name": top["supplier_name"],
- "final_quote_amount": top["pricing"]["final_quote_amount"],
- "currency": top["pricing"]["currency"],
- "confidence_score": confidence,
- "summary": self._recommendation_summary(top, strategy, customer_row),
- },
- "market_context": {
- "benchmark_amount": benchmark["amount"],
- "currency": benchmark["currency"],
- "strategy_name": strategy["name"],
- "strategy_objective": strategy["objective"],
- "strategy_adjustment_rate": strategy["adjustment_rate"],
- },
- "weights": weights,
- "candidates": candidates,
- "rejected_candidates": rejected,
- }
- self._store_run(run_id, result, inquiry, strategy)
- return result
- def get_run(self, run_id: str) -> dict | None:
- with self._lock:
- return self.runs.get(run_id)
- def reset_runtime(self) -> None:
- with self._lock:
- self.runs.clear()
- self.audit_graph = Graph()
- def get_audit(self, run_id: str) -> dict | None:
- run = self.get_run(run_id)
- if not run:
- return None
- run_uri = RUN[run_id]
- with self._lock:
- triples = list(self.audit_graph.triples((run_uri, None, None)))
- return {
- "run_id": run_id,
- "generated_at": run["generated_at"],
- "input": run["normalized_inquiry"],
- "weights": run["weights"],
- "market_context": run["market_context"],
- "candidate_count": len(run["candidates"]),
- "rejected_count": len(run["rejected_candidates"]),
- "rdf_assertions": len(triples),
- "sources": self.repository.loaded_file_names,
- }
- def _candidate_records(self) -> list[CandidateRecord]:
- rows = self.repository.query_file(
- self.root / "queries" / "market-candidates.rq"
- )["rows"]
- return [
- CandidateRecord(
- quote_uri=row["quote"],
- quote_no=row["quoteNo"],
- product_uri=row["product"],
- product_code=row.get("productCode", ""),
- product_name=row.get("productName", ""),
- brand_name=row.get("brandName", ""),
- transport_mode=row.get("transportMode", ""),
- offering_uri=row["offering"],
- offering_code=row.get("offeringCode", ""),
- supplier_uri=row["supplier"],
- supplier_name=row["supplierName"],
- schedule_uri=row.get("schedule", ""),
- schedule_code=row.get("scheduleCode", ""),
- route_type=row.get("routeType", ""),
- service_form=row.get("serviceForm", ""),
- origin=row.get("originLocation", ""),
- destination=row.get("destinationLocation", ""),
- currency=row["currency"],
- supplier_cost=_as_float(row.get("supplierCostAmount")),
- valid_from=_as_date(row.get("validFrom")),
- valid_to=_as_date(row.get("validTo")),
- departure_date=_as_date(row.get("estimatedDepartureTime")),
- arrival_date=_as_date(row.get("estimatedArrivalTime")),
- transit_days=_as_float(row.get("estimatedTransitDays")),
- booking_success_rate=_as_float(row.get("bookingSuccessRate"), 0.8),
- space_release_success_rate=_as_float(
- row.get("spaceReleaseSuccessRate"), 0.8
- ),
- etd_on_time_rate=_as_float(row.get("etdAtdOnTimeRate"), 0.85),
- eta_on_time_rate=_as_float(row.get("etaAtaOnTimeRate"), 0.85),
- supplier_active=_as_bool(row.get("supplierActive"), True),
- capacity_available=_as_bool(row.get("capacityAvailable"), True),
- supports_dangerous_goods=_as_bool(
- row.get("supportsDangerousGoods"), False
- ),
- dangerous_goods_only=_as_bool(row.get("dangerousGoodsOnly"), False),
- agency_level=row.get("agencyLevel", ""),
- offering_channel=row.get("offeringChannel", ""),
- capacity_commitment=row.get("capacityCommitment", ""),
- )
- for row in rows
- ]
- def _find_customer(self, customer_id: str) -> dict:
- rows = self.repository.query_file(
- self.root / "queries" / "market-customers.rq"
- )["rows"]
- for row in rows:
- if row["customer"] == customer_id or row["customerName"] == customer_id:
- return row
- raise ValueError("客户不存在或没有可用画像")
- @staticmethod
- def _customer_signals(row: dict) -> CustomerSignals:
- service_requirement = ";".join(
- value
- for value in (
- row.get("notificationRequirement", ""),
- row.get("documentRequirement", ""),
- row.get("exceptionResponseRequirement", ""),
- )
- if value
- )
- return CustomerSignals(
- price_sensitivity=_as_float(row.get("customerPriceSensitivity"), 0.5),
- time_sensitivity=_as_float(row.get("customerTimeSensitivity"), 0.5),
- reliability_sensitivity=_as_float(
- row.get("customerReliabilitySensitivity"), 0.5
- ),
- repeat_purchase_count=_as_int(row.get("repeatPurchaseCount")),
- overdue_days=_as_int(row.get("overdueDays")),
- service_requirement=service_requirement,
- )
- def _component_costs(
- self, evaluation_date: date, origin: str
- ) -> tuple[float, list[str]]:
- candidates = self._candidate_records()
- total = 0.0
- names: list[str] = []
- product_codes = self.config["component_product_codes_by_origin"].get(
- origin,
- self.config["component_product_codes_by_origin"].get("__default__", []),
- )
- for product_code in product_codes:
- valid = [
- item
- for item in candidates
- if item.product_code == product_code
- and (not item.valid_from or item.valid_from <= evaluation_date)
- and (not item.valid_to or evaluation_date <= item.valid_to)
- ]
- if valid:
- selected = min(valid, key=lambda item: item.supplier_cost)
- total += selected.supplier_cost
- names.append(selected.product_name)
- return total, names
- def _market_context(self, inquiry: InquiryRequest) -> tuple[dict, dict]:
- rows = self.repository.query_file(
- self.root / "queries" / "market-context.rq"
- )["rows"]
- applicable = [
- row
- for row in rows
- if row.get("origin") == inquiry.origin
- and row.get("destination") == inquiry.destination
- and _within(inquiry.evaluation_date, row.get("validFrom"), row.get("validTo"))
- ]
- benchmark_row = next((row for row in applicable if row["kind"] == "benchmark"), None)
- strategy_row = next((row for row in applicable if row["kind"] == "strategy"), None)
- if not benchmark_row or not strategy_row:
- raise ValueError("当前路线缺少有效的市场基准价格或启用策略")
- return (
- {
- "uri": benchmark_row["resource"],
- "amount": _as_float(benchmark_row["amount"]),
- "currency": benchmark_row["currency"],
- },
- {
- "uri": strategy_row["resource"],
- "name": strategy_row["strategyName"],
- "objective": strategy_row["strategyObjective"],
- "adjustment_rate": _as_float(strategy_row["strategyAdjustmentRate"]),
- },
- )
- @staticmethod
- def _candidate_payload(
- candidate: CandidateRecord,
- scores: dict,
- prices: dict,
- component_names: list[str],
- ) -> dict:
- reliability = round(
- (candidate.booking_success_rate + candidate.space_release_success_rate) / 2,
- 2,
- )
- reasons = [
- f"供应商订舱与放舱综合成功率{reliability:.0%}",
- f"预计运输时间{candidate.transit_days:g}天",
- f"{candidate.route_type or '当前'}路由,{candidate.capacity_commitment or '运力已确认'}",
- ]
- if scores["solution_type"] == "经济方案":
- reasons.insert(0, "当前可执行候选中采购价格最有竞争力")
- elif scores["solution_type"] == "时效方案":
- reasons.insert(0, "当前可执行候选中预计运输时间最短")
- else:
- reasons.insert(0, "客户偏好、供应商可靠性与价格综合表现最均衡")
- return {
- "id": candidate.quote_uri,
- "quote_no": candidate.quote_no,
- "solution_type": scores["solution_type"],
- "rank": scores["rank"],
- "brand_name": candidate.brand_name,
- "product_name": candidate.product_name,
- "supplier_name": candidate.supplier_name,
- "offering_channel": candidate.offering_channel,
- "schedule_code": candidate.schedule_code,
- "departure_date": candidate.departure_date.isoformat() if candidate.departure_date else "",
- "arrival_date": candidate.arrival_date.isoformat() if candidate.arrival_date else "",
- "transit_days": candidate.transit_days,
- "route_type": candidate.route_type,
- "capacity_commitment": candidate.capacity_commitment,
- "components": [*component_names, candidate.product_name],
- "reliability_rate": reliability,
- "price_score": scores["price_score"],
- "transit_score": scores["transit_score"],
- "supplier_execution_score": scores["supplier_execution_score"],
- "customer_fit_score": scores["customer_fit_score"],
- "product_strategy_score": scores["product_strategy_score"],
- "total_score": scores["total_score"],
- "confidence_score": None,
- "reasons": reasons,
- "pricing": prices,
- }
- @staticmethod
- def _rejected_payload(candidate: CandidateRecord, reasons: list[str]) -> dict:
- return {
- "id": candidate.quote_uri,
- "quote_no": candidate.quote_no,
- "product_name": candidate.product_name,
- "supplier_name": candidate.supplier_name,
- "schedule_code": candidate.schedule_code,
- "reasons": reasons,
- }
- @staticmethod
- def _recommendation_summary(
- candidate: dict, strategy: dict, customer_row: dict
- ) -> str:
- return (
- f"为{customer_row['customerName']}推荐{candidate['solution_type']}:"
- f"{candidate['supplier_name']}的{candidate['product_name']}。"
- f"综合得分{candidate['total_score']:.1f},采用“{strategy['name']}”,"
- f"在时效、履约可靠性和客户价格接受度之间取得当前最优平衡。"
- )
- def _store_run(
- self, run_id: str, result: dict, inquiry: InquiryRequest, strategy: dict
- ) -> None:
- run_uri = RUN[run_id]
- generated_at = Literal(result["generated_at"], datatype=XSD.dateTime)
- with self._lock:
- self.runs[run_id] = result
- self.audit_graph.add((run_uri, RDF.type, GESLI.RecommendationRun))
- self.audit_graph.add((run_uri, GESLI.generatedAt, generated_at))
- self.audit_graph.add((run_uri, GESLI.usesMarketStrategy, URIRef(strategy["uri"])))
- self.audit_graph.add((run_uri, GESLI.recommendationReason, Literal(result["recommendation"]["summary"])))
- customer_uri = URIRef(inquiry.customer)
- self.audit_graph.add((run_uri, GESLI.quoteForCustomer, customer_uri))
- for candidate in result["candidates"]:
- evaluation_uri = RUN[f"{run_id}/candidate/{candidate['rank']}"]
- self.audit_graph.add((evaluation_uri, RDF.type, GESLI.CandidateEvaluation))
- self.audit_graph.add((run_uri, GESLI.hasCandidateEvaluation, evaluation_uri))
- self.audit_graph.add((evaluation_uri, GESLI.evaluatedQuote, URIRef(candidate["id"])))
- self.audit_graph.add((evaluation_uri, GESLI.recommendationRank, Literal(candidate["rank"])))
- self.audit_graph.add((evaluation_uri, GESLI.totalScore, Literal(candidate["total_score"])))
- top_evaluation = RUN[f"{run_id}/candidate/1"]
- self.audit_graph.add((run_uri, GESLI.recommendedEvaluation, top_evaluation))
- 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_int(value: str | None, default: int = 0) -> int:
- try:
- return int(value) if value not in (None, "") else default
- except (TypeError, ValueError):
- return default
- def _as_bool(value: str | None, default: bool = False) -> bool:
- if value in (None, ""):
- return default
- return str(value).lower() in {"true", "1", "yes"}
- 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)
|