This commit is contained in:
HuangHai
2026-01-19 13:56:47 +08:00
parent 219cd5c220
commit b20b778f5d
10 changed files with 242 additions and 132 deletions

View File

@@ -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"

View File

@@ -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

View File

@@ -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 = """

View File

@@ -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]):

Binary file not shown.

View File

@@ -2,64 +2,56 @@
<html>
<head>
<meta charset="utf-8">
<title>驿来特价格分析大屏</title>
<title>多供应商分时电价分析大屏</title>
<link rel="stylesheet" href="css/app.css">
</head>
<body>
<div id="app">
<div class="left-panel">
<div class="controls">
<span class="label">搜索驿来特场站</span>
<input v-model="searchKeyword" placeholder="输入名称或地址关键字" style="min-width:220px;">
<button @click="loadStations">查询场站</button>
<span class="label">选择供应商</span>
<select v-model="selectedOperator" @change="loadOperatorPrices">
<option v-for="op in operators" :key="op.value" :value="op.value">{{ op.label }}</option>
</select>
<button @click="loadAllOperatorsPrices" :disabled="loading">查询四家最新24小时电价</button>
<div class="controls-spacer"></div>
<span class="label">当前场站:{{ selectedStation ? selectedStation.station_name : "未选择" }}</span>
<button @click="startAiStream" :disabled="!selectedStation || aiLoading">AI价差分析</button>
<button @click="exportAllPrices" :disabled="exporting">一键导出各供应商电价</button>
</div>
<div class="station-list">
<div class="station-list-header">驿来特场站列表</div>
<div class="station-list-header">四家供应商24小时平均电价对比</div>
<table class="station-table">
<thead>
<tr>
<th style="width:32%;">场站名称</th>
<th style="width:48%;">地址</th>
<th style="width:10%;">当前价</th>
<th style="width:10%;">操作</th>
<th style="width:20%;">小时</th>
<th v-for="op in operators" :key="op.value" style="width:20%;">{{ op.label }}</th>
</tr>
</thead>
<tbody>
<tr v-for="s in stations" :key="s.station_hash" class="station-row" :class="{active:selectedStation && selectedStation.station_hash===s.station_hash}" @click="selectStation(s)">
<td>{{ s.station_name }}</td>
<td>{{ s.address }}</td>
<td>{{ formatCell(s.current_price) }}</td>
<td><button @click.stop="selectStation(s)" :disabled="loading">查看价差</button></td>
<tr v-for="(row, idx) in priceTableRows" :key="idx">
<td>{{ row.hour }}</td>
<td v-for="cell in row.values" :key="cell.operator">{{ formatCell(cell.price) }}</td>
</tr>
<tr v-if="stations.length===0">
<td colspan="4">暂无数据,请先查询场站</td>
<tr v-if="priceTableRows.length===0">
<td :colspan="operators.length + 1">暂无数据,请先查询</td>
</tr>
</tbody>
</table>
</div>
<div id="chart"></div>
<div class="price-table" v-if="priceTableColumns.length">
<div class="price-table-title">24 小时分时电价表(元/度)</div>
<table class="price-table-inner">
<thead>
<tr>
<th v-for="col in priceTableColumns" :key="col">{{ col }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(row,idx) in priceTableRows" :key="idx">
<td v-for="(cell,j) in row" :key="j">{{ formatCell(cell) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="right-panel">
<div class="ai-title">AI 辅助分析</div>
<div class="ai-box">{{ aiText || placeholder }}</div>
<div class="ai-box">
<div class="ai-question">
<div class="label">默认问题</div>
<div class="question-text">
请根据爬取的各供应商分时电价等信息,对各司的定价策略,
与我司(驿来特)的定价策略进行综合对比,分析我司可能存在的潜在问题。
</div>
</div>
<button @click="startAiAnalysis" :disabled="aiLoading">发起AI综合分析</button>
<div class="ai-result">{{ aiText || placeholder }}</div>
</div>
</div>
</div>
<script src="js/vue.global.js"></script>

View File

@@ -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")