diff --git a/Apps/AiTeJiYiChong/FirstPageKit.py b/Apps/AiTeJiYiChong/FirstPageKit.py index 59d39ff..834a28b 100644 --- a/Apps/AiTeJiYiChong/FirstPageKit.py +++ b/Apps/AiTeJiYiChong/FirstPageKit.py @@ -57,6 +57,11 @@ LLM_NON_STATION_NAME_KEYWORDS = [ "收费停车", "限时免费停车", "停车费", + "免费停车", + "免停车费", + "停车优惠", + "停车三小时", + "免费停车三小时", "END", "End", "end", @@ -192,6 +197,7 @@ async def run_ocr_rect(image_path, log_path=None): "- 该距离文本所在行的左侧、且在同一卡片中的那一行文字,就是对应的场站标题 station_name;\n" "- 忙闲信息通常出现在卡片右侧的彩色小块中,例如 \"快 3/4\"、\"慢 0/0\"、\"超 1/4\";\n" "- 即使 station_name 中不包含“充电站”“超快充”等字样,但只要与某个 \"x.xkm\" 行在同一卡片区域内,也应视为一个完整的场站名称。\n" + "- 包含“免费停车”“免停车费”“停车优惠”等停车收费文案的文本不允许作为 station_name,如果模型误把这类文本当作场站名称,应当直接忽略该卡片或把这些文本视为停车信息而不是名称。\n" "要求:\n" "- 场站按从上到下排序;\n" "- station_name 不能取距离行本身(如 \"1.4km\"),而是要取与之成一对的标题行;\n" diff --git a/Apps/AiTeJiYiChong/__pycache__/FirstPageKit.cpython-310.pyc b/Apps/AiTeJiYiChong/__pycache__/FirstPageKit.cpython-310.pyc index 752dfda..d41cef2 100644 Binary files a/Apps/AiTeJiYiChong/__pycache__/FirstPageKit.cpython-310.pyc and b/Apps/AiTeJiYiChong/__pycache__/FirstPageKit.cpython-310.pyc differ diff --git a/Config/Config.py b/Config/Config.py index c9a91dd..674d9e7 100644 --- a/Config/Config.py +++ b/Config/Config.py @@ -1,16 +1,16 @@ # 黄海在公司内网开发时的配置信息 -# DORIS_HOST = "10.10.14.204" -# DORIS_PORT = 9030 -# DORIS_FENODES = "10.10.14.204:8030" -# REDIS_HOST = '10.10.14.14' -# REDIS_PASSWORD = None # 如果没有密码则设为 None +DORIS_HOST = "10.10.14.204" +DORIS_PORT = 9030 +DORIS_FENODES = "10.10.14.204:8030" +REDIS_HOST = '10.10.14.14' +REDIS_PASSWORD = None # 如果没有密码则设为 None # 黄海在家开发时的配置信息 -DORIS_HOST = "www.hzkjai.com" -DORIS_PORT = 27025 -DORIS_FENODES = "www.hzkjai.com:27024" -REDIS_HOST = '127.0.0.1' -REDIS_PASSWORD = "DsideaL147258369" +# DORIS_HOST = "www.hzkjai.com" +# DORIS_PORT = 27025 +# DORIS_FENODES = "www.hzkjai.com:27024" +# REDIS_HOST = '127.0.0.1' +# REDIS_PASSWORD = "DsideaL147258369" # 视觉模型配置 VL_MODEL_NAME = "qwen3-vl-flash" @@ -53,7 +53,7 @@ REDIS_MAX_CONNECTIONS = 200 # 临时图片存储路径 TEMP_IMAGE_DIR = r"d:\dsWork\aiData\Output" -# 分时价格全局配置 -# False: 默认按各个供应商自己的 PRICE_FLATTEN_TO_24H 决定 -# True: 强制所有供应商都铺平成 24 小时整点数组 -PRICE_FLATTEN_TO_24H_GLOBAL = True +# 分时价格全局配置 +# False: 默认按各个供应商自己的 PRICE_FLATTEN_TO_24H 决定 +# True: 强制所有供应商都铺平成 24 小时整点数组 +PRICE_FLATTEN_TO_24H_GLOBAL = True diff --git a/Config/__pycache__/Config.cpython-310.pyc b/Config/__pycache__/Config.cpython-310.pyc index 9e60031..f935182 100644 Binary files a/Config/__pycache__/Config.cpython-310.pyc and b/Config/__pycache__/Config.cpython-310.pyc differ diff --git a/Controller/YltAnalyticsController.py b/Controller/YltAnalyticsController.py index fa091ca..d205488 100644 --- a/Controller/YltAnalyticsController.py +++ b/Controller/YltAnalyticsController.py @@ -4,11 +4,15 @@ import json from typing import List, Optional, Dict, Any from fastapi import APIRouter, HTTPException -from fastapi.responses import StreamingResponse +from fastapi.responses import StreamingResponse, FileResponse, JSONResponse from DbKit.Db import Db from Config.Config import DB_URL from Util.LlmUtil import get_llm_response +from Tools.T6_Export import export_excel, DorisExcelExporter, extract_hourly_prices_from_schedule +import tempfile +import os +import zipfile from Model.YltAnalyticsModel import ( StationBase, CompetitorStation, @@ -33,6 +37,89 @@ async def close_db(): await db.close() +@router.get("/api/operators/hourly-prices") +async def get_operators_hourly_prices(): + operators = ["新电途", "特来电", "驿来特", "艾特吉易充"] + exporter = DorisExcelExporter(db_url=DB_URL) + await exporter.init() + try: + result = [] + for op in operators: + rows = await exporter.fetch_current_station_rows(op) + if not rows: + result.append({"operator": op, "series": [None] * 24}) + continue + sums = [0.0] * 24 + counts = [0] * 24 + for row in rows: + schedule_json = row.get("schedule_json") + series = extract_hourly_prices_from_schedule(schedule_json) + for i in range(24): + v = series[i] + if v is None: + continue + sums[i] += float(v) + counts[i] += 1 + avg_series = [] + for i in range(24): + c = counts[i] + if c > 0: + avg_series.append(sums[i] / c) + else: + avg_series.append(None) + result.append({"operator": op, "series": avg_series}) + finally: + await exporter.close() + return {"operators": result} + + +@router.get("/api/export/prices-zip") +async def export_prices_zip(): + operators = ["新电途", "特来电", "驿来特", "艾特吉易充"] + tmp_dir = tempfile.mkdtemp(prefix="price_export_") + excel_paths = [] + for op in operators: + filename = f"{op}_{asyncio.get_event_loop().time():.0f}.xlsx" + output_path = os.path.join(tmp_dir, filename) + await export_excel(op, output_path) + excel_paths.append(output_path) + zip_path = os.path.join(tmp_dir, "prices_export.zip") + with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf: + for p in excel_paths: + arcname = os.path.basename(p) + zf.write(p, arcname=arcname) + return FileResponse( + zip_path, + media_type="application/zip", + filename="多供应商电价导出.zip", + ) + + +@router.get("/api/ai/pricing/strategy-summary") +async def ai_pricing_strategy_summary(): + resp = await get_operators_hourly_prices() + data = resp.get("operators", []) + text_data = [] + for item in data: + text_data.append({"operator": item.get("operator"), "series": item.get("series")}) + text = ( + "下面是四家供应商(新电途、特来电、驿来特、艾特吉易充)基于最新爬取数据计算出的平均24小时分时电价:\n" + f"{text_data}\n" + "请根据这些数据,综合分析各司的定价策略差异,重点对比我司(驿来特)与其他供应商的分时电价水平," + "指出我司在不同时段可能存在的潜在问题和风险(例如明显偏贵、价格结构不合理等),并给出2-3条可执行的优化建议。" + "回答控制在600字以内。" + ) + chunks: List[str] = [] + async for chunk in get_llm_response( + text, + stream=False, + system_prompt="你是熟悉中国充电桩行业的电价策略分析顾问。", + ): + chunks.append(chunk) + summary_text = "".join(chunks) + return JSONResponse({"summary": summary_text}) + + @router.get("/api/ylt/stations", response_model=List[StationBase]) async def list_ylt_stations(q: Optional[str] = None): base_sql = """ diff --git a/Controller/__pycache__/YltAnalyticsController.cpython-310.pyc b/Controller/__pycache__/YltAnalyticsController.cpython-310.pyc index 2e09b33..5bfd980 100644 Binary files a/Controller/__pycache__/YltAnalyticsController.cpython-310.pyc and b/Controller/__pycache__/YltAnalyticsController.cpython-310.pyc differ diff --git a/Tools/T6_Export.py b/Tools/T6_Export.py index f04c41c..e05d03a 100644 --- a/Tools/T6_Export.py +++ b/Tools/T6_Export.py @@ -174,7 +174,7 @@ def _set_column_widths(ws, widths: dict[int, float]): ws.column_dimensions[get_column_letter(col_idx)].width = width -class DorisExcelExporter: +class DorisExcelExporter: def __init__(self, db_url: str): self.db = Db(db_url=db_url) @@ -188,7 +188,7 @@ class DorisExcelExporter: if getattr(self.db, "engine", None): await self.db.engine.dispose() - async def fetch_current_station_rows(self, operator: str) -> List[Dict[str, Any]]: + async def fetch_current_station_rows(self, operator: str) -> List[Dict[str, Any]]: sql = """ SELECT p.station_hash, @@ -214,7 +214,32 @@ class DorisExcelExporter: WHERE p.is_current = 1 AND p.operator = :operator ORDER BY p.station_name """ - return await self.db.find(sql, {"operator": operator}) + return await self.db.find(sql, {"operator": operator}) + + +def extract_hourly_prices_from_schedule(schedule_json: Any) -> List[Optional[float]]: + items = _parse_schedule(schedule_json) + series: List[Optional[float]] = [None] * 24 + if not items: + return series + for idx, item in enumerate(items): + if not isinstance(item, dict): + continue + price = _to_float(item.get("price_kwh") if "price_kwh" in item else item.get("price")) + if price is None: + continue + start = item.get("start") or item.get("begin") or item.get("start_time") + h = None + if isinstance(start, str) and ":" in start: + try: + h = int(start.split(":", 1)[0]) + except Exception: + h = None + if h is None: + h = idx + if 0 <= h < 24: + series[h] = float(price) + return series def _build_station_sheet(ws, row: Dict[str, Any]): diff --git a/Tools/__pycache__/T6_Export.cpython-310.pyc b/Tools/__pycache__/T6_Export.cpython-310.pyc new file mode 100644 index 0000000..65261b3 Binary files /dev/null and b/Tools/__pycache__/T6_Export.cpython-310.pyc differ diff --git a/static/index.html b/static/index.html index 1d4931d..056334f 100644 --- a/static/index.html +++ b/static/index.html @@ -2,64 +2,56 @@ -驿来特价格分析大屏 +多供应商分时电价分析大屏
-搜索驿来特场站 - - +选择供应商 + +
-当前场站:{{ selectedStation ? selectedStation.station_name : "未选择" }} - +
-
驿来特场站列表
+
四家供应商24小时平均电价对比
- - - - + + - - - - - + + + - - + +
场站名称地址当前价操作小时{{ op.label }}
{{ s.station_name }}{{ s.address }}{{ formatCell(s.current_price) }}
{{ row.hour }}{{ formatCell(cell.price) }}
暂无数据,请先查询场站
暂无数据,请先查询
-
-
24 小时分时电价表(元/度)
- - - - - - - - - - - -
{{ col }}
{{ formatCell(cell) }}
-
AI 辅助分析
-
{{ aiText || placeholder }}
+
+
+
默认问题
+
+请根据爬取的各供应商分时电价等信息,对各司的定价策略, +与我司(驿来特)的定价策略进行综合对比,分析我司可能存在的潜在问题。 +
+
+ +
{{ aiText || placeholder }}
+
diff --git a/static/js/app.js b/static/js/app.js index efe6c03..13d0135 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -1,128 +1,127 @@ const {createApp,onMounted,ref} = Vue + createApp({ setup(){ const apiBase = ref(window.location.origin || "http://localhost:8000") -const stationHash = ref("") +const operators = ref([ +{label:"新电途",value:"新电途"}, +{label:"特来电",value:"特来电"}, +{label:"驿来特",value:"驿来特"}, +{label:"艾特吉易充",value:"艾特吉易充"} +]) +const selectedOperator = ref("驿来特") const aiText = ref("") -const placeholder = ref("在左侧列表中选择驿来特场站,点击“查看价差”加载24小时分时电价与竞品对比,再点击“AI价差分析”获取模型给出的策略建议。") +const placeholder = ref("点击左侧按钮查询四家供应商最新24小时分时电价,再使用右侧AI综合分析功能对各司定价策略进行对比。") const loading = ref(false) const aiLoading = ref(false) -const stations = ref([]) -const searchKeyword = ref("") -const selectedStation = ref(null) -const priceTableColumns = ref([]) const priceTableRows = ref([]) +const hourlyPricesByOperator = ref({}) let chartInstance = null -let es = null + const initChart = () => { const dom = document.getElementById("chart") if (dom && !chartInstance) { chartInstance = echarts.init(dom) } } -const loadStations = async () => { -try{ -const url = apiBase.value + "/api/ylt/stations" -const params = searchKeyword.value ? {params:{q:searchKeyword.value}} : {} -const res = await axios.get(url,params) -stations.value = Array.isArray(res.data) ? res.data : [] -if (!selectedStation.value && stations.value.length > 0) { -selectStation(stations.value[0]) -} -}catch(e){ -console.error(e) -} -} -const buildPriceTable = data => { -const cols = [] -cols.push("时段") -cols.push("驿来特") -data.competitors.forEach(c => { -cols.push(c.operator) -}) -priceTableColumns.value = cols + +const buildPriceTable = () => { const rows = [] -for (let i = 0; i < data.hours.length; i++) { -const row = [] -row.push(data.hours[i] + ":00") -row.push(data.ylt.series[i]) -data.competitors.forEach(c => { -row.push(c.series[i]) +for (let h = 0; h < 24; h++) { +const row = {hour: (h.toString().padStart(2,"0") + ":00"), values: []} +operators.value.forEach(op => { +const series = hourlyPricesByOperator.value[op.value] || [] +const price = series[h] !== undefined ? series[h] : null +row.values.push({operator: op.value, price}) }) rows.push(row) } priceTableRows.value = rows +if (chartInstance) { +const series = operators.value.map(op => { +const seriesData = [] +for (let h = 0; h < 24; h++) { +const list = hourlyPricesByOperator.value[op.value] || [] +seriesData.push(list[h] !== undefined ? list[h] : null) } -const loadComparison = async () => { -if (!stationHash.value) return -loading.value = true -try{ -const url = apiBase.value + "/api/ylt/pricing/comparison/" + stationHash.value -const res = await axios.get(url) -const data = res.data -const hours = data.hours.map(h => h + ":00") -const series = [] -series.push({ -name:"驿来特", -type:"line", -smooth:true, -data:data.ylt.series +return {name: op.label, type:"line", smooth:true, data:seriesData} }) -data.competitors.forEach(c=>{ -series.push({ -name:c.operator, -type:"line", -smooth:true, -data:c.series -}) -}) -buildPriceTable(data) +const hours = rows.map(r => r.hour) const option = { tooltip:{trigger:"axis"}, -legend:{data:series.map(s=>s.name),textStyle:{color:"#e5e7eb"}}, +legend:{data:operators.value.map(o=>o.label),textStyle:{color:"#e5e7eb"}}, xAxis:{type:"category",data:hours,axisLine:{lineStyle:{color:"#4b5563"}},axisLabel:{color:"#9ca3af"}}, yAxis:{type:"value",name:"元/度",axisLine:{lineStyle:{color:"#4b5563"}},axisLabel:{color:"#9ca3af"},splitLine:{lineStyle:{color:"#111827"}}}, grid:{left:40,right:20,top:30,bottom:30}, series:series } -initChart() chartInstance.setOption(option) +} +} + +const loadAllOperatorsPrices = async () => { +loading.value = true +try{ +const res = await axios.get(apiBase.value + "/api/operators/hourly-prices") +if (res && res.data && Array.isArray(res.data.operators)) { +const dict = {} +res.data.operators.forEach(item => { +dict[item.operator] = item.series || [] +}) +hourlyPricesByOperator.value = dict +buildPriceTable() +} }catch(e){ console.error(e) }finally{ loading.value = false } } -const selectStation = s => { -selectedStation.value = s -stationHash.value = s.station_hash -loadComparison() + +const loadOperatorPrices = async () => { +if (!selectedOperator.value) return +await loadAllOperatorsPrices() } -const startAiStream = () => { -if (!selectedStation.value || !stationHash.value) return -if (es) { -es.close() -es = null + +const exportAllPrices = async () => { +try{ +loading.value = true +const res = await axios.get(apiBase.value + "/api/export/prices-zip",{responseType:"blob"}) +const blob = new Blob([res.data],{type:"application/zip"}) +const url = window.URL.createObjectURL(blob) +const a = document.createElement("a") +a.href = url +a.download = "多供应商电价导出.zip" +document.body.appendChild(a) +a.click() +document.body.removeChild(a) +window.URL.revokeObjectURL(url) +}catch(e){ +console.error(e) +}finally{ +loading.value = false } +} + +const startAiAnalysis = async () => { +if (aiLoading.value) return aiText.value = "" aiLoading.value = true -const url = apiBase.value + "/api/ylt/pricing/comparison/" + stationHash.value + "/sse" -es = new EventSource(url) -es.onmessage = ev => { -aiText.value += ev.data -} -es.onerror = () => { +try{ +const res = await axios.get(apiBase.value + "/api/ai/pricing/strategy-summary") +aiText.value = res.data && res.data.summary ? res.data.summary : "" +}catch(e){ +console.error(e) +}finally{ aiLoading.value = false -if (es) { -es.close() -es = null -} } } + onMounted(()=>{ initChart() -loadStations() +loadAllOperatorsPrices() }) + const formatCell = v => { if (v === null || v === undefined || v === "") return "-" if (typeof v === "number") { @@ -131,6 +130,7 @@ return v.toFixed(2) } return v } -return {stationHash,aiText,placeholder,loading,aiLoading,stations,searchKeyword,selectedStation,priceTableColumns,priceTableRows,loadStations,loadComparison,selectStation,startAiStream,formatCell} + +return {apiBase,operators,selectedOperator,aiText,placeholder,loading,aiLoading,priceTableRows,loadOperatorPrices,loadAllOperatorsPrices,exportAllPrices,startAiAnalysis,formatCell} } }).mount("#app")