feat: 添加 AI 补货建议功能,优化相关设置和界面
This commit is contained in:
32
README.md
32
README.md
@@ -61,6 +61,28 @@ python app.py
|
|||||||
|
|
||||||
默认访问:`http://127.0.0.1:5000`
|
默认访问:`http://127.0.0.1:5000`
|
||||||
|
|
||||||
|
### 2.4 可选:启用 AI 补货建议(硅基流动)
|
||||||
|
|
||||||
|
在启动前设置环境变量:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:SILICONFLOW_API_KEY="你的APIKey"
|
||||||
|
$env:SILICONFLOW_MODEL="Qwen/Qwen2.5-7B-Instruct"
|
||||||
|
```
|
||||||
|
|
||||||
|
可选变量:
|
||||||
|
|
||||||
|
- `SILICONFLOW_API_URL`(默认:`https://api.siliconflow.cn/v1/chat/completions`)
|
||||||
|
- `SILICONFLOW_TIMEOUT`(默认:`30` 秒)
|
||||||
|
|
||||||
|
仓库概览页点击 `AI补货建议` 按钮即可调用接口。
|
||||||
|
|
||||||
|
也可在页面中直接配置参数:
|
||||||
|
|
||||||
|
- 入口:`仓库概览` -> `AI参数`
|
||||||
|
- 页面:`/ai/settings`
|
||||||
|
- 保存文件:`data/ai_settings.json`
|
||||||
|
|
||||||
## 3. 页面说明
|
## 3. 页面说明
|
||||||
|
|
||||||
### 3.1 首页 `/`
|
### 3.1 首页 `/`
|
||||||
@@ -110,6 +132,16 @@ python app.py
|
|||||||
- 搜索结果可一键跳转到对应盒位编辑页。
|
- 搜索结果可一键跳转到对应盒位编辑页。
|
||||||
- 支持快速出库:只填写数量即可扣减库存,并写入统计日志。
|
- 支持快速出库:只填写数量即可扣减库存,并写入统计日志。
|
||||||
|
|
||||||
|
### 3.6 AI 补货建议 `/ai/restock-plan`
|
||||||
|
|
||||||
|
- 基于低库存清单和最近 30 天出库数据生成补货建议。
|
||||||
|
- 未配置 `SILICONFLOW_API_KEY` 时会返回明确错误提示。
|
||||||
|
|
||||||
|
### 3.7 AI 参数设置 `/ai/settings`
|
||||||
|
|
||||||
|
- 支持页面内编辑:`API URL / 模型名称 / API Key / 超时 / 低库存阈值 / 建议条目上限`。
|
||||||
|
- 保存后立即生效,无需改代码。
|
||||||
|
|
||||||
## 4. 袋装批量新增格式
|
## 4. 袋装批量新增格式
|
||||||
|
|
||||||
在袋装清单页面的批量输入框里,每行一条,可用英文逗号或 Tab 分隔:
|
在袋装清单页面的批量输入框里,每行一条,可用英文逗号或 Tab 分隔:
|
||||||
|
|||||||
384
app.py
384
app.py
@@ -2,6 +2,8 @@ import os
|
|||||||
import re
|
import re
|
||||||
import csv
|
import csv
|
||||||
import json
|
import json
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from io import StringIO
|
from io import StringIO
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
@@ -22,6 +24,21 @@ db = SQLAlchemy(app)
|
|||||||
|
|
||||||
LOW_STOCK_THRESHOLD = 5
|
LOW_STOCK_THRESHOLD = 5
|
||||||
BOX_TYPES_OVERRIDE_PATH = os.path.join(DB_DIR, "box_types.json")
|
BOX_TYPES_OVERRIDE_PATH = os.path.join(DB_DIR, "box_types.json")
|
||||||
|
AI_SETTINGS_PATH = os.path.join(DB_DIR, "ai_settings.json")
|
||||||
|
AI_SETTINGS_DEFAULT = {
|
||||||
|
"api_url": os.environ.get(
|
||||||
|
"SILICONFLOW_API_URL",
|
||||||
|
"https://api.siliconflow.cn/v1/chat/completions",
|
||||||
|
),
|
||||||
|
"model": os.environ.get(
|
||||||
|
"SILICONFLOW_MODEL",
|
||||||
|
"Qwen/Qwen2.5-7B-Instruct",
|
||||||
|
),
|
||||||
|
"api_key": os.environ.get("SILICONFLOW_API_KEY", ""),
|
||||||
|
"timeout": int(os.environ.get("SILICONFLOW_TIMEOUT", "30") or "30"),
|
||||||
|
"restock_threshold": LOW_STOCK_THRESHOLD,
|
||||||
|
"restock_limit": 24,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_BOX_TYPES = {
|
DEFAULT_BOX_TYPES = {
|
||||||
@@ -98,6 +115,56 @@ def _save_box_type_overrides() -> None:
|
|||||||
_apply_box_type_overrides()
|
_apply_box_type_overrides()
|
||||||
|
|
||||||
|
|
||||||
|
def _load_ai_settings() -> dict:
|
||||||
|
settings = dict(AI_SETTINGS_DEFAULT)
|
||||||
|
if not os.path.exists(AI_SETTINGS_PATH):
|
||||||
|
return settings
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(AI_SETTINGS_PATH, "r", encoding="utf-8") as f:
|
||||||
|
saved = json.load(f)
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return settings
|
||||||
|
|
||||||
|
if not isinstance(saved, dict):
|
||||||
|
return settings
|
||||||
|
|
||||||
|
for key in settings.keys():
|
||||||
|
if key in saved:
|
||||||
|
settings[key] = saved[key]
|
||||||
|
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
def _save_ai_settings(settings: dict) -> None:
|
||||||
|
with open(AI_SETTINGS_PATH, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(settings, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_ai_settings() -> dict:
|
||||||
|
settings = _load_ai_settings()
|
||||||
|
|
||||||
|
try:
|
||||||
|
settings["timeout"] = max(5, int(settings.get("timeout", 30)))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
settings["timeout"] = 30
|
||||||
|
|
||||||
|
try:
|
||||||
|
settings["restock_threshold"] = max(0, int(settings.get("restock_threshold", LOW_STOCK_THRESHOLD)))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
settings["restock_threshold"] = LOW_STOCK_THRESHOLD
|
||||||
|
|
||||||
|
try:
|
||||||
|
settings["restock_limit"] = max(1, int(settings.get("restock_limit", 24)))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
settings["restock_limit"] = 24
|
||||||
|
|
||||||
|
settings["api_url"] = (settings.get("api_url") or "").strip()
|
||||||
|
settings["model"] = (settings.get("model") or "").strip()
|
||||||
|
settings["api_key"] = (settings.get("api_key") or "").strip()
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
class Box(db.Model):
|
class Box(db.Model):
|
||||||
__tablename__ = "boxes"
|
__tablename__ = "boxes"
|
||||||
|
|
||||||
@@ -687,6 +754,107 @@ def build_dashboard_context():
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_restock_payload(*, limit: int = 20, threshold: int = LOW_STOCK_THRESHOLD) -> dict:
|
||||||
|
boxes = Box.query.all()
|
||||||
|
box_by_id = {box.id: box for box in boxes}
|
||||||
|
enabled_components = Component.query.filter_by(is_enabled=True).all()
|
||||||
|
low_stock_components = [c for c in enabled_components if int(c.quantity or 0) < threshold]
|
||||||
|
|
||||||
|
low_items = []
|
||||||
|
for c in sorted(low_stock_components, key=lambda item: (int(item.quantity or 0), item.name or ""))[:limit]:
|
||||||
|
box = box_by_id.get(c.box_id)
|
||||||
|
box_type = box.box_type if box and box.box_type in BOX_TYPES else "small_28"
|
||||||
|
low_items.append(
|
||||||
|
{
|
||||||
|
"part_no": c.part_no,
|
||||||
|
"name": c.name,
|
||||||
|
"quantity": int(c.quantity or 0),
|
||||||
|
"box_type": box_type,
|
||||||
|
"box_type_label": BOX_TYPES[box_type]["label"],
|
||||||
|
"box_name": box.name if box else f"盒 {c.box_id}",
|
||||||
|
"slot_code": slot_code_for_box(box, c.slot_index) if box else str(c.slot_index),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
start_day = datetime.now().date() - timedelta(days=29)
|
||||||
|
outbound_rows = (
|
||||||
|
db.session.query(InventoryEvent.part_no, db.func.sum(-InventoryEvent.delta).label("outbound_qty"))
|
||||||
|
.filter(
|
||||||
|
InventoryEvent.event_type == "component_outbound",
|
||||||
|
InventoryEvent.created_at >= datetime.combine(start_day, datetime.min.time()),
|
||||||
|
InventoryEvent.delta < 0,
|
||||||
|
)
|
||||||
|
.group_by(InventoryEvent.part_no)
|
||||||
|
.order_by(db.func.sum(-InventoryEvent.delta).desc())
|
||||||
|
.limit(20)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
outbound_top = [
|
||||||
|
{"part_no": row[0] or "-", "outbound_qty_30d": int(row[1] or 0)}
|
||||||
|
for row in outbound_rows
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
"threshold": int(threshold),
|
||||||
|
"low_stock_items": low_items,
|
||||||
|
"top_outbound_30d": outbound_top,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _call_siliconflow_chat(
|
||||||
|
system_prompt: str,
|
||||||
|
user_prompt: str,
|
||||||
|
*,
|
||||||
|
api_url: str,
|
||||||
|
model: str,
|
||||||
|
api_key: str,
|
||||||
|
timeout: int,
|
||||||
|
) -> str:
|
||||||
|
api_key = (api_key or "").strip()
|
||||||
|
if not api_key:
|
||||||
|
raise RuntimeError("SILICONFLOW_API_KEY 未配置")
|
||||||
|
if not api_url:
|
||||||
|
raise RuntimeError("AI API URL 未配置")
|
||||||
|
if not model:
|
||||||
|
raise RuntimeError("AI 模型名称未配置")
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"model": model,
|
||||||
|
"temperature": 0.2,
|
||||||
|
"max_tokens": 700,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": system_prompt},
|
||||||
|
{"role": "user", "content": user_prompt},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
body = json.dumps(payload).encode("utf-8")
|
||||||
|
req = urllib.request.Request(
|
||||||
|
api_url,
|
||||||
|
data=body,
|
||||||
|
method="POST",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
|
raw = resp.read().decode("utf-8")
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
detail = exc.read().decode("utf-8", errors="ignore")
|
||||||
|
raise RuntimeError(f"AI 服务返回 HTTP {exc.code}: {detail[:200]}") from exc
|
||||||
|
except urllib.error.URLError as exc:
|
||||||
|
raise RuntimeError(f"AI 服务连接失败: {exc.reason}") from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(raw)
|
||||||
|
return data["choices"][0]["message"]["content"].strip()
|
||||||
|
except (KeyError, IndexError, TypeError, json.JSONDecodeError) as exc:
|
||||||
|
raise RuntimeError("AI 返回格式无法解析") from exc
|
||||||
|
|
||||||
|
|
||||||
@app.route("/")
|
@app.route("/")
|
||||||
def index():
|
def index():
|
||||||
return redirect(url_for("types_page"))
|
return redirect(url_for("types_page"))
|
||||||
@@ -1531,6 +1699,222 @@ def search_page():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/ai/restock-plan", methods=["POST"])
|
||||||
|
def ai_restock_plan():
|
||||||
|
ai_settings = _get_ai_settings()
|
||||||
|
data = _build_restock_payload(
|
||||||
|
limit=ai_settings["restock_limit"],
|
||||||
|
threshold=ai_settings["restock_threshold"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def _empty_plan(summary: str) -> dict:
|
||||||
|
return {
|
||||||
|
"summary": summary,
|
||||||
|
"urgent": [],
|
||||||
|
"this_week": [],
|
||||||
|
"defer": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
def _normalize_item(raw_item: dict) -> dict:
|
||||||
|
if not isinstance(raw_item, dict):
|
||||||
|
return {
|
||||||
|
"part_no": "-",
|
||||||
|
"name": "未命名元件",
|
||||||
|
"suggest_qty": "待确认",
|
||||||
|
"reason": "AI 返回项格式异常",
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"part_no": str(raw_item.get("part_no", "-") or "-").strip() or "-",
|
||||||
|
"name": str(raw_item.get("name", "未命名元件") or "未命名元件").strip(),
|
||||||
|
"suggest_qty": str(raw_item.get("suggest_qty", "待确认") or "待确认").strip(),
|
||||||
|
"reason": str(raw_item.get("reason", "") or "").strip() or "无",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _normalize_plan(raw_plan: dict, default_summary: str) -> dict:
|
||||||
|
if not isinstance(raw_plan, dict):
|
||||||
|
return _empty_plan(default_summary)
|
||||||
|
|
||||||
|
summary = str(raw_plan.get("summary", "") or "").strip() or default_summary
|
||||||
|
|
||||||
|
def to_items(key: str):
|
||||||
|
rows = raw_plan.get(key, [])
|
||||||
|
if not isinstance(rows, list):
|
||||||
|
return []
|
||||||
|
return [_normalize_item(row) for row in rows]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"summary": summary,
|
||||||
|
"urgent": to_items("urgent"),
|
||||||
|
"this_week": to_items("this_week"),
|
||||||
|
"defer": to_items("defer"),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _extract_json_block(raw_text: str) -> str:
|
||||||
|
text = (raw_text or "").strip()
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
if text.startswith("```"):
|
||||||
|
text = re.sub(r"^```(?:json)?\\s*", "", text)
|
||||||
|
text = re.sub(r"\\s*```$", "", text)
|
||||||
|
first = text.find("{")
|
||||||
|
last = text.rfind("}")
|
||||||
|
if first >= 0 and last > first:
|
||||||
|
return text[first : last + 1]
|
||||||
|
return text
|
||||||
|
|
||||||
|
def _build_rule_based_plan() -> dict:
|
||||||
|
threshold = int(data.get("threshold", LOW_STOCK_THRESHOLD))
|
||||||
|
urgent = []
|
||||||
|
this_week = []
|
||||||
|
|
||||||
|
for idx, item in enumerate(data.get("low_stock_items", [])):
|
||||||
|
qty = int(item.get("quantity", 0) or 0)
|
||||||
|
suggest_qty = max(threshold * 2 - qty, 1)
|
||||||
|
row = {
|
||||||
|
"part_no": item.get("part_no", "-") or "-",
|
||||||
|
"name": item.get("name", "未命名元件") or "未命名元件",
|
||||||
|
"suggest_qty": str(suggest_qty),
|
||||||
|
"reason": f"当前库存 {qty},低于阈值 {threshold}",
|
||||||
|
}
|
||||||
|
if idx < 5:
|
||||||
|
urgent.append(row)
|
||||||
|
else:
|
||||||
|
this_week.append(row)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"summary": "已按规则生成兜底补货建议(AI 输出异常时使用)",
|
||||||
|
"urgent": urgent,
|
||||||
|
"this_week": this_week,
|
||||||
|
"defer": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
if not data["low_stock_items"]:
|
||||||
|
plan = _empty_plan("当前没有低库存元件,暂不需要补货。")
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"suggestion": "当前没有低库存元件,暂不需要补货。",
|
||||||
|
"plan": plan,
|
||||||
|
"data": data,
|
||||||
|
}
|
||||||
|
|
||||||
|
system_prompt = (
|
||||||
|
"你是电子元器件库存助手。"
|
||||||
|
"必须只输出 JSON,不要 Markdown,不要解释文字。"
|
||||||
|
"输出结构必须是: "
|
||||||
|
"{\"summary\":string,\"urgent\":[item],\"this_week\":[item],\"defer\":[item]}。"
|
||||||
|
"item 结构: {\"part_no\":string,\"name\":string,\"suggest_qty\":string,\"reason\":string}。"
|
||||||
|
"各数组允许为空。"
|
||||||
|
)
|
||||||
|
user_prompt = "库存数据如下(JSON):\n" + json.dumps(data, ensure_ascii=False)
|
||||||
|
|
||||||
|
try:
|
||||||
|
suggestion = _call_siliconflow_chat(
|
||||||
|
system_prompt,
|
||||||
|
user_prompt,
|
||||||
|
api_url=ai_settings["api_url"],
|
||||||
|
model=ai_settings["model"],
|
||||||
|
api_key=ai_settings["api_key"],
|
||||||
|
timeout=ai_settings["timeout"],
|
||||||
|
)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
fallback_plan = _build_rule_based_plan()
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"message": str(exc),
|
||||||
|
"plan": fallback_plan,
|
||||||
|
"data": data,
|
||||||
|
}, 400
|
||||||
|
|
||||||
|
parse_warning = ""
|
||||||
|
try:
|
||||||
|
parsed_plan = json.loads(_extract_json_block(suggestion))
|
||||||
|
plan = _normalize_plan(parsed_plan, "已生成 AI 补货建议")
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
plan = _build_rule_based_plan()
|
||||||
|
parse_warning = "AI 返回格式异常,已切换到规则兜底建议。"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"suggestion": suggestion,
|
||||||
|
"plan": plan,
|
||||||
|
"parse_warning": parse_warning,
|
||||||
|
"data": data,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/ai/settings", methods=["GET", "POST"])
|
||||||
|
def ai_settings_page():
|
||||||
|
settings = _get_ai_settings()
|
||||||
|
error = ""
|
||||||
|
notice = request.args.get("notice", "").strip()
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
api_url = request.form.get("api_url", "").strip()
|
||||||
|
model = request.form.get("model", "").strip()
|
||||||
|
api_key = request.form.get("api_key", "").strip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
timeout = int((request.form.get("timeout", "30") or "30").strip())
|
||||||
|
if timeout < 5:
|
||||||
|
raise ValueError
|
||||||
|
except ValueError:
|
||||||
|
error = "超时时间必须是大于等于 5 的整数"
|
||||||
|
timeout = settings["timeout"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
restock_threshold = int((request.form.get("restock_threshold", "5") or "5").strip())
|
||||||
|
if restock_threshold < 0:
|
||||||
|
raise ValueError
|
||||||
|
except ValueError:
|
||||||
|
if not error:
|
||||||
|
error = "低库存阈值必须是大于等于 0 的整数"
|
||||||
|
restock_threshold = settings["restock_threshold"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
restock_limit = int((request.form.get("restock_limit", "24") or "24").strip())
|
||||||
|
if restock_limit < 1:
|
||||||
|
raise ValueError
|
||||||
|
except ValueError:
|
||||||
|
if not error:
|
||||||
|
error = "补货条目数必须是大于等于 1 的整数"
|
||||||
|
restock_limit = settings["restock_limit"]
|
||||||
|
|
||||||
|
if not api_url and not error:
|
||||||
|
error = "API URL 不能为空"
|
||||||
|
if not model and not error:
|
||||||
|
error = "模型名称不能为空"
|
||||||
|
|
||||||
|
if not error:
|
||||||
|
settings = {
|
||||||
|
"api_url": api_url,
|
||||||
|
"model": model,
|
||||||
|
"api_key": api_key,
|
||||||
|
"timeout": timeout,
|
||||||
|
"restock_threshold": restock_threshold,
|
||||||
|
"restock_limit": restock_limit,
|
||||||
|
}
|
||||||
|
_save_ai_settings(settings)
|
||||||
|
return redirect(url_for("ai_settings_page", notice="AI参数已保存"))
|
||||||
|
|
||||||
|
settings.update(
|
||||||
|
{
|
||||||
|
"api_url": api_url,
|
||||||
|
"model": model,
|
||||||
|
"api_key": api_key,
|
||||||
|
"timeout": timeout,
|
||||||
|
"restock_threshold": restock_threshold,
|
||||||
|
"restock_limit": restock_limit,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"ai_settings.html",
|
||||||
|
settings=settings,
|
||||||
|
notice=notice,
|
||||||
|
error=error,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/component/<int:component_id>/outbound", methods=["POST"])
|
@app.route("/component/<int:component_id>/outbound", methods=["POST"])
|
||||||
def quick_outbound(component_id: int):
|
def quick_outbound(component_id: int):
|
||||||
component = Component.query.get_or_404(component_id)
|
component = Component.query.get_or_404(component_id)
|
||||||
|
|||||||
8
data/ai_settings.json
Normal file
8
data/ai_settings.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"api_url": "https://api.siliconflow.cn/v1/chat/completions",
|
||||||
|
"model": "Pro/MiniMaxAI/MiniMax-M2.5",
|
||||||
|
"api_key": "sk-pekgnbdvwgydxzteabnykswjadkitoopwcekmksydfoslmlo",
|
||||||
|
"timeout": 30,
|
||||||
|
"restock_threshold": 2,
|
||||||
|
"restock_limit": 24
|
||||||
|
}
|
||||||
112
data/lcsc_api_doc.txt
Normal file
112
data/lcsc_api_doc.txt
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
参数名称 参数
|
||||||
|
说明
|
||||||
|
请求
|
||||||
|
类型
|
||||||
|
是否
|
||||||
|
必须 数据类型 schema
|
||||||
|
productInfoQueryReqVO
|
||||||
|
商品
|
||||||
|
信息
|
||||||
|
查询
|
||||||
|
参数
|
||||||
|
body true ProductInfoQueryReqVO ProductInfoQueryReqVO
|
||||||
|
productId
|
||||||
|
商品
|
||||||
|
编号
|
||||||
|
id
|
||||||
|
true integer(int32)
|
||||||
|
状态码 说明 schema
|
||||||
|
200 OK HttpOpenapiResultProductInfoQueryRespVO
|
||||||
|
立创商城 - 商品基础信息查询
|
||||||
|
接口地址:/lcsc/openapi/sku/product/basic
|
||||||
|
请求方式:POST
|
||||||
|
请求数据类型:application/json
|
||||||
|
响应数据类型:*/*
|
||||||
|
接口描述:
|
||||||
|
请求示例:
|
||||||
|
请求参数:
|
||||||
|
响应状态:
|
||||||
|
响应参数:
|
||||||
|
{
|
||||||
|
"productId": 0
|
||||||
|
}
|
||||||
|
参数名称 参数说明 类型 schema
|
||||||
|
code 状态码 integer(int32) integer(int32)
|
||||||
|
message 消息内容 string
|
||||||
|
data ProductInfoQueryRespVO ProductInfoQueryRespVO
|
||||||
|
productBasicInfoVOList 商品基础信息 array ProductBasicInfoVO
|
||||||
|
productId 商品 id integer
|
||||||
|
productCode 商品编号 string
|
||||||
|
productName 商品名称 string
|
||||||
|
productModel 厂家型号 string
|
||||||
|
brandId 品牌 ID integer
|
||||||
|
brandName 品牌名称 string
|
||||||
|
parentCatalogId 一级目录 ID integer
|
||||||
|
parentCatalogName 一级目录名称 string
|
||||||
|
catalogId 二级目录 ID integer
|
||||||
|
catalogName 二级目录名称 string
|
||||||
|
encapStandard 封装规格 string
|
||||||
|
minPacketUnit
|
||||||
|
最小包装单位 "-
|
||||||
|
": "-", "bao":
|
||||||
|
" 包 ", "ben":
|
||||||
|
" 本 ", "dai": " 袋 ",
|
||||||
|
"guan": " 管 ",
|
||||||
|
"he": " 盒 ",
|
||||||
|
"juan": " 卷 ",
|
||||||
|
"kun": " 捆 ",
|
||||||
|
"mi": " 米 ",
|
||||||
|
"pan": " 圆盘 ",
|
||||||
|
"tuopan": " 托
|
||||||
|
盘 ", "xiang":
|
||||||
|
" 箱 "
|
||||||
|
string
|
||||||
|
productArrange
|
||||||
|
商品编排方式 "-
|
||||||
|
": "-", "ben":
|
||||||
|
" 本 ", "biandai":
|
||||||
|
" 编带 ",
|
||||||
|
"bianpai": " 编
|
||||||
|
排 ",
|
||||||
|
"daizhuang":
|
||||||
|
" 袋装 ",
|
||||||
|
"guanzhuang":
|
||||||
|
" 管装 ",
|
||||||
|
"hezhuang": " 盒
|
||||||
|
装 ", "juan": " 卷 ",
|
||||||
|
"kun": " 捆 ",
|
||||||
|
"tuopan": " 托
|
||||||
|
盘 ",
|
||||||
|
"xiangzhuang":
|
||||||
|
" 箱装 "
|
||||||
|
string
|
||||||
|
minPacketNumber 最小包装数量 integer
|
||||||
|
productWeight 商品毛重 number
|
||||||
|
参数名称 参数说明 类型 schema
|
||||||
|
successful boolean
|
||||||
|
响应示例:
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"data": {
|
||||||
|
"productBasicInfoVOList": [
|
||||||
|
{
|
||||||
|
"brandName": "ST( 意法半导体 )",
|
||||||
|
"productArrange": "biandai",
|
||||||
|
"productModel": "DB3",
|
||||||
|
"productId": 61620,
|
||||||
|
"minPacketNumber": 5000,
|
||||||
|
"productWeight": 0.000148000,
|
||||||
|
"parentCatalogName": " 二极管 ",
|
||||||
|
"productName": "32V 100uA",
|
||||||
|
"catalogName": " 触发二极管 ",
|
||||||
|
"catalogId": 379,
|
||||||
|
"productCode": "C60568",
|
||||||
|
"encapStandard": "DO-35",
|
||||||
|
"parentCatalogId": 319,
|
||||||
|
"brandId": 74,
|
||||||
|
"minPacketUnit": "pan"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"successful": true
|
||||||
|
}
|
||||||
@@ -449,6 +449,55 @@ body {
|
|||||||
background: color-mix(in srgb, var(--card-alt) 76%, var(--accent) 24%);
|
background: color-mix(in srgb, var(--card-alt) 76%, var(--accent) 24%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.overview-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 360px;
|
||||||
|
gap: var(--space-2);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-main {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-sidebar {
|
||||||
|
position: sticky;
|
||||||
|
top: 88px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-panel {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-panel-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-1);
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-panel-content {
|
||||||
|
margin: 0;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: color-mix(in srgb, var(--card) 88%, var(--card-alt));
|
||||||
|
padding: var(--space-2);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
font: 13px/1.55 Consolas, "Cascadia Mono", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-plan-groups {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-plan-group h3 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.catalog-content {
|
.catalog-content {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
@@ -922,6 +971,14 @@ th {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 980px) {
|
@media (max-width: 980px) {
|
||||||
|
.overview-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-sidebar {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
|
||||||
.metrics-grid {
|
.metrics-grid {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|||||||
62
templates/ai_settings.html
Normal file
62
templates/ai_settings.html
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>AI参数设置</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="hero slim">
|
||||||
|
<div>
|
||||||
|
<h1>AI参数设置</h1>
|
||||||
|
<p>在此修改硅基流动 API 和补货建议参数</p>
|
||||||
|
</div>
|
||||||
|
<div class="hero-actions">
|
||||||
|
<a class="btn btn-light" href="{{ url_for('types_page') }}">返回仓库概览</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="container">
|
||||||
|
{% if error %}
|
||||||
|
<p class="alert">{{ error }}</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if notice %}
|
||||||
|
<p class="notice">{{ notice }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<form class="form-grid" method="post">
|
||||||
|
<label>
|
||||||
|
API URL *
|
||||||
|
<input type="text" name="api_url" required value="{{ settings.api_url }}" placeholder="https://api.siliconflow.cn/v1/chat/completions">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
模型名称 *
|
||||||
|
<input type="text" name="model" required value="{{ settings.model }}" placeholder="Qwen/Qwen2.5-7B-Instruct">
|
||||||
|
</label>
|
||||||
|
<label class="full">
|
||||||
|
API Key *
|
||||||
|
<input type="text" name="api_key" required value="{{ settings.api_key }}" placeholder="sk-...">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
超时(秒)
|
||||||
|
<input type="number" name="timeout" min="5" value="{{ settings.timeout }}">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
低库存阈值
|
||||||
|
<input type="number" name="restock_threshold" min="0" value="{{ settings.restock_threshold }}">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
建议条目上限
|
||||||
|
<input type="number" name="restock_limit" min="1" value="{{ settings.restock_limit }}">
|
||||||
|
</label>
|
||||||
|
<div class="actions full">
|
||||||
|
<button class="btn" type="submit">保存参数</button>
|
||||||
|
<a class="btn btn-light" href="{{ url_for('types_page') }}">取消</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -27,6 +27,9 @@
|
|||||||
<p class="notice">{{ notice }}</p>
|
<p class="notice">{{ notice }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="overview-layout">
|
||||||
|
<section class="overview-main">
|
||||||
|
|
||||||
<section class="metrics-grid">
|
<section class="metrics-grid">
|
||||||
<article class="metric-card">
|
<article class="metric-card">
|
||||||
<p class="metric-title">容器总数</p>
|
<p class="metric-title">容器总数</p>
|
||||||
@@ -80,6 +83,167 @@
|
|||||||
</ul>
|
</ul>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside class="overview-sidebar">
|
||||||
|
<section class="panel ai-panel" id="ai-panel">
|
||||||
|
<div class="ai-panel-head">
|
||||||
|
<h2>AI补货建议</h2>
|
||||||
|
<a class="btn btn-light" href="{{ url_for('ai_settings_page') }}">参数</a>
|
||||||
|
</div>
|
||||||
|
<p class="hint">根据低库存与近30天出库数据,生成可执行补货建议。</p>
|
||||||
|
<button class="btn" id="ai-restock-btn" type="button">生成建议</button>
|
||||||
|
<p class="hint" id="ai-panel-status"></p>
|
||||||
|
<p class="hint" id="ai-panel-warning"></p>
|
||||||
|
<div id="ai-plan-groups" class="ai-plan-groups"></div>
|
||||||
|
<details class="box-overview" id="ai-raw-wrap" hidden>
|
||||||
|
<summary>查看原始 AI 文本</summary>
|
||||||
|
<pre id="ai-panel-content" class="ai-panel-content"></pre>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var aiBtn = document.getElementById('ai-restock-btn');
|
||||||
|
var statusNode = document.getElementById('ai-panel-status');
|
||||||
|
var warningNode = document.getElementById('ai-panel-warning');
|
||||||
|
var contentNode = document.getElementById('ai-panel-content');
|
||||||
|
var planGroups = document.getElementById('ai-plan-groups');
|
||||||
|
var rawWrap = document.getElementById('ai-raw-wrap');
|
||||||
|
|
||||||
|
function clearPlan() {
|
||||||
|
if (planGroups) {
|
||||||
|
planGroups.innerHTML = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPlan(plan) {
|
||||||
|
if (!planGroups) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearPlan();
|
||||||
|
|
||||||
|
var groups = [
|
||||||
|
{ key: 'urgent', title: '紧急补货' },
|
||||||
|
{ key: 'this_week', title: '本周建议补货' },
|
||||||
|
{ key: 'defer', title: '暂缓补货' }
|
||||||
|
];
|
||||||
|
|
||||||
|
groups.forEach(function (groupMeta) {
|
||||||
|
var rows = (plan && plan[groupMeta.key]) || [];
|
||||||
|
var wrap = document.createElement('section');
|
||||||
|
wrap.className = 'ai-plan-group';
|
||||||
|
|
||||||
|
var title = document.createElement('h3');
|
||||||
|
title.textContent = groupMeta.title + '(' + rows.length + ')';
|
||||||
|
wrap.appendChild(title);
|
||||||
|
|
||||||
|
var list = document.createElement('ul');
|
||||||
|
list.className = 'side-low-stock-list';
|
||||||
|
if (!rows.length) {
|
||||||
|
var empty = document.createElement('li');
|
||||||
|
empty.className = 'muted';
|
||||||
|
empty.textContent = '暂无条目';
|
||||||
|
list.appendChild(empty);
|
||||||
|
} else {
|
||||||
|
rows.forEach(function (item) {
|
||||||
|
var li = document.createElement('li');
|
||||||
|
var content = document.createElement('div');
|
||||||
|
|
||||||
|
var strong = document.createElement('strong');
|
||||||
|
strong.textContent = (item.name || '未命名元件') + ' (' + (item.part_no || '-') + ')';
|
||||||
|
content.appendChild(strong);
|
||||||
|
|
||||||
|
var qty = document.createElement('p');
|
||||||
|
qty.className = 'hint';
|
||||||
|
qty.textContent = '建议补货: ' + (item.suggest_qty || '待确认');
|
||||||
|
content.appendChild(qty);
|
||||||
|
|
||||||
|
var reason = document.createElement('p');
|
||||||
|
reason.className = 'hint';
|
||||||
|
reason.textContent = '理由: ' + (item.reason || '无');
|
||||||
|
content.appendChild(reason);
|
||||||
|
|
||||||
|
li.appendChild(content);
|
||||||
|
list.appendChild(li);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
wrap.appendChild(list);
|
||||||
|
planGroups.appendChild(wrap);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!aiBtn) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
aiBtn.addEventListener('click', function () {
|
||||||
|
aiBtn.disabled = true;
|
||||||
|
statusNode.textContent = '正在生成建议,请稍候...';
|
||||||
|
if (warningNode) {
|
||||||
|
warningNode.textContent = '';
|
||||||
|
}
|
||||||
|
clearPlan();
|
||||||
|
if (contentNode) {
|
||||||
|
contentNode.textContent = '';
|
||||||
|
}
|
||||||
|
if (rawWrap) {
|
||||||
|
rawWrap.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch('{{ url_for('ai_restock_plan') }}', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: '{}'
|
||||||
|
})
|
||||||
|
.then(function (resp) {
|
||||||
|
return resp.json().then(function (data) {
|
||||||
|
return { ok: resp.ok, data: data };
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.then(function (result) {
|
||||||
|
var data = result.data || {};
|
||||||
|
if (!result.ok || !data.ok) {
|
||||||
|
statusNode.textContent = '生成失败';
|
||||||
|
if (warningNode) {
|
||||||
|
warningNode.textContent = data.message || 'AI服务暂时不可用';
|
||||||
|
}
|
||||||
|
if (data.plan) {
|
||||||
|
renderPlan(data.plan);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
statusNode.textContent = (data.plan && data.plan.summary) || '建议已生成';
|
||||||
|
if (warningNode) {
|
||||||
|
warningNode.textContent = data.parse_warning || '';
|
||||||
|
}
|
||||||
|
renderPlan(data.plan || {});
|
||||||
|
if (contentNode && data.suggestion) {
|
||||||
|
contentNode.textContent = data.suggestion;
|
||||||
|
if (rawWrap) {
|
||||||
|
rawWrap.hidden = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
statusNode.textContent = '生成失败';
|
||||||
|
if (warningNode) {
|
||||||
|
warningNode.textContent = '请求失败,请稍后重试';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(function () {
|
||||||
|
aiBtn.disabled = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user