diff --git a/README.md b/README.md index 2dfe3b1..812f913 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,28 @@ python app.py 默认访问:`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.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. 袋装批量新增格式 在袋装清单页面的批量输入框里,每行一条,可用英文逗号或 Tab 分隔: diff --git a/app.py b/app.py index bf7adba..7cd66ee 100644 --- a/app.py +++ b/app.py @@ -2,6 +2,8 @@ import os import re import csv import json +import urllib.error +import urllib.request from copy import deepcopy from io import StringIO from datetime import datetime, timedelta @@ -22,6 +24,21 @@ db = SQLAlchemy(app) LOW_STOCK_THRESHOLD = 5 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 = { @@ -98,6 +115,56 @@ def _save_box_type_overrides() -> None: _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): __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("/") def index(): 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//outbound", methods=["POST"]) def quick_outbound(component_id: int): component = Component.query.get_or_404(component_id) diff --git a/data/ai_settings.json b/data/ai_settings.json new file mode 100644 index 0000000..9e92269 --- /dev/null +++ b/data/ai_settings.json @@ -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 +} \ No newline at end of file diff --git a/data/lcsc_api_doc.txt b/data/lcsc_api_doc.txt new file mode 100644 index 0000000..8ce8f27 --- /dev/null +++ b/data/lcsc_api_doc.txt @@ -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 +} \ No newline at end of file diff --git a/static/css/style.css b/static/css/style.css index 1b4c7f8..a0667e2 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -449,6 +449,55 @@ body { 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 { min-width: 0; } @@ -922,6 +971,14 @@ th { } @media (max-width: 980px) { + .overview-layout { + grid-template-columns: 1fr; + } + + .overview-sidebar { + position: static; + } + .metrics-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } diff --git a/templates/ai_settings.html b/templates/ai_settings.html new file mode 100644 index 0000000..f5db06b --- /dev/null +++ b/templates/ai_settings.html @@ -0,0 +1,62 @@ + + + + + + AI参数设置 + + + +
+
+

AI参数设置

+

在此修改硅基流动 API 和补货建议参数

+
+ +
+ +
+ {% if error %} +

{{ error }}

+ {% endif %} + {% if notice %} +

{{ notice }}

+ {% endif %} + +
+
+ + + + + + +
+ + 取消 +
+
+
+
+ + diff --git a/templates/types.html b/templates/types.html index d03d2ff..d00e291 100644 --- a/templates/types.html +++ b/templates/types.html @@ -27,6 +27,9 @@

{{ notice }}

{% endif %} +
+
+

容器总数

@@ -80,6 +83,167 @@ {% endfor %}
+ +
+ + +
+ + +